1
0
Fork 0

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:
kontrollanten 2025-01-28 14:44:47 +01:00 committed by GitHub
parent 5d968ce325
commit 74b5096a64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 356 additions and 146 deletions

View file

@ -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 () {

View file

@ -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) {

View file

@ -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 () {

View file

@ -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,

View file

@ -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>[] {

View file

@ -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 () {

View file

@ -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[]) {

View file

@ -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,

View file

@ -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
}

View file

@ -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

View 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>

View file

@ -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

View file

@ -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'

View file

@ -9,4 +9,7 @@ export type PluginClientScope =
'admin-plugin' |
'my-library' |
'video-channel' |
'my-account'
'my-account' |
'admin-users' |
'admin-comments' |
'moderation'

View file

@ -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