1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 09:49:20 +02:00

Add Comment Count to Video Preview Components (#6635)

* WIP: Add backend functionality to store comment count per video and update on comment visibility actions

* WIP: Display image icon and comment count on video miniature component

* Probably don't need to index the comment count

* Added comment count back to mini video component

* Added basic tests

* Sort by comments, more robust comments count

---------

Co-authored-by: Chocobozzz <me@florianbigard.com>
This commit is contained in:
RF9A5V 2025-04-02 07:29:22 -07:00 committed by GitHub
parent 75d7c2a9dc
commit 25a9f37ded
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 255 additions and 56 deletions

View file

@ -78,14 +78,15 @@
<p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
</th>
<th scope="col" *ngIf="isSelected('duration')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="duration" i18n>{{ getColumn('duration').label }} <p-sortIcon field="duration"></p-sortIcon></th>
<th scope="col" *ngIf="isSelected('name')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="name" i18n>{{ getColumn('name').label }} <p-sortIcon field="name"></p-sortIcon></th>
<th scope="col" *ngIf="isSelected('privacy')" i18n>{{ getColumn('privacy').label }}</th>
<th scope="col" *ngIf="isSelected('sensitive')" i18n>{{ getColumn('sensitive').label }}</th>
<th scope="col" *ngIf="isSelected('playlists')" i18n>{{ getColumn('playlists').label }}</th>
<th scope="col" *ngIf="isSelected('insights')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="views" i18n>{{ getColumn('insights').label }} <p-sortIcon field="views"></p-sortIcon></th>
<th scope="col" *ngIf="isSelected('duration')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="duration">{{ getColumn('duration').label }} <p-sortIcon field="duration"></p-sortIcon></th>
<th scope="col" *ngIf="isSelected('name')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="name">{{ getColumn('name').label }} <p-sortIcon field="name"></p-sortIcon></th>
<th scope="col" *ngIf="isSelected('privacy')">{{ getColumn('privacy').label }}</th>
<th scope="col" *ngIf="isSelected('sensitive')">{{ getColumn('sensitive').label }}</th>
<th scope="col" *ngIf="isSelected('playlists')">{{ getColumn('playlists').label }}</th>
<th scope="col" *ngIf="isSelected('insights')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="views">{{ getColumn('insights').label }} <p-sortIcon field="views"></p-sortIcon></th>
<th scope="col" *ngIf="isSelected('comments')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="comments">{{ getColumn('comments').label }} <p-sortIcon field="comments"></p-sortIcon></th>
<th scope="col" *ngIf="isSelected('published')" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="publishedAt">{{ getColumn('published').label }} <p-sortIcon field="publishedAt"></p-sortIcon></th>
<th scope="col" *ngIf="isSelected('state')" i18n>{{ getColumn('state').label }}</th>
<th scope="col" *ngIf="isSelected('state')">{{ getColumn('state').label }}</th>
<th scope="col" width="250px" class="action-head">
<div class="d-flex align-items-center justify-content-between">
@ -162,6 +163,10 @@
</a>
</td>
<td *ngIf="isSelected('comments')">
<span i18n>{video.comments, plural, =0 {No comments} =1 {1 comment} other {{{ video.comments | myNumberFormatter }} comments}}</span>
</td>
<td *ngIf="isSelected('published')">
{{ video.publishedAt | ptDate: 'short' }}
</td>

View file

@ -52,7 +52,7 @@ import { VideoPrivacyBadgeComponent } from '../../shared/shared-video/video-priv
import { VideoStateBadgeComponent } from '../../shared/shared-video/video-state-badge.component'
import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
type Column = 'duration' | 'name' | 'privacy' | 'sensitive' | 'playlists' | 'insights' | 'published' | 'state'
type Column = 'duration' | 'name' | 'privacy' | 'sensitive' | 'playlists' | 'insights' | 'published' | 'state' | 'comments'
type CommonFilter = 'live' | 'vod' | 'private' | 'internal' | 'unlisted' | 'password-protected' | 'public'
@Component({
@ -157,6 +157,7 @@ export class MyVideosComponent extends RestTable<Video> implements OnInit, OnDes
{ id: 'sensitive', label: $localize`Sensitive`, selected: true },
{ id: 'playlists', label: $localize`Playlists`, selected: true },
{ id: 'insights', label: $localize`Insights`, selected: true },
{ id: 'comments', label: $localize`Comments`, selected: false },
{ id: 'published', label: $localize`Published`, selected: true },
{ id: 'state', label: $localize`State`, selected: true }
]

View file

@ -116,6 +116,8 @@ export class Video implements VideoServerModel {
automaticTags?: string[]
comments: number
static buildWatchUrl (video: Partial<Pick<Video, 'uuid' | 'shortUUID'>>) {
return buildVideoWatchPath({ shortUUID: video.shortUUID || video.uuid })
}
@ -211,6 +213,8 @@ export class Video implements VideoServerModel {
this.aspectRatio = hash.aspectRatio
this.automaticTags = hash.automaticTags
this.comments = hash.comments
}
isVideoNSFWForUser (user: User, serverConfig: HTMLServerConfig) {

View file

@ -6,9 +6,9 @@ import {
LOCALE_ID,
OnInit,
booleanAttribute,
numberAttribute,
inject,
input,
numberAttribute,
output
} from '@angular/core'
import { RouterLink } from '@angular/router'
@ -24,8 +24,8 @@ import { VideoService } from '../shared-main/video/video.service'
import { VideoThumbnailComponent } from '../shared-thumbnail/video-thumbnail.component'
import { VideoPlaylistService } from '../shared-video-playlist/video-playlist.service'
import { VideoViewsCounterComponent } from '../shared-video/video-views-counter.component'
import { VideoActionsDisplayType, VideoActionsDropdownComponent } from './video-actions-dropdown.component'
import { ActorHostComponent } from '../standalone-actor/actor-host.component'
import { VideoActionsDisplayType, VideoActionsDropdownComponent } from './video-actions-dropdown.component'
export type MiniatureDisplayOptions = {
date?: boolean

View file

@ -51,6 +51,8 @@ export interface Video extends Partial<VideoAdditionalAttributes> {
likes: number
dislikes: number
comments: number
nsfw: boolean
account: AccountSummary

View file

@ -12,9 +12,12 @@ import {
} from '@peertube/peertube-models'
import {
PeerTubeServer,
cleanupTests, createMultipleServers,
cleanupTests,
createMultipleServers,
doubleFollow,
makeActivityPubGetRequest, makeActivityPubRawRequest, setAccessTokensToServers,
makeActivityPubGetRequest,
makeActivityPubRawRequest,
setAccessTokensToServers,
setDefaultAccountAvatar,
waitJobs
} from '@peertube/peertube-server-commands'
@ -546,6 +549,68 @@ describe('Test comments approval', function () {
})
})
describe('Comment count with moderation', function () {
let videoId: string
before(async function () {
videoId = await createVideo(VideoCommentPolicy.REQUIRES_APPROVAL)
})
it('Should not increment comment count when comment is held for review', async function () {
await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: 'held comment' })
await waitJobs(servers)
const video = await servers[0].videos.get({ id: videoId })
expect(video.comments).to.equal(0)
})
it('Should increment comment count after approving comment', async function () {
// Create a new comment that will be held for review
await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: 'test comment' })
await waitJobs(servers)
// Get the held comment
const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
const heldComment = data.find(c => c.text === 'test comment')
expect(heldComment.heldForReview).to.be.true
// Verify initial count is 0
let video = await servers[0].videos.get({ id: videoId })
expect(video.comments).to.equal(0)
// Approve the comment
await servers[0].comments.approve({ token: userToken, videoId, commentId: heldComment.id })
await waitJobs(servers)
// Verify count incremented after approval
video = await servers[0].videos.get({ id: videoId })
expect(video.comments).to.equal(1)
})
it('Should not increment comment count when deleting held comment', async function () {
// Get initial comment count
let video = await servers[0].videos.get({ id: videoId })
const initialCount = video.comments
// Create a new comment that will be held for review
await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: 'to be deleted' })
await waitJobs(servers)
// Get the held comment
const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
const heldComment = data.find(c => c.text === 'to be deleted')
expect(heldComment.heldForReview).to.be.true
// Delete the held comment
await servers[0].comments.delete({ token: userToken, videoId, commentId: heldComment.id })
await waitJobs(servers)
// Verify count remains unchanged after deleting held comment
video = await servers[0].videos.get({ id: videoId })
expect(video.comments).to.equal(initialCount)
})
})
after(async function () {
await cleanupTests(servers)
})

View file

@ -45,7 +45,6 @@ describe('Test video comments', function () {
})
describe('User comments', function () {
it('Should not have threads on this video', async function () {
const body = await command.listThreads({ videoId: videoUUID })
@ -256,11 +255,10 @@ describe('Test video comments', function () {
})
describe('Listing comments on my videos and in admin', function () {
const listFunctions = () => ([
const listFunctions = () => [
command.listForAdmin.bind(command),
command.listCommentsOnMyVideos.bind(command)
])
]
it('Should list comments', async function () {
for (const fn of listFunctions()) {
@ -401,6 +399,38 @@ describe('Test video comments', function () {
// Auto tags filter is checked auto tags test file
})
describe('Video comment count', function () {
let testVideoUUID: string
before(async function () {
const { uuid } = await server.videos.upload()
testVideoUUID = uuid
})
it('Should start with 0 comments', async function () {
const video = await server.videos.get({ id: testVideoUUID })
expect(video.commentsEnabled).to.be.true
expect(video.comments).to.equal(0)
})
it('Should increment comment count when adding comment', async function () {
await command.createThread({ videoId: testVideoUUID, text: 'test comment' })
const video = await server.videos.get({ id: testVideoUUID })
expect(video.comments).to.equal(1)
})
it('Should decrement count when deleting comment', async function () {
const { data } = await command.listThreads({ videoId: testVideoUUID })
const commentToDelete = data[0]
await command.delete({ videoId: testVideoUUID, commentId: commentToDelete.id })
const video = await server.videos.get({ id: testVideoUUID })
expect(video.comments).to.equal(0)
})
})
after(async function () {
await cleanupTests([ server ])
})

View file

@ -46,7 +46,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
// ---------------------------------------------------------------------------
export const LAST_MIGRATION_VERSION = 875
export const LAST_MIGRATION_VERSION = 880
// ---------------------------------------------------------------------------
@ -119,6 +119,7 @@ export const SORTABLE_COLUMNS = {
'originallyPublishedAt',
'views',
'likes',
'comments',
'trending',
'hot',
'best',
@ -1286,7 +1287,9 @@ export async function buildLanguages () {
return (l.iso6391 !== undefined && l.type === 'living') ||
additionalLanguages[l.iso6393] === true
})
.forEach(l => { languages[l.iso6391 || l.iso6393] = l.name })
.forEach(l => {
languages[l.iso6391 || l.iso6393] = l.name
})
// Override Occitan label
languages['oc'] = 'Occitan'

View file

@ -0,0 +1,31 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
}): Promise<void> {
const { transaction } = utils
{
await utils.queryInterface.addColumn('video', 'comments', {
type: Sequelize.INTEGER,
defaultValue: 0,
allowNull: false
}, { transaction })
}
{
const query = 'UPDATE "video" SET "comments" = (SELECT COUNT(*) FROM "videoComment" WHERE "videoComment"."videoId" = "video"."id")'
await utils.sequelize.query(query, { transaction })
}
}
function down (options) {
throw new Error('Not implemented.')
}
export {
down,
up
}

View file

@ -127,6 +127,8 @@ export function videoModelToFormattedJSON (video: MVideoFormattable, options: Vi
? { currentTime: userHistory.currentTime }
: undefined,
comments: video.comments,
// Can be added by external plugins
pluginData: (video as any).pluginData,

View file

@ -1,12 +1,8 @@
/**
*
* Class to build video attributes/join names we want to fetch from the database
*
*/
export class VideoTableAttributes {
constructor (private readonly mode: 'get' | 'list') {
}
getChannelAttributesForUser () {
@ -295,7 +291,8 @@ export class VideoTableAttributes {
'channelId',
'createdAt',
'updatedAt',
'moveJobsRunning'
'moveJobsRunning',
'comments'
]
}
}

View file

@ -7,19 +7,27 @@ import {
VideoCommentForAdminOrUser,
VideoCommentObject
} from '@peertube/peertube-models'
import { afterCommitIfTransaction, retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { logger } from '@server/helpers/logger.js'
import { extractMentions } from '@server/helpers/mentions.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { getLocalApproveReplyActivityPubUrl } from '@server/lib/activitypub/url.js'
import { getServerActor } from '@server/models/application/application.js'
import { MAccount, MAccountId, MUserAccountId } from '@server/types/models/index.js'
import { Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
import {
AfterCreate,
AfterDestroy,
AfterUpdate,
AllowNull,
BelongsTo, Column,
BelongsTo,
Column,
CreatedAt,
DataType,
ForeignKey,
HasMany,
Is, Scopes,
Is,
Scopes,
Table,
UpdatedAt
} from 'sequelize-typescript'
@ -27,13 +35,14 @@ import { isActivityPubUrlValid } from '../../helpers/custom-validators/activityp
import { CONSTRAINTS_FIELDS, USER_EXPORT_MAX_ITEMS } from '../../initializers/constants.js'
import {
MComment,
MCommentAP,
MCommentAdminOrUserFormattable,
MCommentAP,
MCommentExport,
MCommentFormattable,
MCommentId,
MCommentOwner,
MCommentOwnerReplyVideoImmutable, MCommentOwnerVideoFeed,
MCommentOwnerReplyVideoImmutable,
MCommentOwnerVideoFeed,
MCommentOwnerVideoReply,
MVideo,
MVideoImmutable
@ -42,7 +51,7 @@ import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse.js'
import { AccountModel } from '../account/account.js'
import { ActorModel } from '../actor/actor.js'
import { CommentAutomaticTagModel } from '../automatic-tag/comment-automatic-tag.js'
import { SequelizeModel, buildLocalAccountIdsIn, buildSQLAttributes, throwIfNotValid } from '../shared/index.js'
import { buildLocalAccountIdsIn, buildSQLAttributes, SequelizeModel, throwIfNotValid } from '../shared/index.js'
import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder.js'
import { VideoChannelModel } from './video-channel.js'
import { VideoModel } from './video.js'
@ -222,6 +231,43 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
})
CommentAutomaticTags: Awaited<CommentAutomaticTagModel>[]
@AfterCreate
@AfterDestroy
static incrementCommentCount (instance: VideoCommentModel, options: any) {
if (instance.heldForReview) return // Don't count held comments
return afterCommitIfTransaction(options.transaction, () => this.rebuildCommentsCount(instance.videoId))
}
@AfterUpdate
static updateCommentCountOnHeldStatusChange (instance: VideoCommentModel, options: any) {
return afterCommitIfTransaction(options.transaction, () => this.rebuildCommentsCount(instance.videoId))
}
// ---------------------------------------------------------------------------
static async rebuildCommentsCount (videoId: number) {
try {
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async transaction => {
const video = await VideoModel.load(videoId, transaction)
video.comments = await new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, {
selectType: 'comment-only',
videoId: video.id,
heldForReview: false,
notDeleted: true,
transaction
}).countComments()
await video.save({ transaction })
})
})
} catch (err) {
logger.error('Cannot rebuild comments count for video ' + videoId, { err })
}
}
// ---------------------------------------------------------------------------
static getSQLAttributes (tableName: string, aliasPrefix = '') {
@ -442,7 +488,8 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
order: [ [ 'createdAt', order ] ] as Order,
where: {
id: {
[Op.in]: Sequelize.literal('(' +
[Op.in]: Sequelize.literal(
'(' +
'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
`SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
'UNION ' +
@ -450,7 +497,8 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
') ' +
'SELECT id FROM children' +
')'),
')'
),
[Op.ne]: comment.id
}
},

View file

@ -507,6 +507,13 @@ export class VideoModel extends SequelizeModel<VideoModel> {
@Column
dislikes: number
@AllowNull(false)
@Default(0)
@IsInt
@Min(0)
@Column
comments: number
@AllowNull(false)
@Column
remote: boolean

View file

@ -7186,6 +7186,7 @@ components:
- -publishedAt
- -views
- -likes
- -comments
- -trending
- -hot
- -best
@ -8445,6 +8446,9 @@ components:
dislikes:
type: integer
example: 7
comments:
description: "**PeerTube >= 7.2** Number of comments on the video"
type: integer
nsfw:
type: boolean
waitTranscoding: