Add notifications in the client

This commit is contained in:
Chocobozzz 2019-01-08 11:26:41 +01:00 committed by Chocobozzz
parent f7cc67b455
commit 2f1548fda3
56 changed files with 1073 additions and 112 deletions

View File

@ -48,12 +48,12 @@ matrix:
- env: TEST_SUITE=jest - env: TEST_SUITE=jest
script: script:
- travis_retry npm run travis -- "$TEST_SUITE" - NODE_PENDING_JOB_WAIT=1000 travis_retry npm run travis -- "$TEST_SUITE"
after_failure: after_failure:
- cat test1/logs/all-logs.log - cat test1/logs/peertube.log
- cat test2/logs/all-logs.log - cat test2/logs/peertube.log
- cat test3/logs/all-logs.log - cat test3/logs/peertube.log
- cat test4/logs/all-logs.log - cat test4/logs/peertube.log
- cat test5/logs/all-logs.log - cat test5/logs/peertube.log
- cat test6/logs/all-logs.log - cat test6/logs/peertube.log

View File

@ -94,6 +94,7 @@
"@types/markdown-it": "^0.0.5", "@types/markdown-it": "^0.0.5",
"@types/node": "^10.9.2", "@types/node": "^10.9.2",
"@types/sanitize-html": "1.18.0", "@types/sanitize-html": "1.18.0",
"@types/socket.io-client": "^1.4.32",
"@types/video.js": "^7.2.5", "@types/video.js": "^7.2.5",
"@types/webtorrent": "^0.98.4", "@types/webtorrent": "^0.98.4",
"angular2-hotkeys": "^2.1.2", "angular2-hotkeys": "^2.1.2",
@ -141,6 +142,7 @@
"sanitize-html": "^1.18.4", "sanitize-html": "^1.18.4",
"sass-loader": "^7.1.0", "sass-loader": "^7.1.0",
"sass-resources-loader": "^2.0.0", "sass-resources-loader": "^2.0.0",
"socket.io-client": "^2.2.0",
"stream-browserify": "^2.0.1", "stream-browserify": "^2.0.1",
"stream-http": "^3.0.0", "stream-http": "^3.0.0",
"terser-webpack-plugin": "^1.1.0", "terser-webpack-plugin": "^1.1.0",

View File

@ -0,0 +1,7 @@
<div class="header">
<a routerLink="/my-account/settings" i18n>Notification preferences</a>
<button (click)="markAllAsRead()" i18n>Mark all as read</button>
</div>
<my-user-notifications #userNotification></my-user-notifications>

View File

@ -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;
}

View File

@ -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()
}
}

View File

@ -14,6 +14,7 @@ import { MyAccountOwnershipComponent } from '@app/+my-account/my-account-ownersh
import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-blocklist.component' import { 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 { 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 { 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 = [ const myAccountRoutes: Routes = [
{ {
@ -124,6 +125,15 @@ const myAccountRoutes: Routes = [
title: 'Videos history' title: 'Videos history'
} }
} }
},
{
path: 'notifications',
component: MyAccountNotificationsComponent,
data: {
meta: {
title: 'Notifications'
}
}
} }
] ]
} }

View File

@ -0,0 +1 @@
export * from './my-account-notification-preferences.component'

View File

@ -0,0 +1,19 @@
<div class="custom-row">
<div i18n>Activities</div>
<div i18n>Web</div>
<div i18n *ngIf="emailEnabled">Email</div>
</div>
<div class="custom-row" *ngFor="let notificationType of notificationSettingKeys">
<ng-container *ngIf="hasUserRight(notificationType)">
<div>{{ labelNotifications[notificationType] }}</div>
<div>
<p-inputSwitch [(ngModel)]="webNotifications[notificationType]" (onChange)="updateWebSetting(notificationType, $event.checked)"></p-inputSwitch>
</div>
<div *ngIf="emailEnabled">
<p-inputSwitch [(ngModel)]="emailNotifications[notificationType]" (onChange)="updateEmailSetting(notificationType, $event.checked)"></p-inputSwitch>
</div>
</ng-container>
</div>

View File

@ -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
}
}

View File

@ -0,0 +1,99 @@
import { Component, Input, OnInit } from '@angular/core'
import { User } from '@app/shared'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { Subject } from 'rxjs'
import { UserNotificationSetting, UserNotificationSettingValue, UserRight } from '../../../../../../shared'
import { Notifier, ServerService } from '@app/core'
import { debounce } from 'lodash-es'
import { UserNotificationService } from '@app/shared/users/user-notification.service'
@Component({
selector: 'my-account-notification-preferences',
templateUrl: './my-account-notification-preferences.component.html',
styleUrls: [ './my-account-notification-preferences.component.scss' ]
})
export class MyAccountNotificationPreferencesComponent implements OnInit {
@Input() user: User = null
@Input() userInformationLoaded: Subject<any>
notificationSettingKeys: (keyof UserNotificationSetting)[] = []
emailNotifications: { [ id in keyof UserNotificationSetting ]: boolean } = {} as any
webNotifications: { [ id in keyof UserNotificationSetting ]: boolean } = {} as any
labelNotifications: { [ id in keyof UserNotificationSetting ]: string } = {} as any
rightNotifications: { [ id in keyof Partial<UserNotificationSetting> ]: UserRight } = {} as any
emailEnabled: boolean
private savePreferences = debounce(this.savePreferencesImpl.bind(this), 500)
constructor (
private i18n: I18n,
private userNotificationService: UserNotificationService,
private serverService: ServerService,
private notifier: Notifier
) {
this.labelNotifications = {
newVideoFromSubscription: this.i18n('New video from your subscriptions'),
newCommentOnMyVideo: this.i18n('New comment on your video'),
videoAbuseAsModerator: this.i18n('New video abuse on local video'),
blacklistOnMyVideo: this.i18n('One of your video is blacklisted/unblacklisted'),
myVideoPublished: this.i18n('Video published (after transcoding/scheduled update)'),
myVideoImportFinished: this.i18n('Video import finished'),
newUserRegistration: this.i18n('A new user registered on your instance'),
newFollow: this.i18n('You or your channel(s) has a new follower'),
commentMention: this.i18n('Someone mentioned you in video comments')
}
this.notificationSettingKeys = Object.keys(this.labelNotifications) as (keyof UserNotificationSetting)[]
this.rightNotifications = {
videoAbuseAsModerator: UserRight.MANAGE_VIDEO_ABUSES,
newUserRegistration: UserRight.MANAGE_USERS
}
this.emailEnabled = this.serverService.getConfig().email.enabled
}
ngOnInit () {
this.userInformationLoaded.subscribe(() => this.loadNotificationSettings())
}
hasUserRight (field: keyof UserNotificationSetting) {
const rightToHave = this.rightNotifications[field]
if (!rightToHave) return true // No rights needed
return this.user.hasRight(rightToHave)
}
updateEmailSetting (field: keyof UserNotificationSetting, value: boolean) {
if (value === true) this.user.notificationSettings[field] |= UserNotificationSettingValue.EMAIL
else this.user.notificationSettings[field] &= ~UserNotificationSettingValue.EMAIL
this.savePreferences()
}
updateWebSetting (field: keyof UserNotificationSetting, value: boolean) {
if (value === true) this.user.notificationSettings[field] |= UserNotificationSettingValue.WEB
else this.user.notificationSettings[field] &= ~UserNotificationSettingValue.WEB
this.savePreferences()
}
private savePreferencesImpl () {
this.userNotificationService.updateNotificationSettings(this.user, this.user.notificationSettings)
.subscribe(
() => {
this.notifier.success(this.i18n('Preferences saved'), undefined, 2000)
},
err => this.notifier.error(err.message)
)
}
private loadNotificationSettings () {
for (const key of Object.keys(this.user.notificationSettings)) {
const value = this.user.notificationSettings[key]
this.emailNotifications[key] = value & UserNotificationSettingValue.EMAIL
this.webNotifications[key] = value & UserNotificationSettingValue.WEB
}
}
}

View File

@ -9,6 +9,9 @@
<my-account-profile [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-profile> <my-account-profile [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-profile>
</ng-template> </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> <div i18n class="account-title">Password</div>
<my-account-change-password></my-account-change-password> <my-account-change-password></my-account-change-password>
@ -16,4 +19,4 @@
<my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings> <my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings>
<div i18n class="account-title">Danger zone</div> <div i18n class="account-title">Danger zone</div>
<my-account-danger-zone [user]="user"></my-account-danger-zone> <my-account-danger-zone [user]="user"></my-account-danger-zone>

View File

@ -68,6 +68,10 @@ export class MyAccountComponent {
label: this.i18n('My settings'), label: this.i18n('My settings'),
routerLink: '/my-account/settings' routerLink: '/my-account/settings'
}, },
{
label: this.i18n('My notifications'),
routerLink: '/my-account/notifications'
},
libraryEntries, libraryEntries,
miscEntries miscEntries
] ]

View File

@ -23,6 +23,8 @@ import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-sub
import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-blocklist.component' import { 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 { 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 { 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({ @NgModule({
imports: [ imports: [
@ -53,7 +55,9 @@ import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/m
MyAccountSubscriptionsComponent, MyAccountSubscriptionsComponent,
MyAccountBlocklistComponent, MyAccountBlocklistComponent,
MyAccountServerBlocklistComponent, MyAccountServerBlocklistComponent,
MyAccountHistoryComponent MyAccountHistoryComponent,
MyAccountNotificationsComponent,
MyAccountNotificationPreferencesComponent
], ],
exports: [ exports: [

View File

@ -55,7 +55,7 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On
this.videoChannelSub = this.videoChannelService.videoChannelLoaded this.videoChannelSub = this.videoChannelService.videoChannelLoaded
.subscribe(videoChannel => { .subscribe(videoChannel => {
this.videoChannel = videoChannel this.videoChannel = videoChannel
this.currentRoute = '/video-channels/' + this.videoChannel.uuid + '/videos' this.currentRoute = '/video-channels/' + this.videoChannel.nameWithHost + '/videos'
this.reloadVideos() this.reloadVideos()
this.generateSyndicationList() this.generateSyndicationList()

View File

@ -7,7 +7,7 @@ import { VideoChannelAboutComponent } from './video-channel-about/video-channel-
const videoChannelsRoutes: Routes = [ const videoChannelsRoutes: Routes = [
{ {
path: ':videoChannelId', path: ':videoChannelName',
component: VideoChannelsComponent, component: VideoChannelsComponent,
canActivateChild: [ MetaGuard ], canActivateChild: [ MetaGuard ],
children: [ children: [

View File

@ -34,9 +34,9 @@ export class VideoChannelsComponent implements OnInit, OnDestroy {
ngOnInit () { ngOnInit () {
this.routeSub = this.route.params this.routeSub = this.route.params
.pipe( .pipe(
map(params => params[ 'videoChannelId' ]), map(params => params[ 'videoChannelName' ]),
distinctUntilChanged(), distinctUntilChanged(),
switchMap(videoChannelId => this.videoChannelService.getVideoChannel(videoChannelId)), switchMap(videoChannelName => this.videoChannelService.getVideoChannel(videoChannelName)),
catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ])) catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ]))
) )
.subscribe(videoChannel => this.videoChannel = videoChannel) .subscribe(videoChannel => this.videoChannel = videoChannel)

View File

@ -12,13 +12,12 @@ import { AppComponent } from './app.component'
import { CoreModule } from './core' import { CoreModule } from './core'
import { HeaderComponent } from './header' import { HeaderComponent } from './header'
import { LoginModule } from './login' import { LoginModule } from './login'
import { MenuComponent } from './menu' import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
import { SharedModule } from './shared' import { SharedModule } from './shared'
import { SignupModule } from './signup' import { SignupModule } from './signup'
import { VideosModule } from './videos' import { VideosModule } from './videos'
import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n' import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n'
import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
import { SearchModule } from '@app/search' import { SearchModule } from '@app/search'
export function metaFactory (serverService: ServerService): MetaLoader { export function metaFactory (serverService: ServerService): MetaLoader {
@ -40,6 +39,7 @@ export function metaFactory (serverService: ServerService): MetaLoader {
MenuComponent, MenuComponent,
LanguageChooserComponent, LanguageChooserComponent,
AvatarNotificationComponent,
HeaderComponent HeaderComponent
], ],
imports: [ imports: [

View File

@ -0,0 +1,23 @@
<div
[ngbPopover]="popContent" autoClose="outside" placement="bottom-left" container="body" popoverClass="popover-notifications"
i18n-title title="View your notifications" class="notification-avatar" #popover="ngbPopover"
>
<div *ngIf="unreadNotifications > 0" class="unread-notifications">{{ unreadNotifications }}</div>
<img [src]="user.accountAvatarUrl" alt="Avatar" />
</div>
<ng-template #popContent>
<div class="notifications-header">
<div i18n>Notifications</div>
<a
i18n-title title="Update your notification preferences" class="glyphicon glyphicon-cog"
routerLink="/my-account/settings" fragment="notifications"
></a>
</div>
<my-user-notifications [ignoreLoadingBar]="true" [infiniteScroll]="false"></my-user-notifications>
<a class="all-notifications" routerLink="/my-account/notifications" i18n>See all your notifications</a>
</ng-template>

View File

@ -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;
}
}

View File

@ -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
})
}
}

View File

@ -1 +1,3 @@
export * from './language-chooser.component'
export * from './avatar-notification.component'
export * from './menu.component' export * from './menu.component'

View File

@ -2,9 +2,7 @@
<menu> <menu>
<div class="top-menu"> <div class="top-menu">
<div *ngIf="isLoggedIn" class="logged-in-block"> <div *ngIf="isLoggedIn" class="logged-in-block">
<a routerLink="/my-account/settings"> <my-avatar-notification [user]="user"></my-avatar-notification>
<img [src]="user.accountAvatarUrl" alt="Avatar" />
</a>
<div class="logged-in-info"> <div class="logged-in-info">
<a routerLink="/my-account/settings" class="logged-in-username">{{ user.account?.displayName }}</a> <a routerLink="/my-account/settings" class="logged-in-username">{{ user.account?.displayName }}</a>
@ -97,4 +95,4 @@
</menu> </menu>
</div> </div>
<my-language-chooser #languageChooserModal></my-language-chooser> <my-language-chooser #languageChooserModal></my-language-chooser>

View File

@ -39,13 +39,6 @@ menu {
justify-content: center; justify-content: center;
margin-bottom: 35px; margin-bottom: 35px;
img {
@include avatar(34px);
margin-left: 20px;
margin-right: 10px;
}
.logged-in-info { .logged-in-info {
flex-grow: 1; flex-grow: 1;

View File

@ -18,6 +18,7 @@
container="body" container="body"
title="Get help" title="Get help"
i18n-title i18n-title
popoverClass="help-popover"
[attr.aria-pressed]="isPopoverOpened" [attr.aria-pressed]="isPopoverOpened"
[ngbPopover]="tooltipTemplate" [ngbPopover]="tooltipTemplate"
[placement]="tooltipPlacement" [placement]="tooltipPlacement"

View File

@ -12,19 +12,21 @@
} }
/deep/ { /deep/ {
.popover-body { .popover-help.popover {
text-align: left;
padding: 10px;
max-width: 300px; max-width: 300px;
font-size: 13px; .popover-body {
font-family: $main-fonts; text-align: left;
background-color: #fff; padding: 10px;
color: #000; font-size: 13px;
box-shadow: 0 0 6px rgba(0, 0, 0, 0.5); font-family: $main-fonts;
background-color: #fff;
color: #000;
box-shadow: 0 0 6px rgba(0, 0, 0, 0.5);
ul { ul {
padding-left: 20px; padding-left: 20px;
}
} }
} }
} }

View File

@ -3,3 +3,14 @@ export interface ComponentPagination {
itemsPerPage: number itemsPerPage: number
totalItems?: 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
}

View File

@ -80,6 +80,7 @@ export class RestExtractor {
errorMessage = errorMessage ? errorMessage : 'Unknown error.' errorMessage = errorMessage ? errorMessage : 'Unknown error.'
console.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`) console.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`)
} else { } else {
console.error(err)
errorMessage = err errorMessage = err
} }

View File

@ -63,6 +63,8 @@ import { UserModerationDropdownComponent } from '@app/shared/moderation/user-mod
import { BlocklistService } from '@app/shared/blocklist' import { BlocklistService } from '@app/shared/blocklist'
import { TopMenuDropdownComponent } from '@app/shared/menu/top-menu-dropdown.component' import { TopMenuDropdownComponent } from '@app/shared/menu/top-menu-dropdown.component'
import { UserHistoryService } from '@app/shared/users/user-history.service' 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({ @NgModule({
imports: [ imports: [
@ -105,7 +107,8 @@ import { UserHistoryService } from '@app/shared/users/user-history.service'
InstanceFeaturesTableComponent, InstanceFeaturesTableComponent,
UserBanModalComponent, UserBanModalComponent,
UserModerationDropdownComponent, UserModerationDropdownComponent,
TopMenuDropdownComponent TopMenuDropdownComponent,
UserNotificationsComponent
], ],
exports: [ exports: [
@ -145,6 +148,7 @@ import { UserHistoryService } from '@app/shared/users/user-history.service'
UserBanModalComponent, UserBanModalComponent,
UserModerationDropdownComponent, UserModerationDropdownComponent,
TopMenuDropdownComponent, TopMenuDropdownComponent,
UserNotificationsComponent,
NumberFormatterPipe, NumberFormatterPipe,
ObjectLengthPipe, ObjectLengthPipe,
@ -187,6 +191,8 @@ import { UserHistoryService } from '@app/shared/users/user-history.service'
I18nPrimengCalendarService, I18nPrimengCalendarService,
ScreenService, ScreenService,
UserNotificationService,
I18n I18n
] ]
}) })

View File

@ -1,2 +1,3 @@
export * from './user.model' export * from './user.model'
export * from './user.service' export * from './user.service'
export * from './user-notifications.component'

View File

@ -0,0 +1,153 @@
import { UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '../../../../../shared'
export class UserNotification implements UserNotificationServer {
id: number
type: UserNotificationType
read: boolean
video?: VideoInfo & {
channel: {
id: number
displayName: string
}
}
videoImport?: {
id: number
video?: VideoInfo
torrentName?: string
magnetUri?: string
targetUrl?: string
}
comment?: {
id: number
threadId: number
account: {
id: number
displayName: string
}
video: VideoInfo
}
videoAbuse?: {
id: number
video: VideoInfo
}
videoBlacklist?: {
id: number
video: VideoInfo
}
account?: {
id: number
displayName: string
name: string
}
actorFollow?: {
id: number
follower: {
name: string
displayName: string
}
following: {
type: 'account' | 'channel'
name: string
displayName: string
}
}
createdAt: string
updatedAt: string
// Additional fields
videoUrl?: string
commentUrl?: any[]
videoAbuseUrl?: string
accountUrl?: string
videoImportIdentifier?: string
videoImportUrl?: string
constructor (hash: UserNotificationServer) {
this.id = hash.id
this.type = hash.type
this.read = hash.read
this.video = hash.video
this.videoImport = hash.videoImport
this.comment = hash.comment
this.videoAbuse = hash.videoAbuse
this.videoBlacklist = hash.videoBlacklist
this.account = hash.account
this.actorFollow = hash.actorFollow
this.createdAt = hash.createdAt
this.updatedAt = hash.updatedAt
switch (this.type) {
case UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION:
this.videoUrl = this.buildVideoUrl(this.video)
break
case UserNotificationType.UNBLACKLIST_ON_MY_VIDEO:
this.videoUrl = this.buildVideoUrl(this.video)
break
case UserNotificationType.NEW_COMMENT_ON_MY_VIDEO:
case UserNotificationType.COMMENT_MENTION:
this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ]
break
case UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS:
this.videoAbuseUrl = '/admin/moderation/video-abuses/list'
this.videoUrl = this.buildVideoUrl(this.videoAbuse.video)
break
case UserNotificationType.BLACKLIST_ON_MY_VIDEO:
this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video)
break
case UserNotificationType.MY_VIDEO_PUBLISHED:
this.videoUrl = this.buildVideoUrl(this.video)
break
case UserNotificationType.MY_VIDEO_IMPORT_SUCCESS:
this.videoImportUrl = this.buildVideoImportUrl()
this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport)
this.videoUrl = this.buildVideoUrl(this.videoImport.video)
break
case UserNotificationType.MY_VIDEO_IMPORT_ERROR:
this.videoImportUrl = this.buildVideoImportUrl()
this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport)
break
case UserNotificationType.NEW_USER_REGISTRATION:
this.accountUrl = this.buildAccountUrl(this.account)
break
case UserNotificationType.NEW_FOLLOW:
this.accountUrl = this.buildAccountUrl(this.actorFollow.follower)
break
}
}
private buildVideoUrl (video: { uuid: string }) {
return '/videos/watch/' + video.uuid
}
private buildAccountUrl (account: { name: string }) {
return '/accounts/' + account.name
}
private buildVideoImportUrl () {
return '/my-account/video-imports'
}
private buildVideoImportIdentifier (videoImport: { targetUrl?: string, magnetUri?: string, torrentName?: string }) {
return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName
}
}

View File

@ -0,0 +1,110 @@
import { Injectable } from '@angular/core'
import { HttpClient, HttpParams } from '@angular/common/http'
import { RestExtractor, RestService } from '@app/shared/rest'
import { catchError, map, tap } from 'rxjs/operators'
import { environment } from '../../../environments/environment'
import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '../../../../../shared'
import { UserNotification } from '@app/shared/users/user-notification.model'
import { Subject } from 'rxjs'
import * as io from 'socket.io-client'
import { AuthService } from '@app/core'
import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
import { User } from '@app/shared'
@Injectable()
export class UserNotificationService {
static BASE_NOTIFICATIONS_URL = environment.apiUrl + '/api/v1/users/me/notifications'
static BASE_NOTIFICATION_SETTINGS = environment.apiUrl + '/api/v1/users/me/notification-settings'
private notificationSubject = new Subject<{ type: 'new' | 'read' | 'read-all', notification?: UserNotification }>()
private socket: SocketIOClient.Socket
constructor (
private auth: AuthService,
private authHttp: HttpClient,
private restExtractor: RestExtractor,
private restService: RestService
) {}
listMyNotifications (pagination: ComponentPagination, unread?: boolean, ignoreLoadingBar = false) {
let params = new HttpParams()
params = this.restService.addRestGetParams(params, this.restService.componentPaginationToRestPagination(pagination))
if (unread) params = params.append('unread', `${unread}`)
const headers = ignoreLoadingBar ? { ignoreLoadingBar: '' } : undefined
return this.authHttp.get<ResultList<UserNotification>>(UserNotificationService.BASE_NOTIFICATIONS_URL, { params, headers })
.pipe(
map(res => this.restExtractor.convertResultListDateToHuman(res)),
map(res => this.restExtractor.applyToResultListData(res, this.formatNotification.bind(this))),
catchError(err => this.restExtractor.handleError(err))
)
}
countUnreadNotifications () {
return this.listMyNotifications({ currentPage: 1, itemsPerPage: 0 }, true)
.pipe(map(n => n.total))
}
getMyNotificationsSocket () {
const socket = this.getSocket()
socket.on('new-notification', (n: UserNotificationServer) => {
this.notificationSubject.next({ type: 'new', notification: new UserNotification(n) })
})
return this.notificationSubject.asObservable()
}
markAsRead (notification: UserNotification) {
const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read'
const body = { ids: [ notification.id ] }
const headers = { ignoreLoadingBar: '' }
return this.authHttp.post(url, body, { headers })
.pipe(
map(this.restExtractor.extractDataBool),
tap(() => this.notificationSubject.next({ type: 'read' })),
catchError(res => this.restExtractor.handleError(res))
)
}
markAllAsRead () {
const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read-all'
const headers = { ignoreLoadingBar: '' }
return this.authHttp.post(url, {}, { headers })
.pipe(
map(this.restExtractor.extractDataBool),
tap(() => this.notificationSubject.next({ type: 'read-all' })),
catchError(res => this.restExtractor.handleError(res))
)
}
updateNotificationSettings (user: User, settings: UserNotificationSetting) {
const url = UserNotificationService.BASE_NOTIFICATION_SETTINGS
return this.authHttp.put(url, settings)
.pipe(
map(this.restExtractor.extractDataBool),
catchError(res => this.restExtractor.handleError(res))
)
}
private getSocket () {
if (this.socket) return this.socket
this.socket = io(environment.apiUrl + '/user-notifications', {
query: { accessToken: this.auth.getAccessToken() }
})
return this.socket
}
private formatNotification (notification: UserNotificationServer) {
return new UserNotification(notification)
}
}

View File

@ -0,0 +1,61 @@
<div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div>
<div class="notifications" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()">
<div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }">
<div [ngSwitch]="notification.type">
<ng-container i18n *ngSwitchCase="UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION">
{{ notification.video.channel.displayName }} published a <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">new video</a>
</ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.UNBLACKLIST_ON_MY_VIDEO">
Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been unblacklisted
</ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.BLACKLIST_ON_MY_VIDEO">
Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoBlacklist.video.name }}</a> has been blacklisted
</ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS">
<a (click)="markAsRead(notification)" [routerLink]="notification.videoAbuseUrl">A new video abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoAbuse.video.name }}</a>
</ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.NEW_COMMENT_ON_MY_VIDEO">
{{ notification.comment.account.displayName }} commented your video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.comment.video.name }}</a>
</ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_PUBLISHED">
Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been published
</ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_SUCCESS">
<a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">Your video import</a> {{ notification.videoImportIdentifier }} succeeded
</ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_ERROR">
<a (click)="markAsRead(notification)" [routerLink]="notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} failed
</ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.NEW_USER_REGISTRATION">
User <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.account.name }} registered</a> on your instance
</ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.NEW_FOLLOW">
<a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.actorFollow.follower.displayName }}</a> is following
<ng-container *ngIf="notification.actorFollow.following.type === 'channel'">
your channel {{ notification.actorFollow.following.displayName }}
</ng-container>
<ng-container *ngIf="notification.actorFollow.following.type === 'account'">your account</ng-container>
</ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.COMMENT_MENTION">
{{ notification.comment.account.displayName }} mentioned you on <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">video {{ notification.comment.video.name }}</a>
</ng-container>
</div>
<div i18n title="Mark as read" class="mark-as-read">
<div class="glyphicon glyphicon-ok" (click)="markAsRead(notification)"></div>
</div>
</div>
</div>

View File

@ -0,0 +1,30 @@
.notification {
display: flex;
justify-content: space-between;
align-items: center;
font-size: inherit;
padding: 15px 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.10);
.mark-as-read {
min-width: 35px;
.glyphicon {
display: none;
cursor: pointer;
color: rgba(20, 20, 20, 0.5)
}
}
&.unread {
background-color: rgba(0, 0, 0, 0.05);
&:hover .mark-as-read .glyphicon {
display: block;
&:hover {
color: rgba(20, 20, 20, 0.8);
}
}
}
}

View File

@ -0,0 +1,82 @@
import { Component, Input, OnInit } from '@angular/core'
import { UserNotificationService } from '@app/shared/users/user-notification.service'
import { UserNotificationType } from '../../../../../shared'
import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
import { Notifier } from '@app/core'
import { UserNotification } from '@app/shared/users/user-notification.model'
@Component({
selector: 'my-user-notifications',
templateUrl: 'user-notifications.component.html',
styleUrls: [ 'user-notifications.component.scss' ]
})
export class UserNotificationsComponent implements OnInit {
@Input() ignoreLoadingBar = false
@Input() infiniteScroll = true
notifications: UserNotification[] = []
// So we can access it in the template
UserNotificationType = UserNotificationType
componentPagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 10,
totalItems: null
}
constructor (
private userNotificationService: UserNotificationService,
private notifier: Notifier
) { }
ngOnInit () {
this.loadMoreNotifications()
}
loadMoreNotifications () {
this.userNotificationService.listMyNotifications(this.componentPagination, undefined, this.ignoreLoadingBar)
.subscribe(
result => {
this.notifications = this.notifications.concat(result.data)
this.componentPagination.totalItems = result.total
},
err => this.notifier.error(err.message)
)
}
onNearOfBottom () {
if (this.infiniteScroll === false) return
this.componentPagination.currentPage++
if (hasMoreItems(this.componentPagination)) {
this.loadMoreNotifications()
}
}
markAsRead (notification: UserNotification) {
this.userNotificationService.markAsRead(notification)
.subscribe(
() => {
notification.read = true
},
err => this.notifier.error(err.message)
)
}
markAllAsRead () {
this.userNotificationService.markAllAsRead()
.subscribe(
() => {
for (const notification of this.notifications) {
notification.read = true
}
},
err => this.notifier.error(err.message)
)
}
}

View File

@ -1,4 +1,4 @@
import { hasUserRight, User as UserServerModel, UserRight, UserRole, VideoChannel } from '../../../../../shared' import { hasUserRight, User as UserServerModel, UserNotificationSetting, UserRight, UserRole, VideoChannel } from '../../../../../shared'
import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type'
import { Account } from '@app/shared/account/account.model' import { Account } from '@app/shared/account/account.model'
import { Avatar } from '../../../../../shared/models/avatars/avatar.model' import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
@ -24,6 +24,8 @@ export class User implements UserServerModel {
blocked: boolean blocked: boolean
blockedReason?: string blockedReason?: string
notificationSettings?: UserNotificationSetting
constructor (hash: Partial<UserServerModel>) { constructor (hash: Partial<UserServerModel>) {
this.id = hash.id this.id = hash.id
this.username = hash.username this.username = hash.username
@ -41,6 +43,8 @@ export class User implements UserServerModel {
this.blocked = hash.blocked this.blocked = hash.blocked
this.blockedReason = hash.blockedReason this.blockedReason = hash.blockedReason
this.notificationSettings = hash.notificationSettings
if (hash.account !== undefined) { if (hash.account !== undefined) {
this.account = new Account(hash.account) this.account = new Account(hash.account)
} }

View File

@ -4,7 +4,7 @@ import { ConfirmService, Notifier } from '@app/core'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model' import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model'
import { AuthService } from '../../../core/auth' 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 { User } from '../../../shared/users'
import { VideoSortField } from '../../../shared/video/sort-field.type' import { VideoSortField } from '../../../shared/video/sort-field.type'
import { VideoDetails } from '../../../shared/video/video-details.model' import { VideoDetails } from '../../../shared/video/video-details.model'
@ -165,22 +165,11 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
onNearOfBottom () { onNearOfBottom () {
this.componentPagination.currentPage++ this.componentPagination.currentPage++
if (this.hasMoreComments()) { if (hasMoreItems(this.componentPagination)) {
this.loadMoreComments() 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) { private deleteLocalCommentThread (parentComment: VideoCommentThreadTree, commentToDelete: VideoComment) {
for (const commentChild of parentComment.children) { for (const commentChild of parentComment.children) {
if (commentChild.comment.id === commentToDelete.id) { if (commentChild.comment.id === commentToDelete.id) {

View File

@ -31,4 +31,5 @@ $input-focus-border-color: #ced4da;
$nav-pills-link-active-bg: #F0F0F0; $nav-pills-link-active-bg: #F0F0F0;
$nav-pills-link-active-color: #000; $nav-pills-link-active-color: #000;
$zindex-dropdown: 10000; $zindex-dropdown: 10000;
$zindex-popover: 10000;

View File

@ -326,6 +326,8 @@ p-toast {
.notification-block { .notification-block {
display: flex; display: flex;
align-items: center;
padding: 5px;
.message { .message {
flex-grow: 1; flex-grow: 1;
@ -336,12 +338,12 @@ p-toast {
p { p {
font-size: 15px; font-size: 15px;
margin-bottom: 0;
} }
} }
.glyphicon { .glyphicon {
font-size: 32px; font-size: 32px;
margin-top: 15px;
margin-right: 5px; margin-right: 5px;
} }
} }

View File

@ -510,6 +510,11 @@
dependencies: dependencies:
"@types/node" "*" "@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": "@types/video.js@^7.2.5":
version "7.2.5" version "7.2.5"
resolved "https://registry.yarnpkg.com/@types/video.js/-/video.js-7.2.5.tgz#20896c81141d3517c3a89bb6eb97c6a191aa5d4c" resolved "https://registry.yarnpkg.com/@types/video.js/-/video.js-7.2.5.tgz#20896c81141d3517c3a89bb6eb97c6a191aa5d4c"
@ -3195,6 +3200,23 @@ engine.io-client@~3.2.0:
xmlhttprequest-ssl "~1.5.4" xmlhttprequest-ssl "~1.5.4"
yeast "0.1.2" 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: engine.io-parser@~2.1.0, engine.io-parser@~2.1.1:
version "2.1.3" version "2.1.3"
resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.3.tgz#757ab970fbf2dfb32c7b74b033216d5739ef79a6" resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.3.tgz#757ab970fbf2dfb32c7b74b033216d5739ef79a6"
@ -8981,6 +9003,26 @@ socket.io-client@2.1.1:
socket.io-parser "~3.2.0" socket.io-parser "~3.2.0"
to-array "0.1.4" 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: socket.io-parser@~3.2.0:
version "3.2.0" version "3.2.0"
resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.2.0.tgz#e7c6228b6aa1f814e6148aea325b51aa9499e077" resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.2.0.tgz#e7c6228b6aa1f814e6148aea325b51aa9499e077"
@ -8990,6 +9032,15 @@ socket.io-parser@~3.2.0:
debug "~3.1.0" debug "~3.1.0"
isarray "2.0.1" 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: socket.io@2.1.1:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.1.1.tgz#a069c5feabee3e6b214a75b40ce0652e1cfb9980" resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.1.1.tgz#a069c5feabee3e6b214a75b40ce0652e1cfb9980"
@ -10671,7 +10722,7 @@ ws@^5.2.0:
dependencies: dependencies:
async-limiter "~1.0.0" async-limiter "~1.0.0"
ws@^6.0.0: ws@^6.0.0, ws@~6.1.0:
version "6.1.2" version "6.1.2"
resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.2.tgz#3cc7462e98792f0ac679424148903ded3b9c3ad8" resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.2.tgz#3cc7462e98792f0ac679424148903ded3b9c3ad8"
integrity sha512-rfUqzvz0WxmSXtJpPMX2EeASXabOrSMk1ruMOV3JBTBjo4ac2lDjGGsbQSyxj8Odhw5fBib8ZKEjDNvgouNKYw== integrity sha512-rfUqzvz0WxmSXtJpPMX2EeASXabOrSMk1ruMOV3JBTBjo4ac2lDjGGsbQSyxj8Odhw5fBib8ZKEjDNvgouNKYw==

View File

@ -13,7 +13,7 @@ recreateDB () {
} }
removeFiles () { removeFiles () {
rm -rf "./test$1" "./config/local-test.json" "./config/local-test-$1.json" rm -rf "./test$1" "./config/local-test-$1.json"
} }
dropRedis () { dropRedis () {

View File

@ -45,6 +45,11 @@ myNotificationsRouter.post('/me/notifications/read',
asyncMiddleware(markAsReadUserNotifications) asyncMiddleware(markAsReadUserNotifications)
) )
myNotificationsRouter.post('/me/notifications/read-all',
authenticate,
asyncMiddleware(markAsReadAllUserNotifications)
)
export { export {
myNotificationsRouter myNotificationsRouter
} }
@ -70,7 +75,7 @@ async function updateNotificationSettings (req: express.Request, res: express.Re
myVideoImportFinished: body.myVideoImportFinished, myVideoImportFinished: body.myVideoImportFinished,
newFollow: body.newFollow, newFollow: body.newFollow,
newUserRegistration: body.newUserRegistration, newUserRegistration: body.newUserRegistration,
commentMention: body.commentMention, commentMention: body.commentMention
} }
await UserNotificationSettingModel.update(values, query) await UserNotificationSettingModel.update(values, query)
@ -93,3 +98,11 @@ async function markAsReadUserNotifications (req: express.Request, res: express.R
return res.status(204).end() 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()
}

View File

@ -9,8 +9,8 @@ function isArray (value: any) {
return Array.isArray(value) return Array.isArray(value)
} }
function isIntArray (value: any) { function isNotEmptyIntArray (value: any) {
return Array.isArray(value) && value.every(v => validator.isInt('' + v)) return Array.isArray(value) && value.every(v => validator.isInt('' + v)) && value.length !== 0
} }
function isDateValid (value: string) { function isDateValid (value: string) {
@ -82,7 +82,7 @@ function isFileValid (
export { export {
exists, exists,
isIntArray, isNotEmptyIntArray,
isArray, isArray,
isIdValid, isIdValid,
isUUIDValid, isUUIDValid,

View File

@ -9,8 +9,12 @@ function isUserNotificationTypeValid (value: any) {
function isUserNotificationSettingValid (value: any) { function isUserNotificationSettingValid (value: any) {
return exists(value) && return exists(value) &&
validator.isInt('' + value) && validator.isInt('' + value) && (
UserNotificationSettingValue[ value ] !== undefined value === UserNotificationSettingValue.NONE ||
value === UserNotificationSettingValue.WEB ||
value === UserNotificationSettingValue.EMAIL ||
value === (UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL)
)
} }
export { export {

View File

@ -31,7 +31,7 @@ PRIMARY KEY ("id"))
'("newVideoFromSubscription", "newCommentOnMyVideo", "videoAbuseAsModerator", "blacklistOnMyVideo", ' + '("newVideoFromSubscription", "newCommentOnMyVideo", "videoAbuseAsModerator", "blacklistOnMyVideo", ' +
'"myVideoPublished", "myVideoImportFinished", "newUserRegistration", "newFollow", "commentMention", ' + '"myVideoPublished", "myVideoImportFinished", "newUserRegistration", "newFollow", "commentMention", ' +
'"userId", "createdAt", "updatedAt") ' + '"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) await utils.sequelize.query(query)
} }

View File

@ -436,11 +436,11 @@ class Notifier {
private isEmailEnabled (user: UserModel, value: UserNotificationSettingValue) { private isEmailEnabled (user: UserModel, value: UserNotificationSettingValue) {
if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION === true && user.emailVerified !== true) return false 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) { private isWebNotificationEnabled (value: UserNotificationSettingValue) {
return value === UserNotificationSettingValue.WEB_NOTIFICATION || value === UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL return value & UserNotificationSettingValue.WEB
} }
static get Instance () { static get Instance () {

View File

@ -98,15 +98,15 @@ export {
function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Transaction | undefined) { function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Transaction | undefined) {
const values: UserNotificationSetting & { userId: number } = { const values: UserNotificationSetting & { userId: number } = {
userId: user.id, userId: user.id,
newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION, newVideoFromSubscription: UserNotificationSettingValue.WEB,
newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION, newCommentOnMyVideo: UserNotificationSettingValue.WEB,
myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION, myVideoImportFinished: UserNotificationSettingValue.WEB,
myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION, myVideoPublished: UserNotificationSettingValue.WEB,
videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, videoAbuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION, newUserRegistration: UserNotificationSettingValue.WEB,
commentMention: UserNotificationSettingValue.WEB_NOTIFICATION, commentMention: UserNotificationSettingValue.WEB,
newFollow: UserNotificationSettingValue.WEB_NOTIFICATION newFollow: UserNotificationSettingValue.WEB
} }
return UserNotificationSettingModel.create(values, { transaction: t }) return UserNotificationSettingModel.create(values, { transaction: t })

View File

@ -4,7 +4,7 @@ import { body, query } from 'express-validator/check'
import { logger } from '../../helpers/logger' import { logger } from '../../helpers/logger'
import { areValidationErrors } from './utils' import { areValidationErrors } from './utils'
import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications' import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
import { isIntArray } from '../../helpers/custom-validators/misc' import { isNotEmptyIntArray } from '../../helpers/custom-validators/misc'
const listUserNotificationsValidator = [ const listUserNotificationsValidator = [
query('unread') query('unread')
@ -42,7 +42,8 @@ const updateNotificationSettingsValidator = [
const markAsReadUserNotificationsValidator = [ const markAsReadUserNotificationsValidator = [
body('ids') 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) => { (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking markAsReadUserNotificationsValidator parameters', { parameters: req.body }) logger.debug('Checking markAsReadUserNotificationsValidator parameters', { parameters: req.body })

View File

@ -290,6 +290,12 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
return UserNotificationModel.update({ read: true }, query) return UserNotificationModel.update({ read: true }, query)
} }
static markAllAsRead (userId: number) {
const query = { where: { userId } }
return UserNotificationModel.update({ read: true }, query)
}
toFormattedJSON (): UserNotification { toFormattedJSON (): UserNotification {
const video = this.Video ? Object.assign(this.formatVideo(this.Video), { const video = this.Video ? Object.assign(this.formatVideo(this.Video), {
channel: { channel: {

View File

@ -96,6 +96,16 @@ describe('Test user notifications API validators', function () {
statusCodeExpected: 400 statusCodeExpected: 400
}) })
await makePostBodyRequest({
url: server.url,
path,
fields: {
ids: [ ]
},
token: server.accessToken,
statusCodeExpected: 400
})
await makePostBodyRequest({ await makePostBodyRequest({
url: server.url, url: server.url,
path, path,
@ -131,18 +141,39 @@ describe('Test user notifications API validators', function () {
}) })
}) })
describe('When marking as read my notifications', function () {
const path = '/api/v1/users/me/notifications/read-all'
it('Should fail with a non authenticated user', async function () {
await makePostBodyRequest({
url: server.url,
path,
statusCodeExpected: 401
})
})
it('Should succeed with the correct parameters', async function () {
await makePostBodyRequest({
url: server.url,
path,
token: server.accessToken,
statusCodeExpected: 204
})
})
})
describe('When updating my notification settings', function () { describe('When updating my notification settings', function () {
const path = '/api/v1/users/me/notification-settings' const path = '/api/v1/users/me/notification-settings'
const correctFields: UserNotificationSetting = { const correctFields: UserNotificationSetting = {
newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION, newVideoFromSubscription: UserNotificationSettingValue.WEB,
newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION, newCommentOnMyVideo: UserNotificationSettingValue.WEB,
videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION, videoAbuseAsModerator: UserNotificationSettingValue.WEB,
blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION, blacklistOnMyVideo: UserNotificationSettingValue.WEB,
myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION, myVideoImportFinished: UserNotificationSettingValue.WEB,
myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION, myVideoPublished: UserNotificationSettingValue.WEB,
commentMention: UserNotificationSettingValue.WEB_NOTIFICATION, commentMention: UserNotificationSettingValue.WEB,
newFollow: UserNotificationSettingValue.WEB_NOTIFICATION, newFollow: UserNotificationSettingValue.WEB,
newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION newUserRegistration: UserNotificationSettingValue.WEB
} }
it('Should fail with missing fields', async function () { it('Should fail with missing fields', async function () {
@ -150,7 +181,7 @@ describe('Test user notifications API validators', function () {
url: server.url, url: server.url,
path, path,
token: server.accessToken, token: server.accessToken,
fields: { newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION }, fields: { newVideoFromSubscription: UserNotificationSettingValue.WEB },
statusCodeExpected: 400 statusCodeExpected: 400
}) })
}) })

View File

@ -485,11 +485,10 @@ describe('Test users API validators', function () {
email: 'email@example.com', email: 'email@example.com',
emailVerified: true, emailVerified: true,
videoQuota: 42, videoQuota: 42,
role: UserRole.MODERATOR role: UserRole.USER
} }
await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields, statusCodeExpected: 204 }) await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields, statusCodeExpected: 204 })
userAccessToken = await userLogin(server, user)
}) })
}) })

View File

@ -37,7 +37,8 @@ import {
getLastNotification, getLastNotification,
getUserNotifications, getUserNotifications,
markAsReadNotifications, markAsReadNotifications,
updateMyNotificationSettings updateMyNotificationSettings,
markAsReadAllNotifications
} from '../../../../shared/utils/users/user-notifications' } from '../../../../shared/utils/users/user-notifications'
import { import {
User, User,
@ -88,15 +89,15 @@ describe('Test users notifications', function () {
let channelId: number let channelId: number
const allNotificationSettings: UserNotificationSetting = { const allNotificationSettings: UserNotificationSetting = {
newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, newCommentOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, videoAbuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, myVideoImportFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, myVideoPublished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
commentMention: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, commentMention: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
newFollow: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, newFollow: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL newUserRegistration: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
} }
before(async function () { before(async function () {
@ -174,7 +175,10 @@ describe('Test users notifications', function () {
}) })
it('Should send a new video notification if the user follows the local video publisher', async function () { 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 addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9001')
await waitJobs(servers)
const { name, uuid } = await uploadVideoByLocalAccount(servers) const { name, uuid } = await uploadVideoByLocalAccount(servers)
await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence') await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence')
@ -184,6 +188,7 @@ describe('Test users notifications', function () {
this.timeout(50000) // Server 2 has transcoding enabled this.timeout(50000) // Server 2 has transcoding enabled
await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9002') await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9002')
await waitJobs(servers)
const { name, uuid } = await uploadVideoByRemoteAccount(servers) const { name, uuid } = await uploadVideoByRemoteAccount(servers)
await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence') await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence')
@ -822,8 +827,9 @@ describe('Test users notifications', function () {
}) })
it('Should notify when a local channel is following one of our channel', async function () { 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 waitJobs(servers)
await checkNewActorFollow(baseParams, 'channel', 'root', 'super root name', myChannelName, 'presence') await checkNewActorFollow(baseParams, 'channel', 'root', 'super root name', myChannelName, 'presence')
@ -832,8 +838,9 @@ describe('Test users notifications', function () {
}) })
it('Should notify when a remote channel is following one of our channel', async function () { 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 waitJobs(servers)
await checkNewActorFollow(baseParams, 'channel', 'root', 'super root 2 name', myChannelName, 'presence') await checkNewActorFollow(baseParams, 'channel', 'root', 'super root 2 name', myChannelName, 'presence')
@ -895,6 +902,15 @@ describe('Test users notifications', function () {
expect(notification.read).to.be.false 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 () { describe('Notification settings', function () {
@ -928,13 +944,13 @@ describe('Test users notifications', function () {
it('Should only have web notifications', async function () { it('Should only have web notifications', async function () {
await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, { await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, {
newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION newVideoFromSubscription: UserNotificationSettingValue.WEB
})) }))
{ {
const res = await getMyUserInformation(servers[0].url, userAccessToken) const res = await getMyUserInformation(servers[0].url, userAccessToken)
const info = res.body as User 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) const { name, uuid } = await uploadVideoByLocalAccount(servers)
@ -976,13 +992,15 @@ describe('Test users notifications', function () {
it('Should have email and web notifications', async function () { it('Should have email and web notifications', async function () {
await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, { 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 res = await getMyUserInformation(servers[0].url, userAccessToken)
const info = res.body as User 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) const { name, uuid } = await uploadVideoByLocalAccount(servers)

View File

@ -501,10 +501,6 @@ describe('Test users', function () {
accessTokenUser = await userLogin(server, user) 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 () { it('Should be able to list video blacklist by a moderator', async function () {
await getBlacklistedVideosList(server.url, accessTokenUser) await getBlacklistedVideosList(server.url, accessTokenUser)
}) })

View File

@ -1,8 +1,7 @@
export enum UserNotificationSettingValue { export enum UserNotificationSettingValue {
NONE = 1, NONE = 0,
WEB_NOTIFICATION = 2, WEB = 1 << 0,
EMAIL = 3, EMAIL = 1 << 1
WEB_NOTIFICATION_AND_EMAIL = 4
} }
export interface UserNotificationSetting { export interface UserNotificationSetting {

View File

@ -2,11 +2,15 @@ export enum UserNotificationType {
NEW_VIDEO_FROM_SUBSCRIPTION = 1, NEW_VIDEO_FROM_SUBSCRIPTION = 1,
NEW_COMMENT_ON_MY_VIDEO = 2, NEW_COMMENT_ON_MY_VIDEO = 2,
NEW_VIDEO_ABUSE_FOR_MODERATORS = 3, NEW_VIDEO_ABUSE_FOR_MODERATORS = 3,
BLACKLIST_ON_MY_VIDEO = 4, BLACKLIST_ON_MY_VIDEO = 4,
UNBLACKLIST_ON_MY_VIDEO = 5, UNBLACKLIST_ON_MY_VIDEO = 5,
MY_VIDEO_PUBLISHED = 6, MY_VIDEO_PUBLISHED = 6,
MY_VIDEO_IMPORT_SUCCESS = 7, MY_VIDEO_IMPORT_SUCCESS = 7,
MY_VIDEO_IMPORT_ERROR = 8, MY_VIDEO_IMPORT_ERROR = 8,
NEW_USER_REGISTRATION = 9, NEW_USER_REGISTRATION = 9,
NEW_FOLLOW = 10, NEW_FOLLOW = 10,
COMMENT_MENTION = 11 COMMENT_MENTION = 11

View File

@ -29,6 +29,7 @@ function getJobsListPaginationAndSort (url: string, accessToken: string, state:
} }
async function waitJobs (serversArg: ServerInfo[] | ServerInfo) { 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[] let servers: ServerInfo[]
if (Array.isArray(serversArg) === false) servers = [ serversArg as ServerInfo ] if (Array.isArray(serversArg) === false) servers = [ serversArg as ServerInfo ]
@ -62,7 +63,7 @@ async function waitJobs (serversArg: ServerInfo[] | ServerInfo) {
// Retry, in case of new jobs were created // Retry, in case of new jobs were created
if (pendingRequests === false) { if (pendingRequests === false) {
await wait(2000) await wait(pendingJobWait)
await Promise.all(tasksBuilder()) await Promise.all(tasksBuilder())
} }

View File

@ -54,6 +54,16 @@ function markAsReadNotifications (url: string, token: string, ids: number[], sta
statusCodeExpected 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) { async function getLastNotification (serverUrl: string, accessToken: string) {
const res = await getUserNotifications(serverUrl, accessToken, 0, 1, undefined, '-createdAt') const res = await getUserNotifications(serverUrl, accessToken, 0, 1, undefined, '-createdAt')
@ -409,6 +419,7 @@ export {
CheckerBaseParams, CheckerBaseParams,
CheckerType, CheckerType,
checkNotification, checkNotification,
markAsReadAllNotifications,
checkMyVideoImportIsFinished, checkMyVideoImportIsFinished,
checkUserRegistered, checkUserRegistered,
checkVideoIsPublished, checkVideoIsPublished,