From 94148c9028829b5576a5dcbfba2c7fb9cf6443d3 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 27 Jul 2020 11:40:30 +0200 Subject: Add abuse messages management in my account --- .../shared-abuse-list/abuse-details.component.html | 115 +++++ .../shared-abuse-list/abuse-details.component.scss | 34 ++ .../shared-abuse-list/abuse-details.component.ts | 55 +++ .../abuse-list-table.component.html | 194 ++++++++ .../abuse-list-table.component.scss | 107 +++++ .../abuse-list-table.component.ts | 487 +++++++++++++++++++++ .../abuse-message-modal.component.html | 50 +++ .../abuse-message-modal.component.scss | 72 +++ .../abuse-message-modal.component.ts | 127 ++++++ client/src/app/shared/shared-abuse-list/index.ts | 7 + .../moderation-comment-modal.component.html | 38 ++ .../moderation-comment-modal.component.scss | 6 + .../moderation-comment-modal.component.ts | 70 +++ .../shared-abuse-list/processed-abuse.model.ts | 25 ++ .../shared-abuse-list/shared-abuse-list.module.ts | 42 ++ 15 files changed, 1429 insertions(+) create mode 100644 client/src/app/shared/shared-abuse-list/abuse-details.component.html create mode 100644 client/src/app/shared/shared-abuse-list/abuse-details.component.scss create mode 100644 client/src/app/shared/shared-abuse-list/abuse-details.component.ts create mode 100644 client/src/app/shared/shared-abuse-list/abuse-list-table.component.html create mode 100644 client/src/app/shared/shared-abuse-list/abuse-list-table.component.scss create mode 100644 client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts create mode 100644 client/src/app/shared/shared-abuse-list/abuse-message-modal.component.html create mode 100644 client/src/app/shared/shared-abuse-list/abuse-message-modal.component.scss create mode 100644 client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts create mode 100644 client/src/app/shared/shared-abuse-list/index.ts create mode 100644 client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.html create mode 100644 client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.scss create mode 100644 client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts create mode 100644 client/src/app/shared/shared-abuse-list/processed-abuse.model.ts create mode 100644 client/src/app/shared/shared-abuse-list/shared-abuse-list.module.ts (limited to 'client/src/app/shared/shared-abuse-list') diff --git a/client/src/app/shared/shared-abuse-list/abuse-details.component.html b/client/src/app/shared/shared-abuse-list/abuse-details.component.html new file mode 100644 index 000000000..431fdf5aa --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/abuse-details.component.html @@ -0,0 +1,115 @@ +
+ + + + +
+
+
+ The video was deleted + The video was blocked +
+ +
+
+ +
+
+ Comment: +
+ +
+
+
+
diff --git a/client/src/app/shared/shared-abuse-list/abuse-details.component.scss b/client/src/app/shared/shared-abuse-list/abuse-details.component.scss new file mode 100644 index 000000000..d83eb974d --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/abuse-details.component.scss @@ -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; +} diff --git a/client/src/app/shared/shared-abuse-list/abuse-details.component.ts b/client/src/app/shared/shared-abuse-list/abuse-details.component.ts new file mode 100644 index 000000000..cdd4bf2c8 --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/abuse-details.component.ts @@ -0,0 +1,55 @@ +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 './processed-abuse.model' + +@Component({ + selector: 'my-abuse-details', + templateUrl: './abuse-details.component.html', + 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 } + + constructor ( + private i18n: I18n + ) { + this.predefinedReasonsTranslations = { + violentOrRepulsive: this.i18n('Violent or Repulsive'), + hatefulOrAbusive: this.i18n('Hateful or Abusive'), + spamOrMisleading: this.i18n('Spam or Misleading'), + privacy: this.i18n('Privacy'), + rights: this.i18n('Rights'), + serverRules: this.i18n('Server rules'), + thumbnails: this.i18n('Thumbnails'), + captions: this.i18n('Captions') + } + } + + get startAt () { + return durationToString(this.abuse.video.startAt) + } + + get endAt () { + return durationToString(this.abuse.video.endAt) + } + + getPredefinedReasons () { + if (!this.abuse.predefinedReasons) return [] + + return this.abuse.predefinedReasons.map(r => ({ + id: r, + label: this.predefinedReasonsTranslations[r] + })) + } + + switchToDefaultAvatar ($event: Event) { + ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL() + } +} diff --git a/client/src/app/shared/shared-abuse-list/abuse-list-table.component.html b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.html new file mode 100644 index 000000000..a6f707a47 --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.html @@ -0,0 +1,194 @@ + + +
+
+
+ + + + Clear filters +
+
+
+
+ + + + + Reporter + Video/Comment/Account + Created + State + Messages + + + + + + + + + + + + + + +
+ Avatar +
+ {{ abuse.reporterAccount.displayName }} + {{ abuse.reporterAccount.nameWithHost }} +
+
+
+ + + Deleted account + + + + + + + +
+
+ + + {{ abuse.nth }}/{{ abuse.count }} + +
+ +
+
+ + + {{ abuse.video.name }} +
+
by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }}
+
+
+
+ + + +
+
+ Deleted +
+ +
+
+ {{ abuse.video.name }} + +
+
by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }}
+
+
+ +
+ + + + + + + + + + + + + + + + Account deleted + + + + + + {{ abuse.createdAt | date: 'short' }} + + + + + + + + + + {{ abuse.countMessages }} + + + + + + + + + +
+ + + + + + + + + + + + +
+ No abuses found matching current filters. + No abuses found. +
+ + +
+
+ + + diff --git a/client/src/app/shared/shared-abuse-list/abuse-list-table.component.scss b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.scss new file mode 100644 index 000000000..7ed7c9e87 --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.scss @@ -0,0 +1,107 @@ +@import 'variables'; +@import 'mixins'; +@import 'miniature'; + +.table-video-link { + @include disable-outline; + + position: relative; + top: 3px; +} + +.table-comment-link, +.table-account-link { + @include disable-outline; + + color: var(--mainForegroundColor); + + ::ng-deep p:last-child { + margin: 0; + } +} + +.table-account-link { + display: flex; + flex-direction: column; +} + +.comment-flagged-account, +.account-flagged-handle { + font-size: 11px; + color: var(--greyForegroundColor); +} + +.table-video { + display: inline-flex; + + .table-video-image { + @include miniature-thumbnail; + + $image-height: 45px; + + height: $image-height; + width: #{(16/9) * $image-height}; + margin-right: 0.5rem; + border-radius: 2px; + border: none; + background: transparent; + display: inline-flex; + justify-content: center; + align-items: center; + position: relative; + + img { + height: 100%; + width: 100%; + border-radius: 2px; + } + + span { + color: pvar(--inputPlaceholderColor); + } + + .table-video-image-label { + @include static-thumbnail-overlay; + position: absolute; + border-radius: 3px; + font-size: 10px; + padding: 0 3px; + line-height: 1.3; + bottom: 2px; + right: 2px; + } + } + + .table-video-text { + display: inline-flex; + flex-direction: column; + justify-content: center; + font-size: 90%; + color: pvar(--mainForegroundColor); + line-height: 1rem; + + div .glyphicon { + font-size: 80%; + color: gray; + margin-left: 0.1rem; + } + + div + div { + color: var(--greyForegroundColor); + font-size: 11px; + } + } +} + +.abuse-states .glyphicon-comment { + margin-left: 0.5rem; +} + +.abuse-messages { + my-global-icon { + width: 22px; + margin-left: 3px; + position: relative; + top: -2px; + } +} diff --git a/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts new file mode 100644 index 000000000..1d17c9ec9 --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts @@ -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[][] = [] + + 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[] { + 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[] { + 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[] { + 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[] { + 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[] { + 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) + } +} diff --git a/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.html b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.html new file mode 100644 index 000000000..cb965b71d --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.html @@ -0,0 +1,50 @@ + + + + + + diff --git a/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.scss b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.scss new file mode 100644 index 000000000..4dd025fc4 --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.scss @@ -0,0 +1,72 @@ +@import 'variables'; +@import 'mixins'; + +form { + margin: 20px 20px 0 0; + + .form-group:first-child { + // Keep place to display error message without modifying the height + min-height: 125px; + } +} + +textarea { + @include peertube-textarea(100%, 70px); + + margin-top: 20px; +} + +.messages { + display: flex; + flex-direction: column; + overflow-y: scroll; +} + +.no-messages { + display: flex; + font-size: 15px; + justify-content: center; +} + +.message-block { + margin: 0 5px 10px 0; + max-width: 60%; + + .author { + color: var(--greyForegroundColor); + font-size: 14px; + padding: 0 0 3px 10px; + } + + .bubble { + border-radius: 10px; + padding: 5px 10px; + color: var(--mainForegroundColor); + background-color: var(--greyBackgroundColor); + + .content { + font-size: 15px; + } + + .date { + font-size: 13px; + color: var(--greyForegroundColor); + } + } + + &.by-me { + + .bubble { + color: var(--mainBackgroundColor); + background-color: var(--mainColorLighter); + + .date { + color: var(--mainBackgroundColor); + } + } + } + + &.by-moderator { + align-self: flex-end; + } +} diff --git a/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts new file mode 100644 index 000000000..03f5ad735 --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts @@ -0,0 +1,127 @@ +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 '../shared-moderation' + +@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 + + @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 + + constructor ( + protected formValidatorService: FormValidatorService, + private abuseValidatorsService: AbuseValidatorsService, + private modalService: NgbModal, + private i18n: I18n, + private auth: AuthService, + private notifier: Notifier, + 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 + } + + 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 + + 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-abuse-list/index.ts b/client/src/app/shared/shared-abuse-list/index.ts new file mode 100644 index 000000000..3bdd18201 --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/index.ts @@ -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' diff --git a/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.html b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.html new file mode 100644 index 000000000..8082e93f4 --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.html @@ -0,0 +1,38 @@ + + + + + + diff --git a/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.scss b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.scss new file mode 100644 index 000000000..afcdb9a16 --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.scss @@ -0,0 +1,6 @@ +@import 'variables'; +@import 'mixins'; + +textarea { + @include peertube-textarea(100%, 100px); +} diff --git a/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts new file mode 100644 index 000000000..ecb7966bf --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts @@ -0,0 +1,70 @@ +import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' +import { Notifier } from '@app/core' +import { FormReactive, FormValidatorService, AbuseValidatorsService } from '@app/shared/shared-forms' +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 { AdminAbuse } from '@shared/models' + +@Component({ + selector: 'my-moderation-comment-modal', + templateUrl: './moderation-comment-modal.component.html', + styleUrls: [ './moderation-comment-modal.component.scss' ] +}) +export class ModerationCommentModalComponent extends FormReactive implements OnInit { + @ViewChild('modal', { static: true }) modal: NgbModal + @Output() commentUpdated = new EventEmitter() + + private abuseToComment: AdminAbuse + private openedModal: NgbModalRef + + constructor ( + protected formValidatorService: FormValidatorService, + private modalService: NgbModal, + private notifier: Notifier, + private abuseService: AbuseService, + private abuseValidatorsService: AbuseValidatorsService, + private i18n: I18n + ) { + super() + } + + ngOnInit () { + this.buildForm({ + moderationComment: this.abuseValidatorsService.ABUSE_MODERATION_COMMENT + }) + } + + openModal (abuseToComment: AdminAbuse) { + this.abuseToComment = abuseToComment + this.openedModal = this.modalService.open(this.modal, { centered: true }) + + this.form.patchValue({ + moderationComment: this.abuseToComment.moderationComment + }) + } + + hide () { + this.abuseToComment = undefined + this.openedModal.close() + this.form.reset() + } + + async banUser () { + const moderationComment: string = this.form.value[ 'moderationComment' ] + + this.abuseService.updateAbuse(this.abuseToComment, { moderationComment }) + .subscribe( + () => { + this.notifier.success(this.i18n('Comment updated.')) + + this.commentUpdated.emit(moderationComment) + this.hide() + }, + + err => this.notifier.error(err.message) + ) + } + +} diff --git a/client/src/app/shared/shared-abuse-list/processed-abuse.model.ts b/client/src/app/shared/shared-abuse-list/processed-abuse.model.ts new file mode 100644 index 000000000..fce1a8db3 --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/processed-abuse.model.ts @@ -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 + } + } +} diff --git a/client/src/app/shared/shared-abuse-list/shared-abuse-list.module.ts b/client/src/app/shared/shared-abuse-list/shared-abuse-list.module.ts new file mode 100644 index 000000000..663cd902b --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/shared-abuse-list.module.ts @@ -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 { } -- cgit v1.2.3