Add abuse message management in admin

pull/2926/head
Chocobozzz 2020-07-24 17:21:25 +02:00 committed by Chocobozzz
parent edbc932546
commit 441e453ae5
17 changed files with 326 additions and 29 deletions

View File

@ -46,6 +46,7 @@
<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>
@ -157,6 +158,12 @@
<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"
@ -187,3 +194,4 @@
</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>

View File

@ -21,3 +21,12 @@
margin-left: 0;
}
}
.abuse-messages {
my-global-icon {
width: 22px;
margin-left: 3px;
position: relative;
top: -2px;
}
}

View File

@ -8,17 +8,17 @@ 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 } from '@app/shared/shared-moderation'
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 { Abuse, AbuseState } from '@shared/models'
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 = Abuse & {
export type ProcessedAbuse = AdminAbuse & {
moderationCommentHtml?: string,
reasonHtml?: string
embedHtml?: SafeHtml
@ -31,8 +31,8 @@ export type ProcessedAbuse = Abuse & {
truncatedCommentHtml?: string
commentHtml?: string
video: Abuse['video'] & {
channel: Abuse['video']['channel'] & {
video: AdminAbuse['video'] & {
channel: AdminAbuse['video']['channel'] & {
ownerAccount: Account
}
}
@ -45,6 +45,7 @@ export type ProcessedAbuse = Abuse & {
})
export class AbuseListComponent extends RestTable implements OnInit, AfterViewInit {
@ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent
@ViewChild('abuseMessagesModal', { static: true }) abuseMessagesModal: AbuseMessageModalComponent
abuses: ProcessedAbuse[] = []
totalRecords = 0
@ -104,7 +105,7 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
return 'AbuseListComponent'
}
openModerationCommentModal (abuse: Abuse) {
openModerationCommentModal (abuse: AdminAbuse) {
this.moderationCommentModal.openModal(abuse)
}
@ -132,19 +133,19 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
}
/* END Table filter functions */
isAbuseAccepted (abuse: Abuse) {
isAbuseAccepted (abuse: AdminAbuse) {
return abuse.state.id === AbuseState.ACCEPTED
}
isAbuseRejected (abuse: Abuse) {
isAbuseRejected (abuse: AdminAbuse) {
return abuse.state.id === AbuseState.REJECTED
}
getVideoUrl (abuse: Abuse) {
getVideoUrl (abuse: AdminAbuse) {
return Video.buildClientUrl(abuse.video.uuid)
}
getCommentUrl (abuse: Abuse) {
getCommentUrl (abuse: AdminAbuse) {
return Video.buildClientUrl(abuse.comment.video.uuid) + ';threadId=' + abuse.comment.threadId
}
@ -152,7 +153,7 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
return '/accounts/' + abuse.flaggedAccount.nameWithHost
}
getVideoEmbed (abuse: Abuse) {
getVideoEmbed (abuse: AdminAbuse) {
return buildVideoEmbed(
buildVideoLink({
baseUrl: `${environment.embedUrl}/videos/embed/${abuse.video.uuid}`,
@ -168,7 +169,7 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
}
async removeAbuse (abuse: Abuse) {
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
@ -182,7 +183,7 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
)
}
updateAbuseState (abuse: Abuse, state: AbuseState) {
updateAbuseState (abuse: AdminAbuse, state: AbuseState) {
this.abuseService.updateAbuse(abuse, { state })
.subscribe(
() => this.loadData(),
@ -191,10 +192,25 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
)
}
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.getAbuses({
return this.abuseService.getAdminAbuses({
pagination: this.pagination,
sort: this.sort,
search: this.search
@ -257,7 +273,11 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
handler: abuse => this.removeAbuse(abuse)
},
{
label: this.i18n('Add note'),
label: this.i18n('Messages'),
handler: abuse => this.openAbuseMessagesModal(abuse)
},
{
label: this.i18n('Add internal note'),
handler: abuse => this.openModerationCommentModal(abuse),
isDisplayed: abuse => !abuse.moderationComment
},

View File

@ -5,7 +5,7 @@ import { AbuseService } from '@app/shared/shared-moderation'
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 { Abuse } from '@shared/models'
import { AdminAbuse } from '@shared/models'
@Component({
selector: 'my-moderation-comment-modal',
@ -16,7 +16,7 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
@ViewChild('modal', { static: true }) modal: NgbModal
@Output() commentUpdated = new EventEmitter<string>()
private abuseToComment: Abuse
private abuseToComment: AdminAbuse
private openedModal: NgbModalRef
constructor (
@ -36,7 +36,7 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
})
}
openModal (abuseToComment: Abuse) {
openModal (abuseToComment: AdminAbuse) {
this.abuseToComment = abuseToComment
this.openedModal = this.modalService.open(this.modal, { centered: true })

View File

@ -7,6 +7,7 @@ import { BuildFormValidator } from './form-validator.service'
export class AbuseValidatorsService {
readonly ABUSE_REASON: BuildFormValidator
readonly ABUSE_MODERATION_COMMENT: BuildFormValidator
readonly ABUSE_MESSAGE: BuildFormValidator
constructor (private i18n: I18n) {
this.ABUSE_REASON = {
@ -26,5 +27,14 @@ export class AbuseValidatorsService {
'maxlength': this.i18n('Moderation comment cannot be more than 3000 characters long.')
}
}
this.ABUSE_MESSAGE = {
VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
MESSAGES: {
'required': this.i18n('Abuse message is required.'),
'minlength': this.i18n('Abuse message must be at least 2 characters long.'),
'maxlength': this.i18n('Abuse message cannot be more than 3000 characters long.')
}
}
}
}

View File

@ -64,8 +64,7 @@ const icons = {
'go': require('!!raw-loader?!../../../assets/images/feather/arrow-up-right.svg').default,
'cross': require('!!raw-loader?!../../../assets/images/feather/x.svg').default,
'tick': require('!!raw-loader?!../../../assets/images/feather/check.svg').default,
'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
'columns': require('!!raw-loader?!../../../assets/images/feather/columns.svg').default
'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default
}
export type GlobalIconName = keyof typeof icons

View File

@ -0,0 +1,40 @@
<ng-template #modal>
<div class="modal-header">
<h4 i18n class="modal-title">Messages</h4>
<my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
</div>
<div class="modal-body">
<div class="messages" #messagesBlock>
<div
*ngFor="let message of abuseMessages"
class="message-block" [ngClass]="{ 'by-moderator': message.byModerator, 'by-me': isMessageByMe(message) }"
>
<div class="author">{{ message.account.name }}</div>
<div class="bubble">
<div class="content">{{ message.message }}</div>
<div class="date">{{ message.createdAt | date }}</div>
</div>
</div>
</div>
<form novalidate [formGroup]="form" (ngSubmit)="addMessage()">
<div class="form-group">
<textarea formControlName="message" ngbAutofocus [ngClass]="{ 'input-error': formErrors['message'] }" class="form-control"></textarea>
<div *ngIf="formErrors.message" class="form-error">
{{ formErrors.message }}
</div>
</div>
<div class="form-group inputs">
<input type="submit" i18n-value value="Add message" class="action-button-submit" [disabled]="!form.valid || sendingMessage">
</div>
</form>
</div>
</ng-template>

View File

@ -0,0 +1,57 @@
@import 'variables';
@import 'mixins';
form {
margin: 20px 20px 0 0;
}
textarea {
@include peertube-textarea(100%, 70px);
margin-top: 20px;
}
.messages {
display: flex;
flex-direction: column;
overflow-y: scroll;
margin-right: 5px;
}
.message-block {
margin-bottom: 10px;
max-width: 60%;
.author {
color: var(--greyForegroundColor);
font-size: 14px;
}
.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;
}
.content {
font-size: 15px;
}
.date {
font-size: 13px;
color: var(--greyForegroundColor);
}
}
}

View File

@ -0,0 +1,115 @@
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 { 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'
@Component({
selector: 'my-abuse-message-modal',
templateUrl: './abuse-message-modal.component.html',
styleUrls: [ './abuse-message-modal.component.scss' ]
})
export class AbuseMessageModalComponent extends FormReactive implements OnInit {
@ViewChild('modal', { static: true }) modal: NgbModal
@ViewChild('messagesBlock', { static: false }) messagesBlock: ElementRef
@Output() countMessagesUpdated = new EventEmitter<{ abuseId: number, countMessages: number }>()
abuseMessages: AbuseMessage[] = []
textareaMessage: string
sendingMessage = false
private openedModal: NgbModalRef
private abuse: UserAbuse
constructor (
protected formValidatorService: FormValidatorService,
private abuseValidatorsService: AbuseValidatorsService,
private modalService: NgbModal,
private auth: AuthService,
private notifier: Notifier,
private i18n: I18n,
private abuseService: AbuseService
) {
super()
}
ngOnInit () {
this.buildForm({
message: this.abuseValidatorsService.ABUSE_MESSAGE
})
}
openModal (abuse: UserAbuse) {
this.abuse = abuse
this.openedModal = this.modalService.open(this.modal, { centered: true })
this.loadMessages()
}
hide () {
this.abuseMessages = []
this.openedModal.close()
}
addMessage () {
this.sendingMessage = true
this.abuseService.addAbuseMessage(this.abuse, this.form.value['message'])
.subscribe(
() => {
this.form.reset()
this.sendingMessage = false
this.countMessagesUpdated.emit({ abuseId: this.abuse.id, countMessages: this.abuseMessages.length + 1 })
this.loadMessages()
},
err => {
this.sendingMessage = false
console.error(err)
this.notifier.error('Sorry but you cannot send this message. Please retry later')
}
)
}
deleteMessage (abuseMessage: AbuseMessage) {
this.abuseService.deleteAbuseMessage(this.abuse, abuseMessage)
.subscribe(
() => {
this.countMessagesUpdated.emit({ abuseId: this.abuse.id, countMessages: this.abuseMessages.length - 1 })
this.abuseMessages = this.abuseMessages.filter(m => m.id !== abuseMessage.id)
},
err => this.notifier.error(err.message)
)
}
isMessageByMe (abuseMessage: AbuseMessage) {
return this.auth.getUser().account.id === abuseMessage.account.id
}
private loadMessages () {
this.abuseService.listAbuseMessages(this.abuse)
.subscribe(
res => {
this.abuseMessages = res.data
setTimeout(() => {
if (!this.messagesBlock) return
const element = this.messagesBlock.nativeElement as HTMLElement
element.scrollIntoView({ block: 'end', inline: 'nearest' })
})
},
err => this.notifier.error(err.message)
)
}
}

View File

@ -5,7 +5,7 @@ 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 { Abuse, AbuseCreate, AbuseFilter, AbusePredefinedReasonsString, AbuseState, AbuseUpdate, ResultList } from '@shared/models'
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'
@ -20,11 +20,11 @@ export class AbuseService {
private restExtractor: RestExtractor
) { }
getAbuses (options: {
getAdminAbuses (options: {
pagination: RestPagination,
sort: SortMeta,
search?: string
}): Observable<ResultList<Abuse>> {
}): Observable<ResultList<AdminAbuse>> {
const { pagination, sort, search } = options
const url = AbuseService.BASE_ABUSE_URL
@ -61,7 +61,7 @@ export class AbuseService {
params = this.restService.addObjectParams(params, filters)
}
return this.authHttp.get<ResultList<Abuse>>(url, { params })
return this.authHttp.get<ResultList<AdminAbuse>>(url, { params })
.pipe(
catchError(res => this.restExtractor.handleError(res))
)
@ -79,7 +79,7 @@ export class AbuseService {
)
}
updateAbuse (abuse: Abuse, abuseUpdate: AbuseUpdate) {
updateAbuse (abuse: AdminAbuse, abuseUpdate: AbuseUpdate) {
const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
return this.authHttp.put(url, abuseUpdate)
@ -89,7 +89,7 @@ export class AbuseService {
)
}
removeAbuse (abuse: Abuse) {
removeAbuse (abuse: AdminAbuse) {
const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
return this.authHttp.delete(url)
@ -99,6 +99,35 @@ export class AbuseService {
)
}
addAbuseMessage (abuse: UserAbuse, message: string) {
const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id + '/messages'
return this.authHttp.post(url, { message })
.pipe(
map(this.restExtractor.extractDataBool),
catchError(res => this.restExtractor.handleError(res))
)
}
listAbuseMessages (abuse: UserAbuse) {
const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id + '/messages'
return this.authHttp.get<ResultList<AbuseMessage>>(url)
.pipe(
catchError(res => this.restExtractor.handleError(res))
)
}
deleteAbuseMessage (abuse: UserAbuse, abuseMessage: AbuseMessage) {
const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id + '/messages/' + abuseMessage.id
return this.authHttp.delete(url)
.pipe(
map(this.restExtractor.extractDataBool),
catchError(res => this.restExtractor.handleError(res))
)
}
getPrefefinedReasons (type: AbuseFilter) {
let reasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [
{

View File

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

View File

@ -4,15 +4,16 @@ 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'
import { BulkService } from './bulk.service'
import { AccountReportComponent, CommentReportComponent, VideoReportComponent } from './report-modals'
import { UserBanModalComponent } from './user-ban-modal.component'
import { UserModerationDropdownComponent } from './user-moderation-dropdown.component'
import { VideoBlockComponent } from './video-block.component'
import { VideoBlockService } from './video-block.service'
import { VideoReportComponent, AccountReportComponent, CommentReportComponent } from './report-modals'
@NgModule({
imports: [
@ -29,7 +30,8 @@ import { VideoReportComponent, AccountReportComponent, CommentReportComponent }
VideoReportComponent,
BatchDomainsModalComponent,
CommentReportComponent,
AccountReportComponent
AccountReportComponent,
AbuseMessageModalComponent
],
exports: [
@ -39,7 +41,8 @@ import { VideoReportComponent, AccountReportComponent, CommentReportComponent }
VideoReportComponent,
BatchDomainsModalComponent,
CommentReportComponent,
AccountReportComponent
AccountReportComponent,
AbuseMessageModalComponent
],
providers: [

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-message-circle"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path></svg>

After

Width:  |  Height:  |  Size: 428 B

View File

@ -32,6 +32,7 @@ body {
--secondaryColor: #{$secondary-color};
--greyForegroundColor: #{$grey-foreground-color};
--greyBackgroundColor: #{$grey-background-color};
--menuBackgroundColor: #{$menu-background};
--menuForegroundColor: #{$menu-color};

View File

@ -92,6 +92,7 @@ $variables: (
--secondaryColor: var(--secondaryColor),
--greyForegroundColor: var(--greyForegroundColor),
--greyBackgroundColor: var(--greyBackgroundColor),
--menuBackgroundColor: var(--menuBackgroundColor),
--menuForegroundColor: var(--menuForegroundColor),

View File

@ -94,6 +94,8 @@ export class AbuseMessageModel extends Model<AbuseMessageModel> {
return {
id: this.id,
createdAt: this.createdAt,
byModerator: this.byModerator,
message: this.message,

View File

@ -4,6 +4,7 @@ export interface AbuseMessage {
id: number
message: string
byModerator: boolean
createdAt: Date | string
account: AccountSummary
}