- 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
"@types/markdown-it": "^0.0.5",
"@types/node": "^10.9.2",
"@types/sanitize-html": "1.18.0",
+ "@types/socket.io-client": "^1.4.32",
"@types/video.js": "^7.2.5",
"@types/webtorrent": "^0.98.4",
"angular2-hotkeys": "^2.1.2",
"sanitize-html": "^1.18.4",
"sass-loader": "^7.1.0",
"sass-resources-loader": "^2.0.0",
+ "socket.io-client": "^2.2.0",
"stream-browserify": "^2.0.1",
"stream-http": "^3.0.0",
"terser-webpack-plugin": "^1.1.0",
--- /dev/null
+<div class="header">
+ <a routerLink="/my-account/settings" i18n>Notification preferences</a>
+
+ <button (click)="markAllAsRead()" i18n>Mark all as read</button>
+</div>
+
+<my-user-notifications #userNotification></my-user-notifications>
--- /dev/null
+@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;
+}
--- /dev/null
+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()
+ }
+}
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 = [
{
title: 'Videos history'
}
}
+ },
+ {
+ path: 'notifications',
+ component: MyAccountNotificationsComponent,
+ data: {
+ meta: {
+ title: 'Notifications'
+ }
+ }
}
]
}
--- /dev/null
+export * from './my-account-notification-preferences.component'
--- /dev/null
+<div class="custom-row">
+ <div i18n>Activities</div>
+ <div i18n>Web</div>
+ <div i18n *ngIf="emailEnabled">Email</div>
+</div>
+
+<div class="custom-row" *ngFor="let notificationType of notificationSettingKeys">
+ <ng-container *ngIf="hasUserRight(notificationType)">
+ <div>{{ labelNotifications[notificationType] }}</div>
+
+ <div>
+ <p-inputSwitch [(ngModel)]="webNotifications[notificationType]" (onChange)="updateWebSetting(notificationType, $event.checked)"></p-inputSwitch>
+ </div>
+
+ <div *ngIf="emailEnabled">
+ <p-inputSwitch [(ngModel)]="emailNotifications[notificationType]" (onChange)="updateEmailSetting(notificationType, $event.checked)"></p-inputSwitch>
+ </div>
+ </ng-container>
+</div>
--- /dev/null
+@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
+ }
+}
+
--- /dev/null
+import { Component, Input, OnInit } from '@angular/core'
+import { User } from '@app/shared'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Subject } from 'rxjs'
+import { UserNotificationSetting, UserNotificationSettingValue, UserRight } from '../../../../../../shared'
+import { Notifier, ServerService } from '@app/core'
+import { debounce } from 'lodash-es'
+import { UserNotificationService } from '@app/shared/users/user-notification.service'
+
+@Component({
+ selector: 'my-account-notification-preferences',
+ templateUrl: './my-account-notification-preferences.component.html',
+ styleUrls: [ './my-account-notification-preferences.component.scss' ]
+})
+export class MyAccountNotificationPreferencesComponent implements OnInit {
+ @Input() user: User = null
+ @Input() userInformationLoaded: Subject<any>
+
+ notificationSettingKeys: (keyof UserNotificationSetting)[] = []
+ emailNotifications: { [ id in keyof UserNotificationSetting ]: boolean } = {} as any
+ webNotifications: { [ id in keyof UserNotificationSetting ]: boolean } = {} as any
+ labelNotifications: { [ id in keyof UserNotificationSetting ]: string } = {} as any
+ rightNotifications: { [ id in keyof Partial<UserNotificationSetting> ]: UserRight } = {} as any
+ emailEnabled: boolean
+
+ private savePreferences = debounce(this.savePreferencesImpl.bind(this), 500)
+
+ constructor (
+ private i18n: I18n,
+ private userNotificationService: UserNotificationService,
+ private serverService: ServerService,
+ private notifier: Notifier
+ ) {
+ this.labelNotifications = {
+ newVideoFromSubscription: this.i18n('New video from your subscriptions'),
+ newCommentOnMyVideo: this.i18n('New comment on your video'),
+ videoAbuseAsModerator: this.i18n('New video abuse on local video'),
+ blacklistOnMyVideo: this.i18n('One of your video is blacklisted/unblacklisted'),
+ myVideoPublished: this.i18n('Video published (after transcoding/scheduled update)'),
+ myVideoImportFinished: this.i18n('Video import finished'),
+ newUserRegistration: this.i18n('A new user registered on your instance'),
+ newFollow: this.i18n('You or your channel(s) has a new follower'),
+ commentMention: this.i18n('Someone mentioned you in video comments')
+ }
+ this.notificationSettingKeys = Object.keys(this.labelNotifications) as (keyof UserNotificationSetting)[]
+
+ this.rightNotifications = {
+ videoAbuseAsModerator: UserRight.MANAGE_VIDEO_ABUSES,
+ newUserRegistration: UserRight.MANAGE_USERS
+ }
+
+ this.emailEnabled = this.serverService.getConfig().email.enabled
+ }
+
+ ngOnInit () {
+ this.userInformationLoaded.subscribe(() => this.loadNotificationSettings())
+ }
+
+ hasUserRight (field: keyof UserNotificationSetting) {
+ const rightToHave = this.rightNotifications[field]
+ if (!rightToHave) return true // No rights needed
+
+ return this.user.hasRight(rightToHave)
+ }
+
+ updateEmailSetting (field: keyof UserNotificationSetting, value: boolean) {
+ if (value === true) this.user.notificationSettings[field] |= UserNotificationSettingValue.EMAIL
+ else this.user.notificationSettings[field] &= ~UserNotificationSettingValue.EMAIL
+
+ this.savePreferences()
+ }
+
+ updateWebSetting (field: keyof UserNotificationSetting, value: boolean) {
+ if (value === true) this.user.notificationSettings[field] |= UserNotificationSettingValue.WEB
+ else this.user.notificationSettings[field] &= ~UserNotificationSettingValue.WEB
+
+ this.savePreferences()
+ }
+
+ private savePreferencesImpl () {
+ this.userNotificationService.updateNotificationSettings(this.user, this.user.notificationSettings)
+ .subscribe(
+ () => {
+ this.notifier.success(this.i18n('Preferences saved'), undefined, 2000)
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ private loadNotificationSettings () {
+ for (const key of Object.keys(this.user.notificationSettings)) {
+ const value = this.user.notificationSettings[key]
+ this.emailNotifications[key] = value & UserNotificationSettingValue.EMAIL
+
+ this.webNotifications[key] = value & UserNotificationSettingValue.WEB
+ }
+ }
+}
<my-account-profile [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-profile>
</ng-template>
+<div i18n class="account-title" id="notifications">Notifications</div>
+<my-account-notification-preferences [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-notification-preferences>
+
<div i18n class="account-title">Password</div>
<my-account-change-password></my-account-change-password>
<my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings>
<div i18n class="account-title">Danger zone</div>
-<my-account-danger-zone [user]="user"></my-account-danger-zone>
\ No newline at end of file
+<my-account-danger-zone [user]="user"></my-account-danger-zone>
label: this.i18n('My settings'),
routerLink: '/my-account/settings'
},
+ {
+ label: this.i18n('My notifications'),
+ routerLink: '/my-account/notifications'
+ },
libraryEntries,
miscEntries
]
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: [
MyAccountSubscriptionsComponent,
MyAccountBlocklistComponent,
MyAccountServerBlocklistComponent,
- MyAccountHistoryComponent
+ MyAccountHistoryComponent,
+ MyAccountNotificationsComponent,
+ MyAccountNotificationPreferencesComponent
],
exports: [
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()
const videoChannelsRoutes: Routes = [
{
- path: ':videoChannelId',
+ path: ':videoChannelName',
component: VideoChannelsComponent,
canActivateChild: [ MetaGuard ],
children: [
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)
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 {
MenuComponent,
LanguageChooserComponent,
+ AvatarNotificationComponent,
HeaderComponent
],
imports: [
--- /dev/null
+<div
+ [ngbPopover]="popContent" autoClose="outside" placement="bottom-left" container="body" popoverClass="popover-notifications"
+ i18n-title title="View your notifications" class="notification-avatar" #popover="ngbPopover"
+>
+ <div *ngIf="unreadNotifications > 0" class="unread-notifications">{{ unreadNotifications }}</div>
+
+ <img [src]="user.accountAvatarUrl" alt="Avatar" />
+</div>
+
+<ng-template #popContent>
+ <div class="notifications-header">
+ <div i18n>Notifications</div>
+
+ <a
+ i18n-title title="Update your notification preferences" class="glyphicon glyphicon-cog"
+ routerLink="/my-account/settings" fragment="notifications"
+ ></a>
+ </div>
+
+ <my-user-notifications [ignoreLoadingBar]="true" [infiniteScroll]="false"></my-user-notifications>
+
+ <a class="all-notifications" routerLink="/my-account/notifications" i18n>See all your notifications</a>
+</ng-template>
--- /dev/null
+@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;
+ }
+}
--- /dev/null
+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
+ })
+ }
+
+}
+export * from './language-chooser.component'
+export * from './avatar-notification.component'
export * from './menu.component'
<menu>
<div class="top-menu">
<div *ngIf="isLoggedIn" class="logged-in-block">
- <a routerLink="/my-account/settings">
- <img [src]="user.accountAvatarUrl" alt="Avatar" />
- </a>
+ <my-avatar-notification [user]="user"></my-avatar-notification>
<div class="logged-in-info">
<a routerLink="/my-account/settings" class="logged-in-username">{{ user.account?.displayName }}</a>
</menu>
</div>
-<my-language-chooser #languageChooserModal></my-language-chooser>
\ No newline at end of file
+<my-language-chooser #languageChooserModal></my-language-chooser>
justify-content: center;
margin-bottom: 35px;
- img {
- @include avatar(34px);
-
- margin-left: 20px;
- margin-right: 10px;
- }
-
.logged-in-info {
flex-grow: 1;
container="body"
title="Get help"
i18n-title
+ popoverClass="help-popover"
[attr.aria-pressed]="isPopoverOpened"
[ngbPopover]="tooltipTemplate"
[placement]="tooltipPlacement"
}
/deep/ {
- .popover-body {
- text-align: left;
- padding: 10px;
+ .popover-help.popover {
max-width: 300px;
- font-size: 13px;
- font-family: $main-fonts;
- background-color: #fff;
- color: #000;
- box-shadow: 0 0 6px rgba(0, 0, 0, 0.5);
+ .popover-body {
+ text-align: left;
+ padding: 10px;
+ font-size: 13px;
+ font-family: $main-fonts;
+ background-color: #fff;
+ color: #000;
+ box-shadow: 0 0 6px rgba(0, 0, 0, 0.5);
- ul {
- padding-left: 20px;
+ ul {
+ padding-left: 20px;
+ }
}
}
}
itemsPerPage: number
totalItems?: number
}
+
+export function hasMoreItems (componentPagination: ComponentPagination) {
+ // No results
+ if (componentPagination.totalItems === 0) return false
+
+ // Not loaded yet
+ if (!componentPagination.totalItems) return true
+
+ const maxPage = componentPagination.totalItems / componentPagination.itemsPerPage
+ return maxPage > componentPagination.currentPage
+}
errorMessage = errorMessage ? errorMessage : 'Unknown error.'
console.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`)
} else {
+ console.error(err)
errorMessage = err
}
import { BlocklistService } from '@app/shared/blocklist'
import { TopMenuDropdownComponent } from '@app/shared/menu/top-menu-dropdown.component'
import { UserHistoryService } from '@app/shared/users/user-history.service'
+import { UserNotificationService } from '@app/shared/users/user-notification.service'
+import { UserNotificationsComponent } from '@app/shared/users/user-notifications.component'
@NgModule({
imports: [
InstanceFeaturesTableComponent,
UserBanModalComponent,
UserModerationDropdownComponent,
- TopMenuDropdownComponent
+ TopMenuDropdownComponent,
+ UserNotificationsComponent
],
exports: [
UserBanModalComponent,
UserModerationDropdownComponent,
TopMenuDropdownComponent,
+ UserNotificationsComponent,
NumberFormatterPipe,
ObjectLengthPipe,
I18nPrimengCalendarService,
ScreenService,
+ UserNotificationService,
+
I18n
]
})
export * from './user.model'
export * from './user.service'
+export * from './user-notifications.component'
--- /dev/null
+import { UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '../../../../../shared'
+
+export class UserNotification implements UserNotificationServer {
+ id: number
+ type: UserNotificationType
+ read: boolean
+
+ video?: VideoInfo & {
+ channel: {
+ id: number
+ displayName: string
+ }
+ }
+
+ videoImport?: {
+ id: number
+ video?: VideoInfo
+ torrentName?: string
+ magnetUri?: string
+ targetUrl?: string
+ }
+
+ comment?: {
+ id: number
+ threadId: number
+ account: {
+ id: number
+ displayName: string
+ }
+ video: VideoInfo
+ }
+
+ videoAbuse?: {
+ id: number
+ video: VideoInfo
+ }
+
+ videoBlacklist?: {
+ id: number
+ video: VideoInfo
+ }
+
+ account?: {
+ id: number
+ displayName: string
+ name: string
+ }
+
+ actorFollow?: {
+ id: number
+ follower: {
+ name: string
+ displayName: string
+ }
+ following: {
+ type: 'account' | 'channel'
+ name: string
+ displayName: string
+ }
+ }
+
+ createdAt: string
+ updatedAt: string
+
+ // Additional fields
+ videoUrl?: string
+ commentUrl?: any[]
+ videoAbuseUrl?: string
+ accountUrl?: string
+ videoImportIdentifier?: string
+ videoImportUrl?: string
+
+ constructor (hash: UserNotificationServer) {
+ this.id = hash.id
+ this.type = hash.type
+ this.read = hash.read
+
+ this.video = hash.video
+ this.videoImport = hash.videoImport
+ this.comment = hash.comment
+ this.videoAbuse = hash.videoAbuse
+ this.videoBlacklist = hash.videoBlacklist
+ this.account = hash.account
+ this.actorFollow = hash.actorFollow
+
+ this.createdAt = hash.createdAt
+ this.updatedAt = hash.updatedAt
+
+ switch (this.type) {
+ case UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION:
+ this.videoUrl = this.buildVideoUrl(this.video)
+ break
+
+ case UserNotificationType.UNBLACKLIST_ON_MY_VIDEO:
+ this.videoUrl = this.buildVideoUrl(this.video)
+ break
+
+ case UserNotificationType.NEW_COMMENT_ON_MY_VIDEO:
+ case UserNotificationType.COMMENT_MENTION:
+ this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ]
+ break
+
+ case UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS:
+ this.videoAbuseUrl = '/admin/moderation/video-abuses/list'
+ this.videoUrl = this.buildVideoUrl(this.videoAbuse.video)
+ break
+
+ case UserNotificationType.BLACKLIST_ON_MY_VIDEO:
+ this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video)
+ break
+
+ case UserNotificationType.MY_VIDEO_PUBLISHED:
+ this.videoUrl = this.buildVideoUrl(this.video)
+ break
+
+ case UserNotificationType.MY_VIDEO_IMPORT_SUCCESS:
+ this.videoImportUrl = this.buildVideoImportUrl()
+ this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport)
+ this.videoUrl = this.buildVideoUrl(this.videoImport.video)
+ break
+
+ case UserNotificationType.MY_VIDEO_IMPORT_ERROR:
+ this.videoImportUrl = this.buildVideoImportUrl()
+ this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport)
+ break
+
+ case UserNotificationType.NEW_USER_REGISTRATION:
+ this.accountUrl = this.buildAccountUrl(this.account)
+ break
+
+ case UserNotificationType.NEW_FOLLOW:
+ this.accountUrl = this.buildAccountUrl(this.actorFollow.follower)
+ break
+ }
+ }
+
+ private buildVideoUrl (video: { uuid: string }) {
+ return '/videos/watch/' + video.uuid
+ }
+
+ private buildAccountUrl (account: { name: string }) {
+ return '/accounts/' + account.name
+ }
+
+ private buildVideoImportUrl () {
+ return '/my-account/video-imports'
+ }
+
+ private buildVideoImportIdentifier (videoImport: { targetUrl?: string, magnetUri?: string, torrentName?: string }) {
+ return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName
+ }
+
+}
--- /dev/null
+import { Injectable } from '@angular/core'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { RestExtractor, RestService } from '@app/shared/rest'
+import { catchError, map, tap } from 'rxjs/operators'
+import { environment } from '../../../environments/environment'
+import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '../../../../../shared'
+import { UserNotification } from '@app/shared/users/user-notification.model'
+import { Subject } from 'rxjs'
+import * as io from 'socket.io-client'
+import { AuthService } from '@app/core'
+import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
+import { User } from '@app/shared'
+
+@Injectable()
+export class UserNotificationService {
+ static BASE_NOTIFICATIONS_URL = environment.apiUrl + '/api/v1/users/me/notifications'
+ static BASE_NOTIFICATION_SETTINGS = environment.apiUrl + '/api/v1/users/me/notification-settings'
+
+ private notificationSubject = new Subject<{ type: 'new' | 'read' | 'read-all', notification?: UserNotification }>()
+
+ private socket: SocketIOClient.Socket
+
+ constructor (
+ private auth: AuthService,
+ private authHttp: HttpClient,
+ private restExtractor: RestExtractor,
+ private restService: RestService
+ ) {}
+
+ listMyNotifications (pagination: ComponentPagination, unread?: boolean, ignoreLoadingBar = false) {
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, this.restService.componentPaginationToRestPagination(pagination))
+
+ if (unread) params = params.append('unread', `${unread}`)
+
+ const headers = ignoreLoadingBar ? { ignoreLoadingBar: '' } : undefined
+
+ return this.authHttp.get<ResultList<UserNotification>>(UserNotificationService.BASE_NOTIFICATIONS_URL, { params, headers })
+ .pipe(
+ map(res => this.restExtractor.convertResultListDateToHuman(res)),
+ map(res => this.restExtractor.applyToResultListData(res, this.formatNotification.bind(this))),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ countUnreadNotifications () {
+ return this.listMyNotifications({ currentPage: 1, itemsPerPage: 0 }, true)
+ .pipe(map(n => n.total))
+ }
+
+ getMyNotificationsSocket () {
+ const socket = this.getSocket()
+
+ socket.on('new-notification', (n: UserNotificationServer) => {
+ this.notificationSubject.next({ type: 'new', notification: new UserNotification(n) })
+ })
+
+ return this.notificationSubject.asObservable()
+ }
+
+ markAsRead (notification: UserNotification) {
+ const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read'
+
+ const body = { ids: [ notification.id ] }
+ const headers = { ignoreLoadingBar: '' }
+
+ return this.authHttp.post(url, body, { headers })
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ tap(() => this.notificationSubject.next({ type: 'read' })),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ markAllAsRead () {
+ const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read-all'
+ const headers = { ignoreLoadingBar: '' }
+
+ return this.authHttp.post(url, {}, { headers })
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ tap(() => this.notificationSubject.next({ type: 'read-all' })),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ updateNotificationSettings (user: User, settings: UserNotificationSetting) {
+ const url = UserNotificationService.BASE_NOTIFICATION_SETTINGS
+
+ return this.authHttp.put(url, settings)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ private getSocket () {
+ if (this.socket) return this.socket
+
+ this.socket = io(environment.apiUrl + '/user-notifications', {
+ query: { accessToken: this.auth.getAccessToken() }
+ })
+
+ return this.socket
+ }
+
+ private formatNotification (notification: UserNotificationServer) {
+ return new UserNotification(notification)
+ }
+}
--- /dev/null
+<div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div>
+
+<div class="notifications" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()">
+ <div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }">
+
+ <div [ngSwitch]="notification.type">
+ <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION">
+ {{ notification.video.channel.displayName }} published a <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">new video</a>
+ </ng-container>
+
+ <ng-container i18n *ngSwitchCase="UserNotificationType.UNBLACKLIST_ON_MY_VIDEO">
+ Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been unblacklisted
+ </ng-container>
+
+ <ng-container i18n *ngSwitchCase="UserNotificationType.BLACKLIST_ON_MY_VIDEO">
+ Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoBlacklist.video.name }}</a> has been blacklisted
+ </ng-container>
+
+ <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS">
+ <a (click)="markAsRead(notification)" [routerLink]="notification.videoAbuseUrl">A new video abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoAbuse.video.name }}</a>
+ </ng-container>
+
+ <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_COMMENT_ON_MY_VIDEO">
+ {{ notification.comment.account.displayName }} commented your video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.comment.video.name }}</a>
+ </ng-container>
+
+ <ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_PUBLISHED">
+ Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been published
+ </ng-container>
+
+ <ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_SUCCESS">
+ <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">Your video import</a> {{ notification.videoImportIdentifier }} succeeded
+ </ng-container>
+
+ <ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_ERROR">
+ <a (click)="markAsRead(notification)" [routerLink]="notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} failed
+ </ng-container>
+
+ <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_USER_REGISTRATION">
+ User <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.account.name }} registered</a> on your instance
+ </ng-container>
+
+ <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_FOLLOW">
+ <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.actorFollow.follower.displayName }}</a> is following
+
+ <ng-container *ngIf="notification.actorFollow.following.type === 'channel'">
+ your channel {{ notification.actorFollow.following.displayName }}
+ </ng-container>
+ <ng-container *ngIf="notification.actorFollow.following.type === 'account'">your account</ng-container>
+ </ng-container>
+
+ <ng-container i18n *ngSwitchCase="UserNotificationType.COMMENT_MENTION">
+ {{ notification.comment.account.displayName }} mentioned you on <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">video {{ notification.comment.video.name }}</a>
+ </ng-container>
+ </div>
+
+ <div i18n title="Mark as read" class="mark-as-read">
+ <div class="glyphicon glyphicon-ok" (click)="markAsRead(notification)"></div>
+ </div>
+ </div>
+</div>
--- /dev/null
+.notification {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: inherit;
+ padding: 15px 10px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.10);
+
+ .mark-as-read {
+ min-width: 35px;
+
+ .glyphicon {
+ display: none;
+ cursor: pointer;
+ color: rgba(20, 20, 20, 0.5)
+ }
+ }
+
+ &.unread {
+ background-color: rgba(0, 0, 0, 0.05);
+
+ &:hover .mark-as-read .glyphicon {
+ display: block;
+
+ &:hover {
+ color: rgba(20, 20, 20, 0.8);
+ }
+ }
+ }
+}
--- /dev/null
+import { Component, Input, OnInit } from '@angular/core'
+import { UserNotificationService } from '@app/shared/users/user-notification.service'
+import { UserNotificationType } from '../../../../../shared'
+import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
+import { Notifier } from '@app/core'
+import { UserNotification } from '@app/shared/users/user-notification.model'
+
+@Component({
+ selector: 'my-user-notifications',
+ templateUrl: 'user-notifications.component.html',
+ styleUrls: [ 'user-notifications.component.scss' ]
+})
+export class UserNotificationsComponent implements OnInit {
+ @Input() ignoreLoadingBar = false
+ @Input() infiniteScroll = true
+
+ notifications: UserNotification[] = []
+
+ // So we can access it in the template
+ UserNotificationType = UserNotificationType
+
+ componentPagination: ComponentPagination = {
+ currentPage: 1,
+ itemsPerPage: 10,
+ totalItems: null
+ }
+
+ constructor (
+ private userNotificationService: UserNotificationService,
+ private notifier: Notifier
+ ) { }
+
+ ngOnInit () {
+ this.loadMoreNotifications()
+ }
+
+ loadMoreNotifications () {
+ this.userNotificationService.listMyNotifications(this.componentPagination, undefined, this.ignoreLoadingBar)
+ .subscribe(
+ result => {
+ this.notifications = this.notifications.concat(result.data)
+ this.componentPagination.totalItems = result.total
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ onNearOfBottom () {
+ if (this.infiniteScroll === false) return
+
+ this.componentPagination.currentPage++
+
+ if (hasMoreItems(this.componentPagination)) {
+ this.loadMoreNotifications()
+ }
+ }
+
+ markAsRead (notification: UserNotification) {
+ this.userNotificationService.markAsRead(notification)
+ .subscribe(
+ () => {
+ notification.read = true
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ markAllAsRead () {
+ this.userNotificationService.markAllAsRead()
+ .subscribe(
+ () => {
+ for (const notification of this.notifications) {
+ notification.read = true
+ }
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+}
-import { hasUserRight, User as UserServerModel, UserRight, UserRole, VideoChannel } from '../../../../../shared'
+import { hasUserRight, User as UserServerModel, UserNotificationSetting, UserRight, UserRole, VideoChannel } from '../../../../../shared'
import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type'
import { Account } from '@app/shared/account/account.model'
import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
blocked: boolean
blockedReason?: string
+ notificationSettings?: UserNotificationSetting
+
constructor (hash: Partial<UserServerModel>) {
this.id = hash.id
this.username = hash.username
this.blocked = hash.blocked
this.blockedReason = hash.blockedReason
+ this.notificationSettings = hash.notificationSettings
+
if (hash.account !== undefined) {
this.account = new Account(hash.account)
}
import { Subscription } from 'rxjs'
import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model'
import { AuthService } from '../../../core/auth'
-import { ComponentPagination } from '../../../shared/rest/component-pagination.model'
+import { ComponentPagination, hasMoreItems } from '../../../shared/rest/component-pagination.model'
import { User } from '../../../shared/users'
import { VideoSortField } from '../../../shared/video/sort-field.type'
import { VideoDetails } from '../../../shared/video/video-details.model'
onNearOfBottom () {
this.componentPagination.currentPage++
- if (this.hasMoreComments()) {
+ if (hasMoreItems(this.componentPagination)) {
this.loadMoreComments()
}
}
- private hasMoreComments () {
- // No results
- if (this.componentPagination.totalItems === 0) return false
-
- // Not loaded yet
- if (!this.componentPagination.totalItems) return true
-
- const maxPage = this.componentPagination.totalItems / this.componentPagination.itemsPerPage
- return maxPage > this.componentPagination.currentPage
- }
-
private deleteLocalCommentThread (parentComment: VideoCommentThreadTree, commentToDelete: VideoComment) {
for (const commentChild of parentComment.children) {
if (commentChild.comment.id === commentToDelete.id) {
$nav-pills-link-active-bg: #F0F0F0;
$nav-pills-link-active-color: #000;
-$zindex-dropdown: 10000;
\ No newline at end of file
+$zindex-dropdown: 10000;
+$zindex-popover: 10000;
.notification-block {
display: flex;
+ align-items: center;
+ padding: 5px;
.message {
flex-grow: 1;
p {
font-size: 15px;
+ margin-bottom: 0;
}
}
.glyphicon {
font-size: 32px;
- margin-top: 15px;
margin-right: 5px;
}
}
dependencies:
"@types/node" "*"
+"@types/socket.io-client@^1.4.32":
+ version "1.4.32"
+ resolved "https://registry.yarnpkg.com/@types/socket.io-client/-/socket.io-client-1.4.32.tgz#988a65a0386c274b1c22a55377fab6a30789ac14"
+ integrity sha512-Vs55Kq8F+OWvy1RLA31rT+cAyemzgm0EWNeax6BWF8H7QiiOYMJIdcwSDdm5LVgfEkoepsWkS+40+WNb7BUMbg==
+
"@types/video.js@^7.2.5":
version "7.2.5"
resolved "https://registry.yarnpkg.com/@types/video.js/-/video.js-7.2.5.tgz#20896c81141d3517c3a89bb6eb97c6a191aa5d4c"
xmlhttprequest-ssl "~1.5.4"
yeast "0.1.2"
+engine.io-client@~3.3.1:
+ version "3.3.1"
+ resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.3.1.tgz#afedb4a07b2ea48b7190c3136bfea98fdd4f0f03"
+ integrity sha512-q66JBFuQcy7CSlfAz9L3jH+v7DTT3i6ZEadYcVj2pOs8/0uJHLxKX3WBkGTvULJMdz0tUCyJag0aKT/dpXL9BQ==
+ dependencies:
+ component-emitter "1.2.1"
+ component-inherit "0.0.3"
+ debug "~3.1.0"
+ engine.io-parser "~2.1.1"
+ has-cors "1.1.0"
+ indexof "0.0.1"
+ parseqs "0.0.5"
+ parseuri "0.0.5"
+ ws "~6.1.0"
+ xmlhttprequest-ssl "~1.5.4"
+ yeast "0.1.2"
+
engine.io-parser@~2.1.0, engine.io-parser@~2.1.1:
version "2.1.3"
resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.3.tgz#757ab970fbf2dfb32c7b74b033216d5739ef79a6"
socket.io-parser "~3.2.0"
to-array "0.1.4"
+socket.io-client@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.2.0.tgz#84e73ee3c43d5020ccc1a258faeeb9aec2723af7"
+ integrity sha512-56ZrkTDbdTLmBIyfFYesgOxsjcLnwAKoN4CiPyTVkMQj3zTUh0QAx3GbvIvLpFEOvQWu92yyWICxB0u7wkVbYA==
+ dependencies:
+ backo2 "1.0.2"
+ base64-arraybuffer "0.1.5"
+ component-bind "1.0.0"
+ component-emitter "1.2.1"
+ debug "~3.1.0"
+ engine.io-client "~3.3.1"
+ has-binary2 "~1.0.2"
+ has-cors "1.1.0"
+ indexof "0.0.1"
+ object-component "0.0.3"
+ parseqs "0.0.5"
+ parseuri "0.0.5"
+ socket.io-parser "~3.3.0"
+ to-array "0.1.4"
+
socket.io-parser@~3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.2.0.tgz#e7c6228b6aa1f814e6148aea325b51aa9499e077"
debug "~3.1.0"
isarray "2.0.1"
+socket.io-parser@~3.3.0:
+ version "3.3.0"
+ resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.0.tgz#2b52a96a509fdf31440ba40fed6094c7d4f1262f"
+ integrity sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==
+ dependencies:
+ component-emitter "1.2.1"
+ debug "~3.1.0"
+ isarray "2.0.1"
+
socket.io@2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.1.1.tgz#a069c5feabee3e6b214a75b40ce0652e1cfb9980"
dependencies:
async-limiter "~1.0.0"
-ws@^6.0.0:
+ws@^6.0.0, ws@~6.1.0:
version "6.1.2"
resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.2.tgz#3cc7462e98792f0ac679424148903ded3b9c3ad8"
integrity sha512-rfUqzvz0WxmSXtJpPMX2EeASXabOrSMk1ruMOV3JBTBjo4ac2lDjGGsbQSyxj8Odhw5fBib8ZKEjDNvgouNKYw==
}
removeFiles () {
- rm -rf "./test$1" "./config/local-test.json" "./config/local-test-$1.json"
+ rm -rf "./test$1" "./config/local-test-$1.json"
}
dropRedis () {
asyncMiddleware(markAsReadUserNotifications)
)
+myNotificationsRouter.post('/me/notifications/read-all',
+ authenticate,
+ asyncMiddleware(markAsReadAllUserNotifications)
+)
+
export {
myNotificationsRouter
}
myVideoImportFinished: body.myVideoImportFinished,
newFollow: body.newFollow,
newUserRegistration: body.newUserRegistration,
- commentMention: body.commentMention,
+ commentMention: body.commentMention
}
await UserNotificationSettingModel.update(values, query)
return res.status(204).end()
}
+
+async function markAsReadAllUserNotifications (req: express.Request, res: express.Response) {
+ const user: UserModel = res.locals.oauth.token.User
+
+ await UserNotificationModel.markAllAsRead(user.id)
+
+ return res.status(204).end()
+}
return Array.isArray(value)
}
-function isIntArray (value: any) {
- return Array.isArray(value) && value.every(v => validator.isInt('' + v))
+function isNotEmptyIntArray (value: any) {
+ return Array.isArray(value) && value.every(v => validator.isInt('' + v)) && value.length !== 0
}
function isDateValid (value: string) {
export {
exists,
- isIntArray,
+ isNotEmptyIntArray,
isArray,
isIdValid,
isUUIDValid,
function isUserNotificationSettingValid (value: any) {
return exists(value) &&
- validator.isInt('' + value) &&
- UserNotificationSettingValue[ value ] !== undefined
+ validator.isInt('' + value) && (
+ value === UserNotificationSettingValue.NONE ||
+ value === UserNotificationSettingValue.WEB ||
+ value === UserNotificationSettingValue.EMAIL ||
+ value === (UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL)
+ )
}
export {
'("newVideoFromSubscription", "newCommentOnMyVideo", "videoAbuseAsModerator", "blacklistOnMyVideo", ' +
'"myVideoPublished", "myVideoImportFinished", "newUserRegistration", "newFollow", "commentMention", ' +
'"userId", "createdAt", "updatedAt") ' +
- '(SELECT 2, 2, 4, 4, 2, 2, 2, 2, 2, id, NOW(), NOW() FROM "user")'
+ '(SELECT 1, 1, 3, 3, 1, 1, 1, 1, 1, id, NOW(), NOW() FROM "user")'
await utils.sequelize.query(query)
}
private isEmailEnabled (user: UserModel, value: UserNotificationSettingValue) {
if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION === true && user.emailVerified !== true) return false
- return value === UserNotificationSettingValue.EMAIL || value === UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
+ return value & UserNotificationSettingValue.EMAIL
}
private isWebNotificationEnabled (value: UserNotificationSettingValue) {
- return value === UserNotificationSettingValue.WEB_NOTIFICATION || value === UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
+ return value & UserNotificationSettingValue.WEB
}
static get Instance () {
function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Transaction | undefined) {
const values: UserNotificationSetting & { userId: number } = {
userId: user.id,
- newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION,
- newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION,
- myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION,
- myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION,
- videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
- blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
- newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION,
- commentMention: UserNotificationSettingValue.WEB_NOTIFICATION,
- newFollow: UserNotificationSettingValue.WEB_NOTIFICATION
+ newVideoFromSubscription: UserNotificationSettingValue.WEB,
+ newCommentOnMyVideo: UserNotificationSettingValue.WEB,
+ myVideoImportFinished: UserNotificationSettingValue.WEB,
+ myVideoPublished: UserNotificationSettingValue.WEB,
+ videoAbuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
+ blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
+ newUserRegistration: UserNotificationSettingValue.WEB,
+ commentMention: UserNotificationSettingValue.WEB,
+ newFollow: UserNotificationSettingValue.WEB
}
return UserNotificationSettingModel.create(values, { transaction: t })
import { logger } from '../../helpers/logger'
import { areValidationErrors } from './utils'
import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
-import { isIntArray } from '../../helpers/custom-validators/misc'
+import { isNotEmptyIntArray } from '../../helpers/custom-validators/misc'
const listUserNotificationsValidator = [
query('unread')
const markAsReadUserNotificationsValidator = [
body('ids')
- .custom(isIntArray).withMessage('Should have a valid notification ids to mark as read'),
+ .optional()
+ .custom(isNotEmptyIntArray).withMessage('Should have a valid notification ids to mark as read'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking markAsReadUserNotificationsValidator parameters', { parameters: req.body })
return UserNotificationModel.update({ read: true }, query)
}
+ static markAllAsRead (userId: number) {
+ const query = { where: { userId } }
+
+ return UserNotificationModel.update({ read: true }, query)
+ }
+
toFormattedJSON (): UserNotification {
const video = this.Video ? Object.assign(this.formatVideo(this.Video), {
channel: {
statusCodeExpected: 400
})
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: {
+ ids: [ ]
+ },
+ token: server.accessToken,
+ statusCodeExpected: 400
+ })
+
await makePostBodyRequest({
url: server.url,
path,
})
})
+ describe('When marking as read my notifications', function () {
+ const path = '/api/v1/users/me/notifications/read-all'
+
+ it('Should fail with a non authenticated user', async function () {
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ statusCodeExpected: 401
+ })
+ })
+
+ it('Should succeed with the correct parameters', async function () {
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ token: server.accessToken,
+ statusCodeExpected: 204
+ })
+ })
+ })
+
describe('When updating my notification settings', function () {
const path = '/api/v1/users/me/notification-settings'
const correctFields: UserNotificationSetting = {
- newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION,
- newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION,
- videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION,
- blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION,
- myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION,
- myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION,
- commentMention: UserNotificationSettingValue.WEB_NOTIFICATION,
- newFollow: UserNotificationSettingValue.WEB_NOTIFICATION,
- newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION
+ newVideoFromSubscription: UserNotificationSettingValue.WEB,
+ newCommentOnMyVideo: UserNotificationSettingValue.WEB,
+ videoAbuseAsModerator: UserNotificationSettingValue.WEB,
+ blacklistOnMyVideo: UserNotificationSettingValue.WEB,
+ myVideoImportFinished: UserNotificationSettingValue.WEB,
+ myVideoPublished: UserNotificationSettingValue.WEB,
+ commentMention: UserNotificationSettingValue.WEB,
+ newFollow: UserNotificationSettingValue.WEB,
+ newUserRegistration: UserNotificationSettingValue.WEB
}
it('Should fail with missing fields', async function () {
url: server.url,
path,
token: server.accessToken,
- fields: { newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION },
+ fields: { newVideoFromSubscription: UserNotificationSettingValue.WEB },
statusCodeExpected: 400
})
})
email: 'email@example.com',
emailVerified: true,
videoQuota: 42,
- role: UserRole.MODERATOR
+ role: UserRole.USER
}
await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields, statusCodeExpected: 204 })
- userAccessToken = await userLogin(server, user)
})
})
getLastNotification,
getUserNotifications,
markAsReadNotifications,
- updateMyNotificationSettings
+ updateMyNotificationSettings,
+ markAsReadAllNotifications
} from '../../../../shared/utils/users/user-notifications'
import {
User,
let channelId: number
const allNotificationSettings: UserNotificationSetting = {
- newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
- newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
- videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
- blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
- myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
- myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
- commentMention: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
- newFollow: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
- newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
+ newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
+ newCommentOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
+ videoAbuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
+ blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
+ myVideoImportFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
+ myVideoPublished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
+ commentMention: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
+ newFollow: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
+ newUserRegistration: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
}
before(async function () {
})
it('Should send a new video notification if the user follows the local video publisher', async function () {
+ this.timeout(10000)
+
await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9001')
+ await waitJobs(servers)
const { name, uuid } = await uploadVideoByLocalAccount(servers)
await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence')
this.timeout(50000) // Server 2 has transcoding enabled
await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9002')
+ await waitJobs(servers)
const { name, uuid } = await uploadVideoByRemoteAccount(servers)
await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence')
})
it('Should notify when a local channel is following one of our channel', async function () {
- await addUserSubscription(servers[0].url, servers[0].accessToken, 'user_1_channel@localhost:9001')
+ this.timeout(10000)
+ await addUserSubscription(servers[0].url, servers[0].accessToken, 'user_1_channel@localhost:9001')
await waitJobs(servers)
await checkNewActorFollow(baseParams, 'channel', 'root', 'super root name', myChannelName, 'presence')
})
it('Should notify when a remote channel is following one of our channel', async function () {
- await addUserSubscription(servers[1].url, servers[1].accessToken, 'user_1_channel@localhost:9001')
+ this.timeout(10000)
+ await addUserSubscription(servers[1].url, servers[1].accessToken, 'user_1_channel@localhost:9001')
await waitJobs(servers)
await checkNewActorFollow(baseParams, 'channel', 'root', 'super root 2 name', myChannelName, 'presence')
expect(notification.read).to.be.false
}
})
+
+ it('Should mark as read all notifications', async function () {
+ await markAsReadAllNotifications(servers[ 0 ].url, userAccessToken)
+
+ const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 0, 10, true)
+
+ expect(res.body.total).to.equal(0)
+ expect(res.body.data).to.have.lengthOf(0)
+ })
})
describe('Notification settings', function () {
it('Should only have web notifications', async function () {
await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, {
- newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION
+ newVideoFromSubscription: UserNotificationSettingValue.WEB
}))
{
const res = await getMyUserInformation(servers[0].url, userAccessToken)
const info = res.body as User
- expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB_NOTIFICATION)
+ expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB)
}
const { name, uuid } = await uploadVideoByLocalAccount(servers)
it('Should have email and web notifications', async function () {
await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, {
- newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
+ newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
}))
{
const res = await getMyUserInformation(servers[0].url, userAccessToken)
const info = res.body as User
- expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL)
+ expect(info.notificationSettings.newVideoFromSubscription).to.equal(
+ UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
+ )
}
const { name, uuid } = await uploadVideoByLocalAccount(servers)
accessTokenUser = await userLogin(server, user)
})
- it('Should not be able to delete a user by a moderator', async function () {
- await removeUser(server.url, 2, accessTokenUser, 403)
- })
-
it('Should be able to list video blacklist by a moderator', async function () {
await getBlacklistedVideosList(server.url, accessTokenUser)
})
export enum UserNotificationSettingValue {
- NONE = 1,
- WEB_NOTIFICATION = 2,
- EMAIL = 3,
- WEB_NOTIFICATION_AND_EMAIL = 4
+ NONE = 0,
+ WEB = 1 << 0,
+ EMAIL = 1 << 1
}
export interface UserNotificationSetting {
NEW_VIDEO_FROM_SUBSCRIPTION = 1,
NEW_COMMENT_ON_MY_VIDEO = 2,
NEW_VIDEO_ABUSE_FOR_MODERATORS = 3,
+
BLACKLIST_ON_MY_VIDEO = 4,
UNBLACKLIST_ON_MY_VIDEO = 5,
+
MY_VIDEO_PUBLISHED = 6,
+
MY_VIDEO_IMPORT_SUCCESS = 7,
MY_VIDEO_IMPORT_ERROR = 8,
+
NEW_USER_REGISTRATION = 9,
NEW_FOLLOW = 10,
COMMENT_MENTION = 11
}
async function waitJobs (serversArg: ServerInfo[] | ServerInfo) {
+ const pendingJobWait = process.env.NODE_PENDING_JOB_WAIT ? parseInt(process.env.NODE_PENDING_JOB_WAIT, 10) : 2000
let servers: ServerInfo[]
if (Array.isArray(serversArg) === false) servers = [ serversArg as ServerInfo ]
// Retry, in case of new jobs were created
if (pendingRequests === false) {
- await wait(2000)
+ await wait(pendingJobWait)
await Promise.all(tasksBuilder())
}
statusCodeExpected
})
}
+function markAsReadAllNotifications (url: string, token: string, statusCodeExpected = 204) {
+ const path = '/api/v1/users/me/notifications/read-all'
+
+ return makePostBodyRequest({
+ url,
+ path,
+ token,
+ statusCodeExpected
+ })
+}
async function getLastNotification (serverUrl: string, accessToken: string) {
const res = await getUserNotifications(serverUrl, accessToken, 0, 1, undefined, '-createdAt')
CheckerBaseParams,
CheckerType,
checkNotification,
+ markAsReadAllNotifications,
checkMyVideoImportIsFinished,
checkUserRegistered,
checkVideoIsPublished,