14 } from 'sequelize-typescript'
15 import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
16 import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
17 import { VideoComment } from '../../../shared/models/videos/video-comment.model'
18 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
19 import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
20 import { sendDeleteVideoComment } from '../../lib/activitypub/send'
21 import { AccountModel } from '../account/account'
22 import { ActorModel } from '../activitypub/actor'
23 import { AvatarModel } from '../avatar/avatar'
24 import { ServerModel } from '../server/server'
25 import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getSort, throwIfNotValid } from '../utils'
26 import { VideoModel } from './video'
27 import { VideoChannelModel } from './video-channel'
28 import { getServerActor } from '../../helpers/utils'
29 import { UserModel } from '../account/user'
30 import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
31 import { regexpCapture } from '../../helpers/regexp'
32 import { uniq } from 'lodash'
33 import { FindOptions, literal, Op, Order, ScopeOptions, Sequelize, Transaction } from 'sequelize'
36 WITH_ACCOUNT = 'WITH_ACCOUNT',
37 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
38 WITH_VIDEO = 'WITH_VIDEO',
39 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
43 [ScopeNames.ATTRIBUTES_FOR_API]: (serverAccountId: number, userAccountId?: number) => {
50 'WITH "blocklist" AS (' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')' +
51 'SELECT COUNT("replies"."id") - (' +
52 'SELECT COUNT("replies"."id") ' +
53 'FROM "videoComment" AS "replies" ' +
54 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
55 'AND "accountId" IN (SELECT "id" FROM "blocklist")' +
57 'FROM "videoComment" AS "replies" ' +
58 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
59 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
68 [ScopeNames.WITH_ACCOUNT]: {
90 [ScopeNames.WITH_IN_REPLY_TO]: {
93 model: VideoCommentModel,
94 as: 'InReplyToVideoComment'
98 [ScopeNames.WITH_VIDEO]: {
105 model: VideoChannelModel.unscoped(),
126 tableName: 'videoComment',
129 fields: [ 'videoId' ]
132 fields: [ 'videoId', 'originCommentId' ]
139 fields: [ 'accountId' ]
143 export class VideoCommentModel extends Model<VideoCommentModel> {
151 @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
152 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
156 @Column(DataType.TEXT)
159 @ForeignKey(() => VideoCommentModel)
161 originCommentId: number
163 @BelongsTo(() => VideoCommentModel, {
165 name: 'originCommentId',
168 as: 'OriginVideoComment',
171 OriginVideoComment: VideoCommentModel
173 @ForeignKey(() => VideoCommentModel)
175 inReplyToCommentId: number
177 @BelongsTo(() => VideoCommentModel, {
179 name: 'inReplyToCommentId',
182 as: 'InReplyToVideoComment',
185 InReplyToVideoComment: VideoCommentModel | null
187 @ForeignKey(() => VideoModel)
191 @BelongsTo(() => VideoModel, {
199 @ForeignKey(() => AccountModel)
203 @BelongsTo(() => AccountModel, {
209 Account: AccountModel
212 static async sendDeleteIfOwned (instance: VideoCommentModel, options) {
213 if (!instance.Account || !instance.Account.Actor) {
214 instance.Account = await instance.$get('Account', {
215 include: [ ActorModel ],
216 transaction: options.transaction
220 if (!instance.Video) {
221 instance.Video = await instance.$get('Video', {
224 model: VideoChannelModel,
237 transaction: options.transaction
241 if (instance.isOwned()) {
242 await sendDeleteVideoComment(instance, options.transaction)
246 static loadById (id: number, t?: Transaction) {
247 const query: FindOptions = {
253 if (t !== undefined) query.transaction = t
255 return VideoCommentModel.findOne(query)
258 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Transaction) {
259 const query: FindOptions = {
265 if (t !== undefined) query.transaction = t
267 return VideoCommentModel
268 .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
272 static loadByUrlAndPopulateAccount (url: string, t?: Transaction) {
273 const query: FindOptions = {
279 if (t !== undefined) query.transaction = t
281 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
284 static loadByUrlAndPopulateReplyAndVideoUrlAndAccount (url: string, t?: Transaction) {
285 const query: FindOptions = {
291 attributes: [ 'id', 'url' ],
292 model: VideoModel.unscoped()
297 if (t !== undefined) query.transaction = t
299 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_ACCOUNT ]).findOne(query)
302 static async listThreadsForApi (parameters: {
309 const { videoId, start, count, sort, user } = parameters
311 const serverActor = await getServerActor()
312 const serverAccountId = serverActor.Account.id
313 const userAccountId = user ? user.Account.id : undefined
318 order: getSort(sort),
321 inReplyToCommentId: null,
323 [Op.notIn]: Sequelize.literal(
324 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
330 const scopes: (string | ScopeOptions)[] = [
331 ScopeNames.WITH_ACCOUNT,
333 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
337 return VideoCommentModel
339 .findAndCountAll(query)
340 .then(({ rows, count }) => {
341 return { total: count, data: rows }
345 static async listThreadCommentsForApi (parameters: {
350 const { videoId, threadId, user } = parameters
352 const serverActor = await getServerActor()
353 const serverAccountId = serverActor.Account.id
354 const userAccountId = user ? user.Account.id : undefined
357 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order,
362 { originCommentId: threadId }
365 [Op.notIn]: Sequelize.literal(
366 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
372 const scopes: any[] = [
373 ScopeNames.WITH_ACCOUNT,
375 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
379 return VideoCommentModel
381 .findAndCountAll(query)
382 .then(({ rows, count }) => {
383 return { total: count, data: rows }
387 static listThreadParentComments (comment: VideoCommentModel, t: Transaction, order: 'ASC' | 'DESC' = 'ASC') {
389 order: [ [ 'createdAt', order ] ] as Order,
392 [ Op.in ]: Sequelize.literal('(' +
393 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
394 `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
396 'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
397 'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
399 'SELECT id FROM children' +
401 [ Op.ne ]: comment.id
407 return VideoCommentModel
408 .scope([ ScopeNames.WITH_ACCOUNT ])
412 static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Transaction, order: 'ASC' | 'DESC' = 'ASC') {
414 order: [ [ 'createdAt', order ] ] as Order,
423 return VideoCommentModel.findAndCountAll(query)
426 static listForFeed (start: number, count: number, videoId?: number) {
428 order: [ [ 'createdAt', 'DESC' ] ] as Order,
434 attributes: [ 'name', 'uuid' ],
435 model: VideoModel.unscoped(),
441 if (videoId) query.where['videoId'] = videoId
443 return VideoCommentModel
444 .scope([ ScopeNames.WITH_ACCOUNT ])
448 static async getStats () {
449 const totalLocalVideoComments = await VideoCommentModel.count({
466 const totalVideoComments = await VideoCommentModel.count()
469 totalLocalVideoComments,
474 static cleanOldCommentsOf (videoId: number, beforeUpdatedAt: Date) {
478 [Op.lt]: beforeUpdatedAt
482 [Op.notIn]: buildLocalAccountIdsIn()
487 return VideoCommentModel.destroy(query)
490 getCommentStaticPath () {
491 return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
494 getThreadId (): number {
495 return this.originCommentId || this.id
499 return this.Account.isOwned()
503 let result: string[] = []
505 const localMention = `@(${actorNameAlphabet}+)`
506 const remoteMention = `${localMention}@${WEBSERVER.HOST}`
508 const mentionRegex = this.isOwned()
509 ? '(?:(?:' + remoteMention + ')|(?:' + localMention + '))' // Include local mentions?
510 : '(?:' + remoteMention + ')'
512 const firstMentionRegex = new RegExp(`^${mentionRegex} `, 'g')
513 const endMentionRegex = new RegExp(` ${mentionRegex}$`, 'g')
514 const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
516 result = result.concat(
517 regexpCapture(this.text, firstMentionRegex)
518 .map(([ , username1, username2 ]) => username1 || username2),
520 regexpCapture(this.text, endMentionRegex)
521 .map(([ , username1, username2 ]) => username1 || username2),
523 regexpCapture(this.text, remoteMentionsRegex)
524 .map(([ , username ]) => username)
527 // Include local mentions
528 if (this.isOwned()) {
529 const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
531 result = result.concat(
532 regexpCapture(this.text, localMentionsRegex)
533 .map(([ , username ]) => username)
545 threadId: this.originCommentId || this.id,
546 inReplyToCommentId: this.inReplyToCommentId || null,
547 videoId: this.videoId,
548 createdAt: this.createdAt,
549 updatedAt: this.updatedAt,
550 totalReplies: this.get('totalReplies') || 0,
551 account: this.Account.toFormattedJSON()
555 toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
556 let inReplyTo: string
557 // New thread, so in AS we reply to the video
558 if (this.inReplyToCommentId === null) {
559 inReplyTo = this.Video.url
561 inReplyTo = this.InReplyToVideoComment.url
564 const tag: ActivityTagObject[] = []
565 for (const parentComment of threadParentComments) {
566 const actor = parentComment.Account.Actor
571 name: `@${actor.preferredUsername}@${actor.getHost()}`
576 type: 'Note' as 'Note',
580 updated: this.updatedAt.toISOString(),
581 published: this.createdAt.toISOString(),
583 attributedTo: this.Account.Actor.url,