mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2024-08-17 09:20:26 +03:00
Implement auto tag on comments and videos
* Comments and videos can be automatically tagged using core rules or watched word lists * These tags can be used to automatically filter videos and comments * Introduce a new video comment policy where comments must be approved first * Comments may have to be approved if the user auto block them using core rules or watched word lists * Implement FEP-5624 to federate reply control policies
This commit is contained in:
parent
b3e39df59e
commit
29329d6c45
@ -1,9 +1,9 @@
|
||||
import { Command } from '@commander-js/extra-typings'
|
||||
import { VideoCommentPolicy, VideoPrivacy, VideoPrivacyType } from '@peertube/peertube-models'
|
||||
import { PeerTubeServer } from '@peertube/peertube-server-commands'
|
||||
import { access, constants } from 'fs/promises'
|
||||
import { isAbsolute } from 'path'
|
||||
import { inspect } from 'util'
|
||||
import { Command } from '@commander-js/extra-typings'
|
||||
import { VideoPrivacy } from '@peertube/peertube-models'
|
||||
import { PeerTubeServer } from '@peertube/peertube-server-commands'
|
||||
import { assignToken, buildServer, getServerCredentials, listOptions } from './shared/index.js'
|
||||
|
||||
type UploadOptions = {
|
||||
@ -14,13 +14,13 @@ type UploadOptions = {
|
||||
preview?: string
|
||||
file?: string
|
||||
videoName?: string
|
||||
category?: string
|
||||
licence?: string
|
||||
category?: number
|
||||
licence?: number
|
||||
language?: string
|
||||
tags?: string
|
||||
tags?: string[]
|
||||
nsfw?: true
|
||||
videoDescription?: string
|
||||
privacy?: number
|
||||
privacy?: VideoPrivacyType
|
||||
channelName?: string
|
||||
noCommentsEnabled?: true
|
||||
support?: string
|
||||
@ -41,13 +41,13 @@ export function defineUploadProgram () {
|
||||
.option('--preview <previewPath>', 'Preview path')
|
||||
.option('-f, --file <file>', 'Video absolute file path')
|
||||
.option('-n, --video-name <name>', 'Video name')
|
||||
.option('-c, --category <category_number>', 'Category number')
|
||||
.option('-l, --licence <licence_number>', 'Licence number')
|
||||
.option('-c, --category <category_number>', 'Category number', parseInt)
|
||||
.option('-l, --licence <licence_number>', 'Licence number', parseInt)
|
||||
.option('-L, --language <language_code>', 'Language ISO 639 code (fr or en...)')
|
||||
.option('-t, --tags <tags>', 'Video tags', listOptions)
|
||||
.option('-N, --nsfw', 'Video is Not Safe For Work')
|
||||
.option('-d, --video-description <description>', 'Video description')
|
||||
.option('-P, --privacy <privacy_number>', 'Privacy', parseInt)
|
||||
.option('-P, --privacy <privacy_number>', 'Privacy', v => parseInt(v) as VideoPrivacyType)
|
||||
.option('-C, --channel-name <channel_name>', 'Channel name')
|
||||
.option('--no-comments-enabled', 'Disable video comments')
|
||||
.option('-s, --support <support>', 'Video support text')
|
||||
@ -120,10 +120,9 @@ async function run (options: UploadOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
async function buildVideoAttributesFromCommander (server: PeerTubeServer, options: UploadOptions, defaultAttributes: any = {}) {
|
||||
async function buildVideoAttributesFromCommander (server: PeerTubeServer, options: UploadOptions) {
|
||||
const defaultBooleanAttributes = {
|
||||
nsfw: false,
|
||||
commentsEnabled: true,
|
||||
downloadEnabled: true,
|
||||
waitTranscoding: true
|
||||
}
|
||||
@ -133,25 +132,29 @@ async function buildVideoAttributesFromCommander (server: PeerTubeServer, option
|
||||
for (const key of Object.keys(defaultBooleanAttributes)) {
|
||||
if (options[key] !== undefined) {
|
||||
booleanAttributes[key] = options[key]
|
||||
} else if (defaultAttributes[key] !== undefined) {
|
||||
booleanAttributes[key] = defaultAttributes[key]
|
||||
} else {
|
||||
booleanAttributes[key] = defaultBooleanAttributes[key]
|
||||
}
|
||||
}
|
||||
|
||||
const videoAttributes = {
|
||||
name: options.videoName || defaultAttributes.name,
|
||||
category: options.category || defaultAttributes.category || undefined,
|
||||
licence: options.licence || defaultAttributes.licence || undefined,
|
||||
language: options.language || defaultAttributes.language || undefined,
|
||||
privacy: options.privacy || defaultAttributes.privacy || VideoPrivacy.PUBLIC,
|
||||
support: options.support || defaultAttributes.support || undefined,
|
||||
description: options.videoDescription || defaultAttributes.description || undefined,
|
||||
tags: options.tags || defaultAttributes.tags || undefined
|
||||
}
|
||||
name: options.videoName,
|
||||
category: options.category || undefined,
|
||||
licence: options.licence || undefined,
|
||||
language: options.language || undefined,
|
||||
privacy: options.privacy || VideoPrivacy.PUBLIC,
|
||||
support: options.support || undefined,
|
||||
description: options.videoDescription || undefined,
|
||||
tags: options.tags || undefined,
|
||||
|
||||
Object.assign(videoAttributes, booleanAttributes)
|
||||
commentsPolicy: options.noCommentsEnabled !== undefined
|
||||
? options.noCommentsEnabled === true
|
||||
? VideoCommentPolicy.DISABLED
|
||||
: VideoCommentPolicy.ENABLED
|
||||
: undefined,
|
||||
|
||||
...booleanAttributes
|
||||
}
|
||||
|
||||
if (options.channelName) {
|
||||
const videoChannel = await server.channels.get({ channelName: options.channelName })
|
||||
|
@ -120,7 +120,7 @@ function getRemoteObjectOrDie (
|
||||
return { url, username, password }
|
||||
}
|
||||
|
||||
function listOptions (val: any) {
|
||||
function listOptions (val: string) {
|
||||
return val.split(',')
|
||||
}
|
||||
|
||||
|
@ -153,6 +153,14 @@ export class AdminComponent implements OnInit {
|
||||
})
|
||||
}
|
||||
|
||||
if (this.hasServerWatchedWordsRight()) {
|
||||
moderationItems.children.push({
|
||||
label: $localize`Watched words`,
|
||||
routerLink: '/admin/moderation/watched-words/list',
|
||||
iconName: 'eye-open'
|
||||
})
|
||||
}
|
||||
|
||||
if (moderationItems.children.length !== 0) this.menuEntries.push(moderationItems)
|
||||
}
|
||||
|
||||
@ -241,6 +249,10 @@ export class AdminComponent implements OnInit {
|
||||
return this.auth.getUser().hasRight(UserRight.MANAGE_SERVERS_BLOCKLIST)
|
||||
}
|
||||
|
||||
private hasServerWatchedWordsRight () {
|
||||
return this.auth.getUser().hasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS)
|
||||
}
|
||||
|
||||
private hasConfigRight () {
|
||||
return this.auth.getUser().hasRight(UserRight.MANAGE_CONFIGURATION)
|
||||
}
|
||||
|
@ -1,15 +1,16 @@
|
||||
import { NgClass, NgIf } from '@angular/common'
|
||||
import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { Notifier } from '@app/core'
|
||||
import { formatICU } from '@app/helpers'
|
||||
import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators'
|
||||
import { UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators'
|
||||
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
|
||||
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
|
||||
import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
|
||||
import { NgClass, NgIf } from '@angular/common'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { splitAndGetNotEmpty } from '@root-helpers/string'
|
||||
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
|
||||
import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
|
||||
|
||||
@Component({
|
||||
selector: 'my-follow-modal',
|
||||
|
@ -5,6 +5,7 @@ import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list
|
||||
import { UserRightGuard } from '@app/core'
|
||||
import { UserRight } from '@peertube/peertube-models'
|
||||
import { RegistrationListComponent } from './registration-list'
|
||||
import { WatchedWordsListAdminComponent } from './watched-words-list/watched-words-list-admin.component'
|
||||
|
||||
export const ModerationRoutes: Routes = [
|
||||
{
|
||||
@ -114,6 +115,18 @@ export const ModerationRoutes: Routes = [
|
||||
title: $localize`Muted instances`
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
path: 'watched-words/list',
|
||||
component: WatchedWordsListAdminComponent,
|
||||
canActivate: [ UserRightGuard ],
|
||||
data: {
|
||||
userRight: UserRight.MANAGE_INSTANCE_WATCHED_WORDS,
|
||||
meta: {
|
||||
title: $localize`Watched words`
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -0,0 +1,10 @@
|
||||
<h1>
|
||||
<my-global-icon iconName="eye-open" aria-hidden="true"></my-global-icon>
|
||||
<ng-container i18n>Instance watched words lists</ng-container>
|
||||
</h1>
|
||||
|
||||
<em class="d-block" i18n>Video name/description and comments that contain any of the watched words are automatically tagged with the name of the list.</em>
|
||||
<em class="d-block mb-3" i18n>These automatic tags can be used to filter comments and videos.</em>
|
||||
|
||||
<my-watched-words-list-admin-owner mode="admin"></my-watched-words-list-admin-owner>
|
||||
|
@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
|
||||
import { WatchedWordsListAdminOwnerComponent } from '@app/shared/standalone-watched-words/watched-words-list-admin-owner.component'
|
||||
|
||||
@Component({
|
||||
templateUrl: './watched-words-list-admin.component.html',
|
||||
standalone: true,
|
||||
imports: [
|
||||
GlobalIconComponent,
|
||||
WatchedWordsListAdminOwnerComponent
|
||||
]
|
||||
})
|
||||
export class WatchedWordsListAdminComponent { }
|
@ -7,110 +7,5 @@
|
||||
|
||||
<em i18n>This view also shows comments from muted accounts.</em>
|
||||
|
||||
<p-table
|
||||
[value]="comments" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
|
||||
[rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id"
|
||||
[lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
|
||||
[showCurrentPageReport]="true" [currentPageReportTemplate]="getPaginationTemplate()"
|
||||
[expandedRowKeys]="expandedRows" [(selection)]="selectedRows"
|
||||
>
|
||||
<ng-template pTemplate="caption">
|
||||
<div class="caption">
|
||||
<div>
|
||||
<my-action-dropdown
|
||||
*ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
|
||||
[actions]="bulkActions" [entry]="selectedRows"
|
||||
>
|
||||
</my-action-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="ms-auto right-form">
|
||||
<my-advanced-input-filter [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter>
|
||||
|
||||
<my-button i18n-label label="Refresh" icon="refresh" (click)="reloadData()"></my-button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="header">
|
||||
<tr>
|
||||
<th scope="col" style="width: 40px;">
|
||||
<p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
|
||||
</th>
|
||||
<th scope="col" style="width: 40px;">
|
||||
<span i18n class="visually-hidden">More information</span>
|
||||
</th>
|
||||
<th scope="col" style="width: 150px;">
|
||||
<span i18n class="visually-hidden">Actions</span>
|
||||
</th>
|
||||
<th scope="col" style="width: 300px;" i18n>Account</th>
|
||||
<th scope="col" style="width: 300px;" i18n>Video</th>
|
||||
<th scope="col" i18n>Comment</th>
|
||||
<th scope="col" style="width: 150px;" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="createdAt">Date <p-sortIcon field="createdAt"></p-sortIcon></th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="body" let-videoComment let-expanded="expanded">
|
||||
<tr [pSelectableRow]="videoComment">
|
||||
|
||||
<td class="checkbox-cell">
|
||||
<p-tableCheckbox [value]="videoComment" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox>
|
||||
</td>
|
||||
|
||||
<td class="expand-cell">
|
||||
<my-table-expander-icon [pRowToggler]="videoComment" i18n-tooltip tooltip="See full comment" [expanded]="expanded"></my-table-expander-icon>
|
||||
</td>
|
||||
|
||||
<td class="action-cell">
|
||||
<my-action-dropdown
|
||||
[ngClass]="{ 'show': expanded }" placement="bottom-right" container="body"
|
||||
i18n-label label="Actions" [actions]="videoCommentActions" [entry]="videoComment"
|
||||
></my-action-dropdown>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a [href]="videoComment.account.localUrl" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
|
||||
<div class="chip two-lines">
|
||||
<my-actor-avatar [actor]="videoComment.account" actorType="account" size="32"></my-actor-avatar>
|
||||
<div>
|
||||
{{ videoComment.account.displayName }}
|
||||
<span>{{ videoComment.by }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<td class="video">
|
||||
<em i18n>Commented video</em>
|
||||
|
||||
<a [href]="videoComment.localUrl" target="_blank" rel="noopener noreferrer">{{ videoComment.video.name }}</a>
|
||||
</td>
|
||||
|
||||
<td class="comment-html c-hand" [pRowToggler]="videoComment">
|
||||
<div [innerHTML]="videoComment.textHtml"></div>
|
||||
</td>
|
||||
|
||||
<td class="c-hand" [pRowToggler]="videoComment">{{ videoComment.createdAt | date: 'short' }}</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="rowexpansion" let-videoComment>
|
||||
<tr>
|
||||
<td class="expand-cell" myAutoColspan>
|
||||
<div [innerHTML]="videoComment.textHtml"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="emptymessage">
|
||||
<tr>
|
||||
<td myAutoColspan>
|
||||
<div class="no-results">
|
||||
<ng-container *ngIf="search" i18n>No comments found matching current filters.</ng-container>
|
||||
<ng-container *ngIf="!search" i18n>No comments found.</ng-container>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
<my-video-comment-list-admin-owner mode="admin"></my-video-comment-list-admin-owner>
|
||||
|
||||
|
@ -7,54 +7,3 @@ my-feed {
|
||||
display: inline-block;
|
||||
width: 15px;
|
||||
}
|
||||
|
||||
my-global-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.video {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
em {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
a {
|
||||
@include ellipsis;
|
||||
|
||||
color: pvar(--mainForegroundColor);
|
||||
}
|
||||
}
|
||||
|
||||
.comment-html {
|
||||
::ng-deep {
|
||||
> div {
|
||||
max-height: 22px;
|
||||
}
|
||||
|
||||
div,
|
||||
p {
|
||||
@include ellipsis;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right-form {
|
||||
display: flex;
|
||||
|
||||
> *:not(:last-child) {
|
||||
@include margin-right(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $primeng-breakpoint) {
|
||||
.video {
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
}
|
||||
|
@ -1,54 +1,22 @@
|
||||
import { SortMeta, SharedModule } from 'primeng/api'
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { AuthService, ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
|
||||
import { FeedFormat, UserRight } from '@peertube/peertube-models'
|
||||
import { formatICU } from '@app/helpers'
|
||||
import { AutoColspanDirective } from '../../../shared/shared-main/angular/auto-colspan.directive'
|
||||
import { ActorAvatarComponent } from '../../../shared/shared-actor-image/actor-avatar.component'
|
||||
import { TableExpanderIconComponent } from '../../../shared/shared-tables/table-expander-icon.component'
|
||||
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component'
|
||||
import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../../../shared/shared-forms/advanced-input-filter.component'
|
||||
import { ActionDropdownComponent, DropdownAction } from '../../../shared/shared-main/buttons/action-dropdown.component'
|
||||
import { NgIf, NgClass, DatePipe } from '@angular/common'
|
||||
import { TableModule } from 'primeng/table'
|
||||
import { FeedComponent } from '../../../shared/shared-main/feeds/feed.component'
|
||||
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
|
||||
import { VideoCommentAdmin } from '@app/shared/shared-video-comment/video-comment.model'
|
||||
import { BulkService } from '@app/shared/shared-moderation/bulk.service'
|
||||
import { Component } from '@angular/core'
|
||||
import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
|
||||
import { FeedFormat } from '@peertube/peertube-models'
|
||||
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
|
||||
import { FeedComponent } from '../../../shared/shared-main/feeds/feed.component'
|
||||
import { VideoCommentListAdminOwnerComponent } from '../../../shared/shared-video-comment/video-comment-list-admin-owner.component'
|
||||
|
||||
@Component({
|
||||
selector: 'my-video-comment-list',
|
||||
templateUrl: './video-comment-list.component.html',
|
||||
styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './video-comment-list.component.scss' ],
|
||||
styleUrls: [ './video-comment-list.component.scss' ],
|
||||
standalone: true,
|
||||
imports: [
|
||||
GlobalIconComponent,
|
||||
FeedComponent,
|
||||
TableModule,
|
||||
SharedModule,
|
||||
NgIf,
|
||||
ActionDropdownComponent,
|
||||
AdvancedInputFilterComponent,
|
||||
ButtonComponent,
|
||||
NgbTooltip,
|
||||
TableExpanderIconComponent,
|
||||
NgClass,
|
||||
ActorAvatarComponent,
|
||||
AutoColspanDirective,
|
||||
DatePipe
|
||||
VideoCommentListAdminOwnerComponent
|
||||
]
|
||||
})
|
||||
export class VideoCommentListComponent extends RestTable <VideoCommentAdmin> implements OnInit {
|
||||
comments: VideoCommentAdmin[]
|
||||
totalRecords = 0
|
||||
sort: SortMeta = { field: 'createdAt', order: -1 }
|
||||
pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
|
||||
|
||||
videoCommentActions: DropdownAction<VideoCommentAdmin>[][] = []
|
||||
|
||||
export class VideoCommentListComponent {
|
||||
syndicationItems = [
|
||||
{
|
||||
format: FeedFormat.RSS,
|
||||
@ -66,154 +34,4 @@ export class VideoCommentListComponent extends RestTable <VideoCommentAdmin> imp
|
||||
url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.JSON.toLowerCase()
|
||||
}
|
||||
]
|
||||
|
||||
bulkActions: DropdownAction<VideoCommentAdmin[]>[] = []
|
||||
|
||||
inputFilters: AdvancedInputFilter[] = [
|
||||
{
|
||||
title: $localize`Advanced filters`,
|
||||
children: [
|
||||
{
|
||||
value: 'local:true',
|
||||
label: $localize`Local comments`
|
||||
},
|
||||
{
|
||||
value: 'local:false',
|
||||
label: $localize`Remote comments`
|
||||
},
|
||||
{
|
||||
value: 'localVideo:true',
|
||||
label: $localize`Comments on local videos`
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
get authUser () {
|
||||
return this.auth.getUser()
|
||||
}
|
||||
|
||||
constructor (
|
||||
protected router: Router,
|
||||
protected route: ActivatedRoute,
|
||||
private auth: AuthService,
|
||||
private notifier: Notifier,
|
||||
private confirmService: ConfirmService,
|
||||
private videoCommentService: VideoCommentService,
|
||||
private markdownRenderer: MarkdownService,
|
||||
private bulkService: BulkService
|
||||
) {
|
||||
super()
|
||||
|
||||
this.videoCommentActions = [
|
||||
[
|
||||
{
|
||||
label: $localize`Delete this comment`,
|
||||
handler: comment => this.deleteComment(comment),
|
||||
isDisplayed: () => this.authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)
|
||||
},
|
||||
|
||||
{
|
||||
label: $localize`Delete all comments of this account`,
|
||||
description: $localize`Comments are deleted after a few minutes`,
|
||||
handler: comment => this.deleteUserComments(comment),
|
||||
isDisplayed: () => this.authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.initialize()
|
||||
|
||||
this.bulkActions = [
|
||||
{
|
||||
label: $localize`Delete`,
|
||||
handler: comments => this.removeComments(comments),
|
||||
isDisplayed: () => this.authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT),
|
||||
iconName: 'delete'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
getIdentifier () {
|
||||
return 'VideoCommentListComponent'
|
||||
}
|
||||
|
||||
toHtml (text: string) {
|
||||
return this.markdownRenderer.textMarkdownToHTML({ markdown: text, withHtml: true, withEmoji: true })
|
||||
}
|
||||
|
||||
protected reloadDataInternal () {
|
||||
this.videoCommentService.getAdminVideoComments({
|
||||
pagination: this.pagination,
|
||||
sort: this.sort,
|
||||
search: this.search
|
||||
}).subscribe({
|
||||
next: async resultList => {
|
||||
this.totalRecords = resultList.total
|
||||
|
||||
this.comments = []
|
||||
|
||||
for (const c of resultList.data) {
|
||||
this.comments.push(
|
||||
new VideoCommentAdmin(c, await this.toHtml(c.text))
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
private removeComments (comments: VideoCommentAdmin[]) {
|
||||
const commentArgs = comments.map(c => ({ videoId: c.video.id, commentId: c.id }))
|
||||
|
||||
this.videoCommentService.deleteVideoComments(commentArgs)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.notifier.success(
|
||||
formatICU(
|
||||
$localize`{count, plural, =1 {1 comment deleted.} other {{count} comments deleted.}}`,
|
||||
{ count: commentArgs.length }
|
||||
)
|
||||
)
|
||||
|
||||
this.reloadData()
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message),
|
||||
|
||||
complete: () => this.selectedRows = []
|
||||
})
|
||||
}
|
||||
|
||||
private deleteComment (comment: VideoCommentAdmin) {
|
||||
this.videoCommentService.deleteVideoComment(comment.video.id, comment.id)
|
||||
.subscribe({
|
||||
next: () => this.reloadData(),
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
private async deleteUserComments (comment: VideoCommentAdmin) {
|
||||
const message = $localize`Do you really want to delete all comments of ${comment.by}?`
|
||||
const res = await this.confirmService.confirm(message, $localize`Delete`)
|
||||
if (res === false) return
|
||||
|
||||
const options = {
|
||||
accountName: comment.by,
|
||||
scope: 'instance' as 'instance'
|
||||
}
|
||||
|
||||
this.bulkService.removeCommentsOf(options)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.notifier.success($localize`Comments of ${options.accountName} will be deleted in a few minutes`)
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -113,7 +113,8 @@ export class VideoAdminService {
|
||||
VideoInclude.BLOCKED_OWNER |
|
||||
VideoInclude.NOT_PUBLISHED_STATE |
|
||||
VideoInclude.FILES |
|
||||
VideoInclude.SOURCE
|
||||
VideoInclude.SOURCE |
|
||||
VideoInclude.AUTOMATIC_TAGS
|
||||
|
||||
let privacyOneOf = getAllPrivacies()
|
||||
|
||||
@ -143,6 +144,10 @@ export class VideoAdminService {
|
||||
excludePublic: {
|
||||
prefix: 'excludePublic',
|
||||
handler: () => true
|
||||
},
|
||||
autoTagOneOf: {
|
||||
prefix: 'autoTag:',
|
||||
multiple: true
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -70,22 +70,34 @@
|
||||
</td>
|
||||
|
||||
<td>
|
||||
@if (video.isLocal) {
|
||||
<span class="pt-badge badge-blue" i18n>Local</span>
|
||||
} @else {
|
||||
<span class="pt-badge badge-purple" i18n>Remote</span>
|
||||
}
|
||||
<div>
|
||||
@if (video.isLocal) {
|
||||
<span class="pt-badge badge-blue" i18n>Local</span>
|
||||
} @else {
|
||||
<span class="pt-badge badge-purple" i18n>Remote</span>
|
||||
}
|
||||
|
||||
<span [ngClass]="getPrivacyBadgeClass(video)" class="pt-badge">{{ video.privacy.label }}</span>
|
||||
<span [ngClass]="getPrivacyBadgeClass(video)" class="pt-badge">{{ video.privacy.label }}</span>
|
||||
|
||||
<span *ngIf="video.nsfw" class="pt-badge badge-red" i18n>NSFW</span>
|
||||
<span *ngIf="video.nsfw" class="pt-badge badge-red" i18n>NSFW</span>
|
||||
|
||||
<span *ngIf="isUnpublished(video)" class="pt-badge badge-yellow">{{ video.state.label }}</span>
|
||||
<span *ngIf="isUnpublished(video)" class="pt-badge badge-yellow">{{ video.state.label }}</span>
|
||||
|
||||
<span *ngIf="isAccountBlocked(video)" class="pt-badge badge-red" i18n>Account muted</span>
|
||||
<span *ngIf="isServerBlocked(video)" class="pt-badge badge-red" i18n>Server muted</span>
|
||||
<span *ngIf="isAccountBlocked(video)" class="pt-badge badge-red" i18n>Account muted</span>
|
||||
<span *ngIf="isServerBlocked(video)" class="pt-badge badge-red" i18n>Server muted</span>
|
||||
|
||||
<span *ngIf="isVideoBlocked(video)" class="pt-badge badge-red" i18n>Blocked</span>
|
||||
<span *ngIf="isVideoBlocked(video)" class="pt-badge badge-red" i18n>Blocked</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@for (tag of video.automaticTags; track tag) {
|
||||
<a
|
||||
i18n-title title="Only display videos with this tag"
|
||||
class="pt-badge badge-secondary me-1"
|
||||
[routerLink]="[ '.' ]" [queryParams]="{ 'search': buildSearchAutoTag(tag) }"
|
||||
>{{ tag }}</a>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { DatePipe, NgClass, NgFor, NgIf } from '@angular/common'
|
||||
import { Component, OnInit, ViewChild } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router'
|
||||
import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
|
||||
import { formatICU, getAbsoluteAPIUrl } from '@app/helpers'
|
||||
import { Video } from '@app/shared/shared-main/video/video.model'
|
||||
@ -51,6 +51,7 @@ import { VideoAdminService } from './video-admin.service'
|
||||
EmbedComponent,
|
||||
VideoBlockComponent,
|
||||
DatePipe,
|
||||
RouterLink,
|
||||
BytesPipe
|
||||
]
|
||||
})
|
||||
@ -256,6 +257,14 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
|
||||
})
|
||||
}
|
||||
|
||||
buildSearchAutoTag (tag: string) {
|
||||
const str = `autoTag:"${tag}"`
|
||||
|
||||
if (this.search) return this.search + ' ' + str
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
protected reloadDataInternal () {
|
||||
this.loading = true
|
||||
|
||||
|
@ -23,6 +23,7 @@ import { TwoFactorService } from '@app/shared/shared-users/two-factor.service'
|
||||
import { UserAdminService } from '@app/shared/shared-users/user-admin.service'
|
||||
import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
|
||||
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
|
||||
import { WatchedWordsListService } from '@app/shared/standalone-watched-words/watched-words-list.service'
|
||||
|
||||
export default [
|
||||
{
|
||||
@ -52,7 +53,8 @@ export default [
|
||||
DynamicElementService,
|
||||
FindInBulkService,
|
||||
SearchService,
|
||||
VideoPlaylistService
|
||||
VideoPlaylistService,
|
||||
WatchedWordsListService
|
||||
],
|
||||
children: [
|
||||
{
|
||||
|
@ -0,0 +1,45 @@
|
||||
import { HttpClient } from '@angular/common/http'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { RestExtractor } from '@app/core'
|
||||
import { AutomaticTagAvailable, CommentAutomaticTagPolicies } from '@peertube/peertube-models'
|
||||
import { catchError } from 'rxjs/operators'
|
||||
import { environment } from 'src/environments/environment'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AutomaticTagService {
|
||||
private static BASE_AUTOMATIC_TAGS_URL = environment.apiUrl + '/api/v1/automatic-tags/'
|
||||
|
||||
constructor (
|
||||
private authHttp: HttpClient,
|
||||
private restExtractor: RestExtractor
|
||||
) {}
|
||||
|
||||
listAvailable (options: {
|
||||
accountName: string
|
||||
}) {
|
||||
const url = AutomaticTagService.BASE_AUTOMATIC_TAGS_URL + 'accounts/' + options.accountName + '/available'
|
||||
|
||||
return this.authHttp.get<AutomaticTagAvailable>(url)
|
||||
.pipe(catchError(res => this.restExtractor.handleError(res)))
|
||||
}
|
||||
|
||||
getCommentPolicies (options: {
|
||||
accountName: string
|
||||
}) {
|
||||
const url = AutomaticTagService.BASE_AUTOMATIC_TAGS_URL + 'policies/accounts/' + options.accountName + '/comments'
|
||||
|
||||
return this.authHttp.get<CommentAutomaticTagPolicies>(url)
|
||||
.pipe(catchError(res => this.restExtractor.handleError(res)))
|
||||
}
|
||||
|
||||
updateCommentPolicies (options: {
|
||||
accountName: string
|
||||
review: string[]
|
||||
}) {
|
||||
const url = AutomaticTagService.BASE_AUTOMATIC_TAGS_URL + 'policies/accounts/' + options.accountName + '/comments'
|
||||
|
||||
return this.authHttp.put(url, { review: options.review })
|
||||
.pipe(catchError(res => this.restExtractor.handleError(res)))
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
<h1>
|
||||
<my-global-icon iconName="no" aria-hidden="true"></my-global-icon>
|
||||
<ng-container i18n>Your automatic tag policies</ng-container>
|
||||
</h1>
|
||||
|
||||
<strong class="d-block mb-3" i18n>Automatically block comments:</strong>
|
||||
|
||||
@for (tag of tags; track tag; let i = $index) {
|
||||
<div class="form-group ms-3">
|
||||
<my-peertube-checkbox
|
||||
[inputName]="'tag-' + i" [(ngModel)]="tag.review" [labelText]="getLabelText(tag)"
|
||||
(ngModelChange)="updatePolicies()"
|
||||
></my-peertube-checkbox>
|
||||
</div>
|
||||
}
|
@ -0,0 +1,71 @@
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { AuthService, Notifier } from '@app/core'
|
||||
import { PeertubeCheckboxComponent } from '@app/shared/shared-forms/peertube-checkbox.component'
|
||||
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
|
||||
import { AutomaticTagAvailableType } from '@peertube/peertube-models'
|
||||
import { forkJoin } from 'rxjs'
|
||||
import { first } from 'rxjs/operators'
|
||||
import { AutomaticTagService } from './automatic-tag.service'
|
||||
|
||||
@Component({
|
||||
templateUrl: './my-account-auto-tag-policies.component.html',
|
||||
standalone: true,
|
||||
imports: [
|
||||
GlobalIconComponent,
|
||||
FormsModule,
|
||||
PeertubeCheckboxComponent
|
||||
]
|
||||
})
|
||||
export class MyAccountAutoTagPoliciesComponent implements OnInit {
|
||||
tags: { name: string, review: boolean, type: AutomaticTagAvailableType }[] = []
|
||||
|
||||
constructor (
|
||||
private authService: AuthService,
|
||||
private autoTagsService: AutomaticTagService,
|
||||
private notifier: Notifier
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.authService.userInformationLoaded
|
||||
.pipe(first())
|
||||
.subscribe(() => this.loadAvailableTags())
|
||||
}
|
||||
|
||||
getLabelText (tag: { name: string, type: AutomaticTagAvailableType }) {
|
||||
if (tag.name === 'external-link') {
|
||||
return $localize`That contain an external link`
|
||||
}
|
||||
|
||||
return $localize`That contain any word from your "${tag.name}" watched word list`
|
||||
}
|
||||
|
||||
updatePolicies () {
|
||||
const accountName = this.authService.getUser().account.name
|
||||
|
||||
this.autoTagsService.updateCommentPolicies({
|
||||
accountName,
|
||||
review: this.tags.filter(t => t.review).map(t => t.name)
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.notifier.success($localize`Comment policies updated`)
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
private loadAvailableTags () {
|
||||
const accountName = this.authService.getUser().account.name
|
||||
|
||||
forkJoin([
|
||||
this.autoTagsService.listAvailable({ accountName }),
|
||||
this.autoTagsService.getCommentPolicies({ accountName })
|
||||
]).subscribe(([ resAvailable, policies ]) => {
|
||||
this.tags = resAvailable.available
|
||||
.map(a => ({ name: a.name, type: a.type, review: policies.review.includes(a.name) }))
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
<h1>
|
||||
<my-global-icon iconName="message-circle" aria-hidden="true"></my-global-icon>
|
||||
<ng-container i18n>Comments on your videos</ng-container>
|
||||
</h1>
|
||||
|
||||
<my-video-comment-list-admin-owner mode="user"></my-video-comment-list-admin-owner>
|
||||
|
@ -0,0 +1,14 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
|
||||
import { VideoCommentListAdminOwnerComponent } from '../../shared/shared-video-comment/video-comment-list-admin-owner.component'
|
||||
|
||||
@Component({
|
||||
templateUrl: './comments-on-my-videos.component.html',
|
||||
standalone: true,
|
||||
imports: [
|
||||
GlobalIconComponent,
|
||||
VideoCommentListAdminOwnerComponent
|
||||
]
|
||||
})
|
||||
export class CommentsOnMyVideosComponent {
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
<h1>
|
||||
<my-global-icon iconName="no" aria-hidden="true"></my-global-icon>
|
||||
<ng-container i18n>Your watched words lists</ng-container>
|
||||
</h1>
|
||||
|
||||
<em class="d-block" i18n>Comments that contain any of the watched words are automatically tagged with the name of the list.</em>
|
||||
<em class="d-block mb-3" i18n>These automatic tags can be used to filter comments or <a routerLink="/my-account/auto-tag-policies">automatically block</a> them.</em>
|
||||
|
||||
<my-watched-words-list-admin-owner mode="user"></my-watched-words-list-admin-owner>
|
||||
|
@ -0,0 +1,19 @@
|
||||
import { NgIf } from '@angular/common'
|
||||
import { Component } from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
|
||||
import { WatchedWordsListAdminOwnerComponent } from '@app/shared/standalone-watched-words/watched-words-list-admin-owner.component'
|
||||
|
||||
@Component({
|
||||
templateUrl: './my-account-watched-words-list.component.html',
|
||||
standalone: true,
|
||||
imports: [
|
||||
GlobalIconComponent,
|
||||
WatchedWordsListAdminOwnerComponent,
|
||||
NgIf,
|
||||
RouterLink
|
||||
]
|
||||
})
|
||||
export class MyAccountWatchedWordsListComponent {
|
||||
|
||||
}
|
@ -32,27 +32,6 @@ export class MyAccountComponent implements OnInit {
|
||||
private buildMenu () {
|
||||
const clientRoutes = this.pluginService.getAllRegisteredClientRoutesForParent('/my-account') || {}
|
||||
|
||||
const moderationEntries: TopMenuDropdownParam = {
|
||||
label: $localize`Moderation`,
|
||||
children: [
|
||||
{
|
||||
label: $localize`Muted accounts`,
|
||||
routerLink: '/my-account/blocklist/accounts',
|
||||
iconName: 'user-x'
|
||||
},
|
||||
{
|
||||
label: $localize`Muted servers`,
|
||||
routerLink: '/my-account/blocklist/servers',
|
||||
iconName: 'peertube-x'
|
||||
},
|
||||
{
|
||||
label: $localize`Abuse reports`,
|
||||
routerLink: '/my-account/abuses',
|
||||
iconName: 'flag'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
this.menuEntries = [
|
||||
{
|
||||
label: $localize`Settings`,
|
||||
@ -74,7 +53,41 @@ export class MyAccountComponent implements OnInit {
|
||||
routerLink: '/my-account/applications'
|
||||
},
|
||||
|
||||
moderationEntries,
|
||||
{
|
||||
label: $localize`Moderation`,
|
||||
children: [
|
||||
{
|
||||
label: $localize`Muted accounts`,
|
||||
routerLink: '/my-account/blocklist/accounts',
|
||||
iconName: 'user-x'
|
||||
},
|
||||
{
|
||||
label: $localize`Muted servers`,
|
||||
routerLink: '/my-account/blocklist/servers',
|
||||
iconName: 'peertube-x'
|
||||
},
|
||||
{
|
||||
label: $localize`Abuse reports`,
|
||||
routerLink: '/my-account/abuses',
|
||||
iconName: 'flag'
|
||||
},
|
||||
{
|
||||
label: $localize`Comments on your videos`,
|
||||
routerLink: '/my-account/videos/comments',
|
||||
iconName: 'message-circle'
|
||||
},
|
||||
{
|
||||
label: $localize`Watched words`,
|
||||
routerLink: '/my-account/watched-words/list',
|
||||
iconName: 'eye-open'
|
||||
},
|
||||
{
|
||||
label: $localize`Auto tag policies`,
|
||||
routerLink: '/my-account/auto-tag-policies',
|
||||
iconName: 'no'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
...Object.values(clientRoutes)
|
||||
.map(clientRoute => ({
|
||||
|
@ -1,20 +1,25 @@
|
||||
import { Routes } from '@angular/router'
|
||||
import { AbuseService } from '@app/shared/shared-moderation/abuse.service'
|
||||
import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service'
|
||||
import { BulkService } from '@app/shared/shared-moderation/bulk.service'
|
||||
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
|
||||
import { PluginPagesComponent } from '@app/shared/shared-plugin-pages/plugin-pages.component'
|
||||
import { TwoFactorService } from '@app/shared/shared-users/two-factor.service'
|
||||
import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
|
||||
import { WatchedWordsListService } from '@app/shared/standalone-watched-words/watched-words-list.service'
|
||||
import { CanDeactivateGuard, LoginGuard } from '../core'
|
||||
import { MyAccountAbusesListComponent } from './my-account-abuses/my-account-abuses-list.component'
|
||||
import { MyAccountApplicationsComponent } from './my-account-applications/my-account-applications.component'
|
||||
import { MyAccountAutoTagPoliciesComponent } from './my-account-auto-tag-policies/my-account-auto-tag-policies.component'
|
||||
import { MyAccountBlocklistComponent } from './my-account-blocklist/my-account-blocklist.component'
|
||||
import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component'
|
||||
import { CommentsOnMyVideosComponent } from './my-account-comments-on-my-videos/comments-on-my-videos.component'
|
||||
import { MyAccountImportExportComponent, UserImportExportService } from './my-account-import-export'
|
||||
import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
|
||||
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
|
||||
import { MyAccountImportExportComponent, UserImportExportService } from './my-account-import-export'
|
||||
import { MyAccountComponent } from './my-account.component'
|
||||
import { MyAccountTwoFactorComponent } from './my-account-settings/my-account-two-factor/my-account-two-factor.component'
|
||||
import { AbuseService } from '@app/shared/shared-moderation/abuse.service'
|
||||
import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service'
|
||||
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
|
||||
import { TwoFactorService } from '@app/shared/shared-users/two-factor.service'
|
||||
import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
|
||||
import { PluginPagesComponent } from '@app/shared/shared-plugin-pages/plugin-pages.component'
|
||||
import { MyAccountWatchedWordsListComponent } from './my-account-watched-words-list/my-account-watched-words-list.component'
|
||||
import { MyAccountComponent } from './my-account.component'
|
||||
|
||||
export default [
|
||||
{
|
||||
@ -26,7 +31,9 @@ export default [
|
||||
BlocklistService,
|
||||
AbuseService,
|
||||
VideoCommentService,
|
||||
VideoBlockService
|
||||
VideoBlockService,
|
||||
BulkService,
|
||||
WatchedWordsListService
|
||||
],
|
||||
canActivateChild: [ LoginGuard ],
|
||||
children: [
|
||||
@ -152,6 +159,15 @@ export default [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'videos/comments',
|
||||
component: CommentsOnMyVideosComponent,
|
||||
data: {
|
||||
meta: {
|
||||
title: $localize`Comments on your videos`
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'import-export',
|
||||
component: MyAccountImportExportComponent,
|
||||
@ -162,6 +178,24 @@ export default [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'watched-words/list',
|
||||
component: MyAccountWatchedWordsListComponent,
|
||||
data: {
|
||||
meta: {
|
||||
title: $localize`Your watched words`
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'auto-tag-policies',
|
||||
component: MyAccountAutoTagPoliciesComponent,
|
||||
data: {
|
||||
meta: {
|
||||
title: $localize`Your automatic tag policies`
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'p',
|
||||
children: [
|
||||
|
@ -438,10 +438,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<my-peertube-checkbox
|
||||
inputName="commentsEnabled" formControlName="commentsEnabled"
|
||||
i18n-labelText labelText="Enable video comments"
|
||||
></my-peertube-checkbox>
|
||||
<div class="form-group mb-4">
|
||||
<label i18n for="commentsPolicy">Comments policy</label>
|
||||
<my-select-options labelForId="commentsPolicy" [items]="commentPolicies" formControlName="commentsPolicy" [clearable]="false"></my-select-options>
|
||||
|
||||
<div *ngIf="formErrors.commentsPolicy" class="form-error" role="alert">
|
||||
{{ formErrors.commentsPolicy }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<my-peertube-checkbox
|
||||
inputName="downloadEnabled" formControlName="downloadEnabled"
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { forkJoin } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model'
|
||||
import { DatePipe, NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'
|
||||
import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
|
||||
import { AbstractControl, FormArray, FormGroup, Validators, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { AbstractControl, FormArray, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'
|
||||
import { HooksService, PluginService, ServerService } from '@app/core'
|
||||
import { removeElementFromArray } from '@app/helpers'
|
||||
import { BuildFormArgument, BuildFormValidator } from '@app/shared/form-validators/form-validator.model'
|
||||
import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators'
|
||||
import {
|
||||
VIDEO_CATEGORY_VALIDATOR,
|
||||
VIDEO_CHANNEL_VALIDATOR,
|
||||
@ -19,8 +19,14 @@ import {
|
||||
VIDEO_SUPPORT_VALIDATOR,
|
||||
VIDEO_TAGS_ARRAY_VALIDATOR
|
||||
} from '@app/shared/form-validators/video-validators'
|
||||
import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators'
|
||||
import { NgbModal, NgbNav, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { FormReactiveErrors, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service'
|
||||
import { FormValidatorService } from '@app/shared/shared-forms/form-validator.service'
|
||||
import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
|
||||
import { VideoCaptionEdit, VideoCaptionWithPathEdit } from '@app/shared/shared-main/video-caption/video-caption-edit.model'
|
||||
import { VideoChaptersEdit } from '@app/shared/shared-main/video/video-chapters-edit.model'
|
||||
import { VideoEdit } from '@app/shared/shared-main/video/video-edit.model'
|
||||
import { VideoService } from '@app/shared/shared-main/video/video.service'
|
||||
import { NgbModal, NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
|
||||
import {
|
||||
HTMLServerConfig,
|
||||
LiveVideo,
|
||||
@ -28,6 +34,7 @@ import {
|
||||
RegisterClientFormFieldOptions,
|
||||
RegisterClientVideoFieldOptions,
|
||||
VideoChapter,
|
||||
VideoCommentPolicyType,
|
||||
VideoConstant,
|
||||
VideoDetails,
|
||||
VideoPrivacy,
|
||||
@ -36,35 +43,29 @@ import {
|
||||
} from '@peertube/peertube-models'
|
||||
import { logger } from '@root-helpers/logger'
|
||||
import { PluginInfo } from '@root-helpers/plugins-manager'
|
||||
import { CalendarModule } from 'primeng/calendar'
|
||||
import { forkJoin } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model'
|
||||
import { DynamicFormFieldComponent } from '../../../shared/shared-forms/dynamic-form-field.component'
|
||||
import { InputTextComponent } from '../../../shared/shared-forms/input-text.component'
|
||||
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
|
||||
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
|
||||
import { PreviewUploadComponent } from '../../../shared/shared-forms/preview-upload.component'
|
||||
import { SelectChannelComponent } from '../../../shared/shared-forms/select/select-channel.component'
|
||||
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
|
||||
import { SelectTagsComponent } from '../../../shared/shared-forms/select/select-tags.component'
|
||||
import { TimestampInputComponent } from '../../../shared/shared-forms/timestamp-input.component'
|
||||
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
|
||||
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
|
||||
import { DeleteButtonComponent } from '../../../shared/shared-main/buttons/delete-button.component'
|
||||
import { HelpComponent } from '../../../shared/shared-main/misc/help.component'
|
||||
import { EmbedComponent } from '../../../shared/shared-main/video/embed.component'
|
||||
import { LiveDocumentationLinkComponent } from '../../../shared/shared-video-live/live-documentation-link.component'
|
||||
import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
|
||||
import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
|
||||
import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component'
|
||||
import { VideoEditType } from './video-edit.type'
|
||||
import { PreviewUploadComponent } from '../../../shared/shared-forms/preview-upload.component'
|
||||
import { LiveDocumentationLinkComponent } from '../../../shared/shared-video-live/live-documentation-link.component'
|
||||
import { EmbedComponent } from '../../../shared/shared-main/video/embed.component'
|
||||
import { DeleteButtonComponent } from '../../../shared/shared-main/buttons/delete-button.component'
|
||||
import { TimestampInputComponent } from '../../../shared/shared-forms/timestamp-input.component'
|
||||
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
|
||||
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
|
||||
import { CalendarModule } from 'primeng/calendar'
|
||||
import { InputTextComponent } from '../../../shared/shared-forms/input-text.component'
|
||||
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
|
||||
import { SelectChannelComponent } from '../../../shared/shared-forms/select/select-channel.component'
|
||||
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
|
||||
import { SelectTagsComponent } from '../../../shared/shared-forms/select/select-tags.component'
|
||||
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
|
||||
import { HelpComponent } from '../../../shared/shared-main/misc/help.component'
|
||||
import { NgIf, NgFor, NgTemplateOutlet, NgClass, DatePipe } from '@angular/common'
|
||||
import { DynamicFormFieldComponent } from '../../../shared/shared-forms/dynamic-form-field.component'
|
||||
import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
|
||||
import { VideoCaptionWithPathEdit, VideoCaptionEdit } from '@app/shared/shared-main/video-caption/video-caption-edit.model'
|
||||
import { VideoChaptersEdit } from '@app/shared/shared-main/video/video-chapters-edit.model'
|
||||
import { VideoEdit } from '@app/shared/shared-main/video/video-edit.model'
|
||||
import { VideoService } from '@app/shared/shared-main/video/video.service'
|
||||
import { BuildFormArgument, BuildFormValidator } from '@app/shared/form-validators/form-validator.model'
|
||||
import { FormReactiveErrors, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service'
|
||||
import { FormValidatorService } from '@app/shared/shared-forms/form-validator.service'
|
||||
|
||||
type VideoLanguages = VideoConstant<string> & { group?: string }
|
||||
type PluginField = {
|
||||
@ -144,6 +145,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
||||
replayPrivacies: VideoConstant<VideoPrivacyType> [] = []
|
||||
videoCategories: VideoConstant<number>[] = []
|
||||
videoLicences: VideoConstant<number>[] = []
|
||||
commentPolicies: VideoConstant<VideoCommentPolicyType>[] = []
|
||||
videoLanguages: VideoLanguages[] = []
|
||||
latencyModes: SelectOptionsItem[] = [
|
||||
{
|
||||
@ -202,7 +204,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
||||
updateForm () {
|
||||
const defaultValues: any = {
|
||||
nsfw: 'false',
|
||||
commentsEnabled: this.serverConfig.defaults.publish.commentsEnabled,
|
||||
commentsPolicy: this.serverConfig.defaults.publish.commentsPolicy,
|
||||
downloadEnabled: this.serverConfig.defaults.publish.downloadEnabled,
|
||||
waitTranscoding: true,
|
||||
licence: this.serverConfig.defaults.publish.licence,
|
||||
@ -214,7 +216,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
||||
videoPassword: VIDEO_PASSWORD_VALIDATOR,
|
||||
channelId: VIDEO_CHANNEL_VALIDATOR,
|
||||
nsfw: null,
|
||||
commentsEnabled: null,
|
||||
commentsPolicy: null,
|
||||
downloadEnabled: null,
|
||||
waitTranscoding: null,
|
||||
category: VIDEO_CATEGORY_VALIDATOR,
|
||||
@ -272,6 +274,9 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
||||
this.serverService.getVideoLicences()
|
||||
.subscribe(res => this.videoLicences = res)
|
||||
|
||||
this.serverService.getCommentPolicies()
|
||||
.subscribe(res => this.commentPolicies = res)
|
||||
|
||||
forkJoin([
|
||||
this.instanceService.getAbout(),
|
||||
this.serverService.getVideoLanguages()
|
||||
|
@ -27,6 +27,8 @@
|
||||
<a [routerLink]="['/w', video.shortUUID, { 'threadId': comment.threadId }]" class="comment-date" [title]="comment.createdAt">
|
||||
{{ comment.createdAt | myFromNow }}
|
||||
</a>
|
||||
|
||||
<span *ngIf="comment.heldForReview" class="pt-badge badge-red ms-2" i18n>Pending review</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@ -83,6 +85,7 @@
|
||||
(wantedToReply)="onWantToReply($event)"
|
||||
(wantedToDelete)="onWantToDelete($event)"
|
||||
(wantedToRedraft)="onWantToRedraft($event)"
|
||||
(wantedToApprove)="onWantToApprove($event)"
|
||||
(resetReply)="onResetReply()"
|
||||
(timestampClicked)="handleTimestampClicked($event)"
|
||||
[redraftValue]="redraftValue"
|
||||
|
@ -1,20 +1,20 @@
|
||||
import { NgClass, NgFor, NgIf } from '@angular/common'
|
||||
import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { MarkdownService, Notifier, UserService } from '@app/core'
|
||||
import { AuthService } from '@app/core/auth'
|
||||
import { CommentReportComponent } from '@app/shared/shared-moderation/report-modals/comment-report.component'
|
||||
import { User, UserRight } from '@peertube/peertube-models'
|
||||
import { FromNowPipe } from '../../../../shared/shared-main/angular/from-now.pipe'
|
||||
import { VideoCommentAddComponent } from './video-comment-add.component'
|
||||
import { UserModerationDropdownComponent } from '../../../../shared/shared-moderation/user-moderation-dropdown.component'
|
||||
import { TimestampRouteTransformerDirective } from '../timestamp-route-transformer.directive'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { ActorAvatarComponent } from '../../../../shared/shared-actor-image/actor-avatar.component'
|
||||
import { NgIf, NgClass, NgFor } from '@angular/common'
|
||||
import { Video } from '@app/shared/shared-main/video/video.model'
|
||||
import { Account } from '@app/shared/shared-main/account/account.model'
|
||||
import { DropdownAction } from '@app/shared/shared-main/buttons/action-dropdown.component'
|
||||
import { VideoComment } from '@app/shared/shared-video-comment/video-comment.model'
|
||||
import { Video } from '@app/shared/shared-main/video/video.model'
|
||||
import { CommentReportComponent } from '@app/shared/shared-moderation/report-modals/comment-report.component'
|
||||
import { VideoCommentThreadTree } from '@app/shared/shared-video-comment/video-comment-thread-tree.model'
|
||||
import { VideoComment } from '@app/shared/shared-video-comment/video-comment.model'
|
||||
import { User, UserRight } from '@peertube/peertube-models'
|
||||
import { ActorAvatarComponent } from '../../../../shared/shared-actor-image/actor-avatar.component'
|
||||
import { FromNowPipe } from '../../../../shared/shared-main/angular/from-now.pipe'
|
||||
import { UserModerationDropdownComponent } from '../../../../shared/shared-moderation/user-moderation-dropdown.component'
|
||||
import { TimestampRouteTransformerDirective } from '../timestamp-route-transformer.directive'
|
||||
import { VideoCommentAddComponent } from './video-comment-add.component'
|
||||
|
||||
@Component({
|
||||
selector: 'my-video-comment',
|
||||
@ -49,6 +49,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
|
||||
|
||||
@Output() wantedToReply = new EventEmitter<VideoComment>()
|
||||
@Output() wantedToDelete = new EventEmitter<VideoComment>()
|
||||
@Output() wantedToApprove = new EventEmitter<VideoComment>()
|
||||
@Output() wantedToRedraft = new EventEmitter<VideoComment>()
|
||||
@Output() threadCreated = new EventEmitter<VideoCommentThreadTree>()
|
||||
@Output() resetReply = new EventEmitter()
|
||||
@ -115,6 +116,10 @@ export class VideoCommentComponent implements OnInit, OnChanges {
|
||||
this.wantedToRedraft.emit(comment || this.comment)
|
||||
}
|
||||
|
||||
onWantToApprove (comment?: VideoComment) {
|
||||
this.wantedToApprove.emit(comment || this.comment)
|
||||
}
|
||||
|
||||
isUserLoggedIn () {
|
||||
return this.authService.isLoggedIn()
|
||||
}
|
||||
@ -127,12 +132,12 @@ export class VideoCommentComponent implements OnInit, OnChanges {
|
||||
this.timestampClicked.emit(timestamp)
|
||||
}
|
||||
|
||||
isRemovableByUser () {
|
||||
canBeRemovedOrApprovedByUser () {
|
||||
return this.comment.account && this.isUserLoggedIn() &&
|
||||
(
|
||||
this.user.account.id === this.comment.account.id ||
|
||||
this.user.account.id === this.video.account.id ||
|
||||
this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)
|
||||
this.user.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT)
|
||||
)
|
||||
}
|
||||
|
||||
@ -196,6 +201,14 @@ export class VideoCommentComponent implements OnInit, OnChanges {
|
||||
|
||||
this.prependModerationActions = []
|
||||
|
||||
if (this.canBeRemovedOrApprovedByUser() && this.comment.heldForReview) {
|
||||
this.prependModerationActions.push({
|
||||
label: $localize`Approve`,
|
||||
iconName: 'tick',
|
||||
handler: () => this.onWantToApprove()
|
||||
})
|
||||
}
|
||||
|
||||
if (this.isReportableByUser()) {
|
||||
this.prependModerationActions.push({
|
||||
label: $localize`Report this comment`,
|
||||
@ -204,7 +217,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
|
||||
})
|
||||
}
|
||||
|
||||
if (this.isRemovableByUser()) {
|
||||
if (this.canBeRemovedOrApprovedByUser()) {
|
||||
this.prependModerationActions.push({
|
||||
label: $localize`Remove`,
|
||||
iconName: 'delete',
|
||||
|
@ -17,7 +17,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template [ngIf]="video.commentsEnabled === true">
|
||||
@if (commentsEnabled) {
|
||||
<my-video-comment-add
|
||||
[video]="video"
|
||||
[videoPassword]="videoPassword"
|
||||
@ -43,6 +43,7 @@
|
||||
(wantedToReply)="onWantedToReply($event)"
|
||||
(wantedToDelete)="onWantedToDelete($event)"
|
||||
(wantedToRedraft)="onWantedToRedraft($event)"
|
||||
(wantedToApprove)="onWantToApprove($event)"
|
||||
(threadCreated)="onThreadCreated($event)"
|
||||
(resetReply)="onResetReply()"
|
||||
(timestampClicked)="handleTimestampClicked($event)"
|
||||
@ -62,6 +63,7 @@
|
||||
(wantedToReply)="onWantedToReply($event)"
|
||||
(wantedToDelete)="onWantedToDelete($event)"
|
||||
(wantedToRedraft)="onWantedToRedraft($event)"
|
||||
(wantedToApprove)="onWantToApprove($event)"
|
||||
(threadCreated)="onThreadCreated($event)"
|
||||
(resetReply)="onResetReply()"
|
||||
(timestampClicked)="handleTimestampClicked($event)"
|
||||
@ -89,9 +91,7 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div *ngIf="video.commentsEnabled === false" i18n>
|
||||
Comments are disabled.
|
||||
</div>
|
||||
} @else {
|
||||
<div i18n>Comments are disabled.</div>
|
||||
}
|
||||
</div>
|
||||
|
@ -1,21 +1,21 @@
|
||||
import { Subject, Subscription } from 'rxjs'
|
||||
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, hasMoreItems, Notifier, User } from '@app/core'
|
||||
import { AuthService, ComponentPagination, ConfirmService, Notifier, User, hasMoreItems } from '@app/core'
|
||||
import { HooksService } from '@app/core/plugins/hooks.service'
|
||||
import { PeerTubeProblemDocument, ServerErrorCode } from '@peertube/peertube-models'
|
||||
import { LoaderComponent } from '../../../../shared/shared-main/loaders/loader.component'
|
||||
import { VideoCommentComponent } from './video-comment.component'
|
||||
import { InfiniteScrollerDirective } from '../../../../shared/shared-main/angular/infinite-scroller.directive'
|
||||
import { VideoCommentAddComponent } from './video-comment-add.component'
|
||||
import { NgIf, NgFor } from '@angular/common'
|
||||
import { NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownButtonItem, NgbDropdownItem } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { FeedComponent } from '../../../../shared/shared-main/feeds/feed.component'
|
||||
import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
|
||||
import { Syndication } from '@app/shared/shared-main/feeds/syndication.model'
|
||||
import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
|
||||
import { VideoCommentThreadTree } from '@app/shared/shared-video-comment/video-comment-thread-tree.model'
|
||||
import { VideoComment } from '@app/shared/shared-video-comment/video-comment.model'
|
||||
import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
|
||||
import { VideoCommentThreadTree } from '@app/shared/shared-video-comment/video-comment-thread-tree.model'
|
||||
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 { InfiniteScrollerDirective } from '../../../../shared/shared-main/angular/infinite-scroller.directive'
|
||||
import { FeedComponent } from '../../../../shared/shared-main/feeds/feed.component'
|
||||
import { LoaderComponent } from '../../../../shared/shared-main/loaders/loader.component'
|
||||
import { VideoCommentAddComponent } from './video-comment-add.component'
|
||||
import { VideoCommentComponent } from './video-comment.component'
|
||||
|
||||
@Component({
|
||||
selector: 'my-video-comments',
|
||||
@ -61,6 +61,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
|
||||
commentReplyRedraftValue: string
|
||||
commentThreadRedraftValue: string
|
||||
|
||||
commentsEnabled: boolean
|
||||
|
||||
threadComments: { [ id: number ]: VideoCommentThreadTree } = {}
|
||||
threadLoading: { [ id: number ]: boolean } = {}
|
||||
|
||||
@ -258,6 +260,19 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
onWantToApprove (comment: VideoComment) {
|
||||
this.videoCommentService.approveComments([ { commentId: comment.id, videoId: comment.videoId } ])
|
||||
.subscribe({
|
||||
next: () => {
|
||||
comment.heldForReview = false
|
||||
|
||||
this.notifier.success($localize`Comment approved`)
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
isUserLoggedIn () {
|
||||
return this.authService.isLoggedIn()
|
||||
}
|
||||
@ -277,23 +292,25 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
private resetVideo () {
|
||||
if (this.video.commentsEnabled === true) {
|
||||
// Reset all our fields
|
||||
this.highlightedThread = null
|
||||
this.comments = []
|
||||
this.threadComments = {}
|
||||
this.threadLoading = {}
|
||||
this.inReplyToCommentId = undefined
|
||||
this.componentPagination.currentPage = 1
|
||||
this.componentPagination.totalItems = null
|
||||
this.totalNotDeletedComments = null
|
||||
if (this.video.commentsPolicy.id === VideoCommentPolicy.DISABLED) return
|
||||
|
||||
this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video)
|
||||
this.loadMoreThreads()
|
||||
// Reset all our fields
|
||||
this.highlightedThread = null
|
||||
this.comments = []
|
||||
this.threadComments = {}
|
||||
this.threadLoading = {}
|
||||
this.inReplyToCommentId = undefined
|
||||
this.componentPagination.currentPage = 1
|
||||
this.componentPagination.totalItems = null
|
||||
this.totalNotDeletedComments = null
|
||||
|
||||
if (this.activatedRoute.snapshot.params['threadId']) {
|
||||
this.processHighlightedThread(+this.activatedRoute.snapshot.params['threadId'])
|
||||
}
|
||||
this.commentsEnabled = true
|
||||
|
||||
this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video)
|
||||
this.loadMoreThreads()
|
||||
|
||||
if (this.activatedRoute.snapshot.params['threadId']) {
|
||||
this.processHighlightedThread(+this.activatedRoute.snapshot.params['threadId'])
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
HTMLServerConfig,
|
||||
ServerConfig,
|
||||
ServerStats,
|
||||
VideoCommentPolicy,
|
||||
VideoConstant,
|
||||
VideoPlaylistPrivacyType,
|
||||
VideoPrivacyType
|
||||
@ -104,6 +105,24 @@ export class ServerService {
|
||||
return this.htmlConfig
|
||||
}
|
||||
|
||||
getCommentPolicies () {
|
||||
return of([
|
||||
{
|
||||
id: VideoCommentPolicy.DISABLED,
|
||||
label: $localize`Comments are disabled`
|
||||
},
|
||||
{
|
||||
id: VideoCommentPolicy.ENABLED,
|
||||
label: $localize`Comments are enabled`,
|
||||
description: $localize`Comments may require approval depending on your auto tag policies`
|
||||
},
|
||||
{
|
||||
id: VideoCommentPolicy.REQUIRES_APPROVAL,
|
||||
label: $localize`Any new comment requires approval`
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
getVideoCategories () {
|
||||
if (!this.videoCategoriesObservable) {
|
||||
this.videoCategoriesObservable = this.loadAttributeEnum<number>(ServerService.BASE_VIDEO_URL, 'categories', true)
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { AbstractControl, ValidatorFn, Validators } from '@angular/forms'
|
||||
import { splitAndGetNotEmpty } from '@root-helpers/string'
|
||||
import { BuildFormValidator } from './form-validator.model'
|
||||
import { unique } from './shared/validator-utils'
|
||||
|
||||
export function validateHost (value: string) {
|
||||
// Thanks to http://stackoverflow.com/a/106223
|
||||
@ -64,28 +66,6 @@ const validHostsOrHandles: ValidatorFn = (control: AbstractControl) => {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function splitAndGetNotEmpty (value: string) {
|
||||
return value
|
||||
.split('\n')
|
||||
.filter(line => line && line.length !== 0) // Eject empty hosts
|
||||
}
|
||||
|
||||
export const unique: ValidatorFn = (control: AbstractControl) => {
|
||||
if (!control.value) return null
|
||||
|
||||
const hosts = splitAndGetNotEmpty(control.value)
|
||||
|
||||
if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
unique: {
|
||||
reason: 'invalid'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const UNIQUE_HOSTS_VALIDATOR: BuildFormValidator = {
|
||||
VALIDATORS: [ Validators.required, validHosts, unique ],
|
||||
MESSAGES: {
|
||||
|
@ -0,0 +1,18 @@
|
||||
import { AbstractControl, ValidatorFn } from '@angular/forms'
|
||||
import { splitAndGetNotEmpty } from '@root-helpers/string'
|
||||
|
||||
export const unique: ValidatorFn = (control: AbstractControl) => {
|
||||
if (!control.value) return null
|
||||
|
||||
const hosts = splitAndGetNotEmpty(control.value)
|
||||
|
||||
if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
unique: {
|
||||
reason: 'invalid'
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
import { AbstractControl, ValidatorFn, Validators } from '@angular/forms'
|
||||
import { splitAndGetNotEmpty } from '@root-helpers/string'
|
||||
import { BuildFormValidator } from './form-validator.model'
|
||||
import { unique } from './shared/validator-utils'
|
||||
|
||||
const validWords: ValidatorFn = (control: AbstractControl) => {
|
||||
if (!control.value) return null
|
||||
|
||||
const errors = []
|
||||
const words = splitAndGetNotEmpty(control.value)
|
||||
|
||||
for (const word of words) {
|
||||
if (word.length < 1 || word.length > 100) {
|
||||
errors.push($localize`${word} is not valid (min 1 character/max 100 characters)`)
|
||||
}
|
||||
}
|
||||
|
||||
if (words.length > 500) {
|
||||
errors.push($localize`There are too much words in the list (max 500 words)`)
|
||||
}
|
||||
|
||||
// valid
|
||||
if (errors.length === 0) return null
|
||||
|
||||
return {
|
||||
validWords: {
|
||||
reason: 'invalid',
|
||||
value: errors.join('. ') + '.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const WATCHED_WORDS_LIST_NAME_VALIDATOR: BuildFormValidator = {
|
||||
VALIDATORS: [ Validators.required, Validators.minLength(1), Validators.maxLength(100) ],
|
||||
MESSAGES: {
|
||||
required: $localize`List name is required.`,
|
||||
minlength: $localize`List name must be at least 1 character long.`,
|
||||
maxlength: $localize`List name cannot be more than 100 characters long.`
|
||||
}
|
||||
}
|
||||
|
||||
export const UNIQUE_WATCHED_WORDS_VALIDATOR: BuildFormValidator = {
|
||||
VALIDATORS: [ Validators.required, unique, validWords ],
|
||||
MESSAGES: {
|
||||
required: $localize`Words are required.`,
|
||||
unique: $localize`Words entered contain duplicates.`,
|
||||
validWords: $localize`A word must be between 1 and 100 characters and the total number of words must not exceed 500 items`
|
||||
}
|
||||
}
|
@ -10,35 +10,37 @@
|
||||
|
||||
<ng-container *ngIf="!menuEntry.routerLink && isDisplayed(menuEntry)">
|
||||
<!-- On mobile, use a modal to display sub menu items -->
|
||||
<li *ngIf="isInSmallView">
|
||||
<button class="sub-menu-entry" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" (click)="openModal(id)">
|
||||
{{ menuEntry.label }}
|
||||
|
||||
<span class="chevron-down"></span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<!-- On desktop, use a classic dropdown -->
|
||||
<div *ngIf="!isInSmallView" ngbDropdown #dropdown="ngbDropdown" autoClose="true" container="body">
|
||||
@if (isInSmallView) {
|
||||
<li>
|
||||
<button ngbDropdownToggle class="sub-menu-entry" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }">{{ menuEntry.label }}</button>
|
||||
<button class="sub-menu-entry" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" (click)="openModal(id)">
|
||||
{{ menuEntry.label }}
|
||||
|
||||
<span class="chevron-down"></span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<ul ngbDropdownMenu>
|
||||
<li *ngFor="let menuChild of menuEntry.children">
|
||||
<a
|
||||
*ngIf="isDisplayed(menuChild)" ngbDropdownItem
|
||||
routerLinkActive="active" ariaCurrentWhenActive="page"
|
||||
[routerLink]="menuChild.routerLink" #routerLink (click)="onActiveLinkScrollToTop(routerLink)"
|
||||
[queryParams]="menuChild.queryParams"
|
||||
>
|
||||
<my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName" aria-hidden="true"></my-global-icon>
|
||||
|
||||
{{ menuChild.label }}
|
||||
</a>
|
||||
} @else {
|
||||
<!-- On desktop, use a classic dropdown -->
|
||||
<div ngbDropdown #dropdown="ngbDropdown" autoClose="true" container="body">
|
||||
<li>
|
||||
<button ngbDropdownToggle class="sub-menu-entry" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }">{{ menuEntry.label }}</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ul ngbDropdownMenu>
|
||||
<li *ngFor="let menuChild of menuEntry.children">
|
||||
<a
|
||||
*ngIf="isDisplayed(menuChild)" ngbDropdownItem
|
||||
routerLinkActive="active" ariaCurrentWhenActive="page"
|
||||
[routerLink]="menuChild.routerLink" #routerLink (click)="onActiveLinkScrollToTop(routerLink)"
|
||||
[queryParams]="menuChild.queryParams"
|
||||
>
|
||||
<my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName" aria-hidden="true"></my-global-icon>
|
||||
|
||||
{{ menuChild.label }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Subscription } from 'rxjs'
|
||||
import { filter } from 'rxjs/operators'
|
||||
import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
|
||||
import { Component, Input, OnChanges, OnDestroy, OnInit, ViewChild } from '@angular/core'
|
||||
import { NavigationEnd, Router, RouterLinkActive, RouterLink } from '@angular/router'
|
||||
import { MenuService, ScreenService } from '@app/core'
|
||||
import { scrollToTop } from '@app/helpers'
|
||||
@ -42,7 +42,7 @@ export type TopMenuDropdownParam = {
|
||||
GlobalIconComponent
|
||||
]
|
||||
})
|
||||
export class TopMenuDropdownComponent implements OnInit, OnDestroy {
|
||||
export class TopMenuDropdownComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@Input() menuEntries: TopMenuDropdownParam[] = []
|
||||
|
||||
@ViewChild('modal', { static: true }) modal: NgbModal
|
||||
@ -82,6 +82,10 @@ export class TopMenuDropdownComponent implements OnInit, OnDestroy {
|
||||
.subscribe(() => this.updateChildLabels(window.location.pathname))
|
||||
}
|
||||
|
||||
ngOnChanges () {
|
||||
this.updateChildLabels(window.location.pathname)
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
if (this.routeSub) this.routeSub.unsubscribe()
|
||||
}
|
||||
|
@ -36,6 +36,7 @@ export class UserNotification implements UserNotificationServer {
|
||||
comment?: {
|
||||
id: number
|
||||
threadId: number
|
||||
heldForReview: boolean
|
||||
account: ActorInfo & { avatarUrl?: string }
|
||||
video: VideoInfo
|
||||
}
|
||||
@ -96,6 +97,9 @@ export class UserNotification implements UserNotificationServer {
|
||||
videoUrl?: string
|
||||
commentUrl?: any[]
|
||||
|
||||
commentReviewUrl?: string
|
||||
commentReviewQueryParams?: { [id: string]: string } = {}
|
||||
|
||||
abuseUrl?: string
|
||||
abuseQueryParams?: { [id: string]: string } = {}
|
||||
|
||||
@ -163,6 +167,9 @@ export class UserNotification implements UserNotificationServer {
|
||||
if (!this.comment) break
|
||||
this.accountUrl = this.buildAccountUrl(this.comment.account)
|
||||
this.commentUrl = this.buildCommentUrl(this.comment)
|
||||
|
||||
this.commentReviewUrl = '/my-account/videos/comments'
|
||||
this.commentReviewQueryParams.search = 'heldForReview:true'
|
||||
break
|
||||
|
||||
case UserNotificationType.NEW_ABUSE_FOR_MODERATORS:
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Account } from '@app/shared/shared-main/account/account.model'
|
||||
import { VideoChannel } from '@app/shared/shared-main/video-channel/video-channel.model'
|
||||
import {
|
||||
VideoCommentPolicyType,
|
||||
VideoConstant,
|
||||
VideoDetails as VideoDetailsServerModel,
|
||||
VideoFile,
|
||||
@ -16,12 +17,14 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
|
||||
channel: VideoChannel
|
||||
tags: string[]
|
||||
account: Account
|
||||
commentsEnabled: boolean
|
||||
downloadEnabled: boolean
|
||||
|
||||
waitTranscoding: boolean
|
||||
state: VideoConstant<VideoStateType>
|
||||
|
||||
commentsEnabled: never
|
||||
commentsPolicy: VideoConstant<VideoCommentPolicyType>
|
||||
|
||||
likesPercent: number
|
||||
dislikesPercent: number
|
||||
|
||||
@ -40,7 +43,7 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
|
||||
this.account = new Account(hash.account)
|
||||
this.tags = hash.tags
|
||||
this.support = hash.support
|
||||
this.commentsEnabled = hash.commentsEnabled
|
||||
this.commentsPolicy = hash.commentsPolicy
|
||||
this.downloadEnabled = hash.downloadEnabled
|
||||
|
||||
this.inputFileUpdatedAt = hash.inputFileUpdatedAt
|
||||
|
@ -1,6 +1,13 @@
|
||||
import { getAbsoluteAPIUrl } from '@app/helpers'
|
||||
import { objectKeysTyped } from '@peertube/peertube-core-utils'
|
||||
import { VideoPassword, VideoPrivacy, VideoPrivacyType, VideoScheduleUpdate, VideoUpdate } from '@peertube/peertube-models'
|
||||
import {
|
||||
VideoCommentPolicyType,
|
||||
VideoPassword,
|
||||
VideoPrivacy,
|
||||
VideoPrivacyType,
|
||||
VideoScheduleUpdate,
|
||||
VideoUpdate
|
||||
} from '@peertube/peertube-models'
|
||||
import { VideoDetails } from './video-details.model'
|
||||
|
||||
export class VideoEdit implements VideoUpdate {
|
||||
@ -13,7 +20,7 @@ export class VideoEdit implements VideoUpdate {
|
||||
name: string
|
||||
tags: string[]
|
||||
nsfw: boolean
|
||||
commentsEnabled: boolean
|
||||
commentsPolicy: VideoCommentPolicyType
|
||||
downloadEnabled: boolean
|
||||
waitTranscoding: boolean
|
||||
channelId: number
|
||||
@ -52,7 +59,7 @@ export class VideoEdit implements VideoUpdate {
|
||||
|
||||
this.support = video.support
|
||||
|
||||
this.commentsEnabled = video.commentsEnabled
|
||||
this.commentsPolicy = video.commentsPolicy.id
|
||||
this.downloadEnabled = video.downloadEnabled
|
||||
|
||||
if (video.thumbnailPath) this.thumbnailUrl = getAbsoluteAPIUrl() + video.thumbnailPath
|
||||
@ -109,7 +116,7 @@ export class VideoEdit implements VideoUpdate {
|
||||
name: this.name,
|
||||
tags: this.tags,
|
||||
nsfw: this.nsfw,
|
||||
commentsEnabled: this.commentsEnabled,
|
||||
commentsPolicy: this.commentsPolicy,
|
||||
downloadEnabled: this.downloadEnabled,
|
||||
waitTranscoding: this.waitTranscoding,
|
||||
channelId: this.channelId,
|
||||
|
@ -99,7 +99,7 @@ export class VideoImportService {
|
||||
tags: video.tags,
|
||||
nsfw: video.nsfw,
|
||||
waitTranscoding: video.waitTranscoding,
|
||||
commentsEnabled: video.commentsEnabled,
|
||||
commentsPolicy: video.commentsPolicy,
|
||||
downloadEnabled: video.downloadEnabled,
|
||||
thumbnailfile: video.thumbnailfile,
|
||||
previewfile: video.previewfile,
|
||||
|
@ -114,6 +114,8 @@ export class Video implements VideoServerModel {
|
||||
|
||||
videoSource?: VideoSource
|
||||
|
||||
automaticTags?: string[]
|
||||
|
||||
static buildWatchUrl (video: Partial<Pick<Video, 'uuid' | 'shortUUID'>>) {
|
||||
return buildVideoWatchPath({ shortUUID: video.shortUUID || video.uuid })
|
||||
}
|
||||
@ -205,6 +207,8 @@ export class Video implements VideoServerModel {
|
||||
this.pluginData = hash.pluginData
|
||||
|
||||
this.aspectRatio = hash.aspectRatio
|
||||
|
||||
this.automaticTags = hash.automaticTags
|
||||
}
|
||||
|
||||
isVideoNSFWForUser (user: User, serverConfig: HTMLServerConfig) {
|
||||
|
@ -109,7 +109,7 @@ export class VideoService {
|
||||
tags: video.tags,
|
||||
nsfw: video.nsfw,
|
||||
waitTranscoding: video.waitTranscoding,
|
||||
commentsEnabled: video.commentsEnabled,
|
||||
commentsPolicy: video.commentsPolicy,
|
||||
downloadEnabled: video.downloadEnabled,
|
||||
thumbnailfile: video.thumbnailfile,
|
||||
previewfile: video.previewfile,
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { NgClass, NgIf } from '@angular/common'
|
||||
import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
|
||||
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
|
||||
import { splitAndGetNotEmpty, UNIQUE_HOSTS_VALIDATOR } from '../form-validators/host-validators'
|
||||
import { NgClass, NgIf } from '@angular/common'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { splitAndGetNotEmpty } from '@root-helpers/string'
|
||||
import { UNIQUE_HOSTS_VALIDATOR } from '../form-validators/host-validators'
|
||||
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
|
||||
|
||||
@Component({
|
||||
|
@ -407,7 +407,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
|
||||
])
|
||||
}
|
||||
|
||||
if (this.account && this.displayOptions.instanceAccount && authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)) {
|
||||
if (this.account && this.displayOptions.instanceAccount && authUser.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT)) {
|
||||
instanceActions = instanceActions.concat([
|
||||
{
|
||||
label: $localize`Remove comments from your instance`,
|
||||
|
@ -0,0 +1,122 @@
|
||||
<p-table
|
||||
[value]="comments" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
|
||||
[rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id"
|
||||
[lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
|
||||
[showCurrentPageReport]="true" [currentPageReportTemplate]="getPaginationTemplate()"
|
||||
[expandedRowKeys]="expandedRows" [(selection)]="selectedRows"
|
||||
>
|
||||
<ng-template pTemplate="caption">
|
||||
<div class="caption">
|
||||
<div>
|
||||
<my-action-dropdown
|
||||
*ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
|
||||
[actions]="bulkActions" [entry]="selectedRows"
|
||||
>
|
||||
</my-action-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="ms-auto right-form">
|
||||
<my-advanced-input-filter [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter>
|
||||
|
||||
<my-button i18n-label label="Refresh" icon="refresh" (click)="reloadData()"></my-button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="header">
|
||||
<tr>
|
||||
<th scope="col" style="width: 40px;">
|
||||
<p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
|
||||
</th>
|
||||
<th scope="col" style="width: 40px;">
|
||||
<span i18n class="visually-hidden">More information</span>
|
||||
</th>
|
||||
<th scope="col" style="width: 150px;">
|
||||
<span i18n class="visually-hidden">Actions</span>
|
||||
</th>
|
||||
<th scope="col" i18n>Account</th>
|
||||
<th scope="col" i18n>Video</th>
|
||||
<th scope="col" i18n>Comment</th>
|
||||
<th scope="col" i18n>Auto tags</th>
|
||||
<th scope="col" style="width: 150px;" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="createdAt">Date <p-sortIcon field="createdAt"></p-sortIcon></th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="body" let-videoComment let-expanded="expanded">
|
||||
<tr [pSelectableRow]="videoComment">
|
||||
|
||||
<td class="checkbox-cell">
|
||||
<p-tableCheckbox [value]="videoComment" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox>
|
||||
</td>
|
||||
|
||||
<td class="expand-cell">
|
||||
<my-table-expander-icon [pRowToggler]="videoComment" i18n-tooltip tooltip="See full comment" [expanded]="expanded"></my-table-expander-icon>
|
||||
</td>
|
||||
|
||||
<td class="action-cell">
|
||||
<my-action-dropdown
|
||||
[ngClass]="{ 'show': expanded }" placement="bottom-right" container="body"
|
||||
i18n-label label="Actions" [actions]="videoCommentActions" [entry]="videoComment"
|
||||
></my-action-dropdown>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a [href]="videoComment.account.localUrl" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
|
||||
<div class="chip two-lines">
|
||||
<my-actor-avatar [actor]="videoComment.account" actorType="account" size="32"></my-actor-avatar>
|
||||
<div>
|
||||
{{ videoComment.account.displayName }}
|
||||
<span>{{ videoComment.by }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<td class="video">
|
||||
<em i18n>Commented video</em>
|
||||
|
||||
<a [href]="videoComment.localUrl" target="_blank" rel="noopener noreferrer">{{ videoComment.video.name }}</a>
|
||||
</td>
|
||||
|
||||
<td class="c-hand comment-content-cell" [pRowToggler]="videoComment">
|
||||
<span *ngIf="videoComment.heldForReview" class="pt-badge badge-red float-start me-2" i18n>Pending review</span>
|
||||
|
||||
<div class="comment-html">
|
||||
<div [innerHTML]="videoComment.textHtml"></div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
@for (tag of videoComment.automaticTags; track tag) {
|
||||
<a
|
||||
i18n-title title="Only display comments with this tag"
|
||||
class="pt-badge badge-secondary me-1"
|
||||
[routerLink]="[ '.' ]" [queryParams]="{ 'search': buildSearchAutoTag(tag) }"
|
||||
>{{ tag }}</a>
|
||||
}
|
||||
</td>
|
||||
|
||||
<td class="c-hand" [pRowToggler]="videoComment">{{ videoComment.createdAt | date: 'short' }}</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="rowexpansion" let-videoComment>
|
||||
<tr>
|
||||
<td class="expand-cell" myAutoColspan>
|
||||
<div [innerHTML]="videoComment.textHtml"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="emptymessage">
|
||||
<tr>
|
||||
<td myAutoColspan>
|
||||
<div class="no-results">
|
||||
<ng-container *ngIf="search" i18n>No comments found matching current filters.</ng-container>
|
||||
<ng-container *ngIf="!search" i18n>No comments found.</ng-container>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
|
@ -0,0 +1,60 @@
|
||||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
|
||||
my-global-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.video {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
em {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
a {
|
||||
@include ellipsis;
|
||||
|
||||
color: pvar(--mainForegroundColor);
|
||||
}
|
||||
}
|
||||
|
||||
.comment-content-cell {
|
||||
> .pt-badge {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.comment-html {
|
||||
::ng-deep {
|
||||
> div {
|
||||
max-height: 22px;
|
||||
}
|
||||
|
||||
div,
|
||||
p {
|
||||
@include ellipsis;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right-form {
|
||||
display: flex;
|
||||
|
||||
> *:not(:last-child) {
|
||||
@include margin-right(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $primeng-breakpoint) {
|
||||
.video {
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
}
|
@ -0,0 +1,260 @@
|
||||
import { DatePipe, NgClass, NgIf } from '@angular/common'
|
||||
import { Component, Input, OnInit } from '@angular/core'
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router'
|
||||
import { AuthService, ConfirmService, MarkdownService, Notifier, 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'
|
||||
import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
|
||||
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 { 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'
|
||||
import { AutoColspanDirective } from '../shared-main/angular/auto-colspan.directive'
|
||||
import { ActionDropdownComponent, DropdownAction } from '../shared-main/buttons/action-dropdown.component'
|
||||
import { ButtonComponent } from '../shared-main/buttons/button.component'
|
||||
import { FeedComponent } from '../shared-main/feeds/feed.component'
|
||||
import { TableExpanderIconComponent } from '../shared-tables/table-expander-icon.component'
|
||||
|
||||
@Component({
|
||||
selector: 'my-video-comment-list-admin-owner',
|
||||
templateUrl: './video-comment-list-admin-owner.component.html',
|
||||
styleUrls: [ '../shared-moderation/moderation.scss', './video-comment-list-admin-owner.component.scss' ],
|
||||
standalone: true,
|
||||
imports: [
|
||||
GlobalIconComponent,
|
||||
FeedComponent,
|
||||
TableModule,
|
||||
SharedModule,
|
||||
NgIf,
|
||||
ActionDropdownComponent,
|
||||
AdvancedInputFilterComponent,
|
||||
ButtonComponent,
|
||||
NgbTooltip,
|
||||
TableExpanderIconComponent,
|
||||
NgClass,
|
||||
ActorAvatarComponent,
|
||||
AutoColspanDirective,
|
||||
DatePipe,
|
||||
RouterLink
|
||||
]
|
||||
})
|
||||
export class VideoCommentListAdminOwnerComponent extends RestTable <VideoCommentForAdminOrUser> implements OnInit {
|
||||
@Input({ required: true }) mode: 'user' | 'admin'
|
||||
|
||||
comments: VideoCommentForAdminOrUser[]
|
||||
totalRecords = 0
|
||||
sort: SortMeta = { field: 'createdAt', order: -1 }
|
||||
pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
|
||||
|
||||
videoCommentActions: DropdownAction<VideoCommentForAdminOrUser>[][] = []
|
||||
|
||||
bulkActions: DropdownAction<VideoCommentForAdminOrUser[]>[] = []
|
||||
|
||||
inputFilters: AdvancedInputFilter[] = []
|
||||
|
||||
get authUser () {
|
||||
return this.auth.getUser()
|
||||
}
|
||||
|
||||
constructor (
|
||||
protected router: Router,
|
||||
protected route: ActivatedRoute,
|
||||
private auth: AuthService,
|
||||
private notifier: Notifier,
|
||||
private confirmService: ConfirmService,
|
||||
private videoCommentService: VideoCommentService,
|
||||
private markdownRenderer: MarkdownService,
|
||||
private bulkService: BulkService
|
||||
) {
|
||||
super()
|
||||
|
||||
this.videoCommentActions = [
|
||||
[
|
||||
{
|
||||
label: $localize`Delete this comment`,
|
||||
handler: comment => this.removeComment(comment),
|
||||
isDisplayed: () => this.mode === 'user' || this.authUser.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT)
|
||||
},
|
||||
{
|
||||
label: $localize`Delete all comments of this account`,
|
||||
description: $localize`Comments are deleted after a few minutes`,
|
||||
handler: comment => this.removeCommentsOfAccount(comment),
|
||||
isDisplayed: () => this.mode === 'admin' && this.authUser.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT)
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
label: $localize`Approve this comment`,
|
||||
handler: comment => this.approveComments([ comment ]),
|
||||
isDisplayed: comment => this.mode === 'user' && comment.heldForReview
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.initialize()
|
||||
|
||||
this.bulkActions = [
|
||||
{
|
||||
label: $localize`Delete`,
|
||||
handler: comments => this.removeComments(comments),
|
||||
isDisplayed: () => this.mode === 'user' || this.authUser.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT),
|
||||
iconName: 'delete'
|
||||
},
|
||||
{
|
||||
label: $localize`Approve`,
|
||||
handler: comments => this.approveComments(comments),
|
||||
isDisplayed: comments => this.mode === 'user' && comments.every(c => c.heldForReview),
|
||||
iconName: 'tick'
|
||||
}
|
||||
]
|
||||
|
||||
if (this.mode === 'admin') {
|
||||
this.inputFilters = [
|
||||
{
|
||||
title: $localize`Advanced filters`,
|
||||
children: [
|
||||
{
|
||||
value: 'local:true',
|
||||
label: $localize`Local comments`
|
||||
},
|
||||
{
|
||||
value: 'local:false',
|
||||
label: $localize`Remote comments`
|
||||
},
|
||||
{
|
||||
value: 'localVideo:true',
|
||||
label: $localize`Comments on local videos`
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
} else {
|
||||
this.inputFilters = [
|
||||
{
|
||||
title: $localize`Advanced filters`,
|
||||
children: [
|
||||
{
|
||||
value: 'heldForReview:true',
|
||||
label: $localize`Display comments awaiting your approval`
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
getIdentifier () {
|
||||
return 'VideoCommentListAdminOwnerComponent'
|
||||
}
|
||||
|
||||
toHtml (text: string) {
|
||||
return this.markdownRenderer.textMarkdownToHTML({ markdown: text, withHtml: true, withEmoji: true })
|
||||
}
|
||||
|
||||
buildSearchAutoTag (tag: string) {
|
||||
const str = `autoTag:"${tag}"`
|
||||
|
||||
if (this.search) return this.search + ' ' + str
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
protected 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
|
||||
|
||||
this.comments = []
|
||||
|
||||
for (const c of resultList.data) {
|
||||
this.comments.push(new VideoCommentForAdminOrUser(c, await this.toHtml(c.text)))
|
||||
}
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
private approveComments (comments: VideoCommentForAdminOrUser[]) {
|
||||
const commentArgs = comments.map(c => ({ videoId: c.video.id, commentId: c.id }))
|
||||
|
||||
this.videoCommentService.approveComments(commentArgs)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.notifier.success(
|
||||
formatICU(
|
||||
$localize`{count, plural, =1 {Comment approved.} other {{count} comments approved.}}`,
|
||||
{ count: commentArgs.length }
|
||||
)
|
||||
)
|
||||
|
||||
this.reloadData()
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message),
|
||||
|
||||
complete: () => this.selectedRows = []
|
||||
})
|
||||
}
|
||||
|
||||
private removeComments (comments: VideoCommentForAdminOrUser[]) {
|
||||
const commentArgs = comments.map(c => ({ videoId: c.video.id, commentId: c.id }))
|
||||
|
||||
this.videoCommentService.deleteVideoComments(commentArgs)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.notifier.success(
|
||||
formatICU(
|
||||
$localize`{count, plural, =1 {1 comment deleted.} other {{count} comments deleted.}}`,
|
||||
{ count: commentArgs.length }
|
||||
)
|
||||
)
|
||||
|
||||
this.reloadData()
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message),
|
||||
|
||||
complete: () => this.selectedRows = []
|
||||
})
|
||||
}
|
||||
|
||||
private removeComment (comment: VideoCommentForAdminOrUser) {
|
||||
this.videoCommentService.deleteVideoComment(comment.video.id, comment.id)
|
||||
.subscribe({
|
||||
next: () => this.reloadData(),
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
private async removeCommentsOfAccount (comment: VideoCommentForAdminOrUser) {
|
||||
const message = $localize`Do you really want to delete all comments of ${comment.by}?`
|
||||
const res = await this.confirmService.confirm(message, $localize`Delete`)
|
||||
if (res === false) return
|
||||
|
||||
const options = {
|
||||
accountName: comment.by,
|
||||
scope: 'instance' as 'instance'
|
||||
}
|
||||
|
||||
this.bulkService.removeCommentsOf(options)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.notifier.success($localize`Comments of ${options.accountName} will be deleted in a few minutes`)
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
}
|
||||
}
|
@ -2,7 +2,7 @@ import { getAbsoluteAPIUrl } from '@app/helpers'
|
||||
import {
|
||||
Account as AccountInterface,
|
||||
VideoComment as VideoCommentServerModel,
|
||||
VideoCommentAdmin as VideoCommentAdminServerModel
|
||||
VideoCommentForAdminOrUser as VideoCommentForAdminOrUserServerModel
|
||||
} from '@peertube/peertube-models'
|
||||
import { Actor } from '../shared-main/account/actor.model'
|
||||
import { Video } from '../shared-main/video/video.model'
|
||||
@ -18,6 +18,7 @@ export class VideoComment implements VideoCommentServerModel {
|
||||
updatedAt: Date | string
|
||||
deletedAt: Date | string
|
||||
isDeleted: boolean
|
||||
heldForReview: boolean
|
||||
account: AccountInterface
|
||||
totalRepliesFromVideoAuthor: number
|
||||
totalReplies: number
|
||||
@ -36,6 +37,7 @@ export class VideoComment implements VideoCommentServerModel {
|
||||
this.updatedAt = new Date(hash.updatedAt.toString())
|
||||
this.deletedAt = hash.deletedAt ? new Date(hash.deletedAt.toString()) : null
|
||||
this.isDeleted = hash.isDeleted
|
||||
this.heldForReview = hash.heldForReview
|
||||
this.account = hash.account
|
||||
this.totalRepliesFromVideoAuthor = hash.totalRepliesFromVideoAuthor
|
||||
this.totalReplies = hash.totalReplies
|
||||
@ -50,7 +52,7 @@ export class VideoComment implements VideoCommentServerModel {
|
||||
}
|
||||
}
|
||||
|
||||
export class VideoCommentAdmin implements VideoCommentAdminServerModel {
|
||||
export class VideoCommentForAdminOrUser implements VideoCommentForAdminOrUserServerModel {
|
||||
id: number
|
||||
url: string
|
||||
text: string
|
||||
@ -72,20 +74,28 @@ export class VideoCommentAdmin implements VideoCommentAdminServerModel {
|
||||
localUrl: string
|
||||
}
|
||||
|
||||
heldForReview: boolean
|
||||
|
||||
automaticTags: string[]
|
||||
|
||||
by: string
|
||||
|
||||
constructor (hash: VideoCommentAdminServerModel, textHtml: string) {
|
||||
constructor (hash: VideoCommentForAdminOrUserServerModel, textHtml: string) {
|
||||
this.id = hash.id
|
||||
this.url = hash.url
|
||||
this.text = hash.text
|
||||
this.textHtml = textHtml
|
||||
|
||||
this.heldForReview = hash.heldForReview
|
||||
|
||||
this.threadId = hash.threadId
|
||||
this.inReplyToCommentId = hash.inReplyToCommentId
|
||||
|
||||
this.createdAt = new Date(hash.createdAt.toString())
|
||||
this.updatedAt = new Date(hash.updatedAt.toString())
|
||||
|
||||
this.automaticTags = hash.automaticTags
|
||||
|
||||
this.video = {
|
||||
id: hash.video.id,
|
||||
uuid: hash.video.uuid,
|
||||
|
@ -1,6 +1,3 @@
|
||||
import { SortMeta } from 'primeng/api'
|
||||
import { from, Observable } from 'rxjs'
|
||||
import { catchError, concatMap, map, toArray } from 'rxjs/operators'
|
||||
import { HttpClient, HttpParams } from '@angular/common/http'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ComponentPaginationLight, RestExtractor, RestPagination, RestService } from '@app/core'
|
||||
@ -10,21 +7,25 @@ import {
|
||||
ResultList,
|
||||
ThreadsResultList,
|
||||
Video,
|
||||
VideoComment as VideoCommentServerModel,
|
||||
VideoCommentAdmin,
|
||||
VideoCommentCreate,
|
||||
VideoCommentForAdminOrUser,
|
||||
VideoComment as VideoCommentServerModel,
|
||||
VideoCommentThreadTree as VideoCommentThreadTreeServerModel
|
||||
} from '@peertube/peertube-models'
|
||||
import { SortMeta } from 'primeng/api'
|
||||
import { Observable, from } from 'rxjs'
|
||||
import { catchError, concatMap, map, toArray } from 'rxjs/operators'
|
||||
import { environment } from '../../../environments/environment'
|
||||
import { VideoPasswordService } from '../shared-main/video/video-password.service'
|
||||
import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
|
||||
import { VideoComment } from './video-comment.model'
|
||||
import { VideoPasswordService } from '../shared-main/video/video-password.service'
|
||||
|
||||
@Injectable()
|
||||
export class VideoCommentService {
|
||||
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/video-comments.'
|
||||
|
||||
private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
|
||||
private static BASE_ME_URL = environment.apiUrl + '/api/v1/users/me/'
|
||||
|
||||
constructor (
|
||||
private authHttp: HttpClient,
|
||||
@ -57,11 +58,52 @@ export class VideoCommentService {
|
||||
)
|
||||
}
|
||||
|
||||
getAdminVideoComments (options: {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
approveComments (comments: {
|
||||
videoId: number
|
||||
commentId: number
|
||||
}[]) {
|
||||
return from(comments)
|
||||
.pipe(
|
||||
concatMap(({ videoId, commentId }) => {
|
||||
const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + commentId + '/approve'
|
||||
|
||||
return this.authHttp.post(url, {})
|
||||
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||
}),
|
||||
toArray()
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
listVideoCommentsOfMyVideos (options: {
|
||||
pagination: RestPagination
|
||||
sort: SortMeta
|
||||
search?: string
|
||||
}): Observable<ResultList<VideoCommentAdmin>> {
|
||||
}): Observable<ResultList<VideoCommentForAdminOrUser>> {
|
||||
const { pagination, sort, search } = options
|
||||
const url = VideoCommentService.BASE_ME_URL + 'videos/comments'
|
||||
|
||||
let params = new HttpParams()
|
||||
params = this.restService.addRestGetParams(params, pagination, sort)
|
||||
|
||||
if (search) {
|
||||
params = this.buildParamsFromSearch(search, params)
|
||||
}
|
||||
|
||||
return this.authHttp.get<ResultList<VideoCommentForAdminOrUser>>(url, { params })
|
||||
.pipe(
|
||||
catchError(res => this.restExtractor.handleError(res))
|
||||
)
|
||||
}
|
||||
|
||||
listAdminVideoComments (options: {
|
||||
pagination: RestPagination
|
||||
sort: SortMeta
|
||||
search?: string
|
||||
}): Observable<ResultList<VideoCommentForAdminOrUser>> {
|
||||
const { pagination, sort, search } = options
|
||||
const url = VideoCommentService.BASE_VIDEO_URL + 'comments'
|
||||
|
||||
@ -72,12 +114,14 @@ export class VideoCommentService {
|
||||
params = this.buildParamsFromSearch(search, params)
|
||||
}
|
||||
|
||||
return this.authHttp.get<ResultList<VideoCommentAdmin>>(url, { params })
|
||||
return this.authHttp.get<ResultList<VideoCommentForAdminOrUser>>(url, { params })
|
||||
.pipe(
|
||||
catchError(res => this.restExtractor.handleError(res))
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getVideoCommentThreads (parameters: {
|
||||
videoId: string
|
||||
videoPassword: string
|
||||
@ -118,6 +162,8 @@ export class VideoCommentService {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
deleteVideoComment (videoId: number | string, commentId: number) {
|
||||
const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comments/${commentId}`
|
||||
|
||||
@ -134,6 +180,8 @@ export class VideoCommentService {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getVideoCommentsFeeds (video: Pick<Video, 'uuid'>) {
|
||||
const feeds = [
|
||||
{
|
||||
@ -204,6 +252,16 @@ export class VideoCommentService {
|
||||
isBoolean: true
|
||||
},
|
||||
|
||||
isHeldForReview: {
|
||||
prefix: 'heldForReview:',
|
||||
isBoolean: true
|
||||
},
|
||||
|
||||
autoTagOneOf: {
|
||||
prefix: 'autoTag:',
|
||||
multiple: true
|
||||
},
|
||||
|
||||
searchAccount: { prefix: 'account:' },
|
||||
searchVideo: { prefix: 'video:' }
|
||||
})
|
||||
|
@ -58,7 +58,7 @@ export class VideoPlaylistService {
|
||||
) {
|
||||
this.videoExistsInPlaylistObservable = merge(
|
||||
buildBulkObservable({
|
||||
time: 500,
|
||||
time: 5000,
|
||||
bulkGet: (videoIds: number[]) => {
|
||||
// We added a delay to the request, so ensure the user is still logged in
|
||||
if (this.auth.isLoggedIn()) {
|
||||
|
@ -94,6 +94,7 @@
|
||||
|
||||
<div class="message" i18n>
|
||||
<a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> commented your video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.comment.video.name }}</a>
|
||||
<ng-container *ngIf="notification.comment.heldForReview">. This comment requires <a (click)="markAsRead(notification)" [routerLink]="notification.commentReviewUrl" [queryParams]="notification.commentReviewQueryParams">your approval</a></ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
|
@ -0,0 +1,86 @@
|
||||
<p-table
|
||||
[value]="lists" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
|
||||
[rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id"
|
||||
[lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="false"
|
||||
[showCurrentPageReport]="true" [currentPageReportTemplate]="getPaginationTemplate()"
|
||||
[expandedRowKeys]="expandedRows"
|
||||
>
|
||||
<ng-template pTemplate="caption">
|
||||
<div class="caption">
|
||||
<div class="left-buttons">
|
||||
<button type="button" *ngIf="!isInSelectionMode()" class="peertube-create-button" (click)="openCreateOrUpdateList()">
|
||||
<my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
|
||||
<ng-container i18n>Create a new list</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="ms-auto right-form">
|
||||
<my-button i18n-label label="Refresh" icon="refresh" (click)="reloadData()"></my-button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="header">
|
||||
<tr>
|
||||
<th scope="col" style="width: 40px;">
|
||||
<span i18n class="visually-hidden">More information</span>
|
||||
</th>
|
||||
<th scope="col" style="width: 150px;">
|
||||
<span i18n class="visually-hidden">Actions</span>
|
||||
</th>
|
||||
<th scope="col" style="width: 300px;" i18n>List name</th>
|
||||
<th scope="col" style="width: 300px;" i18n>Words</th>
|
||||
<th scope="col" style="width: 150px;" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="updatedAt">Date <p-sortIcon field="updatedAt"></p-sortIcon></th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="body" let-list let-expanded="expanded">
|
||||
<tr>
|
||||
|
||||
<td class="expand-cell">
|
||||
<my-table-expander-icon [pRowToggler]="list" i18n-tooltip tooltip="See all words" [expanded]="expanded"></my-table-expander-icon>
|
||||
</td>
|
||||
|
||||
<td class="action-cell">
|
||||
<my-action-dropdown
|
||||
[ngClass]="{ 'show': expanded }" placement="bottom-right" container="body"
|
||||
i18n-label label="Actions" [actions]="actions" [entry]="list"
|
||||
></my-action-dropdown>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{{ list.listName }}
|
||||
</td>
|
||||
|
||||
<td i18n>
|
||||
{{ list.words.length }} words
|
||||
</td>
|
||||
|
||||
<td>{{ list.updatedAt | date: 'short' }}</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="rowexpansion" let-list>
|
||||
<tr>
|
||||
<td class="expand-cell" myAutoColspan>
|
||||
<ul>
|
||||
@for (word of list.words; track word) {
|
||||
<li>{{ word }}</li>
|
||||
}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="emptymessage">
|
||||
<tr>
|
||||
<td myAutoColspan>
|
||||
<div class="no-results">
|
||||
<ng-container i18n>No watched word lists found.</ng-container>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
|
||||
<my-watched-words-list-save-modal #saveModal [accountName]="accountNameParam" (listAddedOrUpdated)="reloadData()"></my-watched-words-list-save-modal>
|
@ -0,0 +1,138 @@
|
||||
import { DatePipe, NgClass, NgIf } from '@angular/common'
|
||||
import { Component, Input, OnInit, ViewChild } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
|
||||
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { UserRight, WatchedWordsList } from '@peertube/peertube-models'
|
||||
import { SharedModule, SortMeta } from 'primeng/api'
|
||||
import { TableModule } from 'primeng/table'
|
||||
import { first } from 'rxjs'
|
||||
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
|
||||
import { AutoColspanDirective } from '../shared-main/angular/auto-colspan.directive'
|
||||
import { ActionDropdownComponent, DropdownAction } from '../shared-main/buttons/action-dropdown.component'
|
||||
import { ButtonComponent } from '../shared-main/buttons/button.component'
|
||||
import { TableExpanderIconComponent } from '../shared-tables/table-expander-icon.component'
|
||||
import { WatchedWordsListSaveModalComponent } from './watched-words-list-save-modal.component'
|
||||
import { WatchedWordsListService } from './watched-words-list.service'
|
||||
|
||||
@Component({
|
||||
selector: 'my-watched-words-list-admin-owner',
|
||||
templateUrl: './watched-words-list-admin-owner.component.html',
|
||||
standalone: true,
|
||||
imports: [
|
||||
GlobalIconComponent,
|
||||
TableModule,
|
||||
SharedModule,
|
||||
NgIf,
|
||||
ActionDropdownComponent,
|
||||
ButtonComponent,
|
||||
TableExpanderIconComponent,
|
||||
NgClass,
|
||||
AutoColspanDirective,
|
||||
DatePipe,
|
||||
NgbTooltip,
|
||||
WatchedWordsListSaveModalComponent
|
||||
]
|
||||
})
|
||||
export class WatchedWordsListAdminOwnerComponent extends RestTable<WatchedWordsList> implements OnInit {
|
||||
@Input({ required: true }) mode: 'user' | 'admin'
|
||||
|
||||
@ViewChild('saveModal', { static: true }) saveModal: WatchedWordsListSaveModalComponent
|
||||
|
||||
lists: WatchedWordsList[]
|
||||
totalRecords = 0
|
||||
sort: SortMeta = { field: 'createdAt', order: -1 }
|
||||
pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
|
||||
|
||||
actions: DropdownAction<WatchedWordsList>[][] = []
|
||||
|
||||
get authUser () {
|
||||
return this.auth.getUser()
|
||||
}
|
||||
|
||||
get accountNameParam () {
|
||||
if (this.mode === 'admin') return undefined
|
||||
|
||||
return this.authUser.account.name
|
||||
}
|
||||
|
||||
constructor (
|
||||
protected router: Router,
|
||||
protected route: ActivatedRoute,
|
||||
private auth: AuthService,
|
||||
private notifier: Notifier,
|
||||
private confirmService: ConfirmService,
|
||||
private watchedWordsListService: WatchedWordsListService
|
||||
) {
|
||||
super()
|
||||
|
||||
const isDisplayed = () => this.mode === 'user' || this.authUser.hasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS)
|
||||
|
||||
this.actions = [
|
||||
[
|
||||
{
|
||||
iconName: 'edit',
|
||||
label: $localize`Update`,
|
||||
handler: list => this.openCreateOrUpdateList(list),
|
||||
isDisplayed
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
iconName: 'delete',
|
||||
label: $localize`Delete`,
|
||||
handler: list => this.removeList(list),
|
||||
isDisplayed
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.initialize()
|
||||
|
||||
this.auth.userInformationLoaded
|
||||
.pipe(first())
|
||||
.subscribe(() => this.reloadData())
|
||||
}
|
||||
|
||||
getIdentifier () {
|
||||
return 'WatchedWordsListAdminOwnerComponent'
|
||||
}
|
||||
|
||||
openCreateOrUpdateList (list?: WatchedWordsList) {
|
||||
this.saveModal.show(list)
|
||||
}
|
||||
|
||||
protected reloadDataInternal () {
|
||||
this.watchedWordsListService.list({ pagination: this.pagination, sort: this.sort, accountName: this.accountNameParam })
|
||||
.subscribe({
|
||||
next: resultList => {
|
||||
this.totalRecords = resultList.total
|
||||
this.lists = resultList.data
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
private async removeList (list: WatchedWordsList) {
|
||||
const message = $localize`Are you sure you want to delete this ${list.listName} list?`
|
||||
const res = await this.confirmService.confirm(message, $localize`Delete list`)
|
||||
if (res === false) return
|
||||
|
||||
this.watchedWordsListService.deleteList({
|
||||
listId: list.id,
|
||||
accountName: this.accountNameParam
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.notifier.success($localize`${list.listName} removed`)
|
||||
|
||||
this.reloadData()
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
<ng-template #modal>
|
||||
<ng-container [formGroup]="form">
|
||||
|
||||
<div class="modal-header">
|
||||
<h4 i18n class="modal-title">Save watched words list</h4>
|
||||
|
||||
<button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()">
|
||||
<my-global-icon iconName="cross"></my-global-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="listName">List name</label>
|
||||
|
||||
<input
|
||||
type="text" id="listName" class="form-control"
|
||||
formControlName="listName" [ngClass]="{ 'input-error': formErrors['listName'] }"
|
||||
>
|
||||
|
||||
<div *ngIf="formErrors.listName" class="form-error" role="alert">{{ formErrors.listName }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="words">Words</label>
|
||||
|
||||
<div i18n class="form-group-description">One word or group of words per line.</div>
|
||||
|
||||
<textarea id="words" formControlName="words" class="form-control"[ngClass]="{ 'input-error': formErrors['words'] }"></textarea>
|
||||
|
||||
<div *ngIf="formErrors.words" class="form-error" role="alert">{{ formErrors.words }}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer inputs">
|
||||
<input
|
||||
type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
|
||||
(click)="hide()" (key.enter)="hide()"
|
||||
>
|
||||
|
||||
<input
|
||||
type="submit" i18n-value value="Save" class="peertube-button orange-button"
|
||||
[disabled]="!form.valid" (click)="addOrUpdate()"
|
||||
>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-template>
|
@ -0,0 +1,6 @@
|
||||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
|
||||
textarea {
|
||||
min-height: 300px;
|
||||
}
|
@ -0,0 +1,95 @@
|
||||
import { NgClass, NgIf } from '@angular/common'
|
||||
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { Notifier } from '@app/core'
|
||||
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
|
||||
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { WatchedWordsList } from '@peertube/peertube-models'
|
||||
import { splitAndGetNotEmpty } from '@root-helpers/string'
|
||||
import { UNIQUE_WATCHED_WORDS_VALIDATOR, WATCHED_WORDS_LIST_NAME_VALIDATOR } from '../form-validators/watched-words-list-validators'
|
||||
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
|
||||
import { WatchedWordsListService } from './watched-words-list.service'
|
||||
|
||||
@Component({
|
||||
selector: 'my-watched-words-list-save-modal',
|
||||
styleUrls: [ './watched-words-list-save-modal.component.scss' ],
|
||||
templateUrl: './watched-words-list-save-modal.component.html',
|
||||
standalone: true,
|
||||
imports: [ FormsModule, ReactiveFormsModule, GlobalIconComponent, NgIf, NgClass ]
|
||||
})
|
||||
|
||||
export class WatchedWordsListSaveModalComponent extends FormReactive implements OnInit {
|
||||
@Input({ required: true }) accountName: string
|
||||
|
||||
@Output() listAddedOrUpdated = new EventEmitter<void>()
|
||||
|
||||
@ViewChild('modal', { static: true }) modal: ElementRef
|
||||
|
||||
private openedModal: NgbModalRef
|
||||
private listToUpdate: WatchedWordsList
|
||||
|
||||
constructor (
|
||||
protected formReactiveService: FormReactiveService,
|
||||
private modalService: NgbModal,
|
||||
private notifier: Notifier,
|
||||
private watchedWordsService: WatchedWordsListService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.buildForm({
|
||||
listName: WATCHED_WORDS_LIST_NAME_VALIDATOR,
|
||||
words: UNIQUE_WATCHED_WORDS_VALIDATOR
|
||||
})
|
||||
}
|
||||
|
||||
show (list?: WatchedWordsList) {
|
||||
this.listToUpdate = list
|
||||
|
||||
this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
|
||||
|
||||
if (list) {
|
||||
this.form.patchValue({
|
||||
listName: list.listName,
|
||||
words: list.words.join('\n')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
hide () {
|
||||
this.openedModal.close()
|
||||
this.form.reset()
|
||||
|
||||
this.listToUpdate = undefined
|
||||
}
|
||||
|
||||
addOrUpdate () {
|
||||
const commonParams = {
|
||||
accountName: this.accountName,
|
||||
listName: this.form.value['listName'],
|
||||
words: splitAndGetNotEmpty(this.form.value['words'])
|
||||
}
|
||||
|
||||
const obs = this.listToUpdate
|
||||
? this.watchedWordsService.updateList({ ...commonParams, listId: this.listToUpdate.id })
|
||||
: this.watchedWordsService.addList(commonParams)
|
||||
|
||||
obs.subscribe({
|
||||
next: () => {
|
||||
if (this.listToUpdate) {
|
||||
this.notifier.success($localize`${commonParams.listName} updated`)
|
||||
} else {
|
||||
this.notifier.success($localize`${commonParams.listName} created`)
|
||||
}
|
||||
|
||||
this.listAddedOrUpdated.emit()
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
|
||||
this.hide()
|
||||
}
|
||||
}
|
@ -0,0 +1,86 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { RestExtractor, RestPagination, RestService } from '@app/core'
|
||||
import { ResultList, WatchedWordsList } from '@peertube/peertube-models'
|
||||
import { SortMeta } from 'primeng/api'
|
||||
import { Observable } from 'rxjs'
|
||||
import { catchError } from 'rxjs/operators'
|
||||
import { environment } from '../../../environments/environment'
|
||||
|
||||
@Injectable()
|
||||
export class WatchedWordsListService {
|
||||
private static BASE_WATCHED_WORDS_URL = environment.apiUrl + '/api/v1/watched-words/'
|
||||
|
||||
constructor (
|
||||
private authHttp: HttpClient,
|
||||
private restExtractor: RestExtractor,
|
||||
private restService: RestService
|
||||
) {}
|
||||
|
||||
list (options: {
|
||||
accountName?: string
|
||||
pagination: RestPagination
|
||||
sort: SortMeta
|
||||
}): Observable<ResultList<WatchedWordsList>> {
|
||||
const { pagination, sort } = options
|
||||
const url = this.buildServerOrAccountListPath(options)
|
||||
|
||||
let params = new HttpParams()
|
||||
params = this.restService.addRestGetParams(params, pagination, sort)
|
||||
|
||||
return this.authHttp.get<ResultList<WatchedWordsList>>(url, { params })
|
||||
.pipe(catchError(res => this.restExtractor.handleError(res)))
|
||||
}
|
||||
|
||||
addList (options: {
|
||||
accountName?: string
|
||||
listName: string
|
||||
words: string[]
|
||||
}) {
|
||||
const { listName, words } = options
|
||||
|
||||
const url = this.buildServerOrAccountListPath(options)
|
||||
const body = { listName, words }
|
||||
|
||||
return this.authHttp.post(url, body)
|
||||
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||
}
|
||||
|
||||
updateList (options: {
|
||||
accountName?: string
|
||||
|
||||
listId: number
|
||||
listName: string
|
||||
words: string[]
|
||||
}) {
|
||||
const { listName, words } = options
|
||||
|
||||
const url = this.buildServerOrAccountListPath(options)
|
||||
const body = { listName, words }
|
||||
|
||||
return this.authHttp.put(url, body)
|
||||
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||
}
|
||||
|
||||
deleteList (options: {
|
||||
accountName?: string
|
||||
listId: number
|
||||
}) {
|
||||
const url = this.buildServerOrAccountListPath(options)
|
||||
|
||||
return this.authHttp.delete(url)
|
||||
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||
}
|
||||
|
||||
private buildServerOrAccountListPath (options: { accountName?: string, listId?: number }) {
|
||||
let suffixPath = options.accountName
|
||||
? '/accounts/' + options.accountName + '/lists'
|
||||
: '/server/lists'
|
||||
|
||||
if (options.listId) {
|
||||
suffixPath += '/' + options.listId
|
||||
}
|
||||
|
||||
return WatchedWordsListService.BASE_WATCHED_WORDS_URL + suffixPath
|
||||
}
|
||||
}
|
@ -15,3 +15,9 @@ export function randomString (length: number) {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function splitAndGetNotEmpty (value: string) {
|
||||
return value
|
||||
.split('\n')
|
||||
.filter(line => line && line.length !== 0) // Eject empty lines
|
||||
}
|
||||
|
@ -123,7 +123,8 @@ defaults:
|
||||
publish:
|
||||
download_enabled: true
|
||||
|
||||
comments_enabled: true
|
||||
# enabled = 1, disabled = 2, requires_approval = 3
|
||||
comments_policy: 1
|
||||
|
||||
# public = 1, unlisted = 2, private = 3, internal = 4
|
||||
privacy: 1
|
||||
|
@ -121,7 +121,8 @@ defaults:
|
||||
publish:
|
||||
download_enabled: true
|
||||
|
||||
comments_enabled: true
|
||||
# enabled = 1, disabled = 2, requires_approval = 3
|
||||
comments_policy: 1
|
||||
|
||||
# public = 1, unlisted = 2, private = 3, internal = 4
|
||||
privacy: 1
|
||||
|
@ -143,6 +143,7 @@
|
||||
"js-yaml": "^4.0.0",
|
||||
"jsonld": "~8.3.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"linkify-it": "^5.0.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lru-cache": "^10.0.1",
|
||||
"magnet-uri": "^7.0.5",
|
||||
@ -201,6 +202,7 @@
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/jsonld": "^1.5.9",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/linkify-it": "^3.0.5",
|
||||
"@types/lodash-es": "^4.17.8",
|
||||
"@types/magnet-uri": "^5.1.1",
|
||||
"@types/maildev": "^0.0.7",
|
||||
|
@ -17,14 +17,16 @@ const userRoleRights: { [ id in UserRoleType ]: UserRightType[] } = {
|
||||
UserRight.MANAGE_ANY_VIDEO_CHANNEL,
|
||||
UserRight.REMOVE_ANY_VIDEO,
|
||||
UserRight.REMOVE_ANY_VIDEO_PLAYLIST,
|
||||
UserRight.REMOVE_ANY_VIDEO_COMMENT,
|
||||
UserRight.MANAGE_ANY_VIDEO_COMMENT,
|
||||
UserRight.UPDATE_ANY_VIDEO,
|
||||
UserRight.SEE_ALL_VIDEOS,
|
||||
UserRight.MANAGE_ACCOUNTS_BLOCKLIST,
|
||||
UserRight.MANAGE_SERVERS_BLOCKLIST,
|
||||
UserRight.MANAGE_USERS,
|
||||
UserRight.SEE_ALL_COMMENTS,
|
||||
UserRight.MANAGE_REGISTRATIONS
|
||||
UserRight.MANAGE_REGISTRATIONS,
|
||||
UserRight.MANAGE_INSTANCE_WATCHED_WORDS,
|
||||
UserRight.MANAGE_INSTANCE_AUTO_TAGS
|
||||
],
|
||||
|
||||
[UserRole.USER]: []
|
||||
|
@ -33,7 +33,9 @@ export type Activity =
|
||||
ActivityReject |
|
||||
ActivityView |
|
||||
ActivityDislike |
|
||||
ActivityFlag
|
||||
ActivityFlag |
|
||||
ActivityApproveReply |
|
||||
ActivityRejectReply
|
||||
|
||||
export type ActivityType =
|
||||
'Create' |
|
||||
@ -47,7 +49,9 @@ export type ActivityType =
|
||||
'Reject' |
|
||||
'View' |
|
||||
'Dislike' |
|
||||
'Flag'
|
||||
'Flag' |
|
||||
'ApproveReply' |
|
||||
'RejectReply'
|
||||
|
||||
export interface ActivityAudience {
|
||||
to: string[]
|
||||
@ -89,6 +93,18 @@ export interface ActivityAccept extends BaseActivity {
|
||||
object: ActivityFollow
|
||||
}
|
||||
|
||||
export interface ActivityApproveReply extends BaseActivity {
|
||||
type: 'ApproveReply'
|
||||
object: string
|
||||
inReplyTo: string
|
||||
}
|
||||
|
||||
export interface ActivityRejectReply extends BaseActivity {
|
||||
type: 'RejectReply'
|
||||
object: string
|
||||
inReplyTo: string
|
||||
}
|
||||
|
||||
export interface ActivityReject extends BaseActivity {
|
||||
type: 'Reject'
|
||||
object: ActivityFollow
|
||||
|
@ -14,4 +14,6 @@ export type ContextType =
|
||||
'Actor' |
|
||||
'Collection' |
|
||||
'WatchAction' |
|
||||
'Chapters'
|
||||
'Chapters' |
|
||||
'ApproveReply' |
|
||||
'RejectReply'
|
||||
|
@ -13,4 +13,9 @@ export interface VideoCommentObject {
|
||||
url: string
|
||||
attributedTo: ActivityPubAttributedTo
|
||||
tag: ActivityTagObject[]
|
||||
|
||||
replyApproval: string | null
|
||||
|
||||
to?: string[]
|
||||
cc?: string[]
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { LiveVideoLatencyModeType, VideoStateType } from '../../videos/index.js'
|
||||
import { LiveVideoLatencyModeType, VideoCommentPolicyType, VideoStateType } from '../../videos/index.js'
|
||||
import {
|
||||
ActivityIconObject,
|
||||
ActivityIdentifierObject,
|
||||
@ -29,7 +29,10 @@ export interface VideoObject {
|
||||
permanentLive: boolean
|
||||
latencyMode: LiveVideoLatencyModeType
|
||||
|
||||
commentsEnabled: boolean
|
||||
commentsEnabled?: boolean
|
||||
commentsPolicy: VideoCommentPolicyType
|
||||
canReply: 'as:Public' | 'https://www.w3.org/ns/activitystreams#Public'
|
||||
|
||||
downloadEnabled: boolean
|
||||
waitTranscoding: boolean
|
||||
state: VideoStateType
|
||||
|
@ -0,0 +1,5 @@
|
||||
export interface AutoTagPoliciesJSON {
|
||||
reviewComments: {
|
||||
name: string
|
||||
}[]
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
export * from './account-export.model.js'
|
||||
export * from './actor-export.model.js'
|
||||
export * from './auto-tag-policies-export.js'
|
||||
export * from './blocklist-export.model.js'
|
||||
export * from './channel-export.model.js'
|
||||
export * from './comments-export.model.js'
|
||||
@ -11,3 +12,4 @@ export * from './user-settings-export.model.js'
|
||||
export * from './user-video-history-export.js'
|
||||
export * from './video-export.model.js'
|
||||
export * from './video-playlists-export.model.js'
|
||||
export * from './watched-words-lists-export.js'
|
||||
|
@ -4,7 +4,7 @@ export interface UserVideoHistoryExportJSON {
|
||||
lastTimecode: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}[]
|
||||
|
||||
archiveFiles?: never
|
||||
archiveFiles?: never
|
||||
}[]
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import {
|
||||
LiveVideoLatencyModeType,
|
||||
VideoCommentPolicyType,
|
||||
VideoFileMetadata,
|
||||
VideoPrivacyType,
|
||||
VideoStateType,
|
||||
@ -53,7 +54,10 @@ export interface VideoExportJSON {
|
||||
|
||||
nsfw: boolean
|
||||
|
||||
commentsEnabled: boolean
|
||||
// TODO: remove, deprecated in 6.2
|
||||
commentsEnabled?: boolean
|
||||
commentsPolicy: VideoCommentPolicyType
|
||||
|
||||
downloadEnabled: boolean
|
||||
|
||||
channel: {
|
||||
|
@ -0,0 +1,10 @@
|
||||
export interface WatchedWordsListsJSON {
|
||||
watchedWordLists: {
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
listName: string
|
||||
words: string[]
|
||||
|
||||
archiveFiles?: never
|
||||
}[]
|
||||
}
|
@ -18,5 +18,8 @@ export interface UserImportResultSummary {
|
||||
userSettings: Summary
|
||||
|
||||
userVideoHistory: Summary
|
||||
|
||||
watchedWordsLists: Summary
|
||||
commentAutoTagPolicies: Summary
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,8 @@
|
||||
export type AutomaticTagAvailableType = 'core' | 'watched-words-list'
|
||||
|
||||
export interface AutomaticTagAvailable {
|
||||
available: {
|
||||
name: string
|
||||
type: AutomaticTagAvailableType
|
||||
}[]
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
export const AutomaticTagPolicy = {
|
||||
NONE: 1,
|
||||
REVIEW_COMMENT: 2
|
||||
} as const
|
||||
|
||||
export type AutomaticTagPolicyType = typeof AutomaticTagPolicy[keyof typeof AutomaticTagPolicy]
|
@ -0,0 +1,3 @@
|
||||
export interface CommentAutomaticTagPoliciesUpdate {
|
||||
review: string[]
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
export interface CommentAutomaticTagPolicies {
|
||||
review: string[]
|
||||
}
|
@ -1,4 +1,9 @@
|
||||
export * from './abuse/index.js'
|
||||
export * from './block-status.model.js'
|
||||
export * from './automatic-tag-available.model.js'
|
||||
export * from './account-block.model.js'
|
||||
export * from './comment-automatic-tag-policies-update.model.js'
|
||||
export * from './comment-automatic-tag-policies.model.js'
|
||||
export * from './automatic-tag-policy.enum.js'
|
||||
export * from './block-status.model.js'
|
||||
export * from './server-block.model.js'
|
||||
export * from './watched-words-list.model.js'
|
||||
|
@ -0,0 +1,9 @@
|
||||
export interface WatchedWordsList {
|
||||
id: number
|
||||
|
||||
listName: string
|
||||
words: string[]
|
||||
|
||||
updatedAt: Date | string
|
||||
createdAt: Date | string
|
||||
}
|
@ -21,8 +21,6 @@ export interface VideosCommonQuery {
|
||||
|
||||
languageOneOf?: string[]
|
||||
|
||||
privacyOneOf?: VideoPrivacyType[]
|
||||
|
||||
tagsOneOf?: string[]
|
||||
tagsAllOf?: string[]
|
||||
|
||||
@ -36,6 +34,10 @@ export interface VideosCommonQuery {
|
||||
search?: string
|
||||
|
||||
excludeAlreadyWatched?: boolean
|
||||
|
||||
// Only available with special user right
|
||||
autoTagOneOf?: string[]
|
||||
privacyOneOf?: VideoPrivacyType[]
|
||||
}
|
||||
|
||||
export interface VideosCommonQueryAfterSanitize extends VideosCommonQuery {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ActorImage } from '../index.js'
|
||||
import { ActorImage, VideoCommentPolicyType } from '../index.js'
|
||||
import { ClientScriptJSON } from '../plugins/plugin-package-json.model.js'
|
||||
import { NSFWPolicyType } from '../videos/nsfw-policy.type.js'
|
||||
import { VideoPrivacyType } from '../videos/video-privacy.enum.js'
|
||||
@ -57,7 +57,11 @@ export interface ServerConfig {
|
||||
defaults: {
|
||||
publish: {
|
||||
downloadEnabled: boolean
|
||||
|
||||
// TODO: remove, deprecated in 6.2
|
||||
commentsEnabled: boolean
|
||||
commentsPolicy: VideoCommentPolicyType
|
||||
|
||||
privacy: VideoPrivacyType
|
||||
licence: number
|
||||
}
|
||||
|
@ -85,6 +85,7 @@ export interface UserNotification {
|
||||
threadId: number
|
||||
account: ActorInfo
|
||||
video: VideoInfo
|
||||
heldForReview: boolean
|
||||
}
|
||||
|
||||
abuse?: {
|
||||
|
@ -26,7 +26,7 @@ export const UserRight = {
|
||||
|
||||
REMOVE_ANY_VIDEO: 14,
|
||||
REMOVE_ANY_VIDEO_PLAYLIST: 15,
|
||||
REMOVE_ANY_VIDEO_COMMENT: 16,
|
||||
MANAGE_ANY_VIDEO_COMMENT: 16,
|
||||
|
||||
UPDATE_ANY_VIDEO: 17,
|
||||
UPDATE_ANY_VIDEO_PLAYLIST: 18,
|
||||
@ -50,7 +50,10 @@ export const UserRight = {
|
||||
MANAGE_RUNNERS: 29,
|
||||
|
||||
MANAGE_USER_EXPORTS: 30,
|
||||
MANAGE_USER_IMPORTS: 31
|
||||
MANAGE_USER_IMPORTS: 31,
|
||||
|
||||
MANAGE_INSTANCE_WATCHED_WORDS: 32,
|
||||
MANAGE_INSTANCE_AUTO_TAGS: 33
|
||||
} as const
|
||||
|
||||
export type UserRightType = typeof UserRight[keyof typeof UserRight]
|
||||
|
@ -1,2 +1,3 @@
|
||||
export * from './video-comment-create.model.js'
|
||||
export * from './video-comment.model.js'
|
||||
export * from './video-comment-policy.enum.js'
|
||||
|
@ -0,0 +1,7 @@
|
||||
export const VideoCommentPolicy = {
|
||||
ENABLED: 1,
|
||||
DISABLED: 2,
|
||||
REQUIRES_APPROVAL: 3
|
||||
} as const
|
||||
|
||||
export type VideoCommentPolicyType = typeof VideoCommentPolicy[keyof typeof VideoCommentPolicy]
|
@ -5,19 +5,25 @@ export interface VideoComment {
|
||||
id: number
|
||||
url: string
|
||||
text: string
|
||||
|
||||
threadId: number
|
||||
inReplyToCommentId: number
|
||||
videoId: number
|
||||
|
||||
createdAt: Date | string
|
||||
updatedAt: Date | string
|
||||
deletedAt: Date | string
|
||||
|
||||
isDeleted: boolean
|
||||
totalRepliesFromVideoAuthor: number
|
||||
totalReplies: number
|
||||
|
||||
account: Account
|
||||
|
||||
heldForReview: boolean
|
||||
}
|
||||
|
||||
export interface VideoCommentAdmin {
|
||||
export interface VideoCommentForAdminOrUser {
|
||||
id: number
|
||||
url: string
|
||||
text: string
|
||||
@ -35,6 +41,10 @@ export interface VideoCommentAdmin {
|
||||
uuid: string
|
||||
name: string
|
||||
}
|
||||
|
||||
heldForReview: boolean
|
||||
|
||||
automaticTags: string[]
|
||||
}
|
||||
|
||||
export type VideoCommentThreads = ResultList<VideoComment> & { totalNotDeletedComments: number }
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { VideoCommentPolicyType } from './comment/video-comment-policy.enum.js'
|
||||
import { VideoPrivacyType } from './video-privacy.enum.js'
|
||||
import { VideoScheduleUpdate } from './video-schedule-update.model.js'
|
||||
|
||||
@ -13,7 +14,11 @@ export interface VideoCreate {
|
||||
nsfw?: boolean
|
||||
waitTranscoding?: boolean
|
||||
tags?: string[]
|
||||
|
||||
// TODO: remove, deprecated in 6.2
|
||||
commentsEnabled?: boolean
|
||||
commentsPolicy?: VideoCommentPolicyType
|
||||
|
||||
downloadEnabled?: boolean
|
||||
privacy: VideoPrivacyType
|
||||
scheduleUpdate?: VideoScheduleUpdate
|
||||
|
@ -5,7 +5,8 @@ export const VideoInclude = {
|
||||
BLOCKED_OWNER: 1 << 2,
|
||||
FILES: 1 << 3,
|
||||
CAPTIONS: 1 << 4,
|
||||
SOURCE: 1 << 5
|
||||
SOURCE: 1 << 5,
|
||||
AUTOMATIC_TAGS: 1 << 6
|
||||
} as const
|
||||
|
||||
export type VideoIncludeType = typeof VideoInclude[keyof typeof VideoInclude]
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { VideoCommentPolicyType } from './index.js'
|
||||
import { VideoPrivacyType } from './video-privacy.enum.js'
|
||||
import { VideoScheduleUpdate } from './video-schedule-update.model.js'
|
||||
|
||||
@ -10,7 +11,11 @@ export interface VideoUpdate {
|
||||
support?: string
|
||||
privacy?: VideoPrivacyType
|
||||
tags?: string[]
|
||||
|
||||
// TODO: remove, deprecated in 6.2
|
||||
commentsEnabled?: boolean
|
||||
commentsPolicy?: VideoCommentPolicyType
|
||||
|
||||
downloadEnabled?: boolean
|
||||
nsfw?: boolean
|
||||
waitTranscoding?: boolean
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Account, AccountSummary } from '../actors/index.js'
|
||||
import { VideoChannel, VideoChannelSummary } from './channel/video-channel.model.js'
|
||||
import { VideoFile } from './file/index.js'
|
||||
import { VideoCommentPolicyType } from './index.js'
|
||||
import { VideoConstant } from './video-constant.model.js'
|
||||
import { VideoPrivacyType } from './video-privacy.enum.js'
|
||||
import { VideoScheduleUpdate } from './video-schedule-update.model.js'
|
||||
@ -78,17 +79,26 @@ export interface VideoAdditionalAttributes {
|
||||
streamingPlaylists: VideoStreamingPlaylist[]
|
||||
|
||||
videoSource: VideoSource
|
||||
|
||||
automaticTags: string[]
|
||||
}
|
||||
|
||||
export interface VideoDetails extends Video {
|
||||
// Deprecated in 5.0
|
||||
// TODO: remove, deprecated in 5.0
|
||||
descriptionPath: string
|
||||
|
||||
support: string
|
||||
channel: VideoChannel
|
||||
account: Account
|
||||
tags: string[]
|
||||
|
||||
// TODO: remove, deprecated in 6.2
|
||||
commentsEnabled: boolean
|
||||
commentsPolicy: {
|
||||
id: VideoCommentPolicyType
|
||||
label: string
|
||||
}
|
||||
|
||||
downloadEnabled: boolean
|
||||
|
||||
// Not optional in details (unlike in parent Video)
|
||||
|
@ -0,0 +1,68 @@
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
AutomaticTagAvailable,
|
||||
CommentAutomaticTagPolicies,
|
||||
CommentAutomaticTagPoliciesUpdate,
|
||||
HttpStatusCode
|
||||
} from '@peertube/peertube-models'
|
||||
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
|
||||
|
||||
export class AutomaticTagsCommand extends AbstractCommand {
|
||||
|
||||
getCommentPolicies (options: OverrideCommandOptions & {
|
||||
accountName: string
|
||||
}) {
|
||||
const path = '/api/v1/automatic-tags/policies/accounts/' + options.accountName + '/comments'
|
||||
|
||||
return this.getRequestBody<CommentAutomaticTagPolicies>({
|
||||
...options,
|
||||
|
||||
path,
|
||||
implicitToken: true,
|
||||
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||
})
|
||||
}
|
||||
|
||||
updateCommentPolicies (options: OverrideCommandOptions & CommentAutomaticTagPoliciesUpdate & {
|
||||
accountName: string
|
||||
}) {
|
||||
const path = '/api/v1/automatic-tags/policies/accounts/' + options.accountName + '/comments'
|
||||
|
||||
return this.putBodyRequest({
|
||||
...options,
|
||||
|
||||
path,
|
||||
fields: pick(options, [ 'review' ]),
|
||||
implicitToken: true,
|
||||
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getAccountAvailable (options: OverrideCommandOptions & {
|
||||
accountName: string
|
||||
}) {
|
||||
const path = '/api/v1/automatic-tags/accounts/' + options.accountName + '/available'
|
||||
|
||||
return this.getRequestBody<AutomaticTagAvailable>({
|
||||
...options,
|
||||
|
||||
path,
|
||||
implicitToken: true,
|
||||
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||
})
|
||||
}
|
||||
|
||||
getServerAvailable (options: OverrideCommandOptions = {}) {
|
||||
const path = '/api/v1/automatic-tags/server/available'
|
||||
|
||||
return this.getRequestBody<AutomaticTagAvailable>({
|
||||
...options,
|
||||
|
||||
path,
|
||||
implicitToken: true,
|
||||
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||
})
|
||||
}
|
||||
}
|
@ -1 +1,3 @@
|
||||
export * from './abuses-command.js'
|
||||
export * from './automatic-tags-command.js'
|
||||
export * from './watched-words-command.js'
|
||||
|
@ -0,0 +1,87 @@
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
HttpStatusCode,
|
||||
ResultList, WatchedWordsList
|
||||
} from '@peertube/peertube-models'
|
||||
import { unwrapBody } from '../index.js'
|
||||
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
|
||||
|
||||
export class WatchedWordsCommand extends AbstractCommand {
|
||||
|
||||
listWordsLists (options: OverrideCommandOptions & {
|
||||
start?: number
|
||||
count?: number
|
||||
sort?: string
|
||||
|
||||
accountName?: string
|
||||
}) {
|
||||
const query = {
|
||||
sort: '-createdAt',
|
||||
|
||||
...pick(options, [ 'start', 'count', 'sort' ])
|
||||
}
|
||||
|
||||
return this.getRequestBody<ResultList<WatchedWordsList>>({
|
||||
...options,
|
||||
|
||||
path: this.buildAPIBasePath(options.accountName),
|
||||
query,
|
||||
implicitToken: true,
|
||||
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||
})
|
||||
}
|
||||
|
||||
createList (options: OverrideCommandOptions & {
|
||||
listName: string
|
||||
words: string[]
|
||||
accountName?: string
|
||||
}) {
|
||||
const body = pick(options, [ 'listName', 'words' ])
|
||||
|
||||
return unwrapBody<{ watchedWordsList: { id: number } }>(this.postBodyRequest({
|
||||
...options,
|
||||
|
||||
path: this.buildAPIBasePath(options.accountName),
|
||||
fields: body,
|
||||
implicitToken: true,
|
||||
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||
}))
|
||||
}
|
||||
|
||||
updateList (options: OverrideCommandOptions & {
|
||||
listId: number
|
||||
accountName?: string
|
||||
listName?: string
|
||||
words?: string[]
|
||||
}) {
|
||||
const body = pick(options, [ 'listName', 'words' ])
|
||||
|
||||
return this.putBodyRequest({
|
||||
...options,
|
||||
|
||||
path: this.buildAPIBasePath(options.accountName) + '/' + options.listId,
|
||||
fields: body,
|
||||
implicitToken: true,
|
||||
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
|
||||
})
|
||||
}
|
||||
|
||||
deleteList (options: OverrideCommandOptions & {
|
||||
listId: number
|
||||
accountName?: string
|
||||
}) {
|
||||
return this.deleteRequest({
|
||||
...options,
|
||||
|
||||
path: this.buildAPIBasePath(options.accountName) + '/' + options.listId,
|
||||
implicitToken: true,
|
||||
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
|
||||
})
|
||||
}
|
||||
|
||||
private buildAPIBasePath (accountName?: string) {
|
||||
return accountName
|
||||
? '/api/v1/watched-words/accounts/' + accountName + '/lists'
|
||||
: '/api/v1/watched-words/server/lists'
|
||||
}
|
||||
}
|
@ -1,15 +1,15 @@
|
||||
import { ChildProcess, fork } from 'child_process'
|
||||
import { copy } from 'fs-extra/esm'
|
||||
import { join } from 'path'
|
||||
import { randomInt } from '@peertube/peertube-core-utils'
|
||||
import { Video, VideoChannel, VideoChannelSync, VideoCreateResult, VideoDetails } from '@peertube/peertube-models'
|
||||
import { parallelTests, root } from '@peertube/peertube-node-utils'
|
||||
import { ChildProcess, fork } from 'child_process'
|
||||
import { copy } from 'fs-extra/esm'
|
||||
import { join } from 'path'
|
||||
import { BulkCommand } from '../bulk/index.js'
|
||||
import { CLICommand } from '../cli/index.js'
|
||||
import { CustomPagesCommand } from '../custom-pages/index.js'
|
||||
import { FeedCommand } from '../feeds/index.js'
|
||||
import { LogsCommand } from '../logs/index.js'
|
||||
import { AbusesCommand } from '../moderation/index.js'
|
||||
import { AbusesCommand, AutomaticTagsCommand, WatchedWordsCommand } from '../moderation/index.js'
|
||||
import { OverviewsCommand } from '../overviews/index.js'
|
||||
import { RunnerJobsCommand, RunnerRegistrationTokensCommand, RunnersCommand } from '../runners/index.js'
|
||||
import { SearchCommand } from '../search/index.js'
|
||||
@ -17,35 +17,35 @@ import { SocketIOCommand } from '../socket/index.js'
|
||||
import {
|
||||
AccountsCommand,
|
||||
BlocklistCommand,
|
||||
UserExportsCommand,
|
||||
LoginCommand,
|
||||
NotificationsCommand,
|
||||
RegistrationsCommand,
|
||||
SubscriptionsCommand,
|
||||
TwoFactorCommand,
|
||||
UsersCommand,
|
||||
UserImportsCommand
|
||||
UserExportsCommand,
|
||||
UserImportsCommand,
|
||||
UsersCommand
|
||||
} from '../users/index.js'
|
||||
import {
|
||||
BlacklistCommand,
|
||||
CaptionsCommand,
|
||||
ChangeOwnershipCommand,
|
||||
ChannelsCommand,
|
||||
ChannelSyncsCommand,
|
||||
ChannelsCommand,
|
||||
ChaptersCommand,
|
||||
CommentsCommand,
|
||||
HistoryCommand,
|
||||
VideoImportsCommand,
|
||||
LiveCommand,
|
||||
PlaylistsCommand,
|
||||
ServicesCommand,
|
||||
StoryboardCommand,
|
||||
StreamingPlaylistsCommand,
|
||||
VideoImportsCommand,
|
||||
VideoPasswordsCommand,
|
||||
VideosCommand,
|
||||
VideoStatsCommand,
|
||||
VideoStudioCommand,
|
||||
VideoTokenCommand,
|
||||
VideosCommand,
|
||||
ViewsCommand
|
||||
} from '../videos/index.js'
|
||||
import { ConfigCommand } from './config-command.js'
|
||||
@ -163,6 +163,9 @@ export class PeerTubeServer {
|
||||
runnerRegistrationTokens?: RunnerRegistrationTokensCommand
|
||||
runnerJobs?: RunnerJobsCommand
|
||||
|
||||
watchedWordsLists?: WatchedWordsCommand
|
||||
autoTags?: AutomaticTagsCommand
|
||||
|
||||
constructor (options: { serverNumber: number } | { url: string }) {
|
||||
if ((options as any).url) {
|
||||
this.setUrl((options as any).url)
|
||||
@ -458,5 +461,8 @@ export class PeerTubeServer {
|
||||
this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this)
|
||||
this.runnerJobs = new RunnerJobsCommand(this)
|
||||
this.videoPasswords = new VideoPasswordsCommand(this)
|
||||
|
||||
this.watchedWordsLists = new WatchedWordsCommand(this)
|
||||
this.autoTags = new AutomaticTagsCommand(this)
|
||||
}
|
||||
}
|
||||
|
@ -1,30 +1,45 @@
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { HttpStatusCode, ResultList, VideoComment, VideoCommentThreads, VideoCommentThreadTree } from '@peertube/peertube-models'
|
||||
import {
|
||||
HttpStatusCode,
|
||||
ResultList,
|
||||
VideoComment,
|
||||
VideoCommentForAdminOrUser,
|
||||
VideoCommentThreads,
|
||||
VideoCommentThreadTree
|
||||
} from '@peertube/peertube-models'
|
||||
import { unwrapBody } from '../requests/index.js'
|
||||
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
|
||||
|
||||
type ListForAdminOrAccountCommonOptions = {
|
||||
start?: number
|
||||
count?: number
|
||||
sort?: string
|
||||
search?: string
|
||||
searchAccount?: string
|
||||
searchVideo?: string
|
||||
videoId?: string | number
|
||||
videoChannelId?: string | number
|
||||
autoTagOneOf?: string[]
|
||||
}
|
||||
|
||||
export class CommentsCommand extends AbstractCommand {
|
||||
|
||||
private lastVideoId: number | string
|
||||
private lastThreadId: number
|
||||
private lastReplyId: number
|
||||
|
||||
listForAdmin (options: OverrideCommandOptions & {
|
||||
start?: number
|
||||
count?: number
|
||||
sort?: string
|
||||
listForAdmin (options: OverrideCommandOptions & ListForAdminOrAccountCommonOptions & {
|
||||
isLocal?: boolean
|
||||
onLocalVideo?: boolean
|
||||
search?: string
|
||||
searchAccount?: string
|
||||
searchVideo?: string
|
||||
} = {}) {
|
||||
const { sort = '-createdAt' } = options
|
||||
const path = '/api/v1/videos/comments'
|
||||
|
||||
const query = { sort, ...pick(options, [ 'start', 'count', 'isLocal', 'onLocalVideo', 'search', 'searchAccount', 'searchVideo' ]) }
|
||||
const query = {
|
||||
...this.buildListForAdminOrAccountQuery(options),
|
||||
...pick(options, [ 'isLocal', 'onLocalVideo' ])
|
||||
}
|
||||
|
||||
return this.getRequestBody<ResultList<VideoComment>>({
|
||||
return this.getRequestBody<ResultList<VideoCommentForAdminOrUser>>({
|
||||
...options,
|
||||
|
||||
path,
|
||||
@ -34,6 +49,35 @@ export class CommentsCommand extends AbstractCommand {
|
||||
})
|
||||
}
|
||||
|
||||
listCommentsOnMyVideos (options: OverrideCommandOptions & ListForAdminOrAccountCommonOptions & {
|
||||
isHeldForReview?: boolean
|
||||
} = {}) {
|
||||
const path = '/api/v1/users/me/videos/comments'
|
||||
|
||||
return this.getRequestBody<ResultList<VideoCommentForAdminOrUser>>({
|
||||
...options,
|
||||
|
||||
path,
|
||||
query: {
|
||||
...this.buildListForAdminOrAccountQuery(options),
|
||||
|
||||
isHeldForReview: options.isHeldForReview
|
||||
},
|
||||
implicitToken: true,
|
||||
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||
})
|
||||
}
|
||||
|
||||
private buildListForAdminOrAccountQuery (options: ListForAdminOrAccountCommonOptions) {
|
||||
return {
|
||||
sort: '-createdAt',
|
||||
|
||||
...pick(options, [ 'start', 'count', 'search', 'searchAccount', 'searchVideo', 'sort', 'videoId', 'videoChannelId', 'autoTagOneOf' ])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
listThreads (options: OverrideCommandOptions & {
|
||||
videoId: number | string
|
||||
videoPassword?: string
|
||||
@ -71,6 +115,16 @@ export class CommentsCommand extends AbstractCommand {
|
||||
})
|
||||
}
|
||||
|
||||
async getThreadOf (options: OverrideCommandOptions & {
|
||||
videoId: number | string
|
||||
text: string
|
||||
}) {
|
||||
const { videoId, text } = options
|
||||
const threadId = await this.findCommentId({ videoId, text })
|
||||
|
||||
return this.getThread({ ...options, videoId, threadId })
|
||||
}
|
||||
|
||||
async createThread (options: OverrideCommandOptions & {
|
||||
videoId: number | string
|
||||
text: string
|
||||
@ -136,11 +190,13 @@ export class CommentsCommand extends AbstractCommand {
|
||||
text: string
|
||||
}) {
|
||||
const { videoId, text } = options
|
||||
const { data } = await this.listThreads({ videoId, count: 25, sort: '-createdAt' })
|
||||
const { data } = await this.listForAdmin({ videoId, count: 25, sort: '-createdAt' })
|
||||
|
||||
return data.find(c => c.text === text).id
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
delete (options: OverrideCommandOptions & {
|
||||
videoId: number | string
|
||||
commentId: number
|
||||
@ -156,4 +212,34 @@ export class CommentsCommand extends AbstractCommand {
|
||||
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
|
||||
})
|
||||
}
|
||||
|
||||
async deleteAllComments (options: OverrideCommandOptions & {
|
||||
videoUUID: string
|
||||
}) {
|
||||
const { data } = await this.listForAdmin({ ...options, start: 0, count: 20 })
|
||||
|
||||
for (const comment of data) {
|
||||
if (comment?.video.uuid !== options.videoUUID) continue
|
||||
|
||||
await this.delete({ videoId: options.videoUUID, commentId: comment.id, ...options })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
approve (options: OverrideCommandOptions & {
|
||||
videoId: number | string
|
||||
commentId: number
|
||||
}) {
|
||||
const { videoId, commentId } = options
|
||||
const path = '/api/v1/videos/' + videoId + '/comments/' + commentId + '/approve'
|
||||
|
||||
return this.postBodyRequest({
|
||||
...options,
|
||||
|
||||
path,
|
||||
implicitToken: true,
|
||||
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import {
|
||||
HttpStatusCodeType, ResultList,
|
||||
UserVideoRateType,
|
||||
Video,
|
||||
VideoCommentPolicy,
|
||||
VideoCreate,
|
||||
VideoCreateResult,
|
||||
VideoDetails,
|
||||
@ -229,6 +230,7 @@ export class VideosCommand extends AbstractCommand {
|
||||
search?: string
|
||||
isLive?: boolean
|
||||
channelId?: number
|
||||
autoTagOneOf?: string[]
|
||||
} = {}) {
|
||||
const path = '/api/v1/users/me/videos'
|
||||
|
||||
@ -236,7 +238,7 @@ export class VideosCommand extends AbstractCommand {
|
||||
...options,
|
||||
|
||||
path,
|
||||
query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive', 'channelId' ]),
|
||||
query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive', 'channelId', 'autoTagOneOf' ]),
|
||||
implicitToken: true,
|
||||
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||
})
|
||||
@ -282,7 +284,7 @@ export class VideosCommand extends AbstractCommand {
|
||||
}
|
||||
|
||||
listAllForAdmin (options: OverrideCommandOptions & VideosCommonQuery = {}) {
|
||||
const include = VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.BLACKLISTED | VideoInclude.BLOCKED_OWNER
|
||||
const include = VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.BLACKLISTED | VideoInclude.BLOCKED_OWNER | VideoInclude.AUTOMATIC_TAGS
|
||||
const nsfw = 'both'
|
||||
const privacyOneOf = getAllPrivacies()
|
||||
|
||||
@ -429,7 +431,7 @@ export class VideosCommand extends AbstractCommand {
|
||||
support: 'my super support text',
|
||||
tags: [ 'tag' ],
|
||||
privacy: VideoPrivacy.PUBLIC,
|
||||
commentsEnabled: true,
|
||||
commentsPolicy: VideoCommentPolicy.ENABLED,
|
||||
downloadEnabled: true,
|
||||
fixture: 'video_short.webm',
|
||||
|
||||
@ -619,7 +621,8 @@ export class VideosCommand extends AbstractCommand {
|
||||
'tagsAllOf',
|
||||
'isLocal',
|
||||
'include',
|
||||
'skipCount'
|
||||
'skipCount',
|
||||
'autoTagOneOf'
|
||||
])
|
||||
}
|
||||
|
||||
|
137
packages/tests/src/api/check-params/auto-tags.ts
Normal file
137
packages/tests/src/api/check-params/auto-tags.ts
Normal file
@ -0,0 +1,137 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||
|
||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||
import {
|
||||
PeerTubeServer,
|
||||
cleanupTests,
|
||||
createSingleServer, setAccessTokensToServers,
|
||||
setDefaultVideoChannel
|
||||
} from '@peertube/peertube-server-commands'
|
||||
|
||||
describe('Test auto tag policies API validator', function () {
|
||||
let server: PeerTubeServer
|
||||
|
||||
let userToken: string
|
||||
let userToken2: string
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
before(async function () {
|
||||
this.timeout(120000)
|
||||
|
||||
server = await createSingleServer(1)
|
||||
|
||||
await setAccessTokensToServers([ server ])
|
||||
await setDefaultVideoChannel([ server ])
|
||||
|
||||
userToken = await server.users.generateUserAndToken('user1')
|
||||
userToken2 = await server.users.generateUserAndToken('user2')
|
||||
})
|
||||
|
||||
describe('When getting available account auto tags', function () {
|
||||
const baseParams = () => ({ accountName: 'user1', token: userToken })
|
||||
|
||||
it('Should fail without token', async function () {
|
||||
await server.autoTags.getAccountAvailable({ ...baseParams(), token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
||||
})
|
||||
|
||||
it('Should fail with a user that cannot manage account', async function () {
|
||||
await server.autoTags.getAccountAvailable({ ...baseParams(), token: userToken2, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||
})
|
||||
|
||||
it('Should fail with an unknown account', async function () {
|
||||
await server.autoTags.getAccountAvailable({ ...baseParams(), accountName: 'user42', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||
})
|
||||
|
||||
it('Should succeed with the correct params', async function () {
|
||||
await server.autoTags.getAccountAvailable(baseParams())
|
||||
})
|
||||
})
|
||||
|
||||
describe('When getting available server auto tags', function () {
|
||||
|
||||
it('Should fail without token', async function () {
|
||||
await server.autoTags.getServerAvailable({ token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
||||
})
|
||||
|
||||
it('Should fail with a user that that does not have enought rights', async function () {
|
||||
await server.autoTags.getServerAvailable({ token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||
})
|
||||
|
||||
it('Should succeed with the correct params', async function () {
|
||||
await server.autoTags.getServerAvailable()
|
||||
})
|
||||
})
|
||||
|
||||
describe('When getting auto tag policies', function () {
|
||||
const baseParams = () => ({ accountName: 'user1', token: userToken })
|
||||
|
||||
it('Should fail without token', async function () {
|
||||
await server.autoTags.getCommentPolicies({ ...baseParams(), token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
||||
})
|
||||
|
||||
it('Should fail with a user that cannot manage account', async function () {
|
||||
await server.autoTags.getCommentPolicies({ ...baseParams(), token: userToken2, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||
})
|
||||
|
||||
it('Should fail with an unknown account', async function () {
|
||||
await server.autoTags.getCommentPolicies({ ...baseParams(), accountName: 'user42', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||
})
|
||||
|
||||
it('Should succeed with the correct params', async function () {
|
||||
await server.autoTags.getCommentPolicies(baseParams())
|
||||
})
|
||||
})
|
||||
|
||||
describe('When updating auto tag policies', function () {
|
||||
const baseParams = () => ({ accountName: 'user1', review: [ 'external-link' ], token: userToken })
|
||||
|
||||
it('Should fail without token', async function () {
|
||||
await server.autoTags.updateCommentPolicies({
|
||||
...baseParams(),
|
||||
token: null,
|
||||
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
|
||||
})
|
||||
})
|
||||
|
||||
it('Should fail with a user that cannot manage account', async function () {
|
||||
await server.autoTags.updateCommentPolicies({
|
||||
...baseParams(),
|
||||
token: userToken2,
|
||||
expectedStatus: HttpStatusCode.FORBIDDEN_403
|
||||
})
|
||||
})
|
||||
|
||||
it('Should fail with an unknown account', async function () {
|
||||
await server.autoTags.updateCommentPolicies({
|
||||
...baseParams(),
|
||||
accountName: 'user42',
|
||||
expectedStatus: HttpStatusCode.NOT_FOUND_404
|
||||
})
|
||||
})
|
||||
|
||||
it('Should fail with invalid review array', async function () {
|
||||
await server.autoTags.updateCommentPolicies({
|
||||
...baseParams(),
|
||||
review: 'toto' as any,
|
||||
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
||||
})
|
||||
})
|
||||
|
||||
it('Should fail with review array that does not contain available tags', async function () {
|
||||
await server.autoTags.updateCommentPolicies({
|
||||
...baseParams(),
|
||||
review: [ 'toto' ],
|
||||
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
||||
})
|
||||
})
|
||||
|
||||
it('Should succeed with the correct params', async function () {
|
||||
await server.autoTags.updateCommentPolicies(baseParams())
|
||||
})
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
await cleanupTests([ server ])
|
||||
})
|
||||
})
|
@ -1,5 +1,6 @@
|
||||
import './abuses.js'
|
||||
import './accounts.js'
|
||||
import './auto-tags.js'
|
||||
import './blocklist.js'
|
||||
import './bulk.js'
|
||||
import './channel-import-videos.js'
|
||||
@ -8,8 +9,6 @@ import './contact-form.js'
|
||||
import './custom-pages.js'
|
||||
import './debug.js'
|
||||
import './follows.js'
|
||||
import './user-export.js'
|
||||
import './user-import.js'
|
||||
import './jobs.js'
|
||||
import './live.js'
|
||||
import './logs.js'
|
||||
@ -24,6 +23,8 @@ import './services.js'
|
||||
import './transcoding.js'
|
||||
import './two-factor.js'
|
||||
import './upload-quota.js'
|
||||
import './user-export.js'
|
||||
import './user-import.js'
|
||||
import './user-notifications.js'
|
||||
import './user-subscriptions.js'
|
||||
import './users-admin.js'
|
||||
@ -37,8 +38,8 @@ import './video-comments.js'
|
||||
import './video-files.js'
|
||||
import './video-imports.js'
|
||||
import './video-playlists.js'
|
||||
import './video-storyboards.js'
|
||||
import './video-source.js'
|
||||
import './video-storyboards.js'
|
||||
import './video-studio.js'
|
||||
import './video-token.js'
|
||||
import './videos-common-filters.js'
|
||||
@ -46,3 +47,4 @@ import './videos-history.js'
|
||||
import './videos-overviews.js'
|
||||
import './videos.js'
|
||||
import './views.js'
|
||||
import './watched-words.js'
|
||||
|
@ -1,7 +1,14 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||
|
||||
import { omit } from '@peertube/peertube-core-utils'
|
||||
import { HttpStatusCode, LiveVideoLatencyMode, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
|
||||
import {
|
||||
HttpStatusCode,
|
||||
LiveVideoCreate,
|
||||
LiveVideoLatencyMode,
|
||||
VideoCommentPolicy,
|
||||
VideoCreateResult,
|
||||
VideoPrivacy
|
||||
} from '@peertube/peertube-models'
|
||||
import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils'
|
||||
import {
|
||||
LiveCommand,
|
||||
@ -67,7 +74,7 @@ describe('Test video lives API validator', function () {
|
||||
})
|
||||
|
||||
describe('When creating a live', function () {
|
||||
let baseCorrectParams
|
||||
let baseCorrectParams: LiveVideoCreate
|
||||
|
||||
before(function () {
|
||||
baseCorrectParams = {
|
||||
@ -76,7 +83,7 @@ describe('Test video lives API validator', function () {
|
||||
licence: 1,
|
||||
language: 'pt',
|
||||
nsfw: false,
|
||||
commentsEnabled: true,
|
||||
commentsPolicy: VideoCommentPolicy.ENABLED,
|
||||
downloadEnabled: true,
|
||||
waitTranscoding: true,
|
||||
description: 'my super description',
|
||||
@ -120,6 +127,12 @@ describe('Test video lives API validator', function () {
|
||||
await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
|
||||
})
|
||||
|
||||
it('Should fail with bad comments policy', async function () {
|
||||
const fields = { ...baseCorrectParams, commentsPolicy: 42 }
|
||||
|
||||
await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
|
||||
})
|
||||
|
||||
it('Should fail with a long description', async function () {
|
||||
const fields = { ...baseCorrectParams, description: 'super'.repeat(2500) }
|
||||
|
||||
|
@ -285,7 +285,7 @@ describe('Test video channel sync API validator', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should succeed when user delete a sync they own', async function () {
|
||||
it('Should succeed when user delete a sync they own', async function () {
|
||||
const { videoChannelSync } = await command.create({
|
||||
attributes: {
|
||||
externalChannelUrl: FIXTURE_URLS.youtubeChannel,
|
||||
|
@ -1,17 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||
|
||||
import { expect } from 'chai'
|
||||
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
|
||||
import { HttpStatusCode, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
|
||||
import { HttpStatusCode, VideoCommentPolicy, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
|
||||
import {
|
||||
PeerTubeServer,
|
||||
cleanupTests,
|
||||
createSingleServer,
|
||||
makeDeleteRequest,
|
||||
makeGetRequest,
|
||||
makePostBodyRequest,
|
||||
PeerTubeServer,
|
||||
setAccessTokensToServers
|
||||
setAccessTokensToServers,
|
||||
setDefaultVideoChannel
|
||||
} from '@peertube/peertube-server-commands'
|
||||
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
|
||||
import { expect } from 'chai'
|
||||
|
||||
describe('Test video comments API validator', function () {
|
||||
let pathThread: string
|
||||
@ -36,6 +37,7 @@ describe('Test video comments API validator', function () {
|
||||
server = await createSingleServer(1)
|
||||
|
||||
await setAccessTokensToServers([ server ])
|
||||
await setDefaultVideoChannel([ server ])
|
||||
|
||||
{
|
||||
video = await server.videos.upload({ attributes: {} })
|
||||
@ -397,9 +399,10 @@ describe('Test video comments API validator', function () {
|
||||
})
|
||||
|
||||
describe('When a video has comments disabled', function () {
|
||||
|
||||
before(async function () {
|
||||
video = await server.videos.upload({ attributes: { commentsEnabled: false } })
|
||||
pathThread = '/api/v1/videos/' + video.uuid + '/comment-threads'
|
||||
video = await server.videos.upload({ attributes: { commentsPolicy: VideoCommentPolicy.DISABLED } })
|
||||
pathThread = `/api/v1/videos/${video.uuid}/comment-threads`
|
||||
})
|
||||
|
||||
it('Should return an empty thread list', async function () {
|
||||
@ -430,52 +433,133 @@ describe('Test video comments API validator', function () {
|
||||
it('Should return conflict on comment thread add')
|
||||
})
|
||||
|
||||
describe('When listing admin comments threads', function () {
|
||||
const path = '/api/v1/videos/comments'
|
||||
describe('When listing admin/user comments', function () {
|
||||
const paths = [ '/api/v1/videos/comments', '/api/v1/users/me/videos/comments' ]
|
||||
|
||||
it('Should fail with a bad start pagination', async function () {
|
||||
await checkBadStartPagination(server.url, path, server.accessToken)
|
||||
})
|
||||
|
||||
it('Should fail with a bad count pagination', async function () {
|
||||
await checkBadCountPagination(server.url, path, server.accessToken)
|
||||
})
|
||||
|
||||
it('Should fail with an incorrect sort', async function () {
|
||||
await checkBadSortPagination(server.url, path, server.accessToken)
|
||||
it('Should fail with a bad start/count pagination of invalid sort', async function () {
|
||||
for (const path of paths) {
|
||||
await checkBadStartPagination(server.url, path, server.accessToken)
|
||||
await checkBadCountPagination(server.url, path, server.accessToken)
|
||||
await checkBadSortPagination(server.url, path, server.accessToken)
|
||||
}
|
||||
})
|
||||
|
||||
it('Should fail with a non authenticated user', async function () {
|
||||
await makeGetRequest({
|
||||
url: server.url,
|
||||
path,
|
||||
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
|
||||
})
|
||||
await server.comments.listForAdmin({ token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
||||
await server.comments.listCommentsOnMyVideos({ token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
||||
})
|
||||
|
||||
it('Should fail with a non admin user', async function () {
|
||||
await makeGetRequest({
|
||||
url: server.url,
|
||||
path,
|
||||
it('Should fail to list admin comments with a non admin user', async function () {
|
||||
await server.comments.listForAdmin({ token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||
})
|
||||
|
||||
it('Should fail with an invalid video', async function () {
|
||||
await server.comments.listForAdmin({ videoId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
||||
await server.comments.listCommentsOnMyVideos({ videoId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
||||
|
||||
await server.comments.listForAdmin({ videoId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||
await server.comments.listCommentsOnMyVideos({ videoId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||
})
|
||||
|
||||
it('Should fail with an invalid channel', async function () {
|
||||
await server.comments.listForAdmin({ videoChannelId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
||||
await server.comments.listCommentsOnMyVideos({ videoChannelId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
||||
|
||||
await server.comments.listForAdmin({ videoChannelId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||
await server.comments.listCommentsOnMyVideos({ videoChannelId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||
})
|
||||
|
||||
it('Should fail to list comments on my videos with non owned video or channel', async function () {
|
||||
await server.comments.listCommentsOnMyVideos({
|
||||
videoId: video.uuid,
|
||||
token: userAccessToken,
|
||||
expectedStatus: HttpStatusCode.FORBIDDEN_403
|
||||
})
|
||||
|
||||
await server.comments.listCommentsOnMyVideos({
|
||||
videoChannelId: server.store.channel.id,
|
||||
token: userAccessToken,
|
||||
expectedStatus: HttpStatusCode.FORBIDDEN_403
|
||||
})
|
||||
})
|
||||
|
||||
it('Should succeed with the correct params', async function () {
|
||||
await makeGetRequest({
|
||||
url: server.url,
|
||||
path,
|
||||
token: server.accessToken,
|
||||
query: {
|
||||
isLocal: false,
|
||||
search: 'toto',
|
||||
searchAccount: 'toto',
|
||||
searchVideo: 'toto'
|
||||
},
|
||||
expectedStatus: HttpStatusCode.OK_200
|
||||
const base = {
|
||||
search: 'toto',
|
||||
searchAccount: 'toto',
|
||||
searchVideo: 'toto',
|
||||
videoId: video.uuid,
|
||||
videoChannelId: server.store.channel.id,
|
||||
autoTagOneOf: [ 'external-link' ]
|
||||
}
|
||||
|
||||
await server.comments.listForAdmin({ ...base, isLocal: false })
|
||||
await server.comments.listCommentsOnMyVideos(base)
|
||||
})
|
||||
})
|
||||
|
||||
describe('When approving a comment', function () {
|
||||
let videoId: string
|
||||
let commentId: number
|
||||
let deletedCommentId: number
|
||||
|
||||
before(async function () {
|
||||
{
|
||||
const res = await server.videos.upload({
|
||||
attributes: {
|
||||
name: 'review policy',
|
||||
commentsPolicy: VideoCommentPolicy.REQUIRES_APPROVAL
|
||||
}
|
||||
})
|
||||
|
||||
videoId = res.uuid
|
||||
}
|
||||
|
||||
{
|
||||
const res = await server.comments.createThread({ text: 'thread', videoId, token: userAccessToken })
|
||||
commentId = res.id
|
||||
}
|
||||
|
||||
{
|
||||
const res = await server.comments.createThread({ text: 'deleted', videoId, token: userAccessToken })
|
||||
deletedCommentId = res.id
|
||||
|
||||
await server.comments.delete({ commentId: deletedCommentId, videoId })
|
||||
}
|
||||
})
|
||||
|
||||
it('Should fail with a non authenticated user', async function () {
|
||||
await server.comments.approve({ token: 'none', commentId, videoId, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
||||
})
|
||||
|
||||
it('Should fail with another user', async function () {
|
||||
await server.comments.approve({ token: userAccessToken2, commentId, videoId, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||
})
|
||||
|
||||
it('Should fail with an incorrect video', async function () {
|
||||
await server.comments.approve({ token: userAccessToken2, commentId, videoId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||
})
|
||||
|
||||
it('Should fail with an incorrect comment', async function () {
|
||||
await server.comments.approve({ token: userAccessToken2, commentId: 42, videoId, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||
})
|
||||
|
||||
it('Should fail with a deleted comment', async function () {
|
||||
await server.comments.approve({
|
||||
token: userAccessToken,
|
||||
commentId: deletedCommentId,
|
||||
videoId,
|
||||
expectedStatus: HttpStatusCode.CONFLICT_409
|
||||
})
|
||||
})
|
||||
|
||||
it('Should succeed with the correct params', async function () {
|
||||
await server.comments.approve({ token: userAccessToken, commentId, videoId })
|
||||
})
|
||||
|
||||
it('Should fail with an already held for review comment', async function () {
|
||||
await server.comments.approve({ token: userAccessToken, commentId, videoId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
||||
})
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
|
@ -1,21 +1,21 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||
|
||||
import { omit } from '@peertube/peertube-core-utils'
|
||||
import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models'
|
||||
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
|
||||
import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js'
|
||||
import { HttpStatusCode, VideoCommentPolicy, VideoImportCreate, VideoPrivacy } from '@peertube/peertube-models'
|
||||
import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils'
|
||||
import {
|
||||
PeerTubeServer,
|
||||
cleanupTests,
|
||||
createSingleServer,
|
||||
makeGetRequest,
|
||||
makePostBodyRequest,
|
||||
makeUploadRequest,
|
||||
PeerTubeServer,
|
||||
setAccessTokensToServers,
|
||||
setDefaultVideoChannel,
|
||||
waitJobs
|
||||
} from '@peertube/peertube-server-commands'
|
||||
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
|
||||
import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js'
|
||||
|
||||
describe('Test video imports API validator', function () {
|
||||
const path = '/api/v1/videos/imports'
|
||||
@ -74,7 +74,7 @@ describe('Test video imports API validator', function () {
|
||||
})
|
||||
|
||||
describe('When adding a video import', function () {
|
||||
let baseCorrectParams
|
||||
let baseCorrectParams: VideoImportCreate
|
||||
|
||||
before(function () {
|
||||
baseCorrectParams = {
|
||||
@ -84,7 +84,7 @@ describe('Test video imports API validator', function () {
|
||||
licence: 1,
|
||||
language: 'pt',
|
||||
nsfw: false,
|
||||
commentsEnabled: true,
|
||||
commentsPolicy: VideoCommentPolicy.ENABLED,
|
||||
downloadEnabled: true,
|
||||
waitTranscoding: true,
|
||||
description: 'my super description',
|
||||
@ -176,6 +176,12 @@ describe('Test video imports API validator', function () {
|
||||
await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
|
||||
})
|
||||
|
||||
it('Should fail with a bad commentsPolicy', async function () {
|
||||
const fields = { ...baseCorrectParams, commentsPolicy: 42 }
|
||||
|
||||
await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
|
||||
})
|
||||
|
||||
it('Should fail with a long description', async function () {
|
||||
const fields = { ...baseCorrectParams, description: 'super'.repeat(2500) }
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user