diff options
author | Rigel Kent <sendmemail@rigelk.eu> | 2020-06-22 13:00:39 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-06-22 13:00:39 +0200 |
commit | 1ebddadd0704812a4600c39cabe2268321e88331 (patch) | |
tree | 1cc8560e5b63e9976aa5411ba800a62cfe7b8ea9 /client/src/app/shared | |
parent | 07aea1a2642fc9868cb01e30c322514029d5b95a (diff) | |
download | PeerTube-1ebddadd0704812a4600c39cabe2268321e88331.tar.gz PeerTube-1ebddadd0704812a4600c39cabe2268321e88331.tar.zst PeerTube-1ebddadd0704812a4600c39cabe2268321e88331.zip |
predefined report reasons & improved reporter UI (#2842)
- added `startAt` and `endAt` optional timestamps to help pin down reported sections of a video
- added predefined report reasons
- added video player with report modal
Diffstat (limited to 'client/src/app/shared')
7 files changed, 207 insertions, 45 deletions
diff --git a/client/src/app/shared/rest/rest.service.ts b/client/src/app/shared/rest/rest.service.ts index cd6db1f3c..78558851a 100644 --- a/client/src/app/shared/rest/rest.service.ts +++ b/client/src/app/shared/rest/rest.service.ts | |||
@@ -46,7 +46,7 @@ export class RestService { | |||
46 | addObjectParams (params: HttpParams, object: { [ name: string ]: any }) { | 46 | addObjectParams (params: HttpParams, object: { [ name: string ]: any }) { |
47 | for (const name of Object.keys(object)) { | 47 | for (const name of Object.keys(object)) { |
48 | const value = object[name] | 48 | const value = object[name] |
49 | if (!value) continue | 49 | if (value === undefined || value === null) continue |
50 | 50 | ||
51 | if (Array.isArray(value) && value.length !== 0) { | 51 | if (Array.isArray(value) && value.length !== 0) { |
52 | for (const v of value) params = params.append(name, v) | 52 | for (const v of value) params = params.append(name, v) |
@@ -93,7 +93,7 @@ export class RestService { | |||
93 | 93 | ||
94 | return t | 94 | return t |
95 | }) | 95 | }) |
96 | .filter(t => !!t) | 96 | .filter(t => !!t || t === 0) |
97 | 97 | ||
98 | if (matchedTokens.length === 0) continue | 98 | if (matchedTokens.length === 0) continue |
99 | 99 | ||
@@ -103,7 +103,7 @@ export class RestService { | |||
103 | } | 103 | } |
104 | 104 | ||
105 | return { | 105 | return { |
106 | search: searchTokens.join(' '), | 106 | search: searchTokens.join(' ') || undefined, |
107 | 107 | ||
108 | ...additionalFilters | 108 | ...additionalFilters |
109 | } | 109 | } |
diff --git a/client/src/app/shared/video-abuse/video-abuse.service.ts b/client/src/app/shared/video-abuse/video-abuse.service.ts index 700a30239..43f4674b1 100644 --- a/client/src/app/shared/video-abuse/video-abuse.service.ts +++ b/client/src/app/shared/video-abuse/video-abuse.service.ts | |||
@@ -3,9 +3,10 @@ import { HttpClient, HttpParams } from '@angular/common/http' | |||
3 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
4 | import { SortMeta } from 'primeng/api' | 4 | import { SortMeta } from 'primeng/api' |
5 | import { Observable } from 'rxjs' | 5 | import { Observable } from 'rxjs' |
6 | import { ResultList, VideoAbuse, VideoAbuseUpdate, VideoAbuseState } from '../../../../../shared' | 6 | import { ResultList, VideoAbuse, VideoAbuseCreate, VideoAbuseState, VideoAbuseUpdate } from '../../../../../shared' |
7 | import { environment } from '../../../environments/environment' | 7 | import { environment } from '../../../environments/environment' |
8 | import { RestExtractor, RestPagination, RestService } from '../rest' | 8 | import { RestExtractor, RestPagination, RestService } from '../rest' |
9 | import { omit } from 'lodash-es' | ||
9 | 10 | ||
10 | @Injectable() | 11 | @Injectable() |
11 | export class VideoAbuseService { | 12 | export class VideoAbuseService { |
@@ -51,7 +52,8 @@ export class VideoAbuseService { | |||
51 | } | 52 | } |
52 | }, | 53 | }, |
53 | searchReporter: { prefix: 'reporter:' }, | 54 | searchReporter: { prefix: 'reporter:' }, |
54 | searchReportee: { prefix: 'reportee:' } | 55 | searchReportee: { prefix: 'reportee:' }, |
56 | predefinedReason: { prefix: 'tag:' } | ||
55 | }) | 57 | }) |
56 | 58 | ||
57 | params = this.restService.addObjectParams(params, filters) | 59 | params = this.restService.addObjectParams(params, filters) |
@@ -63,9 +65,10 @@ export class VideoAbuseService { | |||
63 | ) | 65 | ) |
64 | } | 66 | } |
65 | 67 | ||
66 | reportVideo (id: number, reason: string) { | 68 | reportVideo (parameters: { id: number } & VideoAbuseCreate) { |
67 | const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + id + '/abuse' | 69 | const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + parameters.id + '/abuse' |
68 | const body = { reason } | 70 | |
71 | const body = omit(parameters, [ 'id' ]) | ||
69 | 72 | ||
70 | return this.authHttp.post(url, body) | 73 | return this.authHttp.post(url, body) |
71 | .pipe( | 74 | .pipe( |
diff --git a/client/src/app/shared/video/modals/video-block.component.html b/client/src/app/shared/video/modals/video-block.component.html index a8dd30b5e..5e73d66c5 100644 --- a/client/src/app/shared/video/modals/video-block.component.html +++ b/client/src/app/shared/video/modals/video-block.component.html | |||
@@ -1,6 +1,6 @@ | |||
1 | <ng-template #modal> | 1 | <ng-template #modal> |
2 | <div class="modal-header"> | 2 | <div class="modal-header"> |
3 | <h4 i18n class="modal-title">Blocklist video</h4> | 3 | <h4 i18n class="modal-title">Block video "{{ video.name }}"</h4> |
4 | <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> | 4 | <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> |
5 | </div> | 5 | </div> |
6 | 6 | ||
@@ -9,7 +9,7 @@ | |||
9 | <form novalidate [formGroup]="form" (ngSubmit)="block()"> | 9 | <form novalidate [formGroup]="form" (ngSubmit)="block()"> |
10 | <div class="form-group"> | 10 | <div class="form-group"> |
11 | <textarea | 11 | <textarea |
12 | i18n-placeholder placeholder="Reason..." formControlName="reason" | 12 | i18n-placeholder placeholder="Please describe the reason..." formControlName="reason" |
13 | [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control" | 13 | [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control" |
14 | ></textarea> | 14 | ></textarea> |
15 | <div *ngIf="formErrors.reason" class="form-error"> | 15 | <div *ngIf="formErrors.reason" class="form-error"> |
diff --git a/client/src/app/shared/video/modals/video-report.component.html b/client/src/app/shared/video/modals/video-report.component.html index e336b6660..d6beb6d2a 100644 --- a/client/src/app/shared/video/modals/video-report.component.html +++ b/client/src/app/shared/video/modals/video-report.component.html | |||
@@ -1,38 +1,97 @@ | |||
1 | <ng-template #modal> | 1 | <ng-template #modal> |
2 | <div class="modal-header"> | 2 | <div class="modal-header"> |
3 | <h4 i18n class="modal-title">Report video</h4> | 3 | <h4 i18n class="modal-title">Report video "{{ video.name }}"</h4> |
4 | <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> | 4 | <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> |
5 | </div> | 5 | </div> |
6 | 6 | ||
7 | <div class="modal-body"> | 7 | <div class="modal-body"> |
8 | <form novalidate [formGroup]="form" (ngSubmit)="report()"> | ||
8 | 9 | ||
9 | <div i18n class="information"> | 10 | <div class="row"> |
10 | Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteVideo()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>. | 11 | <div class="col-5 form-group"> |
11 | </div> | 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 | <div class="form-group" *ngFor="let reason of predefinedReasons"> | ||
18 | <my-peertube-checkbox formControlName="{{reason.id}}" labelText="{{reason.label}}"> | ||
19 | <ng-template *ngIf="reason.help" ptTemplate="help"> | ||
20 | <div [innerHTML]="reason.help"></div> | ||
21 | </ng-template> | ||
22 | <ng-container *ngIf="reason.description" ngProjectAs="description"> | ||
23 | <div [innerHTML]="reason.description"></div> | ||
24 | </ng-container> | ||
25 | </my-peertube-checkbox> | ||
26 | </div> | ||
27 | </ng-container> | ||
28 | </div> | ||
12 | 29 | ||
13 | <form novalidate [formGroup]="form" (ngSubmit)="report()"> | ||
14 | <div class="form-group"> | ||
15 | <textarea | ||
16 | i18n-placeholder placeholder="Reason..." formControlName="reason" | ||
17 | [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control" | ||
18 | ></textarea> | ||
19 | <div *ngIf="formErrors.reason" class="form-error"> | ||
20 | {{ formErrors.reason }} | ||
21 | </div> | ||
22 | </div> | 30 | </div> |
23 | 31 | ||
24 | <div class="form-group inputs"> | 32 | <div class="col-7"> |
25 | <input | 33 | <div class="row justify-content-center"> |
26 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" | 34 | <div class="col-12 col-lg-9 mb-2"> |
27 | (click)="hide()" (key.enter)="hide()" | 35 | <div class="screenratio"> |
28 | > | 36 | <div [innerHTML]="embedHtml"></div> |
37 | </div> | ||
38 | </div> | ||
39 | </div> | ||
40 | |||
41 | <div class="mb-1 start-at" formGroupName="timestamp"> | ||
42 | <my-peertube-checkbox | ||
43 | formControlName="hasStart" | ||
44 | i18n-labelText labelText="Start at" | ||
45 | ></my-peertube-checkbox> | ||
46 | |||
47 | <my-timestamp-input | ||
48 | [timestamp]="timestamp.startAt" | ||
49 | [maxTimestamp]="video.duration" | ||
50 | formControlName="startAt" | ||
51 | inputName="startAt" | ||
52 | > | ||
53 | </my-timestamp-input> | ||
54 | </div> | ||
55 | |||
56 | <div class="mb-3 stop-at" formGroupName="timestamp" *ngIf="timestamp.hasStart"> | ||
57 | <my-peertube-checkbox | ||
58 | formControlName="hasEnd" | ||
59 | i18n-labelText labelText="Stop at" | ||
60 | ></my-peertube-checkbox> | ||
29 | 61 | ||
30 | <input | 62 | <my-timestamp-input |
31 | type="submit" i18n-value value="Submit" class="action-button-submit" | 63 | [timestamp]="timestamp.endAt" |
32 | [disabled]="!form.valid" | 64 | [maxTimestamp]="video.duration" |
33 | > | 65 | formControlName="endAt" |
66 | inputName="endAt" | ||
67 | > | ||
68 | </my-timestamp-input> | ||
69 | </div> | ||
70 | |||
71 | <div i18n class="information"> | ||
72 | Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteVideo()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>. | ||
73 | </div> | ||
74 | |||
75 | <div class="form-group"> | ||
76 | <textarea | ||
77 | i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus | ||
78 | [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control" | ||
79 | ></textarea> | ||
80 | <div *ngIf="formErrors.reason" class="form-error"> | ||
81 | {{ formErrors.reason }} | ||
82 | </div> | ||
83 | </div> | ||
34 | </div> | 84 | </div> |
35 | </form> | 85 | </div> |
36 | 86 | ||
87 | <div class="form-group inputs"> | ||
88 | <input | ||
89 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" | ||
90 | (click)="hide()" (key.enter)="hide()" | ||
91 | > | ||
92 | <input type="submit" i18n-value value="Submit" class="action-button-submit" [disabled]="!form.valid"> | ||
93 | </div> | ||
94 | |||
95 | </form> | ||
37 | </div> | 96 | </div> |
38 | </ng-template> | 97 | </ng-template> |
diff --git a/client/src/app/shared/video/modals/video-report.component.scss b/client/src/app/shared/video/modals/video-report.component.scss index 4713660a2..b2606cbd8 100644 --- a/client/src/app/shared/video/modals/video-report.component.scss +++ b/client/src/app/shared/video/modals/video-report.component.scss | |||
@@ -8,3 +8,20 @@ | |||
8 | textarea { | 8 | textarea { |
9 | @include peertube-textarea(100%, 100px); | 9 | @include peertube-textarea(100%, 100px); |
10 | } | 10 | } |
11 | |||
12 | .start-at, | ||
13 | .stop-at { | ||
14 | width: 300px; | ||
15 | display: flex; | ||
16 | align-items: center; | ||
17 | |||
18 | my-timestamp-input { | ||
19 | margin-left: 10px; | ||
20 | } | ||
21 | } | ||
22 | |||
23 | .screenratio { | ||
24 | @include large-screen-ratio($selector: 'div, ::ng-deep iframe') { | ||
25 | left: 0; | ||
26 | }; | ||
27 | } | ||
diff --git a/client/src/app/shared/video/modals/video-report.component.ts b/client/src/app/shared/video/modals/video-report.component.ts index 988fa03d4..c2d441bba 100644 --- a/client/src/app/shared/video/modals/video-report.component.ts +++ b/client/src/app/shared/video/modals/video-report.component.ts | |||
@@ -8,6 +8,10 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | |||
8 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | 8 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' |
9 | import { VideoAbuseService } from '@app/shared/video-abuse' | 9 | import { VideoAbuseService } from '@app/shared/video-abuse' |
10 | import { Video } from '@app/shared/video/video.model' | 10 | import { Video } from '@app/shared/video/video.model' |
11 | import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils' | ||
12 | import { DomSanitizer, SafeHtml } from '@angular/platform-browser' | ||
13 | import { VideoAbusePredefinedReasonsString, videoAbusePredefinedReasonsMap } from '@shared/models/videos/abuse/video-abuse-reason.model' | ||
14 | import { mapValues, pickBy } from 'lodash-es' | ||
11 | 15 | ||
12 | @Component({ | 16 | @Component({ |
13 | selector: 'my-video-report', | 17 | selector: 'my-video-report', |
@@ -20,6 +24,8 @@ export class VideoReportComponent extends FormReactive implements OnInit { | |||
20 | @ViewChild('modal', { static: true }) modal: NgbModal | 24 | @ViewChild('modal', { static: true }) modal: NgbModal |
21 | 25 | ||
22 | error: string = null | 26 | error: string = null |
27 | predefinedReasons: { id: VideoAbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [] | ||
28 | embedHtml: SafeHtml | ||
23 | 29 | ||
24 | private openedModal: NgbModalRef | 30 | private openedModal: NgbModalRef |
25 | 31 | ||
@@ -29,6 +35,7 @@ export class VideoReportComponent extends FormReactive implements OnInit { | |||
29 | private videoAbuseValidatorsService: VideoAbuseValidatorsService, | 35 | private videoAbuseValidatorsService: VideoAbuseValidatorsService, |
30 | private videoAbuseService: VideoAbuseService, | 36 | private videoAbuseService: VideoAbuseService, |
31 | private notifier: Notifier, | 37 | private notifier: Notifier, |
38 | private sanitizer: DomSanitizer, | ||
32 | private i18n: I18n | 39 | private i18n: I18n |
33 | ) { | 40 | ) { |
34 | super() | 41 | super() |
@@ -46,14 +53,82 @@ export class VideoReportComponent extends FormReactive implements OnInit { | |||
46 | return '' | 53 | return '' |
47 | } | 54 | } |
48 | 55 | ||
56 | get timestamp () { | ||
57 | return this.form.get('timestamp').value | ||
58 | } | ||
59 | |||
60 | getVideoEmbed () { | ||
61 | return this.sanitizer.bypassSecurityTrustHtml( | ||
62 | buildVideoEmbed( | ||
63 | buildVideoLink({ | ||
64 | baseUrl: this.video.embedUrl, | ||
65 | title: false, | ||
66 | warningTitle: false | ||
67 | }) | ||
68 | ) | ||
69 | ) | ||
70 | } | ||
71 | |||
49 | ngOnInit () { | 72 | ngOnInit () { |
50 | this.buildForm({ | 73 | this.buildForm({ |
51 | reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON | 74 | reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON, |
75 | predefinedReasons: mapValues(videoAbusePredefinedReasonsMap, r => null), | ||
76 | timestamp: { | ||
77 | hasStart: null, | ||
78 | startAt: null, | ||
79 | hasEnd: null, | ||
80 | endAt: null | ||
81 | } | ||
52 | }) | 82 | }) |
83 | |||
84 | this.predefinedReasons = [ | ||
85 | { | ||
86 | id: 'violentOrRepulsive', | ||
87 | label: this.i18n('Violent or repulsive'), | ||
88 | help: this.i18n('Contains offensive, violent, or coarse language or iconography.') | ||
89 | }, | ||
90 | { | ||
91 | id: 'hatefulOrAbusive', | ||
92 | label: this.i18n('Hateful or abusive'), | ||
93 | help: this.i18n('Contains abusive, racist or sexist language or iconography.') | ||
94 | }, | ||
95 | { | ||
96 | id: 'spamOrMisleading', | ||
97 | label: this.i18n('Spam, ad or false news'), | ||
98 | help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.') | ||
99 | }, | ||
100 | { | ||
101 | id: 'privacy', | ||
102 | label: this.i18n('Privacy breach or doxxing'), | ||
103 | 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).') | ||
104 | }, | ||
105 | { | ||
106 | id: 'rights', | ||
107 | label: this.i18n('Intellectual property violation'), | ||
108 | help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.') | ||
109 | }, | ||
110 | { | ||
111 | id: 'serverRules', | ||
112 | label: this.i18n('Breaks server rules'), | ||
113 | 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.') | ||
114 | }, | ||
115 | { | ||
116 | id: 'thumbnails', | ||
117 | label: this.i18n('Thumbnails'), | ||
118 | help: this.i18n('The above can only be seen in thumbnails.') | ||
119 | }, | ||
120 | { | ||
121 | id: 'captions', | ||
122 | label: this.i18n('Captions'), | ||
123 | help: this.i18n('The above can only be seen in captions (please describe which).') | ||
124 | } | ||
125 | ] | ||
126 | |||
127 | this.embedHtml = this.getVideoEmbed() | ||
53 | } | 128 | } |
54 | 129 | ||
55 | show () { | 130 | show () { |
56 | this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false }) | 131 | this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' }) |
57 | } | 132 | } |
58 | 133 | ||
59 | hide () { | 134 | hide () { |
@@ -62,17 +137,24 @@ export class VideoReportComponent extends FormReactive implements OnInit { | |||
62 | } | 137 | } |
63 | 138 | ||
64 | report () { | 139 | report () { |
65 | const reason = this.form.value['reason'] | 140 | const reason = this.form.get('reason').value |
141 | const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as VideoAbusePredefinedReasonsString[] | ||
142 | const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value | ||
66 | 143 | ||
67 | this.videoAbuseService.reportVideo(this.video.id, reason) | 144 | this.videoAbuseService.reportVideo({ |
68 | .subscribe( | 145 | id: this.video.id, |
69 | () => { | 146 | reason, |
70 | this.notifier.success(this.i18n('Video reported.')) | 147 | predefinedReasons, |
71 | this.hide() | 148 | startAt: hasStart && startAt ? startAt : undefined, |
72 | }, | 149 | endAt: hasEnd && endAt ? endAt : undefined |
150 | }).subscribe( | ||
151 | () => { | ||
152 | this.notifier.success(this.i18n('Video reported.')) | ||
153 | this.hide() | ||
154 | }, | ||
73 | 155 | ||
74 | err => this.notifier.error(err.message) | 156 | err => this.notifier.error(err.message) |
75 | ) | 157 | ) |
76 | } | 158 | } |
77 | 159 | ||
78 | isRemoteVideo () { | 160 | isRemoteVideo () { |
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts index 16e43cbd8..dc5f45626 100644 --- a/client/src/app/shared/video/video.model.ts +++ b/client/src/app/shared/video/video.model.ts | |||
@@ -7,6 +7,7 @@ import { peertubeTranslate, ServerConfig } from '../../../../../shared/models' | |||
7 | import { Actor } from '@app/shared/actor/actor.model' | 7 | import { Actor } from '@app/shared/actor/actor.model' |
8 | import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model' | 8 | import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model' |
9 | import { AuthUser } from '@app/core' | 9 | import { AuthUser } from '@app/core' |
10 | import { environment } from '../../../environments/environment' | ||
10 | 11 | ||
11 | export class Video implements VideoServerModel { | 12 | export class Video implements VideoServerModel { |
12 | byVideoChannel: string | 13 | byVideoChannel: string |
@@ -111,7 +112,7 @@ export class Video implements VideoServerModel { | |||
111 | this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath) | 112 | this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath) |
112 | 113 | ||
113 | this.embedPath = hash.embedPath | 114 | this.embedPath = hash.embedPath |
114 | this.embedUrl = hash.embedUrl || (absoluteAPIUrl + hash.embedPath) | 115 | this.embedUrl = hash.embedUrl || (environment.embedUrl + hash.embedPath) |
115 | 116 | ||
116 | this.url = hash.url | 117 | this.url = hash.url |
117 | 118 | ||