aboutsummaryrefslogtreecommitdiffhomepage
path: root/client
diff options
context:
space:
mode:
Diffstat (limited to 'client')
-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
38 files changed, 925 insertions, 47 deletions
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==