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