From 2f1548fda32c3ba9e53913270394eedfacd55986 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 8 Jan 2019 11:26:41 +0100 Subject: Add notifications in the client --- client/src/app/shared/users/index.ts | 1 + .../app/shared/users/user-notification.model.ts | 153 +++++++++++++++++++++ .../app/shared/users/user-notification.service.ts | 110 +++++++++++++++ .../shared/users/user-notifications.component.html | 61 ++++++++ .../shared/users/user-notifications.component.scss | 30 ++++ .../shared/users/user-notifications.component.ts | 82 +++++++++++ client/src/app/shared/users/user.model.ts | 6 +- 7 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 client/src/app/shared/users/user-notification.model.ts create mode 100644 client/src/app/shared/users/user-notification.service.ts create mode 100644 client/src/app/shared/users/user-notifications.component.html create mode 100644 client/src/app/shared/users/user-notifications.component.scss create mode 100644 client/src/app/shared/users/user-notifications.component.ts (limited to 'client/src/app/shared/users') diff --git a/client/src/app/shared/users/index.ts b/client/src/app/shared/users/index.ts index 7b5a67bc7..ebd715fb1 100644 --- a/client/src/app/shared/users/index.ts +++ b/client/src/app/shared/users/index.ts @@ -1,2 +1,3 @@ export * from './user.model' export * from './user.service' +export * from './user-notifications.component' diff --git a/client/src/app/shared/users/user-notification.model.ts b/client/src/app/shared/users/user-notification.model.ts new file mode 100644 index 000000000..5ff816fb8 --- /dev/null +++ b/client/src/app/shared/users/user-notification.model.ts @@ -0,0 +1,153 @@ +import { UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '../../../../../shared' + +export class UserNotification implements UserNotificationServer { + id: number + type: UserNotificationType + read: boolean + + video?: VideoInfo & { + channel: { + id: number + displayName: string + } + } + + videoImport?: { + id: number + video?: VideoInfo + torrentName?: string + magnetUri?: string + targetUrl?: string + } + + comment?: { + id: number + threadId: number + account: { + id: number + displayName: string + } + video: VideoInfo + } + + videoAbuse?: { + id: number + video: VideoInfo + } + + videoBlacklist?: { + id: number + video: VideoInfo + } + + account?: { + id: number + displayName: string + name: string + } + + actorFollow?: { + id: number + follower: { + name: string + displayName: string + } + following: { + type: 'account' | 'channel' + name: string + displayName: string + } + } + + createdAt: string + updatedAt: string + + // Additional fields + videoUrl?: string + commentUrl?: any[] + videoAbuseUrl?: string + accountUrl?: string + videoImportIdentifier?: string + videoImportUrl?: string + + constructor (hash: UserNotificationServer) { + this.id = hash.id + this.type = hash.type + this.read = hash.read + + this.video = hash.video + this.videoImport = hash.videoImport + this.comment = hash.comment + this.videoAbuse = hash.videoAbuse + this.videoBlacklist = hash.videoBlacklist + this.account = hash.account + this.actorFollow = hash.actorFollow + + this.createdAt = hash.createdAt + this.updatedAt = hash.updatedAt + + switch (this.type) { + case UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION: + this.videoUrl = this.buildVideoUrl(this.video) + break + + case UserNotificationType.UNBLACKLIST_ON_MY_VIDEO: + this.videoUrl = this.buildVideoUrl(this.video) + break + + case UserNotificationType.NEW_COMMENT_ON_MY_VIDEO: + case UserNotificationType.COMMENT_MENTION: + this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ] + break + + case UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS: + this.videoAbuseUrl = '/admin/moderation/video-abuses/list' + this.videoUrl = this.buildVideoUrl(this.videoAbuse.video) + break + + case UserNotificationType.BLACKLIST_ON_MY_VIDEO: + this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video) + break + + case UserNotificationType.MY_VIDEO_PUBLISHED: + this.videoUrl = this.buildVideoUrl(this.video) + break + + case UserNotificationType.MY_VIDEO_IMPORT_SUCCESS: + this.videoImportUrl = this.buildVideoImportUrl() + this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport) + this.videoUrl = this.buildVideoUrl(this.videoImport.video) + break + + case UserNotificationType.MY_VIDEO_IMPORT_ERROR: + this.videoImportUrl = this.buildVideoImportUrl() + this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport) + break + + case UserNotificationType.NEW_USER_REGISTRATION: + this.accountUrl = this.buildAccountUrl(this.account) + break + + case UserNotificationType.NEW_FOLLOW: + this.accountUrl = this.buildAccountUrl(this.actorFollow.follower) + break + } + } + + private buildVideoUrl (video: { uuid: string }) { + return '/videos/watch/' + video.uuid + } + + private buildAccountUrl (account: { name: string }) { + return '/accounts/' + account.name + } + + private buildVideoImportUrl () { + return '/my-account/video-imports' + } + + private buildVideoImportIdentifier (videoImport: { targetUrl?: string, magnetUri?: string, torrentName?: string }) { + return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName + } + +} diff --git a/client/src/app/shared/users/user-notification.service.ts b/client/src/app/shared/users/user-notification.service.ts new file mode 100644 index 000000000..2dfee8060 --- /dev/null +++ b/client/src/app/shared/users/user-notification.service.ts @@ -0,0 +1,110 @@ +import { Injectable } from '@angular/core' +import { HttpClient, HttpParams } from '@angular/common/http' +import { RestExtractor, RestService } from '@app/shared/rest' +import { catchError, map, tap } from 'rxjs/operators' +import { environment } from '../../../environments/environment' +import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '../../../../../shared' +import { UserNotification } from '@app/shared/users/user-notification.model' +import { Subject } from 'rxjs' +import * as io from 'socket.io-client' +import { AuthService } from '@app/core' +import { ComponentPagination } from '@app/shared/rest/component-pagination.model' +import { User } from '@app/shared' + +@Injectable() +export class UserNotificationService { + static BASE_NOTIFICATIONS_URL = environment.apiUrl + '/api/v1/users/me/notifications' + static BASE_NOTIFICATION_SETTINGS = environment.apiUrl + '/api/v1/users/me/notification-settings' + + private notificationSubject = new Subject<{ type: 'new' | 'read' | 'read-all', notification?: UserNotification }>() + + private socket: SocketIOClient.Socket + + constructor ( + private auth: AuthService, + private authHttp: HttpClient, + private restExtractor: RestExtractor, + private restService: RestService + ) {} + + listMyNotifications (pagination: ComponentPagination, unread?: boolean, ignoreLoadingBar = false) { + let params = new HttpParams() + params = this.restService.addRestGetParams(params, this.restService.componentPaginationToRestPagination(pagination)) + + if (unread) params = params.append('unread', `${unread}`) + + const headers = ignoreLoadingBar ? { ignoreLoadingBar: '' } : undefined + + return this.authHttp.get>(UserNotificationService.BASE_NOTIFICATIONS_URL, { params, headers }) + .pipe( + map(res => this.restExtractor.convertResultListDateToHuman(res)), + map(res => this.restExtractor.applyToResultListData(res, this.formatNotification.bind(this))), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + countUnreadNotifications () { + return this.listMyNotifications({ currentPage: 1, itemsPerPage: 0 }, true) + .pipe(map(n => n.total)) + } + + getMyNotificationsSocket () { + const socket = this.getSocket() + + socket.on('new-notification', (n: UserNotificationServer) => { + this.notificationSubject.next({ type: 'new', notification: new UserNotification(n) }) + }) + + return this.notificationSubject.asObservable() + } + + markAsRead (notification: UserNotification) { + const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read' + + const body = { ids: [ notification.id ] } + const headers = { ignoreLoadingBar: '' } + + return this.authHttp.post(url, body, { headers }) + .pipe( + map(this.restExtractor.extractDataBool), + tap(() => this.notificationSubject.next({ type: 'read' })), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + markAllAsRead () { + const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read-all' + const headers = { ignoreLoadingBar: '' } + + return this.authHttp.post(url, {}, { headers }) + .pipe( + map(this.restExtractor.extractDataBool), + tap(() => this.notificationSubject.next({ type: 'read-all' })), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + updateNotificationSettings (user: User, settings: UserNotificationSetting) { + const url = UserNotificationService.BASE_NOTIFICATION_SETTINGS + + return this.authHttp.put(url, settings) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + private getSocket () { + if (this.socket) return this.socket + + this.socket = io(environment.apiUrl + '/user-notifications', { + query: { accessToken: this.auth.getAccessToken() } + }) + + return this.socket + } + + private formatNotification (notification: UserNotificationServer) { + return new UserNotification(notification) + } +} diff --git a/client/src/app/shared/users/user-notifications.component.html b/client/src/app/shared/users/user-notifications.component.html new file mode 100644 index 000000000..86379d941 --- /dev/null +++ b/client/src/app/shared/users/user-notifications.component.html @@ -0,0 +1,61 @@ +
You don't have notifications.
+ +
+
+ +
+ + {{ notification.video.channel.displayName }} published a new video + + + + Your video {{ notification.video.name }} has been unblacklisted + + + + Your video {{ notification.videoBlacklist.video.name }} has been blacklisted + + + + A new video abuse has been created on video {{ notification.videoAbuse.video.name }} + + + + {{ notification.comment.account.displayName }} commented your video {{ notification.comment.video.name }} + + + + Your video {{ notification.video.name }} has been published + + + + Your video import {{ notification.videoImportIdentifier }} succeeded + + + + Your video import {{ notification.videoImportIdentifier }} failed + + + + User {{ notification.account.name }} registered on your instance + + + + {{ notification.actorFollow.follower.displayName }} is following + + + your channel {{ notification.actorFollow.following.displayName }} + + your account + + + + {{ notification.comment.account.displayName }} mentioned you on video {{ notification.comment.video.name }} + +
+ +
+
+
+
+
diff --git a/client/src/app/shared/users/user-notifications.component.scss b/client/src/app/shared/users/user-notifications.component.scss new file mode 100644 index 000000000..0493b10d9 --- /dev/null +++ b/client/src/app/shared/users/user-notifications.component.scss @@ -0,0 +1,30 @@ +.notification { + display: flex; + justify-content: space-between; + align-items: center; + font-size: inherit; + padding: 15px 10px; + border-bottom: 1px solid rgba(0, 0, 0, 0.10); + + .mark-as-read { + min-width: 35px; + + .glyphicon { + display: none; + cursor: pointer; + color: rgba(20, 20, 20, 0.5) + } + } + + &.unread { + background-color: rgba(0, 0, 0, 0.05); + + &:hover .mark-as-read .glyphicon { + display: block; + + &:hover { + color: rgba(20, 20, 20, 0.8); + } + } + } +} diff --git a/client/src/app/shared/users/user-notifications.component.ts b/client/src/app/shared/users/user-notifications.component.ts new file mode 100644 index 000000000..682116226 --- /dev/null +++ b/client/src/app/shared/users/user-notifications.component.ts @@ -0,0 +1,82 @@ +import { Component, Input, OnInit } from '@angular/core' +import { UserNotificationService } from '@app/shared/users/user-notification.service' +import { UserNotificationType } from '../../../../../shared' +import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model' +import { Notifier } from '@app/core' +import { UserNotification } from '@app/shared/users/user-notification.model' + +@Component({ + selector: 'my-user-notifications', + templateUrl: 'user-notifications.component.html', + styleUrls: [ 'user-notifications.component.scss' ] +}) +export class UserNotificationsComponent implements OnInit { + @Input() ignoreLoadingBar = false + @Input() infiniteScroll = true + + notifications: UserNotification[] = [] + + // So we can access it in the template + UserNotificationType = UserNotificationType + + componentPagination: ComponentPagination = { + currentPage: 1, + itemsPerPage: 10, + totalItems: null + } + + constructor ( + private userNotificationService: UserNotificationService, + private notifier: Notifier + ) { } + + ngOnInit () { + this.loadMoreNotifications() + } + + loadMoreNotifications () { + this.userNotificationService.listMyNotifications(this.componentPagination, undefined, this.ignoreLoadingBar) + .subscribe( + result => { + this.notifications = this.notifications.concat(result.data) + this.componentPagination.totalItems = result.total + }, + + err => this.notifier.error(err.message) + ) + } + + onNearOfBottom () { + if (this.infiniteScroll === false) return + + this.componentPagination.currentPage++ + + if (hasMoreItems(this.componentPagination)) { + this.loadMoreNotifications() + } + } + + markAsRead (notification: UserNotification) { + this.userNotificationService.markAsRead(notification) + .subscribe( + () => { + notification.read = true + }, + + err => this.notifier.error(err.message) + ) + } + + markAllAsRead () { + this.userNotificationService.markAllAsRead() + .subscribe( + () => { + for (const notification of this.notifications) { + notification.read = true + } + }, + + err => this.notifier.error(err.message) + ) + } +} diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts index 3663a7b61..c15f1de8c 100644 --- a/client/src/app/shared/users/user.model.ts +++ b/client/src/app/shared/users/user.model.ts @@ -1,4 +1,4 @@ -import { hasUserRight, User as UserServerModel, UserRight, UserRole, VideoChannel } from '../../../../../shared' +import { hasUserRight, User as UserServerModel, UserNotificationSetting, UserRight, UserRole, VideoChannel } from '../../../../../shared' import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' import { Account } from '@app/shared/account/account.model' import { Avatar } from '../../../../../shared/models/avatars/avatar.model' @@ -24,6 +24,8 @@ export class User implements UserServerModel { blocked: boolean blockedReason?: string + notificationSettings?: UserNotificationSetting + constructor (hash: Partial) { this.id = hash.id this.username = hash.username @@ -41,6 +43,8 @@ export class User implements UserServerModel { this.blocked = hash.blocked this.blockedReason = hash.blockedReason + this.notificationSettings = hash.notificationSettings + if (hash.account !== undefined) { this.account = new Account(hash.account) } -- cgit v1.2.3