predefined report reasons & improved reporter UI (#2842)
authorRigel Kent <sendmemail@rigelk.eu>
Mon, 22 Jun 2020 11:00:39 +0000 (13:00 +0200)
committerGitHub <noreply@github.com>
Mon, 22 Jun 2020 11:00:39 +0000 (13:00 +0200)
- 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

35 files changed:
client/src/app/+admin/moderation/moderation.component.scss
client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html
client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts
client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts
client/src/app/shared/rest/rest.service.ts
client/src/app/shared/video-abuse/video-abuse.service.ts
client/src/app/shared/video/modals/video-block.component.html
client/src/app/shared/video/modals/video-report.component.html
client/src/app/shared/video/modals/video-report.component.scss
client/src/app/shared/video/modals/video-report.component.ts
client/src/app/shared/video/video.model.ts
client/src/environments/environment.e2e.ts
client/src/environments/environment.hmr.ts
client/src/environments/environment.prod.ts
client/src/environments/environment.ts
client/src/sass/include/_mixins.scss
client/src/sass/player/peertube-skin.scss
server/controllers/api/videos/abuse.ts
server/helpers/custom-validators/video-abuses.ts
server/initializers/constants.ts
server/initializers/migrations/0515-video-abuse-reason-timestamps.ts [new file with mode: 0644]
server/lib/activitypub/process/process-flag.ts
server/middlewares/validators/videos/video-abuses.ts
server/models/video/video-abuse.ts
server/tests/api/check-params/video-abuses.ts
server/tests/api/videos/video-abuse.ts
shared/extra-utils/videos/video-abuses.ts
shared/models/activitypub/activity.ts
shared/models/activitypub/objects/common-objects.ts
shared/models/activitypub/objects/video-abuse-object.ts
shared/models/videos/abuse/video-abuse-create.model.ts
shared/models/videos/abuse/video-abuse-reason.model.ts [new file with mode: 0644]
shared/models/videos/abuse/video-abuse.model.ts
shared/models/videos/index.ts
support/doc/api/openapi.yaml

index ba68cf6f6981f669eff2f10db56fda745fd1e8b7..0ec420af9bba88cd103b3ae9e03d9ed929805ffa 100644 (file)
   }
 }
 
+p-calendar {
+  display: block;
+
+  ::ng-deep {
+    .ui-widget-content {
+      min-width: 400px;
+    }
+
+    input {
+      @include peertube-input-text(100%);
+    }
+  }
+}
+
 .screenratio {
   div {
     @include miniature-thumbnail;
index 453a282d1c8ed30dda85fcc035f4f9b3f2d84712..5512bb1dea80f7f77e7dfbd091ef32df52035991 100644 (file)
       <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>
index d9cb19845f03a5440c0e466d9a872fdea7b734b6..13485124f21074f9ee19d5b4b00b9914c745dda3 100644 (file)
@@ -1,7 +1,9 @@
 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()
   }
index a36acc2abdaf3c41432affde437879ddf9ee64c8..d7f5beef3a4bb48a123d03a9314f3f035ed0abc9 100644 (file)
@@ -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) {
index cd6db1f3c7d8d080a04980edcb4a931581964481..78558851a79fff2d3c6e4d95c41f205d1f3ef0fd 100644 (file)
@@ -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
     }
index 700a3023965437b8529ded080455f2a7ab5acbe4..43f4674b14b70fe17bfcbfdcf51f1ed18aa661f6 100644 (file)
@@ -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(
index a8dd30b5ea1fd60957b5411643cd7d8b39fa56ae..5e73d66c5014dc15e907b768ca8cc7337f04f959 100644 (file)
@@ -1,6 +1,6 @@
 <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">
index e336b666038ea222bc5be8eb963459969ceb8d2b..d6beb6d2ad64a79b81ca6575db00a270268b4142 100644 (file)
@@ -1,38 +1,97 @@
 <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>
index 4713660a226c801f1e22f687bd3d0c3dc008a3b8..b2606cbd8250898921eb84f417b71ba6184d78dc 100644 (file)
@@ -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;
+  };
+}
index 988fa03d474cb537f6610e2ca7914839b67a9607..c2d441bba2945409f9df38a285a06403d2be10b2 100644 (file)
@@ -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 () {
index 16e43cbd823cd2fc1e86f2e3e5e017a84bc846d9..dc5f4562674566e9773d83a2d84700f6d26d22b8 100644 (file)
@@ -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
 
index 7c00e8d4f9b56575f3a370a94804f7dc6ae694cc..7724d27c92993eae622fd4a3d1e34eadbcef04e3 100644 (file)
@@ -1,5 +1,6 @@
 export const environment = {
   production: false,
   hmr: false,
-  apiUrl: 'http://localhost:9001'
+  apiUrl: 'http://localhost:9001',
+  embedUrl: 'http://localhost:9001/videos/embed'
 }
index 853e20803863d3dc612be9c4879677df57c5284a..72eed45e5e6e8d7c81427c75bbbdfdaeae37c785 100644 (file)
@@ -1,5 +1,6 @@
 export const environment = {
   production: false,
   hmr: true,
-  apiUrl: ''
+  apiUrl: '',
+  embedUrl: 'http://localhost:9000/videos/embed'
 }
index d5dfe55736a9cca038e9af51fbade0b911a9ba77..368aa1389907d2d15d72f32aca57679338917156 100644 (file)
@@ -1,5 +1,6 @@
 export const environment = {
   production: true,
   hmr: false,
-  apiUrl: ''
+  apiUrl: '',
+  embedUrl: '/videos/embed'
 }
index b6bc784b53cbc8aaa99503aa732a38e2f9fe9b80..60f5d9450980cb49d8e0ec4b9cb2c0214957d684 100644 (file)
@@ -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'
 }
index eb80ea0e32b6c80c32b641e960b690c75ae07cc4..6a1deac76772b07205bedf34861a2b7054eab5f6 100644 (file)
 }
 
 @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);
   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 {
     margin-left: -.4rem;
     margin-right: .2rem;
index 1fc744e674ad783c556cb33416ae461e87829448..bdeff8f9a45f2ceeb5a4f3ca8a1f0358f6606f7d 100644 (file)
@@ -86,7 +86,7 @@ body {
     }
 
     &.focus-visible, &:hover {
-      background-color: var(--mainColor);
+      background-color: var(--mainColor, dimgray);
     }
 
   }
index 77843f149d9d51896db763a47184487ecfcab019..ab207445950daa9169502744b9b75a68e3774e36 100644 (file)
@@ -1,5 +1,5 @@
 import * as express from 'express'
-import { UserRight, VideoAbuseCreate, VideoAbuseState, VideoAbuse } from '../../../../shared'
+import { UserRight, VideoAbuseCreate, VideoAbuseState, VideoAbuse, videoAbusePredefinedReasonsMap } from '../../../../shared'
 import { logger } from '../../../helpers/logger'
 import { getFormattedObjects } from '../../../helpers/utils'
 import { sequelizeTypescript } from '../../../initializers/database'
@@ -74,6 +74,7 @@ async function listVideoAbuses (req: express.Request, res: express.Response) {
     count: req.query.count,
     sort: req.query.sort,
     id: req.query.id,
+    predefinedReason: req.query.predefinedReason,
     search: req.query.search,
     state: req.query.state,
     videoIs: req.query.videoIs,
@@ -123,12 +124,16 @@ async function reportVideoAbuse (req: express.Request, res: express.Response) {
 
   const videoAbuseInstance = await sequelizeTypescript.transaction(async t => {
     reporterAccount = await AccountModel.load(res.locals.oauth.token.User.Account.id, t)
+    const predefinedReasons = body.predefinedReasons?.map(r => videoAbusePredefinedReasonsMap[r])
 
     const abuseToCreate = {
       reporterAccountId: reporterAccount.id,
       reason: body.reason,
       videoId: videoInstance.id,
-      state: VideoAbuseState.PENDING
+      state: VideoAbuseState.PENDING,
+      predefinedReasons,
+      startAt: body.startAt,
+      endAt: body.endAt
     }
 
     const videoAbuseInstance: MVideoAbuseAccountVideo = await VideoAbuseModel.create(abuseToCreate, { transaction: t })
@@ -152,7 +157,7 @@ async function reportVideoAbuse (req: express.Request, res: express.Response) {
     reporter: reporterAccount.Actor.getIdentifier()
   })
 
-  logger.info('Abuse report for video %s created.', videoInstance.name)
+  logger.info('Abuse report for video "%s" created.', videoInstance.name)
 
   return res.json({ videoAbuse: videoAbuseJSON }).end()
 }
index 05e11b1c69c25908dda652f61b6d9f220105bb57..0c2c342681ea3aae4de8d2e547d8778bc7e04502 100644 (file)
@@ -1,8 +1,9 @@
 import validator from 'validator'
 
 import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants'
-import { exists } from './misc'
+import { exists, isArray } from './misc'
 import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type'
+import { VideoAbusePredefinedReasonsString, videoAbusePredefinedReasonsMap } from '@shared/models/videos/abuse/video-abuse-reason.model'
 
 const VIDEO_ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_ABUSES
 
@@ -10,6 +11,22 @@ function isVideoAbuseReasonValid (value: string) {
   return exists(value) && validator.isLength(value, VIDEO_ABUSES_CONSTRAINTS_FIELDS.REASON)
 }
 
+function isVideoAbusePredefinedReasonValid (value: VideoAbusePredefinedReasonsString) {
+  return exists(value) && value in videoAbusePredefinedReasonsMap
+}
+
+function isVideoAbusePredefinedReasonsValid (value: VideoAbusePredefinedReasonsString[]) {
+  return exists(value) && isArray(value) && value.every(v => v in videoAbusePredefinedReasonsMap)
+}
+
+function isVideoAbuseTimestampValid (value: number) {
+  return value === null || (exists(value) && validator.isInt('' + value, { min: 0 }))
+}
+
+function isVideoAbuseTimestampCoherent (endAt: number, { req }) {
+  return exists(req.body.startAt) && endAt > req.body.startAt
+}
+
 function isVideoAbuseModerationCommentValid (value: string) {
   return exists(value) && validator.isLength(value, VIDEO_ABUSES_CONSTRAINTS_FIELDS.MODERATION_COMMENT)
 }
@@ -28,8 +45,12 @@ function isAbuseVideoIsValid (value: VideoAbuseVideoIs) {
 // ---------------------------------------------------------------------------
 
 export {
-  isVideoAbuseStateValid,
   isVideoAbuseReasonValid,
-  isAbuseVideoIsValid,
-  isVideoAbuseModerationCommentValid
+  isVideoAbusePredefinedReasonValid,
+  isVideoAbusePredefinedReasonsValid,
+  isVideoAbuseTimestampValid,
+  isVideoAbuseTimestampCoherent,
+  isVideoAbuseModerationCommentValid,
+  isVideoAbuseStateValid,
+  isAbuseVideoIsValid
 }
index 314f094b3acf322028e611bf5e7b2cc28e5ea41c..dd79c0e168a46066e3ed63346a2e70210de6e95a 100644 (file)
@@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
 
 // ---------------------------------------------------------------------------
 
-const LAST_MIGRATION_VERSION = 510
+const LAST_MIGRATION_VERSION = 515
 
 // ---------------------------------------------------------------------------
 
diff --git a/server/initializers/migrations/0515-video-abuse-reason-timestamps.ts b/server/initializers/migrations/0515-video-abuse-reason-timestamps.ts
new file mode 100644 (file)
index 0000000..c583356
--- /dev/null
@@ -0,0 +1,31 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction
+  queryInterface: Sequelize.QueryInterface
+  sequelize: Sequelize.Sequelize
+}): Promise<void> {
+  await utils.queryInterface.addColumn('videoAbuse', 'predefinedReasons', {
+    type: Sequelize.ARRAY(Sequelize.INTEGER),
+    allowNull: true
+  })
+
+  await utils.queryInterface.addColumn('videoAbuse', 'startAt', {
+    type: Sequelize.INTEGER,
+    allowNull: true
+  })
+
+  await utils.queryInterface.addColumn('videoAbuse', 'endAt', {
+    type: Sequelize.INTEGER,
+    allowNull: true
+  })
+}
+
+function down (options) {
+  throw new Error('Not implemented.')
+}
+
+export {
+  up,
+  down
+}
index 8d1c9c869e9bb2272e8983ac97fa1aaf0d5daee2..1d7132a3a7010f8a04ac94747ae12af42e20070f 100644 (file)
@@ -1,4 +1,9 @@
-import { ActivityCreate, ActivityFlag, VideoAbuseState } from '../../../../shared'
+import {
+  ActivityCreate,
+  ActivityFlag,
+  VideoAbuseState,
+  videoAbusePredefinedReasonsMap
+} from '../../../../shared'
 import { VideoAbuseObject } from '../../../../shared/models/activitypub/objects'
 import { retryTransactionWrapper } from '../../../helpers/database-utils'
 import { logger } from '../../../helpers/logger'
@@ -38,13 +43,21 @@ async function processCreateVideoAbuse (activity: ActivityCreate | ActivityFlag,
 
       const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: object })
       const reporterAccount = await sequelizeTypescript.transaction(async t => AccountModel.load(account.id, t))
+      const tags = Array.isArray(flag.tag) ? flag.tag : []
+      const predefinedReasons = tags.map(tag => videoAbusePredefinedReasonsMap[tag.name])
+                                    .filter(v => !isNaN(v))
+      const startAt = flag.startAt
+      const endAt = flag.endAt
 
       const videoAbuseInstance = await sequelizeTypescript.transaction(async t => {
         const videoAbuseData = {
           reporterAccountId: account.id,
           reason: flag.content,
           videoId: video.id,
-          state: VideoAbuseState.PENDING
+          state: VideoAbuseState.PENDING,
+          predefinedReasons,
+          startAt,
+          endAt
         }
 
         const videoAbuseInstance: MVideoAbuseAccountVideo = await VideoAbuseModel.create(videoAbuseData, { transaction: t })
index 901997bcb2d6fa7a78da8cadbbdb43c6a58bb98d..5bbd1e3c60eb2f7bd5fa4e68f8fbb45b88515141 100644 (file)
@@ -1,19 +1,46 @@
 import * as express from 'express'
 import { body, param, query } from 'express-validator'
-import { exists, isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc'
+import { exists, isIdOrUUIDValid, isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
 import {
   isAbuseVideoIsValid,
   isVideoAbuseModerationCommentValid,
   isVideoAbuseReasonValid,
-  isVideoAbuseStateValid
+  isVideoAbuseStateValid,
+  isVideoAbusePredefinedReasonsValid,
+  isVideoAbusePredefinedReasonValid,
+  isVideoAbuseTimestampValid,
+  isVideoAbuseTimestampCoherent
 } from '../../../helpers/custom-validators/video-abuses'
 import { logger } from '../../../helpers/logger'
 import { doesVideoAbuseExist, doesVideoExist } from '../../../helpers/middlewares'
 import { areValidationErrors } from '../utils'
 
 const videoAbuseReportValidator = [
-  param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
-  body('reason').custom(isVideoAbuseReasonValid).withMessage('Should have a valid reason'),
+  param('videoId')
+    .custom(isIdOrUUIDValid)
+    .not()
+    .isEmpty()
+    .withMessage('Should have a valid videoId'),
+  body('reason')
+    .custom(isVideoAbuseReasonValid)
+    .withMessage('Should have a valid reason'),
+  body('predefinedReasons')
+    .optional()
+    .custom(isVideoAbusePredefinedReasonsValid)
+    .withMessage('Should have a valid list of predefined reasons'),
+  body('startAt')
+    .optional()
+    .customSanitizer(toIntOrNull)
+    .custom(isVideoAbuseTimestampValid)
+    .withMessage('Should have valid starting time value'),
+  body('endAt')
+    .optional()
+    .customSanitizer(toIntOrNull)
+    .custom(isVideoAbuseTimestampValid)
+    .withMessage('Should have valid ending time value')
+    .bail()
+    .custom(isVideoAbuseTimestampCoherent)
+    .withMessage('Should have a startAt timestamp beginning before endAt'),
 
   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
     logger.debug('Checking videoAbuseReport parameters', { parameters: req.body })
@@ -63,6 +90,10 @@ const videoAbuseListValidator = [
   query('id')
     .optional()
     .custom(isIdValid).withMessage('Should have a valid id'),
+  query('predefinedReason')
+    .optional()
+    .custom(isVideoAbusePredefinedReasonValid)
+    .withMessage('Should have a valid predefinedReason'),
   query('search')
     .optional()
     .custom(exists).withMessage('Should have a valid search'),
index b2f11133726847a8d7acaa15e969b271b1745f2a..1319332f0738fa80b645d67f42d71c8fd33b20cc 100644 (file)
@@ -15,7 +15,13 @@ import {
   UpdatedAt
 } from 'sequelize-typescript'
 import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type'
-import { VideoAbuseState, VideoDetails } from '../../../shared'
+import {
+  VideoAbuseState,
+  VideoDetails,
+  VideoAbusePredefinedReasons,
+  VideoAbusePredefinedReasonsString,
+  videoAbusePredefinedReasonsMap
+} from '../../../shared'
 import { VideoAbuseObject } from '../../../shared/models/activitypub/objects'
 import { VideoAbuse } from '../../../shared/models/videos'
 import {
@@ -31,6 +37,7 @@ import { ThumbnailModel } from './thumbnail'
 import { VideoModel } from './video'
 import { VideoBlacklistModel } from './video-blacklist'
 import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
+import { invert } from 'lodash'
 
 export enum ScopeNames {
   FOR_API = 'FOR_API'
@@ -47,6 +54,7 @@ export enum ScopeNames {
 
     // filters
     id?: number
+    predefinedReasonId?: number
 
     state?: VideoAbuseState
     videoIs?: VideoAbuseVideoIs
@@ -104,6 +112,14 @@ export enum ScopeNames {
       })
     }
 
+    if (options.predefinedReasonId) {
+      Object.assign(where, {
+        predefinedReasons: {
+          [Op.contains]: [ options.predefinedReasonId ]
+        }
+      })
+    }
+
     const onlyBlacklisted = options.videoIs === 'blacklisted'
 
     return {
@@ -258,6 +274,21 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
   @Column(DataType.JSONB)
   deletedVideo: VideoDetails
 
+  @AllowNull(true)
+  @Default(null)
+  @Column(DataType.ARRAY(DataType.INTEGER))
+  predefinedReasons: VideoAbusePredefinedReasons[]
+
+  @AllowNull(true)
+  @Default(null)
+  @Column
+  startAt: number
+
+  @AllowNull(true)
+  @Default(null)
+  @Column
+  endAt: number
+
   @CreatedAt
   createdAt: Date
 
@@ -311,6 +342,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
     user?: MUserAccountId
 
     id?: number
+    predefinedReason?: VideoAbusePredefinedReasonsString
     state?: VideoAbuseState
     videoIs?: VideoAbuseVideoIs
 
@@ -329,6 +361,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
       serverAccountId,
       state,
       videoIs,
+      predefinedReason,
       searchReportee,
       searchVideo,
       searchVideoChannel,
@@ -337,6 +370,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
     } = parameters
 
     const userAccountId = user ? user.Account.id : undefined
+    const predefinedReasonId = predefinedReason ? videoAbusePredefinedReasonsMap[predefinedReason] : undefined
 
     const query = {
       offset: start,
@@ -348,6 +382,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
 
     const filters = {
       id,
+      predefinedReasonId,
       search,
       state,
       videoIs,
@@ -360,7 +395,9 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
     }
 
     return VideoAbuseModel
-      .scope({ method: [ ScopeNames.FOR_API, filters ] })
+      .scope([
+        { method: [ ScopeNames.FOR_API, filters ] }
+      ])
       .findAndCountAll(query)
       .then(({ rows, count }) => {
         return { total: count, data: rows }
@@ -368,6 +405,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
   }
 
   toFormattedJSON (this: MVideoAbuseFormattable): VideoAbuse {
+    const predefinedReasons = VideoAbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
     const countReportsForVideo = this.get('countReportsForVideo') as number
     const nthReportForVideo = this.get('nthReportForVideo') as number
     const countReportsForReporterVideo = this.get('countReportsForReporter__video') as number
@@ -382,6 +420,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
     return {
       id: this.id,
       reason: this.reason,
+      predefinedReasons,
       reporterAccount: this.Account.toFormattedJSON(),
       state: {
         id: this.state,
@@ -400,6 +439,8 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
       },
       createdAt: this.createdAt,
       updatedAt: this.updatedAt,
+      startAt: this.startAt,
+      endAt: this.endAt,
       count: countReportsForVideo || 0,
       nth: nthReportForVideo || 0,
       countReportsForReporter: (countReportsForReporterVideo || 0) + (countReportsForReporterDeletedVideo || 0),
@@ -408,14 +449,31 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
   }
 
   toActivityPubObject (this: MVideoAbuseVideo): VideoAbuseObject {
+    const predefinedReasons = VideoAbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
+
+    const startAt = this.startAt
+    const endAt = this.endAt
+
     return {
       type: 'Flag' as 'Flag',
       content: this.reason,
-      object: this.Video.url
+      object: this.Video.url,
+      tag: predefinedReasons.map(r => ({
+        type: 'Hashtag' as 'Hashtag',
+        name: r
+      })),
+      startAt,
+      endAt
     }
   }
 
   private static getStateLabel (id: number) {
     return VIDEO_ABUSE_STATES[id] || 'Unknown'
   }
+
+  private static getPredefinedReasonsStrings (predefinedReasons: VideoAbusePredefinedReasons[]): VideoAbusePredefinedReasonsString[] {
+    return (predefinedReasons || [])
+      .filter(r => r in VideoAbusePredefinedReasons)
+      .map(r => invert(videoAbusePredefinedReasonsMap)[r] as VideoAbusePredefinedReasonsString)
+  }
 }
index a3fe00ffbdfac827820c6f13325dcc18dddd7101..557bf20eb41f79e0699d46fb672d79ed9574fb0f 100644 (file)
@@ -20,7 +20,7 @@ import {
   checkBadSortPagination,
   checkBadStartPagination
 } from '../../../../shared/extra-utils/requests/check-api-params'
-import { VideoAbuseState } from '../../../../shared/models/videos'
+import { VideoAbuseState, VideoAbuseCreate } from '../../../../shared/models/videos'
 
 describe('Test video abuses API validators', function () {
   let server: ServerInfo
@@ -132,12 +132,36 @@ describe('Test video abuses API validators', function () {
       await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
     })
 
-    it('Should succeed with the correct parameters', async function () {
-      const fields = { reason: 'super reason' }
+    it('Should succeed with the correct parameters (basic)', async function () {
+      const fields = { reason: 'my super reason' }
 
       const res = await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 200 })
       videoAbuseId = res.body.videoAbuse.id
     })
+
+    it('Should fail with a wrong predefined reason', async function () {
+      const fields = { reason: 'my super reason', predefinedReasons: [ 'wrongPredefinedReason' ] }
+
+      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
+    })
+
+    it('Should fail with negative timestamps', async function () {
+      const fields = { reason: 'my super reason', startAt: -1 }
+
+      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
+    })
+
+    it('Should fail mith misordered startAt/endAt', async function () {
+      const fields = { reason: 'my super reason', startAt: 5, endAt: 1 }
+
+      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
+    })
+
+    it('Should succeed with the corret parameters (advanced)', async function () {
+      const fields: VideoAbuseCreate = { reason: 'my super reason', predefinedReasons: [ 'serverRules' ], startAt: 1, endAt: 5 }
+
+      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 200 })
+    })
   })
 
   describe('When updating a video abuse', function () {
index a96be97f63629f68d3f687ba043ad3fde511896b..7383bd991c51207a4c1e3e46b8dfc1c76e033f3c 100644 (file)
@@ -2,7 +2,7 @@
 
 import * as chai from 'chai'
 import 'mocha'
-import { VideoAbuse, VideoAbuseState } from '../../../../shared/models/videos'
+import { VideoAbuse, VideoAbuseState, VideoAbusePredefinedReasonsString } from '../../../../shared/models/videos'
 import {
   cleanupTests,
   deleteVideoAbuse,
@@ -291,6 +291,32 @@ describe('Test video abuses', function () {
     }
   })
 
+  it('Should list predefined reasons as well as timestamps for the reported video', async function () {
+    this.timeout(10000)
+
+    const reason5 = 'my super bad reason 5'
+    const predefinedReasons5: VideoAbusePredefinedReasonsString[] = [ 'violentOrRepulsive', 'captions' ]
+    const createdAbuse = (await reportVideoAbuse(
+      servers[0].url,
+      servers[0].accessToken,
+      servers[0].video.id,
+      reason5,
+      predefinedReasons5,
+      1,
+      5
+    )).body.videoAbuse as VideoAbuse
+
+    const res = await getVideoAbusesList({ url: servers[0].url, token: servers[0].accessToken })
+
+    {
+      const abuse = (res.body.data as VideoAbuse[]).find(a => a.id === createdAbuse.id)
+      expect(abuse.reason).to.equals(reason5)
+      expect(abuse.predefinedReasons).to.deep.equals(predefinedReasons5, "predefined reasons do not match the one reported")
+      expect(abuse.startAt).to.equal(1, "starting timestamp doesn't match the one reported")
+      expect(abuse.endAt).to.equal(5, "ending timestamp doesn't match the one reported")
+    }
+  })
+
   it('Should delete the video abuse', async function () {
     this.timeout(10000)
 
@@ -307,7 +333,7 @@ describe('Test video abuses', function () {
 
     {
       const res = await getVideoAbusesList({ url: servers[0].url, token: servers[0].accessToken })
-      expect(res.body.total).to.equal(5)
+      expect(res.body.total).to.equal(6)
     }
   })
 
@@ -328,25 +354,28 @@ describe('Test video abuses', function () {
     expect(await list({ id: 56 })).to.have.lengthOf(0)
     expect(await list({ id: 1 })).to.have.lengthOf(1)
 
-    expect(await list({ search: 'my super name for server 1' })).to.have.lengthOf(3)
+    expect(await list({ search: 'my super name for server 1' })).to.have.lengthOf(4)
     expect(await list({ search: 'aaaaaaaaaaaaaaaaaaaaaaaaaa' })).to.have.lengthOf(0)
 
     expect(await list({ searchVideo: 'my second super name for server 1' })).to.have.lengthOf(1)
 
-    expect(await list({ searchVideoChannel: 'root' })).to.have.lengthOf(3)
+    expect(await list({ searchVideoChannel: 'root' })).to.have.lengthOf(4)
     expect(await list({ searchVideoChannel: 'aaaa' })).to.have.lengthOf(0)
 
     expect(await list({ searchReporter: 'user2' })).to.have.lengthOf(1)
-    expect(await list({ searchReporter: 'root' })).to.have.lengthOf(4)
+    expect(await list({ searchReporter: 'root' })).to.have.lengthOf(5)
 
-    expect(await list({ searchReportee: 'root' })).to.have.lengthOf(3)
+    expect(await list({ searchReportee: 'root' })).to.have.lengthOf(4)
     expect(await list({ searchReportee: 'aaaa' })).to.have.lengthOf(0)
 
     expect(await list({ videoIs: 'deleted' })).to.have.lengthOf(1)
     expect(await list({ videoIs: 'blacklisted' })).to.have.lengthOf(0)
 
     expect(await list({ state: VideoAbuseState.ACCEPTED })).to.have.lengthOf(0)
-    expect(await list({ state: VideoAbuseState.PENDING })).to.have.lengthOf(5)
+    expect(await list({ state: VideoAbuseState.PENDING })).to.have.lengthOf(6)
+
+    expect(await list({ predefinedReason: 'violentOrRepulsive' })).to.have.lengthOf(1)
+    expect(await list({ predefinedReason: 'serverRules' })).to.have.lengthOf(0)
   })
 
   after(async function () {
index 81582bfc7fd22e0f982c76e903c84536b5ad85fa..ff006672ad99a42a5e44867d09a71771d81a42b3 100644 (file)
@@ -1,17 +1,26 @@
 import * as request from 'supertest'
 import { VideoAbuseUpdate } from '../../models/videos/abuse/video-abuse-update.model'
 import { makeDeleteRequest, makePutBodyRequest, makeGetRequest } from '../requests/requests'
-import { VideoAbuseState } from '@shared/models'
+import { VideoAbuseState, VideoAbusePredefinedReasonsString } from '@shared/models'
 import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type'
 
-function reportVideoAbuse (url: string, token: string, videoId: number | string, reason: string, specialStatus = 200) {
+function reportVideoAbuse (
+  url: string,
+  token: string,
+  videoId: number | string,
+  reason: string,
+  predefinedReasons?: VideoAbusePredefinedReasonsString[],
+  startAt?: number,
+  endAt?: number,
+  specialStatus = 200
+) {
   const path = '/api/v1/videos/' + videoId + '/abuse'
 
   return request(url)
           .post(path)
           .set('Accept', 'application/json')
           .set('Authorization', 'Bearer ' + token)
-          .send({ reason })
+          .send({ reason, predefinedReasons, startAt, endAt })
           .expect(specialStatus)
 }
 
@@ -19,6 +28,7 @@ function getVideoAbusesList (options: {
   url: string
   token: string
   id?: number
+  predefinedReason?: VideoAbusePredefinedReasonsString
   search?: string
   state?: VideoAbuseState
   videoIs?: VideoAbuseVideoIs
@@ -31,6 +41,7 @@ function getVideoAbusesList (options: {
     url,
     token,
     id,
+    predefinedReason,
     search,
     state,
     videoIs,
@@ -44,6 +55,7 @@ function getVideoAbusesList (options: {
   const query = {
     sort: 'createdAt',
     id,
+    predefinedReason,
     search,
     state,
     videoIs,
index 20ecf176c607ebbf3ab138d0d92491cd19461217..31b9e46739718ad82479637ec9ee2a5398e9a52a 100644 (file)
@@ -1,6 +1,6 @@
 import { ActivityPubActor } from './activitypub-actor'
 import { ActivityPubSignature } from './activitypub-signature'
-import { CacheFileObject, VideoTorrentObject } from './objects'
+import { CacheFileObject, VideoTorrentObject, ActivityFlagReasonObject } from './objects'
 import { DislikeObject } from './objects/dislike-object'
 import { VideoAbuseObject } from './objects/video-abuse-object'
 import { VideoCommentObject } from './objects/video-comment-object'
@@ -113,4 +113,7 @@ export interface ActivityFlag extends BaseActivity {
   type: 'Flag'
   content: string
   object: APObject | APObject[]
+  tag?: ActivityFlagReasonObject[]
+  startAt?: number
+  endAt?: number
 }
index bb3ffe6785ec28975b4c1ffb8e2604c3e5b45831..096d422eab117a4aaa3f12c30ff75bf64246b2a7 100644 (file)
@@ -1,3 +1,5 @@
+import { VideoAbusePredefinedReasonsString } from '@shared/models/videos'
+
 export interface ActivityIdentifierObject {
   identifier: string
   name: string
@@ -70,17 +72,22 @@ export type ActivityHtmlUrlObject = {
 }
 
 export interface ActivityHashTagObject {
-  type: 'Hashtag' | 'Mention'
+  type: 'Hashtag'
   href?: string
   name: string
 }
 
 export interface ActivityMentionObject {
-  type: 'Hashtag' | 'Mention'
+  type: 'Mention'
   href?: string
   name: string
 }
 
+export interface ActivityFlagReasonObject {
+  type: 'Hashtag'
+  name: VideoAbusePredefinedReasonsString
+}
+
 export type ActivityTagObject =
   ActivityPlaylistSegmentHashesObject
   | ActivityPlaylistInfohashesObject
index d9622b414895b6c70420b6284dc1fffa9c1971a1..73add8ef4479aa99922b2a87d8ec3bce6bb7e005 100644 (file)
@@ -1,5 +1,10 @@
+import { ActivityFlagReasonObject } from './common-objects'
+
 export interface VideoAbuseObject {
   type: 'Flag'
   content: string
   object: string | string[]
+  tag?: ActivityFlagReasonObject[]
+  startAt?: number
+  endAt?: number
 }
index db64582755f448cacba1bcb53625da7b21aecf00..c93cb8b2c42efd791ec965adaf800ab1ac4c0b06 100644 (file)
@@ -1,3 +1,8 @@
+import { VideoAbusePredefinedReasonsString } from './video-abuse-reason.model'
+
 export interface VideoAbuseCreate {
   reason: string
+  predefinedReasons?: VideoAbusePredefinedReasonsString[]
+  startAt?: number
+  endAt?: number
 }
diff --git a/shared/models/videos/abuse/video-abuse-reason.model.ts b/shared/models/videos/abuse/video-abuse-reason.model.ts
new file mode 100644 (file)
index 0000000..9064f0c
--- /dev/null
@@ -0,0 +1,33 @@
+export enum VideoAbusePredefinedReasons {
+  VIOLENT_OR_REPULSIVE = 1,
+  HATEFUL_OR_ABUSIVE,
+  SPAM_OR_MISLEADING,
+  PRIVACY,
+  RIGHTS,
+  SERVER_RULES,
+  THUMBNAILS,
+  CAPTIONS
+}
+
+export type VideoAbusePredefinedReasonsString =
+  'violentOrRepulsive' |
+  'hatefulOrAbusive' |
+  'spamOrMisleading' |
+  'privacy' |
+  'rights' |
+  'serverRules' |
+  'thumbnails' |
+  'captions'
+
+export const videoAbusePredefinedReasonsMap: {
+  [key in VideoAbusePredefinedReasonsString]: VideoAbusePredefinedReasons
+} = {
+  violentOrRepulsive: VideoAbusePredefinedReasons.VIOLENT_OR_REPULSIVE,
+  hatefulOrAbusive: VideoAbusePredefinedReasons.HATEFUL_OR_ABUSIVE,
+  spamOrMisleading: VideoAbusePredefinedReasons.SPAM_OR_MISLEADING,
+  privacy: VideoAbusePredefinedReasons.PRIVACY,
+  rights: VideoAbusePredefinedReasons.RIGHTS,
+  serverRules: VideoAbusePredefinedReasons.SERVER_RULES,
+  thumbnails: VideoAbusePredefinedReasons.THUMBNAILS,
+  captions: VideoAbusePredefinedReasons.CAPTIONS
+}
index f2c2cdc415d99b07d2b9079f0d86fab7ee721474..38605dcac051b7c99d1915a2575fa1ecfc4c7085 100644 (file)
@@ -2,10 +2,12 @@ import { Account } from '../../actors/index'
 import { VideoConstant } from '../video-constant.model'
 import { VideoAbuseState } from './video-abuse-state.model'
 import { VideoChannel } from '../channel/video-channel.model'
+import { VideoAbusePredefinedReasonsString } from './video-abuse-reason.model'
 
 export interface VideoAbuse {
   id: number
   reason: string
+  predefinedReasons?: VideoAbusePredefinedReasonsString[]
   reporterAccount: Account
 
   state: VideoConstant<VideoAbuseState>
@@ -25,6 +27,9 @@ export interface VideoAbuse {
   createdAt: Date
   updatedAt: Date
 
+  startAt: number
+  endAt: number
+
   count?: number
   nth?: number
 
index 51ccb9fbd0e40c60e671e399f620e31e8a657ca7..58bd1ebd7df59fc0cc40bd44948181e772fde9de 100644 (file)
@@ -4,6 +4,7 @@ export * from './rate/account-video-rate.model'
 export * from './rate/user-video-rate.type'
 export * from './abuse/video-abuse-state.model'
 export * from './abuse/video-abuse-create.model'
+export * from './abuse/video-abuse-reason.model'
 export * from './abuse/video-abuse.model'
 export * from './abuse/video-abuse-update.model'
 export * from './blacklist/video-blacklist.model'
index 501187d8fb450cfedff3197c20f5d58b6675bb13..9434af9049097b15e9d76156bcde36d69582845a 100644 (file)
@@ -120,7 +120,7 @@ x-tagGroups:
   - name: Moderation
     tags:
       - Video Abuses
-      - Video Blacklist
+      - Video Blocks
   - name: Instance Configuration
     tags:
       - Config
@@ -1245,6 +1245,7 @@ paths:
       parameters:
         - $ref: '#/components/parameters/idOrUUID'
       requestBody:
+        required: true
         content:
           application/json:
             schema:
@@ -1253,6 +1254,28 @@ paths:
                 reason:
                   description: Reason why the user reports this video
                   type: string
+                predefinedReasons:
+                  description: Reason categories that help triage reports
+                  type: array
+                  items:
+                    type: string
+                    enum:
+                    - violentOrAbusive
+                    - hatefulOrAbusive
+                    - spamOrMisleading
+                    - privacy
+                    - rights
+                    - serverRules
+                    - thumbnails
+                    - captions
+                startAt:
+                  type: number
+                  description: Timestamp in the video that marks the beginning of the report
+                endAt:
+                  type: number
+                  description: Timestamp in the video that marks the ending of the report
+              required:
+                - reason
       responses:
         '204':
           description: successful operation
@@ -2488,6 +2511,19 @@ components:
           $ref: '#/components/schemas/VideoAbuseStateSet'
         label:
           type: string
+    VideoAbusePredefinedReasons:
+      type: array
+      items:
+        type: string
+        enum:
+        - violentOrAbusive
+        - hatefulOrAbusive
+        - spamOrMisleading
+        - privacy
+        - rights
+        - serverRules
+        - thumbnails
+        - captions
 
     VideoResolutionConstant:
       properties:
@@ -2739,6 +2775,8 @@ components:
           type: number
         reason:
           type: string
+        predefinedReasons:
+          $ref: '#/components/schemas/VideoAbusePredefinedReasons'
         reporterAccount:
           $ref: '#/components/schemas/Account'
         state: