aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+accounts/accounts.component.html5
-rw-r--r--client/src/app/+accounts/accounts.component.ts63
-rw-r--r--client/src/app/+admin/moderation/abuse-list/abuse-list.component.html18
-rw-r--r--client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts358
-rw-r--r--client/src/app/+admin/moderation/moderation.component.scss11
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts3
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment.component.ts7
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comments.component.ts4
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.module.ts8
-rw-r--r--client/src/app/core/rest/rest-table.ts14
-rw-r--r--client/src/app/shared/shared-main/account/actor.model.ts6
-rw-r--r--client/src/app/shared/shared-main/users/user-notification.model.ts10
-rw-r--r--client/src/app/shared/shared-main/users/user-notifications.component.html15
-rw-r--r--client/src/app/shared/shared-moderation/comment-report.component.scss11
-rw-r--r--client/src/app/shared/shared-moderation/index.ts3
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/account-report.component.ts94
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts (renamed from client/src/app/shared/shared-moderation/comment-report.component.ts)17
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/index.ts3
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/report.component.html (renamed from client/src/app/shared/shared-moderation/comment-report.component.html)4
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/report.component.scss (renamed from client/src/app/shared/shared-moderation/video-report.component.scss)0
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/video-report.component.html (renamed from client/src/app/shared/shared-moderation/video-report.component.html)2
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/video-report.component.ts (renamed from client/src/app/shared/shared-moderation/video-report.component.ts)10
-rw-r--r--client/src/app/shared/shared-moderation/shared-moderation.module.ts15
-rw-r--r--client/src/app/shared/shared-video-comment/index.ts5
-rw-r--r--client/src/app/shared/shared-video-comment/shared-video-comment.module.ts19
-rw-r--r--client/src/app/shared/shared-video-comment/video-comment-thread-tree.model.ts (renamed from client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts)0
-rw-r--r--client/src/app/shared/shared-video-comment/video-comment.model.ts (renamed from client/src/app/+videos/+video-watch/comment/video-comment.model.ts)0
-rw-r--r--client/src/app/shared/shared-video-comment/video-comment.service.ts (renamed from client/src/app/+videos/+video-watch/comment/video-comment.service.ts)2
-rw-r--r--server/lib/emailer.ts5
-rw-r--r--server/lib/emails/account-abuse-new/html.pug4
-rw-r--r--server/lib/emails/video-comment-abuse-new/html.pug8
-rw-r--r--shared/models/moderation/abuse/abuse.model.ts4
-rw-r--r--shared/models/users/user-notification.model.ts2
33 files changed, 515 insertions, 215 deletions
diff --git a/client/src/app/+accounts/accounts.component.html b/client/src/app/+accounts/accounts.component.html
index af80337ce..31c8e3a8e 100644
--- a/client/src/app/+accounts/accounts.component.html
+++ b/client/src/app/+accounts/accounts.component.html
@@ -22,6 +22,7 @@
22 <span *ngIf="account.mutedServerByInstance" class="badge badge-danger" i18n>Instance muted by your instance</span> 22 <span *ngIf="account.mutedServerByInstance" class="badge badge-danger" i18n>Instance muted by your instance</span>
23 23
24 <my-user-moderation-dropdown 24 <my-user-moderation-dropdown
25 [prependActions]="prependModerationActions"
25 buttonSize="small" [account]="account" [user]="accountUser" placement="bottom-left auto" 26 buttonSize="small" [account]="account" [user]="accountUser" placement="bottom-left auto"
26 (userChanged)="onUserChanged()" (userDeleted)="onUserDeleted()" 27 (userChanged)="onUserChanged()" (userDeleted)="onUserDeleted()"
27 ></my-user-moderation-dropdown> 28 ></my-user-moderation-dropdown>
@@ -50,3 +51,7 @@
50 <router-outlet></router-outlet> 51 <router-outlet></router-outlet>
51 </div> 52 </div>
52</div> 53</div>
54
55<ng-container *ngIf="prependModerationActions">
56 <my-account-report #accountReportModal [account]="account"></my-account-report>
57</ng-container>
diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts
index 01911cac2..9288fcb42 100644
--- a/client/src/app/+accounts/accounts.component.ts
+++ b/client/src/app/+accounts/accounts.component.ts
@@ -1,9 +1,10 @@
1import { Subscription } from 'rxjs' 1import { Subscription } from 'rxjs'
2import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators' 2import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
3import { Component, OnDestroy, OnInit } from '@angular/core' 3import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
4import { ActivatedRoute } from '@angular/router' 4import { ActivatedRoute } from '@angular/router'
5import { AuthService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core' 5import { AuthService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core'
6import { Account, AccountService, ListOverflowItem, VideoChannel, VideoChannelService } from '@app/shared/shared-main' 6import { Account, AccountService, DropdownAction, ListOverflowItem, VideoChannel, VideoChannelService } from '@app/shared/shared-main'
7import { AccountReportComponent } from '@app/shared/shared-moderation'
7import { I18n } from '@ngx-translate/i18n-polyfill' 8import { I18n } from '@ngx-translate/i18n-polyfill'
8import { User, UserRight } from '@shared/models' 9import { User, UserRight } from '@shared/models'
9 10
@@ -12,6 +13,8 @@ import { User, UserRight } from '@shared/models'
12 styleUrls: [ './accounts.component.scss' ] 13 styleUrls: [ './accounts.component.scss' ]
13}) 14})
14export class AccountsComponent implements OnInit, OnDestroy { 15export class AccountsComponent implements OnInit, OnDestroy {
16 @ViewChild('accountReportModal') accountReportModal: AccountReportComponent
17
15 account: Account 18 account: Account
16 accountUser: User 19 accountUser: User
17 videoChannels: VideoChannel[] = [] 20 videoChannels: VideoChannel[] = []
@@ -20,6 +23,8 @@ export class AccountsComponent implements OnInit, OnDestroy {
20 isAccountManageable = false 23 isAccountManageable = false
21 accountFollowerTitle = '' 24 accountFollowerTitle = ''
22 25
26 prependModerationActions: DropdownAction<any>[]
27
23 private routeSub: Subscription 28 private routeSub: Subscription
24 29
25 constructor ( 30 constructor (
@@ -42,24 +47,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
42 map(params => params[ 'accountId' ]), 47 map(params => params[ 'accountId' ]),
43 distinctUntilChanged(), 48 distinctUntilChanged(),
44 switchMap(accountId => this.accountService.getAccount(accountId)), 49 switchMap(accountId => this.accountService.getAccount(accountId)),
45 tap(account => { 50 tap(account => this.onAccount(account)),
46 this.account = account
47
48 if (this.authService.isLoggedIn()) {
49 this.authService.userInformationLoaded.subscribe(
50 () => {
51 this.isAccountManageable = this.account.userId && this.account.userId === this.authService.getUser().id
52
53 this.accountFollowerTitle = this.i18n(
54 '{{followers}} direct account followers',
55 { followers: this.subscribersDisplayFor(account.followersCount) }
56 )
57 }
58 )
59 }
60
61 this.getUserIfNeeded(account)
62 }),
63 switchMap(account => this.videoChannelService.listAccountVideoChannels(account)), 51 switchMap(account => this.videoChannelService.listAccountVideoChannels(account)),
64 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ])) 52 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ]))
65 ) 53 )
@@ -107,6 +95,41 @@ export class AccountsComponent implements OnInit, OnDestroy {
107 return this.i18n('{count, plural, =1 {1 subscriber} other {{{count}} subscribers}}', { count }) 95 return this.i18n('{count, plural, =1 {1 subscriber} other {{{count}} subscribers}}', { count })
108 } 96 }
109 97
98 private onAccount (account: Account) {
99 this.prependModerationActions = undefined
100
101 this.account = account
102
103 if (this.authService.isLoggedIn()) {
104 this.authService.userInformationLoaded.subscribe(
105 () => {
106 this.isAccountManageable = this.account.userId && this.account.userId === this.authService.getUser().id
107
108 this.accountFollowerTitle = this.i18n(
109 '{{followers}} direct account followers',
110 { followers: this.subscribersDisplayFor(account.followersCount) }
111 )
112
113 // It's not our account, we can report it
114 if (!this.isAccountManageable) {
115 this.prependModerationActions = [
116 {
117 label: this.i18n('Report account'),
118 handler: () => this.showReportModal()
119 }
120 ]
121 }
122 }
123 )
124 }
125
126 this.getUserIfNeeded(account)
127 }
128
129 private showReportModal () {
130 this.accountReportModal.show()
131 }
132
110 private getUserIfNeeded (account: Account) { 133 private getUserIfNeeded (account: Account) {
111 if (!account.userId || !this.authService.isLoggedIn()) return 134 if (!account.userId || !this.authService.isLoggedIn()) return
112 135
diff --git a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html
index 1ad73e38a..99502304d 100644
--- a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html
+++ b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html
@@ -1,6 +1,6 @@
1<p-table 1<p-table
2 [value]="abuses" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions" 2 [value]="abuses" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" [resizableColumns]="true" 3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" [resizableColumns]="true" [lazyLoadOnInit]="false"
4 [showCurrentPageReport]="true" i18n-currentPageReportTemplate 4 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
5 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} reports" 5 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} reports"
6 (onPage)="onPage($event)" [expandedRowKeys]="expandedRows" 6 (onPage)="onPage($event)" [expandedRowKeys]="expandedRows"
@@ -128,6 +128,22 @@
128 </td> 128 </td>
129 </ng-container> 129 </ng-container>
130 130
131 <ng-container *ngIf="!abuse.comment && !abuse.video">
132 <td *ngIf="abuse.flaggedAccount">
133 <a [href]="getAccountUrl(abuse)" class="table-account-link" target="_blank" rel="noopener noreferrer">
134 <span>{{ abuse.flaggedAccount.displayName }}</span>
135
136 <span class="account-flagged-handle">{{ abuse.flaggedAccount.nameWithHostForced }}</span>
137 </a>
138 </td>
139
140 <td i18n *ngIf="!abuse.flaggedAccount">
141 Account deleted
142 </td>
143
144 </ng-container>
145
146
131 <td class="c-hand" [pRowToggler]="abuse">{{ abuse.createdAt | date: 'short' }}</td> 147 <td class="c-hand" [pRowToggler]="abuse">{{ abuse.createdAt | date: 'short' }}</td>
132 148
133 <td class="c-hand abuse-states" [pRowToggler]="abuse"> 149 <td class="c-hand abuse-states" [pRowToggler]="abuse">
diff --git a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts
index 1ea61ed37..74c5fe2b3 100644
--- a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts
+++ b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts
@@ -1,3 +1,5 @@
1import * as debug from 'debug'
2import truncate from 'lodash-es/truncate'
1import { SortMeta } from 'primeng/api' 3import { SortMeta } from 'primeng/api'
2import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils' 4import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
3import { environment } from 'src/environments/environment' 5import { environment } from 'src/environments/environment'
@@ -7,11 +9,15 @@ import { ActivatedRoute, Params, Router } from '@angular/router'
7import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core' 9import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
8import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main' 10import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main'
9import { AbuseService, BlocklistService, VideoBlockService } from '@app/shared/shared-moderation' 11import { AbuseService, BlocklistService, VideoBlockService } from '@app/shared/shared-moderation'
12import { VideoCommentService } from '@app/shared/shared-video-comment'
10import { I18n } from '@ngx-translate/i18n-polyfill' 13import { I18n } from '@ngx-translate/i18n-polyfill'
11import { Abuse, AbuseState } from '@shared/models' 14import { Abuse, AbuseState } from '@shared/models'
12import { ModerationCommentModalComponent } from './moderation-comment-modal.component' 15import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
13import truncate from 'lodash-es/truncate'
14 16
17const logger = debug('peertube:moderation:AbuseListComponent')
18
19// Don't use an abuse model because we need external services to compute some properties
20// And this model is only used in this component
15export type ProcessedAbuse = Abuse & { 21export type ProcessedAbuse = Abuse & {
16 moderationCommentHtml?: string, 22 moderationCommentHtml?: string,
17 reasonHtml?: string 23 reasonHtml?: string
@@ -45,12 +51,13 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
45 sort: SortMeta = { field: 'createdAt', order: 1 } 51 sort: SortMeta = { field: 'createdAt', order: 1 }
46 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 52 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
47 53
48 abuseActions: DropdownAction<Abuse>[][] = [] 54 abuseActions: DropdownAction<ProcessedAbuse>[][] = []
49 55
50 constructor ( 56 constructor (
51 private notifier: Notifier, 57 private notifier: Notifier,
52 private abuseService: AbuseService, 58 private abuseService: AbuseService,
53 private blocklistService: BlocklistService, 59 private blocklistService: BlocklistService,
60 private commentService: VideoCommentService,
54 private videoService: VideoService, 61 private videoService: VideoService,
55 private videoBlocklistService: VideoBlockService, 62 private videoBlocklistService: VideoBlockService,
56 private confirmService: ConfirmService, 63 private confirmService: ConfirmService,
@@ -63,140 +70,15 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
63 super() 70 super()
64 71
65 this.abuseActions = [ 72 this.abuseActions = [
66 [ 73 this.buildInternalActions(),
67 {
68 label: this.i18n('Internal actions'),
69 isHeader: true
70 },
71 {
72 label: this.i18n('Delete report'),
73 handler: abuse => this.removeAbuse(abuse)
74 },
75 {
76 label: this.i18n('Add note'),
77 handler: abuse => this.openModerationCommentModal(abuse),
78 isDisplayed: abuse => !abuse.moderationComment
79 },
80 {
81 label: this.i18n('Update note'),
82 handler: abuse => this.openModerationCommentModal(abuse),
83 isDisplayed: abuse => !!abuse.moderationComment
84 },
85 {
86 label: this.i18n('Mark as accepted'),
87 handler: abuse => this.updateAbuseState(abuse, AbuseState.ACCEPTED),
88 isDisplayed: abuse => !this.isAbuseAccepted(abuse)
89 },
90 {
91 label: this.i18n('Mark as rejected'),
92 handler: abuse => this.updateAbuseState(abuse, AbuseState.REJECTED),
93 isDisplayed: abuse => !this.isAbuseRejected(abuse)
94 }
95 ],
96 [
97 {
98 label: this.i18n('Actions for the video'),
99 isHeader: true,
100 isDisplayed: abuse => abuse.video && !abuse.video.deleted
101 },
102 {
103 label: this.i18n('Block video'),
104 isDisplayed: abuse => abuse.video && !abuse.video.deleted && !abuse.video.blacklisted,
105 handler: abuse => {
106 this.videoBlocklistService.blockVideo(abuse.video.id, undefined, true)
107 .subscribe(
108 () => {
109 this.notifier.success(this.i18n('Video blocked.'))
110
111 this.updateAbuseState(abuse, AbuseState.ACCEPTED)
112 },
113
114 err => this.notifier.error(err.message)
115 )
116 }
117 },
118 {
119 label: this.i18n('Unblock video'),
120 isDisplayed: abuse => abuse.video && !abuse.video.deleted && abuse.video.blacklisted,
121 handler: abuse => {
122 this.videoBlocklistService.unblockVideo(abuse.video.id)
123 .subscribe(
124 () => {
125 this.notifier.success(this.i18n('Video unblocked.'))
126
127 this.updateAbuseState(abuse, AbuseState.ACCEPTED)
128 },
129
130 err => this.notifier.error(err.message)
131 )
132 }
133 },
134 {
135 label: this.i18n('Delete video'),
136 isDisplayed: abuse => abuse.video && !abuse.video.deleted,
137 handler: async abuse => {
138 const res = await this.confirmService.confirm(
139 this.i18n('Do you really want to delete this video?'),
140 this.i18n('Delete')
141 )
142 if (res === false) return
143 74
144 this.videoService.removeVideo(abuse.video.id) 75 this.buildFlaggedAccountActions(),
145 .subscribe(
146 () => {
147 this.notifier.success(this.i18n('Video deleted.'))
148 76
149 this.updateAbuseState(abuse, AbuseState.ACCEPTED) 77 this.buildCommentActions(),
150 },
151 78
152 err => this.notifier.error(err.message) 79 this.buildVideoActions(),
153 ) 80
154 } 81 this.buildAccountActions()
155 }
156 ],
157 [
158 {
159 label: this.i18n('Actions for the reporter'),
160 isHeader: true,
161 isDisplayed: abuse => !!abuse.reporterAccount
162 },
163 {
164 label: this.i18n('Mute reporter'),
165 isDisplayed: abuse => !!abuse.reporterAccount,
166 handler: async abuse => {
167 const account = abuse.reporterAccount as Account
168
169 this.blocklistService.blockAccountByInstance(account)
170 .subscribe(
171 () => {
172 this.notifier.success(
173 this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost })
174 )
175
176 account.mutedByInstance = true
177 },
178
179 err => this.notifier.error(err.message)
180 )
181 }
182 },
183 {
184 label: this.i18n('Mute server'),
185 isDisplayed: abuse => abuse.reporterAccount && !abuse.reporterAccount.userId,
186 handler: async abuse => {
187 this.blocklistService.blockServerByInstance(abuse.reporterAccount.host)
188 .subscribe(
189 () => {
190 this.notifier.success(
191 this.i18n('Server {{host}} muted by the instance.', { host: abuse.reporterAccount.host })
192 )
193 },
194
195 err => this.notifier.error(err.message)
196 )
197 }
198 }
199 ]
200 ] 82 ]
201 } 83 }
202 84
@@ -207,6 +89,8 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
207 .subscribe(params => { 89 .subscribe(params => {
208 this.search = params.search || '' 90 this.search = params.search || ''
209 91
92 logger('On URL change (search: %s).', this.search)
93
210 this.setTableFilter(this.search) 94 this.setTableFilter(this.search)
211 this.loadData() 95 this.loadData()
212 }) 96 })
@@ -264,6 +148,10 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
264 return Video.buildClientUrl(abuse.comment.video.uuid) + ';threadId=' + abuse.comment.threadId 148 return Video.buildClientUrl(abuse.comment.video.uuid) + ';threadId=' + abuse.comment.threadId
265 } 149 }
266 150
151 getAccountUrl (abuse: ProcessedAbuse) {
152 return '/accounts/' + abuse.flaggedAccount.nameWithHost
153 }
154
267 getVideoEmbed (abuse: Abuse) { 155 getVideoEmbed (abuse: Abuse) {
268 return buildVideoEmbed( 156 return buildVideoEmbed(
269 buildVideoLink({ 157 buildVideoLink({
@@ -304,6 +192,8 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
304 } 192 }
305 193
306 protected loadData () { 194 protected loadData () {
195 logger('Load data.')
196
307 return this.abuseService.getAbuses({ 197 return this.abuseService.getAbuses({
308 pagination: this.pagination, 198 pagination: this.pagination,
309 sort: this.sort, 199 sort: this.sort,
@@ -356,6 +246,208 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
356 ) 246 )
357 } 247 }
358 248
249 private buildInternalActions (): DropdownAction<ProcessedAbuse>[] {
250 return [
251 {
252 label: this.i18n('Internal actions'),
253 isHeader: true
254 },
255 {
256 label: this.i18n('Delete report'),
257 handler: abuse => this.removeAbuse(abuse)
258 },
259 {
260 label: this.i18n('Add note'),
261 handler: abuse => this.openModerationCommentModal(abuse),
262 isDisplayed: abuse => !abuse.moderationComment
263 },
264 {
265 label: this.i18n('Update note'),
266 handler: abuse => this.openModerationCommentModal(abuse),
267 isDisplayed: abuse => !!abuse.moderationComment
268 },
269 {
270 label: this.i18n('Mark as accepted'),
271 handler: abuse => this.updateAbuseState(abuse, AbuseState.ACCEPTED),
272 isDisplayed: abuse => !this.isAbuseAccepted(abuse)
273 },
274 {
275 label: this.i18n('Mark as rejected'),
276 handler: abuse => this.updateAbuseState(abuse, AbuseState.REJECTED),
277 isDisplayed: abuse => !this.isAbuseRejected(abuse)
278 }
279 ]
280 }
281
282 private buildFlaggedAccountActions (): DropdownAction<ProcessedAbuse>[] {
283 return [
284 {
285 label: this.i18n('Actions for the flagged account'),
286 isHeader: true,
287 isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video
288 },
289
290 {
291 label: this.i18n('Mute account'),
292 isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video,
293 handler: abuse => this.muteAccountHelper(abuse.flaggedAccount)
294 },
295
296 {
297 label: this.i18n('Mute server account'),
298 isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video,
299 handler: abuse => this.muteServerHelper(abuse.flaggedAccount.host)
300 }
301 ]
302 }
303
304 private buildAccountActions (): DropdownAction<ProcessedAbuse>[] {
305 return [
306 {
307 label: this.i18n('Actions for the reporter'),
308 isHeader: true,
309 isDisplayed: abuse => !!abuse.reporterAccount
310 },
311
312 {
313 label: this.i18n('Mute reporter'),
314 isDisplayed: abuse => !!abuse.reporterAccount,
315 handler: abuse => this.muteAccountHelper(abuse.reporterAccount)
316 },
317
318 {
319 label: this.i18n('Mute server'),
320 isDisplayed: abuse => abuse.reporterAccount && !abuse.reporterAccount.userId,
321 handler: abuse => this.muteServerHelper(abuse.reporterAccount.host)
322 }
323 ]
324 }
325
326 private buildVideoActions (): DropdownAction<ProcessedAbuse>[] {
327 return [
328 {
329 label: this.i18n('Actions for the video'),
330 isHeader: true,
331 isDisplayed: abuse => abuse.video && !abuse.video.deleted
332 },
333 {
334 label: this.i18n('Block video'),
335 isDisplayed: abuse => abuse.video && !abuse.video.deleted && !abuse.video.blacklisted,
336 handler: abuse => {
337 this.videoBlocklistService.blockVideo(abuse.video.id, undefined, true)
338 .subscribe(
339 () => {
340 this.notifier.success(this.i18n('Video blocked.'))
341
342 this.updateAbuseState(abuse, AbuseState.ACCEPTED)
343 },
344
345 err => this.notifier.error(err.message)
346 )
347 }
348 },
349 {
350 label: this.i18n('Unblock video'),
351 isDisplayed: abuse => abuse.video && !abuse.video.deleted && abuse.video.blacklisted,
352 handler: abuse => {
353 this.videoBlocklistService.unblockVideo(abuse.video.id)
354 .subscribe(
355 () => {
356 this.notifier.success(this.i18n('Video unblocked.'))
357
358 this.updateAbuseState(abuse, AbuseState.ACCEPTED)
359 },
360
361 err => this.notifier.error(err.message)
362 )
363 }
364 },
365 {
366 label: this.i18n('Delete video'),
367 isDisplayed: abuse => abuse.video && !abuse.video.deleted,
368 handler: async abuse => {
369 const res = await this.confirmService.confirm(
370 this.i18n('Do you really want to delete this video?'),
371 this.i18n('Delete')
372 )
373 if (res === false) return
374
375 this.videoService.removeVideo(abuse.video.id)
376 .subscribe(
377 () => {
378 this.notifier.success(this.i18n('Video deleted.'))
379
380 this.updateAbuseState(abuse, AbuseState.ACCEPTED)
381 },
382
383 err => this.notifier.error(err.message)
384 )
385 }
386 }
387 ]
388 }
389
390 private buildCommentActions (): DropdownAction<ProcessedAbuse>[] {
391 return [
392 {
393 label: this.i18n('Actions for the comment'),
394 isHeader: true,
395 isDisplayed: abuse => abuse.comment && !abuse.comment.deleted
396 },
397
398 {
399 label: this.i18n('Delete comment'),
400 isDisplayed: abuse => abuse.comment && !abuse.comment.deleted,
401 handler: async abuse => {
402 const res = await this.confirmService.confirm(
403 this.i18n('Do you really want to delete this comment?'),
404 this.i18n('Delete')
405 )
406 if (res === false) return
407
408 this.commentService.deleteVideoComment(abuse.comment.video.id, abuse.comment.id)
409 .subscribe(
410 () => {
411 this.notifier.success(this.i18n('Comment deleted.'))
412
413 this.updateAbuseState(abuse, AbuseState.ACCEPTED)
414 },
415
416 err => this.notifier.error(err.message)
417 )
418 }
419 }
420 ]
421 }
422
423 private muteAccountHelper (account: Account) {
424 this.blocklistService.blockAccountByInstance(account)
425 .subscribe(
426 () => {
427 this.notifier.success(
428 this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost })
429 )
430
431 account.mutedByInstance = true
432 },
433
434 err => this.notifier.error(err.message)
435 )
436 }
437
438 private muteServerHelper (host: string) {
439 this.blocklistService.blockServerByInstance(host)
440 .subscribe(
441 () => {
442 this.notifier.success(
443 this.i18n('Server {{host}} muted by the instance.', { host: host })
444 )
445 },
446
447 err => this.notifier.error(err.message)
448 )
449 }
450
359 private toHtml (text: string) { 451 private toHtml (text: string) {
360 return this.markdownRenderer.textMarkdownToHTML(text) 452 return this.markdownRenderer.textMarkdownToHTML(text)
361 } 453 }
diff --git a/client/src/app/+admin/moderation/moderation.component.scss b/client/src/app/+admin/moderation/moderation.component.scss
index f73c71dc5..65fe94d39 100644
--- a/client/src/app/+admin/moderation/moderation.component.scss
+++ b/client/src/app/+admin/moderation/moderation.component.scss
@@ -96,7 +96,8 @@ my-action-dropdown.show {
96 top: 3px; 96 top: 3px;
97} 97}
98 98
99.table-comment-link { 99.table-comment-link,
100.table-account-link {
100 @include disable-outline; 101 @include disable-outline;
101 102
102 color: var(--mainForegroundColor); 103 color: var(--mainForegroundColor);
@@ -106,7 +107,13 @@ my-action-dropdown.show {
106 } 107 }
107} 108}
108 109
109.comment-flagged-account { 110.table-account-link {
111 display: flex;
112 flex-direction: column;
113}
114
115.comment-flagged-account,
116.account-flagged-handle {
110 font-size: 11px; 117 font-size: 11px;
111 color: var(--greyForegroundColor); 118 color: var(--greyForegroundColor);
112} 119}
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts
index 79505c779..d79efbb49 100644
--- a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts
+++ b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts
@@ -4,10 +4,9 @@ import { Router } from '@angular/router'
4import { Notifier, User } from '@app/core' 4import { Notifier, User } from '@app/core'
5import { FormReactive, FormValidatorService, VideoCommentValidatorsService } from '@app/shared/shared-forms' 5import { FormReactive, FormValidatorService, VideoCommentValidatorsService } from '@app/shared/shared-forms'
6import { Video } from '@app/shared/shared-main' 6import { Video } from '@app/shared/shared-main'
7import { VideoComment, VideoCommentService } from '@app/shared/shared-video-comment'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 8import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { VideoCommentCreate } from '@shared/models' 9import { VideoCommentCreate } from '@shared/models'
9import { VideoComment } from './video-comment.model'
10import { VideoCommentService } from './video-comment.service'
11 10
12@Component({ 11@Component({
13 selector: 'my-video-comment-add', 12 selector: 'my-video-comment-add',
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.ts b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts
index 2a4a6e737..6744a0954 100644
--- a/client/src/app/+videos/+video-watch/comment/video-comment.component.ts
+++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts
@@ -3,11 +3,10 @@ import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild }
3import { MarkdownService, Notifier, UserService } from '@app/core' 3import { MarkdownService, Notifier, UserService } from '@app/core'
4import { AuthService } from '@app/core/auth' 4import { AuthService } from '@app/core/auth'
5import { Account, Actor, DropdownAction, Video } from '@app/shared/shared-main' 5import { Account, Actor, DropdownAction, Video } from '@app/shared/shared-main'
6import { CommentReportComponent } from '@app/shared/shared-moderation/comment-report.component' 6import { CommentReportComponent } from '@app/shared/shared-moderation/report-modals/comment-report.component'
7import { VideoComment, VideoCommentThreadTree } from '@app/shared/shared-video-comment'
7import { I18n } from '@ngx-translate/i18n-polyfill' 8import { I18n } from '@ngx-translate/i18n-polyfill'
8import { User, UserRight } from '@shared/models' 9import { User, UserRight } from '@shared/models'
9import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
10import { VideoComment } from './video-comment.model'
11 10
12@Component({ 11@Component({
13 selector: 'my-video-comment', 12 selector: 'my-video-comment',
@@ -136,7 +135,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
136 this.comment.account = null 135 this.comment.account = null
137 } 136 }
138 137
139 if (this.isUserLoggedIn()) { 138 if (this.isUserLoggedIn() && this.authService.getUser().account.id !== this.comment.account.id) {
140 this.prependModerationActions = [ 139 this.prependModerationActions = [
141 { 140 {
142 label: this.i18n('Report comment'), 141 label: this.i18n('Report comment'),
diff --git a/client/src/app/+videos/+video-watch/comment/video-comments.component.ts b/client/src/app/+videos/+video-watch/comment/video-comments.component.ts
index df0018ec6..66494a20a 100644
--- a/client/src/app/+videos/+video-watch/comment/video-comments.component.ts
+++ b/client/src/app/+videos/+video-watch/comment/video-comments.component.ts
@@ -4,10 +4,8 @@ import { ActivatedRoute } from '@angular/router'
4import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core' 4import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core'
5import { HooksService } from '@app/core/plugins/hooks.service' 5import { HooksService } from '@app/core/plugins/hooks.service'
6import { Syndication, VideoDetails } from '@app/shared/shared-main' 6import { Syndication, VideoDetails } from '@app/shared/shared-main'
7import { VideoComment, VideoCommentService, VideoCommentThreadTree } from '@app/shared/shared-video-comment'
7import { I18n } from '@ngx-translate/i18n-polyfill' 8import { I18n } from '@ngx-translate/i18n-polyfill'
8import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
9import { VideoComment } from './video-comment.model'
10import { VideoCommentService } from './video-comment.service'
11 9
12@Component({ 10@Component({
13 selector: 'my-video-comments', 11 selector: 'my-video-comments',
diff --git a/client/src/app/+videos/+video-watch/video-watch.module.ts b/client/src/app/+videos/+video-watch/video-watch.module.ts
index 421170d81..5821dc2b7 100644
--- a/client/src/app/+videos/+video-watch/video-watch.module.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.module.ts
@@ -5,16 +5,17 @@ import { SharedGlobalIconModule } from '@app/shared/shared-icons'
5import { SharedMainModule } from '@app/shared/shared-main' 5import { SharedMainModule } from '@app/shared/shared-main'
6import { SharedModerationModule } from '@app/shared/shared-moderation' 6import { SharedModerationModule } from '@app/shared/shared-moderation'
7import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' 7import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
8import { SharedVideoCommentModule } from '@app/shared/shared-video-comment'
8import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' 9import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
9import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist' 10import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
10import { RecommendationsModule } from './recommendations/recommendations.module'
11import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' 11import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
12import { VideoCommentService } from '../../shared/shared-video-comment/video-comment.service'
12import { VideoCommentAddComponent } from './comment/video-comment-add.component' 13import { VideoCommentAddComponent } from './comment/video-comment-add.component'
13import { VideoCommentComponent } from './comment/video-comment.component' 14import { VideoCommentComponent } from './comment/video-comment.component'
14import { VideoCommentService } from './comment/video-comment.service'
15import { VideoCommentsComponent } from './comment/video-comments.component' 15import { VideoCommentsComponent } from './comment/video-comments.component'
16import { VideoShareComponent } from './modal/video-share.component' 16import { VideoShareComponent } from './modal/video-share.component'
17import { VideoSupportComponent } from './modal/video-support.component' 17import { VideoSupportComponent } from './modal/video-support.component'
18import { RecommendationsModule } from './recommendations/recommendations.module'
18import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive' 19import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive'
19import { VideoDurationPipe } from './video-duration-formatter.pipe' 20import { VideoDurationPipe } from './video-duration-formatter.pipe'
20import { VideoWatchPlaylistComponent } from './video-watch-playlist.component' 21import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
@@ -34,7 +35,8 @@ import { VideoWatchComponent } from './video-watch.component'
34 SharedVideoPlaylistModule, 35 SharedVideoPlaylistModule,
35 SharedUserSubscriptionModule, 36 SharedUserSubscriptionModule,
36 SharedModerationModule, 37 SharedModerationModule,
37 SharedGlobalIconModule 38 SharedGlobalIconModule,
39 SharedVideoCommentModule
38 ], 40 ],
39 41
40 declarations: [ 42 declarations: [
diff --git a/client/src/app/core/rest/rest-table.ts b/client/src/app/core/rest/rest-table.ts
index 1b35ad47d..e6328eddc 100644
--- a/client/src/app/core/rest/rest-table.ts
+++ b/client/src/app/core/rest/rest-table.ts
@@ -3,6 +3,9 @@ import { LazyLoadEvent, SortMeta } from 'primeng/api'
3import { RestPagination } from './rest-pagination' 3import { RestPagination } from './rest-pagination'
4import { Subject } from 'rxjs' 4import { Subject } from 'rxjs'
5import { debounceTime, distinctUntilChanged } from 'rxjs/operators' 5import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
6import * as debug from 'debug'
7
8const logger = debug('peertube:tables:RestTable')
6 9
7export abstract class RestTable { 10export abstract class RestTable {
8 11
@@ -15,7 +18,7 @@ export abstract class RestTable {
15 rowsPerPage = this.rowsPerPageOptions[0] 18 rowsPerPage = this.rowsPerPageOptions[0]
16 expandedRows = {} 19 expandedRows = {}
17 20
18 private searchStream: Subject<string> 21 protected searchStream: Subject<string>
19 22
20 abstract getIdentifier (): string 23 abstract getIdentifier (): string
21 24
@@ -37,6 +40,8 @@ export abstract class RestTable {
37 } 40 }
38 41
39 loadLazy (event: LazyLoadEvent) { 42 loadLazy (event: LazyLoadEvent) {
43 logger('Load lazy %o.', event)
44
40 this.sort = { 45 this.sort = {
41 order: event.sortOrder, 46 order: event.sortOrder,
42 field: event.sortField 47 field: event.sortField
@@ -65,6 +70,9 @@ export abstract class RestTable {
65 ) 70 )
66 .subscribe(search => { 71 .subscribe(search => {
67 this.search = search 72 this.search = search
73
74 logger('On search %s.', this.search)
75
68 this.loadData() 76 this.loadData()
69 }) 77 })
70 } 78 }
@@ -75,14 +83,18 @@ export abstract class RestTable {
75 } 83 }
76 84
77 onPage (event: { first: number, rows: number }) { 85 onPage (event: { first: number, rows: number }) {
86 logger('On page %o.', event)
87
78 if (this.rowsPerPage !== event.rows) { 88 if (this.rowsPerPage !== event.rows) {
79 this.rowsPerPage = event.rows 89 this.rowsPerPage = event.rows
80 this.pagination = { 90 this.pagination = {
81 start: event.first, 91 start: event.first,
82 count: this.rowsPerPage 92 count: this.rowsPerPage
83 } 93 }
94
84 this.loadData() 95 this.loadData()
85 } 96 }
97
86 this.expandedRows = {} 98 this.expandedRows = {}
87 } 99 }
88 100
diff --git a/client/src/app/shared/shared-main/account/actor.model.ts b/client/src/app/shared/shared-main/account/actor.model.ts
index 9ec6dbab1..bda88bdee 100644
--- a/client/src/app/shared/shared-main/account/actor.model.ts
+++ b/client/src/app/shared/shared-main/account/actor.model.ts
@@ -14,6 +14,8 @@ export abstract class Actor implements ActorServer {
14 14
15 avatarUrl: string 15 avatarUrl: string
16 16
17 isLocal: boolean
18
17 static GET_ACTOR_AVATAR_URL (actor: { avatar?: { url?: string, path: string } }) { 19 static GET_ACTOR_AVATAR_URL (actor: { avatar?: { url?: string, path: string } }) {
18 if (actor?.avatar?.url) return actor.avatar.url 20 if (actor?.avatar?.url) return actor.avatar.url
19 21
@@ -52,6 +54,10 @@ export abstract class Actor implements ActorServer {
52 54
53 this.avatar = hash.avatar 55 this.avatar = hash.avatar
54 56
57 const absoluteAPIUrl = getAbsoluteAPIUrl()
58 const thisHost = new URL(absoluteAPIUrl).host
59 this.isLocal = this.host.trim() === thisHost
60
55 this.updateComputedAttributes() 61 this.updateComputedAttributes()
56 } 62 }
57 63
diff --git a/client/src/app/shared/shared-main/users/user-notification.model.ts b/client/src/app/shared/shared-main/users/user-notification.model.ts
index a137f8c62..61b48a806 100644
--- a/client/src/app/shared/shared-main/users/user-notification.model.ts
+++ b/client/src/app/shared/shared-main/users/user-notification.model.ts
@@ -34,7 +34,9 @@ export class UserNotification implements UserNotificationServer {
34 threadId: number 34 threadId: number
35 35
36 video: { 36 video: {
37 id: number
37 uuid: string 38 uuid: string
39 name: string
38 } 40 }
39 } 41 }
40 42
@@ -115,13 +117,15 @@ export class UserNotification implements UserNotificationServer {
115 case UserNotificationType.COMMENT_MENTION: 117 case UserNotificationType.COMMENT_MENTION:
116 if (!this.comment) break 118 if (!this.comment) break
117 this.accountUrl = this.buildAccountUrl(this.comment.account) 119 this.accountUrl = this.buildAccountUrl(this.comment.account)
118 this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ] 120 this.commentUrl = this.buildCommentUrl(this.comment)
119 break 121 break
120 122
121 case UserNotificationType.NEW_ABUSE_FOR_MODERATORS: 123 case UserNotificationType.NEW_ABUSE_FOR_MODERATORS:
122 this.abuseUrl = '/admin/moderation/abuses/list' 124 this.abuseUrl = '/admin/moderation/abuses/list'
123 125
124 if (this.abuse.video) this.videoUrl = this.buildVideoUrl(this.abuse.video) 126 if (this.abuse.video) this.videoUrl = this.buildVideoUrl(this.abuse.video)
127 else if (this.abuse.comment) this.commentUrl = this.buildCommentUrl(this.abuse.comment)
128 else if (this.abuse.account) this.accountUrl = this.buildAccountUrl(this.abuse.account)
125 break 129 break
126 130
127 case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS: 131 case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS:
@@ -190,6 +194,10 @@ export class UserNotification implements UserNotificationServer {
190 return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName 194 return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName
191 } 195 }
192 196
197 private buildCommentUrl (comment: { video: { uuid: string }, threadId: number }) {
198 return [ this.buildVideoUrl(comment.video), { threadId: comment.threadId } ]
199 }
200
193 private setAvatarUrl (actor: { avatarUrl?: string, avatar?: { url?: string, path: string } }) { 201 private setAvatarUrl (actor: { avatarUrl?: string, avatar?: { url?: string, path: string } }) {
194 actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor) 202 actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor)
195 } 203 }
diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.html b/client/src/app/shared/shared-main/users/user-notifications.component.html
index 2b341af2c..8127ae979 100644
--- a/client/src/app/shared/shared-main/users/user-notifications.component.html
+++ b/client/src/app/shared/shared-main/users/user-notifications.component.html
@@ -45,9 +45,22 @@
45 <ng-container *ngSwitchCase="UserNotificationType.NEW_ABUSE_FOR_MODERATORS"> 45 <ng-container *ngSwitchCase="UserNotificationType.NEW_ABUSE_FOR_MODERATORS">
46 <my-global-icon iconName="flag" aria-hidden="true"></my-global-icon> 46 <my-global-icon iconName="flag" aria-hidden="true"></my-global-icon>
47 47
48 <div class="message" i18n> 48 <div class="message" *ngIf="notification.videoUrl" i18n>
49 <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl">A new video abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.abuse.video.name }}</a> 49 <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl">A new video abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.abuse.video.name }}</a>
50 </div> 50 </div>
51
52 <div class="message" *ngIf="notification.commentUrl" i18n>
53 <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl">A new comment abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.abuse.comment.video.name }}</a>
54 </div>
55
56 <div class="message" *ngIf="notification.accountUrl" i18n>
57 <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl">A new account abuse</a> has been created on account <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.abuse.account.displayName }}</a>
58 </div>
59
60 <!-- Deleted entity associated to the abuse -->
61 <div class="message" *ngIf="!notification.videoUrl && !notification.commentUrl && !notification.accountUrl" i18n>
62 <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl">A new abuse</a> has been created
63 </div>
51 </ng-container> 64 </ng-container>
52 65
53 <ng-container *ngSwitchCase="UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS"> 66 <ng-container *ngSwitchCase="UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS">
diff --git a/client/src/app/shared/shared-moderation/comment-report.component.scss b/client/src/app/shared/shared-moderation/comment-report.component.scss
deleted file mode 100644
index 17a33d3a2..000000000
--- a/client/src/app/shared/shared-moderation/comment-report.component.scss
+++ /dev/null
@@ -1,11 +0,0 @@
1@import 'variables';
2@import 'mixins';
3
4.information {
5 margin-bottom: 20px;
6}
7
8textarea {
9 @include peertube-textarea(100%, 100px);
10}
11
diff --git a/client/src/app/shared/shared-moderation/index.ts b/client/src/app/shared/shared-moderation/index.ts
index d6c4a10be..41c910ffe 100644
--- a/client/src/app/shared/shared-moderation/index.ts
+++ b/client/src/app/shared/shared-moderation/index.ts
@@ -1,3 +1,5 @@
1export * from './report-modals'
2
1export * from './abuse.service' 3export * from './abuse.service'
2export * from './account-block.model' 4export * from './account-block.model'
3export * from './account-blocklist.component' 5export * from './account-blocklist.component'
@@ -9,5 +11,4 @@ export * from './user-ban-modal.component'
9export * from './user-moderation-dropdown.component' 11export * from './user-moderation-dropdown.component'
10export * from './video-block.component' 12export * from './video-block.component'
11export * from './video-block.service' 13export * from './video-block.service'
12export * from './video-report.component'
13export * from './shared-moderation.module' 14export * from './shared-moderation.module'
diff --git a/client/src/app/shared/shared-moderation/report-modals/account-report.component.ts b/client/src/app/shared/shared-moderation/report-modals/account-report.component.ts
new file mode 100644
index 000000000..78ca934c7
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/report-modals/account-report.component.ts
@@ -0,0 +1,94 @@
1import { mapValues, pickBy } from 'lodash-es'
2import { Component, Input, OnInit, ViewChild } from '@angular/core'
3import { Notifier } from '@app/core'
4import { AbuseValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms'
5import { Account } from '@app/shared/shared-main'
6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
8import { I18n } from '@ngx-translate/i18n-polyfill'
9import { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models'
10import { AbuseService } from '../abuse.service'
11
12@Component({
13 selector: 'my-account-report',
14 templateUrl: './report.component.html',
15 styleUrls: [ './report.component.scss' ]
16})
17export class AccountReportComponent extends FormReactive implements OnInit {
18 @Input() account: Account = null
19
20 @ViewChild('modal', { static: true }) modal: NgbModal
21
22 error: string = null
23 predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
24 modalTitle: string
25
26 private openedModal: NgbModalRef
27
28 constructor (
29 protected formValidatorService: FormValidatorService,
30 private modalService: NgbModal,
31 private abuseValidatorsService: AbuseValidatorsService,
32 private abuseService: AbuseService,
33 private notifier: Notifier,
34 private i18n: I18n
35 ) {
36 super()
37 }
38
39 get currentHost () {
40 return window.location.host
41 }
42
43 get originHost () {
44 if (this.isRemote()) {
45 return this.account.host
46 }
47
48 return ''
49 }
50
51 ngOnInit () {
52 this.modalTitle = this.i18n('Report {{displayName}}', { displayName: this.account.displayName })
53
54 this.buildForm({
55 reason: this.abuseValidatorsService.ABUSE_REASON,
56 predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null)
57 })
58
59 this.predefinedReasons = this.abuseService.getPrefefinedReasons('account')
60 }
61
62 show () {
63 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
64 }
65
66 hide () {
67 this.openedModal.close()
68 this.openedModal = null
69 }
70
71 report () {
72 const reason = this.form.get('reason').value
73 const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as AbusePredefinedReasonsString[]
74
75 this.abuseService.reportVideo({
76 reason,
77 predefinedReasons,
78 account: {
79 id: this.account.id
80 }
81 }).subscribe(
82 () => {
83 this.notifier.success(this.i18n('Account reported.'))
84 this.hide()
85 },
86
87 err => this.notifier.error(err.message)
88 )
89 }
90
91 isRemote () {
92 return !this.account.isLocal
93 }
94}
diff --git a/client/src/app/shared/shared-moderation/comment-report.component.ts b/client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts
index 5db4b2dc1..00d7b8d34 100644
--- a/client/src/app/shared/shared-moderation/comment-report.component.ts
+++ b/client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts
@@ -1,28 +1,27 @@
1import { mapValues, pickBy } from 'lodash-es' 1import { mapValues, pickBy } from 'lodash-es'
2import { Component, Input, OnInit, ViewChild } from '@angular/core' 2import { Component, Input, OnInit, ViewChild } from '@angular/core'
3import { SafeHtml } from '@angular/platform-browser'
4import { VideoComment } from '@app/+videos/+video-watch/comment/video-comment.model'
5import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
6import { AbuseValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms' 4import { AbuseValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms'
5import { VideoComment } from '@app/shared/shared-video-comment'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
9import { I18n } from '@ngx-translate/i18n-polyfill' 8import { I18n } from '@ngx-translate/i18n-polyfill'
10import { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models' 9import { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models'
11import { AbuseService } from './abuse.service' 10import { AbuseService } from '../abuse.service'
12 11
13@Component({ 12@Component({
14 selector: 'my-comment-report', 13 selector: 'my-comment-report',
15 templateUrl: './comment-report.component.html', 14 templateUrl: './report.component.html',
16 styleUrls: [ './comment-report.component.scss' ] 15 styleUrls: [ './report.component.scss' ]
17}) 16})
18export class CommentReportComponent extends FormReactive implements OnInit { 17export class CommentReportComponent extends FormReactive implements OnInit {
19 @Input() comment: VideoComment = null 18 @Input() comment: VideoComment = null
20 19
21 @ViewChild('modal', { static: true }) modal: NgbModal 20 @ViewChild('modal', { static: true }) modal: NgbModal
22 21
22 modalTitle: string
23 error: string = null 23 error: string = null
24 predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [] 24 predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
25 embedHtml: SafeHtml
26 25
27 private openedModal: NgbModalRef 26 private openedModal: NgbModalRef
28 27
@@ -42,7 +41,7 @@ export class CommentReportComponent extends FormReactive implements OnInit {
42 } 41 }
43 42
44 get originHost () { 43 get originHost () {
45 if (this.isRemoteComment()) { 44 if (this.isRemote()) {
46 return this.comment.account.host 45 return this.comment.account.host
47 } 46 }
48 47
@@ -50,6 +49,8 @@ export class CommentReportComponent extends FormReactive implements OnInit {
50 } 49 }
51 50
52 ngOnInit () { 51 ngOnInit () {
52 this.modalTitle = this.i18n('Report comment')
53
53 this.buildForm({ 54 this.buildForm({
54 reason: this.abuseValidatorsService.ABUSE_REASON, 55 reason: this.abuseValidatorsService.ABUSE_REASON,
55 predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null) 56 predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null)
@@ -87,7 +88,7 @@ export class CommentReportComponent extends FormReactive implements OnInit {
87 ) 88 )
88 } 89 }
89 90
90 isRemoteComment () { 91 isRemote () {
91 return !this.comment.isLocal 92 return !this.comment.isLocal
92 } 93 }
93} 94}
diff --git a/client/src/app/shared/shared-moderation/report-modals/index.ts b/client/src/app/shared/shared-moderation/report-modals/index.ts
new file mode 100644
index 000000000..f3c4058ae
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/report-modals/index.ts
@@ -0,0 +1,3 @@
1export * from './account-report.component'
2export * from './comment-report.component'
3export * from './video-report.component'
diff --git a/client/src/app/shared/shared-moderation/comment-report.component.html b/client/src/app/shared/shared-moderation/report-modals/report.component.html
index 1105b3788..bda62312f 100644
--- a/client/src/app/shared/shared-moderation/comment-report.component.html
+++ b/client/src/app/shared/shared-moderation/report-modals/report.component.html
@@ -1,6 +1,6 @@
1<ng-template #modal> 1<ng-template #modal>
2 <div class="modal-header"> 2 <div class="modal-header">
3 <h4 i18n class="modal-title">Report comment</h4> 3 <h4 class="modal-title">{{ modalTitle }}</h4>
4 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> 4 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
5 </div> 5 </div>
6 6
@@ -34,7 +34,7 @@
34 34
35 <div class="col-7"> 35 <div class="col-7">
36 <div i18n class="information"> 36 <div i18n class="information">
37 Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteComment()"> and will be forwarded to the comment origin ({{ originHost }}) too</ng-container>. 37 Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemote()"> and will be forwarded to the comment origin ({{ originHost }}) too</ng-container>.
38 </div> 38 </div>
39 39
40 <div class="form-group"> 40 <div class="form-group">
diff --git a/client/src/app/shared/shared-moderation/video-report.component.scss b/client/src/app/shared/shared-moderation/report-modals/report.component.scss
index b2606cbd8..b2606cbd8 100644
--- a/client/src/app/shared/shared-moderation/video-report.component.scss
+++ b/client/src/app/shared/shared-moderation/report-modals/report.component.scss
diff --git a/client/src/app/shared/shared-moderation/video-report.component.html b/client/src/app/shared/shared-moderation/report-modals/video-report.component.html
index b724ecb18..4947088d1 100644
--- a/client/src/app/shared/shared-moderation/video-report.component.html
+++ b/client/src/app/shared/shared-moderation/report-modals/video-report.component.html
@@ -72,7 +72,7 @@
72 </div> 72 </div>
73 73
74 <div i18n class="information"> 74 <div i18n class="information">
75 Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteVideo()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>. 75 Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemote()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>.
76 </div> 76 </div>
77 77
78 <div class="form-group"> 78 <div class="form-group">
diff --git a/client/src/app/shared/shared-moderation/video-report.component.ts b/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts
index 26e7b62ba..7d53ea3c9 100644
--- a/client/src/app/shared/shared-moderation/video-report.component.ts
+++ b/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts
@@ -8,13 +8,13 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
9import { I18n } from '@ngx-translate/i18n-polyfill' 9import { I18n } from '@ngx-translate/i18n-polyfill'
10import { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models' 10import { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models'
11import { Video } from '../shared-main' 11import { Video } from '../../shared-main'
12import { AbuseService } from './abuse.service' 12import { AbuseService } from '../abuse.service'
13 13
14@Component({ 14@Component({
15 selector: 'my-video-report', 15 selector: 'my-video-report',
16 templateUrl: './video-report.component.html', 16 templateUrl: './video-report.component.html',
17 styleUrls: [ './video-report.component.scss' ] 17 styleUrls: [ './report.component.scss' ]
18}) 18})
19export class VideoReportComponent extends FormReactive implements OnInit { 19export class VideoReportComponent extends FormReactive implements OnInit {
20 @Input() video: Video = null 20 @Input() video: Video = null
@@ -44,7 +44,7 @@ export class VideoReportComponent extends FormReactive implements OnInit {
44 } 44 }
45 45
46 get originHost () { 46 get originHost () {
47 if (this.isRemoteVideo()) { 47 if (this.isRemote()) {
48 return this.video.account.host 48 return this.video.account.host
49 } 49 }
50 50
@@ -116,7 +116,7 @@ export class VideoReportComponent extends FormReactive implements OnInit {
116 ) 116 )
117 } 117 }
118 118
119 isRemoteVideo () { 119 isRemote () {
120 return !this.video.isLocal 120 return !this.video.isLocal
121 } 121 }
122} 122}
diff --git a/client/src/app/shared/shared-moderation/shared-moderation.module.ts b/client/src/app/shared/shared-moderation/shared-moderation.module.ts
index ff4021a33..8fa9ee794 100644
--- a/client/src/app/shared/shared-moderation/shared-moderation.module.ts
+++ b/client/src/app/shared/shared-moderation/shared-moderation.module.ts
@@ -3,22 +3,23 @@ import { NgModule } from '@angular/core'
3import { SharedFormModule } from '../shared-forms/shared-form.module' 3import { SharedFormModule } from '../shared-forms/shared-form.module'
4import { SharedGlobalIconModule } from '../shared-icons' 4import { SharedGlobalIconModule } from '../shared-icons'
5import { SharedMainModule } from '../shared-main/shared-main.module' 5import { SharedMainModule } from '../shared-main/shared-main.module'
6import { SharedVideoCommentModule } from '../shared-video-comment'
7import { AbuseService } from './abuse.service'
6import { BatchDomainsModalComponent } from './batch-domains-modal.component' 8import { BatchDomainsModalComponent } from './batch-domains-modal.component'
7import { BlocklistService } from './blocklist.service' 9import { BlocklistService } from './blocklist.service'
8import { BulkService } from './bulk.service' 10import { BulkService } from './bulk.service'
9import { UserBanModalComponent } from './user-ban-modal.component' 11import { UserBanModalComponent } from './user-ban-modal.component'
10import { UserModerationDropdownComponent } from './user-moderation-dropdown.component' 12import { UserModerationDropdownComponent } from './user-moderation-dropdown.component'
11import { AbuseService } from './abuse.service'
12import { VideoBlockComponent } from './video-block.component' 13import { VideoBlockComponent } from './video-block.component'
13import { VideoBlockService } from './video-block.service' 14import { VideoBlockService } from './video-block.service'
14import { VideoReportComponent } from './video-report.component' 15import { VideoReportComponent, AccountReportComponent, CommentReportComponent } from './report-modals'
15import { CommentReportComponent } from './comment-report.component'
16 16
17@NgModule({ 17@NgModule({
18 imports: [ 18 imports: [
19 SharedMainModule, 19 SharedMainModule,
20 SharedFormModule, 20 SharedFormModule,
21 SharedGlobalIconModule 21 SharedGlobalIconModule,
22 SharedVideoCommentModule
22 ], 23 ],
23 24
24 declarations: [ 25 declarations: [
@@ -27,7 +28,8 @@ import { CommentReportComponent } from './comment-report.component'
27 VideoBlockComponent, 28 VideoBlockComponent,
28 VideoReportComponent, 29 VideoReportComponent,
29 BatchDomainsModalComponent, 30 BatchDomainsModalComponent,
30 CommentReportComponent 31 CommentReportComponent,
32 AccountReportComponent
31 ], 33 ],
32 34
33 exports: [ 35 exports: [
@@ -36,7 +38,8 @@ import { CommentReportComponent } from './comment-report.component'
36 VideoBlockComponent, 38 VideoBlockComponent,
37 VideoReportComponent, 39 VideoReportComponent,
38 BatchDomainsModalComponent, 40 BatchDomainsModalComponent,
39 CommentReportComponent 41 CommentReportComponent,
42 AccountReportComponent
40 ], 43 ],
41 44
42 providers: [ 45 providers: [
diff --git a/client/src/app/shared/shared-video-comment/index.ts b/client/src/app/shared/shared-video-comment/index.ts
new file mode 100644
index 000000000..b1195f232
--- /dev/null
+++ b/client/src/app/shared/shared-video-comment/index.ts
@@ -0,0 +1,5 @@
1export * from './video-comment.service'
2export * from './video-comment.model'
3export * from './video-comment-thread-tree.model'
4
5export * from './shared-video-comment.module'
diff --git a/client/src/app/shared/shared-video-comment/shared-video-comment.module.ts b/client/src/app/shared/shared-video-comment/shared-video-comment.module.ts
new file mode 100644
index 000000000..41b329861
--- /dev/null
+++ b/client/src/app/shared/shared-video-comment/shared-video-comment.module.ts
@@ -0,0 +1,19 @@
1
2import { NgModule } from '@angular/core'
3import { SharedMainModule } from '../shared-main/shared-main.module'
4import { VideoCommentService } from './video-comment.service'
5
6@NgModule({
7 imports: [
8 SharedMainModule
9 ],
10
11 declarations: [ ],
12
13 exports: [ ],
14
15 providers: [
16 VideoCommentService
17 ]
18})
19export class SharedVideoCommentModule { }
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts b/client/src/app/shared/shared-video-comment/video-comment-thread-tree.model.ts
index 7c2aaeadd..7c2aaeadd 100644
--- a/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts
+++ b/client/src/app/shared/shared-video-comment/video-comment-thread-tree.model.ts
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.model.ts b/client/src/app/shared/shared-video-comment/video-comment.model.ts
index e85443196..e85443196 100644
--- a/client/src/app/+videos/+video-watch/comment/video-comment.model.ts
+++ b/client/src/app/shared/shared-video-comment/video-comment.model.ts
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.service.ts b/client/src/app/shared/shared-video-comment/video-comment.service.ts
index a73fb9ca8..81c65aa38 100644
--- a/client/src/app/+videos/+video-watch/comment/video-comment.service.ts
+++ b/client/src/app/shared/shared-video-comment/video-comment.service.ts
@@ -11,7 +11,7 @@ import {
11 VideoCommentCreate, 11 VideoCommentCreate,
12 VideoCommentThreadTree as VideoCommentThreadTreeServerModel 12 VideoCommentThreadTree as VideoCommentThreadTreeServerModel
13} from '@shared/models' 13} from '@shared/models'
14import { environment } from '../../../../environments/environment' 14import { environment } from '../../../environments/environment'
15import { VideoCommentThreadTree } from './video-comment-thread-tree.model' 15import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
16import { VideoComment } from './video-comment.model' 16import { VideoComment } from './video-comment.model'
17 17
diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts
index 5a6f37bb9..d54eab966 100644
--- a/server/lib/emailer.ts
+++ b/server/lib/emailer.ts
@@ -311,7 +311,8 @@ class Emailer {
311 videoPublishedAt: new Date(video.publishedAt).toLocaleString(), 311 videoPublishedAt: new Date(video.publishedAt).toLocaleString(),
312 videoName: video.name, 312 videoName: video.name,
313 reason: abuse.reason, 313 reason: abuse.reason,
314 videoChannel: video.VideoChannel, 314 videoChannel: abuse.video.channel,
315 reporter,
315 action 316 action
316 } 317 }
317 } 318 }
@@ -330,6 +331,7 @@ class Emailer {
330 commentCreatedAt: new Date(comment.createdAt).toLocaleString(), 331 commentCreatedAt: new Date(comment.createdAt).toLocaleString(),
331 reason: abuse.reason, 332 reason: abuse.reason,
332 flaggedAccount: abuseInstance.FlaggedAccount.getDisplayName(), 333 flaggedAccount: abuseInstance.FlaggedAccount.getDisplayName(),
334 reporter,
333 action 335 action
334 } 336 }
335 } 337 }
@@ -346,6 +348,7 @@ class Emailer {
346 accountDisplayName: account.getDisplayName(), 348 accountDisplayName: account.getDisplayName(),
347 isLocal: account.isOwned(), 349 isLocal: account.isOwned(),
348 reason: abuse.reason, 350 reason: abuse.reason,
351 reporter,
349 action 352 action
350 } 353 }
351 } 354 }
diff --git a/server/lib/emails/account-abuse-new/html.pug b/server/lib/emails/account-abuse-new/html.pug
index 06be8025b..f1aa2886e 100644
--- a/server/lib/emails/account-abuse-new/html.pug
+++ b/server/lib/emails/account-abuse-new/html.pug
@@ -6,8 +6,8 @@ block title
6 6
7block content 7block content
8 p 8 p
9 | #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}] received an abuse report for the #{isLocal ? '' : 'remote '}account " 9 | #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}] received an abuse report for the #{isLocal ? '' : 'remote '}account
10 a(href=accountUrl) #{accountDisplayName} 10 a(href=accountUrl) #{accountDisplayName}
11 11
12 p The reporter, #{reporter}, cited the following reason(s): 12 p The reporter, #{reporter}, cited the following reason(s):
13 blockquote #{reason} 13 blockquote #{reason}
diff --git a/server/lib/emails/video-comment-abuse-new/html.pug b/server/lib/emails/video-comment-abuse-new/html.pug
index fc1c3e4e7..e92d986b5 100644
--- a/server/lib/emails/video-comment-abuse-new/html.pug
+++ b/server/lib/emails/video-comment-abuse-new/html.pug
@@ -6,10 +6,10 @@ block title
6 6
7block content 7block content
8 p 8 p
9 | #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}] received an abuse report for the #{isLocal ? '' : 'remote '}comment " 9 | #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}] received an abuse report for the #{isLocal ? '' : 'remote '}
10 a(href=commentUrl) on video #{videoName} 10 a(href=commentUrl) comment on video "#{videoName}"
11 | of #{flaggedAccount} 11 | of #{flaggedAccount}
12 | created on #{commentCreatedAt} 12 | created on #{commentCreatedAt}
13 13
14 p The reporter, #{reporter}, cited the following reason(s): 14 p The reporter, #{reporter}, cited the following reason(s):
15 blockquote #{reason} 15 blockquote #{reason}
diff --git a/shared/models/moderation/abuse/abuse.model.ts b/shared/models/moderation/abuse/abuse.model.ts
index 74798ab2c..0a0c6bd35 100644
--- a/shared/models/moderation/abuse/abuse.model.ts
+++ b/shared/models/moderation/abuse/abuse.model.ts
@@ -62,9 +62,9 @@ export interface Abuse {
62 // FIXME: deprecated in 2.3, remove the following properties 62 // FIXME: deprecated in 2.3, remove the following properties
63 63
64 // @deprecated 64 // @deprecated
65 startAt: null 65 startAt?: null
66 // @deprecated 66 // @deprecated
67 endAt: null 67 endAt?: null
68 68
69 // @deprecated 69 // @deprecated
70 count?: number 70 count?: number
diff --git a/shared/models/users/user-notification.model.ts b/shared/models/users/user-notification.model.ts
index 11d96fd50..5f7c33976 100644
--- a/shared/models/users/user-notification.model.ts
+++ b/shared/models/users/user-notification.model.ts
@@ -73,7 +73,9 @@ export interface UserNotification {
73 threadId: number 73 threadId: number
74 74
75 video: { 75 video: {
76 id: number
76 uuid: string 77 uuid: string
78 name: string
77 } 79 }
78 } 80 }
79 81