mirror of https://github.com/Squidex/squidex.git
Browse Source
* Provide user role in context context. * Also provide user for backwards compatibility. * Show current users on resource. * Fix tests.pull/778/head
committed by
GitHub
28 changed files with 562 additions and 72 deletions
@ -0,0 +1,36 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Threading.Tasks; |
||||
|
using Orleans; |
||||
|
using Squidex.Infrastructure; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Comments |
||||
|
{ |
||||
|
public sealed class GrainWatchingService : IWatchingService |
||||
|
{ |
||||
|
private readonly IGrainFactory grainFactory; |
||||
|
|
||||
|
public GrainWatchingService(IGrainFactory grainFactory) |
||||
|
{ |
||||
|
this.grainFactory = grainFactory; |
||||
|
} |
||||
|
|
||||
|
public Task<string[]> GetWatchingUsersAsync(DomainId appId, string resource, string userId) |
||||
|
{ |
||||
|
Guard.NotNullOrEmpty(resource, nameof(resource)); |
||||
|
Guard.NotNullOrEmpty(userId, nameof(userId)); |
||||
|
|
||||
|
return GetGrain(appId).GetWatchingUsersAsync(resource, userId); |
||||
|
} |
||||
|
|
||||
|
private IWatchingGrain GetGrain(DomainId appId) |
||||
|
{ |
||||
|
return grainFactory.GetGrain<IWatchingGrain>(appId.ToString()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,17 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Threading.Tasks; |
||||
|
using Orleans; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Comments |
||||
|
{ |
||||
|
public interface IWatchingGrain : IGrainWithStringKey |
||||
|
{ |
||||
|
Task<string[]> GetWatchingUsersAsync(string resource, string userId); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,17 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Squidex.Infrastructure; |
||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Comments |
||||
|
{ |
||||
|
public interface IWatchingService |
||||
|
{ |
||||
|
Task<string[]> GetWatchingUsersAsync(DomainId appId, string resource, string userId); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,88 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Threading.Tasks; |
||||
|
using NodaTime; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.Orleans; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Comments |
||||
|
{ |
||||
|
public sealed class WatchingGrain : GrainBase, IWatchingGrain |
||||
|
{ |
||||
|
private static readonly Duration Timeout = Duration.FromMinutes(1); |
||||
|
private readonly Dictionary<string, Dictionary<string, Instant>> users = new Dictionary<string, Dictionary<string, Instant>>(); |
||||
|
private readonly IClock clock; |
||||
|
|
||||
|
public WatchingGrain(IClock clock) |
||||
|
{ |
||||
|
this.clock = clock; |
||||
|
} |
||||
|
|
||||
|
public override Task OnActivateAsync() |
||||
|
{ |
||||
|
var time = TimeSpan.FromSeconds(30); |
||||
|
|
||||
|
RegisterTimer(x => |
||||
|
{ |
||||
|
Cleanup(); |
||||
|
|
||||
|
return Task.CompletedTask; |
||||
|
}, null, time, time); |
||||
|
|
||||
|
return Task.CompletedTask; |
||||
|
} |
||||
|
|
||||
|
public Task<string[]> GetWatchingUsersAsync(string resource, string userId) |
||||
|
{ |
||||
|
Guard.NotNullOrEmpty(resource, nameof(resource)); |
||||
|
Guard.NotNullOrEmpty(userId, nameof(userId)); |
||||
|
|
||||
|
var usersByResource = users.GetOrAddNew(resource); |
||||
|
|
||||
|
usersByResource[userId] = clock.GetCurrentInstant(); |
||||
|
|
||||
|
return Task.FromResult(usersByResource.Keys.ToArray()); |
||||
|
} |
||||
|
|
||||
|
public void Cleanup() |
||||
|
{ |
||||
|
if (users.Count == 0) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var now = clock.GetCurrentInstant(); |
||||
|
|
||||
|
foreach (var (resource, usersByResource) in users.ToList()) |
||||
|
{ |
||||
|
foreach (var (userId, lastSeen) in usersByResource.ToList()) |
||||
|
{ |
||||
|
var timeSinceLastSeen = now - lastSeen; |
||||
|
|
||||
|
if (timeSinceLastSeen > Timeout) |
||||
|
{ |
||||
|
usersByResource.Remove(userId); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (usersByResource.Count == 0) |
||||
|
{ |
||||
|
users.Remove(resource); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (users.Count == 0) |
||||
|
{ |
||||
|
TryDeactivateOnIdle(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,46 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Threading.Tasks; |
||||
|
using FakeItEasy; |
||||
|
using Orleans; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Comments |
||||
|
{ |
||||
|
public class GrainWatchingServiceTests |
||||
|
{ |
||||
|
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>(); |
||||
|
private readonly GrainWatchingService sut; |
||||
|
|
||||
|
public GrainWatchingServiceTests() |
||||
|
{ |
||||
|
sut = new GrainWatchingService(grainFactory); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_call_grain_if_retrieving_watching_users() |
||||
|
{ |
||||
|
var appId = DomainId.NewGuid(); |
||||
|
var userResource = "resource1"; |
||||
|
var userIdentity = "user1"; |
||||
|
|
||||
|
var grain = A.Fake<IWatchingGrain>(); |
||||
|
|
||||
|
A.CallTo(() => grainFactory.GetGrain<IWatchingGrain>(appId.ToString(), null)) |
||||
|
.Returns(grain); |
||||
|
|
||||
|
A.CallTo(() => grain.GetWatchingUsersAsync(userResource, userIdentity)) |
||||
|
.Returns(Task.FromResult(new[] { "user1", "user2" })); |
||||
|
|
||||
|
var result = await sut.GetWatchingUsersAsync(appId, userResource, userIdentity); |
||||
|
|
||||
|
Assert.Equal(new[] { "user1", "user2" }, result); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,72 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Threading.Tasks; |
||||
|
using FakeItEasy; |
||||
|
using NodaTime; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Comments |
||||
|
{ |
||||
|
public class WatchingGrainTests |
||||
|
{ |
||||
|
private readonly IClock clock = A.Fake<IClock>(); |
||||
|
private readonly WatchingGrain sut; |
||||
|
private Instant now = SystemClock.Instance.GetCurrentInstant(); |
||||
|
|
||||
|
public WatchingGrainTests() |
||||
|
{ |
||||
|
A.CallTo(() => clock.GetCurrentInstant()) |
||||
|
.ReturnsLazily(() => now); |
||||
|
|
||||
|
sut = new WatchingGrain(clock); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_only_return_self_if_no_one_watching() |
||||
|
{ |
||||
|
var watching = await sut.GetWatchingUsersAsync("resource1", "user1"); |
||||
|
|
||||
|
Assert.Equal(new[] { "user1" }, watching); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_return_users_watching_on_same_resource() |
||||
|
{ |
||||
|
await sut.GetWatchingUsersAsync("resource1", "user1"); |
||||
|
await sut.GetWatchingUsersAsync("resource2", "user2"); |
||||
|
|
||||
|
var watching1 = await sut.GetWatchingUsersAsync("resource1", "user3"); |
||||
|
var watching2 = await sut.GetWatchingUsersAsync("resource2", "user4"); |
||||
|
|
||||
|
Assert.Equal(new[] { "user1", "user3" }, watching1); |
||||
|
Assert.Equal(new[] { "user2", "user4" }, watching2); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_cleanup_old_users() |
||||
|
{ |
||||
|
await sut.GetWatchingUsersAsync("resource1", "user1"); |
||||
|
await sut.GetWatchingUsersAsync("resource2", "user2"); |
||||
|
|
||||
|
now = now.Plus(Duration.FromMinutes(2)); |
||||
|
|
||||
|
sut.Cleanup(); |
||||
|
|
||||
|
await sut.GetWatchingUsersAsync("resource1", "user3"); |
||||
|
await sut.GetWatchingUsersAsync("resource2", "user4"); |
||||
|
|
||||
|
sut.Cleanup(); |
||||
|
|
||||
|
var watching1 = await sut.GetWatchingUsersAsync("resource1", "user5"); |
||||
|
var watching2 = await sut.GetWatchingUsersAsync("resource2", "user6"); |
||||
|
|
||||
|
Assert.Equal(new[] { "user3", "user5" }, watching1); |
||||
|
Assert.Equal(new[] { "user4", "user6" }, watching2); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
<ng-container *ngIf="users | async; let currentUsers"> |
||||
|
<ng-container *ngIf="currentUsers.length > 1"> |
||||
|
<div class="user-item" *ngFor="let userId of currentUsers | slice:0:5; let i = index" [style]="{ zIndex: i }"> |
||||
|
<img class="user-picture" title="{{userId | sqxUserName}}" [src]="userId | sqxUserPicture"> |
||||
|
</div> |
||||
|
|
||||
|
<div class="user-item" *ngIf="currentUsers.length > 5" [style]="{ zIndex: 6 }"> |
||||
|
<div class="user-more">+{{currentUsers.length - 5}}</div> |
||||
|
</div> |
||||
|
</ng-container> |
||||
|
</ng-container> |
||||
@ -0,0 +1,31 @@ |
|||||
|
:host { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
margin-right: .5rem; |
||||
|
} |
||||
|
|
||||
|
.user-item { |
||||
|
display: inline-block; |
||||
|
margin-right: 0; |
||||
|
margin-left: -1.1rem; |
||||
|
position: relative; |
||||
|
|
||||
|
&:hover { |
||||
|
z-index: 1000 !important; |
||||
|
} |
||||
|
|
||||
|
.user-picture, |
||||
|
.user-more { |
||||
|
@include circle(2.3rem); |
||||
|
border: 2px solid $color-white; |
||||
|
} |
||||
|
|
||||
|
.user-more { |
||||
|
background: $color-border; |
||||
|
font-size: 90%; |
||||
|
font-weight: bolder; |
||||
|
line-height: 2rem; |
||||
|
text-align: center; |
||||
|
z-index: 6; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,33 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; |
||||
|
import { AppsState, CommentsService, switchSafe } from '@app/shared/internal'; |
||||
|
import { timer } from 'rxjs'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'sqx-watching-users[resource]', |
||||
|
styleUrls: ['./watching-users.component.scss'], |
||||
|
templateUrl: './watching-users.component.html', |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
}) |
||||
|
export class WatchingUsersComponent { |
||||
|
private appName: string; |
||||
|
|
||||
|
@Input() |
||||
|
public resource: string; |
||||
|
|
||||
|
public users = |
||||
|
timer(0, 5000).pipe( |
||||
|
switchSafe((() => this.commentsService.getWatchingUsers(this.appName, this.resource)))); |
||||
|
|
||||
|
constructor(appsState: AppsState, |
||||
|
private readonly commentsService: CommentsService, |
||||
|
) { |
||||
|
this.appName = appsState.appName; |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue