From: Chocobozzz Date: Tue, 8 Jan 2019 10:26:41 +0000 (+0100) Subject: Add notifications in the client X-Git-Tag: v1.2.0-rc.1~42 X-Git-Url: https://git.librecmc.org/?a=commitdiff_plain;h=2f1548fda32c3ba9e53913270394eedfacd55986;p=oweals%2Fpeertube.git Add notifications in the client --- diff --git a/.travis.yml b/.travis.yml index 3a73e4fc0..d252ae625 100644 --- a/.travis.yml +++ b/.travis.yml @@ -48,12 +48,12 @@ matrix: - env: TEST_SUITE=jest script: - - travis_retry npm run travis -- "$TEST_SUITE" + - NODE_PENDING_JOB_WAIT=1000 travis_retry npm run travis -- "$TEST_SUITE" after_failure: - - cat test1/logs/all-logs.log - - cat test2/logs/all-logs.log - - cat test3/logs/all-logs.log - - cat test4/logs/all-logs.log - - cat test5/logs/all-logs.log - - cat test6/logs/all-logs.log + - cat test1/logs/peertube.log + - cat test2/logs/peertube.log + - cat test3/logs/peertube.log + - cat test4/logs/peertube.log + - cat test5/logs/peertube.log + - cat test6/logs/peertube.log diff --git a/client/package.json b/client/package.json index 81422f05f..5fe1f3d5f 100644 --- a/client/package.json +++ b/client/package.json @@ -94,6 +94,7 @@ "@types/markdown-it": "^0.0.5", "@types/node": "^10.9.2", "@types/sanitize-html": "1.18.0", + "@types/socket.io-client": "^1.4.32", "@types/video.js": "^7.2.5", "@types/webtorrent": "^0.98.4", "angular2-hotkeys": "^2.1.2", @@ -141,6 +142,7 @@ "sanitize-html": "^1.18.4", "sass-loader": "^7.1.0", "sass-resources-loader": "^2.0.0", + "socket.io-client": "^2.2.0", "stream-browserify": "^2.0.1", "stream-http": "^3.0.0", "terser-webpack-plugin": "^1.1.0", diff --git a/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.html b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.html new file mode 100644 index 000000000..d2810c343 --- /dev/null +++ b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.html @@ -0,0 +1,7 @@ +
+ Notification preferences + + +
+ + diff --git a/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss new file mode 100644 index 000000000..86ac094c5 --- /dev/null +++ b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss @@ -0,0 +1,23 @@ +@import '_variables'; +@import '_mixins'; + +.header { + display: flex; + justify-content: space-between; + font-size: 15px; + margin-bottom: 10px; + + a { + @include peertube-button-link; + @include grey-button; + } + + button { + @include peertube-button; + @include grey-button; + } +} + +my-user-notifications { + font-size: 15px; +} diff --git a/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.ts b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.ts new file mode 100644 index 000000000..3e197088d --- /dev/null +++ b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.ts @@ -0,0 +1,14 @@ +import { Component, ViewChild } from '@angular/core' +import { UserNotificationsComponent } from '@app/shared' + +@Component({ + templateUrl: './my-account-notifications.component.html', + styleUrls: [ './my-account-notifications.component.scss' ] +}) +export class MyAccountNotificationsComponent { + @ViewChild('userNotification') userNotification: UserNotificationsComponent + + markAllAsRead () { + this.userNotification.markAllAsRead() + } +} diff --git a/client/src/app/+my-account/my-account-routing.module.ts b/client/src/app/+my-account/my-account-routing.module.ts index a2cbeaffc..9996218ca 100644 --- a/client/src/app/+my-account/my-account-routing.module.ts +++ b/client/src/app/+my-account/my-account-routing.module.ts @@ -14,6 +14,7 @@ import { MyAccountOwnershipComponent } from '@app/+my-account/my-account-ownersh import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-blocklist.component' import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component' import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component' +import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component' const myAccountRoutes: Routes = [ { @@ -124,6 +125,15 @@ const myAccountRoutes: Routes = [ title: 'Videos history' } } + }, + { + path: 'notifications', + component: MyAccountNotificationsComponent, + data: { + meta: { + title: 'Notifications' + } + } } ] } diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/index.ts b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/index.ts new file mode 100644 index 000000000..5e1d51339 --- /dev/null +++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/index.ts @@ -0,0 +1 @@ +export * from './my-account-notification-preferences.component' diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html new file mode 100644 index 000000000..59422d682 --- /dev/null +++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html @@ -0,0 +1,19 @@ +
+
Activities
+
Web
+
Email
+
+ +
+ +
{{ labelNotifications[notificationType] }}
+ +
+ +
+ +
+ +
+
+
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss new file mode 100644 index 000000000..6feb16ab1 --- /dev/null +++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss @@ -0,0 +1,25 @@ +@import '_variables'; +@import '_mixins'; + +.custom-row { + display: flex; + align-items: center; + border-bottom: 1px solid rgba(0, 0, 0, 0.10); + + &:first-child { + font-size: 16px; + + & > div { + font-weight: $font-semibold; + } + } + + & > div { + width: 350px; + } + + & > div { + padding: 10px + } +} + diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts new file mode 100644 index 000000000..519bdfab4 --- /dev/null +++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts @@ -0,0 +1,99 @@ +import { Component, Input, OnInit } from '@angular/core' +import { User } from '@app/shared' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Subject } from 'rxjs' +import { UserNotificationSetting, UserNotificationSettingValue, UserRight } from '../../../../../../shared' +import { Notifier, ServerService } from '@app/core' +import { debounce } from 'lodash-es' +import { UserNotificationService } from '@app/shared/users/user-notification.service' + +@Component({ + selector: 'my-account-notification-preferences', + templateUrl: './my-account-notification-preferences.component.html', + styleUrls: [ './my-account-notification-preferences.component.scss' ] +}) +export class MyAccountNotificationPreferencesComponent implements OnInit { + @Input() user: User = null + @Input() userInformationLoaded: Subject + + notificationSettingKeys: (keyof UserNotificationSetting)[] = [] + emailNotifications: { [ id in keyof UserNotificationSetting ]: boolean } = {} as any + webNotifications: { [ id in keyof UserNotificationSetting ]: boolean } = {} as any + labelNotifications: { [ id in keyof UserNotificationSetting ]: string } = {} as any + rightNotifications: { [ id in keyof Partial ]: UserRight } = {} as any + emailEnabled: boolean + + private savePreferences = debounce(this.savePreferencesImpl.bind(this), 500) + + constructor ( + private i18n: I18n, + private userNotificationService: UserNotificationService, + private serverService: ServerService, + private notifier: Notifier + ) { + this.labelNotifications = { + newVideoFromSubscription: this.i18n('New video from your subscriptions'), + newCommentOnMyVideo: this.i18n('New comment on your video'), + videoAbuseAsModerator: this.i18n('New video abuse on local video'), + blacklistOnMyVideo: this.i18n('One of your video is blacklisted/unblacklisted'), + myVideoPublished: this.i18n('Video published (after transcoding/scheduled update)'), + myVideoImportFinished: this.i18n('Video import finished'), + newUserRegistration: this.i18n('A new user registered on your instance'), + newFollow: this.i18n('You or your channel(s) has a new follower'), + commentMention: this.i18n('Someone mentioned you in video comments') + } + this.notificationSettingKeys = Object.keys(this.labelNotifications) as (keyof UserNotificationSetting)[] + + this.rightNotifications = { + videoAbuseAsModerator: UserRight.MANAGE_VIDEO_ABUSES, + newUserRegistration: UserRight.MANAGE_USERS + } + + this.emailEnabled = this.serverService.getConfig().email.enabled + } + + ngOnInit () { + this.userInformationLoaded.subscribe(() => this.loadNotificationSettings()) + } + + hasUserRight (field: keyof UserNotificationSetting) { + const rightToHave = this.rightNotifications[field] + if (!rightToHave) return true // No rights needed + + return this.user.hasRight(rightToHave) + } + + updateEmailSetting (field: keyof UserNotificationSetting, value: boolean) { + if (value === true) this.user.notificationSettings[field] |= UserNotificationSettingValue.EMAIL + else this.user.notificationSettings[field] &= ~UserNotificationSettingValue.EMAIL + + this.savePreferences() + } + + updateWebSetting (field: keyof UserNotificationSetting, value: boolean) { + if (value === true) this.user.notificationSettings[field] |= UserNotificationSettingValue.WEB + else this.user.notificationSettings[field] &= ~UserNotificationSettingValue.WEB + + this.savePreferences() + } + + private savePreferencesImpl () { + this.userNotificationService.updateNotificationSettings(this.user, this.user.notificationSettings) + .subscribe( + () => { + this.notifier.success(this.i18n('Preferences saved'), undefined, 2000) + }, + + err => this.notifier.error(err.message) + ) + } + + private loadNotificationSettings () { + for (const key of Object.keys(this.user.notificationSettings)) { + const value = this.user.notificationSettings[key] + this.emailNotifications[key] = value & UserNotificationSettingValue.EMAIL + + this.webNotifications[key] = value & UserNotificationSettingValue.WEB + } + } +} diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html index c7e23cd1f..2eb7dd56e 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html +++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html @@ -9,6 +9,9 @@ + + + @@ -16,4 +19,4 @@ - \ No newline at end of file + diff --git a/client/src/app/+my-account/my-account.component.ts b/client/src/app/+my-account/my-account.component.ts index 1bac9547d..8a4102d80 100644 --- a/client/src/app/+my-account/my-account.component.ts +++ b/client/src/app/+my-account/my-account.component.ts @@ -68,6 +68,10 @@ export class MyAccountComponent { label: this.i18n('My settings'), routerLink: '/my-account/settings' }, + { + label: this.i18n('My notifications'), + routerLink: '/my-account/notifications' + }, libraryEntries, miscEntries ] diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts index 80d9f0cf7..18f51f171 100644 --- a/client/src/app/+my-account/my-account.module.ts +++ b/client/src/app/+my-account/my-account.module.ts @@ -23,6 +23,8 @@ import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-sub import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-blocklist.component' import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component' import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component' +import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component' +import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences' @NgModule({ imports: [ @@ -53,7 +55,9 @@ import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/m MyAccountSubscriptionsComponent, MyAccountBlocklistComponent, MyAccountServerBlocklistComponent, - MyAccountHistoryComponent + MyAccountHistoryComponent, + MyAccountNotificationsComponent, + MyAccountNotificationPreferencesComponent ], exports: [ diff --git a/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts b/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts index 70c4374e0..dea378a6e 100644 --- a/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts +++ b/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts @@ -55,7 +55,7 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On this.videoChannelSub = this.videoChannelService.videoChannelLoaded .subscribe(videoChannel => { this.videoChannel = videoChannel - this.currentRoute = '/video-channels/' + this.videoChannel.uuid + '/videos' + this.currentRoute = '/video-channels/' + this.videoChannel.nameWithHost + '/videos' this.reloadVideos() this.generateSyndicationList() diff --git a/client/src/app/+video-channels/video-channels-routing.module.ts b/client/src/app/+video-channels/video-channels-routing.module.ts index 935578d2a..3ac3533d9 100644 --- a/client/src/app/+video-channels/video-channels-routing.module.ts +++ b/client/src/app/+video-channels/video-channels-routing.module.ts @@ -7,7 +7,7 @@ import { VideoChannelAboutComponent } from './video-channel-about/video-channel- const videoChannelsRoutes: Routes = [ { - path: ':videoChannelId', + path: ':videoChannelName', component: VideoChannelsComponent, canActivateChild: [ MetaGuard ], children: [ diff --git a/client/src/app/+video-channels/video-channels.component.ts b/client/src/app/+video-channels/video-channels.component.ts index 0c5c814c7..41ff82e98 100644 --- a/client/src/app/+video-channels/video-channels.component.ts +++ b/client/src/app/+video-channels/video-channels.component.ts @@ -34,9 +34,9 @@ export class VideoChannelsComponent implements OnInit, OnDestroy { ngOnInit () { this.routeSub = this.route.params .pipe( - map(params => params[ 'videoChannelId' ]), + map(params => params[ 'videoChannelName' ]), distinctUntilChanged(), - switchMap(videoChannelId => this.videoChannelService.getVideoChannel(videoChannelId)), + switchMap(videoChannelName => this.videoChannelService.getVideoChannel(videoChannelName)), catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ])) ) .subscribe(videoChannel => this.videoChannel = videoChannel) diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts index 371199442..0bbc2e08b 100644 --- a/client/src/app/app.module.ts +++ b/client/src/app/app.module.ts @@ -12,13 +12,12 @@ import { AppComponent } from './app.component' import { CoreModule } from './core' import { HeaderComponent } from './header' import { LoginModule } from './login' -import { MenuComponent } from './menu' +import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu' import { SharedModule } from './shared' import { SignupModule } from './signup' import { VideosModule } from './videos' import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n' import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' -import { LanguageChooserComponent } from '@app/menu/language-chooser.component' import { SearchModule } from '@app/search' export function metaFactory (serverService: ServerService): MetaLoader { @@ -40,6 +39,7 @@ export function metaFactory (serverService: ServerService): MetaLoader { MenuComponent, LanguageChooserComponent, + AvatarNotificationComponent, HeaderComponent ], imports: [ diff --git a/client/src/app/menu/avatar-notification.component.html b/client/src/app/menu/avatar-notification.component.html new file mode 100644 index 000000000..2f0b7c669 --- /dev/null +++ b/client/src/app/menu/avatar-notification.component.html @@ -0,0 +1,23 @@ +
+
{{ unreadNotifications }}
+ + Avatar +
+ + +
+
Notifications
+ + +
+ + + + See all your notifications +
diff --git a/client/src/app/menu/avatar-notification.component.scss b/client/src/app/menu/avatar-notification.component.scss new file mode 100644 index 000000000..c86667469 --- /dev/null +++ b/client/src/app/menu/avatar-notification.component.scss @@ -0,0 +1,82 @@ +@import '_variables'; +@import '_mixins'; + +/deep/ { + .popover-notifications.popover { + max-width: 400px; + + .popover-body { + padding: 0; + font-size: 14px; + font-family: $main-fonts; + overflow-y: auto; + max-height: 500px; + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.30); + + .notifications-header { + display: flex; + justify-content: space-between; + + background-color: rgba(0, 0, 0, 0.10); + align-items: center; + padding: 0 10px; + font-size: 16px; + height: 50px; + + a { + @include disable-default-a-behaviour; + + color: rgba(20, 20, 20, 0.5); + + &:hover { + color: rgba(20, 20, 20, 0.8); + } + } + } + + .all-notifications { + display: flex; + align-items: center; + justify-content: center; + font-weight: $font-semibold; + color: var(--mainForegroundColor); + height: 30px; + } + } + } +} + +.notification-avatar { + cursor: pointer; + position: relative; + + img, + .unread-notifications { + margin-left: 20px; + } + + img { + @include avatar(34px); + + margin-right: 10px; + } + + .unread-notifications { + position: absolute; + top: -5px; + left: -5px; + + display: flex; + align-items: center; + justify-content: center; + + background-color: var(--mainColor); + color: var(--mainBackgroundColor); + font-size: 10px; + font-weight: $font-semibold; + + border-radius: 15px; + width: 15px; + height: 15px; + } +} diff --git a/client/src/app/menu/avatar-notification.component.ts b/client/src/app/menu/avatar-notification.component.ts new file mode 100644 index 000000000..60e090726 --- /dev/null +++ b/client/src/app/menu/avatar-notification.component.ts @@ -0,0 +1,64 @@ +import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core' +import { User } from '../shared/users/user.model' +import { UserNotificationService } from '@app/shared/users/user-notification.service' +import { Subscription } from 'rxjs' +import { Notifier } from '@app/core' +import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' +import { NavigationEnd, Router } from '@angular/router' +import { filter } from 'rxjs/operators' + +@Component({ + selector: 'my-avatar-notification', + templateUrl: './avatar-notification.component.html', + styleUrls: [ './avatar-notification.component.scss' ] +}) +export class AvatarNotificationComponent implements OnInit, OnDestroy { + @ViewChild('popover') popover: NgbPopover + @Input() user: User + + unreadNotifications = 0 + + private notificationSub: Subscription + private routeSub: Subscription + + constructor ( + private userNotificationService: UserNotificationService, + private notifier: Notifier, + private router: Router + ) {} + + ngOnInit () { + this.userNotificationService.countUnreadNotifications() + .subscribe( + result => { + this.unreadNotifications = Math.min(result, 99) // Limit number to 99 + this.subscribeToNotifications() + }, + + err => this.notifier.error(err.message) + ) + + this.routeSub = this.router.events + .pipe(filter(event => event instanceof NavigationEnd)) + .subscribe(() => this.closePopover()) + } + + ngOnDestroy () { + if (this.notificationSub) this.notificationSub.unsubscribe() + if (this.routeSub) this.routeSub.unsubscribe() + } + + closePopover () { + this.popover.close() + } + + private subscribeToNotifications () { + this.notificationSub = this.userNotificationService.getMyNotificationsSocket() + .subscribe(data => { + if (data.type === 'new') return this.unreadNotifications++ + if (data.type === 'read') return this.unreadNotifications-- + if (data.type === 'read-all') return this.unreadNotifications = 0 + }) + } + +} diff --git a/client/src/app/menu/index.ts b/client/src/app/menu/index.ts index 421271c12..39dbde750 100644 --- a/client/src/app/menu/index.ts +++ b/client/src/app/menu/index.ts @@ -1 +1,3 @@ +export * from './language-chooser.component' +export * from './avatar-notification.component' export * from './menu.component' diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html index e04bdf3d6..aa5bfa9c9 100644 --- a/client/src/app/menu/menu.component.html +++ b/client/src/app/menu/menu.component.html @@ -2,9 +2,7 @@ - \ No newline at end of file + diff --git a/client/src/app/menu/menu.component.scss b/client/src/app/menu/menu.component.scss index b271ebfd2..a4aaadc7f 100644 --- a/client/src/app/menu/menu.component.scss +++ b/client/src/app/menu/menu.component.scss @@ -39,13 +39,6 @@ menu { justify-content: center; margin-bottom: 35px; - img { - @include avatar(34px); - - margin-left: 20px; - margin-right: 10px; - } - .logged-in-info { flex-grow: 1; diff --git a/client/src/app/shared/misc/help.component.html b/client/src/app/shared/misc/help.component.html index 28ccb1e26..08a2fc367 100644 --- a/client/src/app/shared/misc/help.component.html +++ b/client/src/app/shared/misc/help.component.html @@ -18,6 +18,7 @@ container="body" title="Get help" i18n-title + popoverClass="help-popover" [attr.aria-pressed]="isPopoverOpened" [ngbPopover]="tooltipTemplate" [placement]="tooltipPlacement" diff --git a/client/src/app/shared/misc/help.component.scss b/client/src/app/shared/misc/help.component.scss index 5c73a8031..6a5c3b1fa 100644 --- a/client/src/app/shared/misc/help.component.scss +++ b/client/src/app/shared/misc/help.component.scss @@ -12,19 +12,21 @@ } /deep/ { - .popover-body { - text-align: left; - padding: 10px; + .popover-help.popover { max-width: 300px; - font-size: 13px; - font-family: $main-fonts; - background-color: #fff; - color: #000; - box-shadow: 0 0 6px rgba(0, 0, 0, 0.5); + .popover-body { + text-align: left; + padding: 10px; + font-size: 13px; + font-family: $main-fonts; + background-color: #fff; + color: #000; + box-shadow: 0 0 6px rgba(0, 0, 0, 0.5); - ul { - padding-left: 20px; + ul { + padding-left: 20px; + } } } } diff --git a/client/src/app/shared/rest/component-pagination.model.ts b/client/src/app/shared/rest/component-pagination.model.ts index 0b8ecc318..85160d445 100644 --- a/client/src/app/shared/rest/component-pagination.model.ts +++ b/client/src/app/shared/rest/component-pagination.model.ts @@ -3,3 +3,14 @@ export interface ComponentPagination { itemsPerPage: number totalItems?: number } + +export function hasMoreItems (componentPagination: ComponentPagination) { + // No results + if (componentPagination.totalItems === 0) return false + + // Not loaded yet + if (!componentPagination.totalItems) return true + + const maxPage = componentPagination.totalItems / componentPagination.itemsPerPage + return maxPage > componentPagination.currentPage +} diff --git a/client/src/app/shared/rest/rest-extractor.service.ts b/client/src/app/shared/rest/rest-extractor.service.ts index f149569ef..e6518dd1d 100644 --- a/client/src/app/shared/rest/rest-extractor.service.ts +++ b/client/src/app/shared/rest/rest-extractor.service.ts @@ -80,6 +80,7 @@ export class RestExtractor { errorMessage = errorMessage ? errorMessage : 'Unknown error.' console.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`) } else { + console.error(err) errorMessage = err } diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 4a5d664db..c99c87c00 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -63,6 +63,8 @@ import { UserModerationDropdownComponent } from '@app/shared/moderation/user-mod import { BlocklistService } from '@app/shared/blocklist' import { TopMenuDropdownComponent } from '@app/shared/menu/top-menu-dropdown.component' import { UserHistoryService } from '@app/shared/users/user-history.service' +import { UserNotificationService } from '@app/shared/users/user-notification.service' +import { UserNotificationsComponent } from '@app/shared/users/user-notifications.component' @NgModule({ imports: [ @@ -105,7 +107,8 @@ import { UserHistoryService } from '@app/shared/users/user-history.service' InstanceFeaturesTableComponent, UserBanModalComponent, UserModerationDropdownComponent, - TopMenuDropdownComponent + TopMenuDropdownComponent, + UserNotificationsComponent ], exports: [ @@ -145,6 +148,7 @@ import { UserHistoryService } from '@app/shared/users/user-history.service' UserBanModalComponent, UserModerationDropdownComponent, TopMenuDropdownComponent, + UserNotificationsComponent, NumberFormatterPipe, ObjectLengthPipe, @@ -187,6 +191,8 @@ import { UserHistoryService } from '@app/shared/users/user-history.service' I18nPrimengCalendarService, ScreenService, + UserNotificationService, + I18n ] }) 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) } diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.ts b/client/src/app/videos/+video-watch/comment/video-comments.component.ts index 957c17bbf..dc62fe5ae 100644 --- a/client/src/app/videos/+video-watch/comment/video-comments.component.ts +++ b/client/src/app/videos/+video-watch/comment/video-comments.component.ts @@ -4,7 +4,7 @@ import { ConfirmService, Notifier } from '@app/core' import { Subscription } from 'rxjs' import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model' import { AuthService } from '../../../core/auth' -import { ComponentPagination } from '../../../shared/rest/component-pagination.model' +import { ComponentPagination, hasMoreItems } from '../../../shared/rest/component-pagination.model' import { User } from '../../../shared/users' import { VideoSortField } from '../../../shared/video/sort-field.type' import { VideoDetails } from '../../../shared/video/video-details.model' @@ -165,22 +165,11 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { onNearOfBottom () { this.componentPagination.currentPage++ - if (this.hasMoreComments()) { + if (hasMoreItems(this.componentPagination)) { this.loadMoreComments() } } - private hasMoreComments () { - // No results - if (this.componentPagination.totalItems === 0) return false - - // Not loaded yet - if (!this.componentPagination.totalItems) return true - - const maxPage = this.componentPagination.totalItems / this.componentPagination.itemsPerPage - return maxPage > this.componentPagination.currentPage - } - private deleteLocalCommentThread (parentComment: VideoCommentThreadTree, commentToDelete: VideoComment) { for (const commentChild of parentComment.children) { if (commentChild.comment.id === commentToDelete.id) { diff --git a/client/src/sass/include/_bootstrap-variables.scss b/client/src/sass/include/_bootstrap-variables.scss index 77a20cfe1..7f413836b 100644 --- a/client/src/sass/include/_bootstrap-variables.scss +++ b/client/src/sass/include/_bootstrap-variables.scss @@ -31,4 +31,5 @@ $input-focus-border-color: #ced4da; $nav-pills-link-active-bg: #F0F0F0; $nav-pills-link-active-color: #000; -$zindex-dropdown: 10000; \ No newline at end of file +$zindex-dropdown: 10000; +$zindex-popover: 10000; diff --git a/client/src/sass/primeng-custom.scss b/client/src/sass/primeng-custom.scss index 05db2a2cb..58a6a0004 100644 --- a/client/src/sass/primeng-custom.scss +++ b/client/src/sass/primeng-custom.scss @@ -326,6 +326,8 @@ p-toast { .notification-block { display: flex; + align-items: center; + padding: 5px; .message { flex-grow: 1; @@ -336,12 +338,12 @@ p-toast { p { font-size: 15px; + margin-bottom: 0; } } .glyphicon { font-size: 32px; - margin-top: 15px; margin-right: 5px; } } diff --git a/client/yarn.lock b/client/yarn.lock index 3c7ba2d25..5ed43117a 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -510,6 +510,11 @@ dependencies: "@types/node" "*" +"@types/socket.io-client@^1.4.32": + version "1.4.32" + resolved "https://registry.yarnpkg.com/@types/socket.io-client/-/socket.io-client-1.4.32.tgz#988a65a0386c274b1c22a55377fab6a30789ac14" + integrity sha512-Vs55Kq8F+OWvy1RLA31rT+cAyemzgm0EWNeax6BWF8H7QiiOYMJIdcwSDdm5LVgfEkoepsWkS+40+WNb7BUMbg== + "@types/video.js@^7.2.5": version "7.2.5" resolved "https://registry.yarnpkg.com/@types/video.js/-/video.js-7.2.5.tgz#20896c81141d3517c3a89bb6eb97c6a191aa5d4c" @@ -3195,6 +3200,23 @@ engine.io-client@~3.2.0: xmlhttprequest-ssl "~1.5.4" yeast "0.1.2" +engine.io-client@~3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.3.1.tgz#afedb4a07b2ea48b7190c3136bfea98fdd4f0f03" + integrity sha512-q66JBFuQcy7CSlfAz9L3jH+v7DTT3i6ZEadYcVj2pOs8/0uJHLxKX3WBkGTvULJMdz0tUCyJag0aKT/dpXL9BQ== + dependencies: + component-emitter "1.2.1" + component-inherit "0.0.3" + debug "~3.1.0" + engine.io-parser "~2.1.1" + has-cors "1.1.0" + indexof "0.0.1" + parseqs "0.0.5" + parseuri "0.0.5" + ws "~6.1.0" + xmlhttprequest-ssl "~1.5.4" + yeast "0.1.2" + engine.io-parser@~2.1.0, engine.io-parser@~2.1.1: version "2.1.3" resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.3.tgz#757ab970fbf2dfb32c7b74b033216d5739ef79a6" @@ -8981,6 +9003,26 @@ socket.io-client@2.1.1: socket.io-parser "~3.2.0" to-array "0.1.4" +socket.io-client@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.2.0.tgz#84e73ee3c43d5020ccc1a258faeeb9aec2723af7" + integrity sha512-56ZrkTDbdTLmBIyfFYesgOxsjcLnwAKoN4CiPyTVkMQj3zTUh0QAx3GbvIvLpFEOvQWu92yyWICxB0u7wkVbYA== + dependencies: + backo2 "1.0.2" + base64-arraybuffer "0.1.5" + component-bind "1.0.0" + component-emitter "1.2.1" + debug "~3.1.0" + engine.io-client "~3.3.1" + has-binary2 "~1.0.2" + has-cors "1.1.0" + indexof "0.0.1" + object-component "0.0.3" + parseqs "0.0.5" + parseuri "0.0.5" + socket.io-parser "~3.3.0" + to-array "0.1.4" + socket.io-parser@~3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.2.0.tgz#e7c6228b6aa1f814e6148aea325b51aa9499e077" @@ -8990,6 +9032,15 @@ socket.io-parser@~3.2.0: debug "~3.1.0" isarray "2.0.1" +socket.io-parser@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.0.tgz#2b52a96a509fdf31440ba40fed6094c7d4f1262f" + integrity sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng== + dependencies: + component-emitter "1.2.1" + debug "~3.1.0" + isarray "2.0.1" + socket.io@2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.1.1.tgz#a069c5feabee3e6b214a75b40ce0652e1cfb9980" @@ -10671,7 +10722,7 @@ ws@^5.2.0: dependencies: async-limiter "~1.0.0" -ws@^6.0.0: +ws@^6.0.0, ws@~6.1.0: version "6.1.2" resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.2.tgz#3cc7462e98792f0ac679424148903ded3b9c3ad8" integrity sha512-rfUqzvz0WxmSXtJpPMX2EeASXabOrSMk1ruMOV3JBTBjo4ac2lDjGGsbQSyxj8Odhw5fBib8ZKEjDNvgouNKYw== diff --git a/scripts/clean/server/test.sh b/scripts/clean/server/test.sh index b897c30ba..75ad491bf 100755 --- a/scripts/clean/server/test.sh +++ b/scripts/clean/server/test.sh @@ -13,7 +13,7 @@ recreateDB () { } removeFiles () { - rm -rf "./test$1" "./config/local-test.json" "./config/local-test-$1.json" + rm -rf "./test$1" "./config/local-test-$1.json" } dropRedis () { diff --git a/server/controllers/api/users/my-notifications.ts b/server/controllers/api/users/my-notifications.ts index d74d26add..76cf97587 100644 --- a/server/controllers/api/users/my-notifications.ts +++ b/server/controllers/api/users/my-notifications.ts @@ -45,6 +45,11 @@ myNotificationsRouter.post('/me/notifications/read', asyncMiddleware(markAsReadUserNotifications) ) +myNotificationsRouter.post('/me/notifications/read-all', + authenticate, + asyncMiddleware(markAsReadAllUserNotifications) +) + export { myNotificationsRouter } @@ -70,7 +75,7 @@ async function updateNotificationSettings (req: express.Request, res: express.Re myVideoImportFinished: body.myVideoImportFinished, newFollow: body.newFollow, newUserRegistration: body.newUserRegistration, - commentMention: body.commentMention, + commentMention: body.commentMention } await UserNotificationSettingModel.update(values, query) @@ -93,3 +98,11 @@ async function markAsReadUserNotifications (req: express.Request, res: express.R return res.status(204).end() } + +async function markAsReadAllUserNotifications (req: express.Request, res: express.Response) { + const user: UserModel = res.locals.oauth.token.User + + await UserNotificationModel.markAllAsRead(user.id) + + return res.status(204).end() +} diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts index a093e3e1b..b6f0ebe6f 100644 --- a/server/helpers/custom-validators/misc.ts +++ b/server/helpers/custom-validators/misc.ts @@ -9,8 +9,8 @@ function isArray (value: any) { return Array.isArray(value) } -function isIntArray (value: any) { - return Array.isArray(value) && value.every(v => validator.isInt('' + v)) +function isNotEmptyIntArray (value: any) { + return Array.isArray(value) && value.every(v => validator.isInt('' + v)) && value.length !== 0 } function isDateValid (value: string) { @@ -82,7 +82,7 @@ function isFileValid ( export { exists, - isIntArray, + isNotEmptyIntArray, isArray, isIdValid, isUUIDValid, diff --git a/server/helpers/custom-validators/user-notifications.ts b/server/helpers/custom-validators/user-notifications.ts index 4fb5d922d..02ea3bbc2 100644 --- a/server/helpers/custom-validators/user-notifications.ts +++ b/server/helpers/custom-validators/user-notifications.ts @@ -9,8 +9,12 @@ function isUserNotificationTypeValid (value: any) { function isUserNotificationSettingValid (value: any) { return exists(value) && - validator.isInt('' + value) && - UserNotificationSettingValue[ value ] !== undefined + validator.isInt('' + value) && ( + value === UserNotificationSettingValue.NONE || + value === UserNotificationSettingValue.WEB || + value === UserNotificationSettingValue.EMAIL || + value === (UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL) + ) } export { diff --git a/server/initializers/migrations/0315-user-notifications.ts b/server/initializers/migrations/0315-user-notifications.ts index 34f9fd193..8284c58a0 100644 --- a/server/initializers/migrations/0315-user-notifications.ts +++ b/server/initializers/migrations/0315-user-notifications.ts @@ -31,7 +31,7 @@ PRIMARY KEY ("id")) '("newVideoFromSubscription", "newCommentOnMyVideo", "videoAbuseAsModerator", "blacklistOnMyVideo", ' + '"myVideoPublished", "myVideoImportFinished", "newUserRegistration", "newFollow", "commentMention", ' + '"userId", "createdAt", "updatedAt") ' + - '(SELECT 2, 2, 4, 4, 2, 2, 2, 2, 2, id, NOW(), NOW() FROM "user")' + '(SELECT 1, 1, 3, 3, 1, 1, 1, 1, 1, id, NOW(), NOW() FROM "user")' await utils.sequelize.query(query) } diff --git a/server/lib/notifier.ts b/server/lib/notifier.ts index 2c51d7101..d1b331346 100644 --- a/server/lib/notifier.ts +++ b/server/lib/notifier.ts @@ -436,11 +436,11 @@ class Notifier { private isEmailEnabled (user: UserModel, value: UserNotificationSettingValue) { if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION === true && user.emailVerified !== true) return false - return value === UserNotificationSettingValue.EMAIL || value === UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL + return value & UserNotificationSettingValue.EMAIL } private isWebNotificationEnabled (value: UserNotificationSettingValue) { - return value === UserNotificationSettingValue.WEB_NOTIFICATION || value === UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL + return value & UserNotificationSettingValue.WEB } static get Instance () { diff --git a/server/lib/user.ts b/server/lib/user.ts index 9e24e85a0..a39ef6c3d 100644 --- a/server/lib/user.ts +++ b/server/lib/user.ts @@ -98,15 +98,15 @@ export { function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Transaction | undefined) { const values: UserNotificationSetting & { userId: number } = { userId: user.id, - newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION, - newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION, - myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION, - myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION, - videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, - blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, - newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION, - commentMention: UserNotificationSettingValue.WEB_NOTIFICATION, - newFollow: UserNotificationSettingValue.WEB_NOTIFICATION + newVideoFromSubscription: UserNotificationSettingValue.WEB, + newCommentOnMyVideo: UserNotificationSettingValue.WEB, + myVideoImportFinished: UserNotificationSettingValue.WEB, + myVideoPublished: UserNotificationSettingValue.WEB, + videoAbuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newUserRegistration: UserNotificationSettingValue.WEB, + commentMention: UserNotificationSettingValue.WEB, + newFollow: UserNotificationSettingValue.WEB } return UserNotificationSettingModel.create(values, { transaction: t }) diff --git a/server/middlewares/validators/user-notifications.ts b/server/middlewares/validators/user-notifications.ts index 1c31f0a73..46486e081 100644 --- a/server/middlewares/validators/user-notifications.ts +++ b/server/middlewares/validators/user-notifications.ts @@ -4,7 +4,7 @@ import { body, query } from 'express-validator/check' import { logger } from '../../helpers/logger' import { areValidationErrors } from './utils' import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications' -import { isIntArray } from '../../helpers/custom-validators/misc' +import { isNotEmptyIntArray } from '../../helpers/custom-validators/misc' const listUserNotificationsValidator = [ query('unread') @@ -42,7 +42,8 @@ const updateNotificationSettingsValidator = [ const markAsReadUserNotificationsValidator = [ body('ids') - .custom(isIntArray).withMessage('Should have a valid notification ids to mark as read'), + .optional() + .custom(isNotEmptyIntArray).withMessage('Should have a valid notification ids to mark as read'), (req: express.Request, res: express.Response, next: express.NextFunction) => { logger.debug('Checking markAsReadUserNotificationsValidator parameters', { parameters: req.body }) diff --git a/server/models/account/user-notification.ts b/server/models/account/user-notification.ts index 79afce600..9e4f982a3 100644 --- a/server/models/account/user-notification.ts +++ b/server/models/account/user-notification.ts @@ -290,6 +290,12 @@ export class UserNotificationModel extends Model { return UserNotificationModel.update({ read: true }, query) } + static markAllAsRead (userId: number) { + const query = { where: { userId } } + + return UserNotificationModel.update({ read: true }, query) + } + toFormattedJSON (): UserNotification { const video = this.Video ? Object.assign(this.formatVideo(this.Video), { channel: { diff --git a/server/tests/api/check-params/user-notifications.ts b/server/tests/api/check-params/user-notifications.ts index 635f5c9a3..714f481e9 100644 --- a/server/tests/api/check-params/user-notifications.ts +++ b/server/tests/api/check-params/user-notifications.ts @@ -96,6 +96,16 @@ describe('Test user notifications API validators', function () { statusCodeExpected: 400 }) + await makePostBodyRequest({ + url: server.url, + path, + fields: { + ids: [ ] + }, + token: server.accessToken, + statusCodeExpected: 400 + }) + await makePostBodyRequest({ url: server.url, path, @@ -131,18 +141,39 @@ describe('Test user notifications API validators', function () { }) }) + describe('When marking as read my notifications', function () { + const path = '/api/v1/users/me/notifications/read-all' + + it('Should fail with a non authenticated user', async function () { + await makePostBodyRequest({ + url: server.url, + path, + statusCodeExpected: 401 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + statusCodeExpected: 204 + }) + }) + }) + describe('When updating my notification settings', function () { const path = '/api/v1/users/me/notification-settings' const correctFields: UserNotificationSetting = { - newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION, - newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION, - videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION, - blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION, - myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION, - myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION, - commentMention: UserNotificationSettingValue.WEB_NOTIFICATION, - newFollow: UserNotificationSettingValue.WEB_NOTIFICATION, - newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION + newVideoFromSubscription: UserNotificationSettingValue.WEB, + newCommentOnMyVideo: UserNotificationSettingValue.WEB, + videoAbuseAsModerator: UserNotificationSettingValue.WEB, + blacklistOnMyVideo: UserNotificationSettingValue.WEB, + myVideoImportFinished: UserNotificationSettingValue.WEB, + myVideoPublished: UserNotificationSettingValue.WEB, + commentMention: UserNotificationSettingValue.WEB, + newFollow: UserNotificationSettingValue.WEB, + newUserRegistration: UserNotificationSettingValue.WEB } it('Should fail with missing fields', async function () { @@ -150,7 +181,7 @@ describe('Test user notifications API validators', function () { url: server.url, path, token: server.accessToken, - fields: { newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION }, + fields: { newVideoFromSubscription: UserNotificationSettingValue.WEB }, statusCodeExpected: 400 }) }) diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts index f8044cbd4..a3e8e2e9c 100644 --- a/server/tests/api/check-params/users.ts +++ b/server/tests/api/check-params/users.ts @@ -485,11 +485,10 @@ describe('Test users API validators', function () { email: 'email@example.com', emailVerified: true, videoQuota: 42, - role: UserRole.MODERATOR + role: UserRole.USER } await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields, statusCodeExpected: 204 }) - userAccessToken = await userLogin(server, user) }) }) diff --git a/server/tests/api/users/user-notifications.ts b/server/tests/api/users/user-notifications.ts index ae77b4db2..ad68d8e69 100644 --- a/server/tests/api/users/user-notifications.ts +++ b/server/tests/api/users/user-notifications.ts @@ -37,7 +37,8 @@ import { getLastNotification, getUserNotifications, markAsReadNotifications, - updateMyNotificationSettings + updateMyNotificationSettings, + markAsReadAllNotifications } from '../../../../shared/utils/users/user-notifications' import { User, @@ -88,15 +89,15 @@ describe('Test users notifications', function () { let channelId: number const allNotificationSettings: UserNotificationSetting = { - newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, - newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, - videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, - blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, - myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, - myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, - commentMention: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, - newFollow: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, - newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL + newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newCommentOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + videoAbuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + myVideoImportFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + myVideoPublished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + commentMention: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newFollow: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newUserRegistration: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL } before(async function () { @@ -174,7 +175,10 @@ describe('Test users notifications', function () { }) it('Should send a new video notification if the user follows the local video publisher', async function () { + this.timeout(10000) + await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9001') + await waitJobs(servers) const { name, uuid } = await uploadVideoByLocalAccount(servers) await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence') @@ -184,6 +188,7 @@ describe('Test users notifications', function () { this.timeout(50000) // Server 2 has transcoding enabled await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9002') + await waitJobs(servers) const { name, uuid } = await uploadVideoByRemoteAccount(servers) await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence') @@ -822,8 +827,9 @@ describe('Test users notifications', function () { }) it('Should notify when a local channel is following one of our channel', async function () { - await addUserSubscription(servers[0].url, servers[0].accessToken, 'user_1_channel@localhost:9001') + this.timeout(10000) + await addUserSubscription(servers[0].url, servers[0].accessToken, 'user_1_channel@localhost:9001') await waitJobs(servers) await checkNewActorFollow(baseParams, 'channel', 'root', 'super root name', myChannelName, 'presence') @@ -832,8 +838,9 @@ describe('Test users notifications', function () { }) it('Should notify when a remote channel is following one of our channel', async function () { - await addUserSubscription(servers[1].url, servers[1].accessToken, 'user_1_channel@localhost:9001') + this.timeout(10000) + await addUserSubscription(servers[1].url, servers[1].accessToken, 'user_1_channel@localhost:9001') await waitJobs(servers) await checkNewActorFollow(baseParams, 'channel', 'root', 'super root 2 name', myChannelName, 'presence') @@ -895,6 +902,15 @@ describe('Test users notifications', function () { expect(notification.read).to.be.false } }) + + it('Should mark as read all notifications', async function () { + await markAsReadAllNotifications(servers[ 0 ].url, userAccessToken) + + const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 0, 10, true) + + expect(res.body.total).to.equal(0) + expect(res.body.data).to.have.lengthOf(0) + }) }) describe('Notification settings', function () { @@ -928,13 +944,13 @@ describe('Test users notifications', function () { it('Should only have web notifications', async function () { await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, { - newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION + newVideoFromSubscription: UserNotificationSettingValue.WEB })) { const res = await getMyUserInformation(servers[0].url, userAccessToken) const info = res.body as User - expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB_NOTIFICATION) + expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB) } const { name, uuid } = await uploadVideoByLocalAccount(servers) @@ -976,13 +992,15 @@ describe('Test users notifications', function () { it('Should have email and web notifications', async function () { await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, { - newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL + newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL })) { const res = await getMyUserInformation(servers[0].url, userAccessToken) const info = res.body as User - expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL) + expect(info.notificationSettings.newVideoFromSubscription).to.equal( + UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL + ) } const { name, uuid } = await uploadVideoByLocalAccount(servers) diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts index 4914c8ed5..ad98ab1c7 100644 --- a/server/tests/api/users/users.ts +++ b/server/tests/api/users/users.ts @@ -501,10 +501,6 @@ describe('Test users', function () { accessTokenUser = await userLogin(server, user) }) - it('Should not be able to delete a user by a moderator', async function () { - await removeUser(server.url, 2, accessTokenUser, 403) - }) - it('Should be able to list video blacklist by a moderator', async function () { await getBlacklistedVideosList(server.url, accessTokenUser) }) diff --git a/shared/models/users/user-notification-setting.model.ts b/shared/models/users/user-notification-setting.model.ts index f580e827e..531e12bba 100644 --- a/shared/models/users/user-notification-setting.model.ts +++ b/shared/models/users/user-notification-setting.model.ts @@ -1,8 +1,7 @@ export enum UserNotificationSettingValue { - NONE = 1, - WEB_NOTIFICATION = 2, - EMAIL = 3, - WEB_NOTIFICATION_AND_EMAIL = 4 + NONE = 0, + WEB = 1 << 0, + EMAIL = 1 << 1 } export interface UserNotificationSetting { diff --git a/shared/models/users/user-notification.model.ts b/shared/models/users/user-notification.model.ts index 9dd4f099f..f41b6f534 100644 --- a/shared/models/users/user-notification.model.ts +++ b/shared/models/users/user-notification.model.ts @@ -2,11 +2,15 @@ export enum UserNotificationType { NEW_VIDEO_FROM_SUBSCRIPTION = 1, NEW_COMMENT_ON_MY_VIDEO = 2, NEW_VIDEO_ABUSE_FOR_MODERATORS = 3, + BLACKLIST_ON_MY_VIDEO = 4, UNBLACKLIST_ON_MY_VIDEO = 5, + MY_VIDEO_PUBLISHED = 6, + MY_VIDEO_IMPORT_SUCCESS = 7, MY_VIDEO_IMPORT_ERROR = 8, + NEW_USER_REGISTRATION = 9, NEW_FOLLOW = 10, COMMENT_MENTION = 11 diff --git a/shared/utils/server/jobs.ts b/shared/utils/server/jobs.ts index 6218c0b66..692b5e24d 100644 --- a/shared/utils/server/jobs.ts +++ b/shared/utils/server/jobs.ts @@ -29,6 +29,7 @@ function getJobsListPaginationAndSort (url: string, accessToken: string, state: } async function waitJobs (serversArg: ServerInfo[] | ServerInfo) { + const pendingJobWait = process.env.NODE_PENDING_JOB_WAIT ? parseInt(process.env.NODE_PENDING_JOB_WAIT, 10) : 2000 let servers: ServerInfo[] if (Array.isArray(serversArg) === false) servers = [ serversArg as ServerInfo ] @@ -62,7 +63,7 @@ async function waitJobs (serversArg: ServerInfo[] | ServerInfo) { // Retry, in case of new jobs were created if (pendingRequests === false) { - await wait(2000) + await wait(pendingJobWait) await Promise.all(tasksBuilder()) } diff --git a/shared/utils/users/user-notifications.ts b/shared/utils/users/user-notifications.ts index 1222899e7..bcbe29fc7 100644 --- a/shared/utils/users/user-notifications.ts +++ b/shared/utils/users/user-notifications.ts @@ -54,6 +54,16 @@ function markAsReadNotifications (url: string, token: string, ids: number[], sta statusCodeExpected }) } +function markAsReadAllNotifications (url: string, token: string, statusCodeExpected = 204) { + const path = '/api/v1/users/me/notifications/read-all' + + return makePostBodyRequest({ + url, + path, + token, + statusCodeExpected + }) +} async function getLastNotification (serverUrl: string, accessToken: string) { const res = await getUserNotifications(serverUrl, accessToken, 0, 1, undefined, '-createdAt') @@ -409,6 +419,7 @@ export { CheckerBaseParams, CheckerType, checkNotification, + markAsReadAllNotifications, checkMyVideoImportIsFinished, checkUserRegistered, checkVideoIsPublished,