1 import * as Sequelize from 'sequelize'
16 } from 'sequelize-typescript'
17 import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
18 import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
19 import { VideoComment } from '../../../shared/models/videos/video-comment.model'
20 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
21 import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers'
22 import { sendDeleteVideoComment } from '../../lib/activitypub/send'
23 import { AccountModel } from '../account/account'
24 import { ActorModel } from '../activitypub/actor'
25 import { AvatarModel } from '../avatar/avatar'
26 import { ServerModel } from '../server/server'
27 import { buildBlockedAccountSQL, getSort, throwIfNotValid } from '../utils'
28 import { VideoModel } from './video'
29 import { VideoChannelModel } from './video-channel'
30 import { getServerActor } from '../../helpers/utils'
31 import { UserModel } from '../account/user'
32 import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
33 import { regexpCapture } from '../../helpers/regexp'
34 import { uniq } from 'lodash'
37 WITH_ACCOUNT = 'WITH_ACCOUNT',
38 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
39 WITH_VIDEO = 'WITH_VIDEO',
40 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
44 [ScopeNames.ATTRIBUTES_FOR_API]: (serverAccountId: number, userAccountId?: number) => {
51 'WITH "blocklist" AS (' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')' +
52 'SELECT COUNT("replies"."id") - (' +
53 'SELECT COUNT("replies"."id") ' +
54 'FROM "videoComment" AS "replies" ' +
55 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
56 'AND "accountId" IN (SELECT "id" FROM "blocklist")' +
58 'FROM "videoComment" AS "replies" ' +
59 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
60 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
69 [ScopeNames.WITH_ACCOUNT]: {
72 model: () => AccountModel,
75 model: () => ActorModel,
78 model: () => ServerModel,
82 model: () => AvatarModel,
91 [ScopeNames.WITH_IN_REPLY_TO]: {
94 model: () => VideoCommentModel,
95 as: 'InReplyToVideoComment'
99 [ScopeNames.WITH_VIDEO]: {
102 model: () => VideoModel,
106 model: () => VideoChannelModel.unscoped(),
110 model: () => AccountModel,
114 model: () => ActorModel,
127 tableName: 'videoComment',
130 fields: [ 'videoId' ]
133 fields: [ 'videoId', 'originCommentId' ]
140 fields: [ 'accountId' ]
144 export class VideoCommentModel extends Model<VideoCommentModel> {
152 @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
153 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
157 @Column(DataType.TEXT)
160 @ForeignKey(() => VideoCommentModel)
162 originCommentId: number
164 @BelongsTo(() => VideoCommentModel, {
166 name: 'originCommentId',
169 as: 'OriginVideoComment',
172 OriginVideoComment: VideoCommentModel
174 @ForeignKey(() => VideoCommentModel)
176 inReplyToCommentId: number
178 @BelongsTo(() => VideoCommentModel, {
180 name: 'inReplyToCommentId',
183 as: 'InReplyToVideoComment',
186 InReplyToVideoComment: VideoCommentModel | null
188 @ForeignKey(() => VideoModel)
192 @BelongsTo(() => VideoModel, {
200 @ForeignKey(() => AccountModel)
204 @BelongsTo(() => AccountModel, {
210 Account: AccountModel
213 static async sendDeleteIfOwned (instance: VideoCommentModel, options) {
214 if (!instance.Account || !instance.Account.Actor) {
215 instance.Account = await instance.$get('Account', {
216 include: [ ActorModel ],
217 transaction: options.transaction
221 if (!instance.Video) {
222 instance.Video = await instance.$get('Video', {
225 model: VideoChannelModel,
238 transaction: options.transaction
242 if (instance.isOwned()) {
243 await sendDeleteVideoComment(instance, options.transaction)
247 static loadById (id: number, t?: Sequelize.Transaction) {
248 const query: IFindOptions<VideoCommentModel> = {
254 if (t !== undefined) query.transaction = t
256 return VideoCommentModel.findOne(query)
259 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) {
260 const query: IFindOptions<VideoCommentModel> = {
266 if (t !== undefined) query.transaction = t
268 return VideoCommentModel
269 .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
273 static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
274 const query: IFindOptions<VideoCommentModel> = {
280 if (t !== undefined) query.transaction = t
282 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
285 static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Sequelize.Transaction) {
286 const query: IFindOptions<VideoCommentModel> = {
292 if (t !== undefined) query.transaction = t
294 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
297 static async listThreadsForApi (videoId: number, start: number, count: number, sort: string, user?: UserModel) {
298 const serverActor = await getServerActor()
299 const serverAccountId = serverActor.Account.id
300 const userAccountId = user ? user.Account.id : undefined
305 order: getSort(sort),
308 inReplyToCommentId: null,
310 [Sequelize.Op.notIn]: Sequelize.literal(
311 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
318 const scopes: any[] = [
319 ScopeNames.WITH_ACCOUNT,
321 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
325 return VideoCommentModel
327 .findAndCountAll(query)
328 .then(({ rows, count }) => {
329 return { total: count, data: rows }
333 static async listThreadCommentsForApi (videoId: number, threadId: number, user?: UserModel) {
334 const serverActor = await getServerActor()
335 const serverAccountId = serverActor.Account.id
336 const userAccountId = user ? user.Account.id : undefined
339 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
342 [ Sequelize.Op.or ]: [
344 { originCommentId: threadId }
347 [Sequelize.Op.notIn]: Sequelize.literal(
348 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
354 const scopes: any[] = [
355 ScopeNames.WITH_ACCOUNT,
357 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
361 return VideoCommentModel
363 .findAndCountAll(query)
364 .then(({ rows, count }) => {
365 return { total: count, data: rows }
369 static listThreadParentComments (comment: VideoCommentModel, t: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
371 order: [ [ 'createdAt', order ] ],
374 [ Sequelize.Op.in ]: Sequelize.literal('(' +
375 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
376 `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
378 'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
379 'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
381 'SELECT id FROM children' +
383 [ Sequelize.Op.ne ]: comment.id
389 return VideoCommentModel
390 .scope([ ScopeNames.WITH_ACCOUNT ])
394 static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
396 order: [ [ 'createdAt', order ] ],
405 return VideoCommentModel.findAndCountAll(query)
408 static listForFeed (start: number, count: number, videoId?: number) {
410 order: [ [ 'createdAt', 'DESC' ] ],
416 attributes: [ 'name', 'uuid' ],
417 model: VideoModel.unscoped(),
423 if (videoId) query.where['videoId'] = videoId
425 return VideoCommentModel
426 .scope([ ScopeNames.WITH_ACCOUNT ])
430 static async getStats () {
431 const totalLocalVideoComments = await VideoCommentModel.count({
448 const totalVideoComments = await VideoCommentModel.count()
451 totalLocalVideoComments,
456 getCommentStaticPath () {
457 return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
460 getThreadId (): number {
461 return this.originCommentId || this.id
465 return this.Account.isOwned()
469 let result: string[] = []
471 const localMention = `@(${actorNameAlphabet}+)`
472 const remoteMention = `${localMention}@${CONFIG.WEBSERVER.HOST}`
474 const mentionRegex = this.isOwned()
475 ? '(?:(?:' + remoteMention + ')|(?:' + localMention + '))' // Include local mentions?
476 : '(?:' + remoteMention + ')'
478 const firstMentionRegex = new RegExp(`^${mentionRegex} `, 'g')
479 const endMentionRegex = new RegExp(` ${mentionRegex}$`, 'g')
480 const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
482 result = result.concat(
483 regexpCapture(this.text, firstMentionRegex)
484 .map(([ , username1, username2 ]) => username1 || username2),
486 regexpCapture(this.text, endMentionRegex)
487 .map(([ , username1, username2 ]) => username1 || username2),
489 regexpCapture(this.text, remoteMentionsRegex)
490 .map(([ , username ]) => username)
493 // Include local mentions
494 if (this.isOwned()) {
495 const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
497 result = result.concat(
498 regexpCapture(this.text, localMentionsRegex)
499 .map(([ , username ]) => username)
511 threadId: this.originCommentId || this.id,
512 inReplyToCommentId: this.inReplyToCommentId || null,
513 videoId: this.videoId,
514 createdAt: this.createdAt,
515 updatedAt: this.updatedAt,
516 totalReplies: this.get('totalReplies') || 0,
517 account: this.Account.toFormattedJSON()
521 toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
522 let inReplyTo: string
523 // New thread, so in AS we reply to the video
524 if (this.inReplyToCommentId === null) {
525 inReplyTo = this.Video.url
527 inReplyTo = this.InReplyToVideoComment.url
530 const tag: ActivityTagObject[] = []
531 for (const parentComment of threadParentComments) {
532 const actor = parentComment.Account.Actor
537 name: `@${actor.preferredUsername}@${actor.getHost()}`
542 type: 'Note' as 'Note',
546 updated: this.updatedAt.toISOString(),
547 published: this.createdAt.toISOString(),
549 attributedTo: this.Account.Actor.url,