<th i18n>Video/Comment/Account</th>
<th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
<th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
+ <th i18n style="width: 80px;">Messages</th>
<th style="width: 150px;"></th>
</tr>
</ng-template>
<span *ngIf="abuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="abuse.moderationComment" class="glyphicon glyphicon-comment"></span>
</td>
+ <td class="c-hand abuse-messages" (click)="openAbuseMessagesModal(abuse)">
+ {{ abuse.countMessages }}
+
+ <my-global-icon iconName="message-circle"></my-global-icon>
+ </td>
+
<td class="action-cell">
<my-action-dropdown
[ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
</p-table>
<my-moderation-comment-modal #moderationCommentModal (commentUpdated)="onModerationCommentUpdated()"></my-moderation-comment-modal>
+<my-abuse-message-modal #abuseMessagesModal (countMessagesUpdated)="onCountMessagesUpdated($event)"></my-abuse-message-modal>
margin-left: 0;
}
}
+
+.abuse-messages {
+ my-global-icon {
+ width: 22px;
+ margin-left: 3px;
+ position: relative;
+ top: -2px;
+ }
+}
import { ActivatedRoute, Params, Router } from '@angular/router'
import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main'
-import { AbuseService, BlocklistService, VideoBlockService } from '@app/shared/shared-moderation'
+import { AbuseService, BlocklistService, VideoBlockService, AbuseMessageModalComponent } from '@app/shared/shared-moderation'
import { VideoCommentService } from '@app/shared/shared-video-comment'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Abuse, AbuseState } from '@shared/models'
+import { AdminAbuse, AbuseState } from '@shared/models'
import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
const logger = debug('peertube:moderation:AbuseListComponent')
// Don't use an abuse model because we need external services to compute some properties
// And this model is only used in this component
-export type ProcessedAbuse = Abuse & {
+export type ProcessedAbuse = AdminAbuse & {
moderationCommentHtml?: string,
reasonHtml?: string
embedHtml?: SafeHtml
truncatedCommentHtml?: string
commentHtml?: string
- video: Abuse['video'] & {
- channel: Abuse['video']['channel'] & {
+ video: AdminAbuse['video'] & {
+ channel: AdminAbuse['video']['channel'] & {
ownerAccount: Account
}
}
})
export class AbuseListComponent extends RestTable implements OnInit, AfterViewInit {
@ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent
+ @ViewChild('abuseMessagesModal', { static: true }) abuseMessagesModal: AbuseMessageModalComponent
abuses: ProcessedAbuse[] = []
totalRecords = 0
return 'AbuseListComponent'
}
- openModerationCommentModal (abuse: Abuse) {
+ openModerationCommentModal (abuse: AdminAbuse) {
this.moderationCommentModal.openModal(abuse)
}
}
/* END Table filter functions */
- isAbuseAccepted (abuse: Abuse) {
+ isAbuseAccepted (abuse: AdminAbuse) {
return abuse.state.id === AbuseState.ACCEPTED
}
- isAbuseRejected (abuse: Abuse) {
+ isAbuseRejected (abuse: AdminAbuse) {
return abuse.state.id === AbuseState.REJECTED
}
- getVideoUrl (abuse: Abuse) {
+ getVideoUrl (abuse: AdminAbuse) {
return Video.buildClientUrl(abuse.video.uuid)
}
- getCommentUrl (abuse: Abuse) {
+ getCommentUrl (abuse: AdminAbuse) {
return Video.buildClientUrl(abuse.comment.video.uuid) + ';threadId=' + abuse.comment.threadId
}
return '/accounts/' + abuse.flaggedAccount.nameWithHost
}
- getVideoEmbed (abuse: Abuse) {
+ getVideoEmbed (abuse: AdminAbuse) {
return buildVideoEmbed(
buildVideoLink({
baseUrl: `${environment.embedUrl}/videos/embed/${abuse.video.uuid}`,
($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
}
- async removeAbuse (abuse: Abuse) {
+ async removeAbuse (abuse: AdminAbuse) {
const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this abuse report?'), this.i18n('Delete'))
if (res === false) return
)
}
- updateAbuseState (abuse: Abuse, state: AbuseState) {
+ updateAbuseState (abuse: AdminAbuse, state: AbuseState) {
this.abuseService.updateAbuse(abuse, { state })
.subscribe(
() => this.loadData(),
)
}
+ onCountMessagesUpdated (event: { abuseId: number, countMessages: number }) {
+ const abuse = this.abuses.find(a => a.id === event.abuseId)
+
+ if (!abuse) {
+ console.error('Cannot find abuse %d.', event.abuseId)
+ return
+ }
+
+ abuse.countMessages = event.countMessages
+ }
+
+ openAbuseMessagesModal (abuse: AdminAbuse) {
+ this.abuseMessagesModal.openModal(abuse)
+ }
+
protected loadData () {
logger('Load data.')
- return this.abuseService.getAbuses({
+ return this.abuseService.getAdminAbuses({
pagination: this.pagination,
sort: this.sort,
search: this.search
handler: abuse => this.removeAbuse(abuse)
},
{
- label: this.i18n('Add note'),
+ label: this.i18n('Messages'),
+ handler: abuse => this.openAbuseMessagesModal(abuse)
+ },
+ {
+ label: this.i18n('Add internal note'),
handler: abuse => this.openModerationCommentModal(abuse),
isDisplayed: abuse => !abuse.moderationComment
},
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Abuse } from '@shared/models'
+import { AdminAbuse } from '@shared/models'
@Component({
selector: 'my-moderation-comment-modal',
@ViewChild('modal', { static: true }) modal: NgbModal
@Output() commentUpdated = new EventEmitter<string>()
- private abuseToComment: Abuse
+ private abuseToComment: AdminAbuse
private openedModal: NgbModalRef
constructor (
})
}
- openModal (abuseToComment: Abuse) {
+ openModal (abuseToComment: AdminAbuse) {
this.abuseToComment = abuseToComment
this.openedModal = this.modalService.open(this.modal, { centered: true })
export class AbuseValidatorsService {
readonly ABUSE_REASON: BuildFormValidator
readonly ABUSE_MODERATION_COMMENT: BuildFormValidator
+ readonly ABUSE_MESSAGE: BuildFormValidator
constructor (private i18n: I18n) {
this.ABUSE_REASON = {
'maxlength': this.i18n('Moderation comment cannot be more than 3000 characters long.')
}
}
+
+ this.ABUSE_MESSAGE = {
+ VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
+ MESSAGES: {
+ 'required': this.i18n('Abuse message is required.'),
+ 'minlength': this.i18n('Abuse message must be at least 2 characters long.'),
+ 'maxlength': this.i18n('Abuse message cannot be more than 3000 characters long.')
+ }
+ }
}
}
'go': require('!!raw-loader?!../../../assets/images/feather/arrow-up-right.svg').default,
'cross': require('!!raw-loader?!../../../assets/images/feather/x.svg').default,
'tick': require('!!raw-loader?!../../../assets/images/feather/check.svg').default,
- 'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
- 'columns': require('!!raw-loader?!../../../assets/images/feather/columns.svg').default
+ 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default
}
export type GlobalIconName = keyof typeof icons
--- /dev/null
+<ng-template #modal>
+ <div class="modal-header">
+ <h4 i18n class="modal-title">Messages</h4>
+
+ <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+ </div>
+
+ <div class="modal-body">
+ <div class="messages" #messagesBlock>
+ <div
+ *ngFor="let message of abuseMessages"
+ class="message-block" [ngClass]="{ 'by-moderator': message.byModerator, 'by-me': isMessageByMe(message) }"
+ >
+
+ <div class="author">{{ message.account.name }}</div>
+
+ <div class="bubble">
+ <div class="content">{{ message.message }}</div>
+ <div class="date">{{ message.createdAt | date }}</div>
+ </div>
+ </div>
+ </div>
+
+ <form novalidate [formGroup]="form" (ngSubmit)="addMessage()">
+ <div class="form-group">
+ <textarea formControlName="message" ngbAutofocus [ngClass]="{ 'input-error': formErrors['message'] }" class="form-control"></textarea>
+
+ <div *ngIf="formErrors.message" class="form-error">
+ {{ formErrors.message }}
+ </div>
+ </div>
+
+ <div class="form-group inputs">
+ <input type="submit" i18n-value value="Add message" class="action-button-submit" [disabled]="!form.valid || sendingMessage">
+ </div>
+ </form>
+
+ </div>
+
+</ng-template>
--- /dev/null
+@import 'variables';
+@import 'mixins';
+
+form {
+ margin: 20px 20px 0 0;
+}
+
+textarea {
+ @include peertube-textarea(100%, 70px);
+
+ margin-top: 20px;
+}
+
+.messages {
+ display: flex;
+ flex-direction: column;
+ overflow-y: scroll;
+ margin-right: 5px;
+}
+
+.message-block {
+ margin-bottom: 10px;
+ max-width: 60%;
+
+ .author {
+ color: var(--greyForegroundColor);
+ font-size: 14px;
+ }
+
+ .bubble {
+ color: var(--mainForegroundColor);
+ background-color: var(--greyBackgroundColor);
+ border-radius: 10px;
+ padding: 5px 10px;
+
+ &.by-me {
+ color: var(--mainForegroundColor);
+ background-color: var(--secondaryColor);
+ }
+
+ &.by-moderator {
+ color: #fff;
+ background-color: var(--mainColor);
+
+ align-self: flex-end;
+ }
+
+ .content {
+ font-size: 15px;
+ }
+
+ .date {
+ font-size: 13px;
+ color: var(--greyForegroundColor);
+ }
+ }
+}
--- /dev/null
+import { Component, ElementRef, EventEmitter, Output, ViewChild, OnInit } from '@angular/core'
+import { Notifier, AuthService } from '@app/core'
+import { FormReactive, FormValidatorService, AbuseValidatorsService } from '@app/shared/shared-forms'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { AbuseMessage, UserAbuse } from '@shared/models'
+import { AbuseService } from './abuse.service'
+
+@Component({
+ selector: 'my-abuse-message-modal',
+ templateUrl: './abuse-message-modal.component.html',
+ styleUrls: [ './abuse-message-modal.component.scss' ]
+})
+export class AbuseMessageModalComponent extends FormReactive implements OnInit {
+ @ViewChild('modal', { static: true }) modal: NgbModal
+ @ViewChild('messagesBlock', { static: false }) messagesBlock: ElementRef
+
+ @Output() countMessagesUpdated = new EventEmitter<{ abuseId: number, countMessages: number }>()
+
+ abuseMessages: AbuseMessage[] = []
+ textareaMessage: string
+ sendingMessage = false
+
+ private openedModal: NgbModalRef
+ private abuse: UserAbuse
+
+ constructor (
+ protected formValidatorService: FormValidatorService,
+ private abuseValidatorsService: AbuseValidatorsService,
+ private modalService: NgbModal,
+ private auth: AuthService,
+ private notifier: Notifier,
+ private i18n: I18n,
+ private abuseService: AbuseService
+ ) {
+ super()
+ }
+
+ ngOnInit () {
+ this.buildForm({
+ message: this.abuseValidatorsService.ABUSE_MESSAGE
+ })
+ }
+
+ openModal (abuse: UserAbuse) {
+ this.abuse = abuse
+
+ this.openedModal = this.modalService.open(this.modal, { centered: true })
+
+ this.loadMessages()
+ }
+
+ hide () {
+ this.abuseMessages = []
+ this.openedModal.close()
+ }
+
+ addMessage () {
+ this.sendingMessage = true
+
+ this.abuseService.addAbuseMessage(this.abuse, this.form.value['message'])
+ .subscribe(
+ () => {
+ this.form.reset()
+ this.sendingMessage = false
+ this.countMessagesUpdated.emit({ abuseId: this.abuse.id, countMessages: this.abuseMessages.length + 1 })
+
+ this.loadMessages()
+ },
+
+ err => {
+ this.sendingMessage = false
+ console.error(err)
+ this.notifier.error('Sorry but you cannot send this message. Please retry later')
+ }
+ )
+ }
+
+ deleteMessage (abuseMessage: AbuseMessage) {
+ this.abuseService.deleteAbuseMessage(this.abuse, abuseMessage)
+ .subscribe(
+ () => {
+ this.countMessagesUpdated.emit({ abuseId: this.abuse.id, countMessages: this.abuseMessages.length - 1 })
+
+ this.abuseMessages = this.abuseMessages.filter(m => m.id !== abuseMessage.id)
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ isMessageByMe (abuseMessage: AbuseMessage) {
+ return this.auth.getUser().account.id === abuseMessage.account.id
+ }
+
+ private loadMessages () {
+ this.abuseService.listAbuseMessages(this.abuse)
+ .subscribe(
+ res => {
+ this.abuseMessages = res.data
+
+ setTimeout(() => {
+ if (!this.messagesBlock) return
+
+ const element = this.messagesBlock.nativeElement as HTMLElement
+ element.scrollIntoView({ block: 'end', inline: 'nearest' })
+ })
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+}
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { RestExtractor, RestPagination, RestService } from '@app/core'
-import { Abuse, AbuseCreate, AbuseFilter, AbusePredefinedReasonsString, AbuseState, AbuseUpdate, ResultList } from '@shared/models'
+import { AdminAbuse, AbuseCreate, AbuseFilter, AbusePredefinedReasonsString, AbuseState, AbuseUpdate, ResultList, UserAbuse, AbuseMessage } from '@shared/models'
import { environment } from '../../../environments/environment'
import { I18n } from '@ngx-translate/i18n-polyfill'
private restExtractor: RestExtractor
) { }
- getAbuses (options: {
+ getAdminAbuses (options: {
pagination: RestPagination,
sort: SortMeta,
search?: string
- }): Observable<ResultList<Abuse>> {
+ }): Observable<ResultList<AdminAbuse>> {
const { pagination, sort, search } = options
const url = AbuseService.BASE_ABUSE_URL
params = this.restService.addObjectParams(params, filters)
}
- return this.authHttp.get<ResultList<Abuse>>(url, { params })
+ return this.authHttp.get<ResultList<AdminAbuse>>(url, { params })
.pipe(
catchError(res => this.restExtractor.handleError(res))
)
)
}
- updateAbuse (abuse: Abuse, abuseUpdate: AbuseUpdate) {
+ updateAbuse (abuse: AdminAbuse, abuseUpdate: AbuseUpdate) {
const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
return this.authHttp.put(url, abuseUpdate)
)
}
- removeAbuse (abuse: Abuse) {
+ removeAbuse (abuse: AdminAbuse) {
const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
return this.authHttp.delete(url)
)
}
+ addAbuseMessage (abuse: UserAbuse, message: string) {
+ const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id + '/messages'
+
+ return this.authHttp.post(url, { message })
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ listAbuseMessages (abuse: UserAbuse) {
+ const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id + '/messages'
+
+ return this.authHttp.get<ResultList<AbuseMessage>>(url)
+ .pipe(
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ deleteAbuseMessage (abuse: UserAbuse, abuseMessage: AbuseMessage) {
+ const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id + '/messages/' + abuseMessage.id
+
+ return this.authHttp.delete(url)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
getPrefefinedReasons (type: AbuseFilter) {
let reasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [
{
export * from './report-modals'
+export * from './abuse-message-modal.component'
export * from './abuse.service'
export * from './account-block.model'
export * from './account-blocklist.component'
import { SharedGlobalIconModule } from '../shared-icons'
import { SharedMainModule } from '../shared-main/shared-main.module'
import { SharedVideoCommentModule } from '../shared-video-comment'
+import { AbuseMessageModalComponent } from './abuse-message-modal.component'
import { AbuseService } from './abuse.service'
import { BatchDomainsModalComponent } from './batch-domains-modal.component'
import { BlocklistService } from './blocklist.service'
import { BulkService } from './bulk.service'
+import { AccountReportComponent, CommentReportComponent, VideoReportComponent } from './report-modals'
import { UserBanModalComponent } from './user-ban-modal.component'
import { UserModerationDropdownComponent } from './user-moderation-dropdown.component'
import { VideoBlockComponent } from './video-block.component'
import { VideoBlockService } from './video-block.service'
-import { VideoReportComponent, AccountReportComponent, CommentReportComponent } from './report-modals'
@NgModule({
imports: [
VideoReportComponent,
BatchDomainsModalComponent,
CommentReportComponent,
- AccountReportComponent
+ AccountReportComponent,
+ AbuseMessageModalComponent
],
exports: [
VideoReportComponent,
BatchDomainsModalComponent,
CommentReportComponent,
- AccountReportComponent
+ AccountReportComponent,
+ AbuseMessageModalComponent
],
providers: [
--- /dev/null
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-message-circle"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path></svg>
\ No newline at end of file
--secondaryColor: #{$secondary-color};
--greyForegroundColor: #{$grey-foreground-color};
+ --greyBackgroundColor: #{$grey-background-color};
--menuBackgroundColor: #{$menu-background};
--menuForegroundColor: #{$menu-color};
--secondaryColor: var(--secondaryColor),
--greyForegroundColor: var(--greyForegroundColor),
+ --greyBackgroundColor: var(--greyBackgroundColor),
--menuBackgroundColor: var(--menuBackgroundColor),
--menuForegroundColor: var(--menuForegroundColor),
return {
id: this.id,
+ createdAt: this.createdAt,
+
byModerator: this.byModerator,
message: this.message,
id: number
message: string
byModerator: boolean
+ createdAt: Date | string
account: AccountSummary
}