Plugin support for sophisticated spam handling (#6692)
* feat(plugins): add filter:admin-user-list.bulk-actions.create.result * feat(plugins): add filter:admin-user-moderation.actions.create.result * feat(plugins): add filter:admin-comment-list.actions.create.result * feat(plugin): add filter:admin-comment-list.bulk-actions.create.result * feat(plugin): add filter:admin-abuse-list.actions.create.result * feat(plugins): add doAction increment/decrement loader Support for plugins to show application loader. * feat(plugins): add doAction admin-user-list:load-data * feat(plugins): add doAction admin-video-comment-list:load-data * feat(plugins): add doAction admin-abuse-list:load-data * feat(plugins): add doAction video-watch-comment-list:load-data * cleanup and bug fixes * fix(abuse-list-table): cleanup plugin action * fixes after review * UserListComponent: remove shortCacheObservable * fix lint issues * rename to admin-users-list:load-data In order keep consistency with filter:admin-users-list.bulk-actions.create.result * update plugin documentation * move plugin actions to client-action.model.ts * Styling --------- Co-authored-by: Chocobozzz <me@florianbigard.com>
This commit is contained in:
parent
5d968ce325
commit
74b5096a64
15 changed files with 356 additions and 146 deletions
|
@ -1,8 +1,17 @@
|
|||
import { NgClass, NgIf } from '@angular/common'
|
||||
import { Component, OnInit, ViewChild } from '@angular/core'
|
||||
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router'
|
||||
import { AuthService, ConfirmService, LocalStorageService, Notifier, RestPagination, RestTable } from '@app/core'
|
||||
import {
|
||||
AuthService,
|
||||
ConfirmService,
|
||||
HooksService,
|
||||
LocalStorageService,
|
||||
Notifier,
|
||||
PluginService,
|
||||
RestPagination,
|
||||
RestTable
|
||||
} from '@app/core'
|
||||
import { formatICU, getAPIHost } from '@app/helpers'
|
||||
import { Actor } from '@app/shared/shared-main/account/actor.model'
|
||||
import { PTDatePipe } from '@app/shared/shared-main/common/date.pipe'
|
||||
|
@ -15,6 +24,7 @@ import { User, UserRole, UserRoleType } from '@peertube/peertube-models'
|
|||
import { logger } from '@root-helpers/logger'
|
||||
import { SharedModule, SortMeta } from 'primeng/api'
|
||||
import { TableModule } from 'primeng/table'
|
||||
import { lastValueFrom } from 'rxjs'
|
||||
import { ActorAvatarComponent } from '../../../../shared/shared-actor-image/actor-avatar.component'
|
||||
import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../../../../shared/shared-forms/advanced-input-filter.component'
|
||||
import { PeertubeCheckboxComponent } from '../../../../shared/shared-forms/peertube-checkbox.component'
|
||||
|
@ -70,7 +80,7 @@ type UserForList = User & {
|
|||
ProgressBarComponent
|
||||
]
|
||||
})
|
||||
export class UserListComponent extends RestTable <User> implements OnInit {
|
||||
export class UserListComponent extends RestTable <User> implements OnInit, OnDestroy {
|
||||
private static readonly LS_SELECTED_COLUMNS_KEY = 'admin-user-list-selected-columns'
|
||||
|
||||
@ViewChild('userBanModal', { static: true }) userBanModal: UserBanModalComponent
|
||||
|
@ -114,7 +124,9 @@ export class UserListComponent extends RestTable <User> implements OnInit {
|
|||
private auth: AuthService,
|
||||
private blocklist: BlocklistService,
|
||||
private userAdminService: UserAdminService,
|
||||
private peertubeLocalStorage: LocalStorageService
|
||||
private peertubeLocalStorage: LocalStorageService,
|
||||
private hooks: HooksService,
|
||||
private pluginService: PluginService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
@ -133,10 +145,12 @@ export class UserListComponent extends RestTable <User> implements OnInit {
|
|||
this.saveSelectedColumns()
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
async ngOnInit () {
|
||||
this.initialize()
|
||||
|
||||
this.bulkActions = [
|
||||
this.pluginService.addAction('admin-users-list:load-data', () => this.reloadDataInternal())
|
||||
|
||||
const bulkActions: DropdownAction<User[]>[][] = [
|
||||
[
|
||||
{
|
||||
label: $localize`Delete`,
|
||||
|
@ -167,6 +181,8 @@ export class UserListComponent extends RestTable <User> implements OnInit {
|
|||
]
|
||||
]
|
||||
|
||||
this.bulkActions = await this.hooks.wrapObject(bulkActions, 'admin-users', 'filter:admin-users-list.bulk-actions.create.result')
|
||||
|
||||
this.columns = [
|
||||
{ id: 'username', label: $localize`Username` },
|
||||
{ id: 'role', label: $localize`Role` },
|
||||
|
@ -183,6 +199,10 @@ export class UserListComponent extends RestTable <User> implements OnInit {
|
|||
this.loadSelectedColumns()
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
this.pluginService.removeAction('admin-users-list:load-data')
|
||||
}
|
||||
|
||||
loadSelectedColumns () {
|
||||
const result = this.peertubeLocalStorage.getItem(UserListComponent.LS_SELECTED_COLUMNS_KEY)
|
||||
|
||||
|
@ -325,34 +345,36 @@ export class UserListComponent extends RestTable <User> implements OnInit {
|
|||
})
|
||||
}
|
||||
|
||||
protected reloadDataInternal () {
|
||||
this.userAdminService.getUsers({
|
||||
protected async reloadDataInternal () {
|
||||
const obs = this.userAdminService.getUsers({
|
||||
pagination: this.pagination,
|
||||
sort: this.sort,
|
||||
search: this.search
|
||||
}).subscribe({
|
||||
next: resultList => {
|
||||
this.users = resultList.data.map(u => ({
|
||||
...u,
|
||||
|
||||
accountMutedStatus: {
|
||||
...u.account,
|
||||
|
||||
nameWithHost: Actor.CREATE_BY_STRING(u.account.name, u.account.host),
|
||||
|
||||
mutedByInstance: false,
|
||||
mutedByUser: false,
|
||||
mutedServerByInstance: false,
|
||||
mutedServerByUser: false
|
||||
}
|
||||
}))
|
||||
this.totalRecords = resultList.total
|
||||
|
||||
this.loadMutedStatus()
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
|
||||
try {
|
||||
const resultList = await lastValueFrom(obs)
|
||||
|
||||
this.users = resultList.data.map(u => ({
|
||||
...u,
|
||||
|
||||
accountMutedStatus: {
|
||||
...u.account,
|
||||
|
||||
nameWithHost: Actor.CREATE_BY_STRING(u.account.name, u.account.host),
|
||||
|
||||
mutedByInstance: false,
|
||||
mutedByUser: false,
|
||||
mutedServerByInstance: false,
|
||||
mutedServerByUser: false
|
||||
}
|
||||
}))
|
||||
this.totalRecords = resultList.total
|
||||
|
||||
this.loadMutedStatus()
|
||||
} catch (err) {
|
||||
this.notifier.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
private loadMutedStatus () {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { NgFor, NgIf } from '@angular/common'
|
||||
import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { AuthService, ComponentPagination, ConfirmService, Notifier, User, hasMoreItems } from '@app/core'
|
||||
import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, PluginService, User } from '@app/core'
|
||||
import { HooksService } from '@app/core/plugins/hooks.service'
|
||||
import { Syndication } from '@app/shared/shared-main/feeds/syndication.model'
|
||||
import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
|
||||
|
@ -10,10 +10,10 @@ import { VideoComment } from '@app/shared/shared-video-comment/video-comment.mod
|
|||
import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
|
||||
import { NgbDropdown, NgbDropdownButtonItem, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { PeerTubeProblemDocument, ServerErrorCode, VideoCommentPolicy } from '@peertube/peertube-models'
|
||||
import { Subject, Subscription } from 'rxjs'
|
||||
import { lastValueFrom, Subject, Subscription } from 'rxjs'
|
||||
import { InfiniteScrollerDirective } from '../../../../shared/shared-main/common/infinite-scroller.directive'
|
||||
import { FeedComponent } from '../../../../shared/shared-main/feeds/feed.component'
|
||||
import { LoaderComponent } from '../../../../shared/shared-main/common/loader.component'
|
||||
import { FeedComponent } from '../../../../shared/shared-main/feeds/feed.component'
|
||||
import { VideoCommentAddComponent } from './video-comment-add.component'
|
||||
import { VideoCommentComponent } from './video-comment.component'
|
||||
|
||||
|
@ -78,10 +78,13 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
|
|||
private confirmService: ConfirmService,
|
||||
private videoCommentService: VideoCommentService,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private hooks: HooksService
|
||||
private hooks: HooksService,
|
||||
private pluginService: PluginService
|
||||
) {}
|
||||
|
||||
ngOnInit () {
|
||||
this.pluginService.addAction('video-watch-comment-list:load-data', () => this.loadMoreThreads(true))
|
||||
|
||||
// Find highlighted comment in params
|
||||
this.sub = this.activatedRoute.params.subscribe(
|
||||
params => {
|
||||
|
@ -100,6 +103,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
|
|||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
this.pluginService.removeAction('video-watch-comment-list:load-data')
|
||||
|
||||
if (this.sub) this.sub.unsubscribe()
|
||||
}
|
||||
|
||||
|
@ -145,7 +150,11 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
|
|||
})
|
||||
}
|
||||
|
||||
loadMoreThreads () {
|
||||
async loadMoreThreads (reset = false) {
|
||||
if (reset === true) {
|
||||
this.componentPagination.currentPage = 1
|
||||
}
|
||||
|
||||
const params = {
|
||||
videoId: this.video.uuid,
|
||||
videoPassword: this.videoPassword,
|
||||
|
@ -161,19 +170,20 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
|
|||
'filter:api.video-watch.video-threads.list.result'
|
||||
)
|
||||
|
||||
obs.subscribe({
|
||||
next: res => {
|
||||
this.comments = this.comments.concat(res.data)
|
||||
this.componentPagination.totalItems = res.total
|
||||
this.totalNotDeletedComments = res.totalNotDeletedComments
|
||||
try {
|
||||
const res = await lastValueFrom(obs)
|
||||
|
||||
this.onDataSubject.next(res.data)
|
||||
if (reset) this.comments = []
|
||||
this.comments = this.comments.concat(res.data)
|
||||
this.componentPagination.totalItems = res.total
|
||||
this.totalNotDeletedComments = res.totalNotDeletedComments
|
||||
|
||||
this.hooks.runAction('action:video-watch.video-threads.loaded', 'video-watch', { data: this.componentPagination })
|
||||
},
|
||||
this.onDataSubject.next(res.data)
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
this.hooks.runAction('action:video-watch.video-threads.loaded', 'video-watch', { data: this.componentPagination })
|
||||
} catch (err) {
|
||||
this.notifier.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
onCommentThreadCreated (comment: VideoComment) {
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { forkJoin } from 'rxjs'
|
||||
import { filter, first, map } from 'rxjs/operators'
|
||||
import { DOCUMENT, getLocaleDirection, NgClass, NgIf, PlatformLocation } from '@angular/common'
|
||||
import { AfterViewInit, Component, Inject, LOCALE_ID, OnInit, ViewChild } from '@angular/core'
|
||||
import { AfterViewInit, Component, Inject, LOCALE_ID, OnDestroy, OnInit, ViewChild } from '@angular/core'
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
|
||||
import { Event, GuardsCheckStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router, RouterLink, RouterOutlet } from '@angular/router'
|
||||
import {
|
||||
|
@ -28,8 +30,6 @@ import { logger } from '@root-helpers/logger'
|
|||
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
|
||||
import { SharedModule } from 'primeng/api'
|
||||
import { ToastModule } from 'primeng/toast'
|
||||
import { forkJoin } from 'rxjs'
|
||||
import { filter, first, map } from 'rxjs/operators'
|
||||
import { MenuService } from './core/menu/menu.service'
|
||||
import { HeaderComponent } from './header/header.component'
|
||||
import { POP_STATE_MODAL_DISMISS } from './helpers'
|
||||
|
@ -65,7 +65,7 @@ import { InstanceService } from './shared/shared-main/instance/instance.service'
|
|||
ButtonComponent
|
||||
]
|
||||
})
|
||||
export class AppComponent implements OnInit, AfterViewInit {
|
||||
export class AppComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private static LS_BROADCAST_MESSAGE = 'app-broadcast-message-dismissed'
|
||||
|
||||
@ViewChild('accountSetupWarningModal') accountSetupWarningModal: AccountSetupWarningModalComponent
|
||||
|
@ -146,12 +146,28 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||
|
||||
this.document.documentElement.lang = getShortLocale(this.localeId)
|
||||
this.document.documentElement.dir = getLocaleDirection(this.localeId)
|
||||
|
||||
this.pluginService.addAction('application:increment-loader', () => {
|
||||
this.loadingBar.useRef('plugins').start()
|
||||
|
||||
return Promise.resolve()
|
||||
})
|
||||
this.pluginService.addAction('application:decrement-loader', () => {
|
||||
this.loadingBar.useRef('plugins').complete()
|
||||
|
||||
return Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
ngAfterViewInit () {
|
||||
this.pluginService.initializeCustomModal(this.customModal)
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
this.pluginService.removeAction('application:increment-loader')
|
||||
this.pluginService.removeAction('application:decrement-loader')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
isUserLoggedIn () {
|
||||
|
|
|
@ -12,6 +12,8 @@ import { getDevLocale, isOnDevLocale } from '@app/helpers'
|
|||
import { CustomModalComponent } from '@app/modal/custom-modal.component'
|
||||
import { getCompleteLocale, getKeys, isDefaultLocale, peertubeTranslate } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
ClientDoActionCallback,
|
||||
ClientDoActionName,
|
||||
ClientHook,
|
||||
ClientHookName,
|
||||
PluginClientScope,
|
||||
|
@ -28,6 +30,7 @@ import {
|
|||
import { PluginInfo, PluginsManager } from '@root-helpers/plugins-manager'
|
||||
import { environment } from '../../../environments/environment'
|
||||
import { RegisterClientHelpers } from '../../../types/register-client-option.model'
|
||||
import { logger } from '@root-helpers/logger'
|
||||
|
||||
type FormFields = {
|
||||
video: {
|
||||
|
@ -58,6 +61,8 @@ export class PluginService implements ClientHook {
|
|||
|
||||
private pluginsManager: PluginsManager
|
||||
|
||||
private actions = new Map<ClientDoActionName, ClientDoActionCallback>()
|
||||
|
||||
constructor (
|
||||
private authService: AuthService,
|
||||
private notifier: Notifier,
|
||||
|
@ -71,6 +76,7 @@ export class PluginService implements ClientHook {
|
|||
this.loadTranslations()
|
||||
|
||||
this.pluginsManager = new PluginsManager({
|
||||
doAction: this.doAction.bind(this),
|
||||
peertubeHelpersFactory: this.buildPeerTubeHelpers.bind(this),
|
||||
onFormFields: this.onFormFields.bind(this),
|
||||
onSettingsScripts: this.onSettingsScripts.bind(this),
|
||||
|
@ -78,6 +84,14 @@ export class PluginService implements ClientHook {
|
|||
})
|
||||
}
|
||||
|
||||
addAction (actionName: ClientDoActionName, callback: ClientDoActionCallback) {
|
||||
this.actions.set(actionName, callback)
|
||||
}
|
||||
|
||||
removeAction (actionName: ClientDoActionName) {
|
||||
this.actions.delete(actionName)
|
||||
}
|
||||
|
||||
initializePlugins () {
|
||||
this.pluginsManager.loadPluginsList(this.server.getHTMLConfig())
|
||||
|
||||
|
@ -184,6 +198,18 @@ export class PluginService implements ClientHook {
|
|||
return firstValueFrom(obs)
|
||||
}
|
||||
|
||||
private doAction (actionName: ClientDoActionName) {
|
||||
if (!this.actions.has(actionName)) {
|
||||
logger.warn(`Plugin tried to do unknown action: ${actionName}`)
|
||||
}
|
||||
|
||||
try {
|
||||
return this.actions.get(actionName)()
|
||||
} catch (err: any) {
|
||||
logger.warn(`Cannot run action ${actionName}`, err)
|
||||
}
|
||||
}
|
||||
|
||||
private onFormFields (
|
||||
pluginInfo: PluginInfo,
|
||||
commonOptions: RegisterClientFormFieldOptions,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { NgClass, NgIf } from '@angular/common'
|
||||
import { Component, Input, OnInit, ViewChild } from '@angular/core'
|
||||
import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
|
||||
import { ConfirmService, HooksService, MarkdownService, Notifier, PluginService, RestPagination, RestTable } from '@app/core'
|
||||
import { formatICU } from '@app/helpers'
|
||||
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { AbuseState, AbuseStateType, AdminAbuse } from '@peertube/peertube-models'
|
||||
|
@ -9,6 +9,7 @@ import { logger } from '@root-helpers/logger'
|
|||
import debug from 'debug'
|
||||
import { SharedModule, SortMeta } from 'primeng/api'
|
||||
import { TableModule } from 'primeng/table'
|
||||
import { lastValueFrom } from 'rxjs'
|
||||
import { ActorAvatarComponent } from '../shared-actor-image/actor-avatar.component'
|
||||
import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../shared-forms/advanced-input-filter.component'
|
||||
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
|
||||
|
@ -56,7 +57,7 @@ const debugLogger = debug('peertube:moderation:AbuseListTableComponent')
|
|||
PTDatePipe
|
||||
]
|
||||
})
|
||||
export class AbuseListTableComponent extends RestTable implements OnInit {
|
||||
export class AbuseListTableComponent extends RestTable implements OnInit, OnDestroy {
|
||||
@Input() viewType: 'admin' | 'user'
|
||||
|
||||
@ViewChild('abuseMessagesModal', { static: true }) abuseMessagesModal: AbuseMessageModalComponent
|
||||
|
@ -107,13 +108,19 @@ export class AbuseListTableComponent extends RestTable implements OnInit {
|
|||
private videoService: VideoService,
|
||||
private videoBlocklistService: VideoBlockService,
|
||||
private confirmService: ConfirmService,
|
||||
private markdownRenderer: MarkdownService
|
||||
private markdownRenderer: MarkdownService,
|
||||
private hooks: HooksService,
|
||||
private pluginService: PluginService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.abuseActions = [
|
||||
async ngOnInit () {
|
||||
if (this.viewType === 'admin') {
|
||||
this.pluginService.addAction('admin-abuse-list:load-data', () => this.reloadDataInternal())
|
||||
}
|
||||
|
||||
const abuseActions: DropdownAction<ProcessedAbuse>[][] = [
|
||||
this.buildInternalActions(),
|
||||
|
||||
this.buildFlaggedAccountActions(),
|
||||
|
@ -125,9 +132,19 @@ export class AbuseListTableComponent extends RestTable implements OnInit {
|
|||
this.buildAccountActions()
|
||||
]
|
||||
|
||||
this.abuseActions = this.viewType === 'admin'
|
||||
? await this.hooks.wrapObject(abuseActions, 'admin-comments', 'filter:admin-abuse-list.actions.create.result')
|
||||
: abuseActions
|
||||
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
if (this.viewType === 'admin') {
|
||||
this.pluginService.removeAction('admin-abuse-list:load-data')
|
||||
}
|
||||
}
|
||||
|
||||
isAdminView () {
|
||||
return this.viewType === 'admin'
|
||||
}
|
||||
|
@ -224,7 +241,7 @@ export class AbuseListTableComponent extends RestTable implements OnInit {
|
|||
)
|
||||
}
|
||||
|
||||
protected reloadDataInternal () {
|
||||
protected async reloadDataInternal () {
|
||||
debugLogger('Loading data.')
|
||||
|
||||
const options = {
|
||||
|
@ -237,51 +254,51 @@ export class AbuseListTableComponent extends RestTable implements OnInit {
|
|||
? this.abuseService.getAdminAbuses(options)
|
||||
: this.abuseService.getUserAbuses(options)
|
||||
|
||||
return observable.subscribe({
|
||||
next: async resultList => {
|
||||
this.totalRecords = resultList.total
|
||||
try {
|
||||
const resultList = await lastValueFrom(observable)
|
||||
|
||||
this.abuses = []
|
||||
this.totalRecords = resultList.total
|
||||
|
||||
for (const a of resultList.data) {
|
||||
const abuse = a as ProcessedAbuse
|
||||
this.abuses = []
|
||||
|
||||
abuse.reasonHtml = await this.toHtml(abuse.reason)
|
||||
for (const a of resultList.data) {
|
||||
const abuse = a as ProcessedAbuse
|
||||
|
||||
if (abuse.moderationComment) {
|
||||
abuse.moderationCommentHtml = await this.toHtml(abuse.moderationComment)
|
||||
}
|
||||
abuse.reasonHtml = await this.toHtml(abuse.reason)
|
||||
|
||||
if (abuse.video) {
|
||||
if (abuse.video.channel?.ownerAccount) {
|
||||
abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount)
|
||||
}
|
||||
}
|
||||
|
||||
if (abuse.comment) {
|
||||
if (abuse.comment.deleted) {
|
||||
abuse.commentHTML = $localize`Deleted comment`
|
||||
} else {
|
||||
abuse.commentHTML = await this.markdownRenderer.textMarkdownToHTML({ markdown: abuse.comment.text, withHtml: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (abuse.reporterAccount) {
|
||||
abuse.reporterAccount = new Account(abuse.reporterAccount)
|
||||
}
|
||||
|
||||
if (abuse.flaggedAccount) {
|
||||
abuse.flaggedAccount = new Account(abuse.flaggedAccount)
|
||||
}
|
||||
|
||||
if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt
|
||||
|
||||
this.abuses.push(abuse)
|
||||
if (abuse.moderationComment) {
|
||||
abuse.moderationCommentHtml = await this.toHtml(abuse.moderationComment)
|
||||
}
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
if (abuse.video) {
|
||||
if (abuse.video.channel?.ownerAccount) {
|
||||
abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount)
|
||||
}
|
||||
}
|
||||
|
||||
if (abuse.comment) {
|
||||
if (abuse.comment.deleted) {
|
||||
abuse.commentHTML = $localize`Deleted comment`
|
||||
} else {
|
||||
abuse.commentHTML = await this.markdownRenderer.textMarkdownToHTML({ markdown: abuse.comment.text, withHtml: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (abuse.reporterAccount) {
|
||||
abuse.reporterAccount = new Account(abuse.reporterAccount)
|
||||
}
|
||||
|
||||
if (abuse.flaggedAccount) {
|
||||
abuse.flaggedAccount = new Account(abuse.flaggedAccount)
|
||||
}
|
||||
|
||||
if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt
|
||||
|
||||
this.abuses.push(abuse)
|
||||
}
|
||||
} catch (err) {
|
||||
this.notifier.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
private buildInternalActions (): DropdownAction<ProcessedAbuse>[] {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core'
|
||||
import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core'
|
||||
import { AuthService, ConfirmService, HooksService, Notifier, PluginService, ServerService } from '@app/core'
|
||||
import { BulkRemoveCommentsOfBody, User, UserRight } from '@peertube/peertube-models'
|
||||
import { BlocklistService } from './blocklist.service'
|
||||
import { BulkService } from './bulk.service'
|
||||
|
@ -58,7 +58,9 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
|
|||
private serverService: ServerService,
|
||||
private userAdminService: UserAdminService,
|
||||
private blocklistService: BlocklistService,
|
||||
private bulkService: BulkService
|
||||
private bulkService: BulkService,
|
||||
private hooks: HooksService,
|
||||
private pluginService: PluginService
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
|
@ -271,20 +273,20 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
|
|||
return account && this.authService.getUser().account.id === account.id
|
||||
}
|
||||
|
||||
private buildActions () {
|
||||
this.userActions = []
|
||||
private async buildActions () {
|
||||
const userActions: DropdownAction<{ user: User, account: AccountMutedStatus }>[][] = []
|
||||
|
||||
if (this.prependActions && this.prependActions.length !== 0) {
|
||||
this.userActions = [
|
||||
this.prependActions
|
||||
]
|
||||
userActions.push(this.prependActions)
|
||||
}
|
||||
|
||||
const myAccountModerationActions = this.buildMyAccountModerationActions()
|
||||
const instanceModerationActions = this.buildInstanceModerationActions()
|
||||
|
||||
if (myAccountModerationActions.length !== 0) this.userActions.push(myAccountModerationActions)
|
||||
if (instanceModerationActions.length !== 0) this.userActions.push(instanceModerationActions)
|
||||
if (myAccountModerationActions.length !== 0) userActions.push(myAccountModerationActions)
|
||||
if (instanceModerationActions.length !== 0) userActions.push(instanceModerationActions)
|
||||
|
||||
this.userActions = await this.hooks.wrapObject(userActions, 'moderation', 'filter:user-moderation.actions.create.result')
|
||||
}
|
||||
|
||||
private buildMyAccountModerationActions () {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { NgClass, NgIf } from '@angular/common'
|
||||
import { Component, Input, OnInit } from '@angular/core'
|
||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core'
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router'
|
||||
import { AuthService, ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
|
||||
import { AuthService, ConfirmService, HooksService, MarkdownService, Notifier, PluginService, RestPagination, RestTable } from '@app/core'
|
||||
import { formatICU } from '@app/helpers'
|
||||
import { BulkService } from '@app/shared/shared-moderation/bulk.service'
|
||||
import { VideoCommentForAdminOrUser } from '@app/shared/shared-video-comment/video-comment.model'
|
||||
|
@ -10,6 +10,7 @@ import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
|
|||
import { UserRight } from '@peertube/peertube-models'
|
||||
import { SharedModule, SortMeta } from 'primeng/api'
|
||||
import { TableModule } from 'primeng/table'
|
||||
import { lastValueFrom } from 'rxjs'
|
||||
import { ActorAvatarComponent } from '../shared-actor-image/actor-avatar.component'
|
||||
import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../shared-forms/advanced-input-filter.component'
|
||||
import { ActionDropdownComponent, DropdownAction } from '../shared-main/buttons/action-dropdown.component'
|
||||
|
@ -39,7 +40,7 @@ import { TableExpanderIconComponent } from '../shared-tables/table-expander-icon
|
|||
RouterLink
|
||||
]
|
||||
})
|
||||
export class VideoCommentListAdminOwnerComponent extends RestTable <VideoCommentForAdminOrUser> implements OnInit {
|
||||
export class VideoCommentListAdminOwnerComponent extends RestTable <VideoCommentForAdminOrUser> implements OnInit, OnDestroy {
|
||||
@Input({ required: true }) mode: 'user' | 'admin'
|
||||
|
||||
comments: VideoCommentForAdminOrUser[]
|
||||
|
@ -65,11 +66,34 @@ export class VideoCommentListAdminOwnerComponent extends RestTable <VideoComment
|
|||
private confirmService: ConfirmService,
|
||||
private videoCommentService: VideoCommentService,
|
||||
private markdownRenderer: MarkdownService,
|
||||
private bulkService: BulkService
|
||||
private bulkService: BulkService,
|
||||
private hooks: HooksService,
|
||||
private pluginService: PluginService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
this.videoCommentActions = [
|
||||
async ngOnInit () {
|
||||
this.initialize()
|
||||
|
||||
if (this.mode === 'admin') {
|
||||
this.pluginService.addAction('admin-video-comment-list:load-data', () => this.reloadDataInternal())
|
||||
}
|
||||
|
||||
this.buildInputFilters()
|
||||
|
||||
await this.buildCommentActions()
|
||||
await this.buildBulkActions()
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
if (this.mode === 'admin') {
|
||||
this.pluginService.removeAction('admin-video-comment-list:load-data')
|
||||
}
|
||||
}
|
||||
|
||||
private async buildCommentActions () {
|
||||
const videoCommentActions: DropdownAction<VideoCommentForAdminOrUser>[][] = [
|
||||
[
|
||||
{
|
||||
label: $localize`Delete this comment`,
|
||||
|
@ -91,12 +115,14 @@ export class VideoCommentListAdminOwnerComponent extends RestTable <VideoComment
|
|||
}
|
||||
]
|
||||
]
|
||||
|
||||
this.videoCommentActions = this.mode === 'admin'
|
||||
? await this.hooks.wrapObject(videoCommentActions, 'admin-comments', 'filter:admin-video-comments-list.actions.create.result')
|
||||
: videoCommentActions
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.initialize()
|
||||
|
||||
this.bulkActions = [
|
||||
private async buildBulkActions () {
|
||||
const bulkActions: DropdownAction<VideoCommentForAdminOrUser[]>[] = [
|
||||
{
|
||||
label: $localize`Delete`,
|
||||
handler: comments => this.removeComments(comments),
|
||||
|
@ -111,6 +137,12 @@ export class VideoCommentListAdminOwnerComponent extends RestTable <VideoComment
|
|||
}
|
||||
]
|
||||
|
||||
this.bulkActions = this.mode === 'admin'
|
||||
? await this.hooks.wrapObject(bulkActions, 'admin-comments', 'filter:admin-video-comments-list.bulk-actions.create.result')
|
||||
: bulkActions
|
||||
}
|
||||
|
||||
private buildInputFilters () {
|
||||
if (this.mode === 'admin') {
|
||||
this.inputFilters = [
|
||||
{
|
||||
|
@ -131,19 +163,21 @@ export class VideoCommentListAdminOwnerComponent extends RestTable <VideoComment
|
|||
]
|
||||
}
|
||||
]
|
||||
} else {
|
||||
this.inputFilters = [
|
||||
{
|
||||
title: $localize`Advanced filters`,
|
||||
children: [
|
||||
{
|
||||
value: 'heldForReview:true',
|
||||
label: $localize`Display comments awaiting your approval`
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.inputFilters = [
|
||||
{
|
||||
title: $localize`Advanced filters`,
|
||||
children: [
|
||||
{
|
||||
value: 'heldForReview:true',
|
||||
label: $localize`Display comments awaiting your approval`
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
getIdentifier () {
|
||||
|
@ -162,24 +196,26 @@ export class VideoCommentListAdminOwnerComponent extends RestTable <VideoComment
|
|||
return str
|
||||
}
|
||||
|
||||
protected reloadDataInternal () {
|
||||
protected async reloadDataInternal () {
|
||||
const method = this.mode === 'admin'
|
||||
? this.videoCommentService.listAdminVideoComments.bind(this.videoCommentService)
|
||||
: this.videoCommentService.listVideoCommentsOfMyVideos.bind(this.videoCommentService)
|
||||
|
||||
method({ pagination: this.pagination, sort: this.sort, search: this.search }).subscribe({
|
||||
next: async resultList => {
|
||||
this.totalRecords = resultList.total
|
||||
const obs = method({ pagination: this.pagination, sort: this.sort, search: this.search })
|
||||
|
||||
this.comments = []
|
||||
try {
|
||||
const resultList = await lastValueFrom(obs)
|
||||
|
||||
for (const c of resultList.data) {
|
||||
this.comments.push(new VideoCommentForAdminOrUser(c, await this.toHtml(c.text)))
|
||||
}
|
||||
},
|
||||
this.totalRecords = resultList.total
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
this.comments = []
|
||||
|
||||
for (const c of resultList.data) {
|
||||
this.comments.push(new VideoCommentForAdminOrUser(c, await this.toHtml(c.text)))
|
||||
}
|
||||
} catch (err) {
|
||||
this.notifier.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
private approveComments (comments: VideoCommentForAdminOrUser[]) {
|
||||
|
|
|
@ -5,6 +5,7 @@ import { first, shareReplay } from 'rxjs/operators'
|
|||
import { RegisterClientHelpers } from 'src/types/register-client-option.model'
|
||||
import { getExternalAuthHref, getHookType, internalRunHook } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
ClientDoAction,
|
||||
ClientHookName,
|
||||
clientHookObject,
|
||||
ClientScriptJSON,
|
||||
|
@ -71,20 +72,26 @@ class PluginsManager {
|
|||
'embed': new ReplaySubject<boolean>(1),
|
||||
'my-library': new ReplaySubject<boolean>(1),
|
||||
'video-channel': new ReplaySubject<boolean>(1),
|
||||
'my-account': new ReplaySubject<boolean>(1)
|
||||
'my-account': new ReplaySubject<boolean>(1),
|
||||
'admin-users': new ReplaySubject<boolean>(1),
|
||||
'admin-comments': new ReplaySubject<boolean>(1),
|
||||
'moderation': new ReplaySubject<boolean>(1)
|
||||
}
|
||||
|
||||
private readonly doAction: ClientDoAction
|
||||
private readonly peertubeHelpersFactory: PeertubeHelpersFactory
|
||||
private readonly onFormFields: OnFormFields
|
||||
private readonly onSettingsScripts: OnSettingsScripts
|
||||
private readonly onClientRoute: OnClientRoute
|
||||
|
||||
constructor (options: {
|
||||
doAction?: ClientDoAction
|
||||
peertubeHelpersFactory: PeertubeHelpersFactory
|
||||
onFormFields?: OnFormFields
|
||||
onSettingsScripts?: OnSettingsScripts
|
||||
onClientRoute?: OnClientRoute
|
||||
}) {
|
||||
this.doAction = options.doAction
|
||||
this.peertubeHelpersFactory = options.peertubeHelpersFactory
|
||||
this.onFormFields = options.onFormFields
|
||||
this.onSettingsScripts = options.onSettingsScripts
|
||||
|
@ -268,6 +275,8 @@ class PluginsManager {
|
|||
return this.onClientRoute(options)
|
||||
}
|
||||
|
||||
const doAction = this.doAction
|
||||
|
||||
const peertubeHelpers = this.peertubeHelpersFactory(pluginInfo)
|
||||
|
||||
logger.info(`Loading script ${clientScript.script} of plugin ${plugin.name}`)
|
||||
|
@ -276,6 +285,7 @@ class PluginsManager {
|
|||
return dynamicImport(absURL)
|
||||
.then((script: ClientScript) => {
|
||||
return script.register({
|
||||
doAction,
|
||||
registerHook,
|
||||
registerVideoField,
|
||||
registerSettingsScript,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
function copyToClipboard (text: string, container?: HTMLElement) {
|
||||
export function copyToClipboard (text: string, container?: HTMLElement) {
|
||||
if (!container) container = document.body
|
||||
|
||||
const el = document.createElement('textarea')
|
||||
|
@ -12,13 +12,8 @@ function copyToClipboard (text: string, container?: HTMLElement) {
|
|||
container.removeChild(el)
|
||||
}
|
||||
|
||||
function wait (ms: number) {
|
||||
export function wait (ms: number) {
|
||||
return new Promise<void>(res => {
|
||||
setTimeout(() => res(), ms)
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
copyToClipboard,
|
||||
wait
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
ClientDoAction,
|
||||
MyUser,
|
||||
RegisterClientFormFieldOptions,
|
||||
RegisterClientHookOptions,
|
||||
|
@ -9,6 +10,8 @@ import {
|
|||
} from '@peertube/peertube-models'
|
||||
|
||||
export type RegisterClientOptions = {
|
||||
doAction: ClientDoAction
|
||||
|
||||
registerHook: (options: RegisterClientHookOptions) => void
|
||||
|
||||
registerVideoField: (commonOptions: RegisterClientFormFieldOptions, videoFormOptions: RegisterClientVideoFieldOptions) => void
|
||||
|
|
13
packages/models/src/plugins/client/client-action.model.ts
Normal file
13
packages/models/src/plugins/client/client-action.model.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
export const clientDoActionObject = {
|
||||
'admin-abuse-list:load-data': true,
|
||||
'application:increment-loader': true,
|
||||
'application:decrement-loader': true,
|
||||
'admin-users-list:load-data': true,
|
||||
'admin-video-comment-list:load-data': true,
|
||||
'video-watch-comment-list:load-data': true
|
||||
}
|
||||
|
||||
export type ClientDoActionName = keyof typeof clientDoActionObject
|
||||
|
||||
export type ClientDoActionCallback = () => Promise<any>
|
||||
export type ClientDoAction = (actionName: ClientDoActionName) => Promise<any>
|
|
@ -101,7 +101,22 @@ export const clientFilterHookObject = {
|
|||
'filter:internal.player.videojs.options.result': true,
|
||||
|
||||
// Filter p2p media loader options built for PeerTube player
|
||||
'filter:internal.player.p2p-media-loader.options.result': true
|
||||
'filter:internal.player.p2p-media-loader.options.result': true,
|
||||
|
||||
// Filter bulk actions in user list
|
||||
'filter:admin-users-list.bulk-actions.create.result': true,
|
||||
|
||||
// Filter actions in comment list
|
||||
'filter:admin-video-comments-list.actions.create.result': true,
|
||||
|
||||
// Filter bulk actions in comment list
|
||||
'filter:admin-video-comments-list.bulk-actions.create.result': true,
|
||||
|
||||
// Filter user moderation actions
|
||||
'filter:user-moderation.actions.create.result': true,
|
||||
|
||||
// Filter actions in abuse list
|
||||
'filter:admin-abuse-list.actions.create.result': true
|
||||
}
|
||||
|
||||
export type ClientFilterHookName = keyof typeof clientFilterHookObject
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
export * from './client-hook.model.js'
|
||||
export * from './client-action.model.js'
|
||||
export * from './plugin-client-scope.type.js'
|
||||
export * from './plugin-element-placeholder.type.js'
|
||||
export * from './plugin-selector-id.type.js'
|
||||
|
|
|
@ -9,4 +9,7 @@ export type PluginClientScope =
|
|||
'admin-plugin' |
|
||||
'my-library' |
|
||||
'video-channel' |
|
||||
'my-account'
|
||||
'my-account' |
|
||||
'admin-users' |
|
||||
'admin-comments' |
|
||||
'moderation'
|
||||
|
|
|
@ -33,6 +33,7 @@ Example:
|
|||
|
||||
```js
|
||||
async function register ({
|
||||
doAction,
|
||||
registerHook,
|
||||
|
||||
registerSetting,
|
||||
|
@ -106,6 +107,41 @@ function register ({ registerHook, peertubeHelpers }) {
|
|||
}
|
||||
```
|
||||
|
||||
See the [plugin API reference](https://docs.joinpeertube.org/api/plugins) to see the complete hooks list.
|
||||
|
||||
### Client actions
|
||||
|
||||
Plugin can trigger actions in the client by calling `doAction` with a specific action.
|
||||
This can be used in combination with a hook to add custom admin actions, for instance:
|
||||
|
||||
```
|
||||
function register ({ registerHook, doAction }) {
|
||||
registerHook({
|
||||
target: 'filter:admin-video-comments-list.bulk-actions.create.result',
|
||||
handler: async menuItems => {
|
||||
return menuItems.concat(
|
||||
[
|
||||
{
|
||||
label: 'Mark as spam',
|
||||
description: 'Report as spam and delete user.',
|
||||
handler: async (comments) => {
|
||||
// Show the loader
|
||||
doAction('application:increment-loader')
|
||||
// Run custom function
|
||||
await deleteCommentsAndMarkAsSpam(comments)
|
||||
// Reload the list in order for the admin to see the updated list
|
||||
await doAction('admin-video-comments-list:load-data')
|
||||
},
|
||||
isDisplayed: (users) => true,
|
||||
}
|
||||
])
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
See the [plugin API reference](https://docs.joinpeertube.org/api/plugins) to see the complete `doAction` list.
|
||||
|
||||
### Static files
|
||||
|
||||
Plugins can declare static directories that PeerTube will serve (images for example)
|
||||
|
@ -1167,7 +1203,7 @@ you can deprecate it. The plugin index will automatically remove it preventing u
|
|||
npm deprecate peertube-plugin-xxx@"> 0.0.0" "explain here why you deprecate your plugin/theme"
|
||||
```
|
||||
|
||||
## Plugin & Theme hooks/helpers API
|
||||
## Plugin & Theme hooks/actions/helpers API
|
||||
|
||||
See the dedicated documentation: https://docs.joinpeertube.org/api/plugins
|
||||
|
||||
|
@ -1206,6 +1242,11 @@ If you want to create an antispam/moderation plugin, you could use the following
|
|||
* `filter:api.video-threads.list.result`: to change/hide the text of threads
|
||||
* `filter:api.video-thread-comments.list.result`: to change/hide the text of replies
|
||||
* `filter:video.auto-blacklist.result`: to automatically blacklist local or remote videos
|
||||
* `filter:admin-users-list.bulk-actions.create.result`: to add bulk actions in the admin users list
|
||||
* `filter:admin-video-comments-list.actions.create.result`: to add actions in the admin video comments list
|
||||
* `filter:admin-video-comments-list.bulk-actions.create.result`: to add bulk actions in the admin video comments list
|
||||
* `filter:user-moderation.actions.create.result`: to add actions in the user moderation dropdown (available in multiple views)
|
||||
* `filter:admin-abuse-list.actions.create.result`: to add actions in the admin abuse list
|
||||
|
||||
### Other plugin examples
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue