mirror of https://github.com/Squidex/squidex.git
committed by
GitHub
55 changed files with 775 additions and 208 deletions
@ -0,0 +1,26 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.History |
||||
|
{ |
||||
|
public sealed class NotifoOptions |
||||
|
{ |
||||
|
public string AppId { get; set; } |
||||
|
|
||||
|
public string ApiKey { get; set; } |
||||
|
|
||||
|
public string ApiUrl { get; set; } = "https://app.notifo.io"; |
||||
|
|
||||
|
public bool IsConfigured() |
||||
|
{ |
||||
|
return |
||||
|
!string.IsNullOrWhiteSpace(ApiKey) && |
||||
|
!string.IsNullOrWhiteSpace(ApiUrl) && |
||||
|
!string.IsNullOrWhiteSpace(AppId); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,279 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.Options; |
||||
|
using NodaTime; |
||||
|
using Notifo.SDK; |
||||
|
using Notifo.Services; |
||||
|
using Squidex.Domain.Apps.Core; |
||||
|
using Squidex.Domain.Apps.Events; |
||||
|
using Squidex.Domain.Apps.Events.Apps; |
||||
|
using Squidex.Domain.Apps.Events.Comments; |
||||
|
using Squidex.Domain.Apps.Events.Contents; |
||||
|
using Squidex.Domain.Users; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
using Squidex.Infrastructure.Tasks; |
||||
|
using Squidex.Shared.Identity; |
||||
|
using Squidex.Shared.Users; |
||||
|
using static Notifo.Services.Notifications; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.History |
||||
|
{ |
||||
|
public class NotifoService : IInitializable, IUserEventHandler |
||||
|
{ |
||||
|
private static readonly Duration MaxAge = Duration.FromHours(12); |
||||
|
private readonly NotifoOptions options; |
||||
|
private readonly IUrlGenerator urlGenerator; |
||||
|
private readonly IUserResolver userResolver; |
||||
|
private readonly IClock clock; |
||||
|
private NotificationsClient? client; |
||||
|
|
||||
|
public NotifoService(IOptions<NotifoOptions> options, IUrlGenerator urlGenerator, IUserResolver userResolver, IClock clock) |
||||
|
{ |
||||
|
Guard.NotNull(options, nameof(options)); |
||||
|
Guard.NotNull(urlGenerator, nameof(urlGenerator)); |
||||
|
Guard.NotNull(userResolver, nameof(userResolver)); |
||||
|
Guard.NotNull(clock, nameof(clock)); |
||||
|
|
||||
|
this.options = options.Value; |
||||
|
|
||||
|
this.urlGenerator = urlGenerator; |
||||
|
this.userResolver = userResolver; |
||||
|
|
||||
|
this.clock = clock; |
||||
|
} |
||||
|
|
||||
|
public Task InitializeAsync(CancellationToken ct = default) |
||||
|
{ |
||||
|
if (options.IsConfigured()) |
||||
|
{ |
||||
|
var builder = |
||||
|
NotificationsClientBuilder.Create() |
||||
|
.SetApiKey(options.ApiKey); |
||||
|
|
||||
|
if (!string.IsNullOrWhiteSpace(options.ApiUrl)) |
||||
|
{ |
||||
|
builder = builder.SetApiUrl(options.ApiUrl); |
||||
|
} |
||||
|
|
||||
|
client = builder.Build(); |
||||
|
} |
||||
|
|
||||
|
return Task.CompletedTask; |
||||
|
} |
||||
|
|
||||
|
public void OnUserUpdated(IUser user) |
||||
|
{ |
||||
|
UpsertUserAsync(user).Forget(); |
||||
|
} |
||||
|
|
||||
|
private async Task UpsertUserAsync(IUser user) |
||||
|
{ |
||||
|
if (client == null) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var settings = new NotificationSettingsDto(); |
||||
|
|
||||
|
settings.Channels[Providers.WebPush] = new NotificationSettingDto |
||||
|
{ |
||||
|
Send = true, |
||||
|
DelayInSeconds = null |
||||
|
}; |
||||
|
|
||||
|
settings.Channels[Providers.Email] = new NotificationSettingDto |
||||
|
{ |
||||
|
Send = true, |
||||
|
DelayInSeconds = 5 * 60 |
||||
|
}; |
||||
|
|
||||
|
var userRequest = new UpsertUserRequest |
||||
|
{ |
||||
|
AppId = options.AppId, |
||||
|
EmailAddress = user.Email, |
||||
|
FullName = user.DisplayName(), |
||||
|
PreferredLanguage = "en", |
||||
|
PreferredTimezone = null, |
||||
|
RequiresWhitelistedTopic = true, |
||||
|
Settings = settings, |
||||
|
UserId = user.Id |
||||
|
}; |
||||
|
|
||||
|
var response = await client.UpsertUserAsync(userRequest); |
||||
|
|
||||
|
await userResolver.SetClaimAsync(user.Id, SquidexClaimTypes.NotifoKey, response.User.ApiKey); |
||||
|
} |
||||
|
|
||||
|
public async Task HandleEventAsync(Envelope<IEvent> @event) |
||||
|
{ |
||||
|
Guard.NotNull(@event, nameof(@event)); |
||||
|
|
||||
|
if (client == null) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
switch (@event.Payload) |
||||
|
{ |
||||
|
case CommentCreated comment: |
||||
|
{ |
||||
|
if (IsTooOld(@event.Headers)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (comment.Mentions == null || comment.Mentions.Length == 0) |
||||
|
{ |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
using (var stream = client.PublishMany()) |
||||
|
{ |
||||
|
foreach (var userId in comment.Mentions) |
||||
|
{ |
||||
|
var publishRequest = new PublishRequest |
||||
|
{ |
||||
|
AppId = options.AppId |
||||
|
}; |
||||
|
|
||||
|
publishRequest.Topic = $"users/{userId}"; |
||||
|
|
||||
|
publishRequest.Properties["SquidexApp"] = comment.AppId.Name; |
||||
|
publishRequest.Preformatted = new NotificationFormattingDto(); |
||||
|
publishRequest.Preformatted.Subject["en"] = comment.Text; |
||||
|
|
||||
|
if (comment.Url?.IsAbsoluteUri == true) |
||||
|
{ |
||||
|
publishRequest.Preformatted.LinkUrl["en"] = comment.Url.ToString(); |
||||
|
} |
||||
|
|
||||
|
SetUser(comment, publishRequest); |
||||
|
|
||||
|
await stream.RequestStream.WriteAsync(publishRequest); |
||||
|
} |
||||
|
|
||||
|
await stream.RequestStream.CompleteAsync(); |
||||
|
await stream.ResponseAsync; |
||||
|
} |
||||
|
|
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
case AppContributorAssigned contributorAssigned: |
||||
|
{ |
||||
|
var user = await userResolver.FindByIdAsync(contributorAssigned.ContributorId); |
||||
|
|
||||
|
if (user != null) |
||||
|
{ |
||||
|
await UpsertUserAsync(user); |
||||
|
} |
||||
|
|
||||
|
var request = BuildAllowedTopicRequest(contributorAssigned, contributorAssigned.ContributorId); |
||||
|
|
||||
|
await client.AddAllowedTopicAsync(request); |
||||
|
|
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
case AppContributorRemoved contributorRemoved: |
||||
|
{ |
||||
|
var request = BuildAllowedTopicRequest(contributorRemoved, contributorRemoved.ContributorId); |
||||
|
|
||||
|
await client.RemoveAllowedTopicAsync(request); |
||||
|
|
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private AllowedTopicRequest BuildAllowedTopicRequest(AppEvent @event, string contributorId) |
||||
|
{ |
||||
|
var topicRequest = new AllowedTopicRequest |
||||
|
{ |
||||
|
AppId = options.AppId |
||||
|
}; |
||||
|
|
||||
|
topicRequest.UserId = contributorId; |
||||
|
topicRequest.TopicPrefix = GetAppPrefix(@event); |
||||
|
|
||||
|
return topicRequest; |
||||
|
} |
||||
|
|
||||
|
public async Task HandleHistoryEventAsync(Envelope<AppEvent> @event, HistoryEvent historyEvent) |
||||
|
{ |
||||
|
if (client == null) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (IsTooOld(@event.Headers)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var appEvent = @event.Payload; |
||||
|
|
||||
|
var publishRequest = new PublishRequest |
||||
|
{ |
||||
|
AppId = options.AppId |
||||
|
}; |
||||
|
|
||||
|
foreach (var (key, value) in historyEvent.Parameters) |
||||
|
{ |
||||
|
publishRequest.Properties.Add(key, value); |
||||
|
} |
||||
|
|
||||
|
publishRequest.Properties["SquidexApp"] = appEvent.AppId.Name; |
||||
|
|
||||
|
if (appEvent is ContentEvent c && !(appEvent is ContentDeleted)) |
||||
|
{ |
||||
|
var url = urlGenerator.ContentUI(c.AppId, c.SchemaId, c.ContentId); |
||||
|
|
||||
|
publishRequest.Properties["SquidexUrl"] = url; |
||||
|
} |
||||
|
|
||||
|
publishRequest.TemplateCode = historyEvent.EventType; |
||||
|
|
||||
|
SetUser(appEvent, publishRequest); |
||||
|
SetTopic(appEvent, publishRequest, historyEvent); |
||||
|
|
||||
|
await client.PublishAsync(publishRequest); |
||||
|
} |
||||
|
|
||||
|
private bool IsTooOld(EnvelopeHeaders headers) |
||||
|
{ |
||||
|
var now = clock.GetCurrentInstant(); |
||||
|
|
||||
|
return now - headers.Timestamp() > MaxAge; |
||||
|
} |
||||
|
|
||||
|
private static void SetUser(AppEvent appEvent, PublishRequest publishRequest) |
||||
|
{ |
||||
|
if (appEvent.Actor.IsSubject) |
||||
|
{ |
||||
|
publishRequest.CreatorId = appEvent.Actor.Identifier; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static void SetTopic(AppEvent appEvent, PublishRequest publishRequest, HistoryEvent @event) |
||||
|
{ |
||||
|
var topicPrefix = GetAppPrefix(appEvent); |
||||
|
var topicSuffix = @event.Channel.Replace('.', '/').Trim(); |
||||
|
|
||||
|
publishRequest.Topic = $"{topicPrefix}/{topicSuffix}"; |
||||
|
} |
||||
|
|
||||
|
private static string GetAppPrefix(AppEvent appEvent) |
||||
|
{ |
||||
|
return $"apps/{appEvent.AppId.Id}"; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,49 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Collections.Generic; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Shared.Users; |
||||
|
|
||||
|
namespace Squidex.Domain.Users |
||||
|
{ |
||||
|
public sealed class UserEvents : IUserEvents |
||||
|
{ |
||||
|
private readonly IEnumerable<IUserEventHandler> userEventHandlers; |
||||
|
|
||||
|
public UserEvents(IEnumerable<IUserEventHandler> userEventHandlers) |
||||
|
{ |
||||
|
Guard.NotNull(userEventHandlers, nameof(userEventHandlers)); |
||||
|
|
||||
|
this.userEventHandlers = userEventHandlers; |
||||
|
} |
||||
|
|
||||
|
public void OnUserRegistered(IUser user) |
||||
|
{ |
||||
|
foreach (var handler in userEventHandlers) |
||||
|
{ |
||||
|
handler.OnUserRegistered(user); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public void OnUserUpdated(IUser user) |
||||
|
{ |
||||
|
foreach (var handler in userEventHandlers) |
||||
|
{ |
||||
|
handler.OnUserUpdated(user); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public void OnConsentGiven(IUser user) |
||||
|
{ |
||||
|
foreach (var handler in userEventHandlers) |
||||
|
{ |
||||
|
handler.OnConsentGiven(user); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,61 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.AspNetCore.Http; |
||||
|
using Microsoft.Extensions.Options; |
||||
|
using Microsoft.Net.Http.Headers; |
||||
|
using Squidex.Domain.Apps.Entities.History; |
||||
|
|
||||
|
namespace Squidex.Areas.Frontend.Middlewares |
||||
|
{ |
||||
|
public class NotifoMiddleware |
||||
|
{ |
||||
|
private readonly RequestDelegate next; |
||||
|
private readonly string? workerUrl; |
||||
|
|
||||
|
public NotifoMiddleware(RequestDelegate next, IOptions<NotifoOptions> options) |
||||
|
{ |
||||
|
this.next = next; |
||||
|
|
||||
|
workerUrl = GetUrl(options.Value); |
||||
|
} |
||||
|
|
||||
|
public async Task InvokeAsync(HttpContext context) |
||||
|
{ |
||||
|
if (context.Request.Path.Equals("/notifo-sw.js") && workerUrl != null) |
||||
|
{ |
||||
|
context.Response.Headers[HeaderNames.ContentType] = "text/javascript"; |
||||
|
|
||||
|
var script = $"importScripts('{workerUrl}')"; |
||||
|
|
||||
|
await context.Response.WriteAsync(script); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
await next(context); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static string? GetUrl(NotifoOptions options) |
||||
|
{ |
||||
|
if (!options.IsConfigured()) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
if (options.ApiUrl.Contains("localhost:5002")) |
||||
|
{ |
||||
|
return "https://localhost:3002/notifo-sdk-worker.js"; |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
return $"{options.ApiUrl}/build/notifo-sdk-worker.js"; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
<span #element></span> |
||||
@ -0,0 +1,26 @@ |
|||||
|
:host ::ng-deep { |
||||
|
.notifo-notifications-button { |
||||
|
margin-left: .75rem; |
||||
|
margin-right: .75rem; |
||||
|
margin-top: .25rem; |
||||
|
|
||||
|
svg { |
||||
|
fill: $color-white; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.notifo-topics-button { |
||||
|
margin-left: .75rem; |
||||
|
margin-right: .75rem; |
||||
|
|
||||
|
svg { |
||||
|
fill: darken($color-border, 30%); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.notifo-container { |
||||
|
display: inline-block; |
||||
|
max-width: 5rem; |
||||
|
min-width: 3rem; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,98 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, Renderer2, SimpleChanges, ViewChild } from '@angular/core'; |
||||
|
import { Pager, ResourceLoaderService, UIOptions } from '@app/framework'; |
||||
|
import { AuthService } from '@app/shared/internal'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'sqx-notifo', |
||||
|
styleUrls: ['./notifo.component.scss'], |
||||
|
templateUrl: './notifo.component.html', |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush |
||||
|
}) |
||||
|
export class NotifoComponent implements AfterViewInit, OnChanges, OnDestroy { |
||||
|
private readonly notifoApiUrl: string; |
||||
|
private readonly notifoApiKey: string | undefined; |
||||
|
|
||||
|
@Output() |
||||
|
public pagerChange = new EventEmitter<Pager>(); |
||||
|
|
||||
|
@Input() |
||||
|
public topic: string; |
||||
|
|
||||
|
@ViewChild('element', { static: false }) |
||||
|
public element: ElementRef<Element>; |
||||
|
|
||||
|
constructor(resourceLoader: ResourceLoaderService, uiOptions: UIOptions, authService: AuthService, |
||||
|
private readonly renderer: Renderer2 |
||||
|
) { |
||||
|
this.notifoApiKey = authService.user?.notifoToken; |
||||
|
this.notifoApiUrl = uiOptions.get('more.notifoApi'); |
||||
|
|
||||
|
if (this.notifoApiKey) { |
||||
|
if (this.notifoApiUrl.indexOf('localhost:5002') >= 0) { |
||||
|
resourceLoader.loadScript(`https://localhost:3002/notifo-sdk.js`); |
||||
|
} else { |
||||
|
resourceLoader.loadScript(`${this.notifoApiUrl}/build/notifo-sdk.js`); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public ngAfterViewInit() { |
||||
|
if (this.notifoApiKey) { |
||||
|
let notifo = window['notifo']; |
||||
|
|
||||
|
if (!notifo) { |
||||
|
notifo = []; |
||||
|
|
||||
|
if (this.notifoApiUrl.indexOf('localhost:5002') >= 0) { |
||||
|
notifo.push(['set', 'style', 'https://localhost:3002/notifo-sdk.css']); |
||||
|
} |
||||
|
|
||||
|
notifo.push(['set', 'api-url', this.notifoApiUrl]); |
||||
|
notifo.push(['set', 'user-token', this.notifoApiKey]); |
||||
|
notifo.push(['subscribe']); |
||||
|
|
||||
|
window['notifo'] = notifo; |
||||
|
} |
||||
|
|
||||
|
const element = this.element?.nativeElement; |
||||
|
|
||||
|
if (!this.topic) { |
||||
|
notifo.push(['show-notifications', element, { position: 'bottom-right' }]); |
||||
|
} else { |
||||
|
notifo.push(['show-topic', element, this.topic, { style: 'bell' }]); |
||||
|
} |
||||
|
|
||||
|
if (element) { |
||||
|
this.renderer.addClass(element, 'notifo-container'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public ngOnChanges(changes: SimpleChanges) { |
||||
|
const notifo = window['notifo']; |
||||
|
|
||||
|
const element = this.element?.nativeElement; |
||||
|
|
||||
|
if (notifo && changes['topic'] && element) { |
||||
|
notifo.push(['hide-topic', element]); |
||||
|
notifo.push(['show-topic', element, this.topic, { style: 'bell' }]); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public ngOnDestroy() { |
||||
|
const notifo = window['notifo']; |
||||
|
|
||||
|
const element = this.element?.nativeElement; |
||||
|
|
||||
|
if (notifo && this.topic && element) { |
||||
|
notifo.push(['hide-topic', element]); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,28 +1,3 @@ |
|||||
<ul class="nav navbar-nav"> |
<ul class="nav navbar-nav"> |
||||
<li class="nav-item nav-icon dropdown" #button> |
<sqx-notifo></sqx-notifo> |
||||
<span class="nav-link dropdown-toggle" (click)="modalMenu.show()"> |
|
||||
<i class="icon-comments"></i> |
|
||||
|
|
||||
<span class="badge badge-pill" *ngIf="unread">{{unread}}</span> |
|
||||
</span> |
|
||||
</li> |
|
||||
</ul> |
</ul> |
||||
|
|
||||
<ng-container *sqxModal="modalMenu;onRoot:false"> |
|
||||
<div class="dropdown-menu" [scrollTop]="scrollMe.scrollHeight" [sqxAnchoredTo]="button" [offset]="10" @fade #scrollMe> |
|
||||
<ng-container *ngIf="commentsState.comments | async; let comments"> |
|
||||
<small class="text-muted" *ngIf="comments.length === 0"> |
|
||||
No notifications yet. |
|
||||
</small> |
|
||||
|
|
||||
<sqx-comment *ngFor="let comment of comments; trackBy: trackByComment" |
|
||||
[comment]="comment" |
|
||||
[commentsState]="commentsState" |
|
||||
[confirmDelete]="false" |
|
||||
[canDelete]="true" |
|
||||
[canFollow]="true" |
|
||||
[userToken]="userToken"> |
|
||||
</sqx-comment> |
|
||||
</ng-container> |
|
||||
</div> |
|
||||
</ng-container> |
|
||||
@ -1,8 +0,0 @@ |
|||||
.dropdown-menu { |
|
||||
max-height: 500px; |
|
||||
min-height: 5rem; |
|
||||
overflow-y: scroll; |
|
||||
padding: 1.5rem; |
|
||||
padding-bottom: 1rem; |
|
||||
width: 300px; |
|
||||
} |
|
||||
Loading…
Reference in new issue