Add abuse messages management in my account
This commit is contained in:
parent
441e453ae5
commit
94148c9028
|
@ -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,
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1 @@
|
|||
export * from './abuse-details.component'
|
||||
export * from './abuse-list.component'
|
||||
export * from './moderation-comment-modal.component'
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -16,3 +16,12 @@ my-global-icon {
|
|||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.caption {
|
||||
justify-content: flex-end;
|
||||
|
||||
input {
|
||||
@include peertube-input-text(250px);
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 })[] = []
|
||||
|
|
|
@ -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>
|
|
@ -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 {
|
||||
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:"' + abuse.reporterAccount.displayName + '"' }"
|
||||
<a [routerLink]="[ baseRoute ]" [queryParams]="{ 'search': 'reporter:"' + abuse.reporterAccount.displayName + '"' }"
|
||||
class="chip"
|
||||
>
|
||||
<img
|
||||
|
@ -21,7 +21,7 @@
|
|||
</div>
|
||||
</a>
|
||||
|
||||
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:"' + abuse.reporterAccount.displayName + '"' }"
|
||||
<a [routerLink]="[ baseRoute ]" [queryParams]="{ 'search': 'reporter:"' + abuse.reporterAccount.displayName + '"' }"
|
||||
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:"' +abuse.flaggedAccount.displayName + '"' }"
|
||||
<a [routerLink]="[ baseRoute ]" [queryParams]="{ 'search': 'reportee:"' +abuse.flaggedAccount.displayName + '"' }"
|
||||
class="chip"
|
||||
>
|
||||
<img
|
||||
|
@ -46,7 +46,7 @@
|
|||
</div>
|
||||
</a>
|
||||
|
||||
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:"' +abuse.flaggedAccount.displayName + '"' }"
|
||||
<a *ngIf="isAdminView" [routerLink]="[ baseRoute ]" [queryParams]="{ 'search': 'reportee:"' +abuse.flaggedAccount.displayName + '"' }"
|
||||
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>
|
|
@ -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;
|
||||
}
|
|
@ -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 }
|
||||
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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'
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 { }
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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 ])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'>
|
||||
|
|
Loading…
Reference in New Issue