Add notifications in the client

pull/1458/head
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
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

View File

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

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

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>
</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>
@ -16,4 +19,4 @@
<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>
<my-account-danger-zone [user]="user"></my-account-danger-zone>

View File

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

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

View File

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

View File

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

View File

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

View File

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

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'

View File

@ -2,9 +2,7 @@
<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>
@ -97,4 +95,4 @@
</menu>
</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;
margin-bottom: 35px;
img {
@include avatar(34px);
margin-left: 20px;
margin-right: 10px;
}
.logged-in-info {
flex-grow: 1;

View File

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

View File

@ -12,19 +12,21 @@
}
/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;
}
}
}
}

View File

@ -3,3 +3,14 @@ export interface ComponentPagination {
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
}

View File

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

View File

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

View File

@ -1,2 +1,3 @@
export * from './user.model'
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 { Account } from '@app/shared/account/account.model'
import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
@ -24,6 +24,8 @@ export class User implements UserServerModel {
blocked: boolean
blockedReason?: string
notificationSettings?: UserNotificationSetting
constructor (hash: Partial<UserServerModel>) {
this.id = hash.id
this.username = hash.username
@ -41,6 +43,8 @@ export class User implements UserServerModel {
this.blocked = hash.blocked
this.blockedReason = hash.blockedReason
this.notificationSettings = hash.notificationSettings
if (hash.account !== undefined) {
this.account = new Account(hash.account)
}

View File

@ -4,7 +4,7 @@ import { ConfirmService, Notifier } from '@app/core'
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'
@ -165,22 +165,11 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
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) {

View File

@ -31,4 +31,5 @@ $input-focus-border-color: #ced4da;
$nav-pills-link-active-bg: #F0F0F0;
$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 {
display: flex;
align-items: center;
padding: 5px;
.message {
flex-grow: 1;
@ -336,12 +338,12 @@ p-toast {
p {
font-size: 15px;
margin-bottom: 0;
}
}
.glyphicon {
font-size: 32px;
margin-top: 15px;
margin-right: 5px;
}
}

View File

@ -510,6 +510,11 @@
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"
@ -3195,6 +3200,23 @@ engine.io-client@~3.2.0:
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"
@ -8981,6 +9003,26 @@ socket.io-client@2.1.1:
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"
@ -8990,6 +9032,15 @@ socket.io-parser@~3.2.0:
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"
@ -10671,7 +10722,7 @@ ws@^5.2.0:
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==

View File

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

View File

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

View File

@ -9,8 +9,12 @@ function isUserNotificationTypeValid (value: any) {
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 {

View File

@ -31,7 +31,7 @@ PRIMARY KEY ("id"))
'("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)
}

View File

@ -436,11 +436,11 @@ class Notifier {
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 () {

View File

@ -98,15 +98,15 @@ export {
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 })

View File

@ -4,7 +4,7 @@ import { body, query } from 'express-validator/check'
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')
@ -42,7 +42,8 @@ const updateNotificationSettingsValidator = [
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 })

View File

@ -290,6 +290,12 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
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: {

View File

@ -96,6 +96,16 @@ describe('Test user notifications API validators', function () {
statusCodeExpected: 400
})
await makePostBodyRequest({
url: server.url,
path,
fields: {
ids: [ ]
},
token: server.accessToken,
statusCodeExpected: 400
})
await makePostBodyRequest({
url: server.url,
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 () {
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 () {
@ -150,7 +181,7 @@ describe('Test user notifications API validators', function () {
url: server.url,
path,
token: server.accessToken,
fields: { newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION },
fields: { newVideoFromSubscription: UserNotificationSettingValue.WEB },
statusCodeExpected: 400
})
})

View File

@ -485,11 +485,10 @@ describe('Test users API validators', function () {
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)
})
})

View File

@ -37,7 +37,8 @@ import {
getLastNotification,
getUserNotifications,
markAsReadNotifications,
updateMyNotificationSettings
updateMyNotificationSettings,
markAsReadAllNotifications
} from '../../../../shared/utils/users/user-notifications'
import {
User,
@ -88,15 +89,15 @@ describe('Test users notifications', function () {
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 () {
@ -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 () {
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')
@ -184,6 +188,7 @@ describe('Test users notifications', function () {
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')
@ -822,8 +827,9 @@ describe('Test users notifications', 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 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 () {
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')
@ -895,6 +902,15 @@ describe('Test users notifications', function () {
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 () {
@ -928,13 +944,13 @@ describe('Test users notifications', 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)
@ -976,13 +992,15 @@ describe('Test users notifications', function () {
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)

View File

@ -501,10 +501,6 @@ describe('Test users', function () {
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)
})

View File

@ -1,8 +1,7 @@
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 {

View File

@ -2,11 +2,15 @@ export enum UserNotificationType {
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

View File

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

View File

@ -54,6 +54,16 @@ function markAsReadNotifications (url: string, token: string, ids: number[], sta
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')
@ -409,6 +419,7 @@ export {
CheckerBaseParams,
CheckerType,
checkNotification,
markAsReadAllNotifications,
checkMyVideoImportIsFinished,
checkUserRegistered,
checkVideoIsPublished,