Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Add Comment Count to Video Preview Components #6635

Open
wants to merge 5 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions client/src/app/shared/shared-main/video/video.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ export class Video implements VideoServerModel {

automaticTags?: string[]

commentCount: number

static buildWatchUrl (video: Partial<Pick<Video, 'uuid' | 'shortUUID'>>) {
return buildVideoWatchPath({ shortUUID: video.shortUUID || video.uuid })
}
Expand Down Expand Up @@ -209,6 +211,8 @@ export class Video implements VideoServerModel {
this.aspectRatio = hash.aspectRatio

this.automaticTags = hash.automaticTags

this.commentCount = hash.commentCount
}

isVideoNSFWForUser (user: User, serverConfig: HTMLServerConfig) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@

<my-video-views-counter *ngIf="displayOptions.views" [isLive]="video.isLive" [viewers]="video.viewers" [views]="video.views"></my-video-views-counter>
</span>
<ng-container *ngIf="hasComments()">
<span class="comment-count"> • </span>
<span class="comment-count">{{ getCommentCount() }}</span>
</ng-container>
</div>

<div class="video-info-privacy fw-semibold">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { AuthService, ScreenService, ServerService, User } from '@app/core'
import { formatICU } from '@app/helpers'
import { HTMLServerConfig, VideoExistInPlaylist, VideoPlaylistType, VideoPrivacy, VideoState } from '@peertube/peertube-models'
import { switchMap } from 'rxjs/operators'
import { LinkType } from '../../../types/link.type'
Expand All @@ -39,7 +40,8 @@ export type MiniatureDisplayOptions = {
nsfw?: boolean

by?: boolean
forceChannelInBy?: boolean
forceChannelInBy?: boolean,
commentCount?: boolean
}
@Component({
selector: 'my-video-miniature',
Expand Down Expand Up @@ -247,6 +249,14 @@ export class VideoMiniatureComponent implements OnInit {
return $localize`Watch video ${this.video.name}`
}

hasComments () {
return this.video.commentCount > 0
}

getCommentCount () {
return $localize`${this.video.commentCount} comment${this.video.commentCount > 1 ? 's' : ''}`
}

loadActions () {
if (this.actionsLoaded) return
if (this.displayVideoActions) this.showActions = true
Expand Down
4 changes: 3 additions & 1 deletion packages/models/src/videos/video.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ export interface Video extends Partial<VideoAdditionalAttributes> {
currentTime: number
}

pluginData?: any
pluginData?: any,

commentCount: number
}

// Not included by default, needs query params
Expand Down
62 changes: 62 additions & 0 deletions packages/tests/src/api/moderation/comment-approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,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.commentCount).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.commentCount).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.commentCount).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.commentCount

// 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.commentCount).to.equal(initialCount)
})
})

after(async function () {
await cleanupTests(servers)
})
Expand Down
32 changes: 32 additions & 0 deletions packages/tests/src/api/videos/video-comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,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.commentCount).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.commentCount).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.commentCount).to.equal(0)
})
})

after(async function () {
await cleanupTests([ server ])
})
Expand Down
2 changes: 1 addition & 1 deletion server/core/initializers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'

// ---------------------------------------------------------------------------

export const LAST_MIGRATION_VERSION = 865
export const LAST_MIGRATION_VERSION = 870

// ---------------------------------------------------------------------------

Expand Down
112 changes: 112 additions & 0 deletions server/core/initializers/migrations/0870-video-comment-count.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import * as Sequelize from 'sequelize'

async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
}): Promise<void> {
const { transaction } = utils

{
// 1. Add the commentCount column as nullable without a default value
await utils.queryInterface.addColumn('video', 'commentCount', {
type: Sequelize.INTEGER,
allowNull: true // Initially allow nulls
}, { transaction })
}

{
// 2. Backfill the commentCount data in small batches
const batchSize = 1000
let offset = 0
let hasMore = true

while (hasMore) {
const [videos] = await utils.sequelize.query(
`
SELECT v.id
FROM video v
ORDER BY v.id
LIMIT ${batchSize} OFFSET ${offset}
`,
{
transaction,
// Sequelize v6 defaults to SELECT type, so no need to specify QueryTypes.SELECT
}
)

if (videos.length === 0) {
hasMore = false
break
}

const videoIds = videos.map((v: any) => v.id)

// Get comment counts for this batch
const [counts] = await utils.sequelize.query(
`
SELECT "videoId", COUNT(*) AS count
FROM "videoComment"
WHERE "videoId" IN (:videoIds)
GROUP BY "videoId"
`,
{
transaction,
replacements: { videoIds }
}
)

// Create a map of videoId to count
const countMap = counts.reduce((map: any, item: any) => {
map[item.videoId] = parseInt(item.count, 10)
return map
}, {})

// Update videos in this batch
const updatePromises = videoIds.map((id: number) => {
const count = countMap[id] || 0
return utils.sequelize.query(
`
UPDATE video
SET "commentCount" = :count
WHERE id = :id
`,
{
transaction,
replacements: { count, id }
}
)
})

await Promise.all(updatePromises)

offset += batchSize
}
}

{
// 3. Set the default value to 0 for future inserts
await utils.queryInterface.changeColumn('video', 'commentCount', {
type: Sequelize.INTEGER,
allowNull: true,
defaultValue: 0
}, { transaction })
}

{
// 4. Alter the column to be NOT NULL now that data is backfilled
await utils.queryInterface.changeColumn('video', 'commentCount', {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
}, { transaction })
}
}

function down (options) {
throw new Error('Not implemented.')
}

export {
down, up
}
2 changes: 2 additions & 0 deletions server/core/models/video/formatter/video-api-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ export function videoModelToFormattedJSON (video: MVideoFormattable, options: Vi
? { currentTime: userHistory.currentTime }
: undefined,

commentCount: video.commentCount,

// Can be added by external plugins
pluginData: (video as any).pluginData,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,8 @@ export class VideoTableAttributes {
'channelId',
'createdAt',
'updatedAt',
'moveJobsRunning'
'moveJobsRunning',
'commentCount'
]
}
}
37 changes: 37 additions & 0 deletions server/core/models/video/video-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ 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,
CreatedAt,
Expand Down Expand Up @@ -222,6 +225,40 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
})
CommentAutomaticTags: Awaited<CommentAutomaticTagModel>[]

@AfterCreate
static async incrementCommentCount(instance: VideoCommentModel, options: any) {
if (instance.heldForReview) return // Don't count held comments

await VideoModel.increment('commentCount', {
by: 1,
where: { id: instance.videoId },
transaction: options.transaction
})
}

@AfterDestroy
static async decrementCommentCount(instance: VideoCommentModel, options: any) {
if (instance.heldForReview) return // Don't count held comments

await VideoModel.decrement('commentCount', {
by: 1,
where: { id: instance.videoId },
transaction: options.transaction
})
}

@AfterUpdate
static async updateCommentCountOnHeldStatusChange(instance: VideoCommentModel, options: any) {
if (instance.changed('heldForReview')) {
const method = instance.heldForReview ? 'decrement' : 'increment'
await VideoModel[method]('commentCount', {
by: 1,
where: { id: instance.videoId },
transaction: options.transaction
})
}
}

// ---------------------------------------------------------------------------

static getSQLAttributes (tableName: string, aliasPrefix = '') {
Expand Down
5 changes: 5 additions & 0 deletions server/core/models/video/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,11 @@ export class VideoModel extends SequelizeModel<VideoModel> {
@Column
originallyPublishedAt: Date

@AllowNull(false)
@Default(0)
@Column
commentCount: number

@ForeignKey(() => VideoChannelModel)
@Column
channelId: number
Expand Down