aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2020-07-09 11:58:46 +0200
committerChocobozzz <chocobozzz@cpy.re>2020-07-10 14:02:41 +0200
commit8ca56654a176ee8f350d31282c6cac4a59f58499 (patch)
tree6e52ed0d8410abfceb62bcb6230b8ed50bd6c574
parent310b5219b38427f0c2c7ba57225afdd8f3064380 (diff)
downloadPeerTube-8ca56654a176ee8f350d31282c6cac4a59f58499.tar.gz
PeerTube-8ca56654a176ee8f350d31282c6cac4a59f58499.tar.zst
PeerTube-8ca56654a176ee8f350d31282c6cac4a59f58499.zip
Add ability to report comments in front end
-rw-r--r--client/src/app/+admin/admin.component.ts4
-rw-r--r--client/src/app/+admin/moderation/abuse-list/abuse-details.component.html54
-rw-r--r--client/src/app/+admin/moderation/abuse-list/abuse-details.component.ts5
-rw-r--r--client/src/app/+admin/moderation/abuse-list/abuse-list.component.html89
-rw-r--r--client/src/app/+admin/moderation/abuse-list/abuse-list.component.scss2
-rw-r--r--client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts77
-rw-r--r--client/src/app/+admin/moderation/moderation.component.scss41
-rw-r--r--client/src/app/+admin/moderation/moderation.routes.ts2
-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.ts27
-rw-r--r--client/src/app/shared/shared-main/account/actor.model.ts6
-rw-r--r--client/src/app/shared/shared-moderation/abuse.service.ts96
-rw-r--r--client/src/app/shared/shared-moderation/comment-report.component.html62
-rw-r--r--client/src/app/shared/shared-moderation/comment-report.component.scss11
-rw-r--r--client/src/app/shared/shared-moderation/comment-report.component.ts93
-rw-r--r--client/src/app/shared/shared-moderation/shared-moderation.module.ts7
-rw-r--r--client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts7
-rw-r--r--client/src/app/shared/shared-moderation/video-report.component.html7
-rw-r--r--client/src/app/shared/shared-moderation/video-report.component.ts43
-rw-r--r--server/models/abuse/abuse.ts4
-rw-r--r--server/models/video/video-comment.ts2
-rw-r--r--shared/models/moderation/abuse/abuse.model.ts1
22 files changed, 485 insertions, 160 deletions
diff --git a/client/src/app/+admin/admin.component.ts b/client/src/app/+admin/admin.component.ts
index 87ed33a45..4345d1945 100644
--- a/client/src/app/+admin/admin.component.ts
+++ b/client/src/app/+admin/admin.component.ts
@@ -47,8 +47,8 @@ export class AdminComponent implements OnInit {
47 47
48 if (this.hasAbusesRight()) { 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 }
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
index d031ea8ed..cba9cfb73 100644
--- a/client/src/app/+admin/moderation/abuse-list/abuse-details.component.html
+++ b/client/src/app/+admin/moderation/abuse-list/abuse-details.component.html
@@ -3,10 +3,13 @@
3 <div class="col-8"> 3 <div class="col-8">
4 4
5 <!-- report metadata --> 5 <!-- report metadata -->
6 <div class="d-flex"> 6 <div class="d-flex" *ngIf="abuse.reporterAccount">
7 <span class="col-3 moderation-expanded-label" i18n>Reporter</span> 7 <span class="col-3 moderation-expanded-label" i18n>Reporter</span>
8
8 <span class="col-9 moderation-expanded-text"> 9 <span class="col-9 moderation-expanded-text">
9 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }" class="chip"> 10 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }"
11 class="chip"
12 >
10 <img 13 <img
11 class="avatar" 14 class="avatar"
12 [src]="abuse.reporterAccount.avatar?.path" 15 [src]="abuse.reporterAccount.avatar?.path"
@@ -17,27 +20,35 @@
17 <span class="text-muted">{{ abuse.reporterAccount.nameWithHost }}</span> 20 <span class="text-muted">{{ abuse.reporterAccount.nameWithHost }}</span>
18 </div> 21 </div>
19 </a> 22 </a>
20 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }" class="ml-auto text-muted video-details-links" i18n> 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 >
21 {abuse.countReportsForReporter, plural, =1 {1 report} other {{{ abuse.countReportsForReporter }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span> 27 {abuse.countReportsForReporter, plural, =1 {1 report} other {{{ abuse.countReportsForReporter }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
22 </a> 28 </a>
23 </span> 29 </span>
24 </div> 30 </div>
25 31
26 <div class="d-flex"> 32 <div class="d-flex" *ngIf="abuse.flaggedAccount">
27 <span class="col-3 moderation-expanded-label" i18n>Reportee</span> 33 <span class="col-3 moderation-expanded-label" i18n>Reportee</span>
28 <span class="col-9 moderation-expanded-text"> 34 <span class="col-9 moderation-expanded-text">
29 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.video.channel.ownerAccount.displayName + '&quot;' }" class="chip"> 35 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }"
36 class="chip"
37 >
30 <img 38 <img
31 class="avatar" 39 class="avatar"
32 [src]="abuse.video.channel.ownerAccount?.avatar?.path" 40 [src]="abuse.flaggedAccount?.avatar?.path"
33 (error)="switchToDefaultAvatar($event)" 41 (error)="switchToDefaultAvatar($event)"
34 alt="Avatar" 42 alt="Avatar"
35 > 43 >
36 <div> 44 <div>
37 <span class="text-muted">{{ abuse.video.channel.ownerAccount ? abuse.video.channel.ownerAccount.nameWithHost : '' }}</span> 45 <span class="text-muted">{{ abuse.flaggedAccount ? abuse.flaggedAccount.nameWithHost : '' }}</span>
38 </div> 46 </div>
39 </a> 47 </a>
40 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.video.channel.ownerAccount.displayName + '&quot;' }" class="ml-auto text-muted video-details-links" i18n> 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 >
41 {abuse.countReportsForReportee, plural, =1 {1 report} other {{{ abuse.countReportsForReportee }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span> 52 {abuse.countReportsForReportee, plural, =1 {1 report} other {{{ abuse.countReportsForReportee }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
42 </a> 53 </a>
43 </span> 54 </span>
@@ -45,7 +56,7 @@
45 56
46 <div class="d-flex" *ngIf="abuse.updatedAt"> 57 <div class="d-flex" *ngIf="abuse.updatedAt">
47 <span class="col-3 moderation-expanded-label" i18n>Updated</span> 58 <span class="col-3 moderation-expanded-label" i18n>Updated</span>
48 <time class="col-9 moderation-expanded-text video-details-date-updated">{{ abuse.updatedAt | date: 'medium' }}</time> 59 <time class="col-9 moderation-expanded-text abuse-details-date-updated">{{ abuse.updatedAt | date: 'medium' }}</time>
49 </div> 60 </div>
50 61
51 <!-- report text --> 62 <!-- report text -->
@@ -60,34 +71,45 @@
60 <div *ngIf="getPredefinedReasons()" class="mt-2 d-flex"> 71 <div *ngIf="getPredefinedReasons()" class="mt-2 d-flex">
61 <span class="col-3"></span> 72 <span class="col-3"></span>
62 <span class="col-9"> 73 <span class="col-9">
63 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'tag:' + reason.id }" class="chip rectangular bg-secondary text-light" *ngFor="let reason of getPredefinedReasons()"> 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 >
64 <div>{{ reason.label }}</div> 77 <div>{{ reason.label }}</div>
65 </a> 78 </a>
66 </span> 79 </span>
67 </div> 80 </div>
68 81
69 <div *ngIf="abuse.startAt" class="mt-2 d-flex"> 82 <div *ngIf="abuse.video?.startAt" class="mt-2 d-flex">
70 <span class="col-3 moderation-expanded-label" i18n>Reported part</span> 83 <span class="col-3 moderation-expanded-label" i18n>Reported part</span>
71 <span class="col-9"> 84 <span class="col-9">
72 {{ startAt }}<ng-container *ngIf="abuse.endAt"> - {{ endAt }}</ng-container> 85 {{ startAt }}<ng-container *ngIf="abuse.video.endAt"> - {{ endAt }}</ng-container>
73 </span> 86 </span>
74 </div> 87 </div>
75 88
76 <div class="mt-3 d-flex" *ngIf="abuse.moderationComment"> 89 <div class="mt-3 d-flex" *ngIf="abuse.moderationComment">
77 <span class="col-3 moderation-expanded-label" i18n>Note</span> 90 <span class="col-3 moderation-expanded-label" i18n>Note</span>
78 <span class="col-9 moderation-expanded-text" [innerHTML]="abuse.moderationCommentHtml"></span> 91 <span class="col-9 moderation-expanded-text d-block" [innerHTML]="abuse.moderationCommentHtml"></span>
79 </div> 92 </div>
80 93
81 </div> 94 </div>
82 95
83 <!-- report right part (video details) --> 96 <!-- report right part (video/comment details) -->
84 <div class="col-4"> 97 <div class="col-4">
85 <div class="screenratio"> 98 <div *ngIf="abuse.video" class="screenratio">
86 <div *ngIf="abuse.video.deleted || abuse.video.blacklisted"> 99 <div>
87 <span i18n *ngIf="abuse.video.deleted">The video was deleted</span> 100 <span i18n *ngIf="abuse.video.deleted">The video was deleted</span>
88 <span i18n *ngIf="!abuse.video.deleted">The video was blocked</span> 101 <span i18n *ngIf="!abuse.video.deleted">The video was blocked</span>
89 </div> 102 </div>
103
90 <div *ngIf="!abuse.video.deleted && !abuse.video.blacklisted" [innerHTML]="abuse.embedHtml"></div> 104 <div *ngIf="!abuse.video.deleted && !abuse.video.blacklisted" [innerHTML]="abuse.embedHtml"></div>
91 </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>
92 </div> 114 </div>
93</div> 115</div>
diff --git a/client/src/app/+admin/moderation/abuse-list/abuse-details.component.ts b/client/src/app/+admin/moderation/abuse-list/abuse-details.component.ts
index 8f87630b8..fb0f65764 100644
--- a/client/src/app/+admin/moderation/abuse-list/abuse-details.component.ts
+++ b/client/src/app/+admin/moderation/abuse-list/abuse-details.component.ts
@@ -31,15 +31,16 @@ export class AbuseDetailsComponent {
31 } 31 }
32 32
33 get startAt () { 33 get startAt () {
34 return durationToString(this.abuse.startAt) 34 return durationToString(this.abuse.video.startAt)
35 } 35 }
36 36
37 get endAt () { 37 get endAt () {
38 return durationToString(this.abuse.endAt) 38 return durationToString(this.abuse.video.endAt)
39 } 39 }
40 40
41 getPredefinedReasons () { 41 getPredefinedReasons () {
42 if (!this.abuse.predefinedReasons) return [] 42 if (!this.abuse.predefinedReasons) return []
43
43 return this.abuse.predefinedReasons.map(r => ({ 44 return this.abuse.predefinedReasons.map(r => ({
44 id: r, 45 id: r,
45 label: this.predefinedReasonsTranslations[r] 46 label: this.predefinedReasonsTranslations[r]
diff --git a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html
index 333438269..1ad73e38a 100644
--- a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html
+++ b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html
@@ -38,7 +38,7 @@
38 <tr> <!-- header --> 38 <tr> <!-- header -->
39 <th style="width: 40px;"></th> 39 <th style="width: 40px;"></th>
40 <th style="width: 20%;" pResizableColumn i18n>Reporter</th> 40 <th style="width: 20%;" pResizableColumn i18n>Reporter</th>
41 <th i18n>Video</th> 41 <th i18n>Video/Comment/Account</th>
42 <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></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> 43 <th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
44 <th style="width: 150px;"></th> 44 <th style="width: 150px;"></th>
@@ -54,7 +54,7 @@
54 </td> 54 </td>
55 55
56 <td> 56 <td>
57 <a [href]="abuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer"> 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"> 58 <div class="chip two-lines">
59 <img 59 <img
60 class="avatar" 60 class="avatar"
@@ -64,54 +64,73 @@
64 > 64 >
65 <div> 65 <div>
66 {{ abuse.reporterAccount.displayName }} 66 {{ abuse.reporterAccount.displayName }}
67 <span class="text-muted">{{ abuse.reporterAccount.nameWithHost }}</span> 67 <span>{{ abuse.reporterAccount.nameWithHost }}</span>
68 </div> 68 </div>
69 </div> 69 </div>
70 </a> 70 </a>
71
72 <span i18n *ngIf="!abuse.reporterAccount">
73 Deleted account
74 </span>
71 </td> 75 </td>
72 76
73 <td *ngIf="!abuse.video.deleted"> 77 <ng-container *ngIf="abuse.video">
74 <a [href]="getVideoUrl(abuse)" class="video-table-video-link" [title]="abuse.video.name" target="_blank" rel="noopener noreferrer"> 78
75 <div class="video-table-video"> 79 <td *ngIf="!abuse.video.deleted">
76 <div class="video-table-video-image"> 80 <a [href]="getVideoUrl(abuse)" class="table-video-link" [title]="abuse.video.name" target="_blank" rel="noopener noreferrer">
77 <img [src]="abuse.video.thumbnailPath"> 81 <div class="table-video">
78 <span 82 <div class="table-video-image">
79 class="video-table-video-image-label" *ngIf="abuse.count > 1" 83 <img [src]="abuse.video.thumbnailPath">
80 i18n-title title="This video has been reported multiple times." 84 <span
81 > 85 class="table-video-image-label" *ngIf="abuse.count > 1"
82 {{ abuse.nth }}/{{ abuse.count }} 86 i18n-title title="This video has been reported multiple times."
83 </span> 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>
84 </div> 100 </div>
85 <div class="video-table-video-text"> 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">
86 <div> 111 <div>
87 <span *ngIf="!abuse.video.blacklisted" class="glyphicon glyphicon-new-window"></span>
88 <span *ngIf="abuse.video.blacklisted" i18n-title title="The video was blocked" class="glyphicon glyphicon-ban-circle"></span>
89 {{ abuse.video.name }} 112 {{ abuse.video.name }}
113 <span class="glyphicon glyphicon-trash"></span>
90 </div> 114 </div>
91 <div class="text-muted" i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div> 115 <div i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
92 </div> 116 </div>
93 </div> 117 </div>
94 </a> 118 </td>
95 </td> 119 </ng-container>
96 120
97 <td *ngIf="abuse.video.deleted" class="c-hand" [pRowToggler]="abuse"> 121 <ng-container *ngIf="abuse.comment">
98 <div class="video-table-video" i18n-title title="Video was deleted"> 122 <td>
99 <div class="video-table-video-image"> 123 <a [href]="getCommentUrl(abuse)" [innerHTML]="abuse.truncatedCommentHtml" class="table-comment-link"
100 <span i18n>Deleted</span> 124 [title]="abuse.comment.video.name" target="_blank" rel="noopener noreferrer"
101 </div> 125 ></a>
102 <div class="video-table-video-text"> 126
103 <div> 127 <div class="comment-flagged-account" *ngIf="abuse.flaggedAccount">by {{ abuse.flaggedAccount.displayName }}</div>
104 {{ abuse.video.name }} 128 </td>
105 <span class="glyphicon glyphicon-trash"></span> 129 </ng-container>
106 </div>
107 <div class="text-muted" i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
108 </div>
109 </div>
110 </td>
111 130
112 <td class="c-hand" [pRowToggler]="abuse">{{ abuse.createdAt | date: 'short' }}</td> 131 <td class="c-hand" [pRowToggler]="abuse">{{ abuse.createdAt | date: 'short' }}</td>
113 132
114 <td class="c-hand video-abuse-states" [pRowToggler]="abuse"> 133 <td class="c-hand abuse-states" [pRowToggler]="abuse">
115 <span *ngIf="isAbuseAccepted(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-ok"></span> 134 <span *ngIf="isAbuseAccepted(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-ok"></span>
116 <span *ngIf="isAbuseRejected(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-remove"></span> 135 <span *ngIf="isAbuseRejected(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-remove"></span>
117 <span *ngIf="abuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="abuse.moderationComment" class="glyphicon glyphicon-comment"></span> 136 <span *ngIf="abuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="abuse.moderationComment" class="glyphicon glyphicon-comment"></span>
diff --git a/client/src/app/+admin/moderation/abuse-list/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/abuse-list/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
index 427ec4d5d..1ea61ed37 100644
--- a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts
+++ b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts
@@ -2,7 +2,7 @@ import { SortMeta } from 'primeng/api'
2import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils' 2import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
3import { environment } from 'src/environments/environment' 3import { environment } from 'src/environments/environment'
4import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core' 4import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'
5import { DomSanitizer } from '@angular/platform-browser' 5import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
6import { ActivatedRoute, Params, Router } from '@angular/router' 6import { ActivatedRoute, Params, Router } from '@angular/router'
7import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core' 7import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
8import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main' 8import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main'
@@ -10,15 +10,20 @@ import { AbuseService, BlocklistService, VideoBlockService } from '@app/shared/s
10import { I18n } from '@ngx-translate/i18n-polyfill' 10import { I18n } from '@ngx-translate/i18n-polyfill'
11import { Abuse, AbuseState } from '@shared/models' 11import { Abuse, AbuseState } from '@shared/models'
12import { ModerationCommentModalComponent } from './moderation-comment-modal.component' 12import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
13import truncate from 'lodash-es/truncate'
13 14
14export type ProcessedAbuse = Abuse & { 15export type ProcessedAbuse = Abuse & {
15 moderationCommentHtml?: string, 16 moderationCommentHtml?: string,
16 reasonHtml?: string 17 reasonHtml?: string
17 embedHtml?: string 18 embedHtml?: SafeHtml
18 updatedAt?: Date 19 updatedAt?: Date
19 20
20 // override bare server-side definitions with rich client-side definitions 21 // override bare server-side definitions with rich client-side definitions
21 reporterAccount: Account 22 reporterAccount?: Account
23 flaggedAccount?: Account
24
25 truncatedCommentHtml?: string
26 commentHtml?: string
22 27
23 video: Abuse['video'] & { 28 video: Abuse['video'] & {
24 channel: Abuse['video']['channel'] & { 29 channel: Abuse['video']['channel'] & {
@@ -92,11 +97,11 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
92 { 97 {
93 label: this.i18n('Actions for the video'), 98 label: this.i18n('Actions for the video'),
94 isHeader: true, 99 isHeader: true,
95 isDisplayed: abuse => !abuse.video.deleted 100 isDisplayed: abuse => abuse.video && !abuse.video.deleted
96 }, 101 },
97 { 102 {
98 label: this.i18n('Block video'), 103 label: this.i18n('Block video'),
99 isDisplayed: abuse => !abuse.video.deleted && !abuse.video.blacklisted, 104 isDisplayed: abuse => abuse.video && !abuse.video.deleted && !abuse.video.blacklisted,
100 handler: abuse => { 105 handler: abuse => {
101 this.videoBlocklistService.blockVideo(abuse.video.id, undefined, true) 106 this.videoBlocklistService.blockVideo(abuse.video.id, undefined, true)
102 .subscribe( 107 .subscribe(
@@ -112,7 +117,7 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
112 }, 117 },
113 { 118 {
114 label: this.i18n('Unblock video'), 119 label: this.i18n('Unblock video'),
115 isDisplayed: abuse => !abuse.video.deleted && abuse.video.blacklisted, 120 isDisplayed: abuse => abuse.video && !abuse.video.deleted && abuse.video.blacklisted,
116 handler: abuse => { 121 handler: abuse => {
117 this.videoBlocklistService.unblockVideo(abuse.video.id) 122 this.videoBlocklistService.unblockVideo(abuse.video.id)
118 .subscribe( 123 .subscribe(
@@ -128,7 +133,7 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
128 }, 133 },
129 { 134 {
130 label: this.i18n('Delete video'), 135 label: this.i18n('Delete video'),
131 isDisplayed: abuse => !abuse.video.deleted, 136 isDisplayed: abuse => abuse.video && !abuse.video.deleted,
132 handler: async abuse => { 137 handler: async abuse => {
133 const res = await this.confirmService.confirm( 138 const res = await this.confirmService.confirm(
134 this.i18n('Do you really want to delete this video?'), 139 this.i18n('Do you really want to delete this video?'),
@@ -152,10 +157,12 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
152 [ 157 [
153 { 158 {
154 label: this.i18n('Actions for the reporter'), 159 label: this.i18n('Actions for the reporter'),
155 isHeader: true 160 isHeader: true,
161 isDisplayed: abuse => !!abuse.reporterAccount
156 }, 162 },
157 { 163 {
158 label: this.i18n('Mute reporter'), 164 label: this.i18n('Mute reporter'),
165 isDisplayed: abuse => !!abuse.reporterAccount,
159 handler: async abuse => { 166 handler: async abuse => {
160 const account = abuse.reporterAccount as Account 167 const account = abuse.reporterAccount as Account
161 168
@@ -175,7 +182,7 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
175 }, 182 },
176 { 183 {
177 label: this.i18n('Mute server'), 184 label: this.i18n('Mute server'),
178 isDisplayed: abuse => !abuse.reporterAccount.userId, 185 isDisplayed: abuse => abuse.reporterAccount && !abuse.reporterAccount.userId,
179 handler: async abuse => { 186 handler: async abuse => {
180 this.blocklistService.blockServerByInstance(abuse.reporterAccount.host) 187 this.blocklistService.blockServerByInstance(abuse.reporterAccount.host)
181 .subscribe( 188 .subscribe(
@@ -231,7 +238,7 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
231 const queryParams: Params = {} 238 const queryParams: Params = {}
232 if (search) Object.assign(queryParams, { search }) 239 if (search) Object.assign(queryParams, { search })
233 240
234 this.router.navigate([ '/admin/moderation/video-abuses/list' ], { queryParams }) 241 this.router.navigate([ '/admin/moderation/abuses/list' ], { queryParams })
235 } 242 }
236 243
237 resetTableFilter () { 244 resetTableFilter () {
@@ -253,6 +260,10 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
253 return Video.buildClientUrl(abuse.video.uuid) 260 return Video.buildClientUrl(abuse.video.uuid)
254 } 261 }
255 262
263 getCommentUrl (abuse: Abuse) {
264 return Video.buildClientUrl(abuse.comment.video.uuid) + ';threadId=' + abuse.comment.threadId
265 }
266
256 getVideoEmbed (abuse: Abuse) { 267 getVideoEmbed (abuse: Abuse) {
257 return buildVideoEmbed( 268 return buildVideoEmbed(
258 buildVideoLink({ 269 buildVideoLink({
@@ -300,23 +311,45 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
300 }).subscribe( 311 }).subscribe(
301 async resultList => { 312 async resultList => {
302 this.totalRecords = resultList.total 313 this.totalRecords = resultList.total
303 const abuses = []
304 314
305 for (const abuse of resultList.data) { 315 this.abuses = []
306 Object.assign(abuse, { 316
307 reasonHtml: await this.toHtml(abuse.reason), 317 for (const a of resultList.data) {
308 moderationCommentHtml: await this.toHtml(abuse.moderationComment), 318 const abuse = a as ProcessedAbuse
309 embedHtml: this.sanitizer.bypassSecurityTrustHtml(this.getVideoEmbed(abuse)), 319
310 reporterAccount: new Account(abuse.reporterAccount) 320 abuse.reasonHtml = await this.toHtml(abuse.reason)
311 }) 321 abuse.moderationCommentHtml = await this.toHtml(abuse.moderationComment)
322
323 if (abuse.video) {
324 abuse.embedHtml = this.sanitizer.bypassSecurityTrustHtml(this.getVideoEmbed(abuse))
325
326 if (abuse.video.channel?.ownerAccount) {
327 abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount)
328 }
329 }
330
331 if (abuse.comment) {
332 if (abuse.comment.deleted) {
333 abuse.truncatedCommentHtml = abuse.commentHtml = this.i18n('Deleted comment')
334 } else {
335 const truncated = truncate(abuse.comment.text, { length: 100 })
336 abuse.truncatedCommentHtml = await this.markdownRenderer.textMarkdownToHTML(truncated, true)
337 abuse.commentHtml = await this.markdownRenderer.textMarkdownToHTML(abuse.comment.text, true)
338 }
339 }
340
341 if (abuse.reporterAccount) {
342 abuse.reporterAccount = new Account(abuse.reporterAccount)
343 }
344
345 if (abuse.flaggedAccount) {
346 abuse.flaggedAccount = new Account(abuse.flaggedAccount)
347 }
312 348
313 if (abuse.video.channel?.ownerAccount) abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount)
314 if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt 349 if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt
315 350
316 abuses.push(abuse as ProcessedAbuse) 351 this.abuses.push(abuse)
317 } 352 }
318
319 this.abuses = abuses
320 }, 353 },
321 354
322 err => this.notifier.error(err.message) 355 err => this.notifier.error(err.message)
diff --git a/client/src/app/+admin/moderation/moderation.component.scss b/client/src/app/+admin/moderation/moderation.component.scss
index 0ec420af9..f73c71dc5 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,32 @@ 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 @include disable-outline;
101
102 color: var(--mainForegroundColor);
103
104 ::ng-deep p:last-child {
105 margin: 0;
106 }
107}
108
109.comment-flagged-account {
110 font-size: 11px;
111 color: var(--greyForegroundColor);
112}
113
114.table-video {
93 display: inline-flex; 115 display: inline-flex;
94 116
95 .video-table-video-image { 117 .table-video-image {
96 @include miniature-thumbnail; 118 @include miniature-thumbnail;
97 119
98 $image-height: 45px; 120 $image-height: 45px;
@@ -118,7 +140,7 @@ my-action-dropdown.show {
118 color: pvar(--inputPlaceholderColor); 140 color: pvar(--inputPlaceholderColor);
119 } 141 }
120 142
121 .video-table-video-image-label { 143 .table-video-image-label {
122 @include static-thumbnail-overlay; 144 @include static-thumbnail-overlay;
123 position: absolute; 145 position: absolute;
124 border-radius: 3px; 146 border-radius: 3px;
@@ -130,7 +152,7 @@ my-action-dropdown.show {
130 } 152 }
131 } 153 }
132 154
133 .video-table-video-text { 155 .table-video-text {
134 display: inline-flex; 156 display: inline-flex;
135 flex-direction: column; 157 flex-direction: column;
136 justify-content: center; 158 justify-content: center;
@@ -145,7 +167,8 @@ my-action-dropdown.show {
145 } 167 }
146 168
147 div + div { 169 div + div {
148 font-size: 80%; 170 color: var(--greyForegroundColor);
171 font-size: 11px;
149 } 172 }
150 } 173 }
151} 174}
diff --git a/client/src/app/+admin/moderation/moderation.routes.ts b/client/src/app/+admin/moderation/moderation.routes.ts
index 1e207e5e8..8a31a54dc 100644
--- a/client/src/app/+admin/moderation/moderation.routes.ts
+++ b/client/src/app/+admin/moderation/moderation.routes.ts
@@ -33,7 +33,7 @@ export const ModerationRoutes: Routes = [
33 data: { 33 data: {
34 userRight: UserRight.MANAGE_ABUSES, 34 userRight: UserRight.MANAGE_ABUSES,
35 meta: { 35 meta: {
36 title: 'Video reports' 36 title: 'Reports'
37 } 37 }
38 } 38 }
39 }, 39 },
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..2a4a6e737 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,7 +1,10 @@
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/comment-report.component'
7import { I18n } from '@ngx-translate/i18n-polyfill'
5import { User, UserRight } from '@shared/models' 8import { User, UserRight } from '@shared/models'
6import { VideoCommentThreadTree } from './video-comment-thread-tree.model' 9import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
7import { VideoComment } from './video-comment.model' 10import { VideoComment } from './video-comment.model'
@@ -12,6 +15,8 @@ import { VideoComment } from './video-comment.model'
12 styleUrls: ['./video-comment.component.scss'] 15 styleUrls: ['./video-comment.component.scss']
13}) 16})
14export class VideoCommentComponent implements OnInit, OnChanges { 17export class VideoCommentComponent implements OnInit, OnChanges {
18 @ViewChild('commentReportModal') commentReportModal: CommentReportComponent
19
15 @Input() video: Video 20 @Input() video: Video
16 @Input() comment: VideoComment 21 @Input() comment: VideoComment
17 @Input() parentComments: VideoComment[] = [] 22 @Input() parentComments: VideoComment[] = []
@@ -26,6 +31,8 @@ export class VideoCommentComponent implements OnInit, OnChanges {
26 @Output() resetReply = new EventEmitter() 31 @Output() resetReply = new EventEmitter()
27 @Output() timestampClicked = new EventEmitter<number>() 32 @Output() timestampClicked = new EventEmitter<number>()
28 33
34 prependModerationActions: DropdownAction<any>[]
35
29 sanitizedCommentHTML = '' 36 sanitizedCommentHTML = ''
30 newParentComments: VideoComment[] = [] 37 newParentComments: VideoComment[] = []
31 38
@@ -33,6 +40,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
33 commentUser: User 40 commentUser: User
34 41
35 constructor ( 42 constructor (
43 private i18n: I18n,
36 private markdownService: MarkdownService, 44 private markdownService: MarkdownService,
37 private authService: AuthService, 45 private authService: AuthService,
38 private userService: UserService, 46 private userService: UserService,
@@ -127,5 +135,20 @@ export class VideoCommentComponent implements OnInit, OnChanges {
127 } else { 135 } else {
128 this.comment.account = null 136 this.comment.account = null
129 } 137 }
138
139 if (this.isUserLoggedIn()) {
140 this.prependModerationActions = [
141 {
142 label: this.i18n('Report comment'),
143 handler: () => this.showReportModal()
144 }
145 ]
146 } else {
147 this.prependModerationActions = undefined
148 }
149 }
150
151 private showReportModal () {
152 this.commentReportModal.show()
130 } 153 }
131} 154}
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 0fa161ce6..9ec6dbab1 100644
--- a/client/src/app/shared/shared-main/account/actor.model.ts
+++ b/client/src/app/shared/shared-main/account/actor.model.ts
@@ -46,8 +46,10 @@ export abstract class Actor implements ActorServer {
46 this.host = hash.host 46 this.host = hash.host
47 this.followingCount = hash.followingCount 47 this.followingCount = hash.followingCount
48 this.followersCount = hash.followersCount 48 this.followersCount = hash.followersCount
49 this.createdAt = new Date(hash.createdAt.toString()) 49
50 this.updatedAt = new Date(hash.updatedAt.toString()) 50 if (hash.createdAt) this.createdAt = new Date(hash.createdAt.toString())
51 if (hash.updatedAt) this.updatedAt = new Date(hash.updatedAt.toString())
52
51 this.avatar = hash.avatar 53 this.avatar = hash.avatar
52 54
53 this.updateComputedAttributes() 55 this.updateComputedAttributes()
diff --git a/client/src/app/shared/shared-moderation/abuse.service.ts b/client/src/app/shared/shared-moderation/abuse.service.ts
index f45018d5c..95ac16955 100644
--- a/client/src/app/shared/shared-moderation/abuse.service.ts
+++ b/client/src/app/shared/shared-moderation/abuse.service.ts
@@ -5,18 +5,20 @@ 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 { AbuseUpdate, ResultList, Abuse, AbuseCreate, AbuseState } from '@shared/models' 8import { Abuse, AbuseCreate, AbuseFilter, AbusePredefinedReasonsString, AbuseState, AbuseUpdate, ResultList } from '@shared/models'
9import { environment } from '../../../environments/environment' 9import { environment } from '../../../environments/environment'
10import { I18n } from '@ngx-translate/i18n-polyfill'
10 11
11@Injectable() 12@Injectable()
12export class AbuseService { 13export class AbuseService {
13 private static BASE_ABUSE_URL = environment.apiUrl + '/api/v1/abuses' 14 private static BASE_ABUSE_URL = environment.apiUrl + '/api/v1/abuses'
14 15
15 constructor ( 16 constructor (
17 private i18n: I18n,
16 private authHttp: HttpClient, 18 private authHttp: HttpClient,
17 private restService: RestService, 19 private restService: RestService,
18 private restExtractor: RestExtractor 20 private restExtractor: RestExtractor
19 ) {} 21 ) { }
20 22
21 getAbuses (options: { 23 getAbuses (options: {
22 pagination: RestPagination, 24 pagination: RestPagination,
@@ -24,7 +26,7 @@ export class AbuseService {
24 search?: string 26 search?: string
25 }): Observable<ResultList<Abuse>> { 27 }): Observable<ResultList<Abuse>> {
26 const { pagination, sort, search } = options 28 const { pagination, sort, search } = options
27 const url = AbuseService.BASE_ABUSE_URL + 'abuse' 29 const url = AbuseService.BASE_ABUSE_URL
28 30
29 let params = new HttpParams() 31 let params = new HttpParams()
30 params = this.restService.addRestGetParams(params, pagination, sort) 32 params = this.restService.addRestGetParams(params, pagination, sort)
@@ -60,39 +62,93 @@ export class AbuseService {
60 } 62 }
61 63
62 return this.authHttp.get<ResultList<Abuse>>(url, { params }) 64 return this.authHttp.get<ResultList<Abuse>>(url, { params })
63 .pipe( 65 .pipe(
64 catchError(res => this.restExtractor.handleError(res)) 66 catchError(res => this.restExtractor.handleError(res))
65 ) 67 )
66 } 68 }
67 69
68 reportVideo (parameters: AbuseCreate) { 70 reportVideo (parameters: AbuseCreate) {
69 const url = AbuseService.BASE_ABUSE_URL 71 const url = AbuseService.BASE_ABUSE_URL
70 72
71 const body = omit(parameters, [ 'id' ]) 73 const body = omit(parameters, ['id'])
72 74
73 return this.authHttp.post(url, body) 75 return this.authHttp.post(url, body)
74 .pipe( 76 .pipe(
75 map(this.restExtractor.extractDataBool), 77 map(this.restExtractor.extractDataBool),
76 catchError(res => this.restExtractor.handleError(res)) 78 catchError(res => this.restExtractor.handleError(res))
77 ) 79 )
78 } 80 }
79 81
80 updateAbuse (abuse: Abuse, abuseUpdate: AbuseUpdate) { 82 updateAbuse (abuse: Abuse, abuseUpdate: AbuseUpdate) {
81 const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id 83 const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
82 84
83 return this.authHttp.put(url, abuseUpdate) 85 return this.authHttp.put(url, abuseUpdate)
84 .pipe( 86 .pipe(
85 map(this.restExtractor.extractDataBool), 87 map(this.restExtractor.extractDataBool),
86 catchError(res => this.restExtractor.handleError(res)) 88 catchError(res => this.restExtractor.handleError(res))
87 ) 89 )
88 } 90 }
89 91
90 removeAbuse (abuse: Abuse) { 92 removeAbuse (abuse: Abuse) {
91 const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id 93 const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
92 94
93 return this.authHttp.delete(url) 95 return this.authHttp.delete(url)
94 .pipe( 96 .pipe(
95 map(this.restExtractor.extractDataBool), 97 map(this.restExtractor.extractDataBool),
96 catchError(res => this.restExtractor.handleError(res)) 98 catchError(res => this.restExtractor.handleError(res))
97 ) 99 )
98 }} 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/comment-report.component.html b/client/src/app/shared/shared-moderation/comment-report.component.html
new file mode 100644
index 000000000..1105b3788
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/comment-report.component.html
@@ -0,0 +1,62 @@
1<ng-template #modal>
2 <div class="modal-header">
3 <h4 i18n class="modal-title">Report comment</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="isRemoteComment()"> 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/comment-report.component.scss b/client/src/app/shared/shared-moderation/comment-report.component.scss
new file mode 100644
index 000000000..17a33d3a2
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/comment-report.component.scss
@@ -0,0 +1,11 @@
1@import 'variables';
2@import 'mixins';
3
4.information {
5 margin-bottom: 20px;
6}
7
8textarea {
9 @include peertube-textarea(100%, 100px);
10}
11
diff --git a/client/src/app/shared/shared-moderation/comment-report.component.ts b/client/src/app/shared/shared-moderation/comment-report.component.ts
new file mode 100644
index 000000000..5db4b2dc1
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/comment-report.component.ts
@@ -0,0 +1,93 @@
1import { mapValues, pickBy } from 'lodash-es'
2import { Component, Input, OnInit, ViewChild } from '@angular/core'
3import { SafeHtml } from '@angular/platform-browser'
4import { VideoComment } from '@app/+videos/+video-watch/comment/video-comment.model'
5import { Notifier } from '@app/core'
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 { AbuseService } from './abuse.service'
12
13@Component({
14 selector: 'my-comment-report',
15 templateUrl: './comment-report.component.html',
16 styleUrls: [ './comment-report.component.scss' ]
17})
18export class CommentReportComponent extends FormReactive implements OnInit {
19 @Input() comment: VideoComment = null
20
21 @ViewChild('modal', { static: true }) modal: NgbModal
22
23 error: string = null
24 predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
25 embedHtml: SafeHtml
26
27 private openedModal: NgbModalRef
28
29 constructor (
30 protected formValidatorService: FormValidatorService,
31 private modalService: NgbModal,
32 private abuseValidatorsService: AbuseValidatorsService,
33 private abuseService: AbuseService,
34 private notifier: Notifier,
35 private i18n: I18n
36 ) {
37 super()
38 }
39
40 get currentHost () {
41 return window.location.host
42 }
43
44 get originHost () {
45 if (this.isRemoteComment()) {
46 return this.comment.account.host
47 }
48
49 return ''
50 }
51
52 ngOnInit () {
53 this.buildForm({
54 reason: this.abuseValidatorsService.ABUSE_REASON,
55 predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null)
56 })
57
58 this.predefinedReasons = this.abuseService.getPrefefinedReasons('comment')
59 }
60
61 show () {
62 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
63 }
64
65 hide () {
66 this.openedModal.close()
67 this.openedModal = null
68 }
69
70 report () {
71 const reason = this.form.get('reason').value
72 const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as AbusePredefinedReasonsString[]
73
74 this.abuseService.reportVideo({
75 reason,
76 predefinedReasons,
77 comment: {
78 id: this.comment.id
79 }
80 }).subscribe(
81 () => {
82 this.notifier.success(this.i18n('Comment reported.'))
83 this.hide()
84 },
85
86 err => this.notifier.error(err.message)
87 )
88 }
89
90 isRemoteComment () {
91 return !this.comment.isLocal
92 }
93}
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 742193e58..ff4021a33 100644
--- a/client/src/app/shared/shared-moderation/shared-moderation.module.ts
+++ b/client/src/app/shared/shared-moderation/shared-moderation.module.ts
@@ -12,6 +12,7 @@ import { AbuseService } from './abuse.service'
12import { VideoBlockComponent } from './video-block.component' 12import { VideoBlockComponent } from './video-block.component'
13import { VideoBlockService } from './video-block.service' 13import { VideoBlockService } from './video-block.service'
14import { VideoReportComponent } from './video-report.component' 14import { VideoReportComponent } from './video-report.component'
15import { CommentReportComponent } from './comment-report.component'
15 16
16@NgModule({ 17@NgModule({
17 imports: [ 18 imports: [
@@ -25,7 +26,8 @@ import { VideoReportComponent } from './video-report.component'
25 UserModerationDropdownComponent, 26 UserModerationDropdownComponent,
26 VideoBlockComponent, 27 VideoBlockComponent,
27 VideoReportComponent, 28 VideoReportComponent,
28 BatchDomainsModalComponent 29 BatchDomainsModalComponent,
30 CommentReportComponent
29 ], 31 ],
30 32
31 exports: [ 33 exports: [
@@ -33,7 +35,8 @@ import { VideoReportComponent } from './video-report.component'
33 UserModerationDropdownComponent, 35 UserModerationDropdownComponent,
34 VideoBlockComponent, 36 VideoBlockComponent,
35 VideoReportComponent, 37 VideoReportComponent,
36 BatchDomainsModalComponent 38 BatchDomainsModalComponent,
39 CommentReportComponent
37 ], 40 ],
38 41
39 providers: [ 42 providers: [
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-report.component.html b/client/src/app/shared/shared-moderation/video-report.component.html
index d6beb6d2a..b724ecb18 100644
--- a/client/src/app/shared/shared-moderation/video-report.component.html
+++ b/client/src/app/shared/shared-moderation/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
@@ -73,7 +76,7 @@
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/video-report.component.ts b/client/src/app/shared/shared-moderation/video-report.component.ts
index 7977e4cca..26e7b62ba 100644
--- a/client/src/app/shared/shared-moderation/video-report.component.ts
+++ b/client/src/app/shared/shared-moderation/video-report.component.ts
@@ -79,48 +79,7 @@ export class VideoReportComponent extends FormReactive implements OnInit {
79 } 79 }
80 }) 80 })
81 81
82 this.predefinedReasons = [ 82 this.predefinedReasons = this.abuseService.getPrefefinedReasons('video')
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 83
125 this.embedHtml = this.getVideoEmbed() 84 this.embedHtml = this.getVideoEmbed()
126 } 85 }
diff --git a/server/models/abuse/abuse.ts b/server/models/abuse/abuse.ts
index dffd503b3..bd96cf79c 100644
--- a/server/models/abuse/abuse.ts
+++ b/server/models/abuse/abuse.ts
@@ -140,7 +140,7 @@ export enum ScopeNames {
140 model: VideoModel.unscoped(), 140 model: VideoModel.unscoped(),
141 include: [ 141 include: [
142 { 142 {
143 attributes: [ 'filename', 'fileUrl' ], 143 attributes: [ 'filename', 'fileUrl', 'type' ],
144 model: ThumbnailModel 144 model: ThumbnailModel
145 }, 145 },
146 { 146 {
@@ -395,6 +395,8 @@ export class AbuseModel extends Model<AbuseModel> {
395 395
396 comment = { 396 comment = {
397 id: entity.id, 397 id: entity.id,
398 threadId: entity.getThreadId(),
399
398 text: entity.text ?? '', 400 text: entity.text ?? '',
399 401
400 deleted: entity.isDeleted(), 402 deleted: entity.isDeleted(),
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index fa4d13c3b..75b914b8c 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -655,7 +655,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
655 id: this.id, 655 id: this.id,
656 url: this.url, 656 url: this.url,
657 text: this.text, 657 text: this.text,
658 threadId: this.originCommentId || this.id, 658 threadId: this.getThreadId(),
659 inReplyToCommentId: this.inReplyToCommentId || null, 659 inReplyToCommentId: this.inReplyToCommentId || null,
660 videoId: this.videoId, 660 videoId: this.videoId,
661 createdAt: this.createdAt, 661 createdAt: this.createdAt,
diff --git a/shared/models/moderation/abuse/abuse.model.ts b/shared/models/moderation/abuse/abuse.model.ts
index e241dbd81..74798ab2c 100644
--- a/shared/models/moderation/abuse/abuse.model.ts
+++ b/shared/models/moderation/abuse/abuse.model.ts
@@ -25,6 +25,7 @@ export interface VideoAbuse {
25 25
26export interface VideoCommentAbuse { 26export interface VideoCommentAbuse {
27 id: number 27 id: number
28 threadId: number
28 29
29 video: { 30 video: {
30 id: number 31 id: number