From 67ed6552b831df66713bac9e672738796128d33f Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 23 Jun 2020 14:10:17 +0200 Subject: Reorganize client shared modules --- client/src/app/shared/shared-main/users/index.ts | 4 + .../shared-main/users/user-history.service.ts | 43 +++++ .../shared-main/users/user-notification.model.ts | 184 +++++++++++++++++++++ .../shared-main/users/user-notification.service.ts | 81 +++++++++ .../users/user-notifications.component.html | 166 +++++++++++++++++++ .../users/user-notifications.component.scss | 53 ++++++ .../users/user-notifications.component.ts | 100 +++++++++++ 7 files changed, 631 insertions(+) create mode 100644 client/src/app/shared/shared-main/users/index.ts create mode 100644 client/src/app/shared/shared-main/users/user-history.service.ts create mode 100644 client/src/app/shared/shared-main/users/user-notification.model.ts create mode 100644 client/src/app/shared/shared-main/users/user-notification.service.ts create mode 100644 client/src/app/shared/shared-main/users/user-notifications.component.html create mode 100644 client/src/app/shared/shared-main/users/user-notifications.component.scss create mode 100644 client/src/app/shared/shared-main/users/user-notifications.component.ts (limited to 'client/src/app/shared/shared-main/users') diff --git a/client/src/app/shared/shared-main/users/index.ts b/client/src/app/shared/shared-main/users/index.ts new file mode 100644 index 000000000..83401ab52 --- /dev/null +++ b/client/src/app/shared/shared-main/users/index.ts @@ -0,0 +1,4 @@ +export * from './user-history.service' +export * from './user-notification.model' +export * from './user-notification.service' +export * from './user-notifications.component' diff --git a/client/src/app/shared/shared-main/users/user-history.service.ts b/client/src/app/shared/shared-main/users/user-history.service.ts new file mode 100644 index 000000000..43970dc5b --- /dev/null +++ b/client/src/app/shared/shared-main/users/user-history.service.ts @@ -0,0 +1,43 @@ +import { catchError, map, switchMap } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' +import { ResultList } from '@shared/models' +import { environment } from '../../../../environments/environment' +import { Video } from '../video/video.model' +import { VideoService } from '../video/video.service' + +@Injectable() +export class UserHistoryService { + static BASE_USER_VIDEOS_HISTORY_URL = environment.apiUrl + '/api/v1/users/me/history/videos' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor, + private restService: RestService, + private videoService: VideoService + ) {} + + getUserVideosHistory (historyPagination: ComponentPaginationLight) { + const pagination = this.restService.componentPaginationToRestPagination(historyPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination) + + return this.authHttp + .get>(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL, { params }) + .pipe( + switchMap(res => this.videoService.extractVideos(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + deleteUserVideosHistory () { + return this.authHttp + .post(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL + '/remove', {}) + .pipe( + map(() => this.restExtractor.extractDataBool()), + catchError(err => this.restExtractor.handleError(err)) + ) + } +} diff --git a/client/src/app/shared/shared-main/users/user-notification.model.ts b/client/src/app/shared/shared-main/users/user-notification.model.ts new file mode 100644 index 000000000..de25d3ab9 --- /dev/null +++ b/client/src/app/shared/shared-main/users/user-notification.model.ts @@ -0,0 +1,184 @@ +import { Actor } from '../account/actor.model' +import { ActorInfo, Avatar, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '@shared/models' + +export class UserNotification implements UserNotificationServer { + id: number + type: UserNotificationType + read: boolean + + video?: VideoInfo & { + channel: ActorInfo & { avatarUrl?: string } + } + + videoImport?: { + id: number + video?: VideoInfo + torrentName?: string + magnetUri?: string + targetUrl?: string + } + + comment?: { + id: number + threadId: number + account: ActorInfo & { avatarUrl?: string } + video: VideoInfo + } + + videoAbuse?: { + id: number + video: VideoInfo + } + + videoBlacklist?: { + id: number + video: VideoInfo + } + + account?: ActorInfo & { avatarUrl?: string } + + actorFollow?: { + id: number + state: FollowState + follower: ActorInfo & { avatarUrl?: string } + following: { + type: 'account' | 'channel' | 'instance' + name: string + displayName: string + host: string + } + } + + createdAt: string + updatedAt: string + + // Additional fields + videoUrl?: string + commentUrl?: any[] + videoAbuseUrl?: string + videoAutoBlacklistUrl?: string + accountUrl?: string + videoImportIdentifier?: string + videoImportUrl?: string + instanceFollowUrl?: string + + constructor (hash: UserNotificationServer) { + this.id = hash.id + this.type = hash.type + this.read = hash.read + + // We assume that some fields exist + // To prevent a notification popup crash in case of bug, wrap it inside a try/catch + try { + this.video = hash.video + if (this.video) this.setAvatarUrl(this.video.channel) + + this.videoImport = hash.videoImport + + this.comment = hash.comment + if (this.comment) this.setAvatarUrl(this.comment.account) + + this.videoAbuse = hash.videoAbuse + + this.videoBlacklist = hash.videoBlacklist + + this.account = hash.account + if (this.account) this.setAvatarUrl(this.account) + + this.actorFollow = hash.actorFollow + if (this.actorFollow) this.setAvatarUrl(this.actorFollow.follower) + + 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: + if (!this.comment) break + this.accountUrl = this.buildAccountUrl(this.comment.account) + 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.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS: + this.videoAutoBlacklistUrl = '/admin/moderation/video-auto-blacklist/list' + // Backward compatibility where we did not assign videoBlacklist to this type of notification before + if (!this.videoBlacklist) this.videoBlacklist = { id: null, video: this.video } + + this.videoUrl = this.buildVideoUrl(this.videoBlacklist.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) + + if (this.videoImport.video) 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 + + case UserNotificationType.NEW_INSTANCE_FOLLOWER: + this.instanceFollowUrl = '/admin/follows/followers-list' + break + + case UserNotificationType.AUTO_INSTANCE_FOLLOWING: + this.instanceFollowUrl = '/admin/follows/following-list' + break + } + } catch (err) { + this.type = null + console.error(err) + } + } + + private buildVideoUrl (video: { uuid: string }) { + return '/videos/watch/' + video.uuid + } + + private buildAccountUrl (account: { name: string, host: string }) { + return '/accounts/' + Actor.CREATE_BY_STRING(account.name, account.host) + } + + private buildVideoImportUrl () { + return '/my-account/video-imports' + } + + private buildVideoImportIdentifier (videoImport: { targetUrl?: string, magnetUri?: string, torrentName?: string }) { + return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName + } + + private setAvatarUrl (actor: { avatarUrl?: string, avatar?: Avatar }) { + actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor) + } +} diff --git a/client/src/app/shared/shared-main/users/user-notification.service.ts b/client/src/app/shared/shared-main/users/user-notification.service.ts new file mode 100644 index 000000000..8dd9472fe --- /dev/null +++ b/client/src/app/shared/shared-main/users/user-notification.service.ts @@ -0,0 +1,81 @@ +import { catchError, map, tap } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { ComponentPaginationLight, RestExtractor, RestService, User, UserNotificationSocket } from '@app/core' +import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '@shared/models' +import { environment } from '../../../../environments/environment' +import { UserNotification } from './user-notification.model' + +@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' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor, + private restService: RestService, + private userNotificationSocket: UserNotificationSocket + ) {} + + listMyNotifications (pagination: ComponentPaginationLight, 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)) + } + + 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.userNotificationSocket.dispatch('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.userNotificationSocket.dispatch('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 formatNotification (notification: UserNotificationServer) { + return new UserNotification(notification) + } +} diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.html b/client/src/app/shared/shared-main/users/user-notifications.component.html new file mode 100644 index 000000000..08771110d --- /dev/null +++ b/client/src/app/shared/shared-main/users/user-notifications.component.html @@ -0,0 +1,166 @@ +
You don't have notifications.
+ +
+
+ + + + + + + + + + +
+ {{ notification.video.channel.displayName }} published a new video: {{ notification.video.name }} +
+
+ + + + +
+ The notification concerns a video now unavailable +
+
+
+ + + + +
+ Your video {{ notification.video.name }} has been unblocked +
+
+ + + + +
+ Your video {{ notification.videoBlacklist.video.name }} has been blocked +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ The notification concerns a comment now unavailable +
+
+
+ + + + +
+ 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 +
+
+ + + + + + + + + + + + +
+ Your instance has a new follower ({{ notification.actorFollow?.follower.host }}) + awaiting your approval +
+
+ + + + +
+ Your instance automatically followed {{ notification.actorFollow.following.host }} +
+
+ + + + +
+ The notification points to a content now unavailable +
+
+
+ +
{{ notification.createdAt | myFromNow }}
+
+
diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.scss b/client/src/app/shared/shared-main/users/user-notifications.component.scss new file mode 100644 index 000000000..5166bd559 --- /dev/null +++ b/client/src/app/shared/shared-main/users/user-notifications.component.scss @@ -0,0 +1,53 @@ +@import '_variables'; +@import '_mixins'; + +.no-notification { + display: flex; + justify-content: center; + align-items: center; + padding: 20px 0; +} + +.notification { + display: flex; + align-items: center; + font-size: inherit; + padding: 15px 5px 15px 10px; + border-bottom: 1px solid $separator-border-color; + word-break: break-word; + + &.unread { + background-color: rgba(0, 0, 0, 0.05); + } + + my-global-icon { + width: 24px; + margin-right: 11px; + margin-left: 3px; + + @include apply-svg-color(#333); + } + + .avatar { + @include avatar(30px); + + margin-right: 10px; + } + + .message { + flex-grow: 1; + + a { + font-weight: $font-semibold; + } + } + + .from-date { + font-size: 0.85em; + color: pvar(--greyForegroundColor); + padding-left: 5px; + min-width: 70px; + text-align: right; + margin-left: auto; + } +} diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.ts b/client/src/app/shared/shared-main/users/user-notifications.component.ts new file mode 100644 index 000000000..6abd8b7d8 --- /dev/null +++ b/client/src/app/shared/shared-main/users/user-notifications.component.ts @@ -0,0 +1,100 @@ +import { Subject } from 'rxjs' +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' +import { ComponentPagination, hasMoreItems, Notifier } from '@app/core' +import { UserNotificationType } from '@shared/models' +import { UserNotification } from './user-notification.model' +import { UserNotificationService } from './user-notification.service' + +@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 + @Input() itemsPerPage = 20 + @Input() markAllAsReadSubject: Subject + + @Output() notificationsLoaded = new EventEmitter() + + notifications: UserNotification[] = [] + + // So we can access it in the template + UserNotificationType = UserNotificationType + + componentPagination: ComponentPagination + + onDataSubject = new Subject() + + constructor ( + private userNotificationService: UserNotificationService, + private notifier: Notifier + ) { } + + ngOnInit () { + this.componentPagination = { + currentPage: 1, + itemsPerPage: this.itemsPerPage, // Reset items per page, because of the @Input() variable + totalItems: null + } + + this.loadMoreNotifications() + + if (this.markAllAsReadSubject) { + this.markAllAsReadSubject.subscribe(() => this.markAllAsRead()) + } + } + + loadMoreNotifications () { + this.userNotificationService.listMyNotifications(this.componentPagination, undefined, this.ignoreLoadingBar) + .subscribe( + result => { + this.notifications = this.notifications.concat(result.data) + this.componentPagination.totalItems = result.total + + this.notificationsLoaded.emit() + + this.onDataSubject.next(result.data) + }, + + err => this.notifier.error(err.message) + ) + } + + onNearOfBottom () { + if (this.infiniteScroll === false) return + + this.componentPagination.currentPage++ + + if (hasMoreItems(this.componentPagination)) { + this.loadMoreNotifications() + } + } + + markAsRead (notification: UserNotification) { + if (notification.read) return + + 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) + ) + } +} -- cgit v1.2.3