Add abuse messages management in my account

This commit is contained in:
Chocobozzz 2020-07-27 11:40:30 +02:00 committed by Chocobozzz
parent 441e453ae5
commit 94148c9028
47 changed files with 1184 additions and 889 deletions

View File

@ -2,6 +2,7 @@ import { ChartModule } from 'primeng/chart'
import { SelectButtonModule } from 'primeng/selectbutton'
import { TableModule } from 'primeng/table'
import { NgModule } from '@angular/core'
import { SharedAbuseListModule } from '@app/shared/shared-abuse-list'
import { SharedFormModule } from '@app/shared/shared-forms'
import { SharedGlobalIconModule } from '@app/shared/shared-icons'
import { SharedMainModule } from '@app/shared/shared-main'
@ -14,10 +15,9 @@ import { FollowersListComponent, FollowsComponent, VideoRedundanciesListComponen
import { FollowingListComponent } from './follows/following-list/following-list.component'
import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component'
import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component'
import { ModerationCommentModalComponent, AbuseListComponent, VideoBlockListComponent } from './moderation'
import { AbuseListComponent, VideoBlockListComponent } from './moderation'
import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist'
import { ModerationComponent } from './moderation/moderation.component'
import { AbuseDetailsComponent } from './moderation/abuse-list/abuse-details.component'
import { PluginListInstalledComponent } from './plugins/plugin-list-installed/plugin-list-installed.component'
import { PluginSearchComponent } from './plugins/plugin-search/plugin-search.component'
import { PluginShowInstalledComponent } from './plugins/plugin-show-installed/plugin-show-installed.component'
@ -36,6 +36,7 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
SharedFormModule,
SharedModerationModule,
SharedGlobalIconModule,
SharedAbuseListModule,
TableModule,
SelectButtonModule,
@ -60,11 +61,8 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
ModerationComponent,
VideoBlockListComponent,
AbuseListComponent,
AbuseDetailsComponent,
ModerationCommentModalComponent,
InstanceServerBlocklistComponent,
InstanceAccountBlocklistComponent,

View File

@ -3,195 +3,4 @@
<ng-container i18n>Reports</ng-container>
</h1>
<p-table
[value]="abuses" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
[sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" [resizableColumns]="true" [lazyLoadOnInit]="false"
[showCurrentPageReport]="true" i18n-currentPageReportTemplate
currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} reports"
(onPage)="onPage($event)" [expandedRowKeys]="expandedRows"
>
<ng-template pTemplate="caption">
<div class="caption">
<div class="ml-auto">
<div class="input-group has-feedback has-clear">
<div class="input-group-prepend c-hand" ngbDropdown placement="bottom-left auto" container="body">
<div class="input-group-text" ngbDropdownToggle>
<span class="caret" aria-haspopup="menu" role="button"></span>
</div>
<div role="menu" ngbDropdownMenu>
<h6 class="dropdown-header" i18n>Advanced report filters</h6>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'state:pending' }" class="dropdown-item" i18n>Unsolved reports</a>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'state:accepted' }" class="dropdown-item" i18n>Accepted reports</a>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'state:rejected' }" class="dropdown-item" i18n>Refused reports</a>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'videoIs:blacklisted' }" class="dropdown-item" i18n>Reports with blocked videos</a>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'videoIs:deleted' }" class="dropdown-item" i18n>Reports with deleted videos</a>
</div>
</div>
<input
type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
(keyup)="onAbuseSearch($event)"
>
<a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetTableFilter()"></a>
<span class="sr-only" i18n>Clear filters</span>
</div>
</div>
</div>
</ng-template>
<ng-template pTemplate="header">
<tr> <!-- header -->
<th style="width: 40px;"></th>
<th style="width: 20%;" pResizableColumn i18n>Reporter</th>
<th i18n>Video/Comment/Account</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 style="width: 80px;">Messages</th>
<th style="width: 150px;"></th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-expanded="expanded" let-abuse>
<tr>
<td class="c-hand" [pRowToggler]="abuse" i18n-ngbTooltip ngbTooltip="More information" placement="top-left" container="body">
<span class="expander">
<i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
</span>
</td>
<td>
<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">
<img
class="avatar"
[src]="abuse.reporterAccount.avatar?.path"
(error)="switchToDefaultAvatar($event)"
alt="Avatar"
>
<div>
{{ abuse.reporterAccount.displayName }}
<span>{{ abuse.reporterAccount.nameWithHost }}</span>
</div>
</div>
</a>
<span i18n *ngIf="!abuse.reporterAccount">
Deleted account
</span>
</td>
<ng-container *ngIf="abuse.video">
<td *ngIf="!abuse.video.deleted">
<a [href]="getVideoUrl(abuse)" class="table-video-link" [title]="abuse.video.name" target="_blank" rel="noopener noreferrer">
<div class="table-video">
<div class="table-video-image">
<img [src]="abuse.video.thumbnailPath">
<span
class="table-video-image-label" *ngIf="abuse.count > 1"
i18n-title title="This video has been reported multiple times."
>
{{ abuse.nth }}/{{ abuse.count }}
</span>
</div>
<div class="table-video-text">
<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 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" 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>
</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>
<ng-container *ngIf="!abuse.comment && !abuse.video">
<td *ngIf="abuse.flaggedAccount">
<a [href]="getAccountUrl(abuse)" class="table-account-link" target="_blank" rel="noopener noreferrer">
<span>{{ abuse.flaggedAccount.displayName }}</span>
<span class="account-flagged-handle">{{ abuse.flaggedAccount.nameWithHostForced }}</span>
</a>
</td>
<td i18n *ngIf="!abuse.flaggedAccount">
Account deleted
</td>
</ng-container>
<td class="c-hand" [pRowToggler]="abuse">{{ abuse.createdAt | date: 'short' }}</td>
<td class="c-hand abuse-states" [pRowToggler]="abuse">
<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="abuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="abuse.moderationComment" class="glyphicon glyphicon-comment"></span>
</td>
<td class="c-hand abuse-messages" (click)="openAbuseMessagesModal(abuse)">
{{ abuse.countMessages }}
<my-global-icon iconName="message-circle"></my-global-icon>
</td>
<td class="action-cell">
<my-action-dropdown
[ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
i18n-label label="Actions" [actions]="abuseActions" [entry]="abuse"
></my-action-dropdown>
</td>
</tr>
</ng-template>
<ng-template pTemplate="rowexpansion" let-abuse>
<tr>
<td class="expand-cell" colspan="6">
<my-abuse-details [abuse]="abuse"></my-abuse-details>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="6">
<div class="no-results">
<ng-container *ngIf="search" i18n>No abuses found matching current filters.</ng-container>
<ng-container *ngIf="!search" i18n>No abuses found.</ng-container>
</div>
</td>
</tr>
</ng-template>
</p-table>
<my-moderation-comment-modal #moderationCommentModal (commentUpdated)="onModerationCommentUpdated()"></my-moderation-comment-modal>
<my-abuse-message-modal #abuseMessagesModal (countMessagesUpdated)="onCountMessagesUpdated($event)"></my-abuse-message-modal>
<my-abuse-list-table viewType="admin" baseRoute="/admin/moderation/abuses/list"></my-abuse-list-table>

View File

@ -1,32 +0,0 @@
@import 'mixins';
@import 'miniature';
.video-details-date-updated {
font-size: 90%;
margin-top: .1rem;
}
.video-details-links {
@include disable-default-a-behaviour;
}
.abuse-states .glyphicon-comment {
margin-left: 0.5rem;
}
.input-group {
@include peertube-input-group(300px);
.dropdown-toggle::after {
margin-left: 0;
}
}
.abuse-messages {
my-global-icon {
width: 22px;
margin-left: 3px;
position: relative;
top: -2px;
}
}

View File

@ -1,474 +1,10 @@
import * as debug from 'debug'
import truncate from 'lodash-es/truncate'
import { SortMeta } from 'primeng/api'
import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
import { environment } from 'src/environments/environment'
import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
import { ActivatedRoute, Params, Router } from '@angular/router'
import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main'
import { AbuseService, BlocklistService, VideoBlockService, AbuseMessageModalComponent } from '@app/shared/shared-moderation'
import { VideoCommentService } from '@app/shared/shared-video-comment'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { AdminAbuse, AbuseState } from '@shared/models'
import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
const logger = debug('peertube:moderation:AbuseListComponent')
// Don't use an abuse model because we need external services to compute some properties
// And this model is only used in this component
export type ProcessedAbuse = AdminAbuse & {
moderationCommentHtml?: string,
reasonHtml?: string
embedHtml?: SafeHtml
updatedAt?: Date
// override bare server-side definitions with rich client-side definitions
reporterAccount?: Account
flaggedAccount?: Account
truncatedCommentHtml?: string
commentHtml?: string
video: AdminAbuse['video'] & {
channel: AdminAbuse['video']['channel'] & {
ownerAccount: Account
}
}
}
import { Component } from '@angular/core'
@Component({
selector: 'my-abuse-list',
templateUrl: './abuse-list.component.html',
styleUrls: [ '../moderation.component.scss', './abuse-list.component.scss' ]
styleUrls: [ ]
})
export class AbuseListComponent extends RestTable implements OnInit, AfterViewInit {
@ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent
@ViewChild('abuseMessagesModal', { static: true }) abuseMessagesModal: AbuseMessageModalComponent
export class AbuseListComponent {
abuses: ProcessedAbuse[] = []
totalRecords = 0
sort: SortMeta = { field: 'createdAt', order: 1 }
pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
abuseActions: DropdownAction<ProcessedAbuse>[][] = []
constructor (
private notifier: Notifier,
private abuseService: AbuseService,
private blocklistService: BlocklistService,
private commentService: VideoCommentService,
private videoService: VideoService,
private videoBlocklistService: VideoBlockService,
private confirmService: ConfirmService,
private i18n: I18n,
private markdownRenderer: MarkdownService,
private sanitizer: DomSanitizer,
private route: ActivatedRoute,
private router: Router
) {
super()
this.abuseActions = [
this.buildInternalActions(),
this.buildFlaggedAccountActions(),
this.buildCommentActions(),
this.buildVideoActions(),
this.buildAccountActions()
]
}
ngOnInit () {
this.initialize()
this.route.queryParams
.subscribe(params => {
this.search = params.search || ''
logger('On URL change (search: %s).', this.search)
this.setTableFilter(this.search)
this.loadData()
})
}
ngAfterViewInit () {
if (this.search) this.setTableFilter(this.search)
}
getIdentifier () {
return 'AbuseListComponent'
}
openModerationCommentModal (abuse: AdminAbuse) {
this.moderationCommentModal.openModal(abuse)
}
onModerationCommentUpdated () {
this.loadData()
}
/* Table filter functions */
onAbuseSearch (event: Event) {
this.onSearch(event)
this.setQueryParams((event.target as HTMLInputElement).value)
}
setQueryParams (search: string) {
const queryParams: Params = {}
if (search) Object.assign(queryParams, { search })
this.router.navigate([ '/admin/moderation/abuses/list' ], { queryParams })
}
resetTableFilter () {
this.setTableFilter('')
this.setQueryParams('')
this.resetSearch()
}
/* END Table filter functions */
isAbuseAccepted (abuse: AdminAbuse) {
return abuse.state.id === AbuseState.ACCEPTED
}
isAbuseRejected (abuse: AdminAbuse) {
return abuse.state.id === AbuseState.REJECTED
}
getVideoUrl (abuse: AdminAbuse) {
return Video.buildClientUrl(abuse.video.uuid)
}
getCommentUrl (abuse: AdminAbuse) {
return Video.buildClientUrl(abuse.comment.video.uuid) + ';threadId=' + abuse.comment.threadId
}
getAccountUrl (abuse: ProcessedAbuse) {
return '/accounts/' + abuse.flaggedAccount.nameWithHost
}
getVideoEmbed (abuse: AdminAbuse) {
return buildVideoEmbed(
buildVideoLink({
baseUrl: `${environment.embedUrl}/videos/embed/${abuse.video.uuid}`,
title: false,
warningTitle: false,
startTime: abuse.startAt,
stopTime: abuse.endAt
})
)
}
switchToDefaultAvatar ($event: Event) {
($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
}
async removeAbuse (abuse: AdminAbuse) {
const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this abuse report?'), this.i18n('Delete'))
if (res === false) return
this.abuseService.removeAbuse(abuse).subscribe(
() => {
this.notifier.success(this.i18n('Abuse deleted.'))
this.loadData()
},
err => this.notifier.error(err.message)
)
}
updateAbuseState (abuse: AdminAbuse, state: AbuseState) {
this.abuseService.updateAbuse(abuse, { state })
.subscribe(
() => this.loadData(),
err => this.notifier.error(err.message)
)
}
onCountMessagesUpdated (event: { abuseId: number, countMessages: number }) {
const abuse = this.abuses.find(a => a.id === event.abuseId)
if (!abuse) {
console.error('Cannot find abuse %d.', event.abuseId)
return
}
abuse.countMessages = event.countMessages
}
openAbuseMessagesModal (abuse: AdminAbuse) {
this.abuseMessagesModal.openModal(abuse)
}
protected loadData () {
logger('Load data.')
return this.abuseService.getAdminAbuses({
pagination: this.pagination,
sort: this.sort,
search: this.search
}).subscribe(
async resultList => {
this.totalRecords = resultList.total
this.abuses = []
for (const a of resultList.data) {
const abuse = a as ProcessedAbuse
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.updatedAt === abuse.createdAt) delete abuse.updatedAt
this.abuses.push(abuse)
}
},
err => this.notifier.error(err.message)
)
}
private buildInternalActions (): DropdownAction<ProcessedAbuse>[] {
return [
{
label: this.i18n('Internal actions'),
isHeader: true
},
{
label: this.i18n('Delete report'),
handler: abuse => this.removeAbuse(abuse)
},
{
label: this.i18n('Messages'),
handler: abuse => this.openAbuseMessagesModal(abuse)
},
{
label: this.i18n('Add internal note'),
handler: abuse => this.openModerationCommentModal(abuse),
isDisplayed: abuse => !abuse.moderationComment
},
{
label: this.i18n('Update note'),
handler: abuse => this.openModerationCommentModal(abuse),
isDisplayed: abuse => !!abuse.moderationComment
},
{
label: this.i18n('Mark as accepted'),
handler: abuse => this.updateAbuseState(abuse, AbuseState.ACCEPTED),
isDisplayed: abuse => !this.isAbuseAccepted(abuse)
},
{
label: this.i18n('Mark as rejected'),
handler: abuse => this.updateAbuseState(abuse, AbuseState.REJECTED),
isDisplayed: abuse => !this.isAbuseRejected(abuse)
}
]
}
private buildFlaggedAccountActions (): DropdownAction<ProcessedAbuse>[] {
return [
{
label: this.i18n('Actions for the flagged account'),
isHeader: true,
isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video
},
{
label: this.i18n('Mute account'),
isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video,
handler: abuse => this.muteAccountHelper(abuse.flaggedAccount)
},
{
label: this.i18n('Mute server account'),
isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video,
handler: abuse => this.muteServerHelper(abuse.flaggedAccount.host)
}
]
}
private buildAccountActions (): DropdownAction<ProcessedAbuse>[] {
return [
{
label: this.i18n('Actions for the reporter'),
isHeader: true,
isDisplayed: abuse => !!abuse.reporterAccount
},
{
label: this.i18n('Mute reporter'),
isDisplayed: abuse => !!abuse.reporterAccount,
handler: abuse => this.muteAccountHelper(abuse.reporterAccount)
},
{
label: this.i18n('Mute server'),
isDisplayed: abuse => abuse.reporterAccount && !abuse.reporterAccount.userId,
handler: abuse => this.muteServerHelper(abuse.reporterAccount.host)
}
]
}
private buildVideoActions (): DropdownAction<ProcessedAbuse>[] {
return [
{
label: this.i18n('Actions for the video'),
isHeader: true,
isDisplayed: abuse => abuse.video && !abuse.video.deleted
},
{
label: this.i18n('Block video'),
isDisplayed: abuse => abuse.video && !abuse.video.deleted && !abuse.video.blacklisted,
handler: abuse => {
this.videoBlocklistService.blockVideo(abuse.video.id, undefined, true)
.subscribe(
() => {
this.notifier.success(this.i18n('Video blocked.'))
this.updateAbuseState(abuse, AbuseState.ACCEPTED)
},
err => this.notifier.error(err.message)
)
}
},
{
label: this.i18n('Unblock video'),
isDisplayed: abuse => abuse.video && !abuse.video.deleted && abuse.video.blacklisted,
handler: abuse => {
this.videoBlocklistService.unblockVideo(abuse.video.id)
.subscribe(
() => {
this.notifier.success(this.i18n('Video unblocked.'))
this.updateAbuseState(abuse, AbuseState.ACCEPTED)
},
err => this.notifier.error(err.message)
)
}
},
{
label: this.i18n('Delete video'),
isDisplayed: abuse => abuse.video && !abuse.video.deleted,
handler: async abuse => {
const res = await this.confirmService.confirm(
this.i18n('Do you really want to delete this video?'),
this.i18n('Delete')
)
if (res === false) return
this.videoService.removeVideo(abuse.video.id)
.subscribe(
() => {
this.notifier.success(this.i18n('Video deleted.'))
this.updateAbuseState(abuse, AbuseState.ACCEPTED)
},
err => this.notifier.error(err.message)
)
}
}
]
}
private buildCommentActions (): DropdownAction<ProcessedAbuse>[] {
return [
{
label: this.i18n('Actions for the comment'),
isHeader: true,
isDisplayed: abuse => abuse.comment && !abuse.comment.deleted
},
{
label: this.i18n('Delete comment'),
isDisplayed: abuse => abuse.comment && !abuse.comment.deleted,
handler: async abuse => {
const res = await this.confirmService.confirm(
this.i18n('Do you really want to delete this comment?'),
this.i18n('Delete')
)
if (res === false) return
this.commentService.deleteVideoComment(abuse.comment.video.id, abuse.comment.id)
.subscribe(
() => {
this.notifier.success(this.i18n('Comment deleted.'))
this.updateAbuseState(abuse, AbuseState.ACCEPTED)
},
err => this.notifier.error(err.message)
)
}
}
]
}
private muteAccountHelper (account: Account) {
this.blocklistService.blockAccountByInstance(account)
.subscribe(
() => {
this.notifier.success(
this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost })
)
account.mutedByInstance = true
},
err => this.notifier.error(err.message)
)
}
private muteServerHelper (host: string) {
this.blocklistService.blockServerByInstance(host)
.subscribe(
() => {
this.notifier.success(
this.i18n('Server {{host}} muted by the instance.', { host: host })
)
},
err => this.notifier.error(err.message)
)
}
private toHtml (text: string) {
return this.markdownRenderer.textMarkdownToHTML(text)
}
}

View File

@ -1,3 +1 @@
export * from './abuse-details.component'
export * from './abuse-list.component'
export * from './moderation-comment-modal.component'

View File

@ -3,7 +3,7 @@ import { BlocklistComponentType, GenericAccountBlocklistComponent } from '@app/s
@Component({
selector: 'my-instance-account-blocklist',
styleUrls: [ '../moderation.component.scss', '../../../shared/shared-moderation/account-blocklist.component.scss' ],
styleUrls: [ '../../../shared/shared-moderation/moderation.scss', '../../../shared/shared-moderation/account-blocklist.component.scss' ],
templateUrl: '../../../shared/shared-moderation/account-blocklist.component.html'
})
export class InstanceAccountBlocklistComponent extends GenericAccountBlocklistComponent {

View File

@ -3,7 +3,7 @@ import { ServerService } from '@app/core'
@Component({
templateUrl: './moderation.component.html',
styleUrls: [ './moderation.component.scss' ]
styleUrls: [ ]
})
export class ModerationComponent implements OnInit {
autoBlockVideosEnabled = false

View File

@ -16,3 +16,12 @@ my-global-icon {
margin-left: 0;
}
}
.caption {
justify-content: flex-end;
input {
@include peertube-input-text(250px);
flex-grow: 1;
}
}

View File

@ -11,7 +11,7 @@ import { VideoBlacklist, VideoBlacklistType } from '@shared/models'
@Component({
selector: 'my-video-block-list',
templateUrl: './video-block-list.component.html',
styleUrls: [ '../moderation.component.scss', './video-block-list.component.scss' ]
styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './video-block-list.component.scss' ]
})
export class VideoBlockListComponent extends RestTable implements OnInit, AfterViewInit {
blocklist: (VideoBlacklist & { reasonHtml?: string })[] = []

View File

@ -0,0 +1,6 @@
<h1>
<my-global-icon iconName="flag" aria-hidden="true"></my-global-icon>
<ng-container i18n>Reports</ng-container>
</h1>
<my-abuse-list-table viewType="user" baseRoute="/my-account/abuses"></my-abuse-list-table>

View File

@ -0,0 +1,11 @@
import { Component } from '@angular/core'
@Component({
selector: 'my-account-abuses-list',
templateUrl: './my-account-abuses-list.component.html',
styleUrls: [ ]
})
export class MyAccountAbusesListComponent {
}

View File

@ -3,7 +3,7 @@ import { BlocklistComponentType, GenericServerBlocklistComponent } from '@app/sh
@Component({
selector: 'my-account-server-blocklist',
styleUrls: [ '../../+admin/moderation/moderation.component.scss', '../../shared/shared-moderation/server-blocklist.component.scss' ],
styleUrls: [ '../../shared/shared-moderation/moderation.scss', '../../shared/shared-moderation/server-blocklist.component.scss' ],
templateUrl: '../../shared/shared-moderation/server-blocklist.component.html'
})
export class MyAccountServerBlocklistComponent extends GenericServerBlocklistComponent {

View File

@ -16,6 +16,7 @@ import { MyAccountVideoPlaylistUpdateComponent } from './my-account-video-playli
import { MyAccountVideoPlaylistsComponent } from './my-account-video-playlists/my-account-video-playlists.component'
import { MyAccountVideosComponent } from './my-account-videos/my-account-videos.component'
import { MyAccountComponent } from './my-account.component'
import { MyAccountAbusesListComponent } from './my-account-abuses/my-account-abuses-list.component'
const myAccountRoutes: Routes = [
{
@ -162,6 +163,15 @@ const myAccountRoutes: Routes = [
title: 'Notifications'
}
}
},
{
path: 'abuses',
component: MyAccountAbusesListComponent,
data: {
meta: {
title: 'My abuses'
}
}
}
]
}

View File

@ -94,6 +94,11 @@ export class MyAccountComponent implements OnInit {
routerLink: '/my-account/blocklist/servers',
iconName: 'peertube-x'
},
{
label: this.i18n('My abuses'),
routerLink: '/my-account/abuses',
iconName: 'flag'
},
{
label: this.i18n('Ownership changes'),
routerLink: '/my-account/ownership',

View File

@ -3,6 +3,7 @@ import { InputSwitchModule } from 'primeng/inputswitch'
import { TableModule } from 'primeng/table'
import { DragDropModule } from '@angular/cdk/drag-drop'
import { NgModule } from '@angular/core'
import { SharedAbuseListModule } from '@app/shared/shared-abuse-list'
import { SharedFormModule } from '@app/shared/shared-forms'
import { SharedGlobalIconModule } from '@app/shared/shared-icons'
import { SharedMainModule } from '@app/shared/shared-main'
@ -11,6 +12,7 @@ import { SharedUserInterfaceSettingsModule } from '@app/shared/shared-user-setti
import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription/shared-user-subscription.module'
import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist/shared-video-playlist.module'
import { MyAccountAbusesListComponent } from './my-account-abuses/my-account-abuses-list.component'
import { MyAccountBlocklistComponent } from './my-account-blocklist/my-account-blocklist.component'
import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component'
import { MyAccountHistoryComponent } from './my-account-history/my-account-history.component'
@ -50,7 +52,8 @@ import { MyAccountComponent } from './my-account.component'
SharedUserSubscriptionModule,
SharedVideoPlaylistModule,
SharedUserInterfaceSettingsModule,
SharedGlobalIconModule
SharedGlobalIconModule,
SharedAbuseListModule
],
declarations: [
@ -69,6 +72,7 @@ import { MyAccountComponent } from './my-account.component'
MyAccountDangerZoneComponent,
MyAccountSubscriptionsComponent,
MyAccountBlocklistComponent,
MyAccountAbusesListComponent,
MyAccountServerBlocklistComponent,
MyAccountHistoryComponent,
MyAccountNotificationsComponent,

View File

@ -36,7 +36,10 @@ function populateAsyncUserVideoChannels (authService: AuthService, channel: { id
}
function getAbsoluteAPIUrl () {
let absoluteAPIUrl = environment.apiUrl
let absoluteAPIUrl = environment.hmr === true
? 'http://localhost:9000'
: environment.apiUrl
if (!absoluteAPIUrl) {
// The API is on the same domain
absoluteAPIUrl = window.location.origin

View File

@ -3,11 +3,11 @@
<div class="col-8">
<!-- report metadata -->
<div class="d-flex" *ngIf="abuse.reporterAccount">
<div class="d-flex" *ngIf="isAdminView && abuse.reporterAccount">
<span class="col-3 moderation-expanded-label" i18n>Reporter</span>
<span class="col-9 moderation-expanded-text">
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }"
<a [routerLink]="[ baseRoute ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }"
class="chip"
>
<img
@ -21,7 +21,7 @@
</div>
</a>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }"
<a [routerLink]="[ baseRoute ]" [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>
@ -32,7 +32,7 @@
<div class="d-flex" *ngIf="abuse.flaggedAccount">
<span class="col-3 moderation-expanded-label" i18n>Reportee</span>
<span class="col-9 moderation-expanded-text">
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }"
<a [routerLink]="[ baseRoute ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }"
class="chip"
>
<img
@ -46,7 +46,7 @@
</div>
</a>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }"
<a *ngIf="isAdminView" [routerLink]="[ baseRoute ]" [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>
@ -63,7 +63,7 @@
<div class="mt-3 d-flex">
<span class="col-3 moderation-expanded-label">
<ng-container i18n>Report</ng-container>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': '#' + abuse.id }" class="ml-1 text-muted">#{{ abuse.id }}</a>
<a [routerLink]="[ baseRoute ]" [queryParams]="{ 'search': '#' + abuse.id }" class="ml-1 text-muted">#{{ abuse.id }}</a>
</span>
<span class="col-9 moderation-expanded-text" [innerHTML]="abuse.reasonHtml"></span>
</div>
@ -71,7 +71,7 @@
<div *ngIf="getPredefinedReasons()" class="mt-2 d-flex">
<span class="col-3"></span>
<span class="col-9">
<a *ngFor="let reason of getPredefinedReasons()" [routerLink]="[ '/admin/moderation/abuses/list' ]"
<a *ngFor="let reason of getPredefinedReasons()" [routerLink]="[ baseRoute ]"
[queryParams]="{ 'search': 'tag:' + reason.id }" class="chip rectangular bg-secondary text-light"
>
<div>{{ reason.label }}</div>
@ -86,7 +86,7 @@
</span>
</div>
<div class="mt-3 d-flex" *ngIf="abuse.moderationComment">
<div class="mt-3 d-flex" *ngIf="isAdminView && abuse.moderationComment">
<span class="col-3 moderation-expanded-label" i18n>Note</span>
<span class="col-9 moderation-expanded-text d-block" [innerHTML]="abuse.moderationCommentHtml"></span>
</div>

View File

@ -0,0 +1,34 @@
@import 'variables';
@import 'mixins';
@import 'miniature';
.screenratio {
div {
@include miniature-thumbnail;
display: inline-flex;
justify-content: center;
align-items: center;
color: pvar(--inputPlaceholderColor);
}
@include large-screen-ratio($selector: 'div, ::ng-deep iframe') {
width: 100% !important;
height: 100% !important;
left: 0;
};
}
.comment-html {
background-color: #ececec;
padding: 10px;
}
.abuse-details-date-updated {
font-size: 90%;
margin-top: .1rem;
}
.abuse-details-links {
@include disable-default-a-behaviour;
}

View File

@ -1,17 +1,19 @@
import { Component, Input } from '@angular/core'
import { durationToString } from '@app/helpers'
import { Actor } from '@app/shared/shared-main'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { AbusePredefinedReasonsString } from '@shared/models'
import { ProcessedAbuse } from './abuse-list.component'
import { durationToString } from '@app/helpers'
import { ProcessedAbuse } from './processed-abuse.model'
@Component({
selector: 'my-abuse-details',
templateUrl: './abuse-details.component.html',
styleUrls: [ '../moderation.component.scss' ]
styleUrls: [ '../shared-moderation/moderation.scss', './abuse-details.component.scss' ]
})
export class AbuseDetailsComponent {
@Input() abuse: ProcessedAbuse
@Input() isAdminView: boolean
@Input() baseRoute: string
private predefinedReasonsTranslations: { [key in AbusePredefinedReasonsString]: string }

View File

@ -0,0 +1,194 @@
<p-table
[value]="abuses" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
[sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" [resizableColumns]="true" [lazyLoadOnInit]="false"
[showCurrentPageReport]="true" i18n-currentPageReportTemplate
currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} reports"
(onPage)="onPage($event)" [expandedRowKeys]="expandedRows"
>
<ng-template pTemplate="caption">
<div class="caption">
<div class="ml-auto">
<div class="input-group has-feedback has-clear">
<div class="input-group-prepend c-hand" ngbDropdown placement="bottom-left auto" container="body">
<div class="input-group-text" ngbDropdownToggle>
<span class="caret" aria-haspopup="menu" role="button"></span>
</div>
<div role="menu" ngbDropdownMenu>
<h6 class="dropdown-header" i18n>Advanced report filters</h6>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'state:pending' }" class="dropdown-item" i18n>Unsolved reports</a>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'state:accepted' }" class="dropdown-item" i18n>Accepted reports</a>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'state:rejected' }" class="dropdown-item" i18n>Refused reports</a>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'videoIs:blacklisted' }" class="dropdown-item" i18n>Reports with blocked videos</a>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'videoIs:deleted' }" class="dropdown-item" i18n>Reports with deleted videos</a>
</div>
</div>
<input
type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
(keyup)="onAbuseSearch($event)"
>
<a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetTableFilter()"></a>
<span class="sr-only" i18n>Clear filters</span>
</div>
</div>
</div>
</ng-template>
<ng-template pTemplate="header">
<tr> <!-- header -->
<th style="width: 40px;"></th>
<th *ngIf="isAdminView()" style="width: 20%;" pResizableColumn i18n>Reporter</th>
<th i18n>Video/Comment/Account</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 style="width: 80px;">Messages</th>
<th style="width: 150px;"></th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-expanded="expanded" let-abuse>
<tr>
<td class="c-hand" [pRowToggler]="abuse" i18n-ngbTooltip ngbTooltip="More information" placement="top-left" container="body">
<span class="expander">
<i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
</span>
</td>
<td *ngIf="isAdminView()">
<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">
<img
class="avatar"
[src]="abuse.reporterAccount.avatar?.path"
(error)="switchToDefaultAvatar($event)"
alt="Avatar"
>
<div>
{{ abuse.reporterAccount.displayName }}
<span>{{ abuse.reporterAccount.nameWithHost }}</span>
</div>
</div>
</a>
<span i18n *ngIf="!abuse.reporterAccount">
Deleted account
</span>
</td>
<ng-container *ngIf="abuse.video">
<td *ngIf="!abuse.video.deleted">
<a [href]="getVideoUrl(abuse)" class="table-video-link" [title]="abuse.video.name" target="_blank" rel="noopener noreferrer">
<div class="table-video">
<div class="table-video-image">
<img [src]="abuse.video.thumbnailPath">
<span
class="table-video-image-label" *ngIf="abuse.count > 1"
i18n-title title="This video has been reported multiple times."
>
{{ abuse.nth }}/{{ abuse.count }}
</span>
</div>
<div class="table-video-text">
<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 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" 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>
</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>
<ng-container *ngIf="!abuse.comment && !abuse.video">
<td *ngIf="abuse.flaggedAccount">
<a [href]="getAccountUrl(abuse)" class="table-account-link" target="_blank" rel="noopener noreferrer">
<span>{{ abuse.flaggedAccount.displayName }}</span>
<span class="account-flagged-handle">{{ abuse.flaggedAccount.nameWithHostForced }}</span>
</a>
</td>
<td i18n *ngIf="!abuse.flaggedAccount">
Account deleted
</td>
</ng-container>
<td class="c-hand" [pRowToggler]="abuse">{{ abuse.createdAt | date: 'short' }}</td>
<td class="c-hand abuse-states" [pRowToggler]="abuse">
<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="abuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="abuse.moderationComment" class="glyphicon glyphicon-comment"></span>
</td>
<td class="c-hand abuse-messages" (click)="openAbuseMessagesModal(abuse)">
<ng-container *ngIf="isLocalAbuse(abuse)">
{{ abuse.countMessages }}
<my-global-icon iconName="message-circle"></my-global-icon>
</ng-container>
</td>
<td class="action-cell">
<my-action-dropdown
[ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
i18n-label label="Actions" [actions]="abuseActions" [entry]="abuse"
></my-action-dropdown>
</td>
</tr>
</ng-template>
<ng-template pTemplate="rowexpansion" let-abuse>
<tr>
<td class="expand-cell" colspan="6">
<my-abuse-details [abuse]="abuse" [baseRoute]="baseRoute" [isAdminView]="isAdminView()"></my-abuse-details>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="6">
<div class="no-results">
<ng-container *ngIf="search" i18n>No abuses found matching current filters.</ng-container>
<ng-container *ngIf="!search" i18n>No abuses found.</ng-container>
</div>
</td>
</tr>
</ng-template>
</p-table>
<my-moderation-comment-modal #moderationCommentModal (commentUpdated)="onModerationCommentUpdated()"></my-moderation-comment-modal>
<my-abuse-message-modal #abuseMessagesModal [isAdminView]="isAdminView()" (countMessagesUpdated)="onCountMessagesUpdated($event)"></my-abuse-message-modal>

View File

@ -2,93 +2,6 @@
@import 'mixins';
@import 'miniature';
.form-sub-title {
flex-grow: 0;
margin-right: 30px;
}
.caption {
justify-content: flex-end;
input {
@include peertube-input-text(250px);
flex-grow: 1;
}
}
.moderation-expanded {
font-size: 90%;
.moderation-expanded-label {
font-weight: $font-semibold;
display: inline-block;
vertical-align: top;
text-align: right;
}
.moderation-expanded-text {
display: inline-flex;
word-wrap: break-word;
::ng-deep p:last-child {
margin-bottom: 0px !important;
}
}
}
.table-states {
& > :not(:first-child) {
margin-left: .4rem;
}
}
p-calendar {
display: block;
::ng-deep {
.ui-widget-content {
min-width: 400px;
}
input {
@include peertube-input-text(100%);
}
}
}
.screenratio {
div {
@include miniature-thumbnail;
display: inline-flex;
justify-content: center;
align-items: center;
color: pvar(--inputPlaceholderColor);
}
@include large-screen-ratio($selector: 'div, ::ng-deep iframe') {
width: 100% !important;
height: 100% !important;
left: 0;
};
}
.comment-html {
background-color: #ececec;
padding: 10px;
}
.chip {
@include chip;
}
my-action-dropdown.show {
::ng-deep .dropdown-root {
display: block !important;
}
}
.table-video-link {
@include disable-outline;
@ -179,3 +92,16 @@ my-action-dropdown.show {
}
}
}
.abuse-states .glyphicon-comment {
margin-left: 0.5rem;
}
.abuse-messages {
my-global-icon {
width: 22px;
margin-left: 3px;
position: relative;
top: -2px;
}
}

View File

@ -0,0 +1,487 @@
import * as debug from 'debug'
import truncate from 'lodash-es/truncate'
import { SortMeta } from 'primeng/api'
import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
import { environment } from 'src/environments/environment'
import { AfterViewInit, Component, OnInit, ViewChild, Input } from '@angular/core'
import { DomSanitizer } from '@angular/platform-browser'
import { ActivatedRoute, Params, Router } from '@angular/router'
import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main'
import { AbuseService, BlocklistService, VideoBlockService } from '@app/shared/shared-moderation'
import { VideoCommentService } from '@app/shared/shared-video-comment'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { AbuseState, AdminAbuse } from '@shared/models'
import { AbuseMessageModalComponent } from './abuse-message-modal.component'
import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
import { ProcessedAbuse } from './processed-abuse.model'
const logger = debug('peertube:moderation:AbuseListTableComponent')
@Component({
selector: 'my-abuse-list-table',
templateUrl: './abuse-list-table.component.html',
styleUrls: [ '../shared-moderation/moderation.scss', './abuse-list-table.component.scss' ]
})
export class AbuseListTableComponent extends RestTable implements OnInit, AfterViewInit {
@Input() viewType: 'admin' | 'user'
@Input() baseRoute: string
@ViewChild('abuseMessagesModal', { static: true }) abuseMessagesModal: AbuseMessageModalComponent
@ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent
abuses: ProcessedAbuse[] = []
totalRecords = 0
sort: SortMeta = { field: 'createdAt', order: 1 }
pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
abuseActions: DropdownAction<ProcessedAbuse>[][] = []
constructor (
private notifier: Notifier,
private abuseService: AbuseService,
private blocklistService: BlocklistService,
private commentService: VideoCommentService,
private videoService: VideoService,
private videoBlocklistService: VideoBlockService,
private confirmService: ConfirmService,
private i18n: I18n,
private markdownRenderer: MarkdownService,
private sanitizer: DomSanitizer,
private route: ActivatedRoute,
private router: Router
) {
super()
}
ngOnInit () {
this.abuseActions = [
this.buildInternalActions(),
this.buildFlaggedAccountActions(),
this.buildCommentActions(),
this.buildVideoActions(),
this.buildAccountActions()
]
this.initialize()
this.route.queryParams
.subscribe(params => {
this.search = params.search || ''
logger('On URL change (search: %s).', this.search)
this.setTableFilter(this.search)
this.loadData()
})
}
ngAfterViewInit () {
if (this.search) this.setTableFilter(this.search)
}
isAdminView () {
return this.viewType === 'admin'
}
getIdentifier () {
return 'AbuseListTableComponent'
}
openModerationCommentModal (abuse: AdminAbuse) {
this.moderationCommentModal.openModal(abuse)
}
onModerationCommentUpdated () {
this.loadData()
}
/* Table filter functions */
onAbuseSearch (event: Event) {
this.onSearch(event)
this.setQueryParams((event.target as HTMLInputElement).value)
}
setQueryParams (search: string) {
const queryParams: Params = {}
if (search) Object.assign(queryParams, { search })
this.router.navigate([ this.baseRoute ], { queryParams })
}
resetTableFilter () {
this.setTableFilter('')
this.setQueryParams('')
this.resetSearch()
}
/* END Table filter functions */
isAbuseAccepted (abuse: AdminAbuse) {
return abuse.state.id === AbuseState.ACCEPTED
}
isAbuseRejected (abuse: AdminAbuse) {
return abuse.state.id === AbuseState.REJECTED
}
getVideoUrl (abuse: AdminAbuse) {
return Video.buildClientUrl(abuse.video.uuid)
}
getCommentUrl (abuse: AdminAbuse) {
return Video.buildClientUrl(abuse.comment.video.uuid) + ';threadId=' + abuse.comment.threadId
}
getAccountUrl (abuse: ProcessedAbuse) {
return '/accounts/' + abuse.flaggedAccount.nameWithHost
}
getVideoEmbed (abuse: AdminAbuse) {
return buildVideoEmbed(
buildVideoLink({
baseUrl: `${environment.embedUrl}/videos/embed/${abuse.video.uuid}`,
title: false,
warningTitle: false,
startTime: abuse.startAt,
stopTime: abuse.endAt
})
)
}
switchToDefaultAvatar ($event: Event) {
($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
}
async removeAbuse (abuse: AdminAbuse) {
const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this abuse report?'), this.i18n('Delete'))
if (res === false) return
this.abuseService.removeAbuse(abuse).subscribe(
() => {
this.notifier.success(this.i18n('Abuse deleted.'))
this.loadData()
},
err => this.notifier.error(err.message)
)
}
updateAbuseState (abuse: AdminAbuse, state: AbuseState) {
this.abuseService.updateAbuse(abuse, { state })
.subscribe(
() => this.loadData(),
err => this.notifier.error(err.message)
)
}
onCountMessagesUpdated (event: { abuseId: number, countMessages: number }) {
const abuse = this.abuses.find(a => a.id === event.abuseId)
if (!abuse) {
console.error('Cannot find abuse %d.', event.abuseId)
return
}
abuse.countMessages = event.countMessages
}
openAbuseMessagesModal (abuse: AdminAbuse) {
this.abuseMessagesModal.openModal(abuse)
}
isLocalAbuse (abuse: AdminAbuse) {
if (this.viewType === 'user') return true
return Actor.IS_LOCAL(abuse.reporterAccount.host)
}
protected loadData () {
logger('Loading data.')
const options = {
pagination: this.pagination,
sort: this.sort,
search: this.search
}
const observable = this.viewType === 'admin'
? this.abuseService.getAdminAbuses(options)
: this.abuseService.getUserAbuses(options)
return observable.subscribe(
async resultList => {
this.totalRecords = resultList.total
this.abuses = []
for (const a of resultList.data) {
const abuse = a as ProcessedAbuse
abuse.reasonHtml = await this.toHtml(abuse.reason)
if (abuse.moderationComment) {
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.updatedAt === abuse.createdAt) delete abuse.updatedAt
this.abuses.push(abuse)
}
},
err => this.notifier.error(err.message)
)
}
private buildInternalActions (): DropdownAction<ProcessedAbuse>[] {
return [
{
label: this.i18n('Internal actions'),
isHeader: true
},
{
label: this.isAdminView()
? this.i18n('Messages with reporter')
: this.i18n('Messages with moderators'),
handler: abuse => this.openAbuseMessagesModal(abuse),
isDisplayed: abuse => this.isLocalAbuse(abuse)
},
{
label: this.i18n('Update note'),
handler: abuse => this.openModerationCommentModal(abuse),
isDisplayed: abuse => this.isAdminView() && !!abuse.moderationComment
},
{
label: this.i18n('Mark as accepted'),
handler: abuse => this.updateAbuseState(abuse, AbuseState.ACCEPTED),
isDisplayed: abuse => this.isAdminView() && !this.isAbuseAccepted(abuse)
},
{
label: this.i18n('Mark as rejected'),
handler: abuse => this.updateAbuseState(abuse, AbuseState.REJECTED),
isDisplayed: abuse => this.isAdminView() && !this.isAbuseRejected(abuse)
},
{
label: this.i18n('Add internal note'),
handler: abuse => this.openModerationCommentModal(abuse),
isDisplayed: abuse => this.isAdminView() && !abuse.moderationComment
},
{
label: this.i18n('Delete report'),
handler: abuse => this.isAdminView() && this.removeAbuse(abuse)
}
]
}
private buildFlaggedAccountActions (): DropdownAction<ProcessedAbuse>[] {
if (!this.isAdminView()) return []
return [
{
label: this.i18n('Actions for the flagged account'),
isHeader: true,
isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video
},
{
label: this.i18n('Mute account'),
isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video,
handler: abuse => this.muteAccountHelper(abuse.flaggedAccount)
},
{
label: this.i18n('Mute server account'),
isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video,
handler: abuse => this.muteServerHelper(abuse.flaggedAccount.host)
}
]
}
private buildAccountActions (): DropdownAction<ProcessedAbuse>[] {
if (!this.isAdminView()) return []
return [
{
label: this.i18n('Actions for the reporter'),
isHeader: true,
isDisplayed: abuse => !!abuse.reporterAccount
},
{
label: this.i18n('Mute reporter'),
isDisplayed: abuse => !!abuse.reporterAccount,
handler: abuse => this.muteAccountHelper(abuse.reporterAccount)
},
{
label: this.i18n('Mute server'),
isDisplayed: abuse => abuse.reporterAccount && !abuse.reporterAccount.userId,
handler: abuse => this.muteServerHelper(abuse.reporterAccount.host)
}
]
}
private buildVideoActions (): DropdownAction<ProcessedAbuse>[] {
if (!this.isAdminView()) return []
return [
{
label: this.i18n('Actions for the video'),
isHeader: true,
isDisplayed: abuse => abuse.video && !abuse.video.deleted
},
{
label: this.i18n('Block video'),
isDisplayed: abuse => abuse.video && !abuse.video.deleted && !abuse.video.blacklisted,
handler: abuse => {
this.videoBlocklistService.blockVideo(abuse.video.id, undefined, true)
.subscribe(
() => {
this.notifier.success(this.i18n('Video blocked.'))
this.updateAbuseState(abuse, AbuseState.ACCEPTED)
},
err => this.notifier.error(err.message)
)
}
},
{
label: this.i18n('Unblock video'),
isDisplayed: abuse => abuse.video && !abuse.video.deleted && abuse.video.blacklisted,
handler: abuse => {
this.videoBlocklistService.unblockVideo(abuse.video.id)
.subscribe(
() => {
this.notifier.success(this.i18n('Video unblocked.'))
this.updateAbuseState(abuse, AbuseState.ACCEPTED)
},
err => this.notifier.error(err.message)
)
}
},
{
label: this.i18n('Delete video'),
isDisplayed: abuse => abuse.video && !abuse.video.deleted,
handler: async abuse => {
const res = await this.confirmService.confirm(
this.i18n('Do you really want to delete this video?'),
this.i18n('Delete')
)
if (res === false) return
this.videoService.removeVideo(abuse.video.id)
.subscribe(
() => {
this.notifier.success(this.i18n('Video deleted.'))
this.updateAbuseState(abuse, AbuseState.ACCEPTED)
},
err => this.notifier.error(err.message)
)
}
}
]
}
private buildCommentActions (): DropdownAction<ProcessedAbuse>[] {
if (!this.isAdminView()) return []
return [
{
label: this.i18n('Actions for the comment'),
isHeader: true,
isDisplayed: abuse => abuse.comment && !abuse.comment.deleted
},
{
label: this.i18n('Delete comment'),
isDisplayed: abuse => abuse.comment && !abuse.comment.deleted,
handler: async abuse => {
const res = await this.confirmService.confirm(
this.i18n('Do you really want to delete this comment?'),
this.i18n('Delete')
)
if (res === false) return
this.commentService.deleteVideoComment(abuse.comment.video.id, abuse.comment.id)
.subscribe(
() => {
this.notifier.success(this.i18n('Comment deleted.'))
this.updateAbuseState(abuse, AbuseState.ACCEPTED)
},
err => this.notifier.error(err.message)
)
}
}
]
}
private muteAccountHelper (account: Account) {
this.blocklistService.blockAccountByInstance(account)
.subscribe(
() => {
this.notifier.success(
this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost })
)
account.mutedByInstance = true
},
err => this.notifier.error(err.message)
)
}
private muteServerHelper (host: string) {
this.blocklistService.blockServerByInstance(host)
.subscribe(
() => {
this.notifier.success(
this.i18n('Server {{host}} muted by the instance.', { host: host })
)
},
err => this.notifier.error(err.message)
)
}
private toHtml (text: string) {
return this.markdownRenderer.textMarkdownToHTML(text)
}
}

View File

@ -1,6 +1,9 @@
<ng-template #modal>
<div class="modal-header">
<h4 i18n class="modal-title">Messages</h4>
<h4 class="modal-title">
<ng-container i18n *ngIf="isAdminView">Messages with the reporter</ng-container>
<ng-container i18n *ngIf="!isAdminView">Messages with the moderation team</ng-container>
</h4>
<my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
</div>
@ -21,9 +24,16 @@
</div>
</div>
<div class="no-messages" *ngIf="noResults" i18n>
No messages for now.
</div>
<form novalidate [formGroup]="form" (ngSubmit)="addMessage()">
<div class="form-group">
<textarea formControlName="message" ngbAutofocus [ngClass]="{ 'input-error': formErrors['message'] }" class="form-control"></textarea>
<textarea
formControlName="message" ngbAutofocus [placeholder]="getPlaceholderMessage()"
[ngClass]="{ 'input-error': formErrors['message'] }" class="form-control"
></textarea>
<div *ngIf="formErrors.message" class="form-error">
{{ formErrors.message }}
@ -31,7 +41,7 @@
</div>
<div class="form-group inputs">
<input type="submit" i18n-value value="Add message" class="action-button-submit" [disabled]="!form.valid || sendingMessage">
<input type="submit" i18n-value value="Add a message" class="action-button-submit" [disabled]="!form.valid || sendingMessage">
</div>
</form>

View File

@ -3,6 +3,11 @@
form {
margin: 20px 20px 0 0;
.form-group:first-child {
// Keep place to display error message without modifying the height
min-height: 125px;
}
}
textarea {
@ -15,35 +20,29 @@ textarea {
display: flex;
flex-direction: column;
overflow-y: scroll;
margin-right: 5px;
}
.no-messages {
display: flex;
font-size: 15px;
justify-content: center;
}
.message-block {
margin-bottom: 10px;
margin: 0 5px 10px 0;
max-width: 60%;
.author {
color: var(--greyForegroundColor);
font-size: 14px;
padding: 0 0 3px 10px;
}
.bubble {
color: var(--mainForegroundColor);
background-color: var(--greyBackgroundColor);
border-radius: 10px;
padding: 5px 10px;
&.by-me {
color: var(--mainForegroundColor);
background-color: var(--secondaryColor);
}
&.by-moderator {
color: #fff;
background-color: var(--mainColor);
align-self: flex-end;
}
color: var(--mainForegroundColor);
background-color: var(--greyBackgroundColor);
.content {
font-size: 15px;
@ -54,4 +53,20 @@ textarea {
color: var(--greyForegroundColor);
}
}
&.by-me {
.bubble {
color: var(--mainBackgroundColor);
background-color: var(--mainColorLighter);
.date {
color: var(--mainBackgroundColor);
}
}
}
&.by-moderator {
align-self: flex-end;
}
}

View File

@ -1,11 +1,11 @@
import { Component, ElementRef, EventEmitter, Output, ViewChild, OnInit } from '@angular/core'
import { Notifier, AuthService } from '@app/core'
import { FormReactive, FormValidatorService, AbuseValidatorsService } from '@app/shared/shared-forms'
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
import { AuthService, 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 { AbuseMessage, UserAbuse } from '@shared/models'
import { AbuseService } from './abuse.service'
import { AbuseService } from '../shared-moderation'
@Component({
selector: 'my-abuse-message-modal',
@ -16,11 +16,14 @@ export class AbuseMessageModalComponent extends FormReactive implements OnInit {
@ViewChild('modal', { static: true }) modal: NgbModal
@ViewChild('messagesBlock', { static: false }) messagesBlock: ElementRef
@Input() isAdminView: boolean
@Output() countMessagesUpdated = new EventEmitter<{ abuseId: number, countMessages: number }>()
abuseMessages: AbuseMessage[] = []
textareaMessage: string
sendingMessage = false
noResults = false
private openedModal: NgbModalRef
private abuse: UserAbuse
@ -29,9 +32,9 @@ export class AbuseMessageModalComponent extends FormReactive implements OnInit {
protected formValidatorService: FormValidatorService,
private abuseValidatorsService: AbuseValidatorsService,
private modalService: NgbModal,
private i18n: I18n,
private auth: AuthService,
private notifier: Notifier,
private i18n: I18n,
private abuseService: AbuseService
) {
super()
@ -94,11 +97,20 @@ export class AbuseMessageModalComponent extends FormReactive implements OnInit {
return this.auth.getUser().account.id === abuseMessage.account.id
}
getPlaceholderMessage () {
if (this.isAdminView) {
return this.i18n('Add a message to communicate with the reporter')
}
return this.i18n('Add a message to communicate with the moderation team')
}
private loadMessages () {
this.abuseService.listAbuseMessages(this.abuse)
.subscribe(
res => {
this.abuseMessages = res.data
this.noResults = this.abuseMessages.length === 0
setTimeout(() => {
if (!this.messagesBlock) return

View File

@ -0,0 +1,7 @@
export * from './abuse-message-modal.component'
export * from './abuse-list-table.component'
export * from './abuse-details.component'
export * from './moderation-comment-modal.component'
export * from './processed-abuse.model'
export * from './shared-abuse-list.module'

View File

@ -0,0 +1,25 @@
import { SafeHtml } from '@angular/platform-browser'
import { AdminAbuse } from '@shared/models'
import { Account } from '@app/shared/shared-main'
// Don't use an abuse model because we need external services to compute some properties
// And this model is only used in this component
export type ProcessedAbuse = AdminAbuse & {
moderationCommentHtml?: string,
reasonHtml?: string
embedHtml?: SafeHtml
updatedAt?: Date
// override bare server-side definitions with rich client-side definitions
reporterAccount?: Account
flaggedAccount?: Account
truncatedCommentHtml?: string
commentHtml?: string
video: AdminAbuse['video'] & {
channel: AdminAbuse['video']['channel'] & {
ownerAccount: Account
}
}
}

View File

@ -0,0 +1,42 @@
import { TableModule } from 'primeng/table'
import { NgModule } from '@angular/core'
import { SharedFormModule } from '../shared-forms/shared-form.module'
import { SharedGlobalIconModule } from '../shared-icons'
import { SharedMainModule } from '../shared-main/shared-main.module'
import { SharedModerationModule } from '../shared-moderation'
import { SharedVideoCommentModule } from '../shared-video-comment'
import { AbuseDetailsComponent } from './abuse-details.component'
import { AbuseListTableComponent } from './abuse-list-table.component'
import { AbuseMessageModalComponent } from './abuse-message-modal.component'
import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
@NgModule({
imports: [
TableModule,
SharedMainModule,
SharedFormModule,
SharedModerationModule,
SharedGlobalIconModule,
SharedVideoCommentModule
],
declarations: [
AbuseDetailsComponent,
AbuseListTableComponent,
ModerationCommentModalComponent,
AbuseMessageModalComponent
],
exports: [
AbuseDetailsComponent,
AbuseListTableComponent,
ModerationCommentModalComponent,
AbuseMessageModalComponent
],
providers: [
]
})
export class SharedAbuseListModule { }

View File

@ -41,6 +41,13 @@ export abstract class Actor implements ActorServer {
return accountName + '@' + host
}
static IS_LOCAL (host: string) {
const absoluteAPIUrl = getAbsoluteAPIUrl()
const thisHost = new URL(absoluteAPIUrl).host
return host.trim() === thisHost
}
protected constructor (hash: ActorServer) {
this.id = hash.id
this.url = hash.url
@ -53,10 +60,7 @@ export abstract class Actor implements ActorServer {
if (hash.updatedAt) this.updatedAt = new Date(hash.updatedAt.toString())
this.avatar = hash.avatar
const absoluteAPIUrl = getAbsoluteAPIUrl()
const thisHost = new URL(absoluteAPIUrl).host
this.isLocal = this.host.trim() === thisHost
this.isLocal = Actor.IS_LOCAL(this.host)
this.updateComputedAttributes()
}

View File

@ -5,13 +5,24 @@ import { catchError, map } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { RestExtractor, RestPagination, RestService } from '@app/core'
import { AdminAbuse, AbuseCreate, AbuseFilter, AbusePredefinedReasonsString, AbuseState, AbuseUpdate, ResultList, UserAbuse, AbuseMessage } from '@shared/models'
import { environment } from '../../../environments/environment'
import { I18n } from '@ngx-translate/i18n-polyfill'
import {
AbuseCreate,
AbuseFilter,
AbuseMessage,
AbusePredefinedReasonsString,
AbuseState,
AbuseUpdate,
AdminAbuse,
ResultList,
UserAbuse
} from '@shared/models'
import { environment } from '../../../environments/environment'
@Injectable()
export class AbuseService {
private static BASE_ABUSE_URL = environment.apiUrl + '/api/v1/abuses'
private static BASE_MY_ABUSE_URL = environment.apiUrl + '/api/v1/users/me/abuses'
constructor (
private i18n: I18n,
@ -32,33 +43,7 @@ export class AbuseService {
params = this.restService.addRestGetParams(params, pagination, sort)
if (search) {
const filters = this.restService.parseQueryStringFilter(search, {
id: { prefix: '#' },
state: {
prefix: 'state:',
handler: v => {
if (v === 'accepted') return AbuseState.ACCEPTED
if (v === 'pending') return AbuseState.PENDING
if (v === 'rejected') return AbuseState.REJECTED
return undefined
}
},
videoIs: {
prefix: 'videoIs:',
handler: v => {
if (v === 'deleted') return v
if (v === 'blacklisted') return v
return undefined
}
},
searchReporter: { prefix: 'reporter:' },
searchReportee: { prefix: 'reportee:' },
predefinedReason: { prefix: 'tag:' }
})
params = this.restService.addObjectParams(params, filters)
params = this.buildParamsFromSearch(search, params)
}
return this.authHttp.get<ResultList<AdminAbuse>>(url, { params })
@ -67,6 +52,27 @@ export class AbuseService {
)
}
getUserAbuses (options: {
pagination: RestPagination,
sort: SortMeta,
search?: string
}): Observable<ResultList<UserAbuse>> {
const { pagination, sort, search } = options
const url = AbuseService.BASE_MY_ABUSE_URL
let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort)
if (search) {
params = this.buildParamsFromSearch(search, params)
}
return this.authHttp.get<ResultList<UserAbuse>>(url, { params })
.pipe(
catchError(res => this.restExtractor.handleError(res))
)
}
reportVideo (parameters: AbuseCreate) {
const url = AbuseService.BASE_ABUSE_URL
@ -180,4 +186,33 @@ export class AbuseService {
return reasons
}
private buildParamsFromSearch (search: string, params: HttpParams) {
const filters = this.restService.parseQueryStringFilter(search, {
id: { prefix: '#' },
state: {
prefix: 'state:',
handler: v => {
if (v === 'accepted') return AbuseState.ACCEPTED
if (v === 'pending') return AbuseState.PENDING
if (v === 'rejected') return AbuseState.REJECTED
return undefined
}
},
videoIs: {
prefix: 'videoIs:',
handler: v => {
if (v === 'deleted') return v
if (v === 'blacklisted') return v
return undefined
}
},
searchReporter: { prefix: 'reporter:' },
searchReportee: { prefix: 'reportee:' },
predefinedReason: { prefix: 'tag:' }
})
return this.restService.addObjectParams(params, filters)
}
}

View File

@ -1,6 +1,5 @@
export * from './report-modals'
export * from './abuse-message-modal.component'
export * from './abuse.service'
export * from './account-block.model'
export * from './account-blocklist.component'

View File

@ -0,0 +1,50 @@
@import 'variables';
@import 'mixins';
@import 'miniature';
.moderation-expanded {
font-size: 90%;
.moderation-expanded-label {
font-weight: $font-semibold;
display: inline-block;
vertical-align: top;
text-align: right;
}
.moderation-expanded-text {
display: inline-flex;
word-wrap: break-word;
::ng-deep p:last-child {
margin-bottom: 0px !important;
}
}
}
.input-group {
@include peertube-input-group(300px);
.dropdown-toggle::after {
margin-left: 0;
}
}
.chip {
@include chip;
}
.caption {
justify-content: flex-end;
input {
@include peertube-input-text(250px);
flex-grow: 1;
}
}
my-action-dropdown.show {
::ng-deep .dropdown-root {
display: block !important;
}
}

View File

@ -32,3 +32,16 @@ a {
.block-button {
@include create-button;
}
.caption {
justify-content: flex-end;
input {
@include peertube-input-text(250px);
flex-grow: 1;
}
}
.chip {
@include chip;
}

View File

@ -4,7 +4,6 @@ import { SharedFormModule } from '../shared-forms/shared-form.module'
import { SharedGlobalIconModule } from '../shared-icons'
import { SharedMainModule } from '../shared-main/shared-main.module'
import { SharedVideoCommentModule } from '../shared-video-comment'
import { AbuseMessageModalComponent } from './abuse-message-modal.component'
import { AbuseService } from './abuse.service'
import { BatchDomainsModalComponent } from './batch-domains-modal.component'
import { BlocklistService } from './blocklist.service'
@ -30,8 +29,7 @@ import { VideoBlockService } from './video-block.service'
VideoReportComponent,
BatchDomainsModalComponent,
CommentReportComponent,
AccountReportComponent,
AbuseMessageModalComponent
AccountReportComponent
],
exports: [
@ -41,8 +39,7 @@ import { VideoBlockService } from './video-block.service'
VideoReportComponent,
BatchDomainsModalComponent,
CommentReportComponent,
AccountReportComponent,
AbuseMessageModalComponent
AccountReportComponent
],
providers: [

View File

@ -16,6 +16,7 @@ import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
checkAbuseValidForMessagesValidator,
deleteAbuseMessageValidator,
ensureUserHasRight,
getAbuseValidator,
@ -58,12 +59,14 @@ abuseRouter.delete('/:id',
abuseRouter.get('/:id/messages',
authenticate,
asyncMiddleware(getAbuseValidator),
checkAbuseValidForMessagesValidator,
asyncRetryTransactionMiddleware(listAbuseMessages)
)
abuseRouter.post('/:id/messages',
authenticate,
asyncMiddleware(getAbuseValidator),
checkAbuseValidForMessagesValidator,
addAbuseMessageValidator,
asyncRetryTransactionMiddleware(addAbuseMessage)
)
@ -71,6 +74,7 @@ abuseRouter.post('/:id/messages',
abuseRouter.delete('/:id/messages/:messageId',
authenticate,
asyncMiddleware(getAbuseValidator),
checkAbuseValidForMessagesValidator,
asyncMiddleware(deleteAbuseMessageValidator),
asyncRetryTransactionMiddleware(deleteAbuseMessage)
)

View File

@ -43,6 +43,6 @@ async function listMyAbuses (req: express.Request, res: express.Response) {
return res.json({
total: resultList.total,
data: resultList.data.map(d => d.toFormattedAdminJSON())
data: resultList.data.map(d => d.toFormattedUserJSON())
})
}

View File

@ -26,7 +26,7 @@ async function doesVideoAbuseExist (abuseIdArg: number | string, videoUUID: stri
}
async function doesAbuseExist (abuseId: number | string, res: Response) {
const abuse = await AbuseModel.loadById(parseInt(abuseId + '', 10))
const abuse = await AbuseModel.loadByIdWithReporter(parseInt(abuseId + '', 10))
if (!abuse) {
res.status(404)

View File

@ -201,6 +201,21 @@ const getAbuseValidator = [
}
]
const checkAbuseValidForMessagesValidator = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking checkAbuseValidForMessagesValidator parameters', { parameters: req.body })
const abuse = res.locals.abuse
if (abuse.ReporterAccount.isOwned() === false) {
return res.status(400).json({
error: 'This abuse was created by a user of your instance.',
})
}
return next()
}
]
const addAbuseMessageValidator = [
body('message').custom(isAbuseMessageValid).not().isEmpty().withMessage('Should have a valid abuse message'),
@ -357,6 +372,7 @@ export {
abuseReportValidator,
abuseGetValidator,
addAbuseMessageValidator,
checkAbuseValidForMessagesValidator,
abuseUpdateValidator,
deleteAbuseMessageValidator,
abuseListForUserValidator,

View File

@ -25,14 +25,14 @@ import {
AbusePredefinedReasonsString,
AbuseState,
AbuseVideoIs,
AdminVideoAbuse,
AdminAbuse,
AdminVideoAbuse,
AdminVideoCommentAbuse,
UserAbuse,
UserVideoAbuse
} from '@shared/models'
import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
import { MAbuse, MAbuseAdminFormattable, MAbuseAP, MUserAccountId, MAbuseUserFormattable } from '../../types/models'
import { MAbuse, MAbuseAdminFormattable, MAbuseAP, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models'
import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
import { getSort, throwIfNotValid } from '../utils'
import { ThumbnailModel } from '../video/thumbnail'
@ -266,7 +266,7 @@ export class AbuseModel extends Model<AbuseModel> {
VideoAbuse: VideoAbuseModel
// FIXME: deprecated in 2.3. Remove these validators
static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird<MAbuse> {
static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird<MAbuseReporter> {
const videoWhere: WhereOptions = {}
if (videoId) videoWhere.videoId = videoId
@ -278,6 +278,10 @@ export class AbuseModel extends Model<AbuseModel> {
model: VideoAbuseModel,
required: true,
where: videoWhere
},
{
model: AccountModel,
as: 'ReporterAccount'
}
],
where: {
@ -287,11 +291,17 @@ export class AbuseModel extends Model<AbuseModel> {
return AbuseModel.findOne(query)
}
static loadById (id: number): Bluebird<MAbuse> {
static loadByIdWithReporter (id: number): Bluebird<MAbuseReporter> {
const query = {
where: {
id
}
},
include: [
{
model: AccountModel,
as: 'ReporterAccount'
}
]
}
return AbuseModel.findOne(query)
@ -466,8 +476,6 @@ export class AbuseModel extends Model<AbuseModel> {
label: AbuseModel.getStateLabel(this.state)
},
moderationComment: this.moderationComment,
countMessages,
createdAt: this.createdAt,
@ -500,6 +508,8 @@ export class AbuseModel extends Model<AbuseModel> {
video,
comment,
moderationComment: this.moderationComment,
reporterAccount: this.ReporterAccount
? this.ReporterAccount.toFormattedJSON()
: null,
@ -519,7 +529,7 @@ export class AbuseModel extends Model<AbuseModel> {
const countMessages = this.get('countMessages') as number
const video = this.buildBaseVideoAbuse()
const comment: AdminVideoCommentAbuse = this.buildBaseVideoCommentAbuse()
const comment = this.buildBaseVideoCommentAbuse()
const abuse = this.buildBaseAbuse(countMessages || 0)
return Object.assign(abuse, {

View File

@ -3,21 +3,26 @@
import 'mocha'
import { AbuseCreate, AbuseState } from '@shared/models'
import {
addAbuseMessage,
cleanupTests,
createUser,
deleteAbuse,
deleteAbuseMessage,
doubleFollow,
flushAndRunServer,
generateUserAccessToken,
getAdminAbusesList,
getVideoIdFromUUID,
listAbuseMessages,
makeGetRequest,
makePostBodyRequest,
reportAbuse,
ServerInfo,
setAccessTokensToServers,
updateAbuse,
uploadVideo,
userLogin,
generateUserAccessToken,
addAbuseMessage,
listAbuseMessages,
deleteAbuseMessage
waitJobs
} from '../../../../shared/extra-utils'
import {
checkBadCountPagination,
@ -29,6 +34,7 @@ describe('Test abuses API validators', function () {
const basePath = '/api/v1/abuses/'
let server: ServerInfo
let userAccessToken = ''
let userAccessToken2 = ''
let abuseId: number
@ -321,7 +327,7 @@ describe('Test abuses API validators', function () {
})
})
describe('When listing abuse message', function () {
describe('When listing abuse messages', function () {
it('Should fail with an invalid abuse id', async function () {
await listAbuseMessages(server.url, userAccessToken, 888, 404)
@ -382,7 +388,43 @@ describe('Test abuses API validators', function () {
})
})
describe('When trying to manage messages of a remote abuse', function () {
let remoteAbuseId: number
let anotherServer: ServerInfo
before(async function () {
this.timeout(20000)
anotherServer = await flushAndRunServer(2)
await setAccessTokensToServers([ anotherServer ])
await doubleFollow(anotherServer, server)
const server2VideoId = await getVideoIdFromUUID(anotherServer.url, server.video.uuid)
await reportAbuse({
url: anotherServer.url,
token: anotherServer.accessToken,
reason: 'remote server',
videoId: server2VideoId
})
await waitJobs([ server, anotherServer ])
const res = await getAdminAbusesList({ url: server.url, token: server.accessToken, sort: '-createdAt' })
remoteAbuseId = res.body.data[0].id
})
it('Should fail when listing abuse messages of a remote abuse', async function () {
await listAbuseMessages(server.url, server.accessToken, remoteAbuseId, 400)
})
it('Should fail when creating abuse message of a remote abuse', async function () {
await addAbuseMessage(server.url, server.accessToken, remoteAbuseId, 'message', 400)
})
})
after(async function () {
await cleanupTests([ server ])
})
})

View File

@ -2,12 +2,23 @@
import 'mocha'
import * as chai from 'chai'
import { AbuseFilter, AbusePredefinedReasonsString, AbuseState, Account, AdminAbuse, UserAbuse, VideoComment, AbuseMessage } from '@shared/models'
import {
AbuseFilter,
AbuseMessage,
AbusePredefinedReasonsString,
AbuseState,
Account,
AdminAbuse,
UserAbuse,
VideoComment
} from '@shared/models'
import {
addAbuseMessage,
addVideoCommentThread,
cleanupTests,
createUser,
deleteAbuse,
deleteAbuseMessage,
deleteVideoComment,
flushAndRunMultipleServers,
generateUserAccessToken,
@ -18,6 +29,7 @@ import {
getVideoIdFromUUID,
getVideosList,
immutableAssign,
listAbuseMessages,
removeUser,
removeVideo,
reportAbuse,
@ -26,10 +38,7 @@ import {
updateAbuse,
uploadVideo,
uploadVideoAndGetId,
userLogin,
addAbuseMessage,
listAbuseMessages,
deleteAbuseMessage
userLogin
} from '../../../../shared/extra-utils/index'
import { doubleFollow } from '../../../../shared/extra-utils/server/follows'
import { waitJobs } from '../../../../shared/extra-utils/server/jobs'

View File

@ -2,7 +2,7 @@ import { VideoAbuseModel } from '@server/models/abuse/video-abuse'
import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse'
import { PickWith } from '@shared/core-utils'
import { AbuseModel } from '../../../models/abuse/abuse'
import { MAccountDefault, MAccountFormattable, MAccountLight, MAccountUrl } from '../account'
import { MAccountDefault, MAccountFormattable, MAccountLight, MAccountUrl, MAccount } from '../account'
import { MCommentOwner, MCommentUrl, MVideoUrl, MCommentOwnerVideo, MComment, MCommentVideo } from '../video'
import { MVideo, MVideoAccountLightBlacklistAllFiles } from '../video/video'
@ -18,6 +18,10 @@ export type MVideoAbuse = Omit<VideoAbuseModel, 'Abuse' | 'Video'>
export type MCommentAbuse = Omit<VideoCommentAbuseModel, 'Abuse' | 'VideoComment'>
export type MAbuseReporter =
MAbuse &
Use<'ReporterAccount', MAccountDefault>
// ############################################################################
export type MVideoAbuseVideo =

View File

@ -9,7 +9,8 @@ import {
MVideoFile,
MVideoImmutable,
MVideoPlaylistFull,
MVideoPlaylistFullSummary
MVideoPlaylistFullSummary,
MAbuseReporter
} from '@server/types/models'
import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server'
@ -78,7 +79,7 @@ declare module 'express' {
videoCaption?: MVideoCaptionVideo
abuse?: MAbuse
abuse?: MAbuseReporter
abuseMessage?: MAbuseMessage
videoStreamingPlaylist?: MStreamingPlaylist

View File

@ -79,4 +79,4 @@ export type UserVideoAbuse = Omit<AdminVideoAbuse, 'countReports' | 'nthReport'>
export type UserVideoCommentAbuse = AdminVideoCommentAbuse
export type UserAbuse = Omit<AdminAbuse, 'reporterAccount' | 'countReportsForReportee' | 'countReportsForReporter' | 'startAt' | 'endAt'
| 'count' | 'nth'>
| 'count' | 'nth' | 'moderationComment'>