diff --git a/.travis.yml b/.travis.yml
index 3a73e4fc0..d252ae625 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -48,12 +48,12 @@ matrix:
- env: TEST_SUITE=jest
script:
- - travis_retry npm run travis -- "$TEST_SUITE"
+ - NODE_PENDING_JOB_WAIT=1000 travis_retry npm run travis -- "$TEST_SUITE"
after_failure:
- - cat test1/logs/all-logs.log
- - cat test2/logs/all-logs.log
- - cat test3/logs/all-logs.log
- - cat test4/logs/all-logs.log
- - cat test5/logs/all-logs.log
- - cat test6/logs/all-logs.log
+ - cat test1/logs/peertube.log
+ - cat test2/logs/peertube.log
+ - cat test3/logs/peertube.log
+ - cat test4/logs/peertube.log
+ - cat test5/logs/peertube.log
+ - cat test6/logs/peertube.log
diff --git a/client/package.json b/client/package.json
index 81422f05f..5fe1f3d5f 100644
--- a/client/package.json
+++ b/client/package.json
@@ -94,6 +94,7 @@
"@types/markdown-it": "^0.0.5",
"@types/node": "^10.9.2",
"@types/sanitize-html": "1.18.0",
+ "@types/socket.io-client": "^1.4.32",
"@types/video.js": "^7.2.5",
"@types/webtorrent": "^0.98.4",
"angular2-hotkeys": "^2.1.2",
@@ -141,6 +142,7 @@
"sanitize-html": "^1.18.4",
"sass-loader": "^7.1.0",
"sass-resources-loader": "^2.0.0",
+ "socket.io-client": "^2.2.0",
"stream-browserify": "^2.0.1",
"stream-http": "^3.0.0",
"terser-webpack-plugin": "^1.1.0",
diff --git a/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.html b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.html
new file mode 100644
index 000000000..d2810c343
--- /dev/null
+++ b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.html
@@ -0,0 +1,7 @@
+
+
+
diff --git a/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss
new file mode 100644
index 000000000..86ac094c5
--- /dev/null
+++ b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss
@@ -0,0 +1,23 @@
+@import '_variables';
+@import '_mixins';
+
+.header {
+ display: flex;
+ justify-content: space-between;
+ font-size: 15px;
+ margin-bottom: 10px;
+
+ a {
+ @include peertube-button-link;
+ @include grey-button;
+ }
+
+ button {
+ @include peertube-button;
+ @include grey-button;
+ }
+}
+
+my-user-notifications {
+ font-size: 15px;
+}
diff --git a/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.ts b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.ts
new file mode 100644
index 000000000..3e197088d
--- /dev/null
+++ b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.ts
@@ -0,0 +1,14 @@
+import { Component, ViewChild } from '@angular/core'
+import { UserNotificationsComponent } from '@app/shared'
+
+@Component({
+ templateUrl: './my-account-notifications.component.html',
+ styleUrls: [ './my-account-notifications.component.scss' ]
+})
+export class MyAccountNotificationsComponent {
+ @ViewChild('userNotification') userNotification: UserNotificationsComponent
+
+ markAllAsRead () {
+ this.userNotification.markAllAsRead()
+ }
+}
diff --git a/client/src/app/+my-account/my-account-routing.module.ts b/client/src/app/+my-account/my-account-routing.module.ts
index a2cbeaffc..9996218ca 100644
--- a/client/src/app/+my-account/my-account-routing.module.ts
+++ b/client/src/app/+my-account/my-account-routing.module.ts
@@ -14,6 +14,7 @@ import { MyAccountOwnershipComponent } from '@app/+my-account/my-account-ownersh
import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-blocklist.component'
import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component'
import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
+import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
const myAccountRoutes: Routes = [
{
@@ -124,6 +125,15 @@ const myAccountRoutes: Routes = [
title: 'Videos history'
}
}
+ },
+ {
+ path: 'notifications',
+ component: MyAccountNotificationsComponent,
+ data: {
+ meta: {
+ title: 'Notifications'
+ }
+ }
}
]
}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/index.ts b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/index.ts
new file mode 100644
index 000000000..5e1d51339
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/index.ts
@@ -0,0 +1 @@
+export * from './my-account-notification-preferences.component'
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html
new file mode 100644
index 000000000..59422d682
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html
@@ -0,0 +1,19 @@
+
+
Activities
+
Web
+
Email
+
+
+
+
+ {{ labelNotifications[notificationType] }}
+
+
+
+
+
+
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss
new file mode 100644
index 000000000..6feb16ab1
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss
@@ -0,0 +1,25 @@
+@import '_variables';
+@import '_mixins';
+
+.custom-row {
+ display: flex;
+ align-items: center;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.10);
+
+ &:first-child {
+ font-size: 16px;
+
+ & > div {
+ font-weight: $font-semibold;
+ }
+ }
+
+ & > div {
+ width: 350px;
+ }
+
+ & > div {
+ padding: 10px
+ }
+}
+
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
new file mode 100644
index 000000000..519bdfab4
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
@@ -0,0 +1,99 @@
+import { Component, Input, OnInit } from '@angular/core'
+import { User } from '@app/shared'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Subject } from 'rxjs'
+import { UserNotificationSetting, UserNotificationSettingValue, UserRight } from '../../../../../../shared'
+import { Notifier, ServerService } from '@app/core'
+import { debounce } from 'lodash-es'
+import { UserNotificationService } from '@app/shared/users/user-notification.service'
+
+@Component({
+ selector: 'my-account-notification-preferences',
+ templateUrl: './my-account-notification-preferences.component.html',
+ styleUrls: [ './my-account-notification-preferences.component.scss' ]
+})
+export class MyAccountNotificationPreferencesComponent implements OnInit {
+ @Input() user: User = null
+ @Input() userInformationLoaded: Subject
+
+ notificationSettingKeys: (keyof UserNotificationSetting)[] = []
+ emailNotifications: { [ id in keyof UserNotificationSetting ]: boolean } = {} as any
+ webNotifications: { [ id in keyof UserNotificationSetting ]: boolean } = {} as any
+ labelNotifications: { [ id in keyof UserNotificationSetting ]: string } = {} as any
+ rightNotifications: { [ id in keyof Partial ]: UserRight } = {} as any
+ emailEnabled: boolean
+
+ private savePreferences = debounce(this.savePreferencesImpl.bind(this), 500)
+
+ constructor (
+ private i18n: I18n,
+ private userNotificationService: UserNotificationService,
+ private serverService: ServerService,
+ private notifier: Notifier
+ ) {
+ this.labelNotifications = {
+ newVideoFromSubscription: this.i18n('New video from your subscriptions'),
+ newCommentOnMyVideo: this.i18n('New comment on your video'),
+ videoAbuseAsModerator: this.i18n('New video abuse on local video'),
+ blacklistOnMyVideo: this.i18n('One of your video is blacklisted/unblacklisted'),
+ myVideoPublished: this.i18n('Video published (after transcoding/scheduled update)'),
+ myVideoImportFinished: this.i18n('Video import finished'),
+ newUserRegistration: this.i18n('A new user registered on your instance'),
+ newFollow: this.i18n('You or your channel(s) has a new follower'),
+ commentMention: this.i18n('Someone mentioned you in video comments')
+ }
+ this.notificationSettingKeys = Object.keys(this.labelNotifications) as (keyof UserNotificationSetting)[]
+
+ this.rightNotifications = {
+ videoAbuseAsModerator: UserRight.MANAGE_VIDEO_ABUSES,
+ newUserRegistration: UserRight.MANAGE_USERS
+ }
+
+ this.emailEnabled = this.serverService.getConfig().email.enabled
+ }
+
+ ngOnInit () {
+ this.userInformationLoaded.subscribe(() => this.loadNotificationSettings())
+ }
+
+ hasUserRight (field: keyof UserNotificationSetting) {
+ const rightToHave = this.rightNotifications[field]
+ if (!rightToHave) return true // No rights needed
+
+ return this.user.hasRight(rightToHave)
+ }
+
+ updateEmailSetting (field: keyof UserNotificationSetting, value: boolean) {
+ if (value === true) this.user.notificationSettings[field] |= UserNotificationSettingValue.EMAIL
+ else this.user.notificationSettings[field] &= ~UserNotificationSettingValue.EMAIL
+
+ this.savePreferences()
+ }
+
+ updateWebSetting (field: keyof UserNotificationSetting, value: boolean) {
+ if (value === true) this.user.notificationSettings[field] |= UserNotificationSettingValue.WEB
+ else this.user.notificationSettings[field] &= ~UserNotificationSettingValue.WEB
+
+ this.savePreferences()
+ }
+
+ private savePreferencesImpl () {
+ this.userNotificationService.updateNotificationSettings(this.user, this.user.notificationSettings)
+ .subscribe(
+ () => {
+ this.notifier.success(this.i18n('Preferences saved'), undefined, 2000)
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ private loadNotificationSettings () {
+ for (const key of Object.keys(this.user.notificationSettings)) {
+ const value = this.user.notificationSettings[key]
+ this.emailNotifications[key] = value & UserNotificationSettingValue.EMAIL
+
+ this.webNotifications[key] = value & UserNotificationSettingValue.WEB
+ }
+ }
+}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
index c7e23cd1f..2eb7dd56e 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
+++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
@@ -9,6 +9,9 @@
+Notifications
+
+
Password
@@ -16,4 +19,4 @@
Danger zone
-
\ No newline at end of file
+
diff --git a/client/src/app/+my-account/my-account.component.ts b/client/src/app/+my-account/my-account.component.ts
index 1bac9547d..8a4102d80 100644
--- a/client/src/app/+my-account/my-account.component.ts
+++ b/client/src/app/+my-account/my-account.component.ts
@@ -68,6 +68,10 @@ export class MyAccountComponent {
label: this.i18n('My settings'),
routerLink: '/my-account/settings'
},
+ {
+ label: this.i18n('My notifications'),
+ routerLink: '/my-account/notifications'
+ },
libraryEntries,
miscEntries
]
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts
index 80d9f0cf7..18f51f171 100644
--- a/client/src/app/+my-account/my-account.module.ts
+++ b/client/src/app/+my-account/my-account.module.ts
@@ -23,6 +23,8 @@ import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-sub
import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-blocklist.component'
import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component'
import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
+import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
+import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences'
@NgModule({
imports: [
@@ -53,7 +55,9 @@ import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/m
MyAccountSubscriptionsComponent,
MyAccountBlocklistComponent,
MyAccountServerBlocklistComponent,
- MyAccountHistoryComponent
+ MyAccountHistoryComponent,
+ MyAccountNotificationsComponent,
+ MyAccountNotificationPreferencesComponent
],
exports: [
diff --git a/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts b/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts
index 70c4374e0..dea378a6e 100644
--- a/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts
+++ b/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts
@@ -55,7 +55,7 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On
this.videoChannelSub = this.videoChannelService.videoChannelLoaded
.subscribe(videoChannel => {
this.videoChannel = videoChannel
- this.currentRoute = '/video-channels/' + this.videoChannel.uuid + '/videos'
+ this.currentRoute = '/video-channels/' + this.videoChannel.nameWithHost + '/videos'
this.reloadVideos()
this.generateSyndicationList()
diff --git a/client/src/app/+video-channels/video-channels-routing.module.ts b/client/src/app/+video-channels/video-channels-routing.module.ts
index 935578d2a..3ac3533d9 100644
--- a/client/src/app/+video-channels/video-channels-routing.module.ts
+++ b/client/src/app/+video-channels/video-channels-routing.module.ts
@@ -7,7 +7,7 @@ import { VideoChannelAboutComponent } from './video-channel-about/video-channel-
const videoChannelsRoutes: Routes = [
{
- path: ':videoChannelId',
+ path: ':videoChannelName',
component: VideoChannelsComponent,
canActivateChild: [ MetaGuard ],
children: [
diff --git a/client/src/app/+video-channels/video-channels.component.ts b/client/src/app/+video-channels/video-channels.component.ts
index 0c5c814c7..41ff82e98 100644
--- a/client/src/app/+video-channels/video-channels.component.ts
+++ b/client/src/app/+video-channels/video-channels.component.ts
@@ -34,9 +34,9 @@ export class VideoChannelsComponent implements OnInit, OnDestroy {
ngOnInit () {
this.routeSub = this.route.params
.pipe(
- map(params => params[ 'videoChannelId' ]),
+ map(params => params[ 'videoChannelName' ]),
distinctUntilChanged(),
- switchMap(videoChannelId => this.videoChannelService.getVideoChannel(videoChannelId)),
+ switchMap(videoChannelName => this.videoChannelService.getVideoChannel(videoChannelName)),
catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ]))
)
.subscribe(videoChannel => this.videoChannel = videoChannel)
diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts
index 371199442..0bbc2e08b 100644
--- a/client/src/app/app.module.ts
+++ b/client/src/app/app.module.ts
@@ -12,13 +12,12 @@ import { AppComponent } from './app.component'
import { CoreModule } from './core'
import { HeaderComponent } from './header'
import { LoginModule } from './login'
-import { MenuComponent } from './menu'
+import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
import { SharedModule } from './shared'
import { SignupModule } from './signup'
import { VideosModule } from './videos'
import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n'
import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
-import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
import { SearchModule } from '@app/search'
export function metaFactory (serverService: ServerService): MetaLoader {
@@ -40,6 +39,7 @@ export function metaFactory (serverService: ServerService): MetaLoader {
MenuComponent,
LanguageChooserComponent,
+ AvatarNotificationComponent,
HeaderComponent
],
imports: [
diff --git a/client/src/app/menu/avatar-notification.component.html b/client/src/app/menu/avatar-notification.component.html
new file mode 100644
index 000000000..2f0b7c669
--- /dev/null
+++ b/client/src/app/menu/avatar-notification.component.html
@@ -0,0 +1,23 @@
+
+
0" class="unread-notifications">{{ unreadNotifications }}
+
+
+
+
+
+
+
+
+
+ See all your notifications
+
diff --git a/client/src/app/menu/avatar-notification.component.scss b/client/src/app/menu/avatar-notification.component.scss
new file mode 100644
index 000000000..c86667469
--- /dev/null
+++ b/client/src/app/menu/avatar-notification.component.scss
@@ -0,0 +1,82 @@
+@import '_variables';
+@import '_mixins';
+
+/deep/ {
+ .popover-notifications.popover {
+ max-width: 400px;
+
+ .popover-body {
+ padding: 0;
+ font-size: 14px;
+ font-family: $main-fonts;
+ overflow-y: auto;
+ max-height: 500px;
+ box-shadow: 0 6px 14px rgba(0, 0, 0, 0.30);
+
+ .notifications-header {
+ display: flex;
+ justify-content: space-between;
+
+ background-color: rgba(0, 0, 0, 0.10);
+ align-items: center;
+ padding: 0 10px;
+ font-size: 16px;
+ height: 50px;
+
+ a {
+ @include disable-default-a-behaviour;
+
+ color: rgba(20, 20, 20, 0.5);
+
+ &:hover {
+ color: rgba(20, 20, 20, 0.8);
+ }
+ }
+ }
+
+ .all-notifications {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: $font-semibold;
+ color: var(--mainForegroundColor);
+ height: 30px;
+ }
+ }
+ }
+}
+
+.notification-avatar {
+ cursor: pointer;
+ position: relative;
+
+ img,
+ .unread-notifications {
+ margin-left: 20px;
+ }
+
+ img {
+ @include avatar(34px);
+
+ margin-right: 10px;
+ }
+
+ .unread-notifications {
+ position: absolute;
+ top: -5px;
+ left: -5px;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ background-color: var(--mainColor);
+ color: var(--mainBackgroundColor);
+ font-size: 10px;
+ font-weight: $font-semibold;
+
+ border-radius: 15px;
+ width: 15px;
+ height: 15px;
+ }
+}
diff --git a/client/src/app/menu/avatar-notification.component.ts b/client/src/app/menu/avatar-notification.component.ts
new file mode 100644
index 000000000..60e090726
--- /dev/null
+++ b/client/src/app/menu/avatar-notification.component.ts
@@ -0,0 +1,64 @@
+import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
+import { User } from '../shared/users/user.model'
+import { UserNotificationService } from '@app/shared/users/user-notification.service'
+import { Subscription } from 'rxjs'
+import { Notifier } from '@app/core'
+import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
+import { NavigationEnd, Router } from '@angular/router'
+import { filter } from 'rxjs/operators'
+
+@Component({
+ selector: 'my-avatar-notification',
+ templateUrl: './avatar-notification.component.html',
+ styleUrls: [ './avatar-notification.component.scss' ]
+})
+export class AvatarNotificationComponent implements OnInit, OnDestroy {
+ @ViewChild('popover') popover: NgbPopover
+ @Input() user: User
+
+ unreadNotifications = 0
+
+ private notificationSub: Subscription
+ private routeSub: Subscription
+
+ constructor (
+ private userNotificationService: UserNotificationService,
+ private notifier: Notifier,
+ private router: Router
+ ) {}
+
+ ngOnInit () {
+ this.userNotificationService.countUnreadNotifications()
+ .subscribe(
+ result => {
+ this.unreadNotifications = Math.min(result, 99) // Limit number to 99
+ this.subscribeToNotifications()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+
+ this.routeSub = this.router.events
+ .pipe(filter(event => event instanceof NavigationEnd))
+ .subscribe(() => this.closePopover())
+ }
+
+ ngOnDestroy () {
+ if (this.notificationSub) this.notificationSub.unsubscribe()
+ if (this.routeSub) this.routeSub.unsubscribe()
+ }
+
+ closePopover () {
+ this.popover.close()
+ }
+
+ private subscribeToNotifications () {
+ this.notificationSub = this.userNotificationService.getMyNotificationsSocket()
+ .subscribe(data => {
+ if (data.type === 'new') return this.unreadNotifications++
+ if (data.type === 'read') return this.unreadNotifications--
+ if (data.type === 'read-all') return this.unreadNotifications = 0
+ })
+ }
+
+}
diff --git a/client/src/app/menu/index.ts b/client/src/app/menu/index.ts
index 421271c12..39dbde750 100644
--- a/client/src/app/menu/index.ts
+++ b/client/src/app/menu/index.ts
@@ -1 +1,3 @@
+export * from './language-chooser.component'
+export * from './avatar-notification.component'
export * from './menu.component'
diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html
index e04bdf3d6..aa5bfa9c9 100644
--- a/client/src/app/menu/menu.component.html
+++ b/client/src/app/menu/menu.component.html
@@ -2,9 +2,7 @@