Unverified Commit 1ebddadd authored by Rigel Kent's avatar Rigel Kent Committed by GitHub
Browse files

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
parent 07aea1a2
......@@ -42,6 +42,20 @@
}
}
p-calendar {
display: block;
::ng-deep {
.ui-widget-content {
min-width: 400px;
}
input {
@include peertube-input-text(100%);
}
}
}
.screenratio {
div {
@include miniature-thumbnail;
......
......@@ -57,6 +57,22 @@
<span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.reasonHtml"></span>
</div>
<div *ngIf="getPredefinedReasons()" class="mt-2 d-flex">
<span class="col-3"></span>
<span class="col-9">
<a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'tag:' + reason.id }" class="chip rectangular bg-secondary text-light" *ngFor="let reason of getPredefinedReasons()">
<div>{{ reason.label }}</div>
</a>
</span>
</div>
<div *ngIf="videoAbuse.startAt" class="mt-2 d-flex">
<span class="col-3 moderation-expanded-label" i18n>Reported part</span>
<span class="col-9">
{{ startAt }}<ng-container *ngIf="videoAbuse.endAt"> - {{ endAt }}</ng-container>
</span>
</div>
<div class="mt-3 d-flex" *ngIf="videoAbuse.moderationComment">
<span class="col-3 moderation-expanded-label" i18n>Note</span>
<span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.moderationCommentHtml"></span>
......
import { Component, Input } from '@angular/core'
import { Account } from '@app/shared/account/account.model'
import { Actor } from '@app/shared/actor/actor.model'
import { VideoAbusePredefinedReasonsString } from '../../../../../../shared/models/videos/abuse/video-abuse-reason.model'
import { ProcessedVideoAbuse } from './video-abuse-list.component'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { durationToString } from '@app/shared/misc/utils'
@Component({
selector: 'my-video-abuse-details',
......@@ -11,6 +13,39 @@ import { ProcessedVideoAbuse } from './video-abuse-list.component'
export class VideoAbuseDetailsComponent {
@Input() videoAbuse: ProcessedVideoAbuse
private predefinedReasonsTranslations: { [key in VideoAbusePredefinedReasonsString]: string }
constructor (
private i18n: I18n
) {
this.predefinedReasonsTranslations = {
violentOrRepulsive: this.i18n('Violent or Repulsive'),
hatefulOrAbusive: this.i18n('Hateful or Abusive'),
spamOrMisleading: this.i18n('Spam or Misleading'),
privacy: this.i18n('Privacy'),
rights: this.i18n('Rights'),
serverRules: this.i18n('Server rules'),
thumbnails: this.i18n('Thumbnails'),
captions: this.i18n('Captions')
}
}
get startAt () {
return durationToString(this.videoAbuse.startAt)
}
get endAt () {
return durationToString(this.videoAbuse.endAt)
}
getPredefinedReasons () {
if (!this.videoAbuse.predefinedReasons) return []
return this.videoAbuse.predefinedReasons.map(r => ({
id: r,
label: this.predefinedReasonsTranslations[r]
}))
}
switchToDefaultAvatar ($event: Event) {
($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
}
......
......@@ -11,13 +11,13 @@ import { ModerationCommentModalComponent } from './moderation-comment-modal.comp
import { Video } from '../../../shared/video/video.model'
import { MarkdownService } from '@app/shared/renderer'
import { Actor } from '@app/shared/actor/actor.model'
import { buildVideoLink, buildVideoEmbed } from 'src/assets/player/utils'
import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
import { DomSanitizer } from '@angular/platform-browser'
import { BlocklistService } from '@app/shared/blocklist'
import { VideoService } from '@app/shared/video/video.service'
import { ActivatedRoute, Params, Router } from '@angular/router'
import { filter } from 'rxjs/operators'
import { environment } from 'src/environments/environment'
export type ProcessedVideoAbuse = VideoAbuse & {
moderationCommentHtml?: string,
......@@ -259,12 +259,15 @@ export class VideoAbuseListComponent extends RestTable implements OnInit, AfterV
}
getVideoEmbed (videoAbuse: VideoAbuse) {
const absoluteAPIUrl = getAbsoluteAPIUrl()
const embedUrl = buildVideoLink({
baseUrl: absoluteAPIUrl + '/videos/embed/' + videoAbuse.video.uuid,
warningTitle: false
})
return buildVideoEmbed(embedUrl)
return buildVideoEmbed(
buildVideoLink({
baseUrl: `${environment.embedUrl}/videos/embed/${videoAbuse.video.uuid}`,
title: false,
warningTitle: false,
startTime: videoAbuse.startAt,
stopTime: videoAbuse.endAt
})
)
}
switchToDefaultAvatar ($event: Event) {
......
......@@ -46,7 +46,7 @@ export class RestService {
addObjectParams (params: HttpParams, object: { [ name: string ]: any }) {
for (const name of Object.keys(object)) {
const value = object[name]
if (!value) continue
if (value === undefined || value === null) continue
if (Array.isArray(value) && value.length !== 0) {
for (const v of value) params = params.append(name, v)
......@@ -93,7 +93,7 @@ export class RestService {
return t
})
.filter(t => !!t)
.filter(t => !!t || t === 0)
if (matchedTokens.length === 0) continue
......@@ -103,7 +103,7 @@ export class RestService {
}
return {
search: searchTokens.join(' '),
search: searchTokens.join(' ') || undefined,
...additionalFilters
}
......
......@@ -3,9 +3,10 @@ import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { SortMeta } from 'primeng/api'
import { Observable } from 'rxjs'
import { ResultList, VideoAbuse, VideoAbuseUpdate, VideoAbuseState } from '../../../../../shared'
import { ResultList, VideoAbuse, VideoAbuseCreate, VideoAbuseState, VideoAbuseUpdate } from '../../../../../shared'
import { environment } from '../../../environments/environment'
import { RestExtractor, RestPagination, RestService } from '../rest'
import { omit } from 'lodash-es'
@Injectable()
export class VideoAbuseService {
......@@ -51,7 +52,8 @@ export class VideoAbuseService {
}
},
searchReporter: { prefix: 'reporter:' },
searchReportee: { prefix: 'reportee:' }
searchReportee: { prefix: 'reportee:' },
predefinedReason: { prefix: 'tag:' }
})
params = this.restService.addObjectParams(params, filters)
......@@ -63,9 +65,10 @@ export class VideoAbuseService {
)
}
reportVideo (id: number, reason: string) {
const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + id + '/abuse'
const body = { reason }
reportVideo (parameters: { id: number } & VideoAbuseCreate) {
const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + parameters.id + '/abuse'
const body = omit(parameters, [ 'id' ])
return this.authHttp.post(url, body)
.pipe(
......
<ng-template #modal>
<div class="modal-header">
<h4 i18n class="modal-title">Blocklist video</h4>
<h4 i18n class="modal-title">Block video "{{ video.name }}"</h4>
<my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
</div>
......@@ -9,7 +9,7 @@
<form novalidate [formGroup]="form" (ngSubmit)="block()">
<div class="form-group">
<textarea
i18n-placeholder placeholder="Reason..." formControlName="reason"
i18n-placeholder placeholder="Please describe the reason..." formControlName="reason"
[ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
></textarea>
<div *ngIf="formErrors.reason" class="form-error">
......
<ng-template #modal>
<div class="modal-header">
<h4 i18n class="modal-title">Report video</h4>
<h4 i18n class="modal-title">Report video "{{ video.name }}"</h4>
<my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
</div>
<div class="modal-body">
<form novalidate [formGroup]="form" (ngSubmit)="report()">
<div i18n class="information">
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>.
</div>
<div class="row">
<div class="col-5 form-group">
<label i18n for="reportPredefinedReasons">What is the issue?</label>
<div class="ml-2 mt-2 d-flex flex-column">
<ng-container formGroupName="predefinedReasons">
<div class="form-group" *ngFor="let reason of predefinedReasons">
<my-peertube-checkbox formControlName="{{reason.id}}" labelText="{{reason.label}}">
<ng-template *ngIf="reason.help" ptTemplate="help">
<div [innerHTML]="reason.help"></div>
</ng-template>
<ng-container *ngIf="reason.description" ngProjectAs="description">
<div [innerHTML]="reason.description"></div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</div>
<form novalidate [formGroup]="form" (ngSubmit)="report()">
<div class="form-group">
<textarea
i18n-placeholder placeholder="Reason..." formControlName="reason"
[ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
></textarea>
<div *ngIf="formErrors.reason" class="form-error">
{{ formErrors.reason }}
</div>
</div>
<div class="form-group inputs">
<input
type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
(click)="hide()" (key.enter)="hide()"
>
<div class="col-7">
<div class="row justify-content-center">
<div class="col-12 col-lg-9 mb-2">
<div class="screenratio">
<div [innerHTML]="embedHtml"></div>
</div>
</div>
</div>
<div class="mb-1 start-at" formGroupName="timestamp">
<my-peertube-checkbox
formControlName="hasStart"
i18n-labelText labelText="Start at"
></my-peertube-checkbox>
<my-timestamp-input
[timestamp]="timestamp.startAt"
[maxTimestamp]="video.duration"
formControlName="startAt"
inputName="startAt"
>
</my-timestamp-input>
</div>
<div class="mb-3 stop-at" formGroupName="timestamp" *ngIf="timestamp.hasStart">
<my-peertube-checkbox
formControlName="hasEnd"
i18n-labelText labelText="Stop at"
></my-peertube-checkbox>
<input
type="submit" i18n-value value="Submit" class="action-button-submit"
[disabled]="!form.valid"
>
<my-timestamp-input
[timestamp]="timestamp.endAt"
[maxTimestamp]="video.duration"
formControlName="endAt"
inputName="endAt"
>
</my-timestamp-input>
</div>
<div i18n class="information">
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>.
</div>
<div class="form-group">
<textarea
i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus
[ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
></textarea>
<div *ngIf="formErrors.reason" class="form-error">
{{ formErrors.reason }}
</div>
</div>
</div>
</form>
</div>
<div class="form-group inputs">
<input
type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
(click)="hide()" (key.enter)="hide()"
>
<input type="submit" i18n-value value="Submit" class="action-button-submit" [disabled]="!form.valid">
</div>
</form>
</div>
</ng-template>
......@@ -8,3 +8,20 @@
textarea {
@include peertube-textarea(100%, 100px);
}
.start-at,
.stop-at {
width: 300px;
display: flex;
align-items: center;
my-timestamp-input {
margin-left: 10px;
}
}
.screenratio {
@include large-screen-ratio($selector: 'div, ::ng-deep iframe') {
left: 0;
};
}
......@@ -8,6 +8,10 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
import { VideoAbuseService } from '@app/shared/video-abuse'
import { Video } from '@app/shared/video/video.model'
import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
import { VideoAbusePredefinedReasonsString, videoAbusePredefinedReasonsMap } from '@shared/models/videos/abuse/video-abuse-reason.model'
import { mapValues, pickBy } from 'lodash-es'
@Component({
selector: 'my-video-report',
......@@ -20,6 +24,8 @@ export class VideoReportComponent extends FormReactive implements OnInit {
@ViewChild('modal', { static: true }) modal: NgbModal
error: string = null
predefinedReasons: { id: VideoAbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
embedHtml: SafeHtml
private openedModal: NgbModalRef
......@@ -29,6 +35,7 @@ export class VideoReportComponent extends FormReactive implements OnInit {
private videoAbuseValidatorsService: VideoAbuseValidatorsService,
private videoAbuseService: VideoAbuseService,
private notifier: Notifier,
private sanitizer: DomSanitizer,
private i18n: I18n
) {
super()
......@@ -46,14 +53,82 @@ export class VideoReportComponent extends FormReactive implements OnInit {
return ''
}
get timestamp () {
return this.form.get('timestamp').value
}
getVideoEmbed () {
return this.sanitizer.bypassSecurityTrustHtml(
buildVideoEmbed(
buildVideoLink({
baseUrl: this.video.embedUrl,
title: false,
warningTitle: false
})
)
)
}
ngOnInit () {
this.buildForm({
reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON
reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON,
predefinedReasons: mapValues(videoAbusePredefinedReasonsMap, r => null),
timestamp: {
hasStart: null,
startAt: null,
hasEnd: null,
endAt: null
}
})
this.predefinedReasons = [
{
id: 'violentOrRepulsive',
label: this.i18n('Violent or repulsive'),
help: this.i18n('Contains offensive, violent, or coarse language or iconography.')
},
{
id: 'hatefulOrAbusive',
label: this.i18n('Hateful or abusive'),
help: this.i18n('Contains abusive, racist or sexist language or iconography.')
},
{
id: 'spamOrMisleading',
label: this.i18n('Spam, ad or false news'),
help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.')
},
{
id: 'privacy',
label: this.i18n('Privacy breach or doxxing'),
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).')
},
{
id: 'rights',
label: this.i18n('Intellectual property violation'),
help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.')
},
{
id: 'serverRules',
label: this.i18n('Breaks server rules'),
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.')
},
{
id: 'thumbnails',
label: this.i18n('Thumbnails'),
help: this.i18n('The above can only be seen in thumbnails.')
},
{
id: 'captions',
label: this.i18n('Captions'),
help: this.i18n('The above can only be seen in captions (please describe which).')
}
]
this.embedHtml = this.getVideoEmbed()
}
show () {
this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
}
hide () {
......@@ -62,17 +137,24 @@ export class VideoReportComponent extends FormReactive implements OnInit {
}
report () {
const reason = this.form.value['reason']
const reason = this.form.get('reason').value
const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as VideoAbusePredefinedReasonsString[]
const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value
this.videoAbuseService.reportVideo(this.video.id, reason)
.subscribe(
() => {
this.notifier.success(this.i18n('Video reported.'))
this.hide()
},
this.videoAbuseService.reportVideo({
id: this.video.id,
reason,
predefinedReasons,
startAt: hasStart && startAt ? startAt : undefined,
endAt: hasEnd && endAt ? endAt : undefined
}).subscribe(
() => {
this.notifier.success(this.i18n('Video reported.'))
this.hide()
},
err => this.notifier.error(err.message)
)
err => this.notifier.error(err.message)
)
}
isRemoteVideo () {
......
......@@ -7,6 +7,7 @@ import { peertubeTranslate, ServerConfig } from '../../../../../shared/models'
import { Actor } from '@app/shared/actor/actor.model'
import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model'
import { AuthUser } from '@app/core'
import { environment } from '../../../environments/environment'
export class Video implements VideoServerModel {
byVideoChannel: string
......@@ -111,7 +112,7 @@ export class Video implements VideoServerModel {
this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath)
this.embedPath = hash.embedPath
this.embedUrl = hash.embedUrl || (absoluteAPIUrl + hash.embedPath)
this.embedUrl = hash.embedUrl || (environment.embedUrl + hash.embedPath)
this.url = hash.url
......
export const environment = {
production: false,
hmr: false,
apiUrl: 'http://localhost:9001'
apiUrl: 'http://localhost:9001',
embedUrl: 'http://localhost:9001/videos/embed'
}
export const environment = {
production: false,
hmr: true,
apiUrl: ''
apiUrl: '',
embedUrl: 'http://localhost:9000/videos/embed'
}
export const environment = {
production: true,
hmr: false,
apiUrl: ''
apiUrl: '',
embedUrl: '/videos/embed'
}
......@@ -11,5 +11,6 @@ import 'core-js/features/reflect'
export const environment = {
production: false,
hmr: false,
apiUrl: 'http://localhost:9000'
apiUrl: 'http://localhost:9000',
embedUrl: 'http://localhost:9000/videos/embed'
}
......@@ -804,10 +804,12 @@
}
@mixin chip {
--chip-radius: 5rem;
--chip-padding: .2rem .4rem;
$avatar-height: 1.2rem;
align-items: center;
border-radius: 5rem;
border-radius: var(--chip-radius);
display: inline-flex;
font-size: 90%;
color: pvar(--mainForegroundColor);
......@@ -816,12 +818,17 @@
margin: .1rem;
max-width: 320px;
overflow: hidden;
padding: .2rem .4rem;
padding: var(--chip-padding);
text-decoration: none;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
&.rectangular {
--chip-radius: .2rem;
--chip-padding: .2rem .3rem;
}
.avatar {