aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src
diff options
context:
space:
mode:
Diffstat (limited to 'client/src')
-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/admin.component.ts12
-rw-r--r--client/src/app/+admin/admin.module.ts10
-rw-r--r--client/src/app/+admin/moderation/abuse-list/abuse-details.component.html115
-rw-r--r--client/src/app/+admin/moderation/abuse-list/abuse-details.component.ts (renamed from client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts)23
-rw-r--r--client/src/app/+admin/moderation/abuse-list/abuse-list.component.html184
-rw-r--r--client/src/app/+admin/moderation/abuse-list/abuse-list.component.scss (renamed from client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.scss)2
-rw-r--r--client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts454
-rw-r--r--client/src/app/+admin/moderation/abuse-list/index.ts3
-rw-r--r--client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.html (renamed from client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.html)0
-rw-r--r--client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.scss (renamed from client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.scss)0
-rw-r--r--client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.ts (renamed from client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts)18
-rw-r--r--client/src/app/+admin/moderation/index.ts2
-rw-r--r--client/src/app/+admin/moderation/moderation.component.scss48
-rw-r--r--client/src/app/+admin/moderation/moderation.routes.ts17
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/index.ts2
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html93
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html149
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts328
-rw-r--r--client/src/app/+admin/users/user-edit/user-edit.component.html8
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts4
-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.html5
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment.component.ts30
-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/core/users/user.model.ts14
-rw-r--r--client/src/app/menu/menu.component.ts4
-rw-r--r--client/src/app/shared/shared-forms/form-validators/abuse-validators.service.ts (renamed from client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts)10
-rw-r--r--client/src/app/shared/shared-forms/form-validators/index.ts2
-rw-r--r--client/src/app/shared/shared-forms/shared-form.module.ts4
-rw-r--r--client/src/app/shared/shared-main/account/actor.model.ts14
-rw-r--r--client/src/app/shared/shared-main/users/user-notification.model.ts38
-rw-r--r--client/src/app/shared/shared-main/users/user-notifications.component.html25
-rw-r--r--client/src/app/shared/shared-moderation/abuse.service.ts154
-rw-r--r--client/src/app/shared/shared-moderation/index.ts5
-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.ts94
-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.html62
-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)9
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/video-report.component.ts122
-rw-r--r--client/src/app/shared/shared-moderation/shared-moderation.module.ts18
-rw-r--r--client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts7
-rw-r--r--client/src/app/shared/shared-moderation/video-abuse.service.ts98
-rw-r--r--client/src/app/shared/shared-moderation/video-report.component.ts161
-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
54 files changed, 1605 insertions, 963 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/admin.component.ts b/client/src/app/+admin/admin.component.ts
index 6f340884f..4345d1945 100644
--- a/client/src/app/+admin/admin.component.ts
+++ b/client/src/app/+admin/admin.component.ts
@@ -45,10 +45,10 @@ export class AdminComponent implements OnInit {
45 children: [] 45 children: []
46 } 46 }
47 47
48 if (this.hasVideoAbusesRight()) { 48 if (this.hasAbusesRight()) {
49 moderationItems.children.push({ 49 moderationItems.children.push({
50 label: this.i18n('Video reports'), 50 label: this.i18n('Reports'),
51 routerLink: '/admin/moderation/video-abuses/list', 51 routerLink: '/admin/moderation/abuses/list',
52 iconName: 'flag' 52 iconName: 'flag'
53 }) 53 })
54 } 54 }
@@ -76,7 +76,7 @@ export class AdminComponent implements OnInit {
76 76
77 if (this.hasUsersRight()) this.menuEntries.push({ label: this.i18n('Users'), routerLink: '/admin/users' }) 77 if (this.hasUsersRight()) this.menuEntries.push({ label: this.i18n('Users'), routerLink: '/admin/users' })
78 if (this.hasServerFollowRight()) this.menuEntries.push(federationItems) 78 if (this.hasServerFollowRight()) this.menuEntries.push(federationItems)
79 if (this.hasVideoAbusesRight() || this.hasVideoBlocklistRight()) this.menuEntries.push(moderationItems) 79 if (this.hasAbusesRight() || this.hasVideoBlocklistRight()) this.menuEntries.push(moderationItems)
80 if (this.hasConfigRight()) this.menuEntries.push({ label: this.i18n('Configuration'), routerLink: '/admin/config' }) 80 if (this.hasConfigRight()) this.menuEntries.push({ label: this.i18n('Configuration'), routerLink: '/admin/config' })
81 if (this.hasPluginsRight()) this.menuEntries.push({ label: this.i18n('Plugins/Themes'), routerLink: '/admin/plugins' }) 81 if (this.hasPluginsRight()) this.menuEntries.push({ label: this.i18n('Plugins/Themes'), routerLink: '/admin/plugins' })
82 if (this.hasJobsRight() || this.hasLogsRight() || this.hasDebugRight()) this.menuEntries.push({ label: this.i18n('System'), routerLink: '/admin/system' }) 82 if (this.hasJobsRight() || this.hasLogsRight() || this.hasDebugRight()) this.menuEntries.push({ label: this.i18n('System'), routerLink: '/admin/system' })
@@ -90,8 +90,8 @@ export class AdminComponent implements OnInit {
90 return this.auth.getUser().hasRight(UserRight.MANAGE_SERVER_FOLLOW) 90 return this.auth.getUser().hasRight(UserRight.MANAGE_SERVER_FOLLOW)
91 } 91 }
92 92
93 hasVideoAbusesRight () { 93 hasAbusesRight () {
94 return this.auth.getUser().hasRight(UserRight.MANAGE_VIDEO_ABUSES) 94 return this.auth.getUser().hasRight(UserRight.MANAGE_ABUSES)
95 } 95 }
96 96
97 hasVideoBlocklistRight () { 97 hasVideoBlocklistRight () {
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts
index 728227a84..c59bd2927 100644
--- a/client/src/app/+admin/admin.module.ts
+++ b/client/src/app/+admin/admin.module.ts
@@ -14,10 +14,10 @@ import { FollowersListComponent, FollowsComponent, VideoRedundanciesListComponen
14import { FollowingListComponent } from './follows/following-list/following-list.component' 14import { FollowingListComponent } from './follows/following-list/following-list.component'
15import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component' 15import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component'
16import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component' 16import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component'
17import { ModerationCommentModalComponent, VideoAbuseListComponent, VideoBlockListComponent } from './moderation' 17import { ModerationCommentModalComponent, AbuseListComponent, VideoBlockListComponent } from './moderation'
18import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist' 18import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist'
19import { ModerationComponent } from './moderation/moderation.component' 19import { ModerationComponent } from './moderation/moderation.component'
20import { VideoAbuseDetailsComponent } from './moderation/video-abuse-list/video-abuse-details.component' 20import { AbuseDetailsComponent } from './moderation/abuse-list/abuse-details.component'
21import { PluginListInstalledComponent } from './plugins/plugin-list-installed/plugin-list-installed.component' 21import { PluginListInstalledComponent } from './plugins/plugin-list-installed/plugin-list-installed.component'
22import { PluginSearchComponent } from './plugins/plugin-search/plugin-search.component' 22import { PluginSearchComponent } from './plugins/plugin-search/plugin-search.component'
23import { PluginShowInstalledComponent } from './plugins/plugin-show-installed/plugin-show-installed.component' 23import { PluginShowInstalledComponent } from './plugins/plugin-show-installed/plugin-show-installed.component'
@@ -60,8 +60,10 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
60 60
61 ModerationComponent, 61 ModerationComponent,
62 VideoBlockListComponent, 62 VideoBlockListComponent,
63 VideoAbuseListComponent, 63
64 VideoAbuseDetailsComponent, 64 AbuseListComponent,
65 AbuseDetailsComponent,
66
65 ModerationCommentModalComponent, 67 ModerationCommentModalComponent,
66 InstanceServerBlocklistComponent, 68 InstanceServerBlocklistComponent,
67 InstanceAccountBlocklistComponent, 69 InstanceAccountBlocklistComponent,
diff --git a/client/src/app/+admin/moderation/abuse-list/abuse-details.component.html b/client/src/app/+admin/moderation/abuse-list/abuse-details.component.html
new file mode 100644
index 000000000..cba9cfb73
--- /dev/null
+++ b/client/src/app/+admin/moderation/abuse-list/abuse-details.component.html
@@ -0,0 +1,115 @@
1<div class="d-flex moderation-expanded">
2 <!-- report left part (report details) -->
3 <div class="col-8">
4
5 <!-- report metadata -->
6 <div class="d-flex" *ngIf="abuse.reporterAccount">
7 <span class="col-3 moderation-expanded-label" i18n>Reporter</span>
8
9 <span class="col-9 moderation-expanded-text">
10 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }"
11 class="chip"
12 >
13 <img
14 class="avatar"
15 [src]="abuse.reporterAccount.avatar?.path"
16 (error)="switchToDefaultAvatar($event)"
17 alt="Avatar"
18 >
19 <div>
20 <span class="text-muted">{{ abuse.reporterAccount.nameWithHost }}</span>
21 </div>
22 </a>
23
24 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }"
25 class="ml-auto text-muted abuse-details-links" i18n
26 >
27 {abuse.countReportsForReporter, plural, =1 {1 report} other {{{ abuse.countReportsForReporter }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
28 </a>
29 </span>
30 </div>
31
32 <div class="d-flex" *ngIf="abuse.flaggedAccount">
33 <span class="col-3 moderation-expanded-label" i18n>Reportee</span>
34 <span class="col-9 moderation-expanded-text">
35 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }"
36 class="chip"
37 >
38 <img
39 class="avatar"
40 [src]="abuse.flaggedAccount?.avatar?.path"
41 (error)="switchToDefaultAvatar($event)"
42 alt="Avatar"
43 >
44 <div>
45 <span class="text-muted">{{ abuse.flaggedAccount ? abuse.flaggedAccount.nameWithHost : '' }}</span>
46 </div>
47 </a>
48
49 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }"
50 class="ml-auto text-muted abuse-details-links" i18n
51 >
52 {abuse.countReportsForReportee, plural, =1 {1 report} other {{{ abuse.countReportsForReportee }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
53 </a>
54 </span>
55 </div>
56
57 <div class="d-flex" *ngIf="abuse.updatedAt">
58 <span class="col-3 moderation-expanded-label" i18n>Updated</span>
59 <time class="col-9 moderation-expanded-text abuse-details-date-updated">{{ abuse.updatedAt | date: 'medium' }}</time>
60 </div>
61
62 <!-- report text -->
63 <div class="mt-3 d-flex">
64 <span class="col-3 moderation-expanded-label">
65 <ng-container i18n>Report</ng-container>
66 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': '#' + abuse.id }" class="ml-1 text-muted">#{{ abuse.id }}</a>
67 </span>
68 <span class="col-9 moderation-expanded-text" [innerHTML]="abuse.reasonHtml"></span>
69 </div>
70
71 <div *ngIf="getPredefinedReasons()" class="mt-2 d-flex">
72 <span class="col-3"></span>
73 <span class="col-9">
74 <a *ngFor="let reason of getPredefinedReasons()" [routerLink]="[ '/admin/moderation/abuses/list' ]"
75 [queryParams]="{ 'search': 'tag:' + reason.id }" class="chip rectangular bg-secondary text-light"
76 >
77 <div>{{ reason.label }}</div>
78 </a>
79 </span>
80 </div>
81
82 <div *ngIf="abuse.video?.startAt" class="mt-2 d-flex">
83 <span class="col-3 moderation-expanded-label" i18n>Reported part</span>
84 <span class="col-9">
85 {{ startAt }}<ng-container *ngIf="abuse.video.endAt"> - {{ endAt }}</ng-container>
86 </span>
87 </div>
88
89 <div class="mt-3 d-flex" *ngIf="abuse.moderationComment">
90 <span class="col-3 moderation-expanded-label" i18n>Note</span>
91 <span class="col-9 moderation-expanded-text d-block" [innerHTML]="abuse.moderationCommentHtml"></span>
92 </div>
93
94 </div>
95
96 <!-- report right part (video/comment details) -->
97 <div class="col-4">
98 <div *ngIf="abuse.video" class="screenratio">
99 <div>
100 <span i18n *ngIf="abuse.video.deleted">The video was deleted</span>
101 <span i18n *ngIf="!abuse.video.deleted">The video was blocked</span>
102 </div>
103
104 <div *ngIf="!abuse.video.deleted && !abuse.video.blacklisted" [innerHTML]="abuse.embedHtml"></div>
105 </div>
106
107 <div *ngIf="abuse.comment" class="comment-html">
108 <div>
109 <strong i18n>Comment:</strong>
110 </div>
111
112 <div [innerHTML]="abuse.commentHtml"></div>
113 </div>
114 </div>
115</div>
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts b/client/src/app/+admin/moderation/abuse-list/abuse-details.component.ts
index 5db2887fa..fb0f65764 100644
--- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts
+++ b/client/src/app/+admin/moderation/abuse-list/abuse-details.component.ts
@@ -1,19 +1,19 @@
1import { Component, Input } from '@angular/core' 1import { Component, Input } from '@angular/core'
2import { Actor } from '@app/shared/shared-main' 2import { Actor } from '@app/shared/shared-main'
3import { I18n } from '@ngx-translate/i18n-polyfill' 3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { VideoAbusePredefinedReasonsString } from '../../../../../../shared/models/videos/abuse/video-abuse-reason.model' 4import { AbusePredefinedReasonsString } from '@shared/models'
5import { ProcessedVideoAbuse } from './video-abuse-list.component' 5import { ProcessedAbuse } from './abuse-list.component'
6import { durationToString } from '@app/helpers' 6import { durationToString } from '@app/helpers'
7 7
8@Component({ 8@Component({
9 selector: 'my-video-abuse-details', 9 selector: 'my-abuse-details',
10 templateUrl: './video-abuse-details.component.html', 10 templateUrl: './abuse-details.component.html',
11 styleUrls: [ '../moderation.component.scss' ] 11 styleUrls: [ '../moderation.component.scss' ]
12}) 12})
13export class VideoAbuseDetailsComponent { 13export class AbuseDetailsComponent {
14 @Input() videoAbuse: ProcessedVideoAbuse 14 @Input() abuse: ProcessedAbuse
15 15
16 private predefinedReasonsTranslations: { [key in VideoAbusePredefinedReasonsString]: string } 16 private predefinedReasonsTranslations: { [key in AbusePredefinedReasonsString]: string }
17 17
18 constructor ( 18 constructor (
19 private i18n: I18n 19 private i18n: I18n
@@ -31,16 +31,17 @@ export class VideoAbuseDetailsComponent {
31 } 31 }
32 32
33 get startAt () { 33 get startAt () {
34 return durationToString(this.videoAbuse.startAt) 34 return durationToString(this.abuse.video.startAt)
35 } 35 }
36 36
37 get endAt () { 37 get endAt () {
38 return durationToString(this.videoAbuse.endAt) 38 return durationToString(this.abuse.video.endAt)
39 } 39 }
40 40
41 getPredefinedReasons () { 41 getPredefinedReasons () {
42 if (!this.videoAbuse.predefinedReasons) return [] 42 if (!this.abuse.predefinedReasons) return []
43 return this.videoAbuse.predefinedReasons.map(r => ({ 43
44 return this.abuse.predefinedReasons.map(r => ({
44 id: r, 45 id: r,
45 label: this.predefinedReasonsTranslations[r] 46 label: this.predefinedReasonsTranslations[r]
46 })) 47 }))
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
new file mode 100644
index 000000000..99502304d
--- /dev/null
+++ b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html
@@ -0,0 +1,184 @@
1<p-table
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" [lazyLoadOnInit]="false"
4 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
5 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} reports"
6 (onPage)="onPage($event)" [expandedRowKeys]="expandedRows"
7>
8 <ng-template pTemplate="caption">
9 <div class="caption">
10 <div class="ml-auto">
11 <div class="input-group has-feedback has-clear">
12 <div class="input-group-prepend c-hand" ngbDropdown placement="bottom-left auto" container="body">
13 <div class="input-group-text" ngbDropdownToggle>
14 <span class="caret" aria-haspopup="menu" role="button"></span>
15 </div>
16
17 <div role="menu" ngbDropdownMenu>
18 <h6 class="dropdown-header" i18n>Advanced report filters</h6>
19 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'state:pending' }" class="dropdown-item" i18n>Unsolved reports</a>
20 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'state:accepted' }" class="dropdown-item" i18n>Accepted reports</a>
21 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'state:rejected' }" class="dropdown-item" i18n>Refused reports</a>
22 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'videoIs:blacklisted' }" class="dropdown-item" i18n>Reports with blocked videos</a>
23 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'videoIs:deleted' }" class="dropdown-item" i18n>Reports with deleted videos</a>
24 </div>
25 </div>
26 <input
27 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
28 (keyup)="onAbuseSearch($event)"
29 >
30 <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetTableFilter()"></a>
31 <span class="sr-only" i18n>Clear filters</span>
32 </div>
33 </div>
34 </div>
35 </ng-template>
36
37 <ng-template pTemplate="header">
38 <tr> <!-- header -->
39 <th style="width: 40px;"></th>
40 <th style="width: 20%;" pResizableColumn i18n>Reporter</th>
41 <th i18n>Video/Comment/Account</th>
42 <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
43 <th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
44 <th style="width: 150px;"></th>
45 </tr>
46 </ng-template>
47
48 <ng-template pTemplate="body" let-expanded="expanded" let-abuse>
49 <tr>
50 <td class="c-hand" [pRowToggler]="abuse" i18n-ngbTooltip ngbTooltip="More information" placement="top-left" container="body">
51 <span class="expander">
52 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
53 </span>
54 </td>
55
56 <td>
57 <a *ngIf="abuse.reporterAccount" [href]="abuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
58 <div class="chip two-lines">
59 <img
60 class="avatar"
61 [src]="abuse.reporterAccount.avatar?.path"
62 (error)="switchToDefaultAvatar($event)"
63 alt="Avatar"
64 >
65 <div>
66 {{ abuse.reporterAccount.displayName }}
67 <span>{{ abuse.reporterAccount.nameWithHost }}</span>
68 </div>
69 </div>
70 </a>
71
72 <span i18n *ngIf="!abuse.reporterAccount">
73 Deleted account
74 </span>
75 </td>
76
77 <ng-container *ngIf="abuse.video">
78
79 <td *ngIf="!abuse.video.deleted">
80 <a [href]="getVideoUrl(abuse)" class="table-video-link" [title]="abuse.video.name" target="_blank" rel="noopener noreferrer">
81 <div class="table-video">
82 <div class="table-video-image">
83 <img [src]="abuse.video.thumbnailPath">
84 <span
85 class="table-video-image-label" *ngIf="abuse.count > 1"
86 i18n-title title="This video has been reported multiple times."
87 >
88 {{ abuse.nth }}/{{ abuse.count }}
89 </span>
90 </div>
91
92 <div class="table-video-text">
93 <div>
94 <span *ngIf="!abuse.video.blacklisted" class="glyphicon glyphicon-new-window"></span>
95 <span *ngIf="abuse.video.blacklisted" i18n-title title="The video was blocked" class="glyphicon glyphicon-ban-circle"></span>
96 {{ abuse.video.name }}
97 </div>
98 <div i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
99 </div>
100 </div>
101 </a>
102 </td>
103
104 <td *ngIf="abuse.video.deleted" class="c-hand" [pRowToggler]="abuse">
105 <div class="table-video" i18n-title title="Video was deleted">
106 <div class="table-video-image">
107 <span i18n>Deleted</span>
108 </div>
109
110 <div class="table-video-text">
111 <div>
112 {{ abuse.video.name }}
113 <span class="glyphicon glyphicon-trash"></span>
114 </div>
115 <div i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
116 </div>
117 </div>
118 </td>
119 </ng-container>
120
121 <ng-container *ngIf="abuse.comment">
122 <td>
123 <a [href]="getCommentUrl(abuse)" [innerHTML]="abuse.truncatedCommentHtml" class="table-comment-link"
124 [title]="abuse.comment.video.name" target="_blank" rel="noopener noreferrer"
125 ></a>
126
127 <div class="comment-flagged-account" *ngIf="abuse.flaggedAccount">by {{ abuse.flaggedAccount.displayName }}</div>
128 </td>
129 </ng-container>
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
147 <td class="c-hand" [pRowToggler]="abuse">{{ abuse.createdAt | date: 'short' }}</td>
148
149 <td class="c-hand abuse-states" [pRowToggler]="abuse">
150 <span *ngIf="isAbuseAccepted(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-ok"></span>
151 <span *ngIf="isAbuseRejected(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-remove"></span>
152 <span *ngIf="abuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="abuse.moderationComment" class="glyphicon glyphicon-comment"></span>
153 </td>
154
155 <td class="action-cell">
156 <my-action-dropdown
157 [ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
158 i18n-label label="Actions" [actions]="abuseActions" [entry]="abuse"
159 ></my-action-dropdown>
160 </td>
161 </tr>
162 </ng-template>
163
164 <ng-template pTemplate="rowexpansion" let-abuse>
165 <tr>
166 <td class="expand-cell" colspan="6">
167 <my-abuse-details [abuse]="abuse"></my-abuse-details>
168 </td>
169 </tr>
170 </ng-template>
171
172 <ng-template pTemplate="emptymessage">
173 <tr>
174 <td colspan="6">
175 <div class="no-results">
176 <ng-container *ngIf="search" i18n>No abuses found matching current filters.</ng-container>
177 <ng-container *ngIf="!search" i18n>No abuses found.</ng-container>
178 </div>
179 </td>
180 </tr>
181 </ng-template>
182</p-table>
183
184<my-moderation-comment-modal #moderationCommentModal (commentUpdated)="onModerationCommentUpdated()"></my-moderation-comment-modal>
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.scss b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.scss
index 8eee15b64..c22f98c47 100644
--- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.scss
+++ b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.scss
@@ -10,7 +10,7 @@
10 @include disable-default-a-behaviour; 10 @include disable-default-a-behaviour;
11} 11}
12 12
13.video-abuse-states .glyphicon-comment { 13.abuse-states .glyphicon-comment {
14 margin-left: 0.5rem; 14 margin-left: 0.5rem;
15} 15}
16 16
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
new file mode 100644
index 000000000..74c5fe2b3
--- /dev/null
+++ b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts
@@ -0,0 +1,454 @@
1import * as debug from 'debug'
2import truncate from 'lodash-es/truncate'
3import { SortMeta } from 'primeng/api'
4import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
5import { environment } from 'src/environments/environment'
6import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'
7import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
8import { ActivatedRoute, Params, Router } from '@angular/router'
9import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
10import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main'
11import { AbuseService, BlocklistService, VideoBlockService } from '@app/shared/shared-moderation'
12import { VideoCommentService } from '@app/shared/shared-video-comment'
13import { I18n } from '@ngx-translate/i18n-polyfill'
14import { Abuse, AbuseState } from '@shared/models'
15import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
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
21export type ProcessedAbuse = Abuse & {
22 moderationCommentHtml?: string,
23 reasonHtml?: string
24 embedHtml?: SafeHtml
25 updatedAt?: Date
26
27 // override bare server-side definitions with rich client-side definitions
28 reporterAccount?: Account
29 flaggedAccount?: Account
30
31 truncatedCommentHtml?: string
32 commentHtml?: string
33
34 video: Abuse['video'] & {
35 channel: Abuse['video']['channel'] & {
36 ownerAccount: Account
37 }
38 }
39}
40
41@Component({
42 selector: 'my-abuse-list',
43 templateUrl: './abuse-list.component.html',
44 styleUrls: [ '../moderation.component.scss', './abuse-list.component.scss' ]
45})
46export class AbuseListComponent extends RestTable implements OnInit, AfterViewInit {
47 @ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent
48
49 abuses: ProcessedAbuse[] = []
50 totalRecords = 0
51 sort: SortMeta = { field: 'createdAt', order: 1 }
52 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
53
54 abuseActions: DropdownAction<ProcessedAbuse>[][] = []
55
56 constructor (
57 private notifier: Notifier,
58 private abuseService: AbuseService,
59 private blocklistService: BlocklistService,
60 private commentService: VideoCommentService,
61 private videoService: VideoService,
62 private videoBlocklistService: VideoBlockService,
63 private confirmService: ConfirmService,
64 private i18n: I18n,
65 private markdownRenderer: MarkdownService,
66 private sanitizer: DomSanitizer,
67 private route: ActivatedRoute,
68 private router: Router
69 ) {
70 super()
71
72 this.abuseActions = [
73 this.buildInternalActions(),
74
75 this.buildFlaggedAccountActions(),
76
77 this.buildCommentActions(),
78
79 this.buildVideoActions(),
80
81 this.buildAccountActions()
82 ]
83 }
84
85 ngOnInit () {
86 this.initialize()
87
88 this.route.queryParams
89 .subscribe(params => {
90 this.search = params.search || ''
91
92 logger('On URL change (search: %s).', this.search)
93
94 this.setTableFilter(this.search)
95 this.loadData()
96 })
97 }
98
99 ngAfterViewInit () {
100 if (this.search) this.setTableFilter(this.search)
101 }
102
103 getIdentifier () {
104 return 'AbuseListComponent'
105 }
106
107 openModerationCommentModal (abuse: Abuse) {
108 this.moderationCommentModal.openModal(abuse)
109 }
110
111 onModerationCommentUpdated () {
112 this.loadData()
113 }
114
115 /* Table filter functions */
116 onAbuseSearch (event: Event) {
117 this.onSearch(event)
118 this.setQueryParams((event.target as HTMLInputElement).value)
119 }
120
121 setQueryParams (search: string) {
122 const queryParams: Params = {}
123 if (search) Object.assign(queryParams, { search })
124
125 this.router.navigate([ '/admin/moderation/abuses/list' ], { queryParams })
126 }
127
128 resetTableFilter () {
129 this.setTableFilter('')
130 this.setQueryParams('')
131 this.resetSearch()
132 }
133 /* END Table filter functions */
134
135 isAbuseAccepted (abuse: Abuse) {
136 return abuse.state.id === AbuseState.ACCEPTED
137 }
138
139 isAbuseRejected (abuse: Abuse) {
140 return abuse.state.id === AbuseState.REJECTED
141 }
142
143 getVideoUrl (abuse: Abuse) {
144 return Video.buildClientUrl(abuse.video.uuid)
145 }
146
147 getCommentUrl (abuse: Abuse) {
148 return Video.buildClientUrl(abuse.comment.video.uuid) + ';threadId=' + abuse.comment.threadId
149 }
150
151 getAccountUrl (abuse: ProcessedAbuse) {
152 return '/accounts/' + abuse.flaggedAccount.nameWithHost
153 }
154
155 getVideoEmbed (abuse: Abuse) {
156 return buildVideoEmbed(
157 buildVideoLink({
158 baseUrl: `${environment.embedUrl}/videos/embed/${abuse.video.uuid}`,
159 title: false,
160 warningTitle: false,
161 startTime: abuse.startAt,
162 stopTime: abuse.endAt
163 })
164 )
165 }
166
167 switchToDefaultAvatar ($event: Event) {
168 ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
169 }
170
171 async removeAbuse (abuse: Abuse) {
172 const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this abuse report?'), this.i18n('Delete'))
173 if (res === false) return
174
175 this.abuseService.removeAbuse(abuse).subscribe(
176 () => {
177 this.notifier.success(this.i18n('Abuse deleted.'))
178 this.loadData()
179 },
180
181 err => this.notifier.error(err.message)
182 )
183 }
184
185 updateAbuseState (abuse: Abuse, state: AbuseState) {
186 this.abuseService.updateAbuse(abuse, { state })
187 .subscribe(
188 () => this.loadData(),
189
190 err => this.notifier.error(err.message)
191 )
192 }
193
194 protected loadData () {
195 logger('Load data.')
196
197 return this.abuseService.getAbuses({
198 pagination: this.pagination,
199 sort: this.sort,
200 search: this.search
201 }).subscribe(
202 async resultList => {
203 this.totalRecords = resultList.total
204
205 this.abuses = []
206
207 for (const a of resultList.data) {
208 const abuse = a as ProcessedAbuse
209
210 abuse.reasonHtml = await this.toHtml(abuse.reason)
211 abuse.moderationCommentHtml = await this.toHtml(abuse.moderationComment)
212
213 if (abuse.video) {
214 abuse.embedHtml = this.sanitizer.bypassSecurityTrustHtml(this.getVideoEmbed(abuse))
215
216 if (abuse.video.channel?.ownerAccount) {
217 abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount)
218 }
219 }
220
221 if (abuse.comment) {
222 if (abuse.comment.deleted) {
223 abuse.truncatedCommentHtml = abuse.commentHtml = this.i18n('Deleted comment')
224 } else {
225 const truncated = truncate(abuse.comment.text, { length: 100 })
226 abuse.truncatedCommentHtml = await this.markdownRenderer.textMarkdownToHTML(truncated, true)
227 abuse.commentHtml = await this.markdownRenderer.textMarkdownToHTML(abuse.comment.text, true)
228 }
229 }
230
231 if (abuse.reporterAccount) {
232 abuse.reporterAccount = new Account(abuse.reporterAccount)
233 }
234
235 if (abuse.flaggedAccount) {
236 abuse.flaggedAccount = new Account(abuse.flaggedAccount)
237 }
238
239 if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt
240
241 this.abuses.push(abuse)
242 }
243 },
244
245 err => this.notifier.error(err.message)
246 )
247 }
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
451 private toHtml (text: string) {
452 return this.markdownRenderer.textMarkdownToHTML(text)
453 }
454}
diff --git a/client/src/app/+admin/moderation/abuse-list/index.ts b/client/src/app/+admin/moderation/abuse-list/index.ts
new file mode 100644
index 000000000..c6037dab4
--- /dev/null
+++ b/client/src/app/+admin/moderation/abuse-list/index.ts
@@ -0,0 +1,3 @@
1export * from './abuse-details.component'
2export * from './abuse-list.component'
3export * from './moderation-comment-modal.component'
diff --git a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.html b/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.html
index 8082e93f4..8082e93f4 100644
--- a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.html
+++ b/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.html
diff --git a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.scss b/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.scss
index afcdb9a16..afcdb9a16 100644
--- a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.scss
+++ b/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.scss
diff --git a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts b/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.ts
index 3cd763ca4..23738f9cd 100644
--- a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts
+++ b/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.ts
@@ -1,11 +1,11 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { FormReactive, FormValidatorService, VideoAbuseValidatorsService } from '@app/shared/shared-forms' 3import { FormReactive, FormValidatorService, AbuseValidatorsService } from '@app/shared/shared-forms'
4import { VideoAbuseService } from '@app/shared/shared-moderation' 4import { AbuseService } from '@app/shared/shared-moderation'
5import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 5import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
6import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 6import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
7import { I18n } from '@ngx-translate/i18n-polyfill' 7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { VideoAbuse } from '@shared/models' 8import { Abuse } from '@shared/models'
9 9
10@Component({ 10@Component({
11 selector: 'my-moderation-comment-modal', 11 selector: 'my-moderation-comment-modal',
@@ -16,15 +16,15 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
16 @ViewChild('modal', { static: true }) modal: NgbModal 16 @ViewChild('modal', { static: true }) modal: NgbModal
17 @Output() commentUpdated = new EventEmitter<string>() 17 @Output() commentUpdated = new EventEmitter<string>()
18 18
19 private abuseToComment: VideoAbuse 19 private abuseToComment: Abuse
20 private openedModal: NgbModalRef 20 private openedModal: NgbModalRef
21 21
22 constructor ( 22 constructor (
23 protected formValidatorService: FormValidatorService, 23 protected formValidatorService: FormValidatorService,
24 private modalService: NgbModal, 24 private modalService: NgbModal,
25 private notifier: Notifier, 25 private notifier: Notifier,
26 private videoAbuseService: VideoAbuseService, 26 private abuseService: AbuseService,
27 private videoAbuseValidatorsService: VideoAbuseValidatorsService, 27 private abuseValidatorsService: AbuseValidatorsService,
28 private i18n: I18n 28 private i18n: I18n
29 ) { 29 ) {
30 super() 30 super()
@@ -32,11 +32,11 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
32 32
33 ngOnInit () { 33 ngOnInit () {
34 this.buildForm({ 34 this.buildForm({
35 moderationComment: this.videoAbuseValidatorsService.VIDEO_ABUSE_MODERATION_COMMENT 35 moderationComment: this.abuseValidatorsService.ABUSE_MODERATION_COMMENT
36 }) 36 })
37 } 37 }
38 38
39 openModal (abuseToComment: VideoAbuse) { 39 openModal (abuseToComment: Abuse) {
40 this.abuseToComment = abuseToComment 40 this.abuseToComment = abuseToComment
41 this.openedModal = this.modalService.open(this.modal, { centered: true }) 41 this.openedModal = this.modalService.open(this.modal, { centered: true })
42 42
@@ -54,7 +54,7 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
54 async banUser () { 54 async banUser () {
55 const moderationComment: string = this.form.value[ 'moderationComment' ] 55 const moderationComment: string = this.form.value[ 'moderationComment' ]
56 56
57 this.videoAbuseService.updateVideoAbuse(this.abuseToComment, { moderationComment }) 57 this.abuseService.updateAbuse(this.abuseToComment, { moderationComment })
58 .subscribe( 58 .subscribe(
59 () => { 59 () => {
60 this.notifier.success(this.i18n('Comment updated.')) 60 this.notifier.success(this.i18n('Comment updated.'))
diff --git a/client/src/app/+admin/moderation/index.ts b/client/src/app/+admin/moderation/index.ts
index 16249236c..53e4bc991 100644
--- a/client/src/app/+admin/moderation/index.ts
+++ b/client/src/app/+admin/moderation/index.ts
@@ -1,5 +1,5 @@
1export * from './abuse-list'
1export * from './instance-blocklist' 2export * from './instance-blocklist'
2export * from './video-abuse-list'
3export * from './video-block-list' 3export * from './video-block-list'
4export * from './moderation.component' 4export * from './moderation.component'
5export * from './moderation.routes' 5export * from './moderation.routes'
diff --git a/client/src/app/+admin/moderation/moderation.component.scss b/client/src/app/+admin/moderation/moderation.component.scss
index 0ec420af9..65fe94d39 100644
--- a/client/src/app/+admin/moderation/moderation.component.scss
+++ b/client/src/app/+admin/moderation/moderation.component.scss
@@ -25,18 +25,18 @@
25 vertical-align: top; 25 vertical-align: top;
26 text-align: right; 26 text-align: right;
27 } 27 }
28 28
29 .moderation-expanded-text { 29 .moderation-expanded-text {
30 display: inline-flex; 30 display: inline-flex;
31 word-wrap: break-word; 31 word-wrap: break-word;
32 32
33 ::ng-deep p:last-child { 33 ::ng-deep p:last-child {
34 margin-bottom: 0px !important; 34 margin-bottom: 0px !important;
35 } 35 }
36 } 36 }
37} 37}
38 38
39.video-table-states { 39.table-states {
40 & > :not(:first-child) { 40 & > :not(:first-child) {
41 margin-left: .4rem; 41 margin-left: .4rem;
42 } 42 }
@@ -59,6 +59,7 @@ p-calendar {
59.screenratio { 59.screenratio {
60 div { 60 div {
61 @include miniature-thumbnail; 61 @include miniature-thumbnail;
62
62 display: inline-flex; 63 display: inline-flex;
63 justify-content: center; 64 justify-content: center;
64 align-items: center; 65 align-items: center;
@@ -72,6 +73,11 @@ p-calendar {
72 }; 73 };
73} 74}
74 75
76.comment-html {
77 background-color: #ececec;
78 padding: 10px;
79}
80
75.chip { 81.chip {
76 @include chip; 82 @include chip;
77} 83}
@@ -83,16 +89,39 @@ my-action-dropdown.show {
83} 89}
84 90
85 91
86.video-table-video-link { 92.table-video-link {
87 @include disable-outline; 93 @include disable-outline;
94
88 position: relative; 95 position: relative;
89 top: 3px; 96 top: 3px;
90} 97}
91 98
92.video-table-video { 99.table-comment-link,
100.table-account-link {
101 @include disable-outline;
102
103 color: var(--mainForegroundColor);
104
105 ::ng-deep p:last-child {
106 margin: 0;
107 }
108}
109
110.table-account-link {
111 display: flex;
112 flex-direction: column;
113}
114
115.comment-flagged-account,
116.account-flagged-handle {
117 font-size: 11px;
118 color: var(--greyForegroundColor);
119}
120
121.table-video {
93 display: inline-flex; 122 display: inline-flex;
94 123
95 .video-table-video-image { 124 .table-video-image {
96 @include miniature-thumbnail; 125 @include miniature-thumbnail;
97 126
98 $image-height: 45px; 127 $image-height: 45px;
@@ -118,7 +147,7 @@ my-action-dropdown.show {
118 color: pvar(--inputPlaceholderColor); 147 color: pvar(--inputPlaceholderColor);
119 } 148 }
120 149
121 .video-table-video-image-label { 150 .table-video-image-label {
122 @include static-thumbnail-overlay; 151 @include static-thumbnail-overlay;
123 position: absolute; 152 position: absolute;
124 border-radius: 3px; 153 border-radius: 3px;
@@ -130,7 +159,7 @@ my-action-dropdown.show {
130 } 159 }
131 } 160 }
132 161
133 .video-table-video-text { 162 .table-video-text {
134 display: inline-flex; 163 display: inline-flex;
135 flex-direction: column; 164 flex-direction: column;
136 justify-content: center; 165 justify-content: center;
@@ -145,7 +174,8 @@ my-action-dropdown.show {
145 } 174 }
146 175
147 div + div { 176 div + div {
148 font-size: 80%; 177 color: var(--greyForegroundColor);
178 font-size: 11px;
149 } 179 }
150 } 180 }
151} 181}
diff --git a/client/src/app/+admin/moderation/moderation.routes.ts b/client/src/app/+admin/moderation/moderation.routes.ts
index cd837bcb9..8a31a54dc 100644
--- a/client/src/app/+admin/moderation/moderation.routes.ts
+++ b/client/src/app/+admin/moderation/moderation.routes.ts
@@ -1,7 +1,7 @@
1import { Routes } from '@angular/router' 1import { Routes } from '@angular/router'
2import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist' 2import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
3import { ModerationComponent } from '@app/+admin/moderation/moderation.component' 3import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
4import { VideoAbuseListComponent } from '@app/+admin/moderation/video-abuse-list' 4import { AbuseListComponent } from '@app/+admin/moderation/abuse-list'
5import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list' 5import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list'
6import { UserRightGuard } from '@app/core' 6import { UserRightGuard } from '@app/core'
7import { UserRight } from '@shared/models' 7import { UserRight } from '@shared/models'
@@ -13,22 +13,27 @@ export const ModerationRoutes: Routes = [
13 children: [ 13 children: [
14 { 14 {
15 path: '', 15 path: '',
16 redirectTo: 'video-abuses/list', 16 redirectTo: 'abuses/list',
17 pathMatch: 'full' 17 pathMatch: 'full'
18 }, 18 },
19 { 19 {
20 path: 'video-abuses', 20 path: 'video-abuses',
21 redirectTo: 'video-abuses/list', 21 redirectTo: 'abuses/list',
22 pathMatch: 'full' 22 pathMatch: 'full'
23 }, 23 },
24 { 24 {
25 path: 'video-abuses/list', 25 path: 'video-abuses/list',
26 component: VideoAbuseListComponent, 26 redirectTo: 'abuses/list',
27 pathMatch: 'full'
28 },
29 {
30 path: 'abuses/list',
31 component: AbuseListComponent,
27 canActivate: [ UserRightGuard ], 32 canActivate: [ UserRightGuard ],
28 data: { 33 data: {
29 userRight: UserRight.MANAGE_VIDEO_ABUSES, 34 userRight: UserRight.MANAGE_ABUSES,
30 meta: { 35 meta: {
31 title: 'Video reports' 36 title: 'Reports'
32 } 37 }
33 } 38 }
34 }, 39 },
diff --git a/client/src/app/+admin/moderation/video-abuse-list/index.ts b/client/src/app/+admin/moderation/video-abuse-list/index.ts
deleted file mode 100644
index da7176e52..000000000
--- a/client/src/app/+admin/moderation/video-abuse-list/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
1export * from './video-abuse-list.component'
2export * from './moderation-comment-modal.component'
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html
deleted file mode 100644
index ec808cdb8..000000000
--- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html
+++ /dev/null
@@ -1,93 +0,0 @@
1<div class="d-flex moderation-expanded">
2 <!-- report left part (report details) -->
3 <div class="col-8">
4
5 <!-- report metadata -->
6 <div class="d-flex">
7 <span class="col-3 moderation-expanded-label" i18n>Reporter</span>
8 <span class="col-9 moderation-expanded-text">
9 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + videoAbuse.reporterAccount.displayName + '&quot;' }" class="chip">
10 <img
11 class="avatar"
12 [src]="videoAbuse.reporterAccount.avatar?.path"
13 (error)="switchToDefaultAvatar($event)"
14 alt="Avatar"
15 >
16 <div>
17 <span class="text-muted">{{ videoAbuse.reporterAccount.nameWithHost }}</span>
18 </div>
19 </a>
20 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + videoAbuse.reporterAccount.displayName + '&quot;' }" class="ml-auto text-muted video-details-links" i18n>
21 {videoAbuse.countReportsForReporter, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReporter }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
22 </a>
23 </span>
24 </div>
25
26 <div class="d-flex">
27 <span class="col-3 moderation-expanded-label" i18n>Reportee</span>
28 <span class="col-9 moderation-expanded-text">
29 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +videoAbuse.video.channel.ownerAccount.displayName + '&quot;' }" class="chip">
30 <img
31 class="avatar"
32 [src]="videoAbuse.video.channel.ownerAccount?.avatar?.path"
33 (error)="switchToDefaultAvatar($event)"
34 alt="Avatar"
35 >
36 <div>
37 <span class="text-muted">{{ videoAbuse.video.channel.ownerAccount ? videoAbuse.video.channel.ownerAccount.nameWithHost : '' }}</span>
38 </div>
39 </a>
40 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +videoAbuse.video.channel.ownerAccount.displayName + '&quot;' }" class="ml-auto text-muted video-details-links" i18n>
41 {videoAbuse.countReportsForReportee, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReportee }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
42 </a>
43 </span>
44 </div>
45
46 <div class="d-flex" *ngIf="videoAbuse.updatedAt">
47 <span class="col-3 moderation-expanded-label" i18n>Updated</span>
48 <time class="col-9 moderation-expanded-text video-details-date-updated">{{ videoAbuse.updatedAt | date: 'medium' }}</time>
49 </div>
50
51 <!-- report text -->
52 <div class="mt-3 d-flex">
53 <span class="col-3 moderation-expanded-label">
54 <ng-container i18n>Report</ng-container>
55 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': '#' + videoAbuse.id }" class="ml-1 text-muted">#{{ videoAbuse.id }}</a>
56 </span>
57 <span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.reasonHtml"></span>
58 </div>
59
60 <div *ngIf="getPredefinedReasons()" class="mt-2 d-flex">
61 <span class="col-3"></span>
62 <span class="col-9">
63 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'tag:' + reason.id }" class="chip rectangular bg-secondary text-light" *ngFor="let reason of getPredefinedReasons()">
64 <div>{{ reason.label }}</div>
65 </a>
66 </span>
67 </div>
68
69 <div *ngIf="videoAbuse.startAt" class="mt-2 d-flex">
70 <span class="col-3 moderation-expanded-label" i18n>Reported part</span>
71 <span class="col-9">
72 {{ startAt }}<ng-container *ngIf="videoAbuse.endAt"> - {{ endAt }}</ng-container>
73 </span>
74 </div>
75
76 <div class="mt-3 d-flex" *ngIf="videoAbuse.moderationComment">
77 <span class="col-3 moderation-expanded-label" i18n>Note</span>
78 <span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.moderationCommentHtml"></span>
79 </div>
80
81 </div>
82
83 <!-- report right part (video details) -->
84 <div class="col-4">
85 <div class="screenratio">
86 <div *ngIf="videoAbuse.video.deleted || videoAbuse.video.blacklisted">
87 <span i18n *ngIf="videoAbuse.video.deleted">The video was deleted</span>
88 <span i18n *ngIf="!videoAbuse.video.deleted">The video was blocked</span>
89 </div>
90 <div *ngIf="!videoAbuse.video.deleted && !videoAbuse.video.blacklisted" [innerHTML]="videoAbuse.embedHtml"></div>
91 </div>
92 </div>
93</div>
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html
deleted file mode 100644
index 64641b28a..000000000
--- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html
+++ /dev/null
@@ -1,149 +0,0 @@
1<p-table
2 [value]="videoAbuses" [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"
4 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
5 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} reports"
6 (onPage)="onPage($event)" [expandedRowKeys]="expandedRows"
7>
8 <ng-template pTemplate="caption">
9 <div class="caption">
10 <div class="ml-auto">
11 <div class="input-group has-feedback has-clear">
12 <div class="input-group-prepend c-hand" ngbDropdown placement="bottom-left auto" container="body">
13 <div class="input-group-text" ngbDropdownToggle>
14 <span class="caret" aria-haspopup="menu" role="button"></span>
15 </div>
16
17 <div role="menu" ngbDropdownMenu>
18 <h6 class="dropdown-header" i18n>Advanced report filters</h6>
19 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:pending' }" class="dropdown-item" i18n>Unsolved reports</a>
20 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:accepted' }" class="dropdown-item" i18n>Accepted reports</a>
21 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:rejected' }" class="dropdown-item" i18n>Refused reports</a>
22 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'videoIs:blacklisted' }" class="dropdown-item" i18n>Reports with blocked videos</a>
23 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'videoIs:deleted' }" class="dropdown-item" i18n>Reports with deleted videos</a>
24 </div>
25 </div>
26 <input
27 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
28 (keyup)="onAbuseSearch($event)"
29 >
30 <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetTableFilter()"></a>
31 <span class="sr-only" i18n>Clear filters</span>
32 </div>
33 </div>
34 </div>
35 </ng-template>
36
37 <ng-template pTemplate="header">
38 <tr> <!-- header -->
39 <th style="width: 40px;"></th>
40 <th style="width: 20%;" pResizableColumn i18n>Reporter</th>
41 <th i18n>Video</th>
42 <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
43 <th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
44 <th style="width: 150px;"></th>
45 </tr>
46 </ng-template>
47
48 <ng-template pTemplate="body" let-expanded="expanded" let-videoAbuse>
49 <tr>
50 <td class="c-hand" [pRowToggler]="videoAbuse" i18n-ngbTooltip ngbTooltip="More information" placement="top-left" container="body">
51 <span class="expander">
52 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
53 </span>
54 </td>
55
56 <td>
57 <a [href]="videoAbuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
58 <div class="chip two-lines">
59 <img
60 class="avatar"
61 [src]="videoAbuse.reporterAccount.avatar?.path"
62 (error)="switchToDefaultAvatar($event)"
63 alt="Avatar"
64 >
65 <div>
66 {{ videoAbuse.reporterAccount.displayName }}
67 <span class="text-muted">{{ videoAbuse.reporterAccount.nameWithHost }}</span>
68 </div>
69 </div>
70 </a>
71 </td>
72
73 <td *ngIf="!videoAbuse.video.deleted">
74 <a [href]="getVideoUrl(videoAbuse)" class="video-table-video-link" [title]="videoAbuse.video.name" target="_blank" rel="noopener noreferrer">
75 <div class="video-table-video">
76 <div class="video-table-video-image">
77 <img [src]="videoAbuse.video.thumbnailPath">
78 <span
79 class="video-table-video-image-label" *ngIf="videoAbuse.count > 1"
80 i18n-title title="This video has been reported multiple times."
81 >
82 {{ videoAbuse.nth }}/{{ videoAbuse.count }}
83 </span>
84 </div>
85 <div class="video-table-video-text">
86 <div>
87 <span *ngIf="!videoAbuse.video.blacklisted" class="glyphicon glyphicon-new-window"></span>
88 <span *ngIf="videoAbuse.video.blacklisted" i18n-title title="The video was blocked" class="glyphicon glyphicon-ban-circle"></span>
89 {{ videoAbuse.video.name }}
90 </div>
91 <div class="text-muted" i18n>by {{ videoAbuse.video.channel?.displayName }} on {{ videoAbuse.video.channel?.host }} </div>
92 </div>
93 </div>
94 </a>
95 </td>
96
97 <td *ngIf="videoAbuse.video.deleted" class="c-hand" [pRowToggler]="videoAbuse">
98 <div class="video-table-video" i18n-title title="Video was deleted">
99 <div class="video-table-video-image">
100 <span i18n>Deleted</span>
101 </div>
102 <div class="video-table-video-text">
103 <div>
104 {{ videoAbuse.video.name }}
105 <span class="glyphicon glyphicon-trash"></span>
106 </div>
107 <div class="text-muted" i18n>by {{ videoAbuse.video.channel?.displayName }} on {{ videoAbuse.video.channel?.host }} </div>
108 </div>
109 </div>
110 </td>
111
112 <td class="c-hand" [pRowToggler]="videoAbuse">{{ videoAbuse.createdAt | date: 'short' }}</td>
113
114 <td class="c-hand video-abuse-states" [pRowToggler]="videoAbuse">
115 <span *ngIf="isVideoAbuseAccepted(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-ok"></span>
116 <span *ngIf="isVideoAbuseRejected(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-remove"></span>
117 <span *ngIf="videoAbuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="videoAbuse.moderationComment" class="glyphicon glyphicon-comment"></span>
118 </td>
119
120 <td class="action-cell">
121 <my-action-dropdown
122 [ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
123 i18n-label label="Actions" [actions]="videoAbuseActions" [entry]="videoAbuse"
124 ></my-action-dropdown>
125 </td>
126 </tr>
127 </ng-template>
128
129 <ng-template pTemplate="rowexpansion" let-videoAbuse>
130 <tr>
131 <td class="expand-cell" colspan="6">
132 <my-video-abuse-details [videoAbuse]="videoAbuse"></my-video-abuse-details>
133 </td>
134 </tr>
135 </ng-template>
136
137 <ng-template pTemplate="emptymessage">
138 <tr>
139 <td colspan="6">
140 <div class="no-results">
141 <ng-container *ngIf="search" i18n>No video abuses found matching current filters.</ng-container>
142 <ng-container *ngIf="!search" i18n>No video abuses found.</ng-container>
143 </div>
144 </td>
145 </tr>
146 </ng-template>
147</p-table>
148
149<my-moderation-comment-modal #moderationCommentModal (commentUpdated)="onModerationCommentUpdated()"></my-moderation-comment-modal>
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts
deleted file mode 100644
index 409dd42c7..000000000
--- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts
+++ /dev/null
@@ -1,328 +0,0 @@
1import { SortMeta } from 'primeng/api'
2import { filter } from 'rxjs/operators'
3import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
4import { environment } from 'src/environments/environment'
5import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'
6import { DomSanitizer } from '@angular/platform-browser'
7import { ActivatedRoute, Params, Router } from '@angular/router'
8import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
9import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main'
10import { BlocklistService, VideoAbuseService, VideoBlockService } from '@app/shared/shared-moderation'
11import { I18n } from '@ngx-translate/i18n-polyfill'
12import { VideoAbuse, VideoAbuseState } from '@shared/models'
13import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
14
15export type ProcessedVideoAbuse = VideoAbuse & {
16 moderationCommentHtml?: string,
17 reasonHtml?: string
18 embedHtml?: string
19 updatedAt?: Date
20 // override bare server-side definitions with rich client-side definitions
21 reporterAccount: Account
22 video: VideoAbuse['video'] & {
23 channel: VideoAbuse['video']['channel'] & {
24 ownerAccount: Account
25 }
26 }
27}
28
29@Component({
30 selector: 'my-video-abuse-list',
31 templateUrl: './video-abuse-list.component.html',
32 styleUrls: [ '../moderation.component.scss', './video-abuse-list.component.scss' ]
33})
34export class VideoAbuseListComponent extends RestTable implements OnInit, AfterViewInit {
35 @ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent
36
37 videoAbuses: ProcessedVideoAbuse[] = []
38 totalRecords = 0
39 sort: SortMeta = { field: 'createdAt', order: 1 }
40 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
41
42 videoAbuseActions: DropdownAction<VideoAbuse>[][] = []
43
44 constructor (
45 private notifier: Notifier,
46 private videoAbuseService: VideoAbuseService,
47 private blocklistService: BlocklistService,
48 private videoService: VideoService,
49 private videoBlocklistService: VideoBlockService,
50 private confirmService: ConfirmService,
51 private i18n: I18n,
52 private markdownRenderer: MarkdownService,
53 private sanitizer: DomSanitizer,
54 private route: ActivatedRoute,
55 private router: Router
56 ) {
57 super()
58
59 this.videoAbuseActions = [
60 [
61 {
62 label: this.i18n('Internal actions'),
63 isHeader: true
64 },
65 {
66 label: this.i18n('Delete report'),
67 handler: videoAbuse => this.removeVideoAbuse(videoAbuse)
68 },
69 {
70 label: this.i18n('Add note'),
71 handler: videoAbuse => this.openModerationCommentModal(videoAbuse),
72 isDisplayed: videoAbuse => !videoAbuse.moderationComment
73 },
74 {
75 label: this.i18n('Update note'),
76 handler: videoAbuse => this.openModerationCommentModal(videoAbuse),
77 isDisplayed: videoAbuse => !!videoAbuse.moderationComment
78 },
79 {
80 label: this.i18n('Mark as accepted'),
81 handler: videoAbuse => this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED),
82 isDisplayed: videoAbuse => !this.isVideoAbuseAccepted(videoAbuse)
83 },
84 {
85 label: this.i18n('Mark as rejected'),
86 handler: videoAbuse => this.updateVideoAbuseState(videoAbuse, VideoAbuseState.REJECTED),
87 isDisplayed: videoAbuse => !this.isVideoAbuseRejected(videoAbuse)
88 }
89 ],
90 [
91 {
92 label: this.i18n('Actions for the video'),
93 isHeader: true,
94 isDisplayed: videoAbuse => !videoAbuse.video.deleted
95 },
96 {
97 label: this.i18n('Block video'),
98 isDisplayed: videoAbuse => !videoAbuse.video.deleted && !videoAbuse.video.blacklisted,
99 handler: videoAbuse => {
100 this.videoBlocklistService.blockVideo(videoAbuse.video.id, undefined, true)
101 .subscribe(
102 () => {
103 this.notifier.success(this.i18n('Video blocked.'))
104
105 this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED)
106 },
107
108 err => this.notifier.error(err.message)
109 )
110 }
111 },
112 {
113 label: this.i18n('Unblock video'),
114 isDisplayed: videoAbuse => !videoAbuse.video.deleted && videoAbuse.video.blacklisted,
115 handler: videoAbuse => {
116 this.videoBlocklistService.unblockVideo(videoAbuse.video.id)
117 .subscribe(
118 () => {
119 this.notifier.success(this.i18n('Video unblocked.'))
120
121 this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED)
122 },
123
124 err => this.notifier.error(err.message)
125 )
126 }
127 },
128 {
129 label: this.i18n('Delete video'),
130 isDisplayed: videoAbuse => !videoAbuse.video.deleted,
131 handler: async videoAbuse => {
132 const res = await this.confirmService.confirm(
133 this.i18n('Do you really want to delete this video?'),
134 this.i18n('Delete')
135 )
136 if (res === false) return
137
138 this.videoService.removeVideo(videoAbuse.video.id)
139 .subscribe(
140 () => {
141 this.notifier.success(this.i18n('Video deleted.'))
142
143 this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED)
144 },
145
146 err => this.notifier.error(err.message)
147 )
148 }
149 }
150 ],
151 [
152 {
153 label: this.i18n('Actions for the reporter'),
154 isHeader: true
155 },
156 {
157 label: this.i18n('Mute reporter'),
158 handler: async videoAbuse => {
159 const account = videoAbuse.reporterAccount as Account
160
161 this.blocklistService.blockAccountByInstance(account)
162 .subscribe(
163 () => {
164 this.notifier.success(
165 this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost })
166 )
167
168 account.mutedByInstance = true
169 },
170
171 err => this.notifier.error(err.message)
172 )
173 }
174 },
175 {
176 label: this.i18n('Mute server'),
177 isDisplayed: videoAbuse => !videoAbuse.reporterAccount.userId,
178 handler: async videoAbuse => {
179 this.blocklistService.blockServerByInstance(videoAbuse.reporterAccount.host)
180 .subscribe(
181 () => {
182 this.notifier.success(
183 this.i18n('Server {{host}} muted by the instance.', { host: videoAbuse.reporterAccount.host })
184 )
185 },
186
187 err => this.notifier.error(err.message)
188 )
189 }
190 }
191 ]
192 ]
193 }
194
195 ngOnInit () {
196 this.initialize()
197
198 this.route.queryParams
199 .subscribe(params => {
200 this.search = params.search || ''
201
202 this.setTableFilter(this.search)
203 this.loadData()
204 })
205 }
206
207 ngAfterViewInit () {
208 if (this.search) this.setTableFilter(this.search)
209 }
210
211 getIdentifier () {
212 return 'VideoAbuseListComponent'
213 }
214
215 openModerationCommentModal (videoAbuse: VideoAbuse) {
216 this.moderationCommentModal.openModal(videoAbuse)
217 }
218
219 onModerationCommentUpdated () {
220 this.loadData()
221 }
222
223 /* Table filter functions */
224 onAbuseSearch (event: Event) {
225 this.onSearch(event)
226 this.setQueryParams((event.target as HTMLInputElement).value)
227 }
228
229 setQueryParams (search: string) {
230 const queryParams: Params = {}
231 if (search) Object.assign(queryParams, { search })
232
233 this.router.navigate([ '/admin/moderation/video-abuses/list' ], { queryParams })
234 }
235
236 resetTableFilter () {
237 this.setTableFilter('')
238 this.setQueryParams('')
239 this.resetSearch()
240 }
241 /* END Table filter functions */
242
243 isVideoAbuseAccepted (videoAbuse: VideoAbuse) {
244 return videoAbuse.state.id === VideoAbuseState.ACCEPTED
245 }
246
247 isVideoAbuseRejected (videoAbuse: VideoAbuse) {
248 return videoAbuse.state.id === VideoAbuseState.REJECTED
249 }
250
251 getVideoUrl (videoAbuse: VideoAbuse) {
252 return Video.buildClientUrl(videoAbuse.video.uuid)
253 }
254
255 getVideoEmbed (videoAbuse: VideoAbuse) {
256 return buildVideoEmbed(
257 buildVideoLink({
258 baseUrl: `${environment.embedUrl}/videos/embed/${videoAbuse.video.uuid}`,
259 title: false,
260 warningTitle: false,
261 startTime: videoAbuse.startAt,
262 stopTime: videoAbuse.endAt
263 })
264 )
265 }
266
267 switchToDefaultAvatar ($event: Event) {
268 ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
269 }
270
271 async removeVideoAbuse (videoAbuse: VideoAbuse) {
272 const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this abuse report?'), this.i18n('Delete'))
273 if (res === false) return
274
275 this.videoAbuseService.removeVideoAbuse(videoAbuse).subscribe(
276 () => {
277 this.notifier.success(this.i18n('Abuse deleted.'))
278 this.loadData()
279 },
280
281 err => this.notifier.error(err.message)
282 )
283 }
284
285 updateVideoAbuseState (videoAbuse: VideoAbuse, state: VideoAbuseState) {
286 this.videoAbuseService.updateVideoAbuse(videoAbuse, { state })
287 .subscribe(
288 () => this.loadData(),
289
290 err => this.notifier.error(err.message)
291 )
292 }
293
294 protected loadData () {
295 return this.videoAbuseService.getVideoAbuses({
296 pagination: this.pagination,
297 sort: this.sort,
298 search: this.search
299 }).subscribe(
300 async resultList => {
301 this.totalRecords = resultList.total
302 const videoAbuses = []
303
304 for (const abuse of resultList.data) {
305 Object.assign(abuse, {
306 reasonHtml: await this.toHtml(abuse.reason),
307 moderationCommentHtml: await this.toHtml(abuse.moderationComment),
308 embedHtml: this.sanitizer.bypassSecurityTrustHtml(this.getVideoEmbed(abuse)),
309 reporterAccount: new Account(abuse.reporterAccount)
310 })
311
312 if (abuse.video.channel?.ownerAccount) abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount)
313 if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt
314
315 videoAbuses.push(abuse as ProcessedVideoAbuse)
316 }
317
318 this.videoAbuses = videoAbuses
319 },
320
321 err => this.notifier.error(err.message)
322 )
323 }
324
325 private toHtml (text: string) {
326 return this.markdownRenderer.textMarkdownToHTML(text)
327 }
328}
diff --git a/client/src/app/+admin/users/user-edit/user-edit.component.html b/client/src/app/+admin/users/user-edit/user-edit.component.html
index 037040902..d103f8e2f 100644
--- a/client/src/app/+admin/users/user-edit/user-edit.component.html
+++ b/client/src/app/+admin/users/user-edit/user-edit.component.html
@@ -37,14 +37,14 @@
37 </a> 37 </a>
38 </div> 38 </div>
39 <div> 39 <div>
40 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' + user?.account.displayName + '&quot;' }"> 40 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' + user?.account.displayName + '&quot;' }">
41 <div class="dashboard-num">{{ user.videoAbusesCount }}</div> 41 <div class="dashboard-num">{{ user.abusesCount }}</div>
42 <div class="dashboard-label" i18n>Incriminated in reports</div> 42 <div class="dashboard-label" i18n>Incriminated in reports</div>
43 </a> 43 </a>
44 </div> 44 </div>
45 <div> 45 <div>
46 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + user?.account.displayName + '&quot; state:accepted' }"> 46 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + user?.account.displayName + '&quot; state:accepted' }">
47 <div class="dashboard-num">{{ user.videoAbusesAcceptedCount }} / {{ user.videoAbusesCreatedCount }}</div> 47 <div class="dashboard-num">{{ user.abusesAcceptedCount }} / {{ user.abusesCreatedCount }}</div>
48 <div class="dashboard-label" i18n>Authored reports accepted</div> 48 <div class="dashboard-label" i18n>Authored reports accepted</div>
49 </a> 49 </a>
50 </div> 50 </div>
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
index cfa514b26..8562e564b 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
@@ -33,7 +33,7 @@ export class MyAccountNotificationPreferencesComponent implements OnInit {
33 this.labelNotifications = { 33 this.labelNotifications = {
34 newVideoFromSubscription: this.i18n('New video from your subscriptions'), 34 newVideoFromSubscription: this.i18n('New video from your subscriptions'),
35 newCommentOnMyVideo: this.i18n('New comment on your video'), 35 newCommentOnMyVideo: this.i18n('New comment on your video'),
36 videoAbuseAsModerator: this.i18n('New video abuse'), 36 abuseAsModerator: this.i18n('New abuse'),
37 videoAutoBlacklistAsModerator: this.i18n('Video blocked automatically waiting review'), 37 videoAutoBlacklistAsModerator: this.i18n('Video blocked automatically waiting review'),
38 blacklistOnMyVideo: this.i18n('One of your video is blocked/unblocked'), 38 blacklistOnMyVideo: this.i18n('One of your video is blocked/unblocked'),
39 myVideoPublished: this.i18n('Video published (after transcoding/scheduled update)'), 39 myVideoPublished: this.i18n('Video published (after transcoding/scheduled update)'),
@@ -47,7 +47,7 @@ export class MyAccountNotificationPreferencesComponent implements OnInit {
47 this.notificationSettingKeys = Object.keys(this.labelNotifications) as (keyof UserNotificationSetting)[] 47 this.notificationSettingKeys = Object.keys(this.labelNotifications) as (keyof UserNotificationSetting)[]
48 48
49 this.rightNotifications = { 49 this.rightNotifications = {
50 videoAbuseAsModerator: UserRight.MANAGE_VIDEO_ABUSES, 50 abuseAsModerator: UserRight.MANAGE_ABUSES,
51 videoAutoBlacklistAsModerator: UserRight.MANAGE_VIDEO_BLACKLIST, 51 videoAutoBlacklistAsModerator: UserRight.MANAGE_VIDEO_BLACKLIST,
52 newUserRegistration: UserRight.MANAGE_USERS, 52 newUserRegistration: UserRight.MANAGE_USERS,
53 newInstanceFollower: UserRight.MANAGE_SERVER_FOLLOW, 53 newInstanceFollower: UserRight.MANAGE_SERVER_FOLLOW,
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.html b/client/src/app/+videos/+video-watch/comment/video-comment.component.html
index 002de57e4..f02ea549a 100644
--- a/client/src/app/+videos/+video-watch/comment/video-comment.component.html
+++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.html
@@ -45,6 +45,7 @@
45 <div *ngIf="isRemovableByUser()" (click)="onWantToDelete()" class="comment-action-delete" i18n>Delete</div> 45 <div *ngIf="isRemovableByUser()" (click)="onWantToDelete()" class="comment-action-delete" i18n>Delete</div>
46 46
47 <my-user-moderation-dropdown 47 <my-user-moderation-dropdown
48 [prependActions]="prependModerationActions"
48 buttonSize="small" [account]="commentAccount" [user]="commentUser" i18n-label label="Options" placement="bottom-left auto" 49 buttonSize="small" [account]="commentAccount" [user]="commentUser" i18n-label label="Options" placement="bottom-left auto"
49 ></my-user-moderation-dropdown> 50 ></my-user-moderation-dropdown>
50 </div> 51 </div>
@@ -93,3 +94,7 @@
93 </div> 94 </div>
94 </div> 95 </div>
95</div> 96</div>
97
98<ng-container *ngIf="prependModerationActions">
99 <my-comment-report #commentReportModal [comment]="comment"></my-comment-report>
100</ng-container>
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 27846c1ad..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
@@ -1,10 +1,12 @@
1import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core' 1
2import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core'
2import { MarkdownService, Notifier, UserService } from '@app/core' 3import { MarkdownService, Notifier, UserService } from '@app/core'
3import { AuthService } from '@app/core/auth' 4import { AuthService } from '@app/core/auth'
4import { Account, Actor, Video } from '@app/shared/shared-main' 5import { Account, Actor, DropdownAction, Video } from '@app/shared/shared-main'
6import { CommentReportComponent } from '@app/shared/shared-moderation/report-modals/comment-report.component'
7import { VideoComment, VideoCommentThreadTree } from '@app/shared/shared-video-comment'
8import { I18n } from '@ngx-translate/i18n-polyfill'
5import { User, UserRight } from '@shared/models' 9import { User, UserRight } from '@shared/models'
6import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
7import { VideoComment } from './video-comment.model'
8 10
9@Component({ 11@Component({
10 selector: 'my-video-comment', 12 selector: 'my-video-comment',
@@ -12,6 +14,8 @@ import { VideoComment } from './video-comment.model'
12 styleUrls: ['./video-comment.component.scss'] 14 styleUrls: ['./video-comment.component.scss']
13}) 15})
14export class VideoCommentComponent implements OnInit, OnChanges { 16export class VideoCommentComponent implements OnInit, OnChanges {
17 @ViewChild('commentReportModal') commentReportModal: CommentReportComponent
18
15 @Input() video: Video 19 @Input() video: Video
16 @Input() comment: VideoComment 20 @Input() comment: VideoComment
17 @Input() parentComments: VideoComment[] = [] 21 @Input() parentComments: VideoComment[] = []
@@ -26,6 +30,8 @@ export class VideoCommentComponent implements OnInit, OnChanges {
26 @Output() resetReply = new EventEmitter() 30 @Output() resetReply = new EventEmitter()
27 @Output() timestampClicked = new EventEmitter<number>() 31 @Output() timestampClicked = new EventEmitter<number>()
28 32
33 prependModerationActions: DropdownAction<any>[]
34
29 sanitizedCommentHTML = '' 35 sanitizedCommentHTML = ''
30 newParentComments: VideoComment[] = [] 36 newParentComments: VideoComment[] = []
31 37
@@ -33,6 +39,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
33 commentUser: User 39 commentUser: User
34 40
35 constructor ( 41 constructor (
42 private i18n: I18n,
36 private markdownService: MarkdownService, 43 private markdownService: MarkdownService,
37 private authService: AuthService, 44 private authService: AuthService,
38 private userService: UserService, 45 private userService: UserService,
@@ -127,5 +134,20 @@ export class VideoCommentComponent implements OnInit, OnChanges {
127 } else { 134 } else {
128 this.comment.account = null 135 this.comment.account = null
129 } 136 }
137
138 if (this.isUserLoggedIn() && this.authService.getUser().account.id !== this.comment.account.id) {
139 this.prependModerationActions = [
140 {
141 label: this.i18n('Report comment'),
142 handler: () => this.showReportModal()
143 }
144 ]
145 } else {
146 this.prependModerationActions = undefined
147 }
148 }
149
150 private showReportModal () {
151 this.commentReportModal.show()
130 } 152 }
131} 153}
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/core/users/user.model.ts b/client/src/app/core/users/user.model.ts
index 8ecdf9fcd..31b9c2152 100644
--- a/client/src/app/core/users/user.model.ts
+++ b/client/src/app/core/users/user.model.ts
@@ -51,12 +51,14 @@ export class User implements UserServerModel {
51 videoQuotaDaily: number 51 videoQuotaDaily: number
52 videoQuotaUsed?: number 52 videoQuotaUsed?: number
53 videoQuotaUsedDaily?: number 53 videoQuotaUsedDaily?: number
54
54 videosCount?: number 55 videosCount?: number
55 videoAbusesCount?: number
56 videoAbusesAcceptedCount?: number
57 videoAbusesCreatedCount?: number
58 videoCommentsCount?: number 56 videoCommentsCount?: number
59 57
58 abusesCount?: number
59 abusesAcceptedCount?: number
60 abusesCreatedCount?: number
61
60 theme: string 62 theme: string
61 63
62 account: Account 64 account: Account
@@ -89,9 +91,9 @@ export class User implements UserServerModel {
89 this.videoQuotaUsed = hash.videoQuotaUsed 91 this.videoQuotaUsed = hash.videoQuotaUsed
90 this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily 92 this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily
91 this.videosCount = hash.videosCount 93 this.videosCount = hash.videosCount
92 this.videoAbusesCount = hash.videoAbusesCount 94 this.abusesCount = hash.abusesCount
93 this.videoAbusesAcceptedCount = hash.videoAbusesAcceptedCount 95 this.abusesAcceptedCount = hash.abusesAcceptedCount
94 this.videoAbusesCreatedCount = hash.videoAbusesCreatedCount 96 this.abusesCreatedCount = hash.abusesCreatedCount
95 this.videoCommentsCount = hash.videoCommentsCount 97 this.videoCommentsCount = hash.videoCommentsCount
96 98
97 this.nsfwPolicy = hash.nsfwPolicy 99 this.nsfwPolicy = hash.nsfwPolicy
diff --git a/client/src/app/menu/menu.component.ts b/client/src/app/menu/menu.component.ts
index 2dbe695c9..0ea251f1c 100644
--- a/client/src/app/menu/menu.component.ts
+++ b/client/src/app/menu/menu.component.ts
@@ -28,7 +28,7 @@ export class MenuComponent implements OnInit {
28 private routesPerRight: { [ role in UserRight ]?: string } = { 28 private routesPerRight: { [ role in UserRight ]?: string } = {
29 [UserRight.MANAGE_USERS]: '/admin/users', 29 [UserRight.MANAGE_USERS]: '/admin/users',
30 [UserRight.MANAGE_SERVER_FOLLOW]: '/admin/friends', 30 [UserRight.MANAGE_SERVER_FOLLOW]: '/admin/friends',
31 [UserRight.MANAGE_VIDEO_ABUSES]: '/admin/moderation/video-abuses', 31 [UserRight.MANAGE_ABUSES]: '/admin/moderation/abuses',
32 [UserRight.MANAGE_VIDEO_BLACKLIST]: '/admin/moderation/video-blocks', 32 [UserRight.MANAGE_VIDEO_BLACKLIST]: '/admin/moderation/video-blocks',
33 [UserRight.MANAGE_JOBS]: '/admin/jobs', 33 [UserRight.MANAGE_JOBS]: '/admin/jobs',
34 [UserRight.MANAGE_CONFIGURATION]: '/admin/config' 34 [UserRight.MANAGE_CONFIGURATION]: '/admin/config'
@@ -126,7 +126,7 @@ export class MenuComponent implements OnInit {
126 const adminRights = [ 126 const adminRights = [
127 UserRight.MANAGE_USERS, 127 UserRight.MANAGE_USERS,
128 UserRight.MANAGE_SERVER_FOLLOW, 128 UserRight.MANAGE_SERVER_FOLLOW,
129 UserRight.MANAGE_VIDEO_ABUSES, 129 UserRight.MANAGE_ABUSES,
130 UserRight.MANAGE_VIDEO_BLACKLIST, 130 UserRight.MANAGE_VIDEO_BLACKLIST,
131 UserRight.MANAGE_JOBS, 131 UserRight.MANAGE_JOBS,
132 UserRight.MANAGE_CONFIGURATION 132 UserRight.MANAGE_CONFIGURATION
diff --git a/client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/abuse-validators.service.ts
index aae56d607..739115e19 100644
--- a/client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts
+++ b/client/src/app/shared/shared-forms/form-validators/abuse-validators.service.ts
@@ -4,12 +4,12 @@ import { Injectable } from '@angular/core'
4import { BuildFormValidator } from './form-validator.service' 4import { BuildFormValidator } from './form-validator.service'
5 5
6@Injectable() 6@Injectable()
7export class VideoAbuseValidatorsService { 7export class AbuseValidatorsService {
8 readonly VIDEO_ABUSE_REASON: BuildFormValidator 8 readonly ABUSE_REASON: BuildFormValidator
9 readonly VIDEO_ABUSE_MODERATION_COMMENT: BuildFormValidator 9 readonly ABUSE_MODERATION_COMMENT: BuildFormValidator
10 10
11 constructor (private i18n: I18n) { 11 constructor (private i18n: I18n) {
12 this.VIDEO_ABUSE_REASON = { 12 this.ABUSE_REASON = {
13 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ], 13 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
14 MESSAGES: { 14 MESSAGES: {
15 'required': this.i18n('Report reason is required.'), 15 'required': this.i18n('Report reason is required.'),
@@ -18,7 +18,7 @@ export class VideoAbuseValidatorsService {
18 } 18 }
19 } 19 }
20 20
21 this.VIDEO_ABUSE_MODERATION_COMMENT = { 21 this.ABUSE_MODERATION_COMMENT = {
22 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ], 22 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
23 MESSAGES: { 23 MESSAGES: {
24 'required': this.i18n('Moderation comment is required.'), 24 'required': this.i18n('Moderation comment is required.'),
diff --git a/client/src/app/shared/shared-forms/form-validators/index.ts b/client/src/app/shared/shared-forms/form-validators/index.ts
index 8b71841a9..b06a326ff 100644
--- a/client/src/app/shared/shared-forms/form-validators/index.ts
+++ b/client/src/app/shared/shared-forms/form-validators/index.ts
@@ -1,3 +1,4 @@
1export * from './abuse-validators.service'
1export * from './batch-domains-validators.service' 2export * from './batch-domains-validators.service'
2export * from './custom-config-validators.service' 3export * from './custom-config-validators.service'
3export * from './form-validator.service' 4export * from './form-validator.service'
@@ -6,7 +7,6 @@ export * from './instance-validators.service'
6export * from './login-validators.service' 7export * from './login-validators.service'
7export * from './reset-password-validators.service' 8export * from './reset-password-validators.service'
8export * from './user-validators.service' 9export * from './user-validators.service'
9export * from './video-abuse-validators.service'
10export * from './video-accept-ownership-validators.service' 10export * from './video-accept-ownership-validators.service'
11export * from './video-block-validators.service' 11export * from './video-block-validators.service'
12export * from './video-captions-validators.service' 12export * from './video-captions-validators.service'
diff --git a/client/src/app/shared/shared-forms/shared-form.module.ts b/client/src/app/shared/shared-forms/shared-form.module.ts
index e82fa97d4..ba33704cf 100644
--- a/client/src/app/shared/shared-forms/shared-form.module.ts
+++ b/client/src/app/shared/shared-forms/shared-form.module.ts
@@ -11,7 +11,7 @@ import {
11 LoginValidatorsService, 11 LoginValidatorsService,
12 ResetPasswordValidatorsService, 12 ResetPasswordValidatorsService,
13 UserValidatorsService, 13 UserValidatorsService,
14 VideoAbuseValidatorsService, 14 AbuseValidatorsService,
15 VideoAcceptOwnershipValidatorsService, 15 VideoAcceptOwnershipValidatorsService,
16 VideoBlockValidatorsService, 16 VideoBlockValidatorsService,
17 VideoCaptionsValidatorsService, 17 VideoCaptionsValidatorsService,
@@ -69,7 +69,7 @@ import { TimestampInputComponent } from './timestamp-input.component'
69 LoginValidatorsService, 69 LoginValidatorsService,
70 ResetPasswordValidatorsService, 70 ResetPasswordValidatorsService,
71 UserValidatorsService, 71 UserValidatorsService,
72 VideoAbuseValidatorsService, 72 AbuseValidatorsService,
73 VideoAcceptOwnershipValidatorsService, 73 VideoAcceptOwnershipValidatorsService,
74 VideoBlockValidatorsService, 74 VideoBlockValidatorsService,
75 VideoCaptionsValidatorsService, 75 VideoCaptionsValidatorsService,
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 5fc7989dd..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,7 +14,9 @@ export abstract class Actor implements ActorServer {
14 14
15 avatarUrl: string 15 avatarUrl: string
16 16
17 static GET_ACTOR_AVATAR_URL (actor: { avatar?: Avatar }) { 17 isLocal: boolean
18
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
20 if (actor && actor.avatar) { 22 if (actor && actor.avatar) {
@@ -46,10 +48,16 @@ export abstract class Actor implements ActorServer {
46 this.host = hash.host 48 this.host = hash.host
47 this.followingCount = hash.followingCount 49 this.followingCount = hash.followingCount
48 this.followersCount = hash.followersCount 50 this.followersCount = hash.followersCount
49 this.createdAt = new Date(hash.createdAt.toString()) 51
50 this.updatedAt = new Date(hash.updatedAt.toString()) 52 if (hash.createdAt) this.createdAt = new Date(hash.createdAt.toString())
53 if (hash.updatedAt) this.updatedAt = new Date(hash.updatedAt.toString())
54
51 this.avatar = hash.avatar 55 this.avatar = hash.avatar
52 56
57 const absoluteAPIUrl = getAbsoluteAPIUrl()
58 const thisHost = new URL(absoluteAPIUrl).host
59 this.isLocal = this.host.trim() === thisHost
60
53 this.updateComputedAttributes() 61 this.updateComputedAttributes()
54 } 62 }
55 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 de25d3ab9..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
@@ -25,9 +25,22 @@ export class UserNotification implements UserNotificationServer {
25 video: VideoInfo 25 video: VideoInfo
26 } 26 }
27 27
28 videoAbuse?: { 28 abuse?: {
29 id: number 29 id: number
30 video: VideoInfo 30
31 video?: VideoInfo
32
33 comment?: {
34 threadId: number
35
36 video: {
37 id: number
38 uuid: string
39 name: string
40 }
41 }
42
43 account?: ActorInfo
31 } 44 }
32 45
33 videoBlacklist?: { 46 videoBlacklist?: {
@@ -55,7 +68,7 @@ export class UserNotification implements UserNotificationServer {
55 // Additional fields 68 // Additional fields
56 videoUrl?: string 69 videoUrl?: string
57 commentUrl?: any[] 70 commentUrl?: any[]
58 videoAbuseUrl?: string 71 abuseUrl?: string
59 videoAutoBlacklistUrl?: string 72 videoAutoBlacklistUrl?: string
60 accountUrl?: string 73 accountUrl?: string
61 videoImportIdentifier?: string 74 videoImportIdentifier?: string
@@ -78,7 +91,7 @@ export class UserNotification implements UserNotificationServer {
78 this.comment = hash.comment 91 this.comment = hash.comment
79 if (this.comment) this.setAvatarUrl(this.comment.account) 92 if (this.comment) this.setAvatarUrl(this.comment.account)
80 93
81 this.videoAbuse = hash.videoAbuse 94 this.abuse = hash.abuse
82 95
83 this.videoBlacklist = hash.videoBlacklist 96 this.videoBlacklist = hash.videoBlacklist
84 97
@@ -104,12 +117,15 @@ export class UserNotification implements UserNotificationServer {
104 case UserNotificationType.COMMENT_MENTION: 117 case UserNotificationType.COMMENT_MENTION:
105 if (!this.comment) break 118 if (!this.comment) break
106 this.accountUrl = this.buildAccountUrl(this.comment.account) 119 this.accountUrl = this.buildAccountUrl(this.comment.account)
107 this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ] 120 this.commentUrl = this.buildCommentUrl(this.comment)
108 break 121 break
109 122
110 case UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS: 123 case UserNotificationType.NEW_ABUSE_FOR_MODERATORS:
111 this.videoAbuseUrl = '/admin/moderation/video-abuses/list' 124 this.abuseUrl = '/admin/moderation/abuses/list'
112 this.videoUrl = this.buildVideoUrl(this.videoAbuse.video) 125
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)
113 break 129 break
114 130
115 case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS: 131 case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS:
@@ -178,7 +194,11 @@ export class UserNotification implements UserNotificationServer {
178 return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName 194 return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName
179 } 195 }
180 196
181 private setAvatarUrl (actor: { avatarUrl?: string, avatar?: Avatar }) { 197 private buildCommentUrl (comment: { video: { uuid: string }, threadId: number }) {
198 return [ this.buildVideoUrl(comment.video), { threadId: comment.threadId } ]
199 }
200
201 private setAvatarUrl (actor: { avatarUrl?: string, avatar?: { url?: string, path: string } }) {
182 actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor) 202 actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor)
183 } 203 }
184} 204}
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 d5be1470e..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
@@ -19,7 +19,7 @@
19 19
20 <ng-template #noVideo> 20 <ng-template #noVideo>
21 <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon> 21 <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
22 22
23 <div class="message" i18n> 23 <div class="message" i18n>
24 The notification concerns a video now unavailable 24 The notification concerns a video now unavailable
25 </div> 25 </div>
@@ -42,11 +42,24 @@
42 </div> 42 </div>
43 </ng-container> 43 </ng-container>
44 44
45 <ng-container *ngSwitchCase="UserNotificationType.NEW_VIDEO_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.videoAbuseUrl">A new video abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoAbuse.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>
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
50 </div> 63 </div>
51 </ng-container> 64 </ng-container>
52 65
@@ -65,7 +78,7 @@
65 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl"> 78 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
66 <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" /> 79 <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
67 </a> 80 </a>
68 81
69 <div class="message" i18n> 82 <div class="message" i18n>
70 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> commented your video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.comment.video.name }}</a> 83 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> commented your video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.comment.video.name }}</a>
71 </div> 84 </div>
@@ -73,7 +86,7 @@
73 86
74 <ng-template #noComment> 87 <ng-template #noComment>
75 <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon> 88 <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
76 89
77 <div class="message" i18n> 90 <div class="message" i18n>
78 The notification concerns a comment now unavailable 91 The notification concerns a comment now unavailable
79 </div> 92 </div>
diff --git a/client/src/app/shared/shared-moderation/abuse.service.ts b/client/src/app/shared/shared-moderation/abuse.service.ts
new file mode 100644
index 000000000..95ac16955
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/abuse.service.ts
@@ -0,0 +1,154 @@
1import { omit } from 'lodash-es'
2import { SortMeta } from 'primeng/api'
3import { Observable } from 'rxjs'
4import { catchError, map } from 'rxjs/operators'
5import { HttpClient, HttpParams } from '@angular/common/http'
6import { Injectable } from '@angular/core'
7import { RestExtractor, RestPagination, RestService } from '@app/core'
8import { Abuse, AbuseCreate, AbuseFilter, AbusePredefinedReasonsString, AbuseState, AbuseUpdate, ResultList } from '@shared/models'
9import { environment } from '../../../environments/environment'
10import { I18n } from '@ngx-translate/i18n-polyfill'
11
12@Injectable()
13export class AbuseService {
14 private static BASE_ABUSE_URL = environment.apiUrl + '/api/v1/abuses'
15
16 constructor (
17 private i18n: I18n,
18 private authHttp: HttpClient,
19 private restService: RestService,
20 private restExtractor: RestExtractor
21 ) { }
22
23 getAbuses (options: {
24 pagination: RestPagination,
25 sort: SortMeta,
26 search?: string
27 }): Observable<ResultList<Abuse>> {
28 const { pagination, sort, search } = options
29 const url = AbuseService.BASE_ABUSE_URL
30
31 let params = new HttpParams()
32 params = this.restService.addRestGetParams(params, pagination, sort)
33
34 if (search) {
35 const filters = this.restService.parseQueryStringFilter(search, {
36 id: { prefix: '#' },
37 state: {
38 prefix: 'state:',
39 handler: v => {
40 if (v === 'accepted') return AbuseState.ACCEPTED
41 if (v === 'pending') return AbuseState.PENDING
42 if (v === 'rejected') return AbuseState.REJECTED
43
44 return undefined
45 }
46 },
47 videoIs: {
48 prefix: 'videoIs:',
49 handler: v => {
50 if (v === 'deleted') return v
51 if (v === 'blacklisted') return v
52
53 return undefined
54 }
55 },
56 searchReporter: { prefix: 'reporter:' },
57 searchReportee: { prefix: 'reportee:' },
58 predefinedReason: { prefix: 'tag:' }
59 })
60
61 params = this.restService.addObjectParams(params, filters)
62 }
63
64 return this.authHttp.get<ResultList<Abuse>>(url, { params })
65 .pipe(
66 catchError(res => this.restExtractor.handleError(res))
67 )
68 }
69
70 reportVideo (parameters: AbuseCreate) {
71 const url = AbuseService.BASE_ABUSE_URL
72
73 const body = omit(parameters, ['id'])
74
75 return this.authHttp.post(url, body)
76 .pipe(
77 map(this.restExtractor.extractDataBool),
78 catchError(res => this.restExtractor.handleError(res))
79 )
80 }
81
82 updateAbuse (abuse: Abuse, abuseUpdate: AbuseUpdate) {
83 const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
84
85 return this.authHttp.put(url, abuseUpdate)
86 .pipe(
87 map(this.restExtractor.extractDataBool),
88 catchError(res => this.restExtractor.handleError(res))
89 )
90 }
91
92 removeAbuse (abuse: Abuse) {
93 const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
94
95 return this.authHttp.delete(url)
96 .pipe(
97 map(this.restExtractor.extractDataBool),
98 catchError(res => this.restExtractor.handleError(res))
99 )
100 }
101
102 getPrefefinedReasons (type: AbuseFilter) {
103 let reasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [
104 {
105 id: 'violentOrRepulsive',
106 label: this.i18n('Violent or repulsive'),
107 help: this.i18n('Contains offensive, violent, or coarse language or iconography.')
108 },
109 {
110 id: 'hatefulOrAbusive',
111 label: this.i18n('Hateful or abusive'),
112 help: this.i18n('Contains abusive, racist or sexist language or iconography.')
113 },
114 {
115 id: 'spamOrMisleading',
116 label: this.i18n('Spam, ad or false news'),
117 help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.')
118 },
119 {
120 id: 'privacy',
121 label: this.i18n('Privacy breach or doxxing'),
122 help: this.i18n('Contains personal information that could be used to track, identify, contact or impersonate someone (e.g. name, address, phone number, email, or credit card details).')
123 },
124 {
125 id: 'rights',
126 label: this.i18n('Intellectual property violation'),
127 help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.')
128 },
129 {
130 id: 'serverRules',
131 label: this.i18n('Breaks server rules'),
132 description: this.i18n('Anything not included in the above that breaks the terms of service, code of conduct, or general rules in place on the server.')
133 }
134 ]
135
136 if (type === 'video') {
137 reasons = reasons.concat([
138 {
139 id: 'thumbnails',
140 label: this.i18n('Thumbnails'),
141 help: this.i18n('The above can only be seen in thumbnails.')
142 },
143 {
144 id: 'captions',
145 label: this.i18n('Captions'),
146 help: this.i18n('The above can only be seen in captions (please describe which).')
147 }
148 ])
149 }
150
151 return reasons
152 }
153
154}
diff --git a/client/src/app/shared/shared-moderation/index.ts b/client/src/app/shared/shared-moderation/index.ts
index 8e74254f6..41c910ffe 100644
--- a/client/src/app/shared/shared-moderation/index.ts
+++ b/client/src/app/shared/shared-moderation/index.ts
@@ -1,3 +1,6 @@
1export * from './report-modals'
2
3export * from './abuse.service'
1export * from './account-block.model' 4export * from './account-block.model'
2export * from './account-blocklist.component' 5export * from './account-blocklist.component'
3export * from './batch-domains-modal.component' 6export * from './batch-domains-modal.component'
@@ -6,8 +9,6 @@ export * from './bulk.service'
6export * from './server-blocklist.component' 9export * from './server-blocklist.component'
7export * from './user-ban-modal.component' 10export * from './user-ban-modal.component'
8export * from './user-moderation-dropdown.component' 11export * from './user-moderation-dropdown.component'
9export * from './video-abuse.service'
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/report-modals/comment-report.component.ts b/client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts
new file mode 100644
index 000000000..00d7b8d34
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/report-modals/comment-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 { VideoComment } from '@app/shared/shared-video-comment'
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-comment-report',
14 templateUrl: './report.component.html',
15 styleUrls: [ './report.component.scss' ]
16})
17export class CommentReportComponent extends FormReactive implements OnInit {
18 @Input() comment: VideoComment = null
19
20 @ViewChild('modal', { static: true }) modal: NgbModal
21
22 modalTitle: string
23 error: string = null
24 predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: 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.comment.account.host
46 }
47
48 return ''
49 }
50
51 ngOnInit () {
52 this.modalTitle = this.i18n('Report comment')
53
54 this.buildForm({
55 reason: this.abuseValidatorsService.ABUSE_REASON,
56 predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null)
57 })
58
59 this.predefinedReasons = this.abuseService.getPrefefinedReasons('comment')
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 comment: {
79 id: this.comment.id
80 }
81 }).subscribe(
82 () => {
83 this.notifier.success(this.i18n('Comment reported.'))
84 this.hide()
85 },
86
87 err => this.notifier.error(err.message)
88 )
89 }
90
91 isRemote () {
92 return !this.comment.isLocal
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/report-modals/report.component.html b/client/src/app/shared/shared-moderation/report-modals/report.component.html
new file mode 100644
index 000000000..bda62312f
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/report-modals/report.component.html
@@ -0,0 +1,62 @@
1<ng-template #modal>
2 <div class="modal-header">
3 <h4 class="modal-title">{{ modalTitle }}</h4>
4 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
5 </div>
6
7 <div class="modal-body">
8 <form novalidate [formGroup]="form" (ngSubmit)="report()">
9
10 <div class="row">
11 <div class="col-5 form-group">
12
13 <label i18n for="reportPredefinedReasons">What is the issue?</label>
14
15 <div class="ml-2 mt-2 d-flex flex-column">
16 <ng-container formGroupName="predefinedReasons">
17
18 <div class="form-group" *ngFor="let reason of predefinedReasons">
19 <my-peertube-checkbox [inputName]="reason.id" [formControlName]="reason.id" [labelText]="reason.label">
20 <ng-template *ngIf="reason.help" ptTemplate="help">
21 <div [innerHTML]="reason.help"></div>
22 </ng-template>
23
24 <ng-container *ngIf="reason.description" ngProjectAs="description">
25 <div [innerHTML]="reason.description"></div>
26 </ng-container>
27 </my-peertube-checkbox>
28 </div>
29
30 </ng-container>
31 </div>
32
33 </div>
34
35 <div class="col-7">
36 <div i18n class="information">
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>
39
40 <div class="form-group">
41 <textarea
42 i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus
43 [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
44 ></textarea>
45 <div *ngIf="formErrors.reason" class="form-error">
46 {{ formErrors.reason }}
47 </div>
48 </div>
49 </div>
50 </div>
51
52 <div class="form-group inputs">
53 <input
54 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
55 (click)="hide()" (key.enter)="hide()"
56 >
57 <input type="submit" i18n-value value="Submit" class="action-button-submit" [disabled]="!form.valid">
58 </div>
59
60 </form>
61 </div>
62</ng-template>
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 d6beb6d2a..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
@@ -14,16 +14,19 @@
14 14
15 <div class="ml-2 mt-2 d-flex flex-column"> 15 <div class="ml-2 mt-2 d-flex flex-column">
16 <ng-container formGroupName="predefinedReasons"> 16 <ng-container formGroupName="predefinedReasons">
17
17 <div class="form-group" *ngFor="let reason of predefinedReasons"> 18 <div class="form-group" *ngFor="let reason of predefinedReasons">
18 <my-peertube-checkbox formControlName="{{reason.id}}" labelText="{{reason.label}}"> 19 <my-peertube-checkbox [inputName]="reason.id" [formControlName]="reason.id" [labelText]="reason.label">
19 <ng-template *ngIf="reason.help" ptTemplate="help"> 20 <ng-template *ngIf="reason.help" ptTemplate="help">
20 <div [innerHTML]="reason.help"></div> 21 <div [innerHTML]="reason.help"></div>
21 </ng-template> 22 </ng-template>
23
22 <ng-container *ngIf="reason.description" ngProjectAs="description"> 24 <ng-container *ngIf="reason.description" ngProjectAs="description">
23 <div [innerHTML]="reason.description"></div> 25 <div [innerHTML]="reason.description"></div>
24 </ng-container> 26 </ng-container>
25 </my-peertube-checkbox> 27 </my-peertube-checkbox>
26 </div> 28 </div>
29
27 </ng-container> 30 </ng-container>
28 </div> 31 </div>
29 32
@@ -69,11 +72,11 @@
69 </div> 72 </div>
70 73
71 <div i18n class="information"> 74 <div i18n class="information">
72 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>.
73 </div> 76 </div>
74 77
75 <div class="form-group"> 78 <div class="form-group">
76 <textarea 79 <textarea
77 i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus 80 i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus
78 [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control" 81 [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
79 ></textarea> 82 ></textarea>
diff --git a/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts b/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts
new file mode 100644
index 000000000..7d53ea3c9
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts
@@ -0,0 +1,122 @@
1import { mapValues, pickBy } from 'lodash-es'
2import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
3import { Component, Input, OnInit, ViewChild } from '@angular/core'
4import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
5import { Notifier } from '@app/core'
6import { AbuseValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
9import { I18n } from '@ngx-translate/i18n-polyfill'
10import { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models'
11import { Video } from '../../shared-main'
12import { AbuseService } from '../abuse.service'
13
14@Component({
15 selector: 'my-video-report',
16 templateUrl: './video-report.component.html',
17 styleUrls: [ './report.component.scss' ]
18})
19export class VideoReportComponent extends FormReactive implements OnInit {
20 @Input() video: Video = null
21
22 @ViewChild('modal', { static: true }) modal: NgbModal
23
24 error: string = null
25 predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
26 embedHtml: SafeHtml
27
28 private openedModal: NgbModalRef
29
30 constructor (
31 protected formValidatorService: FormValidatorService,
32 private modalService: NgbModal,
33 private abuseValidatorsService: AbuseValidatorsService,
34 private abuseService: AbuseService,
35 private notifier: Notifier,
36 private sanitizer: DomSanitizer,
37 private i18n: I18n
38 ) {
39 super()
40 }
41
42 get currentHost () {
43 return window.location.host
44 }
45
46 get originHost () {
47 if (this.isRemote()) {
48 return this.video.account.host
49 }
50
51 return ''
52 }
53
54 get timestamp () {
55 return this.form.get('timestamp').value
56 }
57
58 getVideoEmbed () {
59 return this.sanitizer.bypassSecurityTrustHtml(
60 buildVideoEmbed(
61 buildVideoLink({
62 baseUrl: this.video.embedUrl,
63 title: false,
64 warningTitle: false
65 })
66 )
67 )
68 }
69
70 ngOnInit () {
71 this.buildForm({
72 reason: this.abuseValidatorsService.ABUSE_REASON,
73 predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null),
74 timestamp: {
75 hasStart: null,
76 startAt: null,
77 hasEnd: null,
78 endAt: null
79 }
80 })
81
82 this.predefinedReasons = this.abuseService.getPrefefinedReasons('video')
83
84 this.embedHtml = this.getVideoEmbed()
85 }
86
87 show () {
88 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
89 }
90
91 hide () {
92 this.openedModal.close()
93 this.openedModal = null
94 }
95
96 report () {
97 const reason = this.form.get('reason').value
98 const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as AbusePredefinedReasonsString[]
99 const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value
100
101 this.abuseService.reportVideo({
102 reason,
103 predefinedReasons,
104 video: {
105 id: this.video.id,
106 startAt: hasStart && startAt ? startAt : undefined,
107 endAt: hasEnd && endAt ? endAt : undefined
108 }
109 }).subscribe(
110 () => {
111 this.notifier.success(this.i18n('Video reported.'))
112 this.hide()
113 },
114
115 err => this.notifier.error(err.message)
116 )
117 }
118
119 isRemote () {
120 return !this.video.isLocal
121 }
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 f7e64dfa3..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,21 +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 { VideoAbuseService } from './video-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'
15 16
16@NgModule({ 17@NgModule({
17 imports: [ 18 imports: [
18 SharedMainModule, 19 SharedMainModule,
19 SharedFormModule, 20 SharedFormModule,
20 SharedGlobalIconModule 21 SharedGlobalIconModule,
22 SharedVideoCommentModule
21 ], 23 ],
22 24
23 declarations: [ 25 declarations: [
@@ -25,7 +27,9 @@ import { VideoReportComponent } from './video-report.component'
25 UserModerationDropdownComponent, 27 UserModerationDropdownComponent,
26 VideoBlockComponent, 28 VideoBlockComponent,
27 VideoReportComponent, 29 VideoReportComponent,
28 BatchDomainsModalComponent 30 BatchDomainsModalComponent,
31 CommentReportComponent,
32 AccountReportComponent
29 ], 33 ],
30 34
31 exports: [ 35 exports: [
@@ -33,13 +37,15 @@ import { VideoReportComponent } from './video-report.component'
33 UserModerationDropdownComponent, 37 UserModerationDropdownComponent,
34 VideoBlockComponent, 38 VideoBlockComponent,
35 VideoReportComponent, 39 VideoReportComponent,
36 BatchDomainsModalComponent 40 BatchDomainsModalComponent,
41 CommentReportComponent,
42 AccountReportComponent
37 ], 43 ],
38 44
39 providers: [ 45 providers: [
40 BlocklistService, 46 BlocklistService,
41 BulkService, 47 BulkService,
42 VideoAbuseService, 48 AbuseService,
43 VideoBlockService 49 VideoBlockService
44 ] 50 ]
45}) 51})
diff --git a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
index d3c37f082..78c2658df 100644
--- a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
+++ b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
@@ -16,6 +16,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
16 16
17 @Input() user: User 17 @Input() user: User
18 @Input() account: Account 18 @Input() account: Account
19 @Input() prependActions: DropdownAction<{ user: User, account: Account }>[]
19 20
20 @Input() buttonSize: 'normal' | 'small' = 'normal' 21 @Input() buttonSize: 'normal' | 'small' = 'normal'
21 @Input() placement = 'left-top left-bottom auto' 22 @Input() placement = 'left-top left-bottom auto'
@@ -250,6 +251,12 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
250 private buildActions () { 251 private buildActions () {
251 this.userActions = [] 252 this.userActions = []
252 253
254 if (this.prependActions) {
255 this.userActions = [
256 this.prependActions
257 ]
258 }
259
253 if (this.authService.isLoggedIn()) { 260 if (this.authService.isLoggedIn()) {
254 const authUser = this.authService.getUser() 261 const authUser = this.authService.getUser()
255 262
diff --git a/client/src/app/shared/shared-moderation/video-abuse.service.ts b/client/src/app/shared/shared-moderation/video-abuse.service.ts
deleted file mode 100644
index 44dea44a5..000000000
--- a/client/src/app/shared/shared-moderation/video-abuse.service.ts
+++ /dev/null
@@ -1,98 +0,0 @@
1import { omit } from 'lodash-es'
2import { SortMeta } from 'primeng/api'
3import { Observable } from 'rxjs'
4import { catchError, map } from 'rxjs/operators'
5import { HttpClient, HttpParams } from '@angular/common/http'
6import { Injectable } from '@angular/core'
7import { RestExtractor, RestPagination, RestService } from '@app/core'
8import { ResultList, VideoAbuse, VideoAbuseCreate, VideoAbuseState, VideoAbuseUpdate } from '@shared/models'
9import { environment } from '../../../environments/environment'
10
11@Injectable()
12export class VideoAbuseService {
13 private static BASE_VIDEO_ABUSE_URL = environment.apiUrl + '/api/v1/videos/'
14
15 constructor (
16 private authHttp: HttpClient,
17 private restService: RestService,
18 private restExtractor: RestExtractor
19 ) {}
20
21 getVideoAbuses (options: {
22 pagination: RestPagination,
23 sort: SortMeta,
24 search?: string
25 }): Observable<ResultList<VideoAbuse>> {
26 const { pagination, sort, search } = options
27 const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + 'abuse'
28
29 let params = new HttpParams()
30 params = this.restService.addRestGetParams(params, pagination, sort)
31
32 if (search) {
33 const filters = this.restService.parseQueryStringFilter(search, {
34 id: { prefix: '#' },
35 state: {
36 prefix: 'state:',
37 handler: v => {
38 if (v === 'accepted') return VideoAbuseState.ACCEPTED
39 if (v === 'pending') return VideoAbuseState.PENDING
40 if (v === 'rejected') return VideoAbuseState.REJECTED
41
42 return undefined
43 }
44 },
45 videoIs: {
46 prefix: 'videoIs:',
47 handler: v => {
48 if (v === 'deleted') return v
49 if (v === 'blacklisted') return v
50
51 return undefined
52 }
53 },
54 searchReporter: { prefix: 'reporter:' },
55 searchReportee: { prefix: 'reportee:' },
56 predefinedReason: { prefix: 'tag:' }
57 })
58
59 params = this.restService.addObjectParams(params, filters)
60 }
61
62 return this.authHttp.get<ResultList<VideoAbuse>>(url, { params })
63 .pipe(
64 catchError(res => this.restExtractor.handleError(res))
65 )
66 }
67
68 reportVideo (parameters: { id: number } & VideoAbuseCreate) {
69 const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + parameters.id + '/abuse'
70
71 const body = omit(parameters, [ 'id' ])
72
73 return this.authHttp.post(url, body)
74 .pipe(
75 map(this.restExtractor.extractDataBool),
76 catchError(res => this.restExtractor.handleError(res))
77 )
78 }
79
80 updateVideoAbuse (videoAbuse: VideoAbuse, abuseUpdate: VideoAbuseUpdate) {
81 const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id
82
83 return this.authHttp.put(url, abuseUpdate)
84 .pipe(
85 map(this.restExtractor.extractDataBool),
86 catchError(res => this.restExtractor.handleError(res))
87 )
88 }
89
90 removeVideoAbuse (videoAbuse: VideoAbuse) {
91 const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id
92
93 return this.authHttp.delete(url)
94 .pipe(
95 map(this.restExtractor.extractDataBool),
96 catchError(res => this.restExtractor.handleError(res))
97 )
98 }}
diff --git a/client/src/app/shared/shared-moderation/video-report.component.ts b/client/src/app/shared/shared-moderation/video-report.component.ts
deleted file mode 100644
index 11c805636..000000000
--- a/client/src/app/shared/shared-moderation/video-report.component.ts
+++ /dev/null
@@ -1,161 +0,0 @@
1import { mapValues, pickBy } from 'lodash-es'
2import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
3import { Component, Input, OnInit, ViewChild } from '@angular/core'
4import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
5import { Notifier } from '@app/core'
6import { FormReactive, FormValidatorService, VideoAbuseValidatorsService } from '@app/shared/shared-forms'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
9import { I18n } from '@ngx-translate/i18n-polyfill'
10import { videoAbusePredefinedReasonsMap, VideoAbusePredefinedReasonsString } from '@shared/models/videos/abuse/video-abuse-reason.model'
11import { Video } from '../shared-main'
12import { VideoAbuseService } from './video-abuse.service'
13
14@Component({
15 selector: 'my-video-report',
16 templateUrl: './video-report.component.html',
17 styleUrls: [ './video-report.component.scss' ]
18})
19export class VideoReportComponent extends FormReactive implements OnInit {
20 @Input() video: Video = null
21
22 @ViewChild('modal', { static: true }) modal: NgbModal
23
24 error: string = null
25 predefinedReasons: { id: VideoAbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
26 embedHtml: SafeHtml
27
28 private openedModal: NgbModalRef
29
30 constructor (
31 protected formValidatorService: FormValidatorService,
32 private modalService: NgbModal,
33 private videoAbuseValidatorsService: VideoAbuseValidatorsService,
34 private videoAbuseService: VideoAbuseService,
35 private notifier: Notifier,
36 private sanitizer: DomSanitizer,
37 private i18n: I18n
38 ) {
39 super()
40 }
41
42 get currentHost () {
43 return window.location.host
44 }
45
46 get originHost () {
47 if (this.isRemoteVideo()) {
48 return this.video.account.host
49 }
50
51 return ''
52 }
53
54 get timestamp () {
55 return this.form.get('timestamp').value
56 }
57
58 getVideoEmbed () {
59 return this.sanitizer.bypassSecurityTrustHtml(
60 buildVideoEmbed(
61 buildVideoLink({
62 baseUrl: this.video.embedUrl,
63 title: false,
64 warningTitle: false
65 })
66 )
67 )
68 }
69
70 ngOnInit () {
71 this.buildForm({
72 reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON,
73 predefinedReasons: mapValues(videoAbusePredefinedReasonsMap, r => null),
74 timestamp: {
75 hasStart: null,
76 startAt: null,
77 hasEnd: null,
78 endAt: null
79 }
80 })
81
82 this.predefinedReasons = [
83 {
84 id: 'violentOrRepulsive',
85 label: this.i18n('Violent or repulsive'),
86 help: this.i18n('Contains offensive, violent, or coarse language or iconography.')
87 },
88 {
89 id: 'hatefulOrAbusive',
90 label: this.i18n('Hateful or abusive'),
91 help: this.i18n('Contains abusive, racist or sexist language or iconography.')
92 },
93 {
94 id: 'spamOrMisleading',
95 label: this.i18n('Spam, ad or false news'),
96 help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.')
97 },
98 {
99 id: 'privacy',
100 label: this.i18n('Privacy breach or doxxing'),
101 help: this.i18n('Contains personal information that could be used to track, identify, contact or impersonate someone (e.g. name, address, phone number, email, or credit card details).')
102 },
103 {
104 id: 'rights',
105 label: this.i18n('Intellectual property violation'),
106 help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.')
107 },
108 {
109 id: 'serverRules',
110 label: this.i18n('Breaks server rules'),
111 description: this.i18n('Anything not included in the above that breaks the terms of service, code of conduct, or general rules in place on the server.')
112 },
113 {
114 id: 'thumbnails',
115 label: this.i18n('Thumbnails'),
116 help: this.i18n('The above can only be seen in thumbnails.')
117 },
118 {
119 id: 'captions',
120 label: this.i18n('Captions'),
121 help: this.i18n('The above can only be seen in captions (please describe which).')
122 }
123 ]
124
125 this.embedHtml = this.getVideoEmbed()
126 }
127
128 show () {
129 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
130 }
131
132 hide () {
133 this.openedModal.close()
134 this.openedModal = null
135 }
136
137 report () {
138 const reason = this.form.get('reason').value
139 const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as VideoAbusePredefinedReasonsString[]
140 const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value
141
142 this.videoAbuseService.reportVideo({
143 id: this.video.id,
144 reason,
145 predefinedReasons,
146 startAt: hasStart && startAt ? startAt : undefined,
147 endAt: hasEnd && endAt ? endAt : undefined
148 }).subscribe(
149 () => {
150 this.notifier.success(this.i18n('Video reported.'))
151 this.hide()
152 },
153
154 err => this.notifier.error(err.message)
155 )
156 }
157
158 isRemoteVideo () {
159 return !this.video.isLocal
160 }
161}
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