Add notifications in the client
authorChocobozzz <me@florianbigard.com>
Tue, 8 Jan 2019 10:26:41 +0000 (11:26 +0100)
committerChocobozzz <chocobozzz@cpy.re>
Wed, 9 Jan 2019 10:15:15 +0000 (11:15 +0100)
56 files changed:
.travis.yml
client/package.json
client/src/app/+my-account/my-account-notifications/my-account-notifications.component.html [new file with mode: 0644]
client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss [new file with mode: 0644]
client/src/app/+my-account/my-account-notifications/my-account-notifications.component.ts [new file with mode: 0644]
client/src/app/+my-account/my-account-routing.module.ts
client/src/app/+my-account/my-account-settings/my-account-notification-preferences/index.ts [new file with mode: 0644]
client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html [new file with mode: 0644]
client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss [new file with mode: 0644]
client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts [new file with mode: 0644]
client/src/app/+my-account/my-account-settings/my-account-settings.component.html
client/src/app/+my-account/my-account.component.ts
client/src/app/+my-account/my-account.module.ts
client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts
client/src/app/+video-channels/video-channels-routing.module.ts
client/src/app/+video-channels/video-channels.component.ts
client/src/app/app.module.ts
client/src/app/menu/avatar-notification.component.html [new file with mode: 0644]
client/src/app/menu/avatar-notification.component.scss [new file with mode: 0644]
client/src/app/menu/avatar-notification.component.ts [new file with mode: 0644]
client/src/app/menu/index.ts
client/src/app/menu/menu.component.html
client/src/app/menu/menu.component.scss
client/src/app/shared/misc/help.component.html
client/src/app/shared/misc/help.component.scss
client/src/app/shared/rest/component-pagination.model.ts
client/src/app/shared/rest/rest-extractor.service.ts
client/src/app/shared/shared.module.ts
client/src/app/shared/users/index.ts
client/src/app/shared/users/user-notification.model.ts [new file with mode: 0644]
client/src/app/shared/users/user-notification.service.ts [new file with mode: 0644]
client/src/app/shared/users/user-notifications.component.html [new file with mode: 0644]
client/src/app/shared/users/user-notifications.component.scss [new file with mode: 0644]
client/src/app/shared/users/user-notifications.component.ts [new file with mode: 0644]
client/src/app/shared/users/user.model.ts
client/src/app/videos/+video-watch/comment/video-comments.component.ts
client/src/sass/include/_bootstrap-variables.scss
client/src/sass/primeng-custom.scss
client/yarn.lock
scripts/clean/server/test.sh
server/controllers/api/users/my-notifications.ts
server/helpers/custom-validators/misc.ts
server/helpers/custom-validators/user-notifications.ts
server/initializers/migrations/0315-user-notifications.ts
server/lib/notifier.ts
server/lib/user.ts
server/middlewares/validators/user-notifications.ts
server/models/account/user-notification.ts
server/tests/api/check-params/user-notifications.ts
server/tests/api/check-params/users.ts
server/tests/api/users/user-notifications.ts
server/tests/api/users/users.ts
shared/models/users/user-notification-setting.model.ts
shared/models/users/user-notification.model.ts
shared/utils/server/jobs.ts
shared/utils/users/user-notifications.ts

index 3a73e4fc01da64a4ebee8d36a4730d308f6d772c..d252ae6252b80ab99896ee83147d1cf32599fd45 100644 (file)
@@ -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
index 81422f05feeb94caeb1b16e8fb114628937e02b8..5fe1f3d5f6fe5f58307297f26632075d9a2e76b7 100644 (file)
@@ -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",
     "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 (file)
index 0000000..d2810c3
--- /dev/null
@@ -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>
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 (file)
index 0000000..86ac094
--- /dev/null
@@ -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 (file)
index 0000000..3e19708
--- /dev/null
@@ -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()
+  }
+}
index a2cbeaffc2b9e24719b4f692127eb2a1a125f54e..9996218ca1032cd586da3f58b625a79aaccf7ea4 100644 (file)
@@ -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 (file)
index 0000000..5e1d513
--- /dev/null
@@ -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 (file)
index 0000000..59422d6
--- /dev/null
@@ -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>
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 (file)
index 0000000..6feb16a
--- /dev/null
@@ -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 (file)
index 0000000..519bdfa
--- /dev/null
@@ -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
+    }
+  }
+}
index c7e23cd1ff79453b42a1bebb97540cba2fbf0f82..2eb7dd56e54792c7258a530caafcfa79028544d9 100644 (file)
@@ -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>
\ No newline at end of file
+<my-account-danger-zone [user]="user"></my-account-danger-zone>
index 1bac9547dbd61689a0b199b331b8d1e3c8e2ac03..8a4102d806725ca2349775b11c39e52b6322bd02 100644 (file)
@@ -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
     ]
index 80d9f0cf7312d82171b4a4204e45707b91170f3a..18f51f171391c4d7ba6e5219ff89b2ac363f6df7 100644 (file)
@@ -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: [
index 70c4374e01d132cc39ddfea237b9bbf1ebdcbc4f..dea378a6e6a638c4e05e86e50318fc46f2d81a8b 100644 (file)
@@ -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()
index 935578d2a4ff20486d9589c23b09a8405b973403..3ac3533d9e41f9941b5d4249e68f53f0bc3d4196 100644 (file)
@@ -7,7 +7,7 @@ import { VideoChannelAboutComponent } from './video-channel-about/video-channel-
 
 const videoChannelsRoutes: Routes = [
   {
-    path: ':videoChannelId',
+    path: ':videoChannelName',
     component: VideoChannelsComponent,
     canActivateChild: [ MetaGuard ],
     children: [
index 0c5c814c74d9dbd6065e93b924c1d056bca5ca2f..41ff82e98d6febc285dd3ab284febb0a734ba4ea 100644 (file)
@@ -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)
index 37119944227d427107d979862c023594f84fac76..0bbc2e08b18ead9f25c12ea0a1554c79b4362d9a 100644 (file)
@@ -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 (file)
index 0000000..2f0b7c6
--- /dev/null
@@ -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>
diff --git a/client/src/app/menu/avatar-notification.component.scss b/client/src/app/menu/avatar-notification.component.scss
new file mode 100644 (file)
index 0000000..c866674
--- /dev/null
@@ -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 (file)
index 0000000..60e0907
--- /dev/null
@@ -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
+                               })
+  }
+
+}
index 421271c12982e4a352397c7767afc40e9d46389d..39dbde750c714c636a84c53a02363c93ccd13665 100644 (file)
@@ -1 +1,3 @@
+export * from './language-chooser.component'
+export * from './avatar-notification.component'
 export * from './menu.component'
index e04bdf3d644bdbcb1958b2540f2868b797f6599f..aa5bfa9c9b42e8e0310dca68452a260034c04491 100644 (file)
@@ -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>
\ No newline at end of file
+<my-language-chooser #languageChooserModal></my-language-chooser>
index b271ebfd28b6b0b36c1176fb731a8b38ed23f0b8..a4aaadc7fe8576b7847ba42228d5981969e0d5c4 100644 (file)
@@ -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;
 
index 28ccb1e26170fc1c78fc04067dbb2455810ef462..08a2fc3672fe41e7a1edf0e1a0bdc06ff768cada 100644 (file)
@@ -18,6 +18,7 @@
   container="body"
   title="Get help"
   i18n-title
+  popoverClass="help-popover"
   [attr.aria-pressed]="isPopoverOpened"
   [ngbPopover]="tooltipTemplate"
   [placement]="tooltipPlacement"
index 5c73a80310de606871e4ac868b83b886f34f93d3..6a5c3b1fa5f5bc0fb1e39ca6e281cd2a2e16cb8f 100644 (file)
 }
 
 /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;
+      }
     }
   }
 }
index 0b8ecc318bd7c866dfaa9d547f39c9bd93f8b573..85160d44559c83ff9d539d55654c8424d5c160de 100644 (file)
@@ -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
+}
index f149569ef5028b2a5c27f6e92dc0bf432db957ba..e6518dd1d75ba22ed89e8892f8c7f028b7eef507 100644 (file)
@@ -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
     }
 
index 4a5d664db350cc4cf11d3be30a885d0956b3bca4..c99c87c00cd50a00229d35a99bcb129b1b39088c 100644 (file)
@@ -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
   ]
 })
index 7b5a67bc78513926957a988459da0da1a01df486..ebd715fb1ea7cbe9e59b57a67dc867fd6a367cf3 100644 (file)
@@ -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 (file)
index 0000000..5ff816f
--- /dev/null
@@ -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 (file)
index 0000000..2dfee80
--- /dev/null
@@ -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)
+  }
+}
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 (file)
index 0000000..86379d9
--- /dev/null
@@ -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>
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 (file)
index 0000000..0493b10
--- /dev/null
@@ -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 (file)
index 0000000..6821162
--- /dev/null
@@ -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)
+        )
+  }
+}
index 3663a7b610428c327d6864ae710ba1d75f5b8779..c15f1de8c5b24c1818abfcd9798bead76bf04d62 100644 (file)
@@ -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)
     }
index 957c17bbf3091781a90d462bc2bcfb96b1dedca3..dc62fe5aea9729d5c81beadcd6aedd982ebe45ac 100644 (file)
@@ -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) {
index 77a20cfe160a13e2da9bb2186d9f1fa5921e3e23..7f413836b56388cf32d93582f55c4bcfa5a4c749 100644 (file)
@@ -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;
index 05db2a2cb71ab668eb22cd9cfdd9a2c4749c319b..58a6a0004b9ddbcc9b674cbec67c3db62b69abe2 100644 (file)
@@ -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;
       }
     }
index 3c7ba2d252b49e437af65507c725789023a4744d..5ed43117aac4bd8fb2f64858e7732130e5ebebb1 100644 (file)
   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==
index b897c30baf8e34005477bd7fef835e399348dde9..75ad491bfd945a308f9238f92f8bdcb8ed38e24c 100755 (executable)
@@ -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 () {
index d74d26add62c4ea17817313334b0f835cbbfc90f..76cf9758794a28b4834f209877c79a8f47d5dec3 100644 (file)
@@ -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()
+}
index a093e3e1b8eaac1d2bfc7675c22f02cd22e922a4..b6f0ebe6f2478fd06138978efefa9f95995cd4ef 100644 (file)
@@ -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,
index 4fb5d922d61413097155f2bb1196d1b4778e357c..02ea3bbc24635d900abd1f5d4fe7a058446e0d54 100644 (file)
@@ -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 {
index 34f9fd193abf987cfbaa561c93875b539111da87..8284c58a0f7418d120774cd69f023c64bb3edd51 100644 (file)
@@ -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)
   }
index 2c51d71018e4b17f2a0507fa2bf5a6b746e5a591..d1b3313467ccb61e8f0d630826afa3c36b20707e 100644 (file)
@@ -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 () {
index 9e24e85a0826fb1016f32b272028c45d0cadb4c1..a39ef6c3d98c69c7d0edd7903d142180dcb486ff 100644 (file)
@@ -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 })
index 1c31f0a73b001b5330ae9ddbb635cc23b449d354..46486e081332a815edc0537ee6db4520d57fe09a 100644 (file)
@@ -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 })
index 79afce600ddd6a2cc7b3a7bd207c60ffa465ed2b..9e4f982a335aa4b735e36ac870212555519a8738 100644 (file)
@@ -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: {
index 635f5c9a3650ca62e779f0679e31a775e42c8fa1..714f481e9a2dba71659e96c44a7f1162640885f0 100644 (file)
@@ -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
       })
     })
index f8044cbd4c910c4686d8de1e2cdbc752c05409cf..a3e8e2e9c1b960dced92fe927add68075ec32a09 100644 (file)
@@ -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)
     })
   })
 
index ae77b4db221bd3087235b562787e2702e82f8012..ad68d8e69af142d12e300e4d76bd29cc1eba257a 100644 (file)
@@ -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)
index 4914c8ed560271d994cf9ab61961a0400f50c577..ad98ab1c7571dc531dafad11c4ba86b5387fd9a9 100644 (file)
@@ -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)
   })
index f580e827eefa9c66afd2245d763dfdd2c41a59b6..531e12bba3a3756160f51c39007dae6ac0e32884 100644 (file)
@@ -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 {
index 9dd4f099f26d5617ab0a223593b77c8913ff6290..f41b6f5343e7e11a727b387e95d5ec55f95fcec9 100644 (file)
@@ -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
index 6218c0b6628d4015ae51f021a13db38a384c8dd3..692b5e24d4b6f8eb01c3a7627cb1970321b58bb6 100644 (file)
@@ -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())
     }
 
index 1222899e769db92799677c1acd76b5a5f3bad188..bcbe29fc7d1e58b5e1a226a67b962ef6f294aa4f 100644 (file)
@@ -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,