Browse Source

Add notifications in the client

Chocobozzz 5 years ago
parent
commit
2f1548fda3
56 changed files with 1073 additions and 112 deletions
  1. 7 7
      .travis.yml
  2. 2 0
      client/package.json
  3. 7 0
      client/src/app/+my-account/my-account-notifications/my-account-notifications.component.html
  4. 23 0
      client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss
  5. 14 0
      client/src/app/+my-account/my-account-notifications/my-account-notifications.component.ts
  6. 10 0
      client/src/app/+my-account/my-account-routing.module.ts
  7. 1 0
      client/src/app/+my-account/my-account-settings/my-account-notification-preferences/index.ts
  8. 19 0
      client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html
  9. 25 0
      client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss
  10. 99 0
      client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
  11. 4 1
      client/src/app/+my-account/my-account-settings/my-account-settings.component.html
  12. 4 0
      client/src/app/+my-account/my-account.component.ts
  13. 5 1
      client/src/app/+my-account/my-account.module.ts
  14. 1 1
      client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts
  15. 1 1
      client/src/app/+video-channels/video-channels-routing.module.ts
  16. 2 2
      client/src/app/+video-channels/video-channels.component.ts
  17. 2 2
      client/src/app/app.module.ts
  18. 23 0
      client/src/app/menu/avatar-notification.component.html
  19. 82 0
      client/src/app/menu/avatar-notification.component.scss
  20. 64 0
      client/src/app/menu/avatar-notification.component.ts
  21. 2 0
      client/src/app/menu/index.ts
  22. 2 4
      client/src/app/menu/menu.component.html
  23. 0 7
      client/src/app/menu/menu.component.scss
  24. 1 0
      client/src/app/shared/misc/help.component.html
  25. 12 10
      client/src/app/shared/misc/help.component.scss
  26. 11 0
      client/src/app/shared/rest/component-pagination.model.ts
  27. 1 0
      client/src/app/shared/rest/rest-extractor.service.ts
  28. 7 1
      client/src/app/shared/shared.module.ts
  29. 1 0
      client/src/app/shared/users/index.ts
  30. 153 0
      client/src/app/shared/users/user-notification.model.ts
  31. 110 0
      client/src/app/shared/users/user-notification.service.ts
  32. 61 0
      client/src/app/shared/users/user-notifications.component.html
  33. 30 0
      client/src/app/shared/users/user-notifications.component.scss
  34. 82 0
      client/src/app/shared/users/user-notifications.component.ts
  35. 5 1
      client/src/app/shared/users/user.model.ts
  36. 2 13
      client/src/app/videos/+video-watch/comment/video-comments.component.ts
  37. 2 1
      client/src/sass/include/_bootstrap-variables.scss
  38. 3 1
      client/src/sass/primeng-custom.scss
  39. 52 1
      client/yarn.lock
  40. 1 1
      scripts/clean/server/test.sh
  41. 14 1
      server/controllers/api/users/my-notifications.ts
  42. 3 3
      server/helpers/custom-validators/misc.ts
  43. 6 2
      server/helpers/custom-validators/user-notifications.ts
  44. 1 1
      server/initializers/migrations/0315-user-notifications.ts
  45. 2 2
      server/lib/notifier.ts
  46. 9 9
      server/lib/user.ts
  47. 3 2
      server/middlewares/validators/user-notifications.ts
  48. 6 0
      server/models/account/user-notification.ts
  49. 41 10
      server/tests/api/check-params/user-notifications.ts
  50. 1 2
      server/tests/api/check-params/users.ts
  51. 34 16
      server/tests/api/users/user-notifications.ts
  52. 0 4
      server/tests/api/users/users.ts
  53. 3 4
      shared/models/users/user-notification-setting.model.ts
  54. 4 0
      shared/models/users/user-notification.model.ts
  55. 2 1
      shared/utils/server/jobs.ts
  56. 11 0
      shared/utils/users/user-notifications.ts

+ 7 - 7
.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

+ 2 - 0
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",

+ 7 - 0
client/src/app/+my-account/my-account-notifications/my-account-notifications.component.html

@@ -0,0 +1,7 @@
+<div class="header">
+  <a routerLink="/my-account/settings" i18n>Notification preferences</a>
+
+  <button (click)="markAllAsRead()" i18n>Mark all as read</button>
+</div>
+
+<my-user-notifications #userNotification></my-user-notifications>

+ 23 - 0
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;
+}

+ 14 - 0
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()
+  }
+}

+ 10 - 0
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'
+          }
+        }
       }
     ]
   }

+ 1 - 0
client/src/app/+my-account/my-account-settings/my-account-notification-preferences/index.ts

@@ -0,0 +1 @@
+export * from './my-account-notification-preferences.component'

+ 19 - 0
client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html

@@ -0,0 +1,19 @@
+<div class="custom-row">
+  <div i18n>Activities</div>
+  <div i18n>Web</div>
+  <div i18n *ngIf="emailEnabled">Email</div>
+</div>
+
+<div class="custom-row" *ngFor="let notificationType of notificationSettingKeys">
+  <ng-container *ngIf="hasUserRight(notificationType)">
+    <div>{{ labelNotifications[notificationType] }}</div>
+
+    <div>
+      <p-inputSwitch [(ngModel)]="webNotifications[notificationType]" (onChange)="updateWebSetting(notificationType, $event.checked)"></p-inputSwitch>
+    </div>
+
+    <div *ngIf="emailEnabled">
+      <p-inputSwitch [(ngModel)]="emailNotifications[notificationType]" (onChange)="updateEmailSetting(notificationType, $event.checked)"></p-inputSwitch>
+    </div>
+  </ng-container>
+</div>

+ 25 - 0
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
+  }
+}
+

+ 99 - 0
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<any>
+
+  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<UserNotificationSetting> ]: 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
+    }
+  }
+}

+ 4 - 1
client/src/app/+my-account/my-account-settings/my-account-settings.component.html

@@ -9,6 +9,9 @@
   <my-account-profile [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-profile>
 </ng-template>
 
+<div i18n class="account-title" id="notifications">Notifications</div>
+<my-account-notification-preferences [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-notification-preferences>
+
 <div i18n class="account-title">Password</div>
 <my-account-change-password></my-account-change-password>
 
@@ -16,4 +19,4 @@
 <my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings>
 
 <div i18n class="account-title">Danger zone</div>
-<my-account-danger-zone [user]="user"></my-account-danger-zone>
+<my-account-danger-zone [user]="user"></my-account-danger-zone>

+ 4 - 0
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
     ]

+ 5 - 1
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: [

+ 1 - 1
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()

+ 1 - 1
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: [

+ 2 - 2
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)

+ 2 - 2
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: [

+ 23 - 0
client/src/app/menu/avatar-notification.component.html

@@ -0,0 +1,23 @@
+<div
+  [ngbPopover]="popContent" autoClose="outside" placement="bottom-left" container="body" popoverClass="popover-notifications"
+  i18n-title title="View your notifications" class="notification-avatar" #popover="ngbPopover"
+>
+  <div *ngIf="unreadNotifications > 0" class="unread-notifications">{{ unreadNotifications }}</div>
+
+  <img [src]="user.accountAvatarUrl" alt="Avatar" />
+</div>
+
+<ng-template #popContent>
+  <div class="notifications-header">
+    <div i18n>Notifications</div>
+
+    <a
+      i18n-title title="Update your notification preferences" class="glyphicon glyphicon-cog"
+      routerLink="/my-account/settings" fragment="notifications"
+    ></a>
+  </div>
+
+  <my-user-notifications [ignoreLoadingBar]="true" [infiniteScroll]="false"></my-user-notifications>
+
+  <a class="all-notifications" routerLink="/my-account/notifications" i18n>See all your notifications</a>
+</ng-template>

+ 82 - 0
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;
+  }
+}

+ 64 - 0
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
+                               })
+  }
+
+}

+ 2 - 0
client/src/app/menu/index.ts

@@ -1 +1,3 @@
+export * from './language-chooser.component'
+export * from './avatar-notification.component'
 export * from './menu.component'

+ 2 - 4
client/src/app/menu/menu.component.html

@@ -2,9 +2,7 @@
   <menu>
     <div class="top-menu">
       <div *ngIf="isLoggedIn" class="logged-in-block">
-        <a routerLink="/my-account/settings">
-          <img [src]="user.accountAvatarUrl" alt="Avatar" />
-        </a>
+        <my-avatar-notification [user]="user"></my-avatar-notification>
 
         <div class="logged-in-info">
           <a routerLink="/my-account/settings" class="logged-in-username">{{ user.account?.displayName }}</a>
@@ -97,4 +95,4 @@
   </menu>
 </div>
 
-<my-language-chooser #languageChooserModal></my-language-chooser>
+<my-language-chooser #languageChooserModal></my-language-chooser>

+ 0 - 7
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;
 

+ 1 - 0
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"

+ 12 - 10
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;
+      }
     }
   }
 }

+ 11 - 0
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
+}

+ 1 - 0
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
     }
 

+ 7 - 1
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
   ]
 })

+ 1 - 0
client/src/app/shared/users/index.ts

@@ -1,2 +1,3 @@
 export * from './user.model'
 export * from './user.service'
+export * from './user-notifications.component'

+ 153 - 0
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
+  }
+
+}

+ 110 - 0
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<ResultList<UserNotification>>(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)
+  }
+}

+ 61 - 0
client/src/app/shared/users/user-notifications.component.html

@@ -0,0 +1,61 @@
+<div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div>
+
+<div class="notifications" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()">
+  <div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }">
+
+    <div [ngSwitch]="notification.type">
+      <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION">
+        {{ notification.video.channel.displayName }} published a <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">new video</a>
+      </ng-container>
+
+      <ng-container i18n *ngSwitchCase="UserNotificationType.UNBLACKLIST_ON_MY_VIDEO">
+        Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been unblacklisted
+      </ng-container>
+
+      <ng-container i18n *ngSwitchCase="UserNotificationType.BLACKLIST_ON_MY_VIDEO">
+        Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoBlacklist.video.name }}</a> has been blacklisted
+      </ng-container>
+
+      <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS">
+        <a (click)="markAsRead(notification)" [routerLink]="notification.videoAbuseUrl">A new video abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoAbuse.video.name }}</a>
+      </ng-container>
+
+      <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_COMMENT_ON_MY_VIDEO">
+        {{ notification.comment.account.displayName }} commented your video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.comment.video.name }}</a>
+      </ng-container>
+
+      <ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_PUBLISHED">
+        Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been published
+      </ng-container>
+
+      <ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_SUCCESS">
+        <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">Your video import</a> {{ notification.videoImportIdentifier }} succeeded
+      </ng-container>
+
+      <ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_ERROR">
+        <a (click)="markAsRead(notification)" [routerLink]="notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} failed
+      </ng-container>
+
+      <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_USER_REGISTRATION">
+        User <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.account.name }} registered</a> on your instance
+      </ng-container>
+
+      <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_FOLLOW">
+        <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.actorFollow.follower.displayName }}</a> is following
+
+        <ng-container *ngIf="notification.actorFollow.following.type === 'channel'">
+          your channel {{ notification.actorFollow.following.displayName }}
+        </ng-container>
+        <ng-container *ngIf="notification.actorFollow.following.type === 'account'">your account</ng-container>
+      </ng-container>
+
+      <ng-container i18n *ngSwitchCase="UserNotificationType.COMMENT_MENTION">
+        {{ notification.comment.account.displayName }} mentioned you on <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">video {{ notification.comment.video.name }}</a>
+      </ng-container>
+    </div>
+
+    <div i18n title="Mark as read" class="mark-as-read">
+      <div class="glyphicon glyphicon-ok" (click)="markAsRead(notification)"></div>
+    </div>
+  </div>
+</div>

+ 30 - 0
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);
+      }
+    }
+  }
+}

+ 82 - 0
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)
+        )
+  }
+}

+ 5 - 1
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<UserServerModel>) {
     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)
     }

+ 2 - 13
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) {

+ 2 - 1
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;
+$zindex-dropdown: 10000;
+$zindex-popover: 10000;

+ 3 - 1
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;
       }
     }

+ 52 - 1
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==

+ 1 - 1
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 () {

+ 14 - 1
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()
+}

+ 3 - 3
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,

+ 6 - 2
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 {

+ 1 - 1
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)
   }

+ 2 - 2
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 () {

+ 9 - 9
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 })

+ 3 - 2
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 })

+ 6 - 0
server/models/account/user-notification.ts

@@ -290,6 +290,12 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
     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: {

+ 41 - 10
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
       })
     })

+ 1 - 2
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)
     })
   })
 

+ 34 - 16
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)

+ 0 - 4
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)
   })

+ 3 - 4
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 {

+ 4 - 0
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

+ 2 - 1
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())
     }
 

+ 11 - 0
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,