1 import * as Sequelize from 'sequelize'
2 import { Op } from 'sequelize'
17 } from 'sequelize-typescript'
18 import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
19 import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
20 import { VideoComment } from '../../../shared/models/videos/video-comment.model'
21 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
22 import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers'
23 import { sendDeleteVideoComment } from '../../lib/activitypub/send'
24 import { AccountModel } from '../account/account'
25 import { ActorModel } from '../activitypub/actor'
26 import { AvatarModel } from '../avatar/avatar'
27 import { ServerModel } from '../server/server'
28 import { buildBlockedAccountSQL, getSort, throwIfNotValid } from '../utils'
29 import { VideoModel } from './video'
30 import { VideoChannelModel } from './video-channel'
31 import { getServerActor } from '../../helpers/utils'
32 import { UserModel } from '../account/user'
33 import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
34 import { regexpCapture } from '../../helpers/regexp'
35 import { uniq } from 'lodash'
38 WITH_ACCOUNT = 'WITH_ACCOUNT',
39 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
40 WITH_VIDEO = 'WITH_VIDEO',
41 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
45 [ScopeNames.ATTRIBUTES_FOR_API]: (serverAccountId: number, userAccountId?: number) => {
52 'WITH "blocklist" AS (' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')' +
53 'SELECT COUNT("replies"."id") - (' +
54 'SELECT COUNT("replies"."id") ' +
55 'FROM "videoComment" AS "replies" ' +
56 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
57 'AND "accountId" IN (SELECT "id" FROM "blocklist")' +
59 'FROM "videoComment" AS "replies" ' +
60 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
61 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
70 [ScopeNames.WITH_ACCOUNT]: {
73 model: () => AccountModel,
76 model: () => ActorModel,
79 model: () => ServerModel,
83 model: () => AvatarModel,
92 [ScopeNames.WITH_IN_REPLY_TO]: {
95 model: () => VideoCommentModel,
96 as: 'InReplyToVideoComment'
100 [ScopeNames.WITH_VIDEO]: {
103 model: () => VideoModel,
107 model: () => VideoChannelModel.unscoped(),
111 model: () => AccountModel,
115 model: () => ActorModel,
128 tableName: 'videoComment',
131 fields: [ 'videoId' ]
134 fields: [ 'videoId', 'originCommentId' ]
141 fields: [ 'accountId' ]
145 export class VideoCommentModel extends Model<VideoCommentModel> {
153 @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
154 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
158 @Column(DataType.TEXT)
161 @ForeignKey(() => VideoCommentModel)
163 originCommentId: number
165 @BelongsTo(() => VideoCommentModel, {
167 name: 'originCommentId',
170 as: 'OriginVideoComment',
173 OriginVideoComment: VideoCommentModel
175 @ForeignKey(() => VideoCommentModel)
177 inReplyToCommentId: number
179 @BelongsTo(() => VideoCommentModel, {
181 name: 'inReplyToCommentId',
184 as: 'InReplyToVideoComment',
187 InReplyToVideoComment: VideoCommentModel | null
189 @ForeignKey(() => VideoModel)
193 @BelongsTo(() => VideoModel, {
201 @ForeignKey(() => AccountModel)
205 @BelongsTo(() => AccountModel, {
211 Account: AccountModel
214 static async sendDeleteIfOwned (instance: VideoCommentModel, options) {
215 if (!instance.Account || !instance.Account.Actor) {
216 instance.Account = await instance.$get('Account', {
217 include: [ ActorModel ],
218 transaction: options.transaction
222 if (!instance.Video) {
223 instance.Video = await instance.$get('Video', {
226 model: VideoChannelModel,
239 transaction: options.transaction
243 if (instance.isOwned()) {
244 await sendDeleteVideoComment(instance, options.transaction)
248 static loadById (id: number, t?: Sequelize.Transaction) {
249 const query: IFindOptions<VideoCommentModel> = {
255 if (t !== undefined) query.transaction = t
257 return VideoCommentModel.findOne(query)
260 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) {
261 const query: IFindOptions<VideoCommentModel> = {
267 if (t !== undefined) query.transaction = t
269 return VideoCommentModel
270 .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
274 static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
275 const query: IFindOptions<VideoCommentModel> = {
281 if (t !== undefined) query.transaction = t
283 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
286 static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Sequelize.Transaction) {
287 const query: IFindOptions<VideoCommentModel> = {
293 if (t !== undefined) query.transaction = t
295 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
298 static async listThreadsForApi (videoId: number, start: number, count: number, sort: string, user?: UserModel) {
299 const serverActor = await getServerActor()
300 const serverAccountId = serverActor.Account.id
301 const userAccountId = user ? user.Account.id : undefined
306 order: getSort(sort),
309 inReplyToCommentId: null,
311 [Sequelize.Op.notIn]: Sequelize.literal(
312 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
319 const scopes: any[] = [
320 ScopeNames.WITH_ACCOUNT,
322 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
326 return VideoCommentModel
328 .findAndCountAll(query)
329 .then(({ rows, count }) => {
330 return { total: count, data: rows }
334 static async listThreadCommentsForApi (videoId: number, threadId: number, user?: UserModel) {
335 const serverActor = await getServerActor()
336 const serverAccountId = serverActor.Account.id
337 const userAccountId = user ? user.Account.id : undefined
340 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
343 [ Sequelize.Op.or ]: [
345 { originCommentId: threadId }
348 [Sequelize.Op.notIn]: Sequelize.literal(
349 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
355 const scopes: any[] = [
356 ScopeNames.WITH_ACCOUNT,
358 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
362 return VideoCommentModel
364 .findAndCountAll(query)
365 .then(({ rows, count }) => {
366 return { total: count, data: rows }
370 static listThreadParentComments (comment: VideoCommentModel, t: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
372 order: [ [ 'createdAt', order ] ],
375 [ Sequelize.Op.in ]: Sequelize.literal('(' +
376 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
377 `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
379 'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
380 'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
382 'SELECT id FROM children' +
384 [ Sequelize.Op.ne ]: comment.id
390 return VideoCommentModel
391 .scope([ ScopeNames.WITH_ACCOUNT ])
395 static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
397 order: [ [ 'createdAt', order ] ],
406 return VideoCommentModel.findAndCountAll(query)
409 static listForFeed (start: number, count: number, videoId?: number) {
411 order: [ [ 'createdAt', 'DESC' ] ],
417 attributes: [ 'name', 'uuid' ],
418 model: VideoModel.unscoped(),
424 if (videoId) query.where['videoId'] = videoId
426 return VideoCommentModel
427 .scope([ ScopeNames.WITH_ACCOUNT ])
431 static async getStats () {
432 const totalLocalVideoComments = await VideoCommentModel.count({
449 const totalVideoComments = await VideoCommentModel.count()
452 totalLocalVideoComments,
457 static cleanOldCommentsOf (videoId: number, beforeUpdatedAt: Date) {
461 [Op.lt]: beforeUpdatedAt
467 return VideoCommentModel.destroy(query)
470 getCommentStaticPath () {
471 return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
474 getThreadId (): number {
475 return this.originCommentId || this.id
479 return this.Account.isOwned()
483 let result: string[] = []
485 const localMention = `@(${actorNameAlphabet}+)`
486 const remoteMention = `${localMention}@${CONFIG.WEBSERVER.HOST}`
488 const mentionRegex = this.isOwned()
489 ? '(?:(?:' + remoteMention + ')|(?:' + localMention + '))' // Include local mentions?
490 : '(?:' + remoteMention + ')'
492 const firstMentionRegex = new RegExp(`^${mentionRegex} `, 'g')
493 const endMentionRegex = new RegExp(` ${mentionRegex}$`, 'g')
494 const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
496 result = result.concat(
497 regexpCapture(this.text, firstMentionRegex)
498 .map(([ , username1, username2 ]) => username1 || username2),
500 regexpCapture(this.text, endMentionRegex)
501 .map(([ , username1, username2 ]) => username1 || username2),
503 regexpCapture(this.text, remoteMentionsRegex)
504 .map(([ , username ]) => username)
507 // Include local mentions
508 if (this.isOwned()) {
509 const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
511 result = result.concat(
512 regexpCapture(this.text, localMentionsRegex)
513 .map(([ , username ]) => username)
525 threadId: this.originCommentId || this.id,
526 inReplyToCommentId: this.inReplyToCommentId || null,
527 videoId: this.videoId,
528 createdAt: this.createdAt,
529 updatedAt: this.updatedAt,
530 totalReplies: this.get('totalReplies') || 0,
531 account: this.Account.toFormattedJSON()
535 toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
536 let inReplyTo: string
537 // New thread, so in AS we reply to the video
538 if (this.inReplyToCommentId === null) {
539 inReplyTo = this.Video.url
541 inReplyTo = this.InReplyToVideoComment.url
544 const tag: ActivityTagObject[] = []
545 for (const parentComment of threadParentComments) {
546 const actor = parentComment.Account.Actor
551 name: `@${actor.preferredUsername}@${actor.getHost()}`
556 type: 'Note' as 'Note',
560 updated: this.updatedAt.toISOString(),
561 published: this.createdAt.toISOString(),
563 attributedTo: this.Account.Actor.url,