aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/shared
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/shared')
-rw-r--r--client/src/app/shared/shared-abuse-list/abuse-details.component.html115
-rw-r--r--client/src/app/shared/shared-abuse-list/abuse-details.component.scss34
-rw-r--r--client/src/app/shared/shared-abuse-list/abuse-details.component.ts55
-rw-r--r--client/src/app/shared/shared-abuse-list/abuse-list-table.component.html194
-rw-r--r--client/src/app/shared/shared-abuse-list/abuse-list-table.component.scss107
-rw-r--r--client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts487
-rw-r--r--client/src/app/shared/shared-abuse-list/abuse-message-modal.component.html (renamed from client/src/app/shared/shared-moderation/abuse-message-modal.component.html)16
-rw-r--r--client/src/app/shared/shared-abuse-list/abuse-message-modal.component.scss (renamed from client/src/app/shared/shared-moderation/abuse-message-modal.component.scss)47
-rw-r--r--client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts (renamed from client/src/app/shared/shared-moderation/abuse-message-modal.component.ts)22
-rw-r--r--client/src/app/shared/shared-abuse-list/index.ts7
-rw-r--r--client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.html38
-rw-r--r--client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.scss6
-rw-r--r--client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts70
-rw-r--r--client/src/app/shared/shared-abuse-list/processed-abuse.model.ts25
-rw-r--r--client/src/app/shared/shared-abuse-list/shared-abuse-list.module.ts42
-rw-r--r--client/src/app/shared/shared-main/account/actor.model.ts12
-rw-r--r--client/src/app/shared/shared-moderation/abuse.service.ts93
-rw-r--r--client/src/app/shared/shared-moderation/index.ts1
-rw-r--r--client/src/app/shared/shared-moderation/moderation.scss50
-rw-r--r--client/src/app/shared/shared-moderation/server-blocklist.component.scss13
-rw-r--r--client/src/app/shared/shared-moderation/shared-moderation.module.ts7
21 files changed, 1378 insertions, 63 deletions
diff --git a/client/src/app/shared/shared-abuse-list/abuse-details.component.html b/client/src/app/shared/shared-abuse-list/abuse-details.component.html
new file mode 100644
index 000000000..431fdf5aa
--- /dev/null
+++ b/client/src/app/shared/shared-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="isAdminView && 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]="[ baseRoute ]" [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]="[ baseRoute ]" [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]="[ baseRoute ]" [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 *ngIf="isAdminView" [routerLink]="[ baseRoute ]" [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]="[ baseRoute ]" [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]="[ baseRoute ]"
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="isAdminView && 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/shared/shared-abuse-list/abuse-details.component.scss b/client/src/app/shared/shared-abuse-list/abuse-details.component.scss
new file mode 100644
index 000000000..d83eb974d
--- /dev/null
+++ b/client/src/app/shared/shared-abuse-list/abuse-details.component.scss
@@ -0,0 +1,34 @@
1@import 'variables';
2@import 'mixins';
3@import 'miniature';
4
5.screenratio {
6 div {
7 @include miniature-thumbnail;
8
9 display: inline-flex;
10 justify-content: center;
11 align-items: center;
12 color: pvar(--inputPlaceholderColor);
13 }
14
15 @include large-screen-ratio($selector: 'div, ::ng-deep iframe') {
16 width: 100% !important;
17 height: 100% !important;
18 left: 0;
19 };
20}
21
22.comment-html {
23 background-color: #ececec;
24 padding: 10px;
25}
26
27.abuse-details-date-updated {
28 font-size: 90%;
29 margin-top: .1rem;
30}
31
32.abuse-details-links {
33 @include disable-default-a-behaviour;
34}
diff --git a/client/src/app/shared/shared-abuse-list/abuse-details.component.ts b/client/src/app/shared/shared-abuse-list/abuse-details.component.ts
new file mode 100644
index 000000000..cdd4bf2c8
--- /dev/null
+++ b/client/src/app/shared/shared-abuse-list/abuse-details.component.ts
@@ -0,0 +1,55 @@
1import { Component, Input } from '@angular/core'
2import { durationToString } from '@app/helpers'
3import { Actor } from '@app/shared/shared-main'
4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { AbusePredefinedReasonsString } from '@shared/models'
6import { ProcessedAbuse } from './processed-abuse.model'
7
8@Component({
9 selector: 'my-abuse-details',
10 templateUrl: './abuse-details.component.html',
11 styleUrls: [ '../shared-moderation/moderation.scss', './abuse-details.component.scss' ]
12})
13export class AbuseDetailsComponent {
14 @Input() abuse: ProcessedAbuse
15 @Input() isAdminView: boolean
16 @Input() baseRoute: string
17
18 private predefinedReasonsTranslations: { [key in AbusePredefinedReasonsString]: string }
19
20 constructor (
21 private i18n: I18n
22 ) {
23 this.predefinedReasonsTranslations = {
24 violentOrRepulsive: this.i18n('Violent or Repulsive'),
25 hatefulOrAbusive: this.i18n('Hateful or Abusive'),
26 spamOrMisleading: this.i18n('Spam or Misleading'),
27 privacy: this.i18n('Privacy'),
28 rights: this.i18n('Rights'),
29 serverRules: this.i18n('Server rules'),
30 thumbnails: this.i18n('Thumbnails'),
31 captions: this.i18n('Captions')
32 }
33 }
34
35 get startAt () {
36 return durationToString(this.abuse.video.startAt)
37 }
38
39 get endAt () {
40 return durationToString(this.abuse.video.endAt)
41 }
42
43 getPredefinedReasons () {
44 if (!this.abuse.predefinedReasons) return []
45
46 return this.abuse.predefinedReasons.map(r => ({
47 id: r,
48 label: this.predefinedReasonsTranslations[r]
49 }))
50 }
51
52 switchToDefaultAvatar ($event: Event) {
53 ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
54 }
55}
diff --git a/client/src/app/shared/shared-abuse-list/abuse-list-table.component.html b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.html
new file mode 100644
index 000000000..a6f707a47
--- /dev/null
+++ b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.html
@@ -0,0 +1,194 @@
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 *ngIf="isAdminView()" 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 i18n style="width: 80px;">Messages</th>
45 <th style="width: 150px;"></th>
46 </tr>
47 </ng-template>
48
49 <ng-template pTemplate="body" let-expanded="expanded" let-abuse>
50 <tr>
51 <td class="c-hand" [pRowToggler]="abuse" i18n-ngbTooltip ngbTooltip="More information" placement="top-left" container="body">
52 <span class="expander">
53 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
54 </span>
55 </td>
56
57 <td *ngIf="isAdminView()">
58 <a *ngIf="abuse.reporterAccount" [href]="abuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
59 <div class="chip two-lines">
60 <img
61 class="avatar"
62 [src]="abuse.reporterAccount.avatar?.path"
63 (error)="switchToDefaultAvatar($event)"
64 alt="Avatar"
65 >
66 <div>
67 {{ abuse.reporterAccount.displayName }}
68 <span>{{ abuse.reporterAccount.nameWithHost }}</span>
69 </div>
70 </div>
71 </a>
72
73 <span i18n *ngIf="!abuse.reporterAccount">
74 Deleted account
75 </span>
76 </td>
77
78 <ng-container *ngIf="abuse.video">
79
80 <td *ngIf="!abuse.video.deleted">
81 <a [href]="getVideoUrl(abuse)" class="table-video-link" [title]="abuse.video.name" target="_blank" rel="noopener noreferrer">
82 <div class="table-video">
83 <div class="table-video-image">
84 <img [src]="abuse.video.thumbnailPath">
85 <span
86 class="table-video-image-label" *ngIf="abuse.count > 1"
87 i18n-title title="This video has been reported multiple times."
88 >
89 {{ abuse.nth }}/{{ abuse.count }}
90 </span>
91 </div>
92
93 <div class="table-video-text">
94 <div>
95 <span *ngIf="!abuse.video.blacklisted" class="glyphicon glyphicon-new-window"></span>
96 <span *ngIf="abuse.video.blacklisted" i18n-title title="The video was blocked" class="glyphicon glyphicon-ban-circle"></span>
97 {{ abuse.video.name }}
98 </div>
99 <div i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
100 </div>
101 </div>
102 </a>
103 </td>
104
105 <td *ngIf="abuse.video.deleted" class="c-hand" [pRowToggler]="abuse">
106 <div class="table-video" i18n-title title="Video was deleted">
107 <div class="table-video-image">
108 <span i18n>Deleted</span>
109 </div>
110
111 <div class="table-video-text">
112 <div>
113 {{ abuse.video.name }}
114 <span class="glyphicon glyphicon-trash"></span>
115 </div>
116 <div i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
117 </div>
118 </div>
119 </td>
120 </ng-container>
121
122 <ng-container *ngIf="abuse.comment">
123 <td>
124 <a [href]="getCommentUrl(abuse)" [innerHTML]="abuse.truncatedCommentHtml" class="table-comment-link"
125 [title]="abuse.comment.video.name" target="_blank" rel="noopener noreferrer"
126 ></a>
127
128 <div class="comment-flagged-account" *ngIf="abuse.flaggedAccount">by {{ abuse.flaggedAccount.displayName }}</div>
129 </td>
130 </ng-container>
131
132 <ng-container *ngIf="!abuse.comment && !abuse.video">
133 <td *ngIf="abuse.flaggedAccount">
134 <a [href]="getAccountUrl(abuse)" class="table-account-link" target="_blank" rel="noopener noreferrer">
135 <span>{{ abuse.flaggedAccount.displayName }}</span>
136
137 <span class="account-flagged-handle">{{ abuse.flaggedAccount.nameWithHostForced }}</span>
138 </a>
139 </td>
140
141 <td i18n *ngIf="!abuse.flaggedAccount">
142 Account deleted
143 </td>
144
145 </ng-container>
146
147
148 <td class="c-hand" [pRowToggler]="abuse">{{ abuse.createdAt | date: 'short' }}</td>
149
150 <td class="c-hand abuse-states" [pRowToggler]="abuse">
151 <span *ngIf="isAbuseAccepted(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-ok"></span>
152 <span *ngIf="isAbuseRejected(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-remove"></span>
153 <span *ngIf="abuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="abuse.moderationComment" class="glyphicon glyphicon-comment"></span>
154 </td>
155
156 <td class="c-hand abuse-messages" (click)="openAbuseMessagesModal(abuse)">
157 <ng-container *ngIf="isLocalAbuse(abuse)">
158 {{ abuse.countMessages }}
159
160 <my-global-icon iconName="message-circle"></my-global-icon>
161 </ng-container>
162 </td>
163
164 <td class="action-cell">
165 <my-action-dropdown
166 [ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
167 i18n-label label="Actions" [actions]="abuseActions" [entry]="abuse"
168 ></my-action-dropdown>
169 </td>
170 </tr>
171 </ng-template>
172
173 <ng-template pTemplate="rowexpansion" let-abuse>
174 <tr>
175 <td class="expand-cell" colspan="6">
176 <my-abuse-details [abuse]="abuse" [baseRoute]="baseRoute" [isAdminView]="isAdminView()"></my-abuse-details>
177 </td>
178 </tr>
179 </ng-template>
180
181 <ng-template pTemplate="emptymessage">
182 <tr>
183 <td colspan="6">
184 <div class="no-results">
185 <ng-container *ngIf="search" i18n>No abuses found matching current filters.</ng-container>
186 <ng-container *ngIf="!search" i18n>No abuses found.</ng-container>
187 </div>
188 </td>
189 </tr>
190 </ng-template>
191</p-table>
192
193<my-moderation-comment-modal #moderationCommentModal (commentUpdated)="onModerationCommentUpdated()"></my-moderation-comment-modal>
194<my-abuse-message-modal #abuseMessagesModal [isAdminView]="isAdminView()" (countMessagesUpdated)="onCountMessagesUpdated($event)"></my-abuse-message-modal>
diff --git a/client/src/app/shared/shared-abuse-list/abuse-list-table.component.scss b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.scss
new file mode 100644
index 000000000..7ed7c9e87
--- /dev/null
+++ b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.scss
@@ -0,0 +1,107 @@
1@import 'variables';
2@import 'mixins';
3@import 'miniature';
4
5.table-video-link {
6 @include disable-outline;
7
8 position: relative;
9 top: 3px;
10}
11
12.table-comment-link,
13.table-account-link {
14 @include disable-outline;
15
16 color: var(--mainForegroundColor);
17
18 ::ng-deep p:last-child {
19 margin: 0;
20 }
21}
22
23.table-account-link {
24 display: flex;
25 flex-direction: column;
26}
27
28.comment-flagged-account,
29.account-flagged-handle {
30 font-size: 11px;
31 color: var(--greyForegroundColor);
32}
33
34.table-video {
35 display: inline-flex;
36
37 .table-video-image {
38 @include miniature-thumbnail;
39
40 $image-height: 45px;
41
42 height: $image-height;
43 width: #{(16/9) * $image-height};
44 margin-right: 0.5rem;
45 border-radius: 2px;
46 border: none;
47 background: transparent;
48 display: inline-flex;
49 justify-content: center;
50 align-items: center;
51 position: relative;
52
53 img {
54 height: 100%;
55 width: 100%;
56 border-radius: 2px;
57 }
58
59 span {
60 color: pvar(--inputPlaceholderColor);
61 }
62
63 .table-video-image-label {
64 @include static-thumbnail-overlay;
65 position: absolute;
66 border-radius: 3px;
67 font-size: 10px;
68 padding: 0 3px;
69 line-height: 1.3;
70 bottom: 2px;
71 right: 2px;
72 }
73 }
74
75 .table-video-text {
76 display: inline-flex;
77 flex-direction: column;
78 justify-content: center;
79 font-size: 90%;
80 color: pvar(--mainForegroundColor);
81 line-height: 1rem;
82
83 div .glyphicon {
84 font-size: 80%;
85 color: gray;
86 margin-left: 0.1rem;
87 }
88
89 div + div {
90 color: var(--greyForegroundColor);
91 font-size: 11px;
92 }
93 }
94}
95
96.abuse-states .glyphicon-comment {
97 margin-left: 0.5rem;
98}
99
100.abuse-messages {
101 my-global-icon {
102 width: 22px;
103 margin-left: 3px;
104 position: relative;
105 top: -2px;
106 }
107}
diff --git a/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts
new file mode 100644
index 000000000..1d17c9ec9
--- /dev/null
+++ b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts
@@ -0,0 +1,487 @@
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, Input } from '@angular/core'
7import { DomSanitizer } 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 { AbuseState, AdminAbuse } from '@shared/models'
15import { AbuseMessageModalComponent } from './abuse-message-modal.component'
16import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
17import { ProcessedAbuse } from './processed-abuse.model'
18
19const logger = debug('peertube:moderation:AbuseListTableComponent')
20
21@Component({
22 selector: 'my-abuse-list-table',
23 templateUrl: './abuse-list-table.component.html',
24 styleUrls: [ '../shared-moderation/moderation.scss', './abuse-list-table.component.scss' ]
25})
26export class AbuseListTableComponent extends RestTable implements OnInit, AfterViewInit {
27 @Input() viewType: 'admin' | 'user'
28 @Input() baseRoute: string
29
30 @ViewChild('abuseMessagesModal', { static: true }) abuseMessagesModal: AbuseMessageModalComponent
31 @ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent
32
33 abuses: ProcessedAbuse[] = []
34 totalRecords = 0
35 sort: SortMeta = { field: 'createdAt', order: 1 }
36 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
37
38 abuseActions: DropdownAction<ProcessedAbuse>[][] = []
39
40 constructor (
41 private notifier: Notifier,
42 private abuseService: AbuseService,
43 private blocklistService: BlocklistService,
44 private commentService: VideoCommentService,
45 private videoService: VideoService,
46 private videoBlocklistService: VideoBlockService,
47 private confirmService: ConfirmService,
48 private i18n: I18n,
49 private markdownRenderer: MarkdownService,
50 private sanitizer: DomSanitizer,
51 private route: ActivatedRoute,
52 private router: Router
53 ) {
54 super()
55 }
56
57 ngOnInit () {
58 this.abuseActions = [
59 this.buildInternalActions(),
60
61 this.buildFlaggedAccountActions(),
62
63 this.buildCommentActions(),
64
65 this.buildVideoActions(),
66
67 this.buildAccountActions()
68 ]
69
70 this.initialize()
71
72 this.route.queryParams
73 .subscribe(params => {
74 this.search = params.search || ''
75
76 logger('On URL change (search: %s).', this.search)
77
78 this.setTableFilter(this.search)
79 this.loadData()
80 })
81 }
82
83 ngAfterViewInit () {
84 if (this.search) this.setTableFilter(this.search)
85 }
86
87 isAdminView () {
88 return this.viewType === 'admin'
89 }
90
91 getIdentifier () {
92 return 'AbuseListTableComponent'
93 }
94
95 openModerationCommentModal (abuse: AdminAbuse) {
96 this.moderationCommentModal.openModal(abuse)
97 }
98
99 onModerationCommentUpdated () {
100 this.loadData()
101 }
102
103 /* Table filter functions */
104 onAbuseSearch (event: Event) {
105 this.onSearch(event)
106 this.setQueryParams((event.target as HTMLInputElement).value)
107 }
108
109 setQueryParams (search: string) {
110 const queryParams: Params = {}
111 if (search) Object.assign(queryParams, { search })
112
113 this.router.navigate([ this.baseRoute ], { queryParams })
114 }
115
116 resetTableFilter () {
117 this.setTableFilter('')
118 this.setQueryParams('')
119 this.resetSearch()
120 }
121 /* END Table filter functions */
122
123 isAbuseAccepted (abuse: AdminAbuse) {
124 return abuse.state.id === AbuseState.ACCEPTED
125 }
126
127 isAbuseRejected (abuse: AdminAbuse) {
128 return abuse.state.id === AbuseState.REJECTED
129 }
130
131 getVideoUrl (abuse: AdminAbuse) {
132 return Video.buildClientUrl(abuse.video.uuid)
133 }
134
135 getCommentUrl (abuse: AdminAbuse) {
136 return Video.buildClientUrl(abuse.comment.video.uuid) + ';threadId=' + abuse.comment.threadId
137 }
138
139 getAccountUrl (abuse: ProcessedAbuse) {
140 return '/accounts/' + abuse.flaggedAccount.nameWithHost
141 }
142
143 getVideoEmbed (abuse: AdminAbuse) {
144 return buildVideoEmbed(
145 buildVideoLink({
146 baseUrl: `${environment.embedUrl}/videos/embed/${abuse.video.uuid}`,
147 title: false,
148 warningTitle: false,
149 startTime: abuse.startAt,
150 stopTime: abuse.endAt
151 })
152 )
153 }
154
155 switchToDefaultAvatar ($event: Event) {
156 ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
157 }
158
159 async removeAbuse (abuse: AdminAbuse) {
160 const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this abuse report?'), this.i18n('Delete'))
161 if (res === false) return
162
163 this.abuseService.removeAbuse(abuse).subscribe(
164 () => {
165 this.notifier.success(this.i18n('Abuse deleted.'))
166 this.loadData()
167 },
168
169 err => this.notifier.error(err.message)
170 )
171 }
172
173 updateAbuseState (abuse: AdminAbuse, state: AbuseState) {
174 this.abuseService.updateAbuse(abuse, { state })
175 .subscribe(
176 () => this.loadData(),
177
178 err => this.notifier.error(err.message)
179 )
180 }
181
182 onCountMessagesUpdated (event: { abuseId: number, countMessages: number }) {
183 const abuse = this.abuses.find(a => a.id === event.abuseId)
184
185 if (!abuse) {
186 console.error('Cannot find abuse %d.', event.abuseId)
187 return
188 }
189
190 abuse.countMessages = event.countMessages
191 }
192
193 openAbuseMessagesModal (abuse: AdminAbuse) {
194 this.abuseMessagesModal.openModal(abuse)
195 }
196
197 isLocalAbuse (abuse: AdminAbuse) {
198 if (this.viewType === 'user') return true
199
200 return Actor.IS_LOCAL(abuse.reporterAccount.host)
201 }
202
203 protected loadData () {
204 logger('Loading data.')
205
206 const options = {
207 pagination: this.pagination,
208 sort: this.sort,
209 search: this.search
210 }
211
212 const observable = this.viewType === 'admin'
213 ? this.abuseService.getAdminAbuses(options)
214 : this.abuseService.getUserAbuses(options)
215
216 return observable.subscribe(
217 async resultList => {
218 this.totalRecords = resultList.total
219
220 this.abuses = []
221
222 for (const a of resultList.data) {
223 const abuse = a as ProcessedAbuse
224
225 abuse.reasonHtml = await this.toHtml(abuse.reason)
226
227 if (abuse.moderationComment) {
228 abuse.moderationCommentHtml = await this.toHtml(abuse.moderationComment)
229 }
230
231 if (abuse.video) {
232 abuse.embedHtml = this.sanitizer.bypassSecurityTrustHtml(this.getVideoEmbed(abuse))
233
234 if (abuse.video.channel?.ownerAccount) {
235 abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount)
236 }
237 }
238
239 if (abuse.comment) {
240 if (abuse.comment.deleted) {
241 abuse.truncatedCommentHtml = abuse.commentHtml = this.i18n('Deleted comment')
242 } else {
243 const truncated = truncate(abuse.comment.text, { length: 100 })
244 abuse.truncatedCommentHtml = await this.markdownRenderer.textMarkdownToHTML(truncated, true)
245 abuse.commentHtml = await this.markdownRenderer.textMarkdownToHTML(abuse.comment.text, true)
246 }
247 }
248
249 if (abuse.reporterAccount) {
250 abuse.reporterAccount = new Account(abuse.reporterAccount)
251 }
252
253 if (abuse.flaggedAccount) {
254 abuse.flaggedAccount = new Account(abuse.flaggedAccount)
255 }
256
257 if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt
258
259 this.abuses.push(abuse)
260 }
261 },
262
263 err => this.notifier.error(err.message)
264 )
265 }
266
267 private buildInternalActions (): DropdownAction<ProcessedAbuse>[] {
268 return [
269 {
270 label: this.i18n('Internal actions'),
271 isHeader: true
272 },
273 {
274 label: this.isAdminView()
275 ? this.i18n('Messages with reporter')
276 : this.i18n('Messages with moderators'),
277 handler: abuse => this.openAbuseMessagesModal(abuse),
278 isDisplayed: abuse => this.isLocalAbuse(abuse)
279 },
280 {
281 label: this.i18n('Update note'),
282 handler: abuse => this.openModerationCommentModal(abuse),
283 isDisplayed: abuse => this.isAdminView() && !!abuse.moderationComment
284 },
285 {
286 label: this.i18n('Mark as accepted'),
287 handler: abuse => this.updateAbuseState(abuse, AbuseState.ACCEPTED),
288 isDisplayed: abuse => this.isAdminView() && !this.isAbuseAccepted(abuse)
289 },
290 {
291 label: this.i18n('Mark as rejected'),
292 handler: abuse => this.updateAbuseState(abuse, AbuseState.REJECTED),
293 isDisplayed: abuse => this.isAdminView() && !this.isAbuseRejected(abuse)
294 },
295 {
296 label: this.i18n('Add internal note'),
297 handler: abuse => this.openModerationCommentModal(abuse),
298 isDisplayed: abuse => this.isAdminView() && !abuse.moderationComment
299 },
300 {
301 label: this.i18n('Delete report'),
302 handler: abuse => this.isAdminView() && this.removeAbuse(abuse)
303 }
304 ]
305 }
306
307 private buildFlaggedAccountActions (): DropdownAction<ProcessedAbuse>[] {
308 if (!this.isAdminView()) return []
309
310 return [
311 {
312 label: this.i18n('Actions for the flagged account'),
313 isHeader: true,
314 isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video
315 },
316
317 {
318 label: this.i18n('Mute account'),
319 isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video,
320 handler: abuse => this.muteAccountHelper(abuse.flaggedAccount)
321 },
322
323 {
324 label: this.i18n('Mute server account'),
325 isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video,
326 handler: abuse => this.muteServerHelper(abuse.flaggedAccount.host)
327 }
328 ]
329 }
330
331 private buildAccountActions (): DropdownAction<ProcessedAbuse>[] {
332 if (!this.isAdminView()) return []
333
334 return [
335 {
336 label: this.i18n('Actions for the reporter'),
337 isHeader: true,
338 isDisplayed: abuse => !!abuse.reporterAccount
339 },
340
341 {
342 label: this.i18n('Mute reporter'),
343 isDisplayed: abuse => !!abuse.reporterAccount,
344 handler: abuse => this.muteAccountHelper(abuse.reporterAccount)
345 },
346
347 {
348 label: this.i18n('Mute server'),
349 isDisplayed: abuse => abuse.reporterAccount && !abuse.reporterAccount.userId,
350 handler: abuse => this.muteServerHelper(abuse.reporterAccount.host)
351 }
352 ]
353 }
354
355 private buildVideoActions (): DropdownAction<ProcessedAbuse>[] {
356 if (!this.isAdminView()) return []
357
358 return [
359 {
360 label: this.i18n('Actions for the video'),
361 isHeader: true,
362 isDisplayed: abuse => abuse.video && !abuse.video.deleted
363 },
364 {
365 label: this.i18n('Block video'),
366 isDisplayed: abuse => abuse.video && !abuse.video.deleted && !abuse.video.blacklisted,
367 handler: abuse => {
368 this.videoBlocklistService.blockVideo(abuse.video.id, undefined, true)
369 .subscribe(
370 () => {
371 this.notifier.success(this.i18n('Video blocked.'))
372
373 this.updateAbuseState(abuse, AbuseState.ACCEPTED)
374 },
375
376 err => this.notifier.error(err.message)
377 )
378 }
379 },
380 {
381 label: this.i18n('Unblock video'),
382 isDisplayed: abuse => abuse.video && !abuse.video.deleted && abuse.video.blacklisted,
383 handler: abuse => {
384 this.videoBlocklistService.unblockVideo(abuse.video.id)
385 .subscribe(
386 () => {
387 this.notifier.success(this.i18n('Video unblocked.'))
388
389 this.updateAbuseState(abuse, AbuseState.ACCEPTED)
390 },
391
392 err => this.notifier.error(err.message)
393 )
394 }
395 },
396 {
397 label: this.i18n('Delete video'),
398 isDisplayed: abuse => abuse.video && !abuse.video.deleted,
399 handler: async abuse => {
400 const res = await this.confirmService.confirm(
401 this.i18n('Do you really want to delete this video?'),
402 this.i18n('Delete')
403 )
404 if (res === false) return
405
406 this.videoService.removeVideo(abuse.video.id)
407 .subscribe(
408 () => {
409 this.notifier.success(this.i18n('Video deleted.'))
410
411 this.updateAbuseState(abuse, AbuseState.ACCEPTED)
412 },
413
414 err => this.notifier.error(err.message)
415 )
416 }
417 }
418 ]
419 }
420
421 private buildCommentActions (): DropdownAction<ProcessedAbuse>[] {
422 if (!this.isAdminView()) return []
423
424 return [
425 {
426 label: this.i18n('Actions for the comment'),
427 isHeader: true,
428 isDisplayed: abuse => abuse.comment && !abuse.comment.deleted
429 },
430
431 {
432 label: this.i18n('Delete comment'),
433 isDisplayed: abuse => abuse.comment && !abuse.comment.deleted,
434 handler: async abuse => {
435 const res = await this.confirmService.confirm(
436 this.i18n('Do you really want to delete this comment?'),
437 this.i18n('Delete')
438 )
439 if (res === false) return
440
441 this.commentService.deleteVideoComment(abuse.comment.video.id, abuse.comment.id)
442 .subscribe(
443 () => {
444 this.notifier.success(this.i18n('Comment deleted.'))
445
446 this.updateAbuseState(abuse, AbuseState.ACCEPTED)
447 },
448
449 err => this.notifier.error(err.message)
450 )
451 }
452 }
453 ]
454 }
455
456 private muteAccountHelper (account: Account) {
457 this.blocklistService.blockAccountByInstance(account)
458 .subscribe(
459 () => {
460 this.notifier.success(
461 this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost })
462 )
463
464 account.mutedByInstance = true
465 },
466
467 err => this.notifier.error(err.message)
468 )
469 }
470
471 private muteServerHelper (host: string) {
472 this.blocklistService.blockServerByInstance(host)
473 .subscribe(
474 () => {
475 this.notifier.success(
476 this.i18n('Server {{host}} muted by the instance.', { host: host })
477 )
478 },
479
480 err => this.notifier.error(err.message)
481 )
482 }
483
484 private toHtml (text: string) {
485 return this.markdownRenderer.textMarkdownToHTML(text)
486 }
487}
diff --git a/client/src/app/shared/shared-moderation/abuse-message-modal.component.html b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.html
index 67c6a3081..cb965b71d 100644
--- a/client/src/app/shared/shared-moderation/abuse-message-modal.component.html
+++ b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.html
@@ -1,6 +1,9 @@
1<ng-template #modal> 1<ng-template #modal>
2 <div class="modal-header"> 2 <div class="modal-header">
3 <h4 i18n class="modal-title">Messages</h4> 3 <h4 class="modal-title">
4 <ng-container i18n *ngIf="isAdminView">Messages with the reporter</ng-container>
5 <ng-container i18n *ngIf="!isAdminView">Messages with the moderation team</ng-container>
6 </h4>
4 7
5 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> 8 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
6 </div> 9 </div>
@@ -21,9 +24,16 @@
21 </div> 24 </div>
22 </div> 25 </div>
23 26
27 <div class="no-messages" *ngIf="noResults" i18n>
28 No messages for now.
29 </div>
30
24 <form novalidate [formGroup]="form" (ngSubmit)="addMessage()"> 31 <form novalidate [formGroup]="form" (ngSubmit)="addMessage()">
25 <div class="form-group"> 32 <div class="form-group">
26 <textarea formControlName="message" ngbAutofocus [ngClass]="{ 'input-error': formErrors['message'] }" class="form-control"></textarea> 33 <textarea
34 formControlName="message" ngbAutofocus [placeholder]="getPlaceholderMessage()"
35 [ngClass]="{ 'input-error': formErrors['message'] }" class="form-control"
36 ></textarea>
27 37
28 <div *ngIf="formErrors.message" class="form-error"> 38 <div *ngIf="formErrors.message" class="form-error">
29 {{ formErrors.message }} 39 {{ formErrors.message }}
@@ -31,7 +41,7 @@
31 </div> 41 </div>
32 42
33 <div class="form-group inputs"> 43 <div class="form-group inputs">
34 <input type="submit" i18n-value value="Add message" class="action-button-submit" [disabled]="!form.valid || sendingMessage"> 44 <input type="submit" i18n-value value="Add a message" class="action-button-submit" [disabled]="!form.valid || sendingMessage">
35 </div> 45 </div>
36 </form> 46 </form>
37 47
diff --git a/client/src/app/shared/shared-moderation/abuse-message-modal.component.scss b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.scss
index 89d6b88c1..4dd025fc4 100644
--- a/client/src/app/shared/shared-moderation/abuse-message-modal.component.scss
+++ b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.scss
@@ -3,6 +3,11 @@
3 3
4form { 4form {
5 margin: 20px 20px 0 0; 5 margin: 20px 20px 0 0;
6
7 .form-group:first-child {
8 // Keep place to display error message without modifying the height
9 min-height: 125px;
10 }
6} 11}
7 12
8textarea { 13textarea {
@@ -15,35 +20,29 @@ textarea {
15 display: flex; 20 display: flex;
16 flex-direction: column; 21 flex-direction: column;
17 overflow-y: scroll; 22 overflow-y: scroll;
18 margin-right: 5px; 23}
24
25.no-messages {
26 display: flex;
27 font-size: 15px;
28 justify-content: center;
19} 29}
20 30
21.message-block { 31.message-block {
22 margin-bottom: 10px; 32 margin: 0 5px 10px 0;
23 max-width: 60%; 33 max-width: 60%;
24 34
25 .author { 35 .author {
26 color: var(--greyForegroundColor); 36 color: var(--greyForegroundColor);
27 font-size: 14px; 37 font-size: 14px;
38 padding: 0 0 3px 10px;
28 } 39 }
29 40
30 .bubble { 41 .bubble {
31 color: var(--mainForegroundColor);
32 background-color: var(--greyBackgroundColor);
33 border-radius: 10px; 42 border-radius: 10px;
34 padding: 5px 10px; 43 padding: 5px 10px;
35 44 color: var(--mainForegroundColor);
36 &.by-me { 45 background-color: var(--greyBackgroundColor);
37 color: var(--mainForegroundColor);
38 background-color: var(--secondaryColor);
39 }
40
41 &.by-moderator {
42 color: #fff;
43 background-color: var(--mainColor);
44
45 align-self: flex-end;
46 }
47 46
48 .content { 47 .content {
49 font-size: 15px; 48 font-size: 15px;
@@ -54,4 +53,20 @@ textarea {
54 color: var(--greyForegroundColor); 53 color: var(--greyForegroundColor);
55 } 54 }
56 } 55 }
56
57 &.by-me {
58
59 .bubble {
60 color: var(--mainBackgroundColor);
61 background-color: var(--mainColorLighter);
62
63 .date {
64 color: var(--mainBackgroundColor);
65 }
66 }
67 }
68
69 &.by-moderator {
70 align-self: flex-end;
71 }
57} 72}
diff --git a/client/src/app/shared/shared-moderation/abuse-message-modal.component.ts b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts
index 5822dfe1d..03f5ad735 100644
--- a/client/src/app/shared/shared-moderation/abuse-message-modal.component.ts
+++ b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts
@@ -1,11 +1,11 @@
1import { Component, ElementRef, EventEmitter, Output, ViewChild, OnInit } from '@angular/core' 1import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier, AuthService } from '@app/core' 2import { AuthService, Notifier } from '@app/core'
3import { FormReactive, FormValidatorService, AbuseValidatorsService } from '@app/shared/shared-forms' 3import { AbuseValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms'
4import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 4import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
5import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 5import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
6import { I18n } from '@ngx-translate/i18n-polyfill' 6import { I18n } from '@ngx-translate/i18n-polyfill'
7import { AbuseMessage, UserAbuse } from '@shared/models' 7import { AbuseMessage, UserAbuse } from '@shared/models'
8import { AbuseService } from './abuse.service' 8import { AbuseService } from '../shared-moderation'
9 9
10@Component({ 10@Component({
11 selector: 'my-abuse-message-modal', 11 selector: 'my-abuse-message-modal',
@@ -16,11 +16,14 @@ export class AbuseMessageModalComponent extends FormReactive implements OnInit {
16 @ViewChild('modal', { static: true }) modal: NgbModal 16 @ViewChild('modal', { static: true }) modal: NgbModal
17 @ViewChild('messagesBlock', { static: false }) messagesBlock: ElementRef 17 @ViewChild('messagesBlock', { static: false }) messagesBlock: ElementRef
18 18
19 @Input() isAdminView: boolean
20
19 @Output() countMessagesUpdated = new EventEmitter<{ abuseId: number, countMessages: number }>() 21 @Output() countMessagesUpdated = new EventEmitter<{ abuseId: number, countMessages: number }>()
20 22
21 abuseMessages: AbuseMessage[] = [] 23 abuseMessages: AbuseMessage[] = []
22 textareaMessage: string 24 textareaMessage: string
23 sendingMessage = false 25 sendingMessage = false
26 noResults = false
24 27
25 private openedModal: NgbModalRef 28 private openedModal: NgbModalRef
26 private abuse: UserAbuse 29 private abuse: UserAbuse
@@ -29,9 +32,9 @@ export class AbuseMessageModalComponent extends FormReactive implements OnInit {
29 protected formValidatorService: FormValidatorService, 32 protected formValidatorService: FormValidatorService,
30 private abuseValidatorsService: AbuseValidatorsService, 33 private abuseValidatorsService: AbuseValidatorsService,
31 private modalService: NgbModal, 34 private modalService: NgbModal,
35 private i18n: I18n,
32 private auth: AuthService, 36 private auth: AuthService,
33 private notifier: Notifier, 37 private notifier: Notifier,
34 private i18n: I18n,
35 private abuseService: AbuseService 38 private abuseService: AbuseService
36 ) { 39 ) {
37 super() 40 super()
@@ -94,11 +97,20 @@ export class AbuseMessageModalComponent extends FormReactive implements OnInit {
94 return this.auth.getUser().account.id === abuseMessage.account.id 97 return this.auth.getUser().account.id === abuseMessage.account.id
95 } 98 }
96 99
100 getPlaceholderMessage () {
101 if (this.isAdminView) {
102 return this.i18n('Add a message to communicate with the reporter')
103 }
104
105 return this.i18n('Add a message to communicate with the moderation team')
106 }
107
97 private loadMessages () { 108 private loadMessages () {
98 this.abuseService.listAbuseMessages(this.abuse) 109 this.abuseService.listAbuseMessages(this.abuse)
99 .subscribe( 110 .subscribe(
100 res => { 111 res => {
101 this.abuseMessages = res.data 112 this.abuseMessages = res.data
113 this.noResults = this.abuseMessages.length === 0
102 114
103 setTimeout(() => { 115 setTimeout(() => {
104 if (!this.messagesBlock) return 116 if (!this.messagesBlock) return
diff --git a/client/src/app/shared/shared-abuse-list/index.ts b/client/src/app/shared/shared-abuse-list/index.ts
new file mode 100644
index 000000000..3bdd18201
--- /dev/null
+++ b/client/src/app/shared/shared-abuse-list/index.ts
@@ -0,0 +1,7 @@
1export * from './abuse-message-modal.component'
2export * from './abuse-list-table.component'
3export * from './abuse-details.component'
4export * from './moderation-comment-modal.component'
5export * from './processed-abuse.model'
6
7export * from './shared-abuse-list.module'
diff --git a/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.html b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.html
new file mode 100644
index 000000000..8082e93f4
--- /dev/null
+++ b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.html
@@ -0,0 +1,38 @@
1<ng-template #modal>
2 <div class="modal-header">
3 <h4 i18n class="modal-title">Moderation comment</h4>
4
5 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
6 </div>
7
8 <div class="modal-body">
9 <form novalidate [formGroup]="form" (ngSubmit)="banUser()">
10 <div class="form-group">
11 <textarea
12 formControlName="moderationComment" ngbAutofocus i18-placeholder placeholder="Comment this report…"
13 [ngClass]="{ 'input-error': formErrors['moderationComment'] }" class="form-control">
14 </textarea>
15 <div *ngIf="formErrors.moderationComment" class="form-error">
16 {{ formErrors.moderationComment }}
17 </div>
18 </div>
19
20 <div class="form-group" i18n>
21 This comment can only be seen by you or the other moderators.
22 </div>
23
24 <div class="form-group inputs">
25 <input
26 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
27 (click)="hide()" (key.enter)="hide()"
28 >
29
30 <input
31 type="submit" i18n-value value="Update this comment" class="action-button-submit"
32 [disabled]="!form.valid"
33 >
34 </div>
35 </form>
36 </div>
37
38</ng-template>
diff --git a/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.scss b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.scss
new file mode 100644
index 000000000..afcdb9a16
--- /dev/null
+++ b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.scss
@@ -0,0 +1,6 @@
1@import 'variables';
2@import 'mixins';
3
4textarea {
5 @include peertube-textarea(100%, 100px);
6}
diff --git a/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts
new file mode 100644
index 000000000..ecb7966bf
--- /dev/null
+++ b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts
@@ -0,0 +1,70 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core'
3import { FormReactive, FormValidatorService, AbuseValidatorsService } from '@app/shared/shared-forms'
4import { AbuseService } from '@app/shared/shared-moderation'
5import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
6import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { AdminAbuse } from '@shared/models'
9
10@Component({
11 selector: 'my-moderation-comment-modal',
12 templateUrl: './moderation-comment-modal.component.html',
13 styleUrls: [ './moderation-comment-modal.component.scss' ]
14})
15export class ModerationCommentModalComponent extends FormReactive implements OnInit {
16 @ViewChild('modal', { static: true }) modal: NgbModal
17 @Output() commentUpdated = new EventEmitter<string>()
18
19 private abuseToComment: AdminAbuse
20 private openedModal: NgbModalRef
21
22 constructor (
23 protected formValidatorService: FormValidatorService,
24 private modalService: NgbModal,
25 private notifier: Notifier,
26 private abuseService: AbuseService,
27 private abuseValidatorsService: AbuseValidatorsService,
28 private i18n: I18n
29 ) {
30 super()
31 }
32
33 ngOnInit () {
34 this.buildForm({
35 moderationComment: this.abuseValidatorsService.ABUSE_MODERATION_COMMENT
36 })
37 }
38
39 openModal (abuseToComment: AdminAbuse) {
40 this.abuseToComment = abuseToComment
41 this.openedModal = this.modalService.open(this.modal, { centered: true })
42
43 this.form.patchValue({
44 moderationComment: this.abuseToComment.moderationComment
45 })
46 }
47
48 hide () {
49 this.abuseToComment = undefined
50 this.openedModal.close()
51 this.form.reset()
52 }
53
54 async banUser () {
55 const moderationComment: string = this.form.value[ 'moderationComment' ]
56
57 this.abuseService.updateAbuse(this.abuseToComment, { moderationComment })
58 .subscribe(
59 () => {
60 this.notifier.success(this.i18n('Comment updated.'))
61
62 this.commentUpdated.emit(moderationComment)
63 this.hide()
64 },
65
66 err => this.notifier.error(err.message)
67 )
68 }
69
70}
diff --git a/client/src/app/shared/shared-abuse-list/processed-abuse.model.ts b/client/src/app/shared/shared-abuse-list/processed-abuse.model.ts
new file mode 100644
index 000000000..fce1a8db3
--- /dev/null
+++ b/client/src/app/shared/shared-abuse-list/processed-abuse.model.ts
@@ -0,0 +1,25 @@
1import { SafeHtml } from '@angular/platform-browser'
2import { AdminAbuse } from '@shared/models'
3import { Account } from '@app/shared/shared-main'
4
5// Don't use an abuse model because we need external services to compute some properties
6// And this model is only used in this component
7export type ProcessedAbuse = AdminAbuse & {
8 moderationCommentHtml?: string,
9 reasonHtml?: string
10 embedHtml?: SafeHtml
11 updatedAt?: Date
12
13 // override bare server-side definitions with rich client-side definitions
14 reporterAccount?: Account
15 flaggedAccount?: Account
16
17 truncatedCommentHtml?: string
18 commentHtml?: string
19
20 video: AdminAbuse['video'] & {
21 channel: AdminAbuse['video']['channel'] & {
22 ownerAccount: Account
23 }
24 }
25}
diff --git a/client/src/app/shared/shared-abuse-list/shared-abuse-list.module.ts b/client/src/app/shared/shared-abuse-list/shared-abuse-list.module.ts
new file mode 100644
index 000000000..663cd902b
--- /dev/null
+++ b/client/src/app/shared/shared-abuse-list/shared-abuse-list.module.ts
@@ -0,0 +1,42 @@
1
2import { TableModule } from 'primeng/table'
3import { NgModule } from '@angular/core'
4import { SharedFormModule } from '../shared-forms/shared-form.module'
5import { SharedGlobalIconModule } from '../shared-icons'
6import { SharedMainModule } from '../shared-main/shared-main.module'
7import { SharedModerationModule } from '../shared-moderation'
8import { SharedVideoCommentModule } from '../shared-video-comment'
9import { AbuseDetailsComponent } from './abuse-details.component'
10import { AbuseListTableComponent } from './abuse-list-table.component'
11import { AbuseMessageModalComponent } from './abuse-message-modal.component'
12import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
13
14@NgModule({
15 imports: [
16 TableModule,
17
18 SharedMainModule,
19 SharedFormModule,
20 SharedModerationModule,
21 SharedGlobalIconModule,
22 SharedVideoCommentModule
23 ],
24
25 declarations: [
26 AbuseDetailsComponent,
27 AbuseListTableComponent,
28 ModerationCommentModalComponent,
29 AbuseMessageModalComponent
30 ],
31
32 exports: [
33 AbuseDetailsComponent,
34 AbuseListTableComponent,
35 ModerationCommentModalComponent,
36 AbuseMessageModalComponent
37 ],
38
39 providers: [
40 ]
41})
42export class SharedAbuseListModule { }
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 bda88bdee..950e256ff 100644
--- a/client/src/app/shared/shared-main/account/actor.model.ts
+++ b/client/src/app/shared/shared-main/account/actor.model.ts
@@ -41,6 +41,13 @@ export abstract class Actor implements ActorServer {
41 return accountName + '@' + host 41 return accountName + '@' + host
42 } 42 }
43 43
44 static IS_LOCAL (host: string) {
45 const absoluteAPIUrl = getAbsoluteAPIUrl()
46 const thisHost = new URL(absoluteAPIUrl).host
47
48 return host.trim() === thisHost
49 }
50
44 protected constructor (hash: ActorServer) { 51 protected constructor (hash: ActorServer) {
45 this.id = hash.id 52 this.id = hash.id
46 this.url = hash.url 53 this.url = hash.url
@@ -53,10 +60,7 @@ export abstract class Actor implements ActorServer {
53 if (hash.updatedAt) this.updatedAt = new Date(hash.updatedAt.toString()) 60 if (hash.updatedAt) this.updatedAt = new Date(hash.updatedAt.toString())
54 61
55 this.avatar = hash.avatar 62 this.avatar = hash.avatar
56 63 this.isLocal = Actor.IS_LOCAL(this.host)
57 const absoluteAPIUrl = getAbsoluteAPIUrl()
58 const thisHost = new URL(absoluteAPIUrl).host
59 this.isLocal = this.host.trim() === thisHost
60 64
61 this.updateComputedAttributes() 65 this.updateComputedAttributes()
62 } 66 }
diff --git a/client/src/app/shared/shared-moderation/abuse.service.ts b/client/src/app/shared/shared-moderation/abuse.service.ts
index 652d8370f..c1aa62023 100644
--- a/client/src/app/shared/shared-moderation/abuse.service.ts
+++ b/client/src/app/shared/shared-moderation/abuse.service.ts
@@ -5,13 +5,24 @@ import { catchError, map } from 'rxjs/operators'
5import { HttpClient, HttpParams } from '@angular/common/http' 5import { HttpClient, HttpParams } from '@angular/common/http'
6import { Injectable } from '@angular/core' 6import { Injectable } from '@angular/core'
7import { RestExtractor, RestPagination, RestService } from '@app/core' 7import { RestExtractor, RestPagination, RestService } from '@app/core'
8import { AdminAbuse, AbuseCreate, AbuseFilter, AbusePredefinedReasonsString, AbuseState, AbuseUpdate, ResultList, UserAbuse, AbuseMessage } from '@shared/models'
9import { environment } from '../../../environments/environment'
10import { I18n } from '@ngx-translate/i18n-polyfill' 8import { I18n } from '@ngx-translate/i18n-polyfill'
9import {
10 AbuseCreate,
11 AbuseFilter,
12 AbuseMessage,
13 AbusePredefinedReasonsString,
14 AbuseState,
15 AbuseUpdate,
16 AdminAbuse,
17 ResultList,
18 UserAbuse
19} from '@shared/models'
20import { environment } from '../../../environments/environment'
11 21
12@Injectable() 22@Injectable()
13export class AbuseService { 23export class AbuseService {
14 private static BASE_ABUSE_URL = environment.apiUrl + '/api/v1/abuses' 24 private static BASE_ABUSE_URL = environment.apiUrl + '/api/v1/abuses'
25 private static BASE_MY_ABUSE_URL = environment.apiUrl + '/api/v1/users/me/abuses'
15 26
16 constructor ( 27 constructor (
17 private i18n: I18n, 28 private i18n: I18n,
@@ -32,33 +43,7 @@ export class AbuseService {
32 params = this.restService.addRestGetParams(params, pagination, sort) 43 params = this.restService.addRestGetParams(params, pagination, sort)
33 44
34 if (search) { 45 if (search) {
35 const filters = this.restService.parseQueryStringFilter(search, { 46 params = this.buildParamsFromSearch(search, params)
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 } 47 }
63 48
64 return this.authHttp.get<ResultList<AdminAbuse>>(url, { params }) 49 return this.authHttp.get<ResultList<AdminAbuse>>(url, { params })
@@ -67,6 +52,27 @@ export class AbuseService {
67 ) 52 )
68 } 53 }
69 54
55 getUserAbuses (options: {
56 pagination: RestPagination,
57 sort: SortMeta,
58 search?: string
59 }): Observable<ResultList<UserAbuse>> {
60 const { pagination, sort, search } = options
61 const url = AbuseService.BASE_MY_ABUSE_URL
62
63 let params = new HttpParams()
64 params = this.restService.addRestGetParams(params, pagination, sort)
65
66 if (search) {
67 params = this.buildParamsFromSearch(search, params)
68 }
69
70 return this.authHttp.get<ResultList<UserAbuse>>(url, { params })
71 .pipe(
72 catchError(res => this.restExtractor.handleError(res))
73 )
74 }
75
70 reportVideo (parameters: AbuseCreate) { 76 reportVideo (parameters: AbuseCreate) {
71 const url = AbuseService.BASE_ABUSE_URL 77 const url = AbuseService.BASE_ABUSE_URL
72 78
@@ -180,4 +186,33 @@ export class AbuseService {
180 return reasons 186 return reasons
181 } 187 }
182 188
189 private buildParamsFromSearch (search: string, params: HttpParams) {
190 const filters = this.restService.parseQueryStringFilter(search, {
191 id: { prefix: '#' },
192 state: {
193 prefix: 'state:',
194 handler: v => {
195 if (v === 'accepted') return AbuseState.ACCEPTED
196 if (v === 'pending') return AbuseState.PENDING
197 if (v === 'rejected') return AbuseState.REJECTED
198
199 return undefined
200 }
201 },
202 videoIs: {
203 prefix: 'videoIs:',
204 handler: v => {
205 if (v === 'deleted') return v
206 if (v === 'blacklisted') return v
207
208 return undefined
209 }
210 },
211 searchReporter: { prefix: 'reporter:' },
212 searchReportee: { prefix: 'reportee:' },
213 predefinedReason: { prefix: 'tag:' }
214 })
215
216 return this.restService.addObjectParams(params, filters)
217 }
183} 218}
diff --git a/client/src/app/shared/shared-moderation/index.ts b/client/src/app/shared/shared-moderation/index.ts
index c8082d4b3..41c910ffe 100644
--- a/client/src/app/shared/shared-moderation/index.ts
+++ b/client/src/app/shared/shared-moderation/index.ts
@@ -1,6 +1,5 @@
1export * from './report-modals' 1export * from './report-modals'
2 2
3export * from './abuse-message-modal.component'
4export * from './abuse.service' 3export * from './abuse.service'
5export * from './account-block.model' 4export * from './account-block.model'
6export * from './account-blocklist.component' 5export * from './account-blocklist.component'
diff --git a/client/src/app/shared/shared-moderation/moderation.scss b/client/src/app/shared/shared-moderation/moderation.scss
new file mode 100644
index 000000000..260346dc5
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/moderation.scss
@@ -0,0 +1,50 @@
1@import 'variables';
2@import 'mixins';
3@import 'miniature';
4
5.moderation-expanded {
6 font-size: 90%;
7
8 .moderation-expanded-label {
9 font-weight: $font-semibold;
10 display: inline-block;
11 vertical-align: top;
12 text-align: right;
13 }
14
15 .moderation-expanded-text {
16 display: inline-flex;
17 word-wrap: break-word;
18
19 ::ng-deep p:last-child {
20 margin-bottom: 0px !important;
21 }
22 }
23}
24
25.input-group {
26 @include peertube-input-group(300px);
27
28 .dropdown-toggle::after {
29 margin-left: 0;
30 }
31}
32
33.chip {
34 @include chip;
35}
36
37.caption {
38 justify-content: flex-end;
39
40 input {
41 @include peertube-input-text(250px);
42 flex-grow: 1;
43 }
44}
45
46my-action-dropdown.show {
47 ::ng-deep .dropdown-root {
48 display: block !important;
49 }
50}
diff --git a/client/src/app/shared/shared-moderation/server-blocklist.component.scss b/client/src/app/shared/shared-moderation/server-blocklist.component.scss
index 9ddb76850..31db4d92b 100644
--- a/client/src/app/shared/shared-moderation/server-blocklist.component.scss
+++ b/client/src/app/shared/shared-moderation/server-blocklist.component.scss
@@ -32,3 +32,16 @@ a {
32.block-button { 32.block-button {
33 @include create-button; 33 @include create-button;
34} 34}
35
36.caption {
37 justify-content: flex-end;
38
39 input {
40 @include peertube-input-text(250px);
41 flex-grow: 1;
42 }
43}
44
45.chip {
46 @include chip;
47}
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 b5b6daf27..b1b98f8d0 100644
--- a/client/src/app/shared/shared-moderation/shared-moderation.module.ts
+++ b/client/src/app/shared/shared-moderation/shared-moderation.module.ts
@@ -4,7 +4,6 @@ import { 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' 6import { SharedVideoCommentModule } from '../shared-video-comment'
7import { AbuseMessageModalComponent } from './abuse-message-modal.component'
8import { AbuseService } from './abuse.service' 7import { AbuseService } from './abuse.service'
9import { BatchDomainsModalComponent } from './batch-domains-modal.component' 8import { BatchDomainsModalComponent } from './batch-domains-modal.component'
10import { BlocklistService } from './blocklist.service' 9import { BlocklistService } from './blocklist.service'
@@ -30,8 +29,7 @@ import { VideoBlockService } from './video-block.service'
30 VideoReportComponent, 29 VideoReportComponent,
31 BatchDomainsModalComponent, 30 BatchDomainsModalComponent,
32 CommentReportComponent, 31 CommentReportComponent,
33 AccountReportComponent, 32 AccountReportComponent
34 AbuseMessageModalComponent
35 ], 33 ],
36 34
37 exports: [ 35 exports: [
@@ -41,8 +39,7 @@ import { VideoBlockService } from './video-block.service'
41 VideoReportComponent, 39 VideoReportComponent,
42 BatchDomainsModalComponent, 40 BatchDomainsModalComponent,
43 CommentReportComponent, 41 CommentReportComponent,
44 AccountReportComponent, 42 AccountReportComponent
45 AbuseMessageModalComponent
46 ], 43 ],
47 44
48 providers: [ 45 providers: [