aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.travis.yml14
-rw-r--r--client/package.json2
-rw-r--r--client/src/app/+my-account/my-account-notifications/my-account-notifications.component.html7
-rw-r--r--client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss23
-rw-r--r--client/src/app/+my-account/my-account-notifications/my-account-notifications.component.ts14
-rw-r--r--client/src/app/+my-account/my-account-routing.module.ts10
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-notification-preferences/index.ts1
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html19
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss25
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts99
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-settings.component.html5
-rw-r--r--client/src/app/+my-account/my-account.component.ts4
-rw-r--r--client/src/app/+my-account/my-account.module.ts6
-rw-r--r--client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts2
-rw-r--r--client/src/app/+video-channels/video-channels-routing.module.ts2
-rw-r--r--client/src/app/+video-channels/video-channels.component.ts4
-rw-r--r--client/src/app/app.module.ts4
-rw-r--r--client/src/app/menu/avatar-notification.component.html23
-rw-r--r--client/src/app/menu/avatar-notification.component.scss82
-rw-r--r--client/src/app/menu/avatar-notification.component.ts64
-rw-r--r--client/src/app/menu/index.ts2
-rw-r--r--client/src/app/menu/menu.component.html6
-rw-r--r--client/src/app/menu/menu.component.scss7
-rw-r--r--client/src/app/shared/misc/help.component.html1
-rw-r--r--client/src/app/shared/misc/help.component.scss22
-rw-r--r--client/src/app/shared/rest/component-pagination.model.ts11
-rw-r--r--client/src/app/shared/rest/rest-extractor.service.ts1
-rw-r--r--client/src/app/shared/shared.module.ts8
-rw-r--r--client/src/app/shared/users/index.ts1
-rw-r--r--client/src/app/shared/users/user-notification.model.ts153
-rw-r--r--client/src/app/shared/users/user-notification.service.ts110
-rw-r--r--client/src/app/shared/users/user-notifications.component.html61
-rw-r--r--client/src/app/shared/users/user-notifications.component.scss30
-rw-r--r--client/src/app/shared/users/user-notifications.component.ts82
-rw-r--r--client/src/app/shared/users/user.model.ts6
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comments.component.ts15
-rw-r--r--client/src/sass/include/_bootstrap-variables.scss3
-rw-r--r--client/src/sass/primeng-custom.scss4
-rw-r--r--client/yarn.lock53
-rwxr-xr-xscripts/clean/server/test.sh2
-rw-r--r--server/controllers/api/users/my-notifications.ts15
-rw-r--r--server/helpers/custom-validators/misc.ts6
-rw-r--r--server/helpers/custom-validators/user-notifications.ts8
-rw-r--r--server/initializers/migrations/0315-user-notifications.ts2
-rw-r--r--server/lib/notifier.ts4
-rw-r--r--server/lib/user.ts18
-rw-r--r--server/middlewares/validators/user-notifications.ts5
-rw-r--r--server/models/account/user-notification.ts6
-rw-r--r--server/tests/api/check-params/user-notifications.ts51
-rw-r--r--server/tests/api/check-params/users.ts3
-rw-r--r--server/tests/api/users/user-notifications.ts50
-rw-r--r--server/tests/api/users/users.ts4
-rw-r--r--shared/models/users/user-notification-setting.model.ts7
-rw-r--r--shared/models/users/user-notification.model.ts4
-rw-r--r--shared/utils/server/jobs.ts3
-rw-r--r--shared/utils/users/user-notifications.ts11
56 files changed, 1073 insertions, 112 deletions
diff --git a/.travis.yml b/.travis.yml
index 3a73e4fc0..d252ae625 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -48,12 +48,12 @@ matrix:
48 - env: TEST_SUITE=jest 48 - env: TEST_SUITE=jest
49 49
50script: 50script:
51 - travis_retry npm run travis -- "$TEST_SUITE" 51 - NODE_PENDING_JOB_WAIT=1000 travis_retry npm run travis -- "$TEST_SUITE"
52 52
53after_failure: 53after_failure:
54 - cat test1/logs/all-logs.log 54 - cat test1/logs/peertube.log
55 - cat test2/logs/all-logs.log 55 - cat test2/logs/peertube.log
56 - cat test3/logs/all-logs.log 56 - cat test3/logs/peertube.log
57 - cat test4/logs/all-logs.log 57 - cat test4/logs/peertube.log
58 - cat test5/logs/all-logs.log 58 - cat test5/logs/peertube.log
59 - cat test6/logs/all-logs.log 59 - cat test6/logs/peertube.log
diff --git a/client/package.json b/client/package.json
index 81422f05f..5fe1f3d5f 100644
--- a/client/package.json
+++ b/client/package.json
@@ -94,6 +94,7 @@
94 "@types/markdown-it": "^0.0.5", 94 "@types/markdown-it": "^0.0.5",
95 "@types/node": "^10.9.2", 95 "@types/node": "^10.9.2",
96 "@types/sanitize-html": "1.18.0", 96 "@types/sanitize-html": "1.18.0",
97 "@types/socket.io-client": "^1.4.32",
97 "@types/video.js": "^7.2.5", 98 "@types/video.js": "^7.2.5",
98 "@types/webtorrent": "^0.98.4", 99 "@types/webtorrent": "^0.98.4",
99 "angular2-hotkeys": "^2.1.2", 100 "angular2-hotkeys": "^2.1.2",
@@ -141,6 +142,7 @@
141 "sanitize-html": "^1.18.4", 142 "sanitize-html": "^1.18.4",
142 "sass-loader": "^7.1.0", 143 "sass-loader": "^7.1.0",
143 "sass-resources-loader": "^2.0.0", 144 "sass-resources-loader": "^2.0.0",
145 "socket.io-client": "^2.2.0",
144 "stream-browserify": "^2.0.1", 146 "stream-browserify": "^2.0.1",
145 "stream-http": "^3.0.0", 147 "stream-http": "^3.0.0",
146 "terser-webpack-plugin": "^1.1.0", 148 "terser-webpack-plugin": "^1.1.0",
diff --git a/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.html b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.html
new file mode 100644
index 000000000..d2810c343
--- /dev/null
+++ b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.html
@@ -0,0 +1,7 @@
1<div class="header">
2 <a routerLink="/my-account/settings" i18n>Notification preferences</a>
3
4 <button (click)="markAllAsRead()" i18n>Mark all as read</button>
5</div>
6
7<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
index 000000000..86ac094c5
--- /dev/null
+++ b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss
@@ -0,0 +1,23 @@
1@import '_variables';
2@import '_mixins';
3
4.header {
5 display: flex;
6 justify-content: space-between;
7 font-size: 15px;
8 margin-bottom: 10px;
9
10 a {
11 @include peertube-button-link;
12 @include grey-button;
13 }
14
15 button {
16 @include peertube-button;
17 @include grey-button;
18 }
19}
20
21my-user-notifications {
22 font-size: 15px;
23}
diff --git a/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.ts b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.ts
new file mode 100644
index 000000000..3e197088d
--- /dev/null
+++ b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.ts
@@ -0,0 +1,14 @@
1import { Component, ViewChild } from '@angular/core'
2import { UserNotificationsComponent } from '@app/shared'
3
4@Component({
5 templateUrl: './my-account-notifications.component.html',
6 styleUrls: [ './my-account-notifications.component.scss' ]
7})
8export class MyAccountNotificationsComponent {
9 @ViewChild('userNotification') userNotification: UserNotificationsComponent
10
11 markAllAsRead () {
12 this.userNotification.markAllAsRead()
13 }
14}
diff --git a/client/src/app/+my-account/my-account-routing.module.ts b/client/src/app/+my-account/my-account-routing.module.ts
index a2cbeaffc..9996218ca 100644
--- a/client/src/app/+my-account/my-account-routing.module.ts
+++ b/client/src/app/+my-account/my-account-routing.module.ts
@@ -14,6 +14,7 @@ import { MyAccountOwnershipComponent } from '@app/+my-account/my-account-ownersh
14import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-blocklist.component' 14import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-blocklist.component'
15import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component' 15import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component'
16import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component' 16import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
17import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
17 18
18const myAccountRoutes: Routes = [ 19const myAccountRoutes: Routes = [
19 { 20 {
@@ -124,6 +125,15 @@ const myAccountRoutes: Routes = [
124 title: 'Videos history' 125 title: 'Videos history'
125 } 126 }
126 } 127 }
128 },
129 {
130 path: 'notifications',
131 component: MyAccountNotificationsComponent,
132 data: {
133 meta: {
134 title: 'Notifications'
135 }
136 }
127 } 137 }
128 ] 138 ]
129 } 139 }
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/index.ts b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/index.ts
new file mode 100644
index 000000000..5e1d51339
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/index.ts
@@ -0,0 +1 @@
export * from './my-account-notification-preferences.component'
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html
new file mode 100644
index 000000000..59422d682
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html
@@ -0,0 +1,19 @@
1<div class="custom-row">
2 <div i18n>Activities</div>
3 <div i18n>Web</div>
4 <div i18n *ngIf="emailEnabled">Email</div>
5</div>
6
7<div class="custom-row" *ngFor="let notificationType of notificationSettingKeys">
8 <ng-container *ngIf="hasUserRight(notificationType)">
9 <div>{{ labelNotifications[notificationType] }}</div>
10
11 <div>
12 <p-inputSwitch [(ngModel)]="webNotifications[notificationType]" (onChange)="updateWebSetting(notificationType, $event.checked)"></p-inputSwitch>
13 </div>
14
15 <div *ngIf="emailEnabled">
16 <p-inputSwitch [(ngModel)]="emailNotifications[notificationType]" (onChange)="updateEmailSetting(notificationType, $event.checked)"></p-inputSwitch>
17 </div>
18 </ng-container>
19</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
index 000000000..6feb16ab1
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss
@@ -0,0 +1,25 @@
1@import '_variables';
2@import '_mixins';
3
4.custom-row {
5 display: flex;
6 align-items: center;
7 border-bottom: 1px solid rgba(0, 0, 0, 0.10);
8
9 &:first-child {
10 font-size: 16px;
11
12 & > div {
13 font-weight: $font-semibold;
14 }
15 }
16
17 & > div {
18 width: 350px;
19 }
20
21 & > div {
22 padding: 10px
23 }
24}
25
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
new file mode 100644
index 000000000..519bdfab4
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
@@ -0,0 +1,99 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { User } from '@app/shared'
3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { Subject } from 'rxjs'
5import { UserNotificationSetting, UserNotificationSettingValue, UserRight } from '../../../../../../shared'
6import { Notifier, ServerService } from '@app/core'
7import { debounce } from 'lodash-es'
8import { UserNotificationService } from '@app/shared/users/user-notification.service'
9
10@Component({
11 selector: 'my-account-notification-preferences',
12 templateUrl: './my-account-notification-preferences.component.html',
13 styleUrls: [ './my-account-notification-preferences.component.scss' ]
14})
15export class MyAccountNotificationPreferencesComponent implements OnInit {
16 @Input() user: User = null
17 @Input() userInformationLoaded: Subject<any>
18
19 notificationSettingKeys: (keyof UserNotificationSetting)[] = []
20 emailNotifications: { [ id in keyof UserNotificationSetting ]: boolean } = {} as any
21 webNotifications: { [ id in keyof UserNotificationSetting ]: boolean } = {} as any
22 labelNotifications: { [ id in keyof UserNotificationSetting ]: string } = {} as any
23 rightNotifications: { [ id in keyof Partial<UserNotificationSetting> ]: UserRight } = {} as any
24 emailEnabled: boolean
25
26 private savePreferences = debounce(this.savePreferencesImpl.bind(this), 500)
27
28 constructor (
29 private i18n: I18n,
30 private userNotificationService: UserNotificationService,
31 private serverService: ServerService,
32 private notifier: Notifier
33 ) {
34 this.labelNotifications = {
35 newVideoFromSubscription: this.i18n('New video from your subscriptions'),
36 newCommentOnMyVideo: this.i18n('New comment on your video'),
37 videoAbuseAsModerator: this.i18n('New video abuse on local video'),
38 blacklistOnMyVideo: this.i18n('One of your video is blacklisted/unblacklisted'),
39 myVideoPublished: this.i18n('Video published (after transcoding/scheduled update)'),
40 myVideoImportFinished: this.i18n('Video import finished'),
41 newUserRegistration: this.i18n('A new user registered on your instance'),
42 newFollow: this.i18n('You or your channel(s) has a new follower'),
43 commentMention: this.i18n('Someone mentioned you in video comments')
44 }
45 this.notificationSettingKeys = Object.keys(this.labelNotifications) as (keyof UserNotificationSetting)[]
46
47 this.rightNotifications = {
48 videoAbuseAsModerator: UserRight.MANAGE_VIDEO_ABUSES,
49 newUserRegistration: UserRight.MANAGE_USERS
50 }
51
52 this.emailEnabled = this.serverService.getConfig().email.enabled
53 }
54
55 ngOnInit () {
56 this.userInformationLoaded.subscribe(() => this.loadNotificationSettings())
57 }
58
59 hasUserRight (field: keyof UserNotificationSetting) {
60 const rightToHave = this.rightNotifications[field]
61 if (!rightToHave) return true // No rights needed
62
63 return this.user.hasRight(rightToHave)
64 }
65
66 updateEmailSetting (field: keyof UserNotificationSetting, value: boolean) {
67 if (value === true) this.user.notificationSettings[field] |= UserNotificationSettingValue.EMAIL
68 else this.user.notificationSettings[field] &= ~UserNotificationSettingValue.EMAIL
69
70 this.savePreferences()
71 }
72
73 updateWebSetting (field: keyof UserNotificationSetting, value: boolean) {
74 if (value === true) this.user.notificationSettings[field] |= UserNotificationSettingValue.WEB
75 else this.user.notificationSettings[field] &= ~UserNotificationSettingValue.WEB
76
77 this.savePreferences()
78 }
79
80 private savePreferencesImpl () {
81 this.userNotificationService.updateNotificationSettings(this.user, this.user.notificationSettings)
82 .subscribe(
83 () => {
84 this.notifier.success(this.i18n('Preferences saved'), undefined, 2000)
85 },
86
87 err => this.notifier.error(err.message)
88 )
89 }
90
91 private loadNotificationSettings () {
92 for (const key of Object.keys(this.user.notificationSettings)) {
93 const value = this.user.notificationSettings[key]
94 this.emailNotifications[key] = value & UserNotificationSettingValue.EMAIL
95
96 this.webNotifications[key] = value & UserNotificationSettingValue.WEB
97 }
98 }
99}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
index c7e23cd1f..2eb7dd56e 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
+++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
@@ -9,6 +9,9 @@
9 <my-account-profile [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-profile> 9 <my-account-profile [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-profile>
10</ng-template> 10</ng-template>
11 11
12<div i18n class="account-title" id="notifications">Notifications</div>
13<my-account-notification-preferences [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-notification-preferences>
14
12<div i18n class="account-title">Password</div> 15<div i18n class="account-title">Password</div>
13<my-account-change-password></my-account-change-password> 16<my-account-change-password></my-account-change-password>
14 17
@@ -16,4 +19,4 @@
16<my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings> 19<my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings>
17 20
18<div i18n class="account-title">Danger zone</div> 21<div i18n class="account-title">Danger zone</div>
19<my-account-danger-zone [user]="user"></my-account-danger-zone> \ No newline at end of file 22<my-account-danger-zone [user]="user"></my-account-danger-zone>
diff --git a/client/src/app/+my-account/my-account.component.ts b/client/src/app/+my-account/my-account.component.ts
index 1bac9547d..8a4102d80 100644
--- a/client/src/app/+my-account/my-account.component.ts
+++ b/client/src/app/+my-account/my-account.component.ts
@@ -68,6 +68,10 @@ export class MyAccountComponent {
68 label: this.i18n('My settings'), 68 label: this.i18n('My settings'),
69 routerLink: '/my-account/settings' 69 routerLink: '/my-account/settings'
70 }, 70 },
71 {
72 label: this.i18n('My notifications'),
73 routerLink: '/my-account/notifications'
74 },
71 libraryEntries, 75 libraryEntries,
72 miscEntries 76 miscEntries
73 ] 77 ]
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts
index 80d9f0cf7..18f51f171 100644
--- a/client/src/app/+my-account/my-account.module.ts
+++ b/client/src/app/+my-account/my-account.module.ts
@@ -23,6 +23,8 @@ import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-sub
23import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-blocklist.component' 23import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-blocklist.component'
24import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component' 24import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component'
25import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component' 25import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
26import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
27import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences'
26 28
27@NgModule({ 29@NgModule({
28 imports: [ 30 imports: [
@@ -53,7 +55,9 @@ import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/m
53 MyAccountSubscriptionsComponent, 55 MyAccountSubscriptionsComponent,
54 MyAccountBlocklistComponent, 56 MyAccountBlocklistComponent,
55 MyAccountServerBlocklistComponent, 57 MyAccountServerBlocklistComponent,
56 MyAccountHistoryComponent 58 MyAccountHistoryComponent,
59 MyAccountNotificationsComponent,
60 MyAccountNotificationPreferencesComponent
57 ], 61 ],
58 62
59 exports: [ 63 exports: [
diff --git a/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts b/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts
index 70c4374e0..dea378a6e 100644
--- a/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts
+++ b/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts
@@ -55,7 +55,7 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On
55 this.videoChannelSub = this.videoChannelService.videoChannelLoaded 55 this.videoChannelSub = this.videoChannelService.videoChannelLoaded
56 .subscribe(videoChannel => { 56 .subscribe(videoChannel => {
57 this.videoChannel = videoChannel 57 this.videoChannel = videoChannel
58 this.currentRoute = '/video-channels/' + this.videoChannel.uuid + '/videos' 58 this.currentRoute = '/video-channels/' + this.videoChannel.nameWithHost + '/videos'
59 59
60 this.reloadVideos() 60 this.reloadVideos()
61 this.generateSyndicationList() 61 this.generateSyndicationList()
diff --git a/client/src/app/+video-channels/video-channels-routing.module.ts b/client/src/app/+video-channels/video-channels-routing.module.ts
index 935578d2a..3ac3533d9 100644
--- a/client/src/app/+video-channels/video-channels-routing.module.ts
+++ b/client/src/app/+video-channels/video-channels-routing.module.ts
@@ -7,7 +7,7 @@ import { VideoChannelAboutComponent } from './video-channel-about/video-channel-
7 7
8const videoChannelsRoutes: Routes = [ 8const videoChannelsRoutes: Routes = [
9 { 9 {
10 path: ':videoChannelId', 10 path: ':videoChannelName',
11 component: VideoChannelsComponent, 11 component: VideoChannelsComponent,
12 canActivateChild: [ MetaGuard ], 12 canActivateChild: [ MetaGuard ],
13 children: [ 13 children: [
diff --git a/client/src/app/+video-channels/video-channels.component.ts b/client/src/app/+video-channels/video-channels.component.ts
index 0c5c814c7..41ff82e98 100644
--- a/client/src/app/+video-channels/video-channels.component.ts
+++ b/client/src/app/+video-channels/video-channels.component.ts
@@ -34,9 +34,9 @@ export class VideoChannelsComponent implements OnInit, OnDestroy {
34 ngOnInit () { 34 ngOnInit () {
35 this.routeSub = this.route.params 35 this.routeSub = this.route.params
36 .pipe( 36 .pipe(
37 map(params => params[ 'videoChannelId' ]), 37 map(params => params[ 'videoChannelName' ]),
38 distinctUntilChanged(), 38 distinctUntilChanged(),
39 switchMap(videoChannelId => this.videoChannelService.getVideoChannel(videoChannelId)), 39 switchMap(videoChannelName => this.videoChannelService.getVideoChannel(videoChannelName)),
40 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ])) 40 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ]))
41 ) 41 )
42 .subscribe(videoChannel => this.videoChannel = videoChannel) 42 .subscribe(videoChannel => this.videoChannel = videoChannel)
diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts
index 371199442..0bbc2e08b 100644
--- a/client/src/app/app.module.ts
+++ b/client/src/app/app.module.ts
@@ -12,13 +12,12 @@ import { AppComponent } from './app.component'
12import { CoreModule } from './core' 12import { CoreModule } from './core'
13import { HeaderComponent } from './header' 13import { HeaderComponent } from './header'
14import { LoginModule } from './login' 14import { LoginModule } from './login'
15import { MenuComponent } from './menu' 15import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
16import { SharedModule } from './shared' 16import { SharedModule } from './shared'
17import { SignupModule } from './signup' 17import { SignupModule } from './signup'
18import { VideosModule } from './videos' 18import { VideosModule } from './videos'
19import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n' 19import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n'
20import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' 20import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
21import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
22import { SearchModule } from '@app/search' 21import { SearchModule } from '@app/search'
23 22
24export function metaFactory (serverService: ServerService): MetaLoader { 23export function metaFactory (serverService: ServerService): MetaLoader {
@@ -40,6 +39,7 @@ export function metaFactory (serverService: ServerService): MetaLoader {
40 39
41 MenuComponent, 40 MenuComponent,
42 LanguageChooserComponent, 41 LanguageChooserComponent,
42 AvatarNotificationComponent,
43 HeaderComponent 43 HeaderComponent
44 ], 44 ],
45 imports: [ 45 imports: [
diff --git a/client/src/app/menu/avatar-notification.component.html b/client/src/app/menu/avatar-notification.component.html
new file mode 100644
index 000000000..2f0b7c669
--- /dev/null
+++ b/client/src/app/menu/avatar-notification.component.html
@@ -0,0 +1,23 @@
1<div
2 [ngbPopover]="popContent" autoClose="outside" placement="bottom-left" container="body" popoverClass="popover-notifications"
3 i18n-title title="View your notifications" class="notification-avatar" #popover="ngbPopover"
4>
5 <div *ngIf="unreadNotifications > 0" class="unread-notifications">{{ unreadNotifications }}</div>
6
7 <img [src]="user.accountAvatarUrl" alt="Avatar" />
8</div>
9
10<ng-template #popContent>
11 <div class="notifications-header">
12 <div i18n>Notifications</div>
13
14 <a
15 i18n-title title="Update your notification preferences" class="glyphicon glyphicon-cog"
16 routerLink="/my-account/settings" fragment="notifications"
17 ></a>
18 </div>
19
20 <my-user-notifications [ignoreLoadingBar]="true" [infiniteScroll]="false"></my-user-notifications>
21
22 <a class="all-notifications" routerLink="/my-account/notifications" i18n>See all your notifications</a>
23</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
index 000000000..c86667469
--- /dev/null
+++ b/client/src/app/menu/avatar-notification.component.scss
@@ -0,0 +1,82 @@
1@import '_variables';
2@import '_mixins';
3
4/deep/ {
5 .popover-notifications.popover {
6 max-width: 400px;
7
8 .popover-body {
9 padding: 0;
10 font-size: 14px;
11 font-family: $main-fonts;
12 overflow-y: auto;
13 max-height: 500px;
14 box-shadow: 0 6px 14px rgba(0, 0, 0, 0.30);
15
16 .notifications-header {
17 display: flex;
18 justify-content: space-between;
19
20 background-color: rgba(0, 0, 0, 0.10);
21 align-items: center;
22 padding: 0 10px;
23 font-size: 16px;
24 height: 50px;
25
26 a {
27 @include disable-default-a-behaviour;
28
29 color: rgba(20, 20, 20, 0.5);
30
31 &:hover {
32 color: rgba(20, 20, 20, 0.8);
33 }
34 }
35 }
36
37 .all-notifications {
38 display: flex;
39 align-items: center;
40 justify-content: center;
41 font-weight: $font-semibold;
42 color: var(--mainForegroundColor);
43 height: 30px;
44 }
45 }
46 }
47}
48
49.notification-avatar {
50 cursor: pointer;
51 position: relative;
52
53 img,
54 .unread-notifications {
55 margin-left: 20px;
56 }
57
58 img {
59 @include avatar(34px);
60
61 margin-right: 10px;
62 }
63
64 .unread-notifications {
65 position: absolute;
66 top: -5px;
67 left: -5px;
68
69 display: flex;
70 align-items: center;
71 justify-content: center;
72
73 background-color: var(--mainColor);
74 color: var(--mainBackgroundColor);
75 font-size: 10px;
76 font-weight: $font-semibold;
77
78 border-radius: 15px;
79 width: 15px;
80 height: 15px;
81 }
82}
diff --git a/client/src/app/menu/avatar-notification.component.ts b/client/src/app/menu/avatar-notification.component.ts
new file mode 100644
index 000000000..60e090726
--- /dev/null
+++ b/client/src/app/menu/avatar-notification.component.ts
@@ -0,0 +1,64 @@
1import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
2import { User } from '../shared/users/user.model'
3import { UserNotificationService } from '@app/shared/users/user-notification.service'
4import { Subscription } from 'rxjs'
5import { Notifier } from '@app/core'
6import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
7import { NavigationEnd, Router } from '@angular/router'
8import { filter } from 'rxjs/operators'
9
10@Component({
11 selector: 'my-avatar-notification',
12 templateUrl: './avatar-notification.component.html',
13 styleUrls: [ './avatar-notification.component.scss' ]
14})
15export class AvatarNotificationComponent implements OnInit, OnDestroy {
16 @ViewChild('popover') popover: NgbPopover
17 @Input() user: User
18
19 unreadNotifications = 0
20
21 private notificationSub: Subscription
22 private routeSub: Subscription
23
24 constructor (
25 private userNotificationService: UserNotificationService,
26 private notifier: Notifier,
27 private router: Router
28 ) {}
29
30 ngOnInit () {
31 this.userNotificationService.countUnreadNotifications()
32 .subscribe(
33 result => {
34 this.unreadNotifications = Math.min(result, 99) // Limit number to 99
35 this.subscribeToNotifications()
36 },
37
38 err => this.notifier.error(err.message)
39 )
40
41 this.routeSub = this.router.events
42 .pipe(filter(event => event instanceof NavigationEnd))
43 .subscribe(() => this.closePopover())
44 }
45
46 ngOnDestroy () {
47 if (this.notificationSub) this.notificationSub.unsubscribe()
48 if (this.routeSub) this.routeSub.unsubscribe()
49 }
50
51 closePopover () {
52 this.popover.close()
53 }
54
55 private subscribeToNotifications () {
56 this.notificationSub = this.userNotificationService.getMyNotificationsSocket()
57 .subscribe(data => {
58 if (data.type === 'new') return this.unreadNotifications++
59 if (data.type === 'read') return this.unreadNotifications--
60 if (data.type === 'read-all') return this.unreadNotifications = 0
61 })
62 }
63
64}
diff --git a/client/src/app/menu/index.ts b/client/src/app/menu/index.ts
index 421271c12..39dbde750 100644
--- a/client/src/app/menu/index.ts
+++ b/client/src/app/menu/index.ts
@@ -1 +1,3 @@
1export * from './language-chooser.component'
2export * from './avatar-notification.component'
1export * from './menu.component' 3export * from './menu.component'
diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html
index e04bdf3d6..aa5bfa9c9 100644
--- a/client/src/app/menu/menu.component.html
+++ b/client/src/app/menu/menu.component.html
@@ -2,9 +2,7 @@
2 <menu> 2 <menu>
3 <div class="top-menu"> 3 <div class="top-menu">
4 <div *ngIf="isLoggedIn" class="logged-in-block"> 4 <div *ngIf="isLoggedIn" class="logged-in-block">
5 <a routerLink="/my-account/settings"> 5 <my-avatar-notification [user]="user"></my-avatar-notification>
6 <img [src]="user.accountAvatarUrl" alt="Avatar" />
7 </a>
8 6
9 <div class="logged-in-info"> 7 <div class="logged-in-info">
10 <a routerLink="/my-account/settings" class="logged-in-username">{{ user.account?.displayName }}</a> 8 <a routerLink="/my-account/settings" class="logged-in-username">{{ user.account?.displayName }}</a>
@@ -97,4 +95,4 @@
97 </menu> 95 </menu>
98</div> 96</div>
99 97
100<my-language-chooser #languageChooserModal></my-language-chooser> \ No newline at end of file 98<my-language-chooser #languageChooserModal></my-language-chooser>
diff --git a/client/src/app/menu/menu.component.scss b/client/src/app/menu/menu.component.scss
index b271ebfd2..a4aaadc7f 100644
--- a/client/src/app/menu/menu.component.scss
+++ b/client/src/app/menu/menu.component.scss
@@ -39,13 +39,6 @@ menu {
39 justify-content: center; 39 justify-content: center;
40 margin-bottom: 35px; 40 margin-bottom: 35px;
41 41
42 img {
43 @include avatar(34px);
44
45 margin-left: 20px;
46 margin-right: 10px;
47 }
48
49 .logged-in-info { 42 .logged-in-info {
50 flex-grow: 1; 43 flex-grow: 1;
51 44
diff --git a/client/src/app/shared/misc/help.component.html b/client/src/app/shared/misc/help.component.html
index 28ccb1e26..08a2fc367 100644
--- a/client/src/app/shared/misc/help.component.html
+++ b/client/src/app/shared/misc/help.component.html
@@ -18,6 +18,7 @@
18 container="body" 18 container="body"
19 title="Get help" 19 title="Get help"
20 i18n-title 20 i18n-title
21 popoverClass="help-popover"
21 [attr.aria-pressed]="isPopoverOpened" 22 [attr.aria-pressed]="isPopoverOpened"
22 [ngbPopover]="tooltipTemplate" 23 [ngbPopover]="tooltipTemplate"
23 [placement]="tooltipPlacement" 24 [placement]="tooltipPlacement"
diff --git a/client/src/app/shared/misc/help.component.scss b/client/src/app/shared/misc/help.component.scss
index 5c73a8031..6a5c3b1fa 100644
--- a/client/src/app/shared/misc/help.component.scss
+++ b/client/src/app/shared/misc/help.component.scss
@@ -12,19 +12,21 @@
12} 12}
13 13
14/deep/ { 14/deep/ {
15 .popover-body { 15 .popover-help.popover {
16 text-align: left;
17 padding: 10px;
18 max-width: 300px; 16 max-width: 300px;
19 17
20 font-size: 13px; 18 .popover-body {
21 font-family: $main-fonts; 19 text-align: left;
22 background-color: #fff; 20 padding: 10px;
23 color: #000; 21 font-size: 13px;
24 box-shadow: 0 0 6px rgba(0, 0, 0, 0.5); 22 font-family: $main-fonts;
23 background-color: #fff;
24 color: #000;
25 box-shadow: 0 0 6px rgba(0, 0, 0, 0.5);
25 26
26 ul { 27 ul {
27 padding-left: 20px; 28 padding-left: 20px;
29 }
28 } 30 }
29 } 31 }
30} 32}
diff --git a/client/src/app/shared/rest/component-pagination.model.ts b/client/src/app/shared/rest/component-pagination.model.ts
index 0b8ecc318..85160d445 100644
--- a/client/src/app/shared/rest/component-pagination.model.ts
+++ b/client/src/app/shared/rest/component-pagination.model.ts
@@ -3,3 +3,14 @@ export interface ComponentPagination {
3 itemsPerPage: number 3 itemsPerPage: number
4 totalItems?: number 4 totalItems?: number
5} 5}
6
7export function hasMoreItems (componentPagination: ComponentPagination) {
8 // No results
9 if (componentPagination.totalItems === 0) return false
10
11 // Not loaded yet
12 if (!componentPagination.totalItems) return true
13
14 const maxPage = componentPagination.totalItems / componentPagination.itemsPerPage
15 return maxPage > componentPagination.currentPage
16}
diff --git a/client/src/app/shared/rest/rest-extractor.service.ts b/client/src/app/shared/rest/rest-extractor.service.ts
index f149569ef..e6518dd1d 100644
--- a/client/src/app/shared/rest/rest-extractor.service.ts
+++ b/client/src/app/shared/rest/rest-extractor.service.ts
@@ -80,6 +80,7 @@ export class RestExtractor {
80 errorMessage = errorMessage ? errorMessage : 'Unknown error.' 80 errorMessage = errorMessage ? errorMessage : 'Unknown error.'
81 console.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`) 81 console.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`)
82 } else { 82 } else {
83 console.error(err)
83 errorMessage = err 84 errorMessage = err
84 } 85 }
85 86
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index 4a5d664db..c99c87c00 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -63,6 +63,8 @@ import { UserModerationDropdownComponent } from '@app/shared/moderation/user-mod
63import { BlocklistService } from '@app/shared/blocklist' 63import { BlocklistService } from '@app/shared/blocklist'
64import { TopMenuDropdownComponent } from '@app/shared/menu/top-menu-dropdown.component' 64import { TopMenuDropdownComponent } from '@app/shared/menu/top-menu-dropdown.component'
65import { UserHistoryService } from '@app/shared/users/user-history.service' 65import { UserHistoryService } from '@app/shared/users/user-history.service'
66import { UserNotificationService } from '@app/shared/users/user-notification.service'
67import { UserNotificationsComponent } from '@app/shared/users/user-notifications.component'
66 68
67@NgModule({ 69@NgModule({
68 imports: [ 70 imports: [
@@ -105,7 +107,8 @@ import { UserHistoryService } from '@app/shared/users/user-history.service'
105 InstanceFeaturesTableComponent, 107 InstanceFeaturesTableComponent,
106 UserBanModalComponent, 108 UserBanModalComponent,
107 UserModerationDropdownComponent, 109 UserModerationDropdownComponent,
108 TopMenuDropdownComponent 110 TopMenuDropdownComponent,
111 UserNotificationsComponent
109 ], 112 ],
110 113
111 exports: [ 114 exports: [
@@ -145,6 +148,7 @@ import { UserHistoryService } from '@app/shared/users/user-history.service'
145 UserBanModalComponent, 148 UserBanModalComponent,
146 UserModerationDropdownComponent, 149 UserModerationDropdownComponent,
147 TopMenuDropdownComponent, 150 TopMenuDropdownComponent,
151 UserNotificationsComponent,
148 152
149 NumberFormatterPipe, 153 NumberFormatterPipe,
150 ObjectLengthPipe, 154 ObjectLengthPipe,
@@ -187,6 +191,8 @@ import { UserHistoryService } from '@app/shared/users/user-history.service'
187 I18nPrimengCalendarService, 191 I18nPrimengCalendarService,
188 ScreenService, 192 ScreenService,
189 193
194 UserNotificationService,
195
190 I18n 196 I18n
191 ] 197 ]
192}) 198})
diff --git a/client/src/app/shared/users/index.ts b/client/src/app/shared/users/index.ts
index 7b5a67bc7..ebd715fb1 100644
--- a/client/src/app/shared/users/index.ts
+++ b/client/src/app/shared/users/index.ts
@@ -1,2 +1,3 @@
1export * from './user.model' 1export * from './user.model'
2export * from './user.service' 2export * from './user.service'
3export * from './user-notifications.component'
diff --git a/client/src/app/shared/users/user-notification.model.ts b/client/src/app/shared/users/user-notification.model.ts
new file mode 100644
index 000000000..5ff816fb8
--- /dev/null
+++ b/client/src/app/shared/users/user-notification.model.ts
@@ -0,0 +1,153 @@
1import { UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '../../../../../shared'
2
3export class UserNotification implements UserNotificationServer {
4 id: number
5 type: UserNotificationType
6 read: boolean
7
8 video?: VideoInfo & {
9 channel: {
10 id: number
11 displayName: string
12 }
13 }
14
15 videoImport?: {
16 id: number
17 video?: VideoInfo
18 torrentName?: string
19 magnetUri?: string
20 targetUrl?: string
21 }
22
23 comment?: {
24 id: number
25 threadId: number
26 account: {
27 id: number
28 displayName: string
29 }
30 video: VideoInfo
31 }
32
33 videoAbuse?: {
34 id: number
35 video: VideoInfo
36 }
37
38 videoBlacklist?: {
39 id: number
40 video: VideoInfo
41 }
42
43 account?: {
44 id: number
45 displayName: string
46 name: string
47 }
48
49 actorFollow?: {
50 id: number
51 follower: {
52 name: string
53 displayName: string
54 }
55 following: {
56 type: 'account' | 'channel'
57 name: string
58 displayName: string
59 }
60 }
61
62 createdAt: string
63 updatedAt: string
64
65 // Additional fields
66 videoUrl?: string
67 commentUrl?: any[]
68 videoAbuseUrl?: string
69 accountUrl?: string
70 videoImportIdentifier?: string
71 videoImportUrl?: string
72
73 constructor (hash: UserNotificationServer) {
74 this.id = hash.id
75 this.type = hash.type
76 this.read = hash.read
77
78 this.video = hash.video
79 this.videoImport = hash.videoImport
80 this.comment = hash.comment
81 this.videoAbuse = hash.videoAbuse
82 this.videoBlacklist = hash.videoBlacklist
83 this.account = hash.account
84 this.actorFollow = hash.actorFollow
85
86 this.createdAt = hash.createdAt
87 this.updatedAt = hash.updatedAt
88
89 switch (this.type) {
90 case UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION:
91 this.videoUrl = this.buildVideoUrl(this.video)
92 break
93
94 case UserNotificationType.UNBLACKLIST_ON_MY_VIDEO:
95 this.videoUrl = this.buildVideoUrl(this.video)
96 break
97
98 case UserNotificationType.NEW_COMMENT_ON_MY_VIDEO:
99 case UserNotificationType.COMMENT_MENTION:
100 this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ]
101 break
102
103 case UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS:
104 this.videoAbuseUrl = '/admin/moderation/video-abuses/list'
105 this.videoUrl = this.buildVideoUrl(this.videoAbuse.video)
106 break
107
108 case UserNotificationType.BLACKLIST_ON_MY_VIDEO:
109 this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video)
110 break
111
112 case UserNotificationType.MY_VIDEO_PUBLISHED:
113 this.videoUrl = this.buildVideoUrl(this.video)
114 break
115
116 case UserNotificationType.MY_VIDEO_IMPORT_SUCCESS:
117 this.videoImportUrl = this.buildVideoImportUrl()
118 this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport)
119 this.videoUrl = this.buildVideoUrl(this.videoImport.video)
120 break
121
122 case UserNotificationType.MY_VIDEO_IMPORT_ERROR:
123 this.videoImportUrl = this.buildVideoImportUrl()
124 this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport)
125 break
126
127 case UserNotificationType.NEW_USER_REGISTRATION:
128 this.accountUrl = this.buildAccountUrl(this.account)
129 break
130
131 case UserNotificationType.NEW_FOLLOW:
132 this.accountUrl = this.buildAccountUrl(this.actorFollow.follower)
133 break
134 }
135 }
136
137 private buildVideoUrl (video: { uuid: string }) {
138 return '/videos/watch/' + video.uuid
139 }
140
141 private buildAccountUrl (account: { name: string }) {
142 return '/accounts/' + account.name
143 }
144
145 private buildVideoImportUrl () {
146 return '/my-account/video-imports'
147 }
148
149 private buildVideoImportIdentifier (videoImport: { targetUrl?: string, magnetUri?: string, torrentName?: string }) {
150 return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName
151 }
152
153}
diff --git a/client/src/app/shared/users/user-notification.service.ts b/client/src/app/shared/users/user-notification.service.ts
new file mode 100644
index 000000000..2dfee8060
--- /dev/null
+++ b/client/src/app/shared/users/user-notification.service.ts
@@ -0,0 +1,110 @@
1import { Injectable } from '@angular/core'
2import { HttpClient, HttpParams } from '@angular/common/http'
3import { RestExtractor, RestService } from '@app/shared/rest'
4import { catchError, map, tap } from 'rxjs/operators'
5import { environment } from '../../../environments/environment'
6import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '../../../../../shared'
7import { UserNotification } from '@app/shared/users/user-notification.model'
8import { Subject } from 'rxjs'
9import * as io from 'socket.io-client'
10import { AuthService } from '@app/core'
11import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
12import { User } from '@app/shared'
13
14@Injectable()
15export class UserNotificationService {
16 static BASE_NOTIFICATIONS_URL = environment.apiUrl + '/api/v1/users/me/notifications'
17 static BASE_NOTIFICATION_SETTINGS = environment.apiUrl + '/api/v1/users/me/notification-settings'
18
19 private notificationSubject = new Subject<{ type: 'new' | 'read' | 'read-all', notification?: UserNotification }>()
20
21 private socket: SocketIOClient.Socket
22
23 constructor (
24 private auth: AuthService,
25 private authHttp: HttpClient,
26 private restExtractor: RestExtractor,
27 private restService: RestService
28 ) {}
29
30 listMyNotifications (pagination: ComponentPagination, unread?: boolean, ignoreLoadingBar = false) {
31 let params = new HttpParams()
32 params = this.restService.addRestGetParams(params, this.restService.componentPaginationToRestPagination(pagination))
33
34 if (unread) params = params.append('unread', `${unread}`)
35
36 const headers = ignoreLoadingBar ? { ignoreLoadingBar: '' } : undefined
37
38 return this.authHttp.get<ResultList<UserNotification>>(UserNotificationService.BASE_NOTIFICATIONS_URL, { params, headers })
39 .pipe(
40 map(res => this.restExtractor.convertResultListDateToHuman(res)),
41 map(res => this.restExtractor.applyToResultListData(res, this.formatNotification.bind(this))),
42 catchError(err => this.restExtractor.handleError(err))
43 )
44 }
45
46 countUnreadNotifications () {
47 return this.listMyNotifications({ currentPage: 1, itemsPerPage: 0 }, true)
48 .pipe(map(n => n.total))
49 }
50
51 getMyNotificationsSocket () {
52 const socket = this.getSocket()
53
54 socket.on('new-notification', (n: UserNotificationServer) => {
55 this.notificationSubject.next({ type: 'new', notification: new UserNotification(n) })
56 })
57
58 return this.notificationSubject.asObservable()
59 }
60
61 markAsRead (notification: UserNotification) {
62 const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read'
63
64 const body = { ids: [ notification.id ] }
65 const headers = { ignoreLoadingBar: '' }
66
67 return this.authHttp.post(url, body, { headers })
68 .pipe(
69 map(this.restExtractor.extractDataBool),
70 tap(() => this.notificationSubject.next({ type: 'read' })),
71 catchError(res => this.restExtractor.handleError(res))
72 )
73 }
74
75 markAllAsRead () {
76 const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read-all'
77 const headers = { ignoreLoadingBar: '' }
78
79 return this.authHttp.post(url, {}, { headers })
80 .pipe(
81 map(this.restExtractor.extractDataBool),
82 tap(() => this.notificationSubject.next({ type: 'read-all' })),
83 catchError(res => this.restExtractor.handleError(res))
84 )
85 }
86
87 updateNotificationSettings (user: User, settings: UserNotificationSetting) {
88 const url = UserNotificationService.BASE_NOTIFICATION_SETTINGS
89
90 return this.authHttp.put(url, settings)
91 .pipe(
92 map(this.restExtractor.extractDataBool),
93 catchError(res => this.restExtractor.handleError(res))
94 )
95 }
96
97 private getSocket () {
98 if (this.socket) return this.socket
99
100 this.socket = io(environment.apiUrl + '/user-notifications', {
101 query: { accessToken: this.auth.getAccessToken() }
102 })
103
104 return this.socket
105 }
106
107 private formatNotification (notification: UserNotificationServer) {
108 return new UserNotification(notification)
109 }
110}
diff --git a/client/src/app/shared/users/user-notifications.component.html b/client/src/app/shared/users/user-notifications.component.html
new file mode 100644
index 000000000..86379d941
--- /dev/null
+++ b/client/src/app/shared/users/user-notifications.component.html
@@ -0,0 +1,61 @@
1<div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div>
2
3<div class="notifications" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()">
4 <div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }">
5
6 <div [ngSwitch]="notification.type">
7 <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION">
8 {{ notification.video.channel.displayName }} published a <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">new video</a>
9 </ng-container>
10
11 <ng-container i18n *ngSwitchCase="UserNotificationType.UNBLACKLIST_ON_MY_VIDEO">
12 Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been unblacklisted
13 </ng-container>
14
15 <ng-container i18n *ngSwitchCase="UserNotificationType.BLACKLIST_ON_MY_VIDEO">
16 Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoBlacklist.video.name }}</a> has been blacklisted
17 </ng-container>
18
19 <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS">
20 <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>
21 </ng-container>
22
23 <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_COMMENT_ON_MY_VIDEO">
24 {{ notification.comment.account.displayName }} commented your video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.comment.video.name }}</a>
25 </ng-container>
26
27 <ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_PUBLISHED">
28 Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been published
29 </ng-container>
30
31 <ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_SUCCESS">
32 <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">Your video import</a> {{ notification.videoImportIdentifier }} succeeded
33 </ng-container>
34
35 <ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_ERROR">
36 <a (click)="markAsRead(notification)" [routerLink]="notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} failed
37 </ng-container>
38
39 <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_USER_REGISTRATION">
40 User <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.account.name }} registered</a> on your instance
41 </ng-container>
42
43 <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_FOLLOW">
44 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.actorFollow.follower.displayName }}</a> is following
45
46 <ng-container *ngIf="notification.actorFollow.following.type === 'channel'">
47 your channel {{ notification.actorFollow.following.displayName }}
48 </ng-container>
49 <ng-container *ngIf="notification.actorFollow.following.type === 'account'">your account</ng-container>
50 </ng-container>
51
52 <ng-container i18n *ngSwitchCase="UserNotificationType.COMMENT_MENTION">
53 {{ notification.comment.account.displayName }} mentioned you on <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">video {{ notification.comment.video.name }}</a>
54 </ng-container>
55 </div>
56
57 <div i18n title="Mark as read" class="mark-as-read">
58 <div class="glyphicon glyphicon-ok" (click)="markAsRead(notification)"></div>
59 </div>
60 </div>
61</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
index 000000000..0493b10d9
--- /dev/null
+++ b/client/src/app/shared/users/user-notifications.component.scss
@@ -0,0 +1,30 @@
1.notification {
2 display: flex;
3 justify-content: space-between;
4 align-items: center;
5 font-size: inherit;
6 padding: 15px 10px;
7 border-bottom: 1px solid rgba(0, 0, 0, 0.10);
8
9 .mark-as-read {
10 min-width: 35px;
11
12 .glyphicon {
13 display: none;
14 cursor: pointer;
15 color: rgba(20, 20, 20, 0.5)
16 }
17 }
18
19 &.unread {
20 background-color: rgba(0, 0, 0, 0.05);
21
22 &:hover .mark-as-read .glyphicon {
23 display: block;
24
25 &:hover {
26 color: rgba(20, 20, 20, 0.8);
27 }
28 }
29 }
30}
diff --git a/client/src/app/shared/users/user-notifications.component.ts b/client/src/app/shared/users/user-notifications.component.ts
new file mode 100644
index 000000000..682116226
--- /dev/null
+++ b/client/src/app/shared/users/user-notifications.component.ts
@@ -0,0 +1,82 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { UserNotificationService } from '@app/shared/users/user-notification.service'
3import { UserNotificationType } from '../../../../../shared'
4import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
5import { Notifier } from '@app/core'
6import { UserNotification } from '@app/shared/users/user-notification.model'
7
8@Component({
9 selector: 'my-user-notifications',
10 templateUrl: 'user-notifications.component.html',
11 styleUrls: [ 'user-notifications.component.scss' ]
12})
13export class UserNotificationsComponent implements OnInit {
14 @Input() ignoreLoadingBar = false
15 @Input() infiniteScroll = true
16
17 notifications: UserNotification[] = []
18
19 // So we can access it in the template
20 UserNotificationType = UserNotificationType
21
22 componentPagination: ComponentPagination = {
23 currentPage: 1,
24 itemsPerPage: 10,
25 totalItems: null
26 }
27
28 constructor (
29 private userNotificationService: UserNotificationService,
30 private notifier: Notifier
31 ) { }
32
33 ngOnInit () {
34 this.loadMoreNotifications()
35 }
36
37 loadMoreNotifications () {
38 this.userNotificationService.listMyNotifications(this.componentPagination, undefined, this.ignoreLoadingBar)
39 .subscribe(
40 result => {
41 this.notifications = this.notifications.concat(result.data)
42 this.componentPagination.totalItems = result.total
43 },
44
45 err => this.notifier.error(err.message)
46 )
47 }
48
49 onNearOfBottom () {
50 if (this.infiniteScroll === false) return
51
52 this.componentPagination.currentPage++
53
54 if (hasMoreItems(this.componentPagination)) {
55 this.loadMoreNotifications()
56 }
57 }
58
59 markAsRead (notification: UserNotification) {
60 this.userNotificationService.markAsRead(notification)
61 .subscribe(
62 () => {
63 notification.read = true
64 },
65
66 err => this.notifier.error(err.message)
67 )
68 }
69
70 markAllAsRead () {
71 this.userNotificationService.markAllAsRead()
72 .subscribe(
73 () => {
74 for (const notification of this.notifications) {
75 notification.read = true
76 }
77 },
78
79 err => this.notifier.error(err.message)
80 )
81 }
82}
diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts
index 3663a7b61..c15f1de8c 100644
--- a/client/src/app/shared/users/user.model.ts
+++ b/client/src/app/shared/users/user.model.ts
@@ -1,4 +1,4 @@
1import { hasUserRight, User as UserServerModel, UserRight, UserRole, VideoChannel } from '../../../../../shared' 1import { hasUserRight, User as UserServerModel, UserNotificationSetting, UserRight, UserRole, VideoChannel } from '../../../../../shared'
2import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' 2import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type'
3import { Account } from '@app/shared/account/account.model' 3import { Account } from '@app/shared/account/account.model'
4import { Avatar } from '../../../../../shared/models/avatars/avatar.model' 4import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
@@ -24,6 +24,8 @@ export class User implements UserServerModel {
24 blocked: boolean 24 blocked: boolean
25 blockedReason?: string 25 blockedReason?: string
26 26
27 notificationSettings?: UserNotificationSetting
28
27 constructor (hash: Partial<UserServerModel>) { 29 constructor (hash: Partial<UserServerModel>) {
28 this.id = hash.id 30 this.id = hash.id
29 this.username = hash.username 31 this.username = hash.username
@@ -41,6 +43,8 @@ export class User implements UserServerModel {
41 this.blocked = hash.blocked 43 this.blocked = hash.blocked
42 this.blockedReason = hash.blockedReason 44 this.blockedReason = hash.blockedReason
43 45
46 this.notificationSettings = hash.notificationSettings
47
44 if (hash.account !== undefined) { 48 if (hash.account !== undefined) {
45 this.account = new Account(hash.account) 49 this.account = new Account(hash.account)
46 } 50 }
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.ts b/client/src/app/videos/+video-watch/comment/video-comments.component.ts
index 957c17bbf..dc62fe5ae 100644
--- a/client/src/app/videos/+video-watch/comment/video-comments.component.ts
+++ b/client/src/app/videos/+video-watch/comment/video-comments.component.ts
@@ -4,7 +4,7 @@ import { ConfirmService, Notifier } from '@app/core'
4import { Subscription } from 'rxjs' 4import { Subscription } from 'rxjs'
5import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model' 5import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model'
6import { AuthService } from '../../../core/auth' 6import { AuthService } from '../../../core/auth'
7import { ComponentPagination } from '../../../shared/rest/component-pagination.model' 7import { ComponentPagination, hasMoreItems } from '../../../shared/rest/component-pagination.model'
8import { User } from '../../../shared/users' 8import { User } from '../../../shared/users'
9import { VideoSortField } from '../../../shared/video/sort-field.type' 9import { VideoSortField } from '../../../shared/video/sort-field.type'
10import { VideoDetails } from '../../../shared/video/video-details.model' 10import { VideoDetails } from '../../../shared/video/video-details.model'
@@ -165,22 +165,11 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
165 onNearOfBottom () { 165 onNearOfBottom () {
166 this.componentPagination.currentPage++ 166 this.componentPagination.currentPage++
167 167
168 if (this.hasMoreComments()) { 168 if (hasMoreItems(this.componentPagination)) {
169 this.loadMoreComments() 169 this.loadMoreComments()
170 } 170 }
171 } 171 }
172 172
173 private hasMoreComments () {
174 // No results
175 if (this.componentPagination.totalItems === 0) return false
176
177 // Not loaded yet
178 if (!this.componentPagination.totalItems) return true
179
180 const maxPage = this.componentPagination.totalItems / this.componentPagination.itemsPerPage
181 return maxPage > this.componentPagination.currentPage
182 }
183
184 private deleteLocalCommentThread (parentComment: VideoCommentThreadTree, commentToDelete: VideoComment) { 173 private deleteLocalCommentThread (parentComment: VideoCommentThreadTree, commentToDelete: VideoComment) {
185 for (const commentChild of parentComment.children) { 174 for (const commentChild of parentComment.children) {
186 if (commentChild.comment.id === commentToDelete.id) { 175 if (commentChild.comment.id === commentToDelete.id) {
diff --git a/client/src/sass/include/_bootstrap-variables.scss b/client/src/sass/include/_bootstrap-variables.scss
index 77a20cfe1..7f413836b 100644
--- a/client/src/sass/include/_bootstrap-variables.scss
+++ b/client/src/sass/include/_bootstrap-variables.scss
@@ -31,4 +31,5 @@ $input-focus-border-color: #ced4da;
31$nav-pills-link-active-bg: #F0F0F0; 31$nav-pills-link-active-bg: #F0F0F0;
32$nav-pills-link-active-color: #000; 32$nav-pills-link-active-color: #000;
33 33
34$zindex-dropdown: 10000; \ No newline at end of file 34$zindex-dropdown: 10000;
35$zindex-popover: 10000;
diff --git a/client/src/sass/primeng-custom.scss b/client/src/sass/primeng-custom.scss
index 05db2a2cb..58a6a0004 100644
--- a/client/src/sass/primeng-custom.scss
+++ b/client/src/sass/primeng-custom.scss
@@ -326,6 +326,8 @@ p-toast {
326 326
327 .notification-block { 327 .notification-block {
328 display: flex; 328 display: flex;
329 align-items: center;
330 padding: 5px;
329 331
330 .message { 332 .message {
331 flex-grow: 1; 333 flex-grow: 1;
@@ -336,12 +338,12 @@ p-toast {
336 338
337 p { 339 p {
338 font-size: 15px; 340 font-size: 15px;
341 margin-bottom: 0;
339 } 342 }
340 } 343 }
341 344
342 .glyphicon { 345 .glyphicon {
343 font-size: 32px; 346 font-size: 32px;
344 margin-top: 15px;
345 margin-right: 5px; 347 margin-right: 5px;
346 } 348 }
347 } 349 }
diff --git a/client/yarn.lock b/client/yarn.lock
index 3c7ba2d25..5ed43117a 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -510,6 +510,11 @@
510 dependencies: 510 dependencies:
511 "@types/node" "*" 511 "@types/node" "*"
512 512
513"@types/socket.io-client@^1.4.32":
514 version "1.4.32"
515 resolved "https://registry.yarnpkg.com/@types/socket.io-client/-/socket.io-client-1.4.32.tgz#988a65a0386c274b1c22a55377fab6a30789ac14"
516 integrity sha512-Vs55Kq8F+OWvy1RLA31rT+cAyemzgm0EWNeax6BWF8H7QiiOYMJIdcwSDdm5LVgfEkoepsWkS+40+WNb7BUMbg==
517
513"@types/video.js@^7.2.5": 518"@types/video.js@^7.2.5":
514 version "7.2.5" 519 version "7.2.5"
515 resolved "https://registry.yarnpkg.com/@types/video.js/-/video.js-7.2.5.tgz#20896c81141d3517c3a89bb6eb97c6a191aa5d4c" 520 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:
3195 xmlhttprequest-ssl "~1.5.4" 3200 xmlhttprequest-ssl "~1.5.4"
3196 yeast "0.1.2" 3201 yeast "0.1.2"
3197 3202
3203engine.io-client@~3.3.1:
3204 version "3.3.1"
3205 resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.3.1.tgz#afedb4a07b2ea48b7190c3136bfea98fdd4f0f03"
3206 integrity sha512-q66JBFuQcy7CSlfAz9L3jH+v7DTT3i6ZEadYcVj2pOs8/0uJHLxKX3WBkGTvULJMdz0tUCyJag0aKT/dpXL9BQ==
3207 dependencies:
3208 component-emitter "1.2.1"
3209 component-inherit "0.0.3"
3210 debug "~3.1.0"
3211 engine.io-parser "~2.1.1"
3212 has-cors "1.1.0"
3213 indexof "0.0.1"
3214 parseqs "0.0.5"
3215 parseuri "0.0.5"
3216 ws "~6.1.0"
3217 xmlhttprequest-ssl "~1.5.4"
3218 yeast "0.1.2"
3219
3198engine.io-parser@~2.1.0, engine.io-parser@~2.1.1: 3220engine.io-parser@~2.1.0, engine.io-parser@~2.1.1:
3199 version "2.1.3" 3221 version "2.1.3"
3200 resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.3.tgz#757ab970fbf2dfb32c7b74b033216d5739ef79a6" 3222 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:
8981 socket.io-parser "~3.2.0" 9003 socket.io-parser "~3.2.0"
8982 to-array "0.1.4" 9004 to-array "0.1.4"
8983 9005
9006socket.io-client@^2.2.0:
9007 version "2.2.0"
9008 resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.2.0.tgz#84e73ee3c43d5020ccc1a258faeeb9aec2723af7"
9009 integrity sha512-56ZrkTDbdTLmBIyfFYesgOxsjcLnwAKoN4CiPyTVkMQj3zTUh0QAx3GbvIvLpFEOvQWu92yyWICxB0u7wkVbYA==
9010 dependencies:
9011 backo2 "1.0.2"
9012 base64-arraybuffer "0.1.5"
9013 component-bind "1.0.0"
9014 component-emitter "1.2.1"
9015 debug "~3.1.0"
9016 engine.io-client "~3.3.1"
9017 has-binary2 "~1.0.2"
9018 has-cors "1.1.0"
9019 indexof "0.0.1"
9020 object-component "0.0.3"
9021 parseqs "0.0.5"
9022 parseuri "0.0.5"
9023 socket.io-parser "~3.3.0"
9024 to-array "0.1.4"
9025
8984socket.io-parser@~3.2.0: 9026socket.io-parser@~3.2.0:
8985 version "3.2.0" 9027 version "3.2.0"
8986 resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.2.0.tgz#e7c6228b6aa1f814e6148aea325b51aa9499e077" 9028 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:
8990 debug "~3.1.0" 9032 debug "~3.1.0"
8991 isarray "2.0.1" 9033 isarray "2.0.1"
8992 9034
9035socket.io-parser@~3.3.0:
9036 version "3.3.0"
9037 resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.0.tgz#2b52a96a509fdf31440ba40fed6094c7d4f1262f"
9038 integrity sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==
9039 dependencies:
9040 component-emitter "1.2.1"
9041 debug "~3.1.0"
9042 isarray "2.0.1"
9043
8993socket.io@2.1.1: 9044socket.io@2.1.1:
8994 version "2.1.1" 9045 version "2.1.1"
8995 resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.1.1.tgz#a069c5feabee3e6b214a75b40ce0652e1cfb9980" 9046 resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.1.1.tgz#a069c5feabee3e6b214a75b40ce0652e1cfb9980"
@@ -10671,7 +10722,7 @@ ws@^5.2.0:
10671 dependencies: 10722 dependencies:
10672 async-limiter "~1.0.0" 10723 async-limiter "~1.0.0"
10673 10724
10674ws@^6.0.0: 10725ws@^6.0.0, ws@~6.1.0:
10675 version "6.1.2" 10726 version "6.1.2"
10676 resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.2.tgz#3cc7462e98792f0ac679424148903ded3b9c3ad8" 10727 resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.2.tgz#3cc7462e98792f0ac679424148903ded3b9c3ad8"
10677 integrity sha512-rfUqzvz0WxmSXtJpPMX2EeASXabOrSMk1ruMOV3JBTBjo4ac2lDjGGsbQSyxj8Odhw5fBib8ZKEjDNvgouNKYw== 10728 integrity sha512-rfUqzvz0WxmSXtJpPMX2EeASXabOrSMk1ruMOV3JBTBjo4ac2lDjGGsbQSyxj8Odhw5fBib8ZKEjDNvgouNKYw==
diff --git a/scripts/clean/server/test.sh b/scripts/clean/server/test.sh
index b897c30ba..75ad491bf 100755
--- a/scripts/clean/server/test.sh
+++ b/scripts/clean/server/test.sh
@@ -13,7 +13,7 @@ recreateDB () {
13} 13}
14 14
15removeFiles () { 15removeFiles () {
16 rm -rf "./test$1" "./config/local-test.json" "./config/local-test-$1.json" 16 rm -rf "./test$1" "./config/local-test-$1.json"
17} 17}
18 18
19dropRedis () { 19dropRedis () {
diff --git a/server/controllers/api/users/my-notifications.ts b/server/controllers/api/users/my-notifications.ts
index d74d26add..76cf97587 100644
--- a/server/controllers/api/users/my-notifications.ts
+++ b/server/controllers/api/users/my-notifications.ts
@@ -45,6 +45,11 @@ myNotificationsRouter.post('/me/notifications/read',
45 asyncMiddleware(markAsReadUserNotifications) 45 asyncMiddleware(markAsReadUserNotifications)
46) 46)
47 47
48myNotificationsRouter.post('/me/notifications/read-all',
49 authenticate,
50 asyncMiddleware(markAsReadAllUserNotifications)
51)
52
48export { 53export {
49 myNotificationsRouter 54 myNotificationsRouter
50} 55}
@@ -70,7 +75,7 @@ async function updateNotificationSettings (req: express.Request, res: express.Re
70 myVideoImportFinished: body.myVideoImportFinished, 75 myVideoImportFinished: body.myVideoImportFinished,
71 newFollow: body.newFollow, 76 newFollow: body.newFollow,
72 newUserRegistration: body.newUserRegistration, 77 newUserRegistration: body.newUserRegistration,
73 commentMention: body.commentMention, 78 commentMention: body.commentMention
74 } 79 }
75 80
76 await UserNotificationSettingModel.update(values, query) 81 await UserNotificationSettingModel.update(values, query)
@@ -93,3 +98,11 @@ async function markAsReadUserNotifications (req: express.Request, res: express.R
93 98
94 return res.status(204).end() 99 return res.status(204).end()
95} 100}
101
102async function markAsReadAllUserNotifications (req: express.Request, res: express.Response) {
103 const user: UserModel = res.locals.oauth.token.User
104
105 await UserNotificationModel.markAllAsRead(user.id)
106
107 return res.status(204).end()
108}
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts
index a093e3e1b..b6f0ebe6f 100644
--- a/server/helpers/custom-validators/misc.ts
+++ b/server/helpers/custom-validators/misc.ts
@@ -9,8 +9,8 @@ function isArray (value: any) {
9 return Array.isArray(value) 9 return Array.isArray(value)
10} 10}
11 11
12function isIntArray (value: any) { 12function isNotEmptyIntArray (value: any) {
13 return Array.isArray(value) && value.every(v => validator.isInt('' + v)) 13 return Array.isArray(value) && value.every(v => validator.isInt('' + v)) && value.length !== 0
14} 14}
15 15
16function isDateValid (value: string) { 16function isDateValid (value: string) {
@@ -82,7 +82,7 @@ function isFileValid (
82 82
83export { 83export {
84 exists, 84 exists,
85 isIntArray, 85 isNotEmptyIntArray,
86 isArray, 86 isArray,
87 isIdValid, 87 isIdValid,
88 isUUIDValid, 88 isUUIDValid,
diff --git a/server/helpers/custom-validators/user-notifications.ts b/server/helpers/custom-validators/user-notifications.ts
index 4fb5d922d..02ea3bbc2 100644
--- a/server/helpers/custom-validators/user-notifications.ts
+++ b/server/helpers/custom-validators/user-notifications.ts
@@ -9,8 +9,12 @@ function isUserNotificationTypeValid (value: any) {
9 9
10function isUserNotificationSettingValid (value: any) { 10function isUserNotificationSettingValid (value: any) {
11 return exists(value) && 11 return exists(value) &&
12 validator.isInt('' + value) && 12 validator.isInt('' + value) && (
13 UserNotificationSettingValue[ value ] !== undefined 13 value === UserNotificationSettingValue.NONE ||
14 value === UserNotificationSettingValue.WEB ||
15 value === UserNotificationSettingValue.EMAIL ||
16 value === (UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL)
17 )
14} 18}
15 19
16export { 20export {
diff --git a/server/initializers/migrations/0315-user-notifications.ts b/server/initializers/migrations/0315-user-notifications.ts
index 34f9fd193..8284c58a0 100644
--- a/server/initializers/migrations/0315-user-notifications.ts
+++ b/server/initializers/migrations/0315-user-notifications.ts
@@ -31,7 +31,7 @@ PRIMARY KEY ("id"))
31 '("newVideoFromSubscription", "newCommentOnMyVideo", "videoAbuseAsModerator", "blacklistOnMyVideo", ' + 31 '("newVideoFromSubscription", "newCommentOnMyVideo", "videoAbuseAsModerator", "blacklistOnMyVideo", ' +
32 '"myVideoPublished", "myVideoImportFinished", "newUserRegistration", "newFollow", "commentMention", ' + 32 '"myVideoPublished", "myVideoImportFinished", "newUserRegistration", "newFollow", "commentMention", ' +
33 '"userId", "createdAt", "updatedAt") ' + 33 '"userId", "createdAt", "updatedAt") ' +
34 '(SELECT 2, 2, 4, 4, 2, 2, 2, 2, 2, id, NOW(), NOW() FROM "user")' 34 '(SELECT 1, 1, 3, 3, 1, 1, 1, 1, 1, id, NOW(), NOW() FROM "user")'
35 35
36 await utils.sequelize.query(query) 36 await utils.sequelize.query(query)
37 } 37 }
diff --git a/server/lib/notifier.ts b/server/lib/notifier.ts
index 2c51d7101..d1b331346 100644
--- a/server/lib/notifier.ts
+++ b/server/lib/notifier.ts
@@ -436,11 +436,11 @@ class Notifier {
436 private isEmailEnabled (user: UserModel, value: UserNotificationSettingValue) { 436 private isEmailEnabled (user: UserModel, value: UserNotificationSettingValue) {
437 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION === true && user.emailVerified !== true) return false 437 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION === true && user.emailVerified !== true) return false
438 438
439 return value === UserNotificationSettingValue.EMAIL || value === UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL 439 return value & UserNotificationSettingValue.EMAIL
440 } 440 }
441 441
442 private isWebNotificationEnabled (value: UserNotificationSettingValue) { 442 private isWebNotificationEnabled (value: UserNotificationSettingValue) {
443 return value === UserNotificationSettingValue.WEB_NOTIFICATION || value === UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL 443 return value & UserNotificationSettingValue.WEB
444 } 444 }
445 445
446 static get Instance () { 446 static get Instance () {
diff --git a/server/lib/user.ts b/server/lib/user.ts
index 9e24e85a0..a39ef6c3d 100644
--- a/server/lib/user.ts
+++ b/server/lib/user.ts
@@ -98,15 +98,15 @@ export {
98function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Transaction | undefined) { 98function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Transaction | undefined) {
99 const values: UserNotificationSetting & { userId: number } = { 99 const values: UserNotificationSetting & { userId: number } = {
100 userId: user.id, 100 userId: user.id,
101 newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION, 101 newVideoFromSubscription: UserNotificationSettingValue.WEB,
102 newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION, 102 newCommentOnMyVideo: UserNotificationSettingValue.WEB,
103 myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION, 103 myVideoImportFinished: UserNotificationSettingValue.WEB,
104 myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION, 104 myVideoPublished: UserNotificationSettingValue.WEB,
105 videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, 105 videoAbuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
106 blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, 106 blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
107 newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION, 107 newUserRegistration: UserNotificationSettingValue.WEB,
108 commentMention: UserNotificationSettingValue.WEB_NOTIFICATION, 108 commentMention: UserNotificationSettingValue.WEB,
109 newFollow: UserNotificationSettingValue.WEB_NOTIFICATION 109 newFollow: UserNotificationSettingValue.WEB
110 } 110 }
111 111
112 return UserNotificationSettingModel.create(values, { transaction: t }) 112 return UserNotificationSettingModel.create(values, { transaction: t })
diff --git a/server/middlewares/validators/user-notifications.ts b/server/middlewares/validators/user-notifications.ts
index 1c31f0a73..46486e081 100644
--- a/server/middlewares/validators/user-notifications.ts
+++ b/server/middlewares/validators/user-notifications.ts
@@ -4,7 +4,7 @@ import { body, query } from 'express-validator/check'
4import { logger } from '../../helpers/logger' 4import { logger } from '../../helpers/logger'
5import { areValidationErrors } from './utils' 5import { areValidationErrors } from './utils'
6import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications' 6import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
7import { isIntArray } from '../../helpers/custom-validators/misc' 7import { isNotEmptyIntArray } from '../../helpers/custom-validators/misc'
8 8
9const listUserNotificationsValidator = [ 9const listUserNotificationsValidator = [
10 query('unread') 10 query('unread')
@@ -42,7 +42,8 @@ const updateNotificationSettingsValidator = [
42 42
43const markAsReadUserNotificationsValidator = [ 43const markAsReadUserNotificationsValidator = [
44 body('ids') 44 body('ids')
45 .custom(isIntArray).withMessage('Should have a valid notification ids to mark as read'), 45 .optional()
46 .custom(isNotEmptyIntArray).withMessage('Should have a valid notification ids to mark as read'),
46 47
47 (req: express.Request, res: express.Response, next: express.NextFunction) => { 48 (req: express.Request, res: express.Response, next: express.NextFunction) => {
48 logger.debug('Checking markAsReadUserNotificationsValidator parameters', { parameters: req.body }) 49 logger.debug('Checking markAsReadUserNotificationsValidator parameters', { parameters: req.body })
diff --git a/server/models/account/user-notification.ts b/server/models/account/user-notification.ts
index 79afce600..9e4f982a3 100644
--- a/server/models/account/user-notification.ts
+++ b/server/models/account/user-notification.ts
@@ -290,6 +290,12 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
290 return UserNotificationModel.update({ read: true }, query) 290 return UserNotificationModel.update({ read: true }, query)
291 } 291 }
292 292
293 static markAllAsRead (userId: number) {
294 const query = { where: { userId } }
295
296 return UserNotificationModel.update({ read: true }, query)
297 }
298
293 toFormattedJSON (): UserNotification { 299 toFormattedJSON (): UserNotification {
294 const video = this.Video ? Object.assign(this.formatVideo(this.Video), { 300 const video = this.Video ? Object.assign(this.formatVideo(this.Video), {
295 channel: { 301 channel: {
diff --git a/server/tests/api/check-params/user-notifications.ts b/server/tests/api/check-params/user-notifications.ts
index 635f5c9a3..714f481e9 100644
--- a/server/tests/api/check-params/user-notifications.ts
+++ b/server/tests/api/check-params/user-notifications.ts
@@ -100,6 +100,16 @@ describe('Test user notifications API validators', function () {
100 url: server.url, 100 url: server.url,
101 path, 101 path,
102 fields: { 102 fields: {
103 ids: [ ]
104 },
105 token: server.accessToken,
106 statusCodeExpected: 400
107 })
108
109 await makePostBodyRequest({
110 url: server.url,
111 path,
112 fields: {
103 ids: 5 113 ids: 5
104 }, 114 },
105 token: server.accessToken, 115 token: server.accessToken,
@@ -131,18 +141,39 @@ describe('Test user notifications API validators', function () {
131 }) 141 })
132 }) 142 })
133 143
144 describe('When marking as read my notifications', function () {
145 const path = '/api/v1/users/me/notifications/read-all'
146
147 it('Should fail with a non authenticated user', async function () {
148 await makePostBodyRequest({
149 url: server.url,
150 path,
151 statusCodeExpected: 401
152 })
153 })
154
155 it('Should succeed with the correct parameters', async function () {
156 await makePostBodyRequest({
157 url: server.url,
158 path,
159 token: server.accessToken,
160 statusCodeExpected: 204
161 })
162 })
163 })
164
134 describe('When updating my notification settings', function () { 165 describe('When updating my notification settings', function () {
135 const path = '/api/v1/users/me/notification-settings' 166 const path = '/api/v1/users/me/notification-settings'
136 const correctFields: UserNotificationSetting = { 167 const correctFields: UserNotificationSetting = {
137 newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION, 168 newVideoFromSubscription: UserNotificationSettingValue.WEB,
138 newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION, 169 newCommentOnMyVideo: UserNotificationSettingValue.WEB,
139 videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION, 170 videoAbuseAsModerator: UserNotificationSettingValue.WEB,
140 blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION, 171 blacklistOnMyVideo: UserNotificationSettingValue.WEB,
141 myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION, 172 myVideoImportFinished: UserNotificationSettingValue.WEB,
142 myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION, 173 myVideoPublished: UserNotificationSettingValue.WEB,
143 commentMention: UserNotificationSettingValue.WEB_NOTIFICATION, 174 commentMention: UserNotificationSettingValue.WEB,
144 newFollow: UserNotificationSettingValue.WEB_NOTIFICATION, 175 newFollow: UserNotificationSettingValue.WEB,
145 newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION 176 newUserRegistration: UserNotificationSettingValue.WEB
146 } 177 }
147 178
148 it('Should fail with missing fields', async function () { 179 it('Should fail with missing fields', async function () {
@@ -150,7 +181,7 @@ describe('Test user notifications API validators', function () {
150 url: server.url, 181 url: server.url,
151 path, 182 path,
152 token: server.accessToken, 183 token: server.accessToken,
153 fields: { newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION }, 184 fields: { newVideoFromSubscription: UserNotificationSettingValue.WEB },
154 statusCodeExpected: 400 185 statusCodeExpected: 400
155 }) 186 })
156 }) 187 })
diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts
index f8044cbd4..a3e8e2e9c 100644
--- a/server/tests/api/check-params/users.ts
+++ b/server/tests/api/check-params/users.ts
@@ -485,11 +485,10 @@ describe('Test users API validators', function () {
485 email: 'email@example.com', 485 email: 'email@example.com',
486 emailVerified: true, 486 emailVerified: true,
487 videoQuota: 42, 487 videoQuota: 42,
488 role: UserRole.MODERATOR 488 role: UserRole.USER
489 } 489 }
490 490
491 await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields, statusCodeExpected: 204 }) 491 await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields, statusCodeExpected: 204 })
492 userAccessToken = await userLogin(server, user)
493 }) 492 })
494 }) 493 })
495 494
diff --git a/server/tests/api/users/user-notifications.ts b/server/tests/api/users/user-notifications.ts
index ae77b4db2..ad68d8e69 100644
--- a/server/tests/api/users/user-notifications.ts
+++ b/server/tests/api/users/user-notifications.ts
@@ -37,7 +37,8 @@ import {
37 getLastNotification, 37 getLastNotification,
38 getUserNotifications, 38 getUserNotifications,
39 markAsReadNotifications, 39 markAsReadNotifications,
40 updateMyNotificationSettings 40 updateMyNotificationSettings,
41 markAsReadAllNotifications
41} from '../../../../shared/utils/users/user-notifications' 42} from '../../../../shared/utils/users/user-notifications'
42import { 43import {
43 User, 44 User,
@@ -88,15 +89,15 @@ describe('Test users notifications', function () {
88 let channelId: number 89 let channelId: number
89 90
90 const allNotificationSettings: UserNotificationSetting = { 91 const allNotificationSettings: UserNotificationSetting = {
91 newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, 92 newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
92 newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, 93 newCommentOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
93 videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, 94 videoAbuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
94 blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, 95 blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
95 myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, 96 myVideoImportFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
96 myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, 97 myVideoPublished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
97 commentMention: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, 98 commentMention: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
98 newFollow: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, 99 newFollow: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
99 newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL 100 newUserRegistration: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
100 } 101 }
101 102
102 before(async function () { 103 before(async function () {
@@ -174,7 +175,10 @@ describe('Test users notifications', function () {
174 }) 175 })
175 176
176 it('Should send a new video notification if the user follows the local video publisher', async function () { 177 it('Should send a new video notification if the user follows the local video publisher', async function () {
178 this.timeout(10000)
179
177 await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9001') 180 await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9001')
181 await waitJobs(servers)
178 182
179 const { name, uuid } = await uploadVideoByLocalAccount(servers) 183 const { name, uuid } = await uploadVideoByLocalAccount(servers)
180 await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence') 184 await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence')
@@ -184,6 +188,7 @@ describe('Test users notifications', function () {
184 this.timeout(50000) // Server 2 has transcoding enabled 188 this.timeout(50000) // Server 2 has transcoding enabled
185 189
186 await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9002') 190 await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9002')
191 await waitJobs(servers)
187 192
188 const { name, uuid } = await uploadVideoByRemoteAccount(servers) 193 const { name, uuid } = await uploadVideoByRemoteAccount(servers)
189 await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence') 194 await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence')
@@ -822,8 +827,9 @@ describe('Test users notifications', function () {
822 }) 827 })
823 828
824 it('Should notify when a local channel is following one of our channel', async function () { 829 it('Should notify when a local channel is following one of our channel', async function () {
825 await addUserSubscription(servers[0].url, servers[0].accessToken, 'user_1_channel@localhost:9001') 830 this.timeout(10000)
826 831
832 await addUserSubscription(servers[0].url, servers[0].accessToken, 'user_1_channel@localhost:9001')
827 await waitJobs(servers) 833 await waitJobs(servers)
828 834
829 await checkNewActorFollow(baseParams, 'channel', 'root', 'super root name', myChannelName, 'presence') 835 await checkNewActorFollow(baseParams, 'channel', 'root', 'super root name', myChannelName, 'presence')
@@ -832,8 +838,9 @@ describe('Test users notifications', function () {
832 }) 838 })
833 839
834 it('Should notify when a remote channel is following one of our channel', async function () { 840 it('Should notify when a remote channel is following one of our channel', async function () {
835 await addUserSubscription(servers[1].url, servers[1].accessToken, 'user_1_channel@localhost:9001') 841 this.timeout(10000)
836 842
843 await addUserSubscription(servers[1].url, servers[1].accessToken, 'user_1_channel@localhost:9001')
837 await waitJobs(servers) 844 await waitJobs(servers)
838 845
839 await checkNewActorFollow(baseParams, 'channel', 'root', 'super root 2 name', myChannelName, 'presence') 846 await checkNewActorFollow(baseParams, 'channel', 'root', 'super root 2 name', myChannelName, 'presence')
@@ -895,6 +902,15 @@ describe('Test users notifications', function () {
895 expect(notification.read).to.be.false 902 expect(notification.read).to.be.false
896 } 903 }
897 }) 904 })
905
906 it('Should mark as read all notifications', async function () {
907 await markAsReadAllNotifications(servers[ 0 ].url, userAccessToken)
908
909 const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 0, 10, true)
910
911 expect(res.body.total).to.equal(0)
912 expect(res.body.data).to.have.lengthOf(0)
913 })
898 }) 914 })
899 915
900 describe('Notification settings', function () { 916 describe('Notification settings', function () {
@@ -928,13 +944,13 @@ describe('Test users notifications', function () {
928 944
929 it('Should only have web notifications', async function () { 945 it('Should only have web notifications', async function () {
930 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, { 946 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, {
931 newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION 947 newVideoFromSubscription: UserNotificationSettingValue.WEB
932 })) 948 }))
933 949
934 { 950 {
935 const res = await getMyUserInformation(servers[0].url, userAccessToken) 951 const res = await getMyUserInformation(servers[0].url, userAccessToken)
936 const info = res.body as User 952 const info = res.body as User
937 expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB_NOTIFICATION) 953 expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB)
938 } 954 }
939 955
940 const { name, uuid } = await uploadVideoByLocalAccount(servers) 956 const { name, uuid } = await uploadVideoByLocalAccount(servers)
@@ -976,13 +992,15 @@ describe('Test users notifications', function () {
976 992
977 it('Should have email and web notifications', async function () { 993 it('Should have email and web notifications', async function () {
978 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, { 994 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, {
979 newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL 995 newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
980 })) 996 }))
981 997
982 { 998 {
983 const res = await getMyUserInformation(servers[0].url, userAccessToken) 999 const res = await getMyUserInformation(servers[0].url, userAccessToken)
984 const info = res.body as User 1000 const info = res.body as User
985 expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL) 1001 expect(info.notificationSettings.newVideoFromSubscription).to.equal(
1002 UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
1003 )
986 } 1004 }
987 1005
988 const { name, uuid } = await uploadVideoByLocalAccount(servers) 1006 const { name, uuid } = await uploadVideoByLocalAccount(servers)
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts
index 4914c8ed5..ad98ab1c7 100644
--- a/server/tests/api/users/users.ts
+++ b/server/tests/api/users/users.ts
@@ -501,10 +501,6 @@ describe('Test users', function () {
501 accessTokenUser = await userLogin(server, user) 501 accessTokenUser = await userLogin(server, user)
502 }) 502 })
503 503
504 it('Should not be able to delete a user by a moderator', async function () {
505 await removeUser(server.url, 2, accessTokenUser, 403)
506 })
507
508 it('Should be able to list video blacklist by a moderator', async function () { 504 it('Should be able to list video blacklist by a moderator', async function () {
509 await getBlacklistedVideosList(server.url, accessTokenUser) 505 await getBlacklistedVideosList(server.url, accessTokenUser)
510 }) 506 })
diff --git a/shared/models/users/user-notification-setting.model.ts b/shared/models/users/user-notification-setting.model.ts
index f580e827e..531e12bba 100644
--- a/shared/models/users/user-notification-setting.model.ts
+++ b/shared/models/users/user-notification-setting.model.ts
@@ -1,8 +1,7 @@
1export enum UserNotificationSettingValue { 1export enum UserNotificationSettingValue {
2 NONE = 1, 2 NONE = 0,
3 WEB_NOTIFICATION = 2, 3 WEB = 1 << 0,
4 EMAIL = 3, 4 EMAIL = 1 << 1
5 WEB_NOTIFICATION_AND_EMAIL = 4
6} 5}
7 6
8export interface UserNotificationSetting { 7export interface UserNotificationSetting {
diff --git a/shared/models/users/user-notification.model.ts b/shared/models/users/user-notification.model.ts
index 9dd4f099f..f41b6f534 100644
--- a/shared/models/users/user-notification.model.ts
+++ b/shared/models/users/user-notification.model.ts
@@ -2,11 +2,15 @@ export enum UserNotificationType {
2 NEW_VIDEO_FROM_SUBSCRIPTION = 1, 2 NEW_VIDEO_FROM_SUBSCRIPTION = 1,
3 NEW_COMMENT_ON_MY_VIDEO = 2, 3 NEW_COMMENT_ON_MY_VIDEO = 2,
4 NEW_VIDEO_ABUSE_FOR_MODERATORS = 3, 4 NEW_VIDEO_ABUSE_FOR_MODERATORS = 3,
5
5 BLACKLIST_ON_MY_VIDEO = 4, 6 BLACKLIST_ON_MY_VIDEO = 4,
6 UNBLACKLIST_ON_MY_VIDEO = 5, 7 UNBLACKLIST_ON_MY_VIDEO = 5,
8
7 MY_VIDEO_PUBLISHED = 6, 9 MY_VIDEO_PUBLISHED = 6,
10
8 MY_VIDEO_IMPORT_SUCCESS = 7, 11 MY_VIDEO_IMPORT_SUCCESS = 7,
9 MY_VIDEO_IMPORT_ERROR = 8, 12 MY_VIDEO_IMPORT_ERROR = 8,
13
10 NEW_USER_REGISTRATION = 9, 14 NEW_USER_REGISTRATION = 9,
11 NEW_FOLLOW = 10, 15 NEW_FOLLOW = 10,
12 COMMENT_MENTION = 11 16 COMMENT_MENTION = 11
diff --git a/shared/utils/server/jobs.ts b/shared/utils/server/jobs.ts
index 6218c0b66..692b5e24d 100644
--- a/shared/utils/server/jobs.ts
+++ b/shared/utils/server/jobs.ts
@@ -29,6 +29,7 @@ function getJobsListPaginationAndSort (url: string, accessToken: string, state:
29} 29}
30 30
31async function waitJobs (serversArg: ServerInfo[] | ServerInfo) { 31async function waitJobs (serversArg: ServerInfo[] | ServerInfo) {
32 const pendingJobWait = process.env.NODE_PENDING_JOB_WAIT ? parseInt(process.env.NODE_PENDING_JOB_WAIT, 10) : 2000
32 let servers: ServerInfo[] 33 let servers: ServerInfo[]
33 34
34 if (Array.isArray(serversArg) === false) servers = [ serversArg as ServerInfo ] 35 if (Array.isArray(serversArg) === false) servers = [ serversArg as ServerInfo ]
@@ -62,7 +63,7 @@ async function waitJobs (serversArg: ServerInfo[] | ServerInfo) {
62 63
63 // Retry, in case of new jobs were created 64 // Retry, in case of new jobs were created
64 if (pendingRequests === false) { 65 if (pendingRequests === false) {
65 await wait(2000) 66 await wait(pendingJobWait)
66 await Promise.all(tasksBuilder()) 67 await Promise.all(tasksBuilder())
67 } 68 }
68 69
diff --git a/shared/utils/users/user-notifications.ts b/shared/utils/users/user-notifications.ts
index 1222899e7..bcbe29fc7 100644
--- a/shared/utils/users/user-notifications.ts
+++ b/shared/utils/users/user-notifications.ts
@@ -54,6 +54,16 @@ function markAsReadNotifications (url: string, token: string, ids: number[], sta
54 statusCodeExpected 54 statusCodeExpected
55 }) 55 })
56} 56}
57function markAsReadAllNotifications (url: string, token: string, statusCodeExpected = 204) {
58 const path = '/api/v1/users/me/notifications/read-all'
59
60 return makePostBodyRequest({
61 url,
62 path,
63 token,
64 statusCodeExpected
65 })
66}
57 67
58async function getLastNotification (serverUrl: string, accessToken: string) { 68async function getLastNotification (serverUrl: string, accessToken: string) {
59 const res = await getUserNotifications(serverUrl, accessToken, 0, 1, undefined, '-createdAt') 69 const res = await getUserNotifications(serverUrl, accessToken, 0, 1, undefined, '-createdAt')
@@ -409,6 +419,7 @@ export {
409 CheckerBaseParams, 419 CheckerBaseParams,
410 CheckerType, 420 CheckerType,
411 checkNotification, 421 checkNotification,
422 markAsReadAllNotifications,
412 checkMyVideoImportIsFinished, 423 checkMyVideoImportIsFinished,
413 checkUserRegistered, 424 checkUserRegistered,
414 checkVideoIsPublished, 425 checkVideoIsPublished,