From cfde28bac33c3644e1b6218eb471b675a37def60 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 9 Jul 2020 15:54:24 +0200 Subject: Add ability to report account --- client/src/app/+accounts/accounts.component.html | 5 + client/src/app/+accounts/accounts.component.ts | 63 ++-- .../abuse-list/abuse-list.component.html | 18 +- .../moderation/abuse-list/abuse-list.component.ts | 358 +++++++++++++-------- .../+admin/moderation/moderation.component.scss | 11 +- .../comment/video-comment-add.component.ts | 3 +- .../comment/video-comment-thread-tree.model.ts | 7 - .../comment/video-comment.component.ts | 7 +- .../+video-watch/comment/video-comment.model.ts | 48 --- .../+video-watch/comment/video-comment.service.ts | 149 --------- .../comment/video-comments.component.ts | 4 +- .../app/+videos/+video-watch/video-watch.module.ts | 8 +- client/src/app/core/rest/rest-table.ts | 14 +- .../app/shared/shared-main/account/actor.model.ts | 6 + .../shared-main/users/user-notification.model.ts | 10 +- .../users/user-notifications.component.html | 15 +- .../comment-report.component.html | 62 ---- .../comment-report.component.scss | 11 - .../shared-moderation/comment-report.component.ts | 93 ------ client/src/app/shared/shared-moderation/index.ts | 3 +- .../report-modals/account-report.component.ts | 94 ++++++ .../report-modals/comment-report.component.ts | 94 ++++++ .../shared-moderation/report-modals/index.ts | 3 + .../report-modals/report.component.html | 62 ++++ .../report-modals/report.component.scss | 27 ++ .../report-modals/video-report.component.html | 100 ++++++ .../report-modals/video-report.component.ts | 122 +++++++ .../shared-moderation/shared-moderation.module.ts | 15 +- .../shared-moderation/video-report.component.html | 100 ------ .../shared-moderation/video-report.component.scss | 27 -- .../shared-moderation/video-report.component.ts | 122 ------- .../src/app/shared/shared-video-comment/index.ts | 5 + .../shared-video-comment.module.ts | 19 ++ .../video-comment-thread-tree.model.ts | 7 + .../shared-video-comment/video-comment.model.ts | 48 +++ .../shared-video-comment/video-comment.service.ts | 149 +++++++++ 36 files changed, 1092 insertions(+), 797 deletions(-) delete mode 100644 client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts delete mode 100644 client/src/app/+videos/+video-watch/comment/video-comment.model.ts delete mode 100644 client/src/app/+videos/+video-watch/comment/video-comment.service.ts delete mode 100644 client/src/app/shared/shared-moderation/comment-report.component.html delete mode 100644 client/src/app/shared/shared-moderation/comment-report.component.scss delete mode 100644 client/src/app/shared/shared-moderation/comment-report.component.ts create mode 100644 client/src/app/shared/shared-moderation/report-modals/account-report.component.ts create mode 100644 client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts create mode 100644 client/src/app/shared/shared-moderation/report-modals/index.ts create mode 100644 client/src/app/shared/shared-moderation/report-modals/report.component.html create mode 100644 client/src/app/shared/shared-moderation/report-modals/report.component.scss create mode 100644 client/src/app/shared/shared-moderation/report-modals/video-report.component.html create mode 100644 client/src/app/shared/shared-moderation/report-modals/video-report.component.ts delete mode 100644 client/src/app/shared/shared-moderation/video-report.component.html delete mode 100644 client/src/app/shared/shared-moderation/video-report.component.scss delete mode 100644 client/src/app/shared/shared-moderation/video-report.component.ts create mode 100644 client/src/app/shared/shared-video-comment/index.ts create mode 100644 client/src/app/shared/shared-video-comment/shared-video-comment.module.ts create mode 100644 client/src/app/shared/shared-video-comment/video-comment-thread-tree.model.ts create mode 100644 client/src/app/shared/shared-video-comment/video-comment.model.ts create mode 100644 client/src/app/shared/shared-video-comment/video-comment.service.ts (limited to 'client/src') diff --git a/client/src/app/+accounts/accounts.component.html b/client/src/app/+accounts/accounts.component.html index af80337ce..31c8e3a8e 100644 --- a/client/src/app/+accounts/accounts.component.html +++ b/client/src/app/+accounts/accounts.component.html @@ -22,6 +22,7 @@ Instance muted by your instance @@ -50,3 +51,7 @@ + + + + diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts index 01911cac2..9288fcb42 100644 --- a/client/src/app/+accounts/accounts.component.ts +++ b/client/src/app/+accounts/accounts.component.ts @@ -1,9 +1,10 @@ import { Subscription } from 'rxjs' import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators' -import { Component, OnDestroy, OnInit } from '@angular/core' +import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core' import { ActivatedRoute } from '@angular/router' import { AuthService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core' -import { Account, AccountService, ListOverflowItem, VideoChannel, VideoChannelService } from '@app/shared/shared-main' +import { Account, AccountService, DropdownAction, ListOverflowItem, VideoChannel, VideoChannelService } from '@app/shared/shared-main' +import { AccountReportComponent } from '@app/shared/shared-moderation' import { I18n } from '@ngx-translate/i18n-polyfill' import { User, UserRight } from '@shared/models' @@ -12,6 +13,8 @@ import { User, UserRight } from '@shared/models' styleUrls: [ './accounts.component.scss' ] }) export class AccountsComponent implements OnInit, OnDestroy { + @ViewChild('accountReportModal') accountReportModal: AccountReportComponent + account: Account accountUser: User videoChannels: VideoChannel[] = [] @@ -20,6 +23,8 @@ export class AccountsComponent implements OnInit, OnDestroy { isAccountManageable = false accountFollowerTitle = '' + prependModerationActions: DropdownAction[] + private routeSub: Subscription constructor ( @@ -42,24 +47,7 @@ export class AccountsComponent implements OnInit, OnDestroy { map(params => params[ 'accountId' ]), distinctUntilChanged(), switchMap(accountId => this.accountService.getAccount(accountId)), - tap(account => { - this.account = account - - if (this.authService.isLoggedIn()) { - this.authService.userInformationLoaded.subscribe( - () => { - this.isAccountManageable = this.account.userId && this.account.userId === this.authService.getUser().id - - this.accountFollowerTitle = this.i18n( - '{{followers}} direct account followers', - { followers: this.subscribersDisplayFor(account.followersCount) } - ) - } - ) - } - - this.getUserIfNeeded(account) - }), + tap(account => this.onAccount(account)), switchMap(account => this.videoChannelService.listAccountVideoChannels(account)), catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ])) ) @@ -107,6 +95,41 @@ export class AccountsComponent implements OnInit, OnDestroy { return this.i18n('{count, plural, =1 {1 subscriber} other {{{count}} subscribers}}', { count }) } + private onAccount (account: Account) { + this.prependModerationActions = undefined + + this.account = account + + if (this.authService.isLoggedIn()) { + this.authService.userInformationLoaded.subscribe( + () => { + this.isAccountManageable = this.account.userId && this.account.userId === this.authService.getUser().id + + this.accountFollowerTitle = this.i18n( + '{{followers}} direct account followers', + { followers: this.subscribersDisplayFor(account.followersCount) } + ) + + // It's not our account, we can report it + if (!this.isAccountManageable) { + this.prependModerationActions = [ + { + label: this.i18n('Report account'), + handler: () => this.showReportModal() + } + ] + } + } + ) + } + + this.getUserIfNeeded(account) + } + + private showReportModal () { + this.accountReportModal.show() + } + private getUserIfNeeded (account: Account) { if (!account.userId || !this.authService.isLoggedIn()) return 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 1ad73e38a..99502304d 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 @@ -1,6 +1,6 @@ + + + + + + + Account deleted + + + + + {{ abuse.createdAt | date: 'short' }} 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 1ea61ed37..74c5fe2b3 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 @@ -1,3 +1,5 @@ +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' @@ -7,11 +9,15 @@ 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 { Abuse, AbuseState } from '@shared/models' import { ModerationCommentModalComponent } from './moderation-comment-modal.component' -import truncate from 'lodash-es/truncate' +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 & { moderationCommentHtml?: string, reasonHtml?: string @@ -45,12 +51,13 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn sort: SortMeta = { field: 'createdAt', order: 1 } pagination: RestPagination = { count: this.rowsPerPage, start: 0 } - abuseActions: DropdownAction[][] = [] + abuseActions: DropdownAction[][] = [] constructor ( private notifier: Notifier, private abuseService: AbuseService, private blocklistService: BlocklistService, + private commentService: VideoCommentService, private videoService: VideoService, private videoBlocklistService: VideoBlockService, private confirmService: ConfirmService, @@ -63,140 +70,15 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn super() this.abuseActions = [ - [ - { - label: this.i18n('Internal actions'), - isHeader: true - }, - { - label: this.i18n('Delete report'), - handler: abuse => this.removeAbuse(abuse) - }, - { - label: this.i18n('Add 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) - } - ], - [ - { - 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.buildInternalActions(), - this.videoService.removeVideo(abuse.video.id) - .subscribe( - () => { - this.notifier.success(this.i18n('Video deleted.')) + this.buildFlaggedAccountActions(), - this.updateAbuseState(abuse, AbuseState.ACCEPTED) - }, + this.buildCommentActions(), - err => this.notifier.error(err.message) - ) - } - } - ], - [ - { - label: this.i18n('Actions for the reporter'), - isHeader: true, - isDisplayed: abuse => !!abuse.reporterAccount - }, - { - label: this.i18n('Mute reporter'), - isDisplayed: abuse => !!abuse.reporterAccount, - handler: async abuse => { - const account = abuse.reporterAccount as 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) - ) - } - }, - { - label: this.i18n('Mute server'), - isDisplayed: abuse => abuse.reporterAccount && !abuse.reporterAccount.userId, - handler: async abuse => { - this.blocklistService.blockServerByInstance(abuse.reporterAccount.host) - .subscribe( - () => { - this.notifier.success( - this.i18n('Server {{host}} muted by the instance.', { host: abuse.reporterAccount.host }) - ) - }, - - err => this.notifier.error(err.message) - ) - } - } - ] + this.buildVideoActions(), + + this.buildAccountActions() ] } @@ -207,6 +89,8 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn .subscribe(params => { this.search = params.search || '' + logger('On URL change (search: %s).', this.search) + this.setTableFilter(this.search) this.loadData() }) @@ -264,6 +148,10 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn return Video.buildClientUrl(abuse.comment.video.uuid) + ';threadId=' + abuse.comment.threadId } + getAccountUrl (abuse: ProcessedAbuse) { + return '/accounts/' + abuse.flaggedAccount.nameWithHost + } + getVideoEmbed (abuse: Abuse) { return buildVideoEmbed( buildVideoLink({ @@ -304,6 +192,8 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn } protected loadData () { + logger('Load data.') + return this.abuseService.getAbuses({ pagination: this.pagination, sort: this.sort, @@ -356,6 +246,208 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn ) } + private buildInternalActions (): DropdownAction[] { + return [ + { + label: this.i18n('Internal actions'), + isHeader: true + }, + { + label: this.i18n('Delete report'), + handler: abuse => this.removeAbuse(abuse) + }, + { + label: this.i18n('Add 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[] { + 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[] { + 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[] { + 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[] { + 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/+admin/moderation/moderation.component.scss b/client/src/app/+admin/moderation/moderation.component.scss index f73c71dc5..65fe94d39 100644 --- a/client/src/app/+admin/moderation/moderation.component.scss +++ b/client/src/app/+admin/moderation/moderation.component.scss @@ -96,7 +96,8 @@ my-action-dropdown.show { top: 3px; } -.table-comment-link { +.table-comment-link, +.table-account-link { @include disable-outline; color: var(--mainForegroundColor); @@ -106,7 +107,13 @@ my-action-dropdown.show { } } -.comment-flagged-account { +.table-account-link { + display: flex; + flex-direction: column; +} + +.comment-flagged-account, +.account-flagged-handle { font-size: 11px; color: var(--greyForegroundColor); } diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts index 79505c779..d79efbb49 100644 --- a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts +++ b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts @@ -4,10 +4,9 @@ import { Router } from '@angular/router' import { Notifier, User } from '@app/core' import { FormReactive, FormValidatorService, VideoCommentValidatorsService } from '@app/shared/shared-forms' import { Video } from '@app/shared/shared-main' +import { VideoComment, VideoCommentService } from '@app/shared/shared-video-comment' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { VideoCommentCreate } from '@shared/models' -import { VideoComment } from './video-comment.model' -import { VideoCommentService } from './video-comment.service' @Component({ selector: 'my-video-comment-add', diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts b/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts deleted file mode 100644 index 7c2aaeadd..000000000 --- a/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '@shared/models' -import { VideoComment } from './video-comment.model' - -export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel { - comment: VideoComment - children: VideoCommentThreadTree[] -} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.ts b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts index 2a4a6e737..6744a0954 100644 --- a/client/src/app/+videos/+video-watch/comment/video-comment.component.ts +++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts @@ -3,11 +3,10 @@ import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } import { MarkdownService, Notifier, UserService } from '@app/core' import { AuthService } from '@app/core/auth' import { Account, Actor, DropdownAction, Video } from '@app/shared/shared-main' -import { CommentReportComponent } from '@app/shared/shared-moderation/comment-report.component' +import { CommentReportComponent } from '@app/shared/shared-moderation/report-modals/comment-report.component' +import { VideoComment, VideoCommentThreadTree } from '@app/shared/shared-video-comment' import { I18n } from '@ngx-translate/i18n-polyfill' import { User, UserRight } from '@shared/models' -import { VideoCommentThreadTree } from './video-comment-thread-tree.model' -import { VideoComment } from './video-comment.model' @Component({ selector: 'my-video-comment', @@ -136,7 +135,7 @@ export class VideoCommentComponent implements OnInit, OnChanges { this.comment.account = null } - if (this.isUserLoggedIn()) { + if (this.isUserLoggedIn() && this.authService.getUser().account.id !== this.comment.account.id) { this.prependModerationActions = [ { label: this.i18n('Report comment'), diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.model.ts b/client/src/app/+videos/+video-watch/comment/video-comment.model.ts deleted file mode 100644 index e85443196..000000000 --- a/client/src/app/+videos/+video-watch/comment/video-comment.model.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { getAbsoluteAPIUrl } from '@app/helpers' -import { Actor } from '@app/shared/shared-main' -import { Account as AccountInterface, VideoComment as VideoCommentServerModel } from '@shared/models' - -export class VideoComment implements VideoCommentServerModel { - id: number - url: string - text: string - threadId: number - inReplyToCommentId: number - videoId: number - createdAt: Date | string - updatedAt: Date | string - deletedAt: Date | string - isDeleted: boolean - account: AccountInterface - totalRepliesFromVideoAuthor: number - totalReplies: number - by: string - accountAvatarUrl: string - - isLocal: boolean - - constructor (hash: VideoCommentServerModel) { - this.id = hash.id - this.url = hash.url - this.text = hash.text - this.threadId = hash.threadId - this.inReplyToCommentId = hash.inReplyToCommentId - this.videoId = hash.videoId - this.createdAt = new Date(hash.createdAt.toString()) - this.updatedAt = new Date(hash.updatedAt.toString()) - this.deletedAt = hash.deletedAt ? new Date(hash.deletedAt.toString()) : null - this.isDeleted = hash.isDeleted - this.account = hash.account - this.totalRepliesFromVideoAuthor = hash.totalRepliesFromVideoAuthor - this.totalReplies = hash.totalReplies - - if (this.account) { - this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host) - this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account) - - const absoluteAPIUrl = getAbsoluteAPIUrl() - const thisHost = new URL(absoluteAPIUrl).host - this.isLocal = this.account.host.trim() === thisHost - } - } -} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.service.ts b/client/src/app/+videos/+video-watch/comment/video-comment.service.ts deleted file mode 100644 index a73fb9ca8..000000000 --- a/client/src/app/+videos/+video-watch/comment/video-comment.service.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { Observable } from 'rxjs' -import { catchError, map } from 'rxjs/operators' -import { HttpClient, HttpParams } from '@angular/common/http' -import { Injectable } from '@angular/core' -import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' -import { objectLineFeedToHtml } from '@app/helpers' -import { - FeedFormat, - ResultList, - VideoComment as VideoCommentServerModel, - VideoCommentCreate, - VideoCommentThreadTree as VideoCommentThreadTreeServerModel -} from '@shared/models' -import { environment } from '../../../../environments/environment' -import { VideoCommentThreadTree } from './video-comment-thread-tree.model' -import { VideoComment } from './video-comment.model' - -@Injectable() -export class VideoCommentService { - private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' - private static BASE_FEEDS_URL = environment.apiUrl + '/feeds/video-comments.' - - constructor ( - private authHttp: HttpClient, - private restExtractor: RestExtractor, - private restService: RestService - ) {} - - addCommentThread (videoId: number | string, comment: VideoCommentCreate) { - const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' - const normalizedComment = objectLineFeedToHtml(comment, 'text') - - return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) - .pipe( - map(data => this.extractVideoComment(data.comment)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - addCommentReply (videoId: number | string, inReplyToCommentId: number, comment: VideoCommentCreate) { - const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId - const normalizedComment = objectLineFeedToHtml(comment, 'text') - - return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) - .pipe( - map(data => this.extractVideoComment(data.comment)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - getVideoCommentThreads (parameters: { - videoId: number | string, - componentPagination: ComponentPaginationLight, - sort: string - }): Observable> { - const { videoId, componentPagination, sort } = parameters - - const pagination = this.restService.componentPaginationToRestPagination(componentPagination) - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' - return this.authHttp.get>(url, { params }) - .pipe( - map(result => this.extractVideoComments(result)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - getVideoThreadComments (parameters: { - videoId: number | string, - threadId: number - }): Observable { - const { videoId, threadId } = parameters - const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}` - - return this.authHttp - .get(url) - .pipe( - map(tree => this.extractVideoCommentTree(tree)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - deleteVideoComment (videoId: number | string, commentId: number) { - const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comments/${commentId}` - - return this.authHttp - .delete(url) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - getVideoCommentsFeeds (videoUUID?: string) { - const feeds = [ - { - format: FeedFormat.RSS, - label: 'rss 2.0', - url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.RSS.toLowerCase() - }, - { - format: FeedFormat.ATOM, - label: 'atom 1.0', - url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.ATOM.toLowerCase() - }, - { - format: FeedFormat.JSON, - label: 'json 1.0', - url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.JSON.toLowerCase() - } - ] - - if (videoUUID !== undefined) { - for (const feed of feeds) { - feed.url += '?videoId=' + videoUUID - } - } - - return feeds - } - - private extractVideoComment (videoComment: VideoCommentServerModel) { - return new VideoComment(videoComment) - } - - private extractVideoComments (result: ResultList) { - const videoCommentsJson = result.data - const totalComments = result.total - const comments: VideoComment[] = [] - - for (const videoCommentJson of videoCommentsJson) { - comments.push(new VideoComment(videoCommentJson)) - } - - return { data: comments, total: totalComments } - } - - private extractVideoCommentTree (tree: VideoCommentThreadTreeServerModel) { - if (!tree) return tree as VideoCommentThreadTree - - tree.comment = new VideoComment(tree.comment) - tree.children.forEach(c => this.extractVideoCommentTree(c)) - - return tree as VideoCommentThreadTree - } -} diff --git a/client/src/app/+videos/+video-watch/comment/video-comments.component.ts b/client/src/app/+videos/+video-watch/comment/video-comments.component.ts index df0018ec6..66494a20a 100644 --- a/client/src/app/+videos/+video-watch/comment/video-comments.component.ts +++ b/client/src/app/+videos/+video-watch/comment/video-comments.component.ts @@ -4,10 +4,8 @@ import { ActivatedRoute } from '@angular/router' import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core' import { HooksService } from '@app/core/plugins/hooks.service' import { Syndication, VideoDetails } from '@app/shared/shared-main' +import { VideoComment, VideoCommentService, VideoCommentThreadTree } from '@app/shared/shared-video-comment' import { I18n } from '@ngx-translate/i18n-polyfill' -import { VideoCommentThreadTree } from './video-comment-thread-tree.model' -import { VideoComment } from './video-comment.model' -import { VideoCommentService } from './video-comment.service' @Component({ selector: 'my-video-comments', diff --git a/client/src/app/+videos/+video-watch/video-watch.module.ts b/client/src/app/+videos/+video-watch/video-watch.module.ts index 421170d81..5821dc2b7 100644 --- a/client/src/app/+videos/+video-watch/video-watch.module.ts +++ b/client/src/app/+videos/+video-watch/video-watch.module.ts @@ -5,16 +5,17 @@ import { SharedGlobalIconModule } from '@app/shared/shared-icons' import { SharedMainModule } from '@app/shared/shared-main' import { SharedModerationModule } from '@app/shared/shared-moderation' import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' +import { SharedVideoCommentModule } from '@app/shared/shared-video-comment' import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist' -import { RecommendationsModule } from './recommendations/recommendations.module' import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' +import { VideoCommentService } from '../../shared/shared-video-comment/video-comment.service' import { VideoCommentAddComponent } from './comment/video-comment-add.component' import { VideoCommentComponent } from './comment/video-comment.component' -import { VideoCommentService } from './comment/video-comment.service' import { VideoCommentsComponent } from './comment/video-comments.component' import { VideoShareComponent } from './modal/video-share.component' import { VideoSupportComponent } from './modal/video-support.component' +import { RecommendationsModule } from './recommendations/recommendations.module' import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive' import { VideoDurationPipe } from './video-duration-formatter.pipe' import { VideoWatchPlaylistComponent } from './video-watch-playlist.component' @@ -34,7 +35,8 @@ import { VideoWatchComponent } from './video-watch.component' SharedVideoPlaylistModule, SharedUserSubscriptionModule, SharedModerationModule, - SharedGlobalIconModule + SharedGlobalIconModule, + SharedVideoCommentModule ], declarations: [ diff --git a/client/src/app/core/rest/rest-table.ts b/client/src/app/core/rest/rest-table.ts index 1b35ad47d..e6328eddc 100644 --- a/client/src/app/core/rest/rest-table.ts +++ b/client/src/app/core/rest/rest-table.ts @@ -3,6 +3,9 @@ import { LazyLoadEvent, SortMeta } from 'primeng/api' import { RestPagination } from './rest-pagination' import { Subject } from 'rxjs' import { debounceTime, distinctUntilChanged } from 'rxjs/operators' +import * as debug from 'debug' + +const logger = debug('peertube:tables:RestTable') export abstract class RestTable { @@ -15,7 +18,7 @@ export abstract class RestTable { rowsPerPage = this.rowsPerPageOptions[0] expandedRows = {} - private searchStream: Subject + protected searchStream: Subject abstract getIdentifier (): string @@ -37,6 +40,8 @@ export abstract class RestTable { } loadLazy (event: LazyLoadEvent) { + logger('Load lazy %o.', event) + this.sort = { order: event.sortOrder, field: event.sortField @@ -65,6 +70,9 @@ export abstract class RestTable { ) .subscribe(search => { this.search = search + + logger('On search %s.', this.search) + this.loadData() }) } @@ -75,14 +83,18 @@ export abstract class RestTable { } onPage (event: { first: number, rows: number }) { + logger('On page %o.', event) + if (this.rowsPerPage !== event.rows) { this.rowsPerPage = event.rows this.pagination = { start: event.first, count: this.rowsPerPage } + this.loadData() } + this.expandedRows = {} } diff --git a/client/src/app/shared/shared-main/account/actor.model.ts b/client/src/app/shared/shared-main/account/actor.model.ts index 9ec6dbab1..bda88bdee 100644 --- a/client/src/app/shared/shared-main/account/actor.model.ts +++ b/client/src/app/shared/shared-main/account/actor.model.ts @@ -14,6 +14,8 @@ export abstract class Actor implements ActorServer { avatarUrl: string + isLocal: boolean + static GET_ACTOR_AVATAR_URL (actor: { avatar?: { url?: string, path: string } }) { if (actor?.avatar?.url) return actor.avatar.url @@ -52,6 +54,10 @@ export abstract class Actor implements ActorServer { this.avatar = hash.avatar + const absoluteAPIUrl = getAbsoluteAPIUrl() + const thisHost = new URL(absoluteAPIUrl).host + this.isLocal = this.host.trim() === thisHost + this.updateComputedAttributes() } diff --git a/client/src/app/shared/shared-main/users/user-notification.model.ts b/client/src/app/shared/shared-main/users/user-notification.model.ts index a137f8c62..61b48a806 100644 --- a/client/src/app/shared/shared-main/users/user-notification.model.ts +++ b/client/src/app/shared/shared-main/users/user-notification.model.ts @@ -34,7 +34,9 @@ export class UserNotification implements UserNotificationServer { threadId: number video: { + id: number uuid: string + name: string } } @@ -115,13 +117,15 @@ export class UserNotification implements UserNotificationServer { case UserNotificationType.COMMENT_MENTION: if (!this.comment) break this.accountUrl = this.buildAccountUrl(this.comment.account) - this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ] + this.commentUrl = this.buildCommentUrl(this.comment) break case UserNotificationType.NEW_ABUSE_FOR_MODERATORS: this.abuseUrl = '/admin/moderation/abuses/list' if (this.abuse.video) this.videoUrl = this.buildVideoUrl(this.abuse.video) + else if (this.abuse.comment) this.commentUrl = this.buildCommentUrl(this.abuse.comment) + else if (this.abuse.account) this.accountUrl = this.buildAccountUrl(this.abuse.account) break case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS: @@ -190,6 +194,10 @@ export class UserNotification implements UserNotificationServer { return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName } + private buildCommentUrl (comment: { video: { uuid: string }, threadId: number }) { + return [ this.buildVideoUrl(comment.video), { threadId: comment.threadId } ] + } + private setAvatarUrl (actor: { avatarUrl?: string, avatar?: { url?: string, path: string } }) { actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor) } diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.html b/client/src/app/shared/shared-main/users/user-notifications.component.html index 2b341af2c..8127ae979 100644 --- a/client/src/app/shared/shared-main/users/user-notifications.component.html +++ b/client/src/app/shared/shared-main/users/user-notifications.component.html @@ -45,9 +45,22 @@ -
+ + + + + + + +
+ A new abuse has been created +
diff --git a/client/src/app/shared/shared-moderation/comment-report.component.html b/client/src/app/shared/shared-moderation/comment-report.component.html deleted file mode 100644 index 1105b3788..000000000 --- a/client/src/app/shared/shared-moderation/comment-report.component.html +++ /dev/null @@ -1,62 +0,0 @@ - - - - - diff --git a/client/src/app/shared/shared-moderation/comment-report.component.scss b/client/src/app/shared/shared-moderation/comment-report.component.scss deleted file mode 100644 index 17a33d3a2..000000000 --- a/client/src/app/shared/shared-moderation/comment-report.component.scss +++ /dev/null @@ -1,11 +0,0 @@ -@import 'variables'; -@import 'mixins'; - -.information { - margin-bottom: 20px; -} - -textarea { - @include peertube-textarea(100%, 100px); -} - diff --git a/client/src/app/shared/shared-moderation/comment-report.component.ts b/client/src/app/shared/shared-moderation/comment-report.component.ts deleted file mode 100644 index 5db4b2dc1..000000000 --- a/client/src/app/shared/shared-moderation/comment-report.component.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { mapValues, pickBy } from 'lodash-es' -import { Component, Input, OnInit, ViewChild } from '@angular/core' -import { SafeHtml } from '@angular/platform-browser' -import { VideoComment } from '@app/+videos/+video-watch/comment/video-comment.model' -import { 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 { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models' -import { AbuseService } from './abuse.service' - -@Component({ - selector: 'my-comment-report', - templateUrl: './comment-report.component.html', - styleUrls: [ './comment-report.component.scss' ] -}) -export class CommentReportComponent extends FormReactive implements OnInit { - @Input() comment: VideoComment = null - - @ViewChild('modal', { static: true }) modal: NgbModal - - error: string = null - predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [] - embedHtml: SafeHtml - - private openedModal: NgbModalRef - - constructor ( - protected formValidatorService: FormValidatorService, - private modalService: NgbModal, - private abuseValidatorsService: AbuseValidatorsService, - private abuseService: AbuseService, - private notifier: Notifier, - private i18n: I18n - ) { - super() - } - - get currentHost () { - return window.location.host - } - - get originHost () { - if (this.isRemoteComment()) { - return this.comment.account.host - } - - return '' - } - - ngOnInit () { - this.buildForm({ - reason: this.abuseValidatorsService.ABUSE_REASON, - predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null) - }) - - this.predefinedReasons = this.abuseService.getPrefefinedReasons('comment') - } - - show () { - this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' }) - } - - hide () { - this.openedModal.close() - this.openedModal = null - } - - report () { - const reason = this.form.get('reason').value - const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as AbusePredefinedReasonsString[] - - this.abuseService.reportVideo({ - reason, - predefinedReasons, - comment: { - id: this.comment.id - } - }).subscribe( - () => { - this.notifier.success(this.i18n('Comment reported.')) - this.hide() - }, - - err => this.notifier.error(err.message) - ) - } - - isRemoteComment () { - return !this.comment.isLocal - } -} diff --git a/client/src/app/shared/shared-moderation/index.ts b/client/src/app/shared/shared-moderation/index.ts index d6c4a10be..41c910ffe 100644 --- a/client/src/app/shared/shared-moderation/index.ts +++ b/client/src/app/shared/shared-moderation/index.ts @@ -1,3 +1,5 @@ +export * from './report-modals' + export * from './abuse.service' export * from './account-block.model' export * from './account-blocklist.component' @@ -9,5 +11,4 @@ export * from './user-ban-modal.component' export * from './user-moderation-dropdown.component' export * from './video-block.component' export * from './video-block.service' -export * from './video-report.component' export * from './shared-moderation.module' diff --git a/client/src/app/shared/shared-moderation/report-modals/account-report.component.ts b/client/src/app/shared/shared-moderation/report-modals/account-report.component.ts new file mode 100644 index 000000000..78ca934c7 --- /dev/null +++ b/client/src/app/shared/shared-moderation/report-modals/account-report.component.ts @@ -0,0 +1,94 @@ +import { mapValues, pickBy } from 'lodash-es' +import { Component, Input, OnInit, ViewChild } from '@angular/core' +import { Notifier } from '@app/core' +import { AbuseValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms' +import { Account } from '@app/shared/shared-main' +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 { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models' +import { AbuseService } from '../abuse.service' + +@Component({ + selector: 'my-account-report', + templateUrl: './report.component.html', + styleUrls: [ './report.component.scss' ] +}) +export class AccountReportComponent extends FormReactive implements OnInit { + @Input() account: Account = null + + @ViewChild('modal', { static: true }) modal: NgbModal + + error: string = null + predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [] + modalTitle: string + + private openedModal: NgbModalRef + + constructor ( + protected formValidatorService: FormValidatorService, + private modalService: NgbModal, + private abuseValidatorsService: AbuseValidatorsService, + private abuseService: AbuseService, + private notifier: Notifier, + private i18n: I18n + ) { + super() + } + + get currentHost () { + return window.location.host + } + + get originHost () { + if (this.isRemote()) { + return this.account.host + } + + return '' + } + + ngOnInit () { + this.modalTitle = this.i18n('Report {{displayName}}', { displayName: this.account.displayName }) + + this.buildForm({ + reason: this.abuseValidatorsService.ABUSE_REASON, + predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null) + }) + + this.predefinedReasons = this.abuseService.getPrefefinedReasons('account') + } + + show () { + this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' }) + } + + hide () { + this.openedModal.close() + this.openedModal = null + } + + report () { + const reason = this.form.get('reason').value + const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as AbusePredefinedReasonsString[] + + this.abuseService.reportVideo({ + reason, + predefinedReasons, + account: { + id: this.account.id + } + }).subscribe( + () => { + this.notifier.success(this.i18n('Account reported.')) + this.hide() + }, + + err => this.notifier.error(err.message) + ) + } + + isRemote () { + return !this.account.isLocal + } +} diff --git a/client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts b/client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts new file mode 100644 index 000000000..00d7b8d34 --- /dev/null +++ b/client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts @@ -0,0 +1,94 @@ +import { mapValues, pickBy } from 'lodash-es' +import { Component, Input, OnInit, ViewChild } from '@angular/core' +import { Notifier } from '@app/core' +import { AbuseValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms' +import { VideoComment } from '@app/shared/shared-video-comment' +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 { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models' +import { AbuseService } from '../abuse.service' + +@Component({ + selector: 'my-comment-report', + templateUrl: './report.component.html', + styleUrls: [ './report.component.scss' ] +}) +export class CommentReportComponent extends FormReactive implements OnInit { + @Input() comment: VideoComment = null + + @ViewChild('modal', { static: true }) modal: NgbModal + + modalTitle: string + error: string = null + predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [] + + private openedModal: NgbModalRef + + constructor ( + protected formValidatorService: FormValidatorService, + private modalService: NgbModal, + private abuseValidatorsService: AbuseValidatorsService, + private abuseService: AbuseService, + private notifier: Notifier, + private i18n: I18n + ) { + super() + } + + get currentHost () { + return window.location.host + } + + get originHost () { + if (this.isRemote()) { + return this.comment.account.host + } + + return '' + } + + ngOnInit () { + this.modalTitle = this.i18n('Report comment') + + this.buildForm({ + reason: this.abuseValidatorsService.ABUSE_REASON, + predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null) + }) + + this.predefinedReasons = this.abuseService.getPrefefinedReasons('comment') + } + + show () { + this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' }) + } + + hide () { + this.openedModal.close() + this.openedModal = null + } + + report () { + const reason = this.form.get('reason').value + const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as AbusePredefinedReasonsString[] + + this.abuseService.reportVideo({ + reason, + predefinedReasons, + comment: { + id: this.comment.id + } + }).subscribe( + () => { + this.notifier.success(this.i18n('Comment reported.')) + this.hide() + }, + + err => this.notifier.error(err.message) + ) + } + + isRemote () { + return !this.comment.isLocal + } +} diff --git a/client/src/app/shared/shared-moderation/report-modals/index.ts b/client/src/app/shared/shared-moderation/report-modals/index.ts new file mode 100644 index 000000000..f3c4058ae --- /dev/null +++ b/client/src/app/shared/shared-moderation/report-modals/index.ts @@ -0,0 +1,3 @@ +export * from './account-report.component' +export * from './comment-report.component' +export * from './video-report.component' diff --git a/client/src/app/shared/shared-moderation/report-modals/report.component.html b/client/src/app/shared/shared-moderation/report-modals/report.component.html new file mode 100644 index 000000000..bda62312f --- /dev/null +++ b/client/src/app/shared/shared-moderation/report-modals/report.component.html @@ -0,0 +1,62 @@ + + + + + diff --git a/client/src/app/shared/shared-moderation/report-modals/report.component.scss b/client/src/app/shared/shared-moderation/report-modals/report.component.scss new file mode 100644 index 000000000..b2606cbd8 --- /dev/null +++ b/client/src/app/shared/shared-moderation/report-modals/report.component.scss @@ -0,0 +1,27 @@ +@import 'variables'; +@import 'mixins'; + +.information { + margin-bottom: 20px; +} + +textarea { + @include peertube-textarea(100%, 100px); +} + +.start-at, +.stop-at { + width: 300px; + display: flex; + align-items: center; + + my-timestamp-input { + margin-left: 10px; + } +} + +.screenratio { + @include large-screen-ratio($selector: 'div, ::ng-deep iframe') { + left: 0; + }; +} diff --git a/client/src/app/shared/shared-moderation/report-modals/video-report.component.html b/client/src/app/shared/shared-moderation/report-modals/video-report.component.html new file mode 100644 index 000000000..4947088d1 --- /dev/null +++ b/client/src/app/shared/shared-moderation/report-modals/video-report.component.html @@ -0,0 +1,100 @@ + + + + + diff --git a/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts b/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts new file mode 100644 index 000000000..7d53ea3c9 --- /dev/null +++ b/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts @@ -0,0 +1,122 @@ +import { mapValues, pickBy } from 'lodash-es' +import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils' +import { Component, Input, OnInit, ViewChild } from '@angular/core' +import { DomSanitizer, SafeHtml } from '@angular/platform-browser' +import { 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 { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models' +import { Video } from '../../shared-main' +import { AbuseService } from '../abuse.service' + +@Component({ + selector: 'my-video-report', + templateUrl: './video-report.component.html', + styleUrls: [ './report.component.scss' ] +}) +export class VideoReportComponent extends FormReactive implements OnInit { + @Input() video: Video = null + + @ViewChild('modal', { static: true }) modal: NgbModal + + error: string = null + predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [] + embedHtml: SafeHtml + + private openedModal: NgbModalRef + + constructor ( + protected formValidatorService: FormValidatorService, + private modalService: NgbModal, + private abuseValidatorsService: AbuseValidatorsService, + private abuseService: AbuseService, + private notifier: Notifier, + private sanitizer: DomSanitizer, + private i18n: I18n + ) { + super() + } + + get currentHost () { + return window.location.host + } + + get originHost () { + if (this.isRemote()) { + return this.video.account.host + } + + return '' + } + + get timestamp () { + return this.form.get('timestamp').value + } + + getVideoEmbed () { + return this.sanitizer.bypassSecurityTrustHtml( + buildVideoEmbed( + buildVideoLink({ + baseUrl: this.video.embedUrl, + title: false, + warningTitle: false + }) + ) + ) + } + + ngOnInit () { + this.buildForm({ + reason: this.abuseValidatorsService.ABUSE_REASON, + predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null), + timestamp: { + hasStart: null, + startAt: null, + hasEnd: null, + endAt: null + } + }) + + this.predefinedReasons = this.abuseService.getPrefefinedReasons('video') + + this.embedHtml = this.getVideoEmbed() + } + + show () { + this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' }) + } + + hide () { + this.openedModal.close() + this.openedModal = null + } + + report () { + const reason = this.form.get('reason').value + const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as AbusePredefinedReasonsString[] + const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value + + this.abuseService.reportVideo({ + reason, + predefinedReasons, + video: { + id: this.video.id, + startAt: hasStart && startAt ? startAt : undefined, + endAt: hasEnd && endAt ? endAt : undefined + } + }).subscribe( + () => { + this.notifier.success(this.i18n('Video reported.')) + this.hide() + }, + + err => this.notifier.error(err.message) + ) + } + + isRemote () { + return !this.video.isLocal + } +} 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 ff4021a33..8fa9ee794 100644 --- a/client/src/app/shared/shared-moderation/shared-moderation.module.ts +++ b/client/src/app/shared/shared-moderation/shared-moderation.module.ts @@ -3,22 +3,23 @@ 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 { SharedVideoCommentModule } from '../shared-video-comment' +import { AbuseService } from './abuse.service' import { BatchDomainsModalComponent } from './batch-domains-modal.component' import { BlocklistService } from './blocklist.service' import { BulkService } from './bulk.service' import { UserBanModalComponent } from './user-ban-modal.component' import { UserModerationDropdownComponent } from './user-moderation-dropdown.component' -import { AbuseService } from './abuse.service' import { VideoBlockComponent } from './video-block.component' import { VideoBlockService } from './video-block.service' -import { VideoReportComponent } from './video-report.component' -import { CommentReportComponent } from './comment-report.component' +import { VideoReportComponent, AccountReportComponent, CommentReportComponent } from './report-modals' @NgModule({ imports: [ SharedMainModule, SharedFormModule, - SharedGlobalIconModule + SharedGlobalIconModule, + SharedVideoCommentModule ], declarations: [ @@ -27,7 +28,8 @@ import { CommentReportComponent } from './comment-report.component' VideoBlockComponent, VideoReportComponent, BatchDomainsModalComponent, - CommentReportComponent + CommentReportComponent, + AccountReportComponent ], exports: [ @@ -36,7 +38,8 @@ import { CommentReportComponent } from './comment-report.component' VideoBlockComponent, VideoReportComponent, BatchDomainsModalComponent, - CommentReportComponent + CommentReportComponent, + AccountReportComponent ], providers: [ diff --git a/client/src/app/shared/shared-moderation/video-report.component.html b/client/src/app/shared/shared-moderation/video-report.component.html deleted file mode 100644 index b724ecb18..000000000 --- a/client/src/app/shared/shared-moderation/video-report.component.html +++ /dev/null @@ -1,100 +0,0 @@ - - - - - diff --git a/client/src/app/shared/shared-moderation/video-report.component.scss b/client/src/app/shared/shared-moderation/video-report.component.scss deleted file mode 100644 index b2606cbd8..000000000 --- a/client/src/app/shared/shared-moderation/video-report.component.scss +++ /dev/null @@ -1,27 +0,0 @@ -@import 'variables'; -@import 'mixins'; - -.information { - margin-bottom: 20px; -} - -textarea { - @include peertube-textarea(100%, 100px); -} - -.start-at, -.stop-at { - width: 300px; - display: flex; - align-items: center; - - my-timestamp-input { - margin-left: 10px; - } -} - -.screenratio { - @include large-screen-ratio($selector: 'div, ::ng-deep iframe') { - left: 0; - }; -} diff --git a/client/src/app/shared/shared-moderation/video-report.component.ts b/client/src/app/shared/shared-moderation/video-report.component.ts deleted file mode 100644 index 26e7b62ba..000000000 --- a/client/src/app/shared/shared-moderation/video-report.component.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { mapValues, pickBy } from 'lodash-es' -import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils' -import { Component, Input, OnInit, ViewChild } from '@angular/core' -import { DomSanitizer, SafeHtml } from '@angular/platform-browser' -import { 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 { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models' -import { Video } from '../shared-main' -import { AbuseService } from './abuse.service' - -@Component({ - selector: 'my-video-report', - templateUrl: './video-report.component.html', - styleUrls: [ './video-report.component.scss' ] -}) -export class VideoReportComponent extends FormReactive implements OnInit { - @Input() video: Video = null - - @ViewChild('modal', { static: true }) modal: NgbModal - - error: string = null - predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [] - embedHtml: SafeHtml - - private openedModal: NgbModalRef - - constructor ( - protected formValidatorService: FormValidatorService, - private modalService: NgbModal, - private abuseValidatorsService: AbuseValidatorsService, - private abuseService: AbuseService, - private notifier: Notifier, - private sanitizer: DomSanitizer, - private i18n: I18n - ) { - super() - } - - get currentHost () { - return window.location.host - } - - get originHost () { - if (this.isRemoteVideo()) { - return this.video.account.host - } - - return '' - } - - get timestamp () { - return this.form.get('timestamp').value - } - - getVideoEmbed () { - return this.sanitizer.bypassSecurityTrustHtml( - buildVideoEmbed( - buildVideoLink({ - baseUrl: this.video.embedUrl, - title: false, - warningTitle: false - }) - ) - ) - } - - ngOnInit () { - this.buildForm({ - reason: this.abuseValidatorsService.ABUSE_REASON, - predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null), - timestamp: { - hasStart: null, - startAt: null, - hasEnd: null, - endAt: null - } - }) - - this.predefinedReasons = this.abuseService.getPrefefinedReasons('video') - - this.embedHtml = this.getVideoEmbed() - } - - show () { - this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' }) - } - - hide () { - this.openedModal.close() - this.openedModal = null - } - - report () { - const reason = this.form.get('reason').value - const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as AbusePredefinedReasonsString[] - const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value - - this.abuseService.reportVideo({ - reason, - predefinedReasons, - video: { - id: this.video.id, - startAt: hasStart && startAt ? startAt : undefined, - endAt: hasEnd && endAt ? endAt : undefined - } - }).subscribe( - () => { - this.notifier.success(this.i18n('Video reported.')) - this.hide() - }, - - err => this.notifier.error(err.message) - ) - } - - isRemoteVideo () { - return !this.video.isLocal - } -} diff --git a/client/src/app/shared/shared-video-comment/index.ts b/client/src/app/shared/shared-video-comment/index.ts new file mode 100644 index 000000000..b1195f232 --- /dev/null +++ b/client/src/app/shared/shared-video-comment/index.ts @@ -0,0 +1,5 @@ +export * from './video-comment.service' +export * from './video-comment.model' +export * from './video-comment-thread-tree.model' + +export * from './shared-video-comment.module' diff --git a/client/src/app/shared/shared-video-comment/shared-video-comment.module.ts b/client/src/app/shared/shared-video-comment/shared-video-comment.module.ts new file mode 100644 index 000000000..41b329861 --- /dev/null +++ b/client/src/app/shared/shared-video-comment/shared-video-comment.module.ts @@ -0,0 +1,19 @@ + +import { NgModule } from '@angular/core' +import { SharedMainModule } from '../shared-main/shared-main.module' +import { VideoCommentService } from './video-comment.service' + +@NgModule({ + imports: [ + SharedMainModule + ], + + declarations: [ ], + + exports: [ ], + + providers: [ + VideoCommentService + ] +}) +export class SharedVideoCommentModule { } diff --git a/client/src/app/shared/shared-video-comment/video-comment-thread-tree.model.ts b/client/src/app/shared/shared-video-comment/video-comment-thread-tree.model.ts new file mode 100644 index 000000000..7c2aaeadd --- /dev/null +++ b/client/src/app/shared/shared-video-comment/video-comment-thread-tree.model.ts @@ -0,0 +1,7 @@ +import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '@shared/models' +import { VideoComment } from './video-comment.model' + +export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel { + comment: VideoComment + children: VideoCommentThreadTree[] +} diff --git a/client/src/app/shared/shared-video-comment/video-comment.model.ts b/client/src/app/shared/shared-video-comment/video-comment.model.ts new file mode 100644 index 000000000..e85443196 --- /dev/null +++ b/client/src/app/shared/shared-video-comment/video-comment.model.ts @@ -0,0 +1,48 @@ +import { getAbsoluteAPIUrl } from '@app/helpers' +import { Actor } from '@app/shared/shared-main' +import { Account as AccountInterface, VideoComment as VideoCommentServerModel } from '@shared/models' + +export class VideoComment implements VideoCommentServerModel { + id: number + url: string + text: string + threadId: number + inReplyToCommentId: number + videoId: number + createdAt: Date | string + updatedAt: Date | string + deletedAt: Date | string + isDeleted: boolean + account: AccountInterface + totalRepliesFromVideoAuthor: number + totalReplies: number + by: string + accountAvatarUrl: string + + isLocal: boolean + + constructor (hash: VideoCommentServerModel) { + this.id = hash.id + this.url = hash.url + this.text = hash.text + this.threadId = hash.threadId + this.inReplyToCommentId = hash.inReplyToCommentId + this.videoId = hash.videoId + this.createdAt = new Date(hash.createdAt.toString()) + this.updatedAt = new Date(hash.updatedAt.toString()) + this.deletedAt = hash.deletedAt ? new Date(hash.deletedAt.toString()) : null + this.isDeleted = hash.isDeleted + this.account = hash.account + this.totalRepliesFromVideoAuthor = hash.totalRepliesFromVideoAuthor + this.totalReplies = hash.totalReplies + + if (this.account) { + this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host) + this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account) + + const absoluteAPIUrl = getAbsoluteAPIUrl() + const thisHost = new URL(absoluteAPIUrl).host + this.isLocal = this.account.host.trim() === thisHost + } + } +} diff --git a/client/src/app/shared/shared-video-comment/video-comment.service.ts b/client/src/app/shared/shared-video-comment/video-comment.service.ts new file mode 100644 index 000000000..81c65aa38 --- /dev/null +++ b/client/src/app/shared/shared-video-comment/video-comment.service.ts @@ -0,0 +1,149 @@ +import { Observable } from 'rxjs' +import { catchError, map } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' +import { objectLineFeedToHtml } from '@app/helpers' +import { + FeedFormat, + ResultList, + VideoComment as VideoCommentServerModel, + VideoCommentCreate, + VideoCommentThreadTree as VideoCommentThreadTreeServerModel +} from '@shared/models' +import { environment } from '../../../environments/environment' +import { VideoCommentThreadTree } from './video-comment-thread-tree.model' +import { VideoComment } from './video-comment.model' + +@Injectable() +export class VideoCommentService { + private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' + private static BASE_FEEDS_URL = environment.apiUrl + '/feeds/video-comments.' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor, + private restService: RestService + ) {} + + addCommentThread (videoId: number | string, comment: VideoCommentCreate) { + const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' + const normalizedComment = objectLineFeedToHtml(comment, 'text') + + return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) + .pipe( + map(data => this.extractVideoComment(data.comment)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + addCommentReply (videoId: number | string, inReplyToCommentId: number, comment: VideoCommentCreate) { + const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId + const normalizedComment = objectLineFeedToHtml(comment, 'text') + + return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) + .pipe( + map(data => this.extractVideoComment(data.comment)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + getVideoCommentThreads (parameters: { + videoId: number | string, + componentPagination: ComponentPaginationLight, + sort: string + }): Observable> { + const { videoId, componentPagination, sort } = parameters + + const pagination = this.restService.componentPaginationToRestPagination(componentPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' + return this.authHttp.get>(url, { params }) + .pipe( + map(result => this.extractVideoComments(result)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + getVideoThreadComments (parameters: { + videoId: number | string, + threadId: number + }): Observable { + const { videoId, threadId } = parameters + const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}` + + return this.authHttp + .get(url) + .pipe( + map(tree => this.extractVideoCommentTree(tree)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + deleteVideoComment (videoId: number | string, commentId: number) { + const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comments/${commentId}` + + return this.authHttp + .delete(url) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + getVideoCommentsFeeds (videoUUID?: string) { + const feeds = [ + { + format: FeedFormat.RSS, + label: 'rss 2.0', + url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.RSS.toLowerCase() + }, + { + format: FeedFormat.ATOM, + label: 'atom 1.0', + url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.ATOM.toLowerCase() + }, + { + format: FeedFormat.JSON, + label: 'json 1.0', + url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.JSON.toLowerCase() + } + ] + + if (videoUUID !== undefined) { + for (const feed of feeds) { + feed.url += '?videoId=' + videoUUID + } + } + + return feeds + } + + private extractVideoComment (videoComment: VideoCommentServerModel) { + return new VideoComment(videoComment) + } + + private extractVideoComments (result: ResultList) { + const videoCommentsJson = result.data + const totalComments = result.total + const comments: VideoComment[] = [] + + for (const videoCommentJson of videoCommentsJson) { + comments.push(new VideoComment(videoCommentJson)) + } + + return { data: comments, total: totalComments } + } + + private extractVideoCommentTree (tree: VideoCommentThreadTreeServerModel) { + if (!tree) return tree as VideoCommentThreadTree + + tree.comment = new VideoComment(tree.comment) + tree.children.forEach(c => this.extractVideoCommentTree(c)) + + return tree as VideoCommentThreadTree + } +} -- cgit v1.2.3