1 import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
2 import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
3 import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
4 import { VideoComment } from '../../../shared/models/videos/video-comment.model'
5 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
6 import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
7 import { AccountModel } from '../account/account'
8 import { ActorModel } from '../activitypub/actor'
9 import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getSort, throwIfNotValid } from '../utils'
10 import { VideoModel } from './video'
11 import { VideoChannelModel } from './video-channel'
12 import { getServerActor } from '../../helpers/utils'
13 import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
14 import { regexpCapture } from '../../helpers/regexp'
15 import { uniq } from 'lodash'
16 import { FindOptions, Op, Order, ScopeOptions, Sequelize, Transaction } from 'sequelize'
17 import * as Bluebird from 'bluebird'
22 MCommentOwnerReplyVideoLight,
24 MCommentOwnerVideoFeed,
25 MCommentOwnerVideoReply
26 } from '../../typings/models/video'
27 import { MUserAccountId } from '@server/typings/models'
30 WITH_ACCOUNT = 'WITH_ACCOUNT',
31 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
32 WITH_VIDEO = 'WITH_VIDEO',
33 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
37 [ScopeNames.ATTRIBUTES_FOR_API]: (serverAccountId: number, userAccountId?: number) => {
44 'WITH "blocklist" AS (' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')' +
45 'SELECT COUNT("replies"."id") - (' +
46 'SELECT COUNT("replies"."id") ' +
47 'FROM "videoComment" AS "replies" ' +
48 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
49 'AND "accountId" IN (SELECT "id" FROM "blocklist")' +
51 'FROM "videoComment" AS "replies" ' +
52 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
53 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
62 [ScopeNames.WITH_ACCOUNT]: {
69 [ScopeNames.WITH_IN_REPLY_TO]: {
72 model: VideoCommentModel,
73 as: 'InReplyToVideoComment'
77 [ScopeNames.WITH_VIDEO]: {
84 model: VideoChannelModel,
99 tableName: 'videoComment',
102 fields: [ 'videoId' ]
105 fields: [ 'videoId', 'originCommentId' ]
112 fields: [ 'accountId' ]
116 export class VideoCommentModel extends Model<VideoCommentModel> {
124 @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
125 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
129 @Column(DataType.TEXT)
132 @ForeignKey(() => VideoCommentModel)
134 originCommentId: number
136 @BelongsTo(() => VideoCommentModel, {
138 name: 'originCommentId',
141 as: 'OriginVideoComment',
144 OriginVideoComment: VideoCommentModel
146 @ForeignKey(() => VideoCommentModel)
148 inReplyToCommentId: number
150 @BelongsTo(() => VideoCommentModel, {
152 name: 'inReplyToCommentId',
155 as: 'InReplyToVideoComment',
158 InReplyToVideoComment: VideoCommentModel | null
160 @ForeignKey(() => VideoModel)
164 @BelongsTo(() => VideoModel, {
172 @ForeignKey(() => AccountModel)
176 @BelongsTo(() => AccountModel, {
182 Account: AccountModel
184 static loadById (id: number, t?: Transaction): Bluebird<MComment> {
185 const query: FindOptions = {
191 if (t !== undefined) query.transaction = t
193 return VideoCommentModel.findOne(query)
196 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Transaction): Bluebird<MCommentOwnerVideoReply> {
197 const query: FindOptions = {
203 if (t !== undefined) query.transaction = t
205 return VideoCommentModel
206 .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
210 static loadByUrlAndPopulateAccountAndVideo (url: string, t?: Transaction): Bluebird<MCommentOwnerVideo> {
211 const query: FindOptions = {
217 if (t !== undefined) query.transaction = t
219 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEO ]).findOne(query)
222 static loadByUrlAndPopulateReplyAndVideoUrlAndAccount (url: string, t?: Transaction): Bluebird<MCommentOwnerReplyVideoLight> {
223 const query: FindOptions = {
229 attributes: [ 'id', 'url' ],
230 model: VideoModel.unscoped()
235 if (t !== undefined) query.transaction = t
237 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_ACCOUNT ]).findOne(query)
240 static async listThreadsForApi (parameters: {
245 user?: MUserAccountId
247 const { videoId, start, count, sort, user } = parameters
249 const serverActor = await getServerActor()
250 const serverAccountId = serverActor.Account.id
251 const userAccountId = user ? user.Account.id : undefined
256 order: getSort(sort),
259 inReplyToCommentId: null,
261 [Op.notIn]: Sequelize.literal(
262 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
268 const scopes: (string | ScopeOptions)[] = [
269 ScopeNames.WITH_ACCOUNT,
271 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
275 return VideoCommentModel
277 .findAndCountAll(query)
278 .then(({ rows, count }) => {
279 return { total: count, data: rows }
283 static async listThreadCommentsForApi (parameters: {
286 user?: MUserAccountId
288 const { videoId, threadId, user } = parameters
290 const serverActor = await getServerActor()
291 const serverAccountId = serverActor.Account.id
292 const userAccountId = user ? user.Account.id : undefined
295 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order,
300 { originCommentId: threadId }
303 [Op.notIn]: Sequelize.literal(
304 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
310 const scopes: any[] = [
311 ScopeNames.WITH_ACCOUNT,
313 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
317 return VideoCommentModel
319 .findAndCountAll(query)
320 .then(({ rows, count }) => {
321 return { total: count, data: rows }
325 static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Bluebird<MCommentOwner[]> {
327 order: [ [ 'createdAt', order ] ] as Order,
330 [ Op.in ]: Sequelize.literal('(' +
331 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
332 `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
334 'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
335 'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
337 'SELECT id FROM children' +
339 [ Op.ne ]: comment.id
345 return VideoCommentModel
346 .scope([ ScopeNames.WITH_ACCOUNT ])
350 static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Transaction, order: 'ASC' | 'DESC' = 'ASC') {
352 order: [ [ 'createdAt', order ] ] as Order,
361 return VideoCommentModel.findAndCountAll<MComment>(query)
364 static listForFeed (start: number, count: number, videoId?: number): Bluebird<MCommentOwnerVideoFeed[]> {
366 order: [ [ 'createdAt', 'DESC' ] ] as Order,
372 attributes: [ 'name', 'uuid' ],
373 model: VideoModel.unscoped(),
379 if (videoId) query.where['videoId'] = videoId
381 return VideoCommentModel
382 .scope([ ScopeNames.WITH_ACCOUNT ])
386 static async getStats () {
387 const totalLocalVideoComments = await VideoCommentModel.count({
404 const totalVideoComments = await VideoCommentModel.count()
407 totalLocalVideoComments,
412 static cleanOldCommentsOf (videoId: number, beforeUpdatedAt: Date) {
416 [Op.lt]: beforeUpdatedAt
420 [Op.notIn]: buildLocalAccountIdsIn()
425 return VideoCommentModel.destroy(query)
428 getCommentStaticPath () {
429 return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
432 getThreadId (): number {
433 return this.originCommentId || this.id
437 return this.Account.isOwned()
441 let result: string[] = []
443 const localMention = `@(${actorNameAlphabet}+)`
444 const remoteMention = `${localMention}@${WEBSERVER.HOST}`
446 const mentionRegex = this.isOwned()
447 ? '(?:(?:' + remoteMention + ')|(?:' + localMention + '))' // Include local mentions?
448 : '(?:' + remoteMention + ')'
450 const firstMentionRegex = new RegExp(`^${mentionRegex} `, 'g')
451 const endMentionRegex = new RegExp(` ${mentionRegex}$`, 'g')
452 const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
454 result = result.concat(
455 regexpCapture(this.text, firstMentionRegex)
456 .map(([ , username1, username2 ]) => username1 || username2),
458 regexpCapture(this.text, endMentionRegex)
459 .map(([ , username1, username2 ]) => username1 || username2),
461 regexpCapture(this.text, remoteMentionsRegex)
462 .map(([ , username ]) => username)
465 // Include local mentions
466 if (this.isOwned()) {
467 const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
469 result = result.concat(
470 regexpCapture(this.text, localMentionsRegex)
471 .map(([ , username ]) => username)
483 threadId: this.originCommentId || this.id,
484 inReplyToCommentId: this.inReplyToCommentId || null,
485 videoId: this.videoId,
486 createdAt: this.createdAt,
487 updatedAt: this.updatedAt,
488 totalReplies: this.get('totalReplies') || 0,
489 account: this.Account.toFormattedJSON()
493 toActivityPubObject (threadParentComments: MCommentOwner[]): VideoCommentObject {
494 let inReplyTo: string
495 // New thread, so in AS we reply to the video
496 if (this.inReplyToCommentId === null) {
497 inReplyTo = this.Video.url
499 inReplyTo = this.InReplyToVideoComment.url
502 const tag: ActivityTagObject[] = []
503 for (const parentComment of threadParentComments) {
504 const actor = parentComment.Account.Actor
509 name: `@${actor.preferredUsername}@${actor.getHost()}`
514 type: 'Note' as 'Note',
518 updated: this.updatedAt.toISOString(),
519 published: this.createdAt.toISOString(),
521 attributedTo: this.Account.Actor.url,