Add ability to report comments in front end

pull/2962/head
Chocobozzz 2020-07-09 11:58:46 +02:00 committed by Chocobozzz
parent 310b5219b3
commit 8ca56654a1
22 changed files with 488 additions and 163 deletions

View File

@ -47,8 +47,8 @@ export class AdminComponent implements OnInit {
if (this.hasAbusesRight()) {
moderationItems.children.push({
label: this.i18n('Video reports'),
routerLink: '/admin/moderation/video-abuses/list',
label: this.i18n('Reports'),
routerLink: '/admin/moderation/abuses/list',
iconName: 'flag'
})
}

View File

@ -3,10 +3,13 @@
<div class="col-8">
<!-- report metadata -->
<div class="d-flex">
<div class="d-flex" *ngIf="abuse.reporterAccount">
<span class="col-3 moderation-expanded-label" i18n>Reporter</span>
<span class="col-9 moderation-expanded-text">
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }" class="chip">
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }"
class="chip"
>
<img
class="avatar"
[src]="abuse.reporterAccount.avatar?.path"
@ -17,27 +20,35 @@
<span class="text-muted">{{ abuse.reporterAccount.nameWithHost }}</span>
</div>
</a>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }" class="ml-auto text-muted video-details-links" i18n>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }"
class="ml-auto text-muted abuse-details-links" i18n
>
{abuse.countReportsForReporter, plural, =1 {1 report} other {{{ abuse.countReportsForReporter }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
</a>
</span>
</div>
<div class="d-flex">
<div class="d-flex" *ngIf="abuse.flaggedAccount">
<span class="col-3 moderation-expanded-label" i18n>Reportee</span>
<span class="col-9 moderation-expanded-text">
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.video.channel.ownerAccount.displayName + '&quot;' }" class="chip">
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }"
class="chip"
>
<img
class="avatar"
[src]="abuse.video.channel.ownerAccount?.avatar?.path"
[src]="abuse.flaggedAccount?.avatar?.path"
(error)="switchToDefaultAvatar($event)"
alt="Avatar"
>
<div>
<span class="text-muted">{{ abuse.video.channel.ownerAccount ? abuse.video.channel.ownerAccount.nameWithHost : '' }}</span>
<span class="text-muted">{{ abuse.flaggedAccount ? abuse.flaggedAccount.nameWithHost : '' }}</span>
</div>
</a>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.video.channel.ownerAccount.displayName + '&quot;' }" class="ml-auto text-muted video-details-links" i18n>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }"
class="ml-auto text-muted abuse-details-links" i18n
>
{abuse.countReportsForReportee, plural, =1 {1 report} other {{{ abuse.countReportsForReportee }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
</a>
</span>
@ -45,7 +56,7 @@
<div class="d-flex" *ngIf="abuse.updatedAt">
<span class="col-3 moderation-expanded-label" i18n>Updated</span>
<time class="col-9 moderation-expanded-text video-details-date-updated">{{ abuse.updatedAt | date: 'medium' }}</time>
<time class="col-9 moderation-expanded-text abuse-details-date-updated">{{ abuse.updatedAt | date: 'medium' }}</time>
</div>
<!-- report text -->
@ -60,34 +71,45 @@
<div *ngIf="getPredefinedReasons()" class="mt-2 d-flex">
<span class="col-3"></span>
<span class="col-9">
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'tag:' + reason.id }" class="chip rectangular bg-secondary text-light" *ngFor="let reason of getPredefinedReasons()">
<a *ngFor="let reason of getPredefinedReasons()" [routerLink]="[ '/admin/moderation/abuses/list' ]"
[queryParams]="{ 'search': 'tag:' + reason.id }" class="chip rectangular bg-secondary text-light"
>
<div>{{ reason.label }}</div>
</a>
</span>
</div>
<div *ngIf="abuse.startAt" class="mt-2 d-flex">
<div *ngIf="abuse.video?.startAt" class="mt-2 d-flex">
<span class="col-3 moderation-expanded-label" i18n>Reported part</span>
<span class="col-9">
{{ startAt }}<ng-container *ngIf="abuse.endAt"> - {{ endAt }}</ng-container>
{{ startAt }}<ng-container *ngIf="abuse.video.endAt"> - {{ endAt }}</ng-container>
</span>
</div>
<div class="mt-3 d-flex" *ngIf="abuse.moderationComment">
<span class="col-3 moderation-expanded-label" i18n>Note</span>
<span class="col-9 moderation-expanded-text" [innerHTML]="abuse.moderationCommentHtml"></span>
<span class="col-9 moderation-expanded-text d-block" [innerHTML]="abuse.moderationCommentHtml"></span>
</div>
</div>
<!-- report right part (video details) -->
<!-- report right part (video/comment details) -->
<div class="col-4">
<div class="screenratio">
<div *ngIf="abuse.video.deleted || abuse.video.blacklisted">
<div *ngIf="abuse.video" class="screenratio">
<div>
<span i18n *ngIf="abuse.video.deleted">The video was deleted</span>
<span i18n *ngIf="!abuse.video.deleted">The video was blocked</span>
</div>
<div *ngIf="!abuse.video.deleted && !abuse.video.blacklisted" [innerHTML]="abuse.embedHtml"></div>
</div>
<div *ngIf="abuse.comment" class="comment-html">
<div>
<strong i18n>Comment:</strong>
</div>
<div [innerHTML]="abuse.commentHtml"></div>
</div>
</div>
</div>

View File

@ -31,15 +31,16 @@ export class AbuseDetailsComponent {
}
get startAt () {
return durationToString(this.abuse.startAt)
return durationToString(this.abuse.video.startAt)
}
get endAt () {
return durationToString(this.abuse.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]

View File

@ -38,7 +38,7 @@
<tr> <!-- header -->
<th style="width: 40px;"></th>
<th style="width: 20%;" pResizableColumn i18n>Reporter</th>
<th i18n>Video</th>
<th i18n>Video/Comment/Account</th>
<th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
<th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
<th style="width: 150px;"></th>
@ -54,7 +54,7 @@
</td>
<td>
<a [href]="abuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
<a *ngIf="abuse.reporterAccount" [href]="abuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
<div class="chip two-lines">
<img
class="avatar"
@ -64,54 +64,73 @@
>
<div>
{{ abuse.reporterAccount.displayName }}
<span class="text-muted">{{ abuse.reporterAccount.nameWithHost }}</span>
<span>{{ abuse.reporterAccount.nameWithHost }}</span>
</div>
</div>
</a>
<span i18n *ngIf="!abuse.reporterAccount">
Deleted account
</span>
</td>
<td *ngIf="!abuse.video.deleted">
<a [href]="getVideoUrl(abuse)" class="video-table-video-link" [title]="abuse.video.name" target="_blank" rel="noopener noreferrer">
<div class="video-table-video">
<div class="video-table-video-image">
<img [src]="abuse.video.thumbnailPath">
<span
class="video-table-video-image-label" *ngIf="abuse.count > 1"
i18n-title title="This video has been reported multiple times."
>
{{ abuse.nth }}/{{ abuse.count }}
</span>
</div>
<div class="video-table-video-text">
<div>
<span *ngIf="!abuse.video.blacklisted" class="glyphicon glyphicon-new-window"></span>
<span *ngIf="abuse.video.blacklisted" i18n-title title="The video was blocked" class="glyphicon glyphicon-ban-circle"></span>
{{ abuse.video.name }}
<ng-container *ngIf="abuse.video">
<td *ngIf="!abuse.video.deleted">
<a [href]="getVideoUrl(abuse)" class="table-video-link" [title]="abuse.video.name" target="_blank" rel="noopener noreferrer">
<div class="table-video">
<div class="table-video-image">
<img [src]="abuse.video.thumbnailPath">
<span
class="table-video-image-label" *ngIf="abuse.count > 1"
i18n-title title="This video has been reported multiple times."
>
{{ abuse.nth }}/{{ abuse.count }}
</span>
</div>
<div class="text-muted" i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
</div>
</div>
</a>
</td>
<td *ngIf="abuse.video.deleted" class="c-hand" [pRowToggler]="abuse">
<div class="video-table-video" i18n-title title="Video was deleted">
<div class="video-table-video-image">
<span i18n>Deleted</span>
</div>
<div class="video-table-video-text">
<div>
{{ abuse.video.name }}
<span class="glyphicon glyphicon-trash"></span>
<div class="table-video-text">
<div>
<span *ngIf="!abuse.video.blacklisted" class="glyphicon glyphicon-new-window"></span>
<span *ngIf="abuse.video.blacklisted" i18n-title title="The video was blocked" class="glyphicon glyphicon-ban-circle"></span>
{{ abuse.video.name }}
</div>
<div i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
</div>
</div>
</a>
</td>
<td *ngIf="abuse.video.deleted" class="c-hand" [pRowToggler]="abuse">
<div class="table-video" i18n-title title="Video was deleted">
<div class="table-video-image">
<span i18n>Deleted</span>
</div>
<div class="table-video-text">
<div>
{{ abuse.video.name }}
<span class="glyphicon glyphicon-trash"></span>
</div>
<div i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
</div>
<div class="text-muted" i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
</div>
</div>
</td>
</td>
</ng-container>
<ng-container *ngIf="abuse.comment">
<td>
<a [href]="getCommentUrl(abuse)" [innerHTML]="abuse.truncatedCommentHtml" class="table-comment-link"
[title]="abuse.comment.video.name" target="_blank" rel="noopener noreferrer"
></a>
<div class="comment-flagged-account" *ngIf="abuse.flaggedAccount">by {{ abuse.flaggedAccount.displayName }}</div>
</td>
</ng-container>
<td class="c-hand" [pRowToggler]="abuse">{{ abuse.createdAt | date: 'short' }}</td>
<td class="c-hand video-abuse-states" [pRowToggler]="abuse">
<td class="c-hand abuse-states" [pRowToggler]="abuse">
<span *ngIf="isAbuseAccepted(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-ok"></span>
<span *ngIf="isAbuseRejected(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-remove"></span>
<span *ngIf="abuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="abuse.moderationComment" class="glyphicon glyphicon-comment"></span>

View File

@ -10,7 +10,7 @@
@include disable-default-a-behaviour;
}
.video-abuse-states .glyphicon-comment {
.abuse-states .glyphicon-comment {
margin-left: 0.5rem;
}

View File

@ -2,7 +2,7 @@ import { SortMeta } from 'primeng/api'
import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
import { environment } from 'src/environments/environment'
import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'
import { DomSanitizer } from '@angular/platform-browser'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
import { ActivatedRoute, Params, Router } from '@angular/router'
import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main'
@ -10,15 +10,20 @@ import { AbuseService, BlocklistService, VideoBlockService } from '@app/shared/s
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'
export type ProcessedAbuse = Abuse & {
moderationCommentHtml?: string,
reasonHtml?: string
embedHtml?: string
embedHtml?: SafeHtml
updatedAt?: Date
// override bare server-side definitions with rich client-side definitions
reporterAccount: Account
reporterAccount?: Account
flaggedAccount?: Account
truncatedCommentHtml?: string
commentHtml?: string
video: Abuse['video'] & {
channel: Abuse['video']['channel'] & {
@ -92,11 +97,11 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
{
label: this.i18n('Actions for the video'),
isHeader: true,
isDisplayed: abuse => !abuse.video.deleted
isDisplayed: abuse => abuse.video && !abuse.video.deleted
},
{
label: this.i18n('Block video'),
isDisplayed: abuse => !abuse.video.deleted && !abuse.video.blacklisted,
isDisplayed: abuse => abuse.video && !abuse.video.deleted && !abuse.video.blacklisted,
handler: abuse => {
this.videoBlocklistService.blockVideo(abuse.video.id, undefined, true)
.subscribe(
@ -112,7 +117,7 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
},
{
label: this.i18n('Unblock video'),
isDisplayed: abuse => !abuse.video.deleted && abuse.video.blacklisted,
isDisplayed: abuse => abuse.video && !abuse.video.deleted && abuse.video.blacklisted,
handler: abuse => {
this.videoBlocklistService.unblockVideo(abuse.video.id)
.subscribe(
@ -128,7 +133,7 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
},
{
label: this.i18n('Delete video'),
isDisplayed: abuse => !abuse.video.deleted,
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?'),
@ -152,10 +157,12 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
[
{
label: this.i18n('Actions for the reporter'),
isHeader: true
isHeader: true,
isDisplayed: abuse => !!abuse.reporterAccount
},
{
label: this.i18n('Mute reporter'),
isDisplayed: abuse => !!abuse.reporterAccount,
handler: async abuse => {
const account = abuse.reporterAccount as Account
@ -175,7 +182,7 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
},
{
label: this.i18n('Mute server'),
isDisplayed: abuse => !abuse.reporterAccount.userId,
isDisplayed: abuse => abuse.reporterAccount && !abuse.reporterAccount.userId,
handler: async abuse => {
this.blocklistService.blockServerByInstance(abuse.reporterAccount.host)
.subscribe(
@ -231,7 +238,7 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
const queryParams: Params = {}
if (search) Object.assign(queryParams, { search })
this.router.navigate([ '/admin/moderation/video-abuses/list' ], { queryParams })
this.router.navigate([ '/admin/moderation/abuses/list' ], { queryParams })
}
resetTableFilter () {
@ -253,6 +260,10 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
return Video.buildClientUrl(abuse.video.uuid)
}
getCommentUrl (abuse: Abuse) {
return Video.buildClientUrl(abuse.comment.video.uuid) + ';threadId=' + abuse.comment.threadId
}
getVideoEmbed (abuse: Abuse) {
return buildVideoEmbed(
buildVideoLink({
@ -300,23 +311,45 @@ export class AbuseListComponent extends RestTable implements OnInit, AfterViewIn
}).subscribe(
async resultList => {
this.totalRecords = resultList.total
const abuses = []
for (const abuse of resultList.data) {
Object.assign(abuse, {
reasonHtml: await this.toHtml(abuse.reason),
moderationCommentHtml: await this.toHtml(abuse.moderationComment),
embedHtml: this.sanitizer.bypassSecurityTrustHtml(this.getVideoEmbed(abuse)),
reporterAccount: new Account(abuse.reporterAccount)
})
this.abuses = []
for (const a of resultList.data) {
const abuse = a as ProcessedAbuse
abuse.reasonHtml = await this.toHtml(abuse.reason)
abuse.moderationCommentHtml = await this.toHtml(abuse.moderationComment)
if (abuse.video) {
abuse.embedHtml = this.sanitizer.bypassSecurityTrustHtml(this.getVideoEmbed(abuse))
if (abuse.video.channel?.ownerAccount) {
abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount)
}
}
if (abuse.comment) {
if (abuse.comment.deleted) {
abuse.truncatedCommentHtml = abuse.commentHtml = this.i18n('Deleted comment')
} else {
const truncated = truncate(abuse.comment.text, { length: 100 })
abuse.truncatedCommentHtml = await this.markdownRenderer.textMarkdownToHTML(truncated, true)
abuse.commentHtml = await this.markdownRenderer.textMarkdownToHTML(abuse.comment.text, true)
}
}
if (abuse.reporterAccount) {
abuse.reporterAccount = new Account(abuse.reporterAccount)
}
if (abuse.flaggedAccount) {
abuse.flaggedAccount = new Account(abuse.flaggedAccount)
}
if (abuse.video.channel?.ownerAccount) abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount)
if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt
abuses.push(abuse as ProcessedAbuse)
this.abuses.push(abuse)
}
this.abuses = abuses
},
err => this.notifier.error(err.message)

View File

@ -25,18 +25,18 @@
vertical-align: top;
text-align: right;
}
.moderation-expanded-text {
display: inline-flex;
word-wrap: break-word;
::ng-deep p:last-child {
margin-bottom: 0px !important;
}
}
}
.video-table-states {
.table-states {
& > :not(:first-child) {
margin-left: .4rem;
}
@ -59,6 +59,7 @@ p-calendar {
.screenratio {
div {
@include miniature-thumbnail;
display: inline-flex;
justify-content: center;
align-items: center;
@ -72,6 +73,11 @@ p-calendar {
};
}
.comment-html {
background-color: #ececec;
padding: 10px;
}
.chip {
@include chip;
}
@ -83,16 +89,32 @@ my-action-dropdown.show {
}
.video-table-video-link {
.table-video-link {
@include disable-outline;
position: relative;
top: 3px;
}
.video-table-video {
.table-comment-link {
@include disable-outline;
color: var(--mainForegroundColor);
::ng-deep p:last-child {
margin: 0;
}
}
.comment-flagged-account {
font-size: 11px;
color: var(--greyForegroundColor);
}
.table-video {
display: inline-flex;
.video-table-video-image {
.table-video-image {
@include miniature-thumbnail;
$image-height: 45px;
@ -118,7 +140,7 @@ my-action-dropdown.show {
color: pvar(--inputPlaceholderColor);
}
.video-table-video-image-label {
.table-video-image-label {
@include static-thumbnail-overlay;
position: absolute;
border-radius: 3px;
@ -130,7 +152,7 @@ my-action-dropdown.show {
}
}
.video-table-video-text {
.table-video-text {
display: inline-flex;
flex-direction: column;
justify-content: center;
@ -145,7 +167,8 @@ my-action-dropdown.show {
}
div + div {
font-size: 80%;
color: var(--greyForegroundColor);
font-size: 11px;
}
}
}

View File

@ -33,7 +33,7 @@ export const ModerationRoutes: Routes = [
data: {
userRight: UserRight.MANAGE_ABUSES,
meta: {
title: 'Video reports'
title: 'Reports'
}
}
},

View File

@ -45,6 +45,7 @@
<div *ngIf="isRemovableByUser()" (click)="onWantToDelete()" class="comment-action-delete" i18n>Delete</div>
<my-user-moderation-dropdown
[prependActions]="prependModerationActions"
buttonSize="small" [account]="commentAccount" [user]="commentUser" i18n-label label="Options" placement="bottom-left auto"
></my-user-moderation-dropdown>
</div>
@ -93,3 +94,7 @@
</div>
</div>
</div>
<ng-container *ngIf="prependModerationActions">
<my-comment-report #commentReportModal [comment]="comment"></my-comment-report>
</ng-container>

View File

@ -1,7 +1,10 @@
import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core'
import { MarkdownService, Notifier, UserService } from '@app/core'
import { AuthService } from '@app/core/auth'
import { Account, Actor, Video } from '@app/shared/shared-main'
import { Account, Actor, DropdownAction, Video } from '@app/shared/shared-main'
import { CommentReportComponent } from '@app/shared/shared-moderation/comment-report.component'
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'
@ -12,6 +15,8 @@ import { VideoComment } from './video-comment.model'
styleUrls: ['./video-comment.component.scss']
})
export class VideoCommentComponent implements OnInit, OnChanges {
@ViewChild('commentReportModal') commentReportModal: CommentReportComponent
@Input() video: Video
@Input() comment: VideoComment
@Input() parentComments: VideoComment[] = []
@ -26,6 +31,8 @@ export class VideoCommentComponent implements OnInit, OnChanges {
@Output() resetReply = new EventEmitter()
@Output() timestampClicked = new EventEmitter<number>()
prependModerationActions: DropdownAction<any>[]
sanitizedCommentHTML = ''
newParentComments: VideoComment[] = []
@ -33,6 +40,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
commentUser: User
constructor (
private i18n: I18n,
private markdownService: MarkdownService,
private authService: AuthService,
private userService: UserService,
@ -127,5 +135,20 @@ export class VideoCommentComponent implements OnInit, OnChanges {
} else {
this.comment.account = null
}
if (this.isUserLoggedIn()) {
this.prependModerationActions = [
{
label: this.i18n('Report comment'),
handler: () => this.showReportModal()
}
]
} else {
this.prependModerationActions = undefined
}
}
private showReportModal () {
this.commentReportModal.show()
}
}

View File

@ -46,8 +46,10 @@ export abstract class Actor implements ActorServer {
this.host = hash.host
this.followingCount = hash.followingCount
this.followersCount = hash.followersCount
this.createdAt = new Date(hash.createdAt.toString())
this.updatedAt = new Date(hash.updatedAt.toString())
if (hash.createdAt) this.createdAt = new Date(hash.createdAt.toString())
if (hash.updatedAt) this.updatedAt = new Date(hash.updatedAt.toString())
this.avatar = hash.avatar
this.updateComputedAttributes()

View File

@ -5,18 +5,20 @@ import { catchError, map } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { RestExtractor, RestPagination, RestService } from '@app/core'
import { AbuseUpdate, ResultList, Abuse, AbuseCreate, AbuseState } from '@shared/models'
import { Abuse, AbuseCreate, AbuseFilter, AbusePredefinedReasonsString, AbuseState, AbuseUpdate, ResultList } from '@shared/models'
import { environment } from '../../../environments/environment'
import { I18n } from '@ngx-translate/i18n-polyfill'
@Injectable()
export class AbuseService {
private static BASE_ABUSE_URL = environment.apiUrl + '/api/v1/abuses'
constructor (
private i18n: I18n,
private authHttp: HttpClient,
private restService: RestService,
private restExtractor: RestExtractor
) {}
) { }
getAbuses (options: {
pagination: RestPagination,
@ -24,7 +26,7 @@ export class AbuseService {
search?: string
}): Observable<ResultList<Abuse>> {
const { pagination, sort, search } = options
const url = AbuseService.BASE_ABUSE_URL + 'abuse'
const url = AbuseService.BASE_ABUSE_URL
let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort)
@ -60,39 +62,93 @@ export class AbuseService {
}
return this.authHttp.get<ResultList<Abuse>>(url, { params })
.pipe(
catchError(res => this.restExtractor.handleError(res))
)
.pipe(
catchError(res => this.restExtractor.handleError(res))
)
}
reportVideo (parameters: AbuseCreate) {
const url = AbuseService.BASE_ABUSE_URL
const body = omit(parameters, [ 'id' ])
const body = omit(parameters, ['id'])
return this.authHttp.post(url, body)
.pipe(
map(this.restExtractor.extractDataBool),
catchError(res => this.restExtractor.handleError(res))
)
.pipe(
map(this.restExtractor.extractDataBool),
catchError(res => this.restExtractor.handleError(res))
)
}
updateAbuse (abuse: Abuse, abuseUpdate: AbuseUpdate) {
const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
return this.authHttp.put(url, abuseUpdate)
.pipe(
map(this.restExtractor.extractDataBool),
catchError(res => this.restExtractor.handleError(res))
)
.pipe(
map(this.restExtractor.extractDataBool),
catchError(res => this.restExtractor.handleError(res))
)
}
removeAbuse (abuse: Abuse) {
const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
return this.authHttp.delete(url)
.pipe(
map(this.restExtractor.extractDataBool),
catchError(res => this.restExtractor.handleError(res))
)
}}
.pipe(
map(this.restExtractor.extractDataBool),
catchError(res => this.restExtractor.handleError(res))
)
}
getPrefefinedReasons (type: AbuseFilter) {
let reasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [
{
id: 'violentOrRepulsive',
label: this.i18n('Violent or repulsive'),
help: this.i18n('Contains offensive, violent, or coarse language or iconography.')
},
{
id: 'hatefulOrAbusive',
label: this.i18n('Hateful or abusive'),
help: this.i18n('Contains abusive, racist or sexist language or iconography.')
},
{
id: 'spamOrMisleading',
label: this.i18n('Spam, ad or false news'),
help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.')
},
{
id: 'privacy',
label: this.i18n('Privacy breach or doxxing'),
help: this.i18n('Contains personal information that could be used to track, identify, contact or impersonate someone (e.g. name, address, phone number, email, or credit card details).')
},
{
id: 'rights',
label: this.i18n('Intellectual property violation'),
help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.')
},
{
id: 'serverRules',
label: this.i18n('Breaks server rules'),
description: this.i18n('Anything not included in the above that breaks the terms of service, code of conduct, or general rules in place on the server.')
}
]
if (type === 'video') {
reasons = reasons.concat([
{
id: 'thumbnails',
label: this.i18n('Thumbnails'),
help: this.i18n('The above can only be seen in thumbnails.')
},
{
id: 'captions',
label: this.i18n('Captions'),
help: this.i18n('The above can only be seen in captions (please describe which).')
}
])
}
return reasons
}
}

View File

@ -0,0 +1,62 @@
<ng-template #modal>
<div class="modal-header">
<h4 i18n class="modal-title">Report comment</h4>
<my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
</div>
<div class="modal-body">
<form novalidate [formGroup]="form" (ngSubmit)="report()">
<div class="row">
<div class="col-5 form-group">
<label i18n for="reportPredefinedReasons">What is the issue?</label>
<div class="ml-2 mt-2 d-flex flex-column">
<ng-container formGroupName="predefinedReasons">
<div class="form-group" *ngFor="let reason of predefinedReasons">
<my-peertube-checkbox [inputName]="reason.id" [formControlName]="reason.id" [labelText]="reason.label">
<ng-template *ngIf="reason.help" ptTemplate="help">
<div [innerHTML]="reason.help"></div>
</ng-template>
<ng-container *ngIf="reason.description" ngProjectAs="description">
<div [innerHTML]="reason.description"></div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</div>
</div>
<div class="col-7">
<div i18n class="information">
Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteComment()"> and will be forwarded to the comment origin ({{ originHost }}) too</ng-container>.
</div>
<div class="form-group">
<textarea
i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus
[ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
></textarea>
<div *ngIf="formErrors.reason" class="form-error">
{{ formErrors.reason }}
</div>
</div>
</div>
</div>
<div class="form-group inputs">
<input
type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
(click)="hide()" (key.enter)="hide()"
>
<input type="submit" i18n-value value="Submit" class="action-button-submit" [disabled]="!form.valid">
</div>
</form>
</div>
</ng-template>

View File

@ -0,0 +1,11 @@
@import 'variables';
@import 'mixins';
.information {
margin-bottom: 20px;
}
textarea {
@include peertube-textarea(100%, 100px);
}

View File

@ -0,0 +1,93 @@
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
}
}

View File

@ -12,6 +12,7 @@ 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'
@NgModule({
imports: [
@ -25,7 +26,8 @@ import { VideoReportComponent } from './video-report.component'
UserModerationDropdownComponent,
VideoBlockComponent,
VideoReportComponent,
BatchDomainsModalComponent
BatchDomainsModalComponent,
CommentReportComponent
],
exports: [
@ -33,7 +35,8 @@ import { VideoReportComponent } from './video-report.component'
UserModerationDropdownComponent,
VideoBlockComponent,
VideoReportComponent,
BatchDomainsModalComponent
BatchDomainsModalComponent,
CommentReportComponent
],
providers: [

View File

@ -16,6 +16,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
@Input() user: User
@Input() account: Account
@Input() prependActions: DropdownAction<{ user: User, account: Account }>[]
@Input() buttonSize: 'normal' | 'small' = 'normal'
@Input() placement = 'left-top left-bottom auto'
@ -250,6 +251,12 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
private buildActions () {
this.userActions = []
if (this.prependActions) {
this.userActions = [
this.prependActions
]
}
if (this.authService.isLoggedIn()) {
const authUser = this.authService.getUser()

View File

@ -14,16 +14,19 @@
<div class="ml-2 mt-2 d-flex flex-column">
<ng-container formGroupName="predefinedReasons">
<div class="form-group" *ngFor="let reason of predefinedReasons">
<my-peertube-checkbox formControlName="{{reason.id}}" labelText="{{reason.label}}">
<my-peertube-checkbox [inputName]="reason.id" [formControlName]="reason.id" [labelText]="reason.label">
<ng-template *ngIf="reason.help" ptTemplate="help">
<div [innerHTML]="reason.help"></div>
</ng-template>
<ng-container *ngIf="reason.description" ngProjectAs="description">
<div [innerHTML]="reason.description"></div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</div>
@ -73,7 +76,7 @@
</div>
<div class="form-group">
<textarea
<textarea
i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus
[ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
></textarea>

View File

@ -79,48 +79,7 @@ export class VideoReportComponent extends FormReactive implements OnInit {
}
})
this.predefinedReasons = [
{
id: 'violentOrRepulsive',
label: this.i18n('Violent or repulsive'),
help: this.i18n('Contains offensive, violent, or coarse language or iconography.')
},
{
id: 'hatefulOrAbusive',
label: this.i18n('Hateful or abusive'),
help: this.i18n('Contains abusive, racist or sexist language or iconography.')
},
{
id: 'spamOrMisleading',
label: this.i18n('Spam, ad or false news'),
help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.')
},
{
id: 'privacy',
label: this.i18n('Privacy breach or doxxing'),
help: this.i18n('Contains personal information that could be used to track, identify, contact or impersonate someone (e.g. name, address, phone number, email, or credit card details).')
},
{
id: 'rights',
label: this.i18n('Intellectual property violation'),
help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.')
},
{
id: 'serverRules',
label: this.i18n('Breaks server rules'),
description: this.i18n('Anything not included in the above that breaks the terms of service, code of conduct, or general rules in place on the server.')
},
{
id: 'thumbnails',
label: this.i18n('Thumbnails'),
help: this.i18n('The above can only be seen in thumbnails.')
},
{
id: 'captions',
label: this.i18n('Captions'),
help: this.i18n('The above can only be seen in captions (please describe which).')
}
]
this.predefinedReasons = this.abuseService.getPrefefinedReasons('video')
this.embedHtml = this.getVideoEmbed()
}

View File

@ -140,7 +140,7 @@ export enum ScopeNames {
model: VideoModel.unscoped(),
include: [
{
attributes: [ 'filename', 'fileUrl' ],
attributes: [ 'filename', 'fileUrl', 'type' ],
model: ThumbnailModel
},
{
@ -395,6 +395,8 @@ export class AbuseModel extends Model<AbuseModel> {
comment = {
id: entity.id,
threadId: entity.getThreadId(),
text: entity.text ?? '',
deleted: entity.isDeleted(),

View File

@ -655,7 +655,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
id: this.id,
url: this.url,
text: this.text,
threadId: this.originCommentId || this.id,
threadId: this.getThreadId(),
inReplyToCommentId: this.inReplyToCommentId || null,
videoId: this.videoId,
createdAt: this.createdAt,

View File

@ -25,6 +25,7 @@ export interface VideoAbuse {
export interface VideoCommentAbuse {
id: number
threadId: number
video: {
id: number