Add ability to report comments in front end

This commit is contained in:
Chocobozzz 2020-07-09 11:58:46 +02:00 committed by Chocobozzz
parent 310b5219b3
commit 8ca56654a1
22 changed files with 488 additions and 163 deletions

View File

@ -47,8 +47,8 @@ export class AdminComponent implements OnInit {
if (this.hasAbusesRight()) { if (this.hasAbusesRight()) {
moderationItems.children.push({ moderationItems.children.push({
label: this.i18n('Video reports'), label: this.i18n('Reports'),
routerLink: '/admin/moderation/video-abuses/list', routerLink: '/admin/moderation/abuses/list',
iconName: 'flag' iconName: 'flag'
}) })
} }

View File

@ -3,10 +3,13 @@
<div class="col-8"> <div class="col-8">
<!-- report metadata --> <!-- report metadata -->
<div class="d-flex"> <div class="d-flex" *ngIf="abuse.reporterAccount">
<span class="col-3 moderation-expanded-label" i18n>Reporter</span> <span class="col-3 moderation-expanded-label" i18n>Reporter</span>
<span class="col-9 moderation-expanded-text"> <span class="col-9 moderation-expanded-text">
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }" class="chip"> <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }"
class="chip"
>
<img <img
class="avatar" class="avatar"
[src]="abuse.reporterAccount.avatar?.path" [src]="abuse.reporterAccount.avatar?.path"
@ -17,27 +20,35 @@
<span class="text-muted">{{ abuse.reporterAccount.nameWithHost }}</span> <span class="text-muted">{{ abuse.reporterAccount.nameWithHost }}</span>
</div> </div>
</a> </a>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }" class="ml-auto text-muted video-details-links" i18n>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }"
class="ml-auto text-muted abuse-details-links" i18n
>
{abuse.countReportsForReporter, plural, =1 {1 report} other {{{ abuse.countReportsForReporter }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span> {abuse.countReportsForReporter, plural, =1 {1 report} other {{{ abuse.countReportsForReporter }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
</a> </a>
</span> </span>
</div> </div>
<div class="d-flex"> <div class="d-flex" *ngIf="abuse.flaggedAccount">
<span class="col-3 moderation-expanded-label" i18n>Reportee</span> <span class="col-3 moderation-expanded-label" i18n>Reportee</span>
<span class="col-9 moderation-expanded-text"> <span class="col-9 moderation-expanded-text">
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.video.channel.ownerAccount.displayName + '&quot;' }" class="chip"> <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }"
class="chip"
>
<img <img
class="avatar" class="avatar"
[src]="abuse.video.channel.ownerAccount?.avatar?.path" [src]="abuse.flaggedAccount?.avatar?.path"
(error)="switchToDefaultAvatar($event)" (error)="switchToDefaultAvatar($event)"
alt="Avatar" alt="Avatar"
> >
<div> <div>
<span class="text-muted">{{ abuse.video.channel.ownerAccount ? abuse.video.channel.ownerAccount.nameWithHost : '' }}</span> <span class="text-muted">{{ abuse.flaggedAccount ? abuse.flaggedAccount.nameWithHost : '' }}</span>
</div> </div>
</a> </a>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.video.channel.ownerAccount.displayName + '&quot;' }" class="ml-auto text-muted video-details-links" i18n>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }"
class="ml-auto text-muted abuse-details-links" i18n
>
{abuse.countReportsForReportee, plural, =1 {1 report} other {{{ abuse.countReportsForReportee }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span> {abuse.countReportsForReportee, plural, =1 {1 report} other {{{ abuse.countReportsForReportee }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
</a> </a>
</span> </span>
@ -45,7 +56,7 @@
<div class="d-flex" *ngIf="abuse.updatedAt"> <div class="d-flex" *ngIf="abuse.updatedAt">
<span class="col-3 moderation-expanded-label" i18n>Updated</span> <span class="col-3 moderation-expanded-label" i18n>Updated</span>
<time class="col-9 moderation-expanded-text video-details-date-updated">{{ abuse.updatedAt | date: 'medium' }}</time> <time class="col-9 moderation-expanded-text abuse-details-date-updated">{{ abuse.updatedAt | date: 'medium' }}</time>
</div> </div>
<!-- report text --> <!-- report text -->
@ -60,34 +71,45 @@
<div *ngIf="getPredefinedReasons()" class="mt-2 d-flex"> <div *ngIf="getPredefinedReasons()" class="mt-2 d-flex">
<span class="col-3"></span> <span class="col-3"></span>
<span class="col-9"> <span class="col-9">
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'tag:' + reason.id }" class="chip rectangular bg-secondary text-light" *ngFor="let reason of getPredefinedReasons()"> <a *ngFor="let reason of getPredefinedReasons()" [routerLink]="[ '/admin/moderation/abuses/list' ]"
[queryParams]="{ 'search': 'tag:' + reason.id }" class="chip rectangular bg-secondary text-light"
>
<div>{{ reason.label }}</div> <div>{{ reason.label }}</div>
</a> </a>
</span> </span>
</div> </div>
<div *ngIf="abuse.startAt" class="mt-2 d-flex"> <div *ngIf="abuse.video?.startAt" class="mt-2 d-flex">
<span class="col-3 moderation-expanded-label" i18n>Reported part</span> <span class="col-3 moderation-expanded-label" i18n>Reported part</span>
<span class="col-9"> <span class="col-9">
{{ startAt }}<ng-container *ngIf="abuse.endAt"> - {{ endAt }}</ng-container> {{ startAt }}<ng-container *ngIf="abuse.video.endAt"> - {{ endAt }}</ng-container>
</span> </span>
</div> </div>
<div class="mt-3 d-flex" *ngIf="abuse.moderationComment"> <div class="mt-3 d-flex" *ngIf="abuse.moderationComment">
<span class="col-3 moderation-expanded-label" i18n>Note</span> <span class="col-3 moderation-expanded-label" i18n>Note</span>
<span class="col-9 moderation-expanded-text" [innerHTML]="abuse.moderationCommentHtml"></span> <span class="col-9 moderation-expanded-text d-block" [innerHTML]="abuse.moderationCommentHtml"></span>
</div> </div>
</div> </div>
<!-- report right part (video details) --> <!-- report right part (video/comment details) -->
<div class="col-4"> <div class="col-4">
<div class="screenratio"> <div *ngIf="abuse.video" class="screenratio">
<div *ngIf="abuse.video.deleted || abuse.video.blacklisted"> <div>
<span i18n *ngIf="abuse.video.deleted">The video was deleted</span> <span i18n *ngIf="abuse.video.deleted">The video was deleted</span>
<span i18n *ngIf="!abuse.video.deleted">The video was blocked</span> <span i18n *ngIf="!abuse.video.deleted">The video was blocked</span>
</div> </div>
<div *ngIf="!abuse.video.deleted && !abuse.video.blacklisted" [innerHTML]="abuse.embedHtml"></div> <div *ngIf="!abuse.video.deleted && !abuse.video.blacklisted" [innerHTML]="abuse.embedHtml"></div>
</div> </div>
<div *ngIf="abuse.comment" class="comment-html">
<div>
<strong i18n>Comment:</strong>
</div>
<div [innerHTML]="abuse.commentHtml"></div>
</div>
</div> </div>
</div> </div>

View File

@ -31,15 +31,16 @@ export class AbuseDetailsComponent {
} }
get startAt () { get startAt () {
return durationToString(this.abuse.startAt) return durationToString(this.abuse.video.startAt)
} }
get endAt () { get endAt () {
return durationToString(this.abuse.endAt) return durationToString(this.abuse.video.endAt)
} }
getPredefinedReasons () { getPredefinedReasons () {
if (!this.abuse.predefinedReasons) return [] if (!this.abuse.predefinedReasons) return []
return this.abuse.predefinedReasons.map(r => ({ return this.abuse.predefinedReasons.map(r => ({
id: r, id: r,
label: this.predefinedReasonsTranslations[r] label: this.predefinedReasonsTranslations[r]

View File

@ -38,7 +38,7 @@
<tr> <!-- header --> <tr> <!-- header -->
<th style="width: 40px;"></th> <th style="width: 40px;"></th>
<th style="width: 20%;" pResizableColumn i18n>Reporter</th> <th style="width: 20%;" pResizableColumn i18n>Reporter</th>
<th i18n>Video</th> <th i18n>Video/Comment/Account</th>
<th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
<th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th> <th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
<th style="width: 150px;"></th> <th style="width: 150px;"></th>
@ -54,7 +54,7 @@
</td> </td>
<td> <td>
<a [href]="abuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer"> <a *ngIf="abuse.reporterAccount" [href]="abuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
<div class="chip two-lines"> <div class="chip two-lines">
<img <img
class="avatar" class="avatar"
@ -64,54 +64,73 @@
> >
<div> <div>
{{ abuse.reporterAccount.displayName }} {{ abuse.reporterAccount.displayName }}
<span class="text-muted">{{ abuse.reporterAccount.nameWithHost }}</span> <span>{{ abuse.reporterAccount.nameWithHost }}</span>
</div> </div>
</div> </div>
</a> </a>
<span i18n *ngIf="!abuse.reporterAccount">
Deleted account
</span>
</td> </td>
<td *ngIf="!abuse.video.deleted"> <ng-container *ngIf="abuse.video">
<a [href]="getVideoUrl(abuse)" class="video-table-video-link" [title]="abuse.video.name" target="_blank" rel="noopener noreferrer">
<div class="video-table-video"> <td *ngIf="!abuse.video.deleted">
<div class="video-table-video-image"> <a [href]="getVideoUrl(abuse)" class="table-video-link" [title]="abuse.video.name" target="_blank" rel="noopener noreferrer">
<img [src]="abuse.video.thumbnailPath"> <div class="table-video">
<span <div class="table-video-image">
class="video-table-video-image-label" *ngIf="abuse.count > 1" <img [src]="abuse.video.thumbnailPath">
i18n-title title="This video has been reported multiple times." <span
> class="table-video-image-label" *ngIf="abuse.count > 1"
{{ abuse.nth }}/{{ abuse.count }} i18n-title title="This video has been reported multiple times."
</span> >
</div> {{ abuse.nth }}/{{ abuse.count }}
<div class="video-table-video-text"> </span>
<div>
<span *ngIf="!abuse.video.blacklisted" class="glyphicon glyphicon-new-window"></span>
<span *ngIf="abuse.video.blacklisted" i18n-title title="The video was blocked" class="glyphicon glyphicon-ban-circle"></span>
{{ abuse.video.name }}
</div> </div>
<div class="text-muted" i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
</div>
</div>
</a>
</td>
<td *ngIf="abuse.video.deleted" class="c-hand" [pRowToggler]="abuse"> <div class="table-video-text">
<div class="video-table-video" i18n-title title="Video was deleted"> <div>
<div class="video-table-video-image"> <span *ngIf="!abuse.video.blacklisted" class="glyphicon glyphicon-new-window"></span>
<span i18n>Deleted</span> <span *ngIf="abuse.video.blacklisted" i18n-title title="The video was blocked" class="glyphicon glyphicon-ban-circle"></span>
</div> {{ abuse.video.name }}
<div class="video-table-video-text"> </div>
<div> <div i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
{{ abuse.video.name }} </div>
<span class="glyphicon glyphicon-trash"></span> </div>
</a>
</td>
<td *ngIf="abuse.video.deleted" class="c-hand" [pRowToggler]="abuse">
<div class="table-video" i18n-title title="Video was deleted">
<div class="table-video-image">
<span i18n>Deleted</span>
</div>
<div class="table-video-text">
<div>
{{ abuse.video.name }}
<span class="glyphicon glyphicon-trash"></span>
</div>
<div i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
</div> </div>
<div class="text-muted" i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
</div> </div>
</div> </td>
</td> </ng-container>
<ng-container *ngIf="abuse.comment">
<td>
<a [href]="getCommentUrl(abuse)" [innerHTML]="abuse.truncatedCommentHtml" class="table-comment-link"
[title]="abuse.comment.video.name" target="_blank" rel="noopener noreferrer"
></a>
<div class="comment-flagged-account" *ngIf="abuse.flaggedAccount">by {{ abuse.flaggedAccount.displayName }}</div>
</td>
</ng-container>
<td class="c-hand" [pRowToggler]="abuse">{{ abuse.createdAt | date: 'short' }}</td> <td class="c-hand" [pRowToggler]="abuse">{{ abuse.createdAt | date: 'short' }}</td>
<td class="c-hand video-abuse-states" [pRowToggler]="abuse"> <td class="c-hand abuse-states" [pRowToggler]="abuse">
<span *ngIf="isAbuseAccepted(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-ok"></span> <span *ngIf="isAbuseAccepted(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-ok"></span>
<span *ngIf="isAbuseRejected(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-remove"></span> <span *ngIf="isAbuseRejected(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-remove"></span>
<span *ngIf="abuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="abuse.moderationComment" class="glyphicon glyphicon-comment"></span> <span *ngIf="abuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="abuse.moderationComment" class="glyphicon glyphicon-comment"></span>

View File

@ -10,7 +10,7 @@
@include disable-default-a-behaviour; @include disable-default-a-behaviour;
} }
.video-abuse-states .glyphicon-comment { .abuse-states .glyphicon-comment {
margin-left: 0.5rem; margin-left: 0.5rem;
} }

View File

@ -2,7 +2,7 @@ import { SortMeta } from 'primeng/api'
import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils' import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core' import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'
import { DomSanitizer } from '@angular/platform-browser' import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
import { ActivatedRoute, Params, Router } from '@angular/router' import { ActivatedRoute, Params, Router } from '@angular/router'
import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core' import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main' import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main'
@ -10,15 +10,20 @@ import { AbuseService, BlocklistService, VideoBlockService } from '@app/shared/s
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { Abuse, AbuseState } from '@shared/models' import { Abuse, AbuseState } from '@shared/models'
import { ModerationCommentModalComponent } from './moderation-comment-modal.component' import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
import truncate from 'lodash-es/truncate'
export type ProcessedAbuse = Abuse & { export type ProcessedAbuse = Abuse & {
moderationCommentHtml?: string, moderationCommentHtml?: string,
reasonHtml?: string reasonHtml?: string
embedHtml?: string embedHtml?: SafeHtml
updatedAt?: Date updatedAt?: Date
// override bare server-side definitions with rich client-side definitions // override bare server-side definitions with rich client-side definitions
reporterAccount: Account reporterAccount?: Account
flaggedAccount?: Account
truncatedCommentHtml?: string
commentHtml?: string
video: Abuse['video'] & { video: Abuse['video'] & {
channel: Abuse['video']['channel'] & { channel: Abuse['video']['channel'] & {
@ -92,11 +97,11 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
{ {
label: this.i18n('Actions for the video'), label: this.i18n('Actions for the video'),
isHeader: true, isHeader: true,
isDisplayed: abuse => !abuse.video.deleted isDisplayed: abuse => abuse.video && !abuse.video.deleted
}, },
{ {
label: this.i18n('Block video'), label: this.i18n('Block video'),
isDisplayed: abuse => !abuse.video.deleted && !abuse.video.blacklisted, isDisplayed: abuse => abuse.video && !abuse.video.deleted && !abuse.video.blacklisted,
handler: abuse => { handler: abuse => {
this.videoBlocklistService.blockVideo(abuse.video.id, undefined, true) this.videoBlocklistService.blockVideo(abuse.video.id, undefined, true)
.subscribe( .subscribe(
@ -112,7 +117,7 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
}, },
{ {
label: this.i18n('Unblock video'), label: this.i18n('Unblock video'),
isDisplayed: abuse => !abuse.video.deleted && abuse.video.blacklisted, isDisplayed: abuse => abuse.video && !abuse.video.deleted && abuse.video.blacklisted,
handler: abuse => { handler: abuse => {
this.videoBlocklistService.unblockVideo(abuse.video.id) this.videoBlocklistService.unblockVideo(abuse.video.id)
.subscribe( .subscribe(
@ -128,7 +133,7 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
}, },
{ {
label: this.i18n('Delete video'), label: this.i18n('Delete video'),
isDisplayed: abuse => !abuse.video.deleted, isDisplayed: abuse => abuse.video && !abuse.video.deleted,
handler: async abuse => { handler: async abuse => {
const res = await this.confirmService.confirm( const res = await this.confirmService.confirm(
this.i18n('Do you really want to delete this video?'), this.i18n('Do you really want to delete this video?'),
@ -152,10 +157,12 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
[ [
{ {
label: this.i18n('Actions for the reporter'), label: this.i18n('Actions for the reporter'),
isHeader: true isHeader: true,
isDisplayed: abuse => !!abuse.reporterAccount
}, },
{ {
label: this.i18n('Mute reporter'), label: this.i18n('Mute reporter'),
isDisplayed: abuse => !!abuse.reporterAccount,
handler: async abuse => { handler: async abuse => {
const account = abuse.reporterAccount as Account const account = abuse.reporterAccount as Account
@ -175,7 +182,7 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
}, },
{ {
label: this.i18n('Mute server'), label: this.i18n('Mute server'),
isDisplayed: abuse => !abuse.reporterAccount.userId, isDisplayed: abuse => abuse.reporterAccount && !abuse.reporterAccount.userId,
handler: async abuse => { handler: async abuse => {
this.blocklistService.blockServerByInstance(abuse.reporterAccount.host) this.blocklistService.blockServerByInstance(abuse.reporterAccount.host)
.subscribe( .subscribe(
@ -231,7 +238,7 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
const queryParams: Params = {} const queryParams: Params = {}
if (search) Object.assign(queryParams, { search }) if (search) Object.assign(queryParams, { search })
this.router.navigate([ '/admin/moderation/video-abuses/list' ], { queryParams }) this.router.navigate([ '/admin/moderation/abuses/list' ], { queryParams })
} }
resetTableFilter () { resetTableFilter () {
@ -253,6 +260,10 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
return Video.buildClientUrl(abuse.video.uuid) return Video.buildClientUrl(abuse.video.uuid)
} }
getCommentUrl (abuse: Abuse) {
return Video.buildClientUrl(abuse.comment.video.uuid) + ';threadId=' + abuse.comment.threadId
}
getVideoEmbed (abuse: Abuse) { getVideoEmbed (abuse: Abuse) {
return buildVideoEmbed( return buildVideoEmbed(
buildVideoLink({ buildVideoLink({
@ -300,23 +311,45 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
}).subscribe( }).subscribe(
async resultList => { async resultList => {
this.totalRecords = resultList.total this.totalRecords = resultList.total
const abuses = []
for (const abuse of resultList.data) { this.abuses = []
Object.assign(abuse, {
reasonHtml: await this.toHtml(abuse.reason), for (const a of resultList.data) {
moderationCommentHtml: await this.toHtml(abuse.moderationComment), const abuse = a as ProcessedAbuse
embedHtml: this.sanitizer.bypassSecurityTrustHtml(this.getVideoEmbed(abuse)),
reporterAccount: new Account(abuse.reporterAccount) abuse.reasonHtml = await this.toHtml(abuse.reason)
}) abuse.moderationCommentHtml = await this.toHtml(abuse.moderationComment)
if (abuse.video) {
abuse.embedHtml = this.sanitizer.bypassSecurityTrustHtml(this.getVideoEmbed(abuse))
if (abuse.video.channel?.ownerAccount) {
abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount)
}
}
if (abuse.comment) {
if (abuse.comment.deleted) {
abuse.truncatedCommentHtml = abuse.commentHtml = this.i18n('Deleted comment')
} else {
const truncated = truncate(abuse.comment.text, { length: 100 })
abuse.truncatedCommentHtml = await this.markdownRenderer.textMarkdownToHTML(truncated, true)
abuse.commentHtml = await this.markdownRenderer.textMarkdownToHTML(abuse.comment.text, true)
}
}
if (abuse.reporterAccount) {
abuse.reporterAccount = new Account(abuse.reporterAccount)
}
if (abuse.flaggedAccount) {
abuse.flaggedAccount = new Account(abuse.flaggedAccount)
}
if (abuse.video.channel?.ownerAccount) abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount)
if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt
abuses.push(abuse as ProcessedAbuse) this.abuses.push(abuse)
} }
this.abuses = abuses
}, },
err => this.notifier.error(err.message) err => this.notifier.error(err.message)

View File

@ -25,18 +25,18 @@
vertical-align: top; vertical-align: top;
text-align: right; text-align: right;
} }
.moderation-expanded-text { .moderation-expanded-text {
display: inline-flex; display: inline-flex;
word-wrap: break-word; word-wrap: break-word;
::ng-deep p:last-child { ::ng-deep p:last-child {
margin-bottom: 0px !important; margin-bottom: 0px !important;
} }
} }
} }
.video-table-states { .table-states {
& > :not(:first-child) { & > :not(:first-child) {
margin-left: .4rem; margin-left: .4rem;
} }
@ -59,6 +59,7 @@ p-calendar {
.screenratio { .screenratio {
div { div {
@include miniature-thumbnail; @include miniature-thumbnail;
display: inline-flex; display: inline-flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@ -72,6 +73,11 @@ p-calendar {
}; };
} }
.comment-html {
background-color: #ececec;
padding: 10px;
}
.chip { .chip {
@include chip; @include chip;
} }
@ -83,16 +89,32 @@ my-action-dropdown.show {
} }
.video-table-video-link { .table-video-link {
@include disable-outline; @include disable-outline;
position: relative; position: relative;
top: 3px; top: 3px;
} }
.video-table-video { .table-comment-link {
@include disable-outline;
color: var(--mainForegroundColor);
::ng-deep p:last-child {
margin: 0;
}
}
.comment-flagged-account {
font-size: 11px;
color: var(--greyForegroundColor);
}
.table-video {
display: inline-flex; display: inline-flex;
.video-table-video-image { .table-video-image {
@include miniature-thumbnail; @include miniature-thumbnail;
$image-height: 45px; $image-height: 45px;
@ -118,7 +140,7 @@ my-action-dropdown.show {
color: pvar(--inputPlaceholderColor); color: pvar(--inputPlaceholderColor);
} }
.video-table-video-image-label { .table-video-image-label {
@include static-thumbnail-overlay; @include static-thumbnail-overlay;
position: absolute; position: absolute;
border-radius: 3px; border-radius: 3px;
@ -130,7 +152,7 @@ my-action-dropdown.show {
} }
} }
.video-table-video-text { .table-video-text {
display: inline-flex; display: inline-flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@ -145,7 +167,8 @@ my-action-dropdown.show {
} }
div + div { div + div {
font-size: 80%; color: var(--greyForegroundColor);
font-size: 11px;
} }
} }
} }

View File

@ -33,7 +33,7 @@ export const ModerationRoutes: Routes = [
data: { data: {
userRight: UserRight.MANAGE_ABUSES, userRight: UserRight.MANAGE_ABUSES,
meta: { meta: {
title: 'Video reports' title: 'Reports'
} }
} }
}, },

View File

@ -45,6 +45,7 @@
<div *ngIf="isRemovableByUser()" (click)="onWantToDelete()" class="comment-action-delete" i18n>Delete</div> <div *ngIf="isRemovableByUser()" (click)="onWantToDelete()" class="comment-action-delete" i18n>Delete</div>
<my-user-moderation-dropdown <my-user-moderation-dropdown
[prependActions]="prependModerationActions"
buttonSize="small" [account]="commentAccount" [user]="commentUser" i18n-label label="Options" placement="bottom-left auto" buttonSize="small" [account]="commentAccount" [user]="commentUser" i18n-label label="Options" placement="bottom-left auto"
></my-user-moderation-dropdown> ></my-user-moderation-dropdown>
</div> </div>
@ -93,3 +94,7 @@
</div> </div>
</div> </div>
</div> </div>
<ng-container *ngIf="prependModerationActions">
<my-comment-report #commentReportModal [comment]="comment"></my-comment-report>
</ng-container>

View File

@ -1,7 +1,10 @@
import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core'
import { MarkdownService, Notifier, UserService } from '@app/core' import { MarkdownService, Notifier, UserService } from '@app/core'
import { AuthService } from '@app/core/auth' import { AuthService } from '@app/core/auth'
import { Account, Actor, Video } from '@app/shared/shared-main' import { Account, Actor, DropdownAction, Video } from '@app/shared/shared-main'
import { CommentReportComponent } from '@app/shared/shared-moderation/comment-report.component'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { User, UserRight } from '@shared/models' import { User, UserRight } from '@shared/models'
import { VideoCommentThreadTree } from './video-comment-thread-tree.model' import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
import { VideoComment } from './video-comment.model' import { VideoComment } from './video-comment.model'
@ -12,6 +15,8 @@ import { VideoComment } from './video-comment.model'
styleUrls: ['./video-comment.component.scss'] styleUrls: ['./video-comment.component.scss']
}) })
export class VideoCommentComponent implements OnInit, OnChanges { export class VideoCommentComponent implements OnInit, OnChanges {
@ViewChild('commentReportModal') commentReportModal: CommentReportComponent
@Input() video: Video @Input() video: Video
@Input() comment: VideoComment @Input() comment: VideoComment
@Input() parentComments: VideoComment[] = [] @Input() parentComments: VideoComment[] = []
@ -26,6 +31,8 @@ export class VideoCommentComponent implements OnInit, OnChanges {
@Output() resetReply = new EventEmitter() @Output() resetReply = new EventEmitter()
@Output() timestampClicked = new EventEmitter<number>() @Output() timestampClicked = new EventEmitter<number>()
prependModerationActions: DropdownAction<any>[]
sanitizedCommentHTML = '' sanitizedCommentHTML = ''
newParentComments: VideoComment[] = [] newParentComments: VideoComment[] = []
@ -33,6 +40,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
commentUser: User commentUser: User
constructor ( constructor (
private i18n: I18n,
private markdownService: MarkdownService, private markdownService: MarkdownService,
private authService: AuthService, private authService: AuthService,
private userService: UserService, private userService: UserService,
@ -127,5 +135,20 @@ export class VideoCommentComponent implements OnInit, OnChanges {
} else { } else {
this.comment.account = null this.comment.account = null
} }
if (this.isUserLoggedIn()) {
this.prependModerationActions = [
{
label: this.i18n('Report comment'),
handler: () => this.showReportModal()
}
]
} else {
this.prependModerationActions = undefined
}
}
private showReportModal () {
this.commentReportModal.show()
} }
} }

View File

@ -46,8 +46,10 @@ export abstract class Actor implements ActorServer {
this.host = hash.host this.host = hash.host
this.followingCount = hash.followingCount this.followingCount = hash.followingCount
this.followersCount = hash.followersCount this.followersCount = hash.followersCount
this.createdAt = new Date(hash.createdAt.toString())
this.updatedAt = new Date(hash.updatedAt.toString()) if (hash.createdAt) this.createdAt = new Date(hash.createdAt.toString())
if (hash.updatedAt) this.updatedAt = new Date(hash.updatedAt.toString())
this.avatar = hash.avatar this.avatar = hash.avatar
this.updateComputedAttributes() this.updateComputedAttributes()

View File

@ -5,18 +5,20 @@ import { catchError, map } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http' import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { RestExtractor, RestPagination, RestService } from '@app/core' import { RestExtractor, RestPagination, RestService } from '@app/core'
import { AbuseUpdate, ResultList, Abuse, AbuseCreate, AbuseState } from '@shared/models' import { Abuse, AbuseCreate, AbuseFilter, AbusePredefinedReasonsString, AbuseState, AbuseUpdate, ResultList } from '@shared/models'
import { environment } from '../../../environments/environment' import { environment } from '../../../environments/environment'
import { I18n } from '@ngx-translate/i18n-polyfill'
@Injectable() @Injectable()
export class AbuseService { export class AbuseService {
private static BASE_ABUSE_URL = environment.apiUrl + '/api/v1/abuses' private static BASE_ABUSE_URL = environment.apiUrl + '/api/v1/abuses'
constructor ( constructor (
private i18n: I18n,
private authHttp: HttpClient, private authHttp: HttpClient,
private restService: RestService, private restService: RestService,
private restExtractor: RestExtractor private restExtractor: RestExtractor
) {} ) { }
getAbuses (options: { getAbuses (options: {
pagination: RestPagination, pagination: RestPagination,
@ -24,7 +26,7 @@ export class AbuseService {
search?: string search?: string
}): Observable<ResultList<Abuse>> { }): Observable<ResultList<Abuse>> {
const { pagination, sort, search } = options const { pagination, sort, search } = options
const url = AbuseService.BASE_ABUSE_URL + 'abuse' const url = AbuseService.BASE_ABUSE_URL
let params = new HttpParams() let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort) params = this.restService.addRestGetParams(params, pagination, sort)
@ -60,39 +62,93 @@ export class AbuseService {
} }
return this.authHttp.get<ResultList<Abuse>>(url, { params }) return this.authHttp.get<ResultList<Abuse>>(url, { params })
.pipe( .pipe(
catchError(res => this.restExtractor.handleError(res)) catchError(res => this.restExtractor.handleError(res))
) )
} }
reportVideo (parameters: AbuseCreate) { reportVideo (parameters: AbuseCreate) {
const url = AbuseService.BASE_ABUSE_URL const url = AbuseService.BASE_ABUSE_URL
const body = omit(parameters, [ 'id' ]) const body = omit(parameters, ['id'])
return this.authHttp.post(url, body) return this.authHttp.post(url, body)
.pipe( .pipe(
map(this.restExtractor.extractDataBool), map(this.restExtractor.extractDataBool),
catchError(res => this.restExtractor.handleError(res)) catchError(res => this.restExtractor.handleError(res))
) )
} }
updateAbuse (abuse: Abuse, abuseUpdate: AbuseUpdate) { updateAbuse (abuse: Abuse, abuseUpdate: AbuseUpdate) {
const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
return this.authHttp.put(url, abuseUpdate) return this.authHttp.put(url, abuseUpdate)
.pipe( .pipe(
map(this.restExtractor.extractDataBool), map(this.restExtractor.extractDataBool),
catchError(res => this.restExtractor.handleError(res)) catchError(res => this.restExtractor.handleError(res))
) )
} }
removeAbuse (abuse: Abuse) { removeAbuse (abuse: Abuse) {
const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
return this.authHttp.delete(url) return this.authHttp.delete(url)
.pipe( .pipe(
map(this.restExtractor.extractDataBool), map(this.restExtractor.extractDataBool),
catchError(res => this.restExtractor.handleError(res)) catchError(res => this.restExtractor.handleError(res))
) )
}} }
getPrefefinedReasons (type: AbuseFilter) {
let reasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [
{
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.')
}
]
if (type === 'video') {
reasons = reasons.concat([
{
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).')
}
])
}
return reasons
}
}

View File

@ -0,0 +1,62 @@
<ng-template #modal>
<div class="modal-header">
<h4 i18n class="modal-title">Report comment</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 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 [inputName]="reason.id" [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>
</div>
<div class="col-7">
<div i18n class="information">
Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteComment()"> and will be forwarded to the comment origin ({{ originHost }}) too</ng-container>.
</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>
</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>

View File

@ -0,0 +1,11 @@
@import 'variables';
@import 'mixins';
.information {
margin-bottom: 20px;
}
textarea {
@include peertube-textarea(100%, 100px);
}

View File

@ -0,0 +1,93 @@
import { mapValues, pickBy } from 'lodash-es'
import { Component, Input, OnInit, ViewChild } from '@angular/core'
import { SafeHtml } from '@angular/platform-browser'
import { VideoComment } from '@app/+videos/+video-watch/comment/video-comment.model'
import { Notifier } from '@app/core'
import { AbuseValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models'
import { AbuseService } from './abuse.service'
@Component({
selector: 'my-comment-report',
templateUrl: './comment-report.component.html',
styleUrls: [ './comment-report.component.scss' ]
})
export class CommentReportComponent extends FormReactive implements OnInit {
@Input() comment: VideoComment = null
@ViewChild('modal', { static: true }) modal: NgbModal
error: string = null
predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
embedHtml: SafeHtml
private openedModal: NgbModalRef
constructor (
protected formValidatorService: FormValidatorService,
private modalService: NgbModal,
private abuseValidatorsService: AbuseValidatorsService,
private abuseService: AbuseService,
private notifier: Notifier,
private i18n: I18n
) {
super()
}
get currentHost () {
return window.location.host
}
get originHost () {
if (this.isRemoteComment()) {
return this.comment.account.host
}
return ''
}
ngOnInit () {
this.buildForm({
reason: this.abuseValidatorsService.ABUSE_REASON,
predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null)
})
this.predefinedReasons = this.abuseService.getPrefefinedReasons('comment')
}
show () {
this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
}
hide () {
this.openedModal.close()
this.openedModal = null
}
report () {
const reason = this.form.get('reason').value
const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as AbusePredefinedReasonsString[]
this.abuseService.reportVideo({
reason,
predefinedReasons,
comment: {
id: this.comment.id
}
}).subscribe(
() => {
this.notifier.success(this.i18n('Comment reported.'))
this.hide()
},
err => this.notifier.error(err.message)
)
}
isRemoteComment () {
return !this.comment.isLocal
}
}

View File

@ -12,6 +12,7 @@ import { AbuseService } from './abuse.service'
import { VideoBlockComponent } from './video-block.component' import { VideoBlockComponent } from './video-block.component'
import { VideoBlockService } from './video-block.service' import { VideoBlockService } from './video-block.service'
import { VideoReportComponent } from './video-report.component' import { VideoReportComponent } from './video-report.component'
import { CommentReportComponent } from './comment-report.component'
@NgModule({ @NgModule({
imports: [ imports: [
@ -25,7 +26,8 @@ import { VideoReportComponent } from './video-report.component'
UserModerationDropdownComponent, UserModerationDropdownComponent,
VideoBlockComponent, VideoBlockComponent,
VideoReportComponent, VideoReportComponent,
BatchDomainsModalComponent BatchDomainsModalComponent,
CommentReportComponent
], ],
exports: [ exports: [
@ -33,7 +35,8 @@ import { VideoReportComponent } from './video-report.component'
UserModerationDropdownComponent, UserModerationDropdownComponent,
VideoBlockComponent, VideoBlockComponent,
VideoReportComponent, VideoReportComponent,
BatchDomainsModalComponent BatchDomainsModalComponent,
CommentReportComponent
], ],
providers: [ providers: [

View File

@ -16,6 +16,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
@Input() user: User @Input() user: User
@Input() account: Account @Input() account: Account
@Input() prependActions: DropdownAction<{ user: User, account: Account }>[]
@Input() buttonSize: 'normal' | 'small' = 'normal' @Input() buttonSize: 'normal' | 'small' = 'normal'
@Input() placement = 'left-top left-bottom auto' @Input() placement = 'left-top left-bottom auto'
@ -250,6 +251,12 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
private buildActions () { private buildActions () {
this.userActions = [] this.userActions = []
if (this.prependActions) {
this.userActions = [
this.prependActions
]
}
if (this.authService.isLoggedIn()) { if (this.authService.isLoggedIn()) {
const authUser = this.authService.getUser() const authUser = this.authService.getUser()

View File

@ -14,16 +14,19 @@
<div class="ml-2 mt-2 d-flex flex-column"> <div class="ml-2 mt-2 d-flex flex-column">
<ng-container formGroupName="predefinedReasons"> <ng-container formGroupName="predefinedReasons">
<div class="form-group" *ngFor="let reason of predefinedReasons"> <div class="form-group" *ngFor="let reason of predefinedReasons">
<my-peertube-checkbox formControlName="{{reason.id}}" labelText="{{reason.label}}"> <my-peertube-checkbox [inputName]="reason.id" [formControlName]="reason.id" [labelText]="reason.label">
<ng-template *ngIf="reason.help" ptTemplate="help"> <ng-template *ngIf="reason.help" ptTemplate="help">
<div [innerHTML]="reason.help"></div> <div [innerHTML]="reason.help"></div>
</ng-template> </ng-template>
<ng-container *ngIf="reason.description" ngProjectAs="description"> <ng-container *ngIf="reason.description" ngProjectAs="description">
<div [innerHTML]="reason.description"></div> <div [innerHTML]="reason.description"></div>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
</div> </div>
@ -73,7 +76,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<textarea <textarea
i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus
[ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control" [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
></textarea> ></textarea>

View File

@ -79,48 +79,7 @@ export class VideoReportComponent extends FormReactive implements OnInit {
} }
}) })
this.predefinedReasons = [ this.predefinedReasons = this.abuseService.getPrefefinedReasons('video')
{
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() this.embedHtml = this.getVideoEmbed()
} }

View File

@ -140,7 +140,7 @@ export enum ScopeNames {
model: VideoModel.unscoped(), model: VideoModel.unscoped(),
include: [ include: [
{ {
attributes: [ 'filename', 'fileUrl' ], attributes: [ 'filename', 'fileUrl', 'type' ],
model: ThumbnailModel model: ThumbnailModel
}, },
{ {
@ -395,6 +395,8 @@ export class AbuseModel extends Model<AbuseModel> {
comment = { comment = {
id: entity.id, id: entity.id,
threadId: entity.getThreadId(),
text: entity.text ?? '', text: entity.text ?? '',
deleted: entity.isDeleted(), deleted: entity.isDeleted(),

View File

@ -655,7 +655,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
id: this.id, id: this.id,
url: this.url, url: this.url,
text: this.text, text: this.text,
threadId: this.originCommentId || this.id, threadId: this.getThreadId(),
inReplyToCommentId: this.inReplyToCommentId || null, inReplyToCommentId: this.inReplyToCommentId || null,
videoId: this.videoId, videoId: this.videoId,
createdAt: this.createdAt, createdAt: this.createdAt,

View File

@ -25,6 +25,7 @@ export interface VideoAbuse {
export interface VideoCommentAbuse { export interface VideoCommentAbuse {
id: number id: number
threadId: number
video: { video: {
id: number id: number