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:
Chocobozzz 2024-03-29 14:25:03 +01:00 committed by Chocobozzz
parent b3e39df59e
commit 29329d6c45
241 changed files with 8090 additions and 1399 deletions

View File

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

View File

@ -120,7 +120,7 @@ function getRemoteObjectOrDie (
return { url, username, password }
}
function listOptions (val: any) {
function listOptions (val: string) {
return val.split(',')
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'])
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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:' }
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
@use '_variables' as *;
@use '_mixins' as *;
textarea {
min-height: 300px;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,4 +14,6 @@ export type ContextType =
'Actor' |
'Collection' |
'WatchAction' |
'Chapters'
'Chapters' |
'ApproveReply' |
'RejectReply'

View File

@ -13,4 +13,9 @@ export interface VideoCommentObject {
url: string
attributedTo: ActivityPubAttributedTo
tag: ActivityTagObject[]
replyApproval: string | null
to?: string[]
cc?: string[]
}

View File

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

View File

@ -0,0 +1,5 @@
export interface AutoTagPoliciesJSON {
reviewComments: {
name: string
}[]
}

View File

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

View File

@ -4,7 +4,7 @@ export interface UserVideoHistoryExportJSON {
lastTimecode: number
createdAt: string
updatedAt: string
}[]
archiveFiles?: never
archiveFiles?: never
}[]
}

View File

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

View File

@ -0,0 +1,10 @@
export interface WatchedWordsListsJSON {
watchedWordLists: {
createdAt: string
updatedAt: string
listName: string
words: string[]
archiveFiles?: never
}[]
}

View File

@ -18,5 +18,8 @@ export interface UserImportResultSummary {
userSettings: Summary
userVideoHistory: Summary
watchedWordsLists: Summary
commentAutoTagPolicies: Summary
}
}

View File

@ -0,0 +1,8 @@
export type AutomaticTagAvailableType = 'core' | 'watched-words-list'
export interface AutomaticTagAvailable {
available: {
name: string
type: AutomaticTagAvailableType
}[]
}

View File

@ -0,0 +1,6 @@
export const AutomaticTagPolicy = {
NONE: 1,
REVIEW_COMMENT: 2
} as const
export type AutomaticTagPolicyType = typeof AutomaticTagPolicy[keyof typeof AutomaticTagPolicy]

View File

@ -0,0 +1,3 @@
export interface CommentAutomaticTagPoliciesUpdate {
review: string[]
}

View File

@ -0,0 +1,3 @@
export interface CommentAutomaticTagPolicies {
review: string[]
}

View File

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

View File

@ -0,0 +1,9 @@
export interface WatchedWordsList {
id: number
listName: string
words: string[]
updatedAt: Date | string
createdAt: Date | string
}

View File

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

View File

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

View File

@ -85,6 +85,7 @@ export interface UserNotification {
threadId: number
account: ActorInfo
video: VideoInfo
heldForReview: boolean
}
abuse?: {

View File

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

View File

@ -1,2 +1,3 @@
export * from './video-comment-create.model.js'
export * from './video-comment.model.js'
export * from './video-comment-policy.enum.js'

View File

@ -0,0 +1,7 @@
export const VideoCommentPolicy = {
ENABLED: 1,
DISABLED: 2,
REQUIRES_APPROVAL: 3
} as const
export type VideoCommentPolicyType = typeof VideoCommentPolicy[keyof typeof VideoCommentPolicy]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +1,3 @@
export * from './abuses-command.js'
export * from './automatic-tags-command.js'
export * from './watched-words-command.js'

View File

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

View File

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

View File

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

View File

@ -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'
])
}

View 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 ])
})
})

View File

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

View File

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

View File

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

View File

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

View File

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