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"> |
|||
<li class="nav-item nav-icon dropdown" #button> |
|||
<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> |
|||
<sqx-notifo></sqx-notifo> |
|||
</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