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 if (!this.text) return []
471 const localMention = `@(${actorNameAlphabet}+)`
472 const remoteMention = `${localMention}@${CONFIG.WEBSERVER.HOST}`
474 const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
475 const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
476 const firstMentionRegex = new RegExp('^(?:(?:' + remoteMention + ')|(?:' + localMention + ')) ', 'g')
477 const endMentionRegex = new RegExp(' (?:(?:' + remoteMention + ')|(?:' + localMention + '))$', 'g')
481 regexpCapture(this.text, remoteMentionsRegex)
482 .map(([ , username ]) => username),
484 regexpCapture(this.text, localMentionsRegex)
485 .map(([ , username ]) => username),
487 regexpCapture(this.text, firstMentionRegex)
488 .map(([ , username1, username2 ]) => username1 || username2),
490 regexpCapture(this.text, endMentionRegex)
491 .map(([ , username1, username2 ]) => username1 || username2)
501 threadId: this.originCommentId || this.id,
502 inReplyToCommentId: this.inReplyToCommentId || null,
503 videoId: this.videoId,
504 createdAt: this.createdAt,
505 updatedAt: this.updatedAt,
506 totalReplies: this.get('totalReplies') || 0,
507 account: this.Account.toFormattedJSON()
511 toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
512 let inReplyTo: string
513 // New thread, so in AS we reply to the video
514 if (this.inReplyToCommentId === null) {
515 inReplyTo = this.Video.url
517 inReplyTo = this.InReplyToVideoComment.url
520 const tag: ActivityTagObject[] = []
521 for (const parentComment of threadParentComments) {
522 const actor = parentComment.Account.Actor
527 name: `@${actor.preferredUsername}@${actor.getHost()}`
532 type: 'Note' as 'Note',
536 updated: this.updatedAt.toISOString(),
537 published: this.createdAt.toISOString(),
539 attributedTo: this.Account.Actor.url,