diff --git a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html index 48b31b99c..9fae5667f 100644 --- a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html +++ b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html @@ -46,6 +46,7 @@ Video/Comment/Account Created State + Messages @@ -157,6 +158,12 @@ + + {{ abuse.countMessages }} + + + + + diff --git a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.scss b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.scss index c22f98c47..48536e3c2 100644 --- a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.scss +++ b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.scss @@ -21,3 +21,12 @@ margin-left: 0; } } + +.abuse-messages { + my-global-icon { + width: 22px; + margin-left: 3px; + position: relative; + top: -2px; + } +} diff --git a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts index 74c5fe2b3..86121fe58 100644 --- a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts +++ b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts @@ -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 }, diff --git a/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.ts b/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.ts index 23738f9cd..ecb7966bf 100644 --- a/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.ts +++ b/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.ts @@ -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() - 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 }) diff --git a/client/src/app/shared/shared-forms/form-validators/abuse-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/abuse-validators.service.ts index 739115e19..5f15963f3 100644 --- a/client/src/app/shared/shared-forms/form-validators/abuse-validators.service.ts +++ b/client/src/app/shared/shared-forms/form-validators/abuse-validators.service.ts @@ -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.') + } + } } } diff --git a/client/src/app/shared/shared-icons/global-icon.component.ts b/client/src/app/shared/shared-icons/global-icon.component.ts index c58ef29fa..409681702 100644 --- a/client/src/app/shared/shared-icons/global-icon.component.ts +++ b/client/src/app/shared/shared-icons/global-icon.component.ts @@ -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 diff --git a/client/src/app/shared/shared-moderation/abuse-message-modal.component.html b/client/src/app/shared/shared-moderation/abuse-message-modal.component.html new file mode 100644 index 000000000..67c6a3081 --- /dev/null +++ b/client/src/app/shared/shared-moderation/abuse-message-modal.component.html @@ -0,0 +1,40 @@ + + + + + + diff --git a/client/src/app/shared/shared-moderation/abuse-message-modal.component.scss b/client/src/app/shared/shared-moderation/abuse-message-modal.component.scss new file mode 100644 index 000000000..89d6b88c1 --- /dev/null +++ b/client/src/app/shared/shared-moderation/abuse-message-modal.component.scss @@ -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); + } + } +} diff --git a/client/src/app/shared/shared-moderation/abuse-message-modal.component.ts b/client/src/app/shared/shared-moderation/abuse-message-modal.component.ts new file mode 100644 index 000000000..5822dfe1d --- /dev/null +++ b/client/src/app/shared/shared-moderation/abuse-message-modal.component.ts @@ -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) + ) + } + +} diff --git a/client/src/app/shared/shared-moderation/abuse.service.ts b/client/src/app/shared/shared-moderation/abuse.service.ts index 95ac16955..652d8370f 100644 --- a/client/src/app/shared/shared-moderation/abuse.service.ts +++ b/client/src/app/shared/shared-moderation/abuse.service.ts @@ -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> { + }): Observable> { 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>(url, { params }) + return this.authHttp.get>(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>(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 }[] = [ { diff --git a/client/src/app/shared/shared-moderation/index.ts b/client/src/app/shared/shared-moderation/index.ts index 41c910ffe..c8082d4b3 100644 --- a/client/src/app/shared/shared-moderation/index.ts +++ b/client/src/app/shared/shared-moderation/index.ts @@ -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' diff --git a/client/src/app/shared/shared-moderation/shared-moderation.module.ts b/client/src/app/shared/shared-moderation/shared-moderation.module.ts index 8fa9ee794..b5b6daf27 100644 --- a/client/src/app/shared/shared-moderation/shared-moderation.module.ts +++ b/client/src/app/shared/shared-moderation/shared-moderation.module.ts @@ -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: [ diff --git a/client/src/assets/images/feather/message-circle.svg b/client/src/assets/images/feather/message-circle.svg new file mode 100644 index 000000000..4b21b32b6 --- /dev/null +++ b/client/src/assets/images/feather/message-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/sass/application.scss b/client/src/sass/application.scss index be510c08f..7ed900574 100644 --- a/client/src/sass/application.scss +++ b/client/src/sass/application.scss @@ -32,6 +32,7 @@ body { --secondaryColor: #{$secondary-color}; --greyForegroundColor: #{$grey-foreground-color}; + --greyBackgroundColor: #{$grey-background-color}; --menuBackgroundColor: #{$menu-background}; --menuForegroundColor: #{$menu-color}; diff --git a/client/src/sass/include/_variables.scss b/client/src/sass/include/_variables.scss index 7b95bb8cc..130462b89 100644 --- a/client/src/sass/include/_variables.scss +++ b/client/src/sass/include/_variables.scss @@ -92,6 +92,7 @@ $variables: ( --secondaryColor: var(--secondaryColor), --greyForegroundColor: var(--greyForegroundColor), + --greyBackgroundColor: var(--greyBackgroundColor), --menuBackgroundColor: var(--menuBackgroundColor), --menuForegroundColor: var(--menuForegroundColor), diff --git a/server/models/abuse/abuse-message.ts b/server/models/abuse/abuse-message.ts index f7721c87d..fce20f7a7 100644 --- a/server/models/abuse/abuse-message.ts +++ b/server/models/abuse/abuse-message.ts @@ -94,6 +94,8 @@ export class AbuseMessageModel extends Model { return { id: this.id, + createdAt: this.createdAt, + byModerator: this.byModerator, message: this.message, diff --git a/shared/models/moderation/abuse/abuse-message.model.ts b/shared/models/moderation/abuse/abuse-message.model.ts index 02072d5ce..642496646 100644 --- a/shared/models/moderation/abuse/abuse-message.model.ts +++ b/shared/models/moderation/abuse/abuse-message.model.ts @@ -4,6 +4,7 @@ export interface AbuseMessage { id: number message: string byModerator: boolean + createdAt: Date | string account: AccountSummary }