]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/video/video-comment.ts
Add ability to mute a user/instance by server in client
[github/Chocobozzz/PeerTube.git] / server / models / video / video-comment.ts
CommitLineData
6d852470
C
1import * as Sequelize from 'sequelize'
2import {
7ad9b984
C
3 AllowNull,
4 BeforeDestroy,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 ForeignKey,
10 IFindOptions,
11 Is,
12 Model,
13 Scopes,
14 Table,
6d852470
C
15 UpdatedAt
16} from 'sequelize-typescript'
d7e70384 17import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
ea44f375 18import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
bf1f6508 19import { VideoComment } from '../../../shared/models/videos/video-comment.model'
da854ddd 20import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
6d852470 21import { CONSTRAINTS_FIELDS } from '../../initializers'
4cb6d457 22import { sendDeleteVideoComment } from '../../lib/activitypub/send'
d3ea8975 23import { AccountModel } from '../account/account'
4635f59d 24import { ActorModel } from '../activitypub/actor'
cf117aaa 25import { AvatarModel } from '../avatar/avatar'
4635f59d 26import { ServerModel } from '../server/server'
7ad9b984 27import { buildBlockedAccountSQL, getSort, throwIfNotValid } from '../utils'
6d852470 28import { VideoModel } from './video'
4cb6d457 29import { VideoChannelModel } from './video-channel'
7ad9b984
C
30import { getServerActor } from '../../helpers/utils'
31import { UserModel } from '../account/user'
6d852470 32
bf1f6508 33enum ScopeNames {
ea44f375 34 WITH_ACCOUNT = 'WITH_ACCOUNT',
4635f59d 35 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
da854ddd 36 WITH_VIDEO = 'WITH_VIDEO',
4635f59d 37 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
bf1f6508
C
38}
39
40@Scopes({
7ad9b984
C
41 [ScopeNames.ATTRIBUTES_FOR_API]: (serverAccountId: number, userAccountId?: number) => {
42 return {
43 attributes: {
44 include: [
45 [
46 Sequelize.literal(
47 '(' +
48 'WITH "blocklist" AS (' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')' +
49 'SELECT COUNT("replies"."id") - (' +
50 'SELECT COUNT("replies"."id") ' +
51 'FROM "videoComment" AS "replies" ' +
52 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
53 'AND "accountId" IN (SELECT "id" FROM "blocklist")' +
54 ')' +
55 'FROM "videoComment" AS "replies" ' +
56 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
57 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
58 ')'
59 ),
60 'totalReplies'
61 ]
4635f59d 62 ]
7ad9b984 63 }
4635f59d
C
64 }
65 },
d3ea8975 66 [ScopeNames.WITH_ACCOUNT]: {
bf1f6508 67 include: [
4635f59d
C
68 {
69 model: () => AccountModel,
70 include: [
71 {
72 model: () => ActorModel,
73 include: [
74 {
75 model: () => ServerModel,
76 required: false
cf117aaa
C
77 },
78 {
79 model: () => AvatarModel,
80 required: false
4635f59d
C
81 }
82 ]
83 }
84 ]
85 }
bf1f6508 86 ]
ea44f375
C
87 },
88 [ScopeNames.WITH_IN_REPLY_TO]: {
89 include: [
90 {
91 model: () => VideoCommentModel,
da854ddd
C
92 as: 'InReplyToVideoComment'
93 }
94 ]
95 },
96 [ScopeNames.WITH_VIDEO]: {
97 include: [
98 {
99 model: () => VideoModel,
4cb6d457
C
100 required: true,
101 include: [
102 {
103 model: () => VideoChannelModel.unscoped(),
104 required: true,
105 include: [
106 {
107 model: () => AccountModel,
108 required: true,
109 include: [
110 {
111 model: () => ActorModel,
112 required: true
113 }
114 ]
115 }
116 ]
117 }
118 ]
ea44f375
C
119 }
120 ]
bf1f6508
C
121 }
122})
6d852470
C
123@Table({
124 tableName: 'videoComment',
125 indexes: [
126 {
127 fields: [ 'videoId' ]
bf1f6508
C
128 },
129 {
130 fields: [ 'videoId', 'originCommentId' ]
0776d83f
C
131 },
132 {
133 fields: [ 'url' ],
134 unique: true
8cd72bd3
C
135 },
136 {
137 fields: [ 'accountId' ]
6d852470
C
138 }
139 ]
140})
141export class VideoCommentModel extends Model<VideoCommentModel> {
142 @CreatedAt
143 createdAt: Date
144
145 @UpdatedAt
146 updatedAt: Date
147
148 @AllowNull(false)
149 @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
150 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
151 url: string
152
153 @AllowNull(false)
154 @Column(DataType.TEXT)
155 text: string
156
157 @ForeignKey(() => VideoCommentModel)
158 @Column
159 originCommentId: number
160
161 @BelongsTo(() => VideoCommentModel, {
162 foreignKey: {
db799da3 163 name: 'originCommentId',
6d852470
C
164 allowNull: true
165 },
db799da3 166 as: 'OriginVideoComment',
6d852470
C
167 onDelete: 'CASCADE'
168 })
169 OriginVideoComment: VideoCommentModel
170
171 @ForeignKey(() => VideoCommentModel)
172 @Column
173 inReplyToCommentId: number
174
175 @BelongsTo(() => VideoCommentModel, {
176 foreignKey: {
db799da3 177 name: 'inReplyToCommentId',
6d852470
C
178 allowNull: true
179 },
da854ddd 180 as: 'InReplyToVideoComment',
6d852470
C
181 onDelete: 'CASCADE'
182 })
c1e791ba 183 InReplyToVideoComment: VideoCommentModel | null
6d852470
C
184
185 @ForeignKey(() => VideoModel)
186 @Column
187 videoId: number
188
189 @BelongsTo(() => VideoModel, {
190 foreignKey: {
191 allowNull: false
192 },
193 onDelete: 'CASCADE'
194 })
195 Video: VideoModel
196
d3ea8975 197 @ForeignKey(() => AccountModel)
6d852470 198 @Column
d3ea8975 199 accountId: number
6d852470 200
d3ea8975 201 @BelongsTo(() => AccountModel, {
6d852470
C
202 foreignKey: {
203 allowNull: false
204 },
205 onDelete: 'CASCADE'
206 })
d3ea8975 207 Account: AccountModel
6d852470 208
f05a1c30
C
209 @BeforeDestroy
210 static async sendDeleteIfOwned (instance: VideoCommentModel, options) {
211 if (!instance.Account || !instance.Account.Actor) {
212 instance.Account = await instance.$get('Account', {
213 include: [ ActorModel ],
214 transaction: options.transaction
215 }) as AccountModel
216 }
217
29c6b829
C
218 if (!instance.Video) {
219 instance.Video = await instance.$get('Video', {
220 include: [
221 {
222 model: VideoChannelModel,
223 include: [
224 {
225 model: AccountModel,
226 include: [
227 {
228 model: ActorModel
229 }
230 ]
231 }
232 ]
233 }
234 ],
235 transaction: options.transaction
236 }) as VideoModel
237 }
238
4cb6d457 239 if (instance.isOwned()) {
f05a1c30 240 await sendDeleteVideoComment(instance, options.transaction)
4cb6d457 241 }
bf1f6508
C
242 }
243
244 static loadById (id: number, t?: Sequelize.Transaction) {
245 const query: IFindOptions<VideoCommentModel> = {
246 where: {
247 id
248 }
249 }
250
251 if (t !== undefined) query.transaction = t
252
253 return VideoCommentModel.findOne(query)
254 }
255
da854ddd
C
256 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) {
257 const query: IFindOptions<VideoCommentModel> = {
258 where: {
259 id
260 }
261 }
262
263 if (t !== undefined) query.transaction = t
264
265 return VideoCommentModel
266 .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
267 .findOne(query)
268 }
269
2ccaeeb3 270 static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
6d852470
C
271 const query: IFindOptions<VideoCommentModel> = {
272 where: {
273 url
274 }
275 }
276
277 if (t !== undefined) query.transaction = t
278
2ccaeeb3 279 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
6d852470 280 }
bf1f6508 281
2ccaeeb3 282 static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Sequelize.Transaction) {
4cb6d457
C
283 const query: IFindOptions<VideoCommentModel> = {
284 where: {
285 url
286 }
287 }
288
289 if (t !== undefined) query.transaction = t
290
2ccaeeb3 291 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
4cb6d457
C
292 }
293
7ad9b984
C
294 static async listThreadsForApi (videoId: number, start: number, count: number, sort: string, user?: UserModel) {
295 const serverActor = await getServerActor()
296 const serverAccountId = serverActor.Account.id
65b21c96 297 const userAccountId = user ? user.Account.id : undefined
7ad9b984 298
bf1f6508
C
299 const query = {
300 offset: start,
301 limit: count,
3bb6c526 302 order: getSort(sort),
bf1f6508 303 where: {
d3ea8975 304 videoId,
7ad9b984
C
305 inReplyToCommentId: null,
306 accountId: {
307 [Sequelize.Op.notIn]: Sequelize.literal(
308 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
309 )
310 }
bf1f6508
C
311 }
312 }
313
7ad9b984
C
314 // FIXME: typings
315 const scopes: any[] = [
316 ScopeNames.WITH_ACCOUNT,
317 {
318 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
319 }
320 ]
321
bf1f6508 322 return VideoCommentModel
7ad9b984 323 .scope(scopes)
bf1f6508
C
324 .findAndCountAll(query)
325 .then(({ rows, count }) => {
326 return { total: count, data: rows }
327 })
328 }
329
7ad9b984
C
330 static async listThreadCommentsForApi (videoId: number, threadId: number, user?: UserModel) {
331 const serverActor = await getServerActor()
332 const serverAccountId = serverActor.Account.id
65b21c96 333 const userAccountId = user ? user.Account.id : undefined
7ad9b984 334
bf1f6508 335 const query = {
a3fd560d 336 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
bf1f6508
C
337 where: {
338 videoId,
339 [ Sequelize.Op.or ]: [
340 { id: threadId },
341 { originCommentId: threadId }
7ad9b984
C
342 ],
343 accountId: {
344 [Sequelize.Op.notIn]: Sequelize.literal(
345 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
346 )
347 }
bf1f6508
C
348 }
349 }
350
7ad9b984
C
351 const scopes: any[] = [
352 ScopeNames.WITH_ACCOUNT,
353 {
354 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
355 }
356 ]
357
bf1f6508 358 return VideoCommentModel
7ad9b984 359 .scope(scopes)
bf1f6508
C
360 .findAndCountAll(query)
361 .then(({ rows, count }) => {
362 return { total: count, data: rows }
363 })
364 }
365
2ccaeeb3 366 static listThreadParentComments (comment: VideoCommentModel, t: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
d7e70384 367 const query = {
2ccaeeb3 368 order: [ [ 'createdAt', order ] ],
d7e70384 369 where: {
d7e70384 370 id: {
a3cffab4
C
371 [ Sequelize.Op.in ]: Sequelize.literal('(' +
372 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
373 'SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ' + comment.id + ' UNION ' +
374 'SELECT p.id, p."inReplyToCommentId" from "videoComment" p ' +
375 'INNER JOIN children c ON c."inReplyToCommentId" = p.id) ' +
376 'SELECT id FROM children' +
377 ')'),
d7e70384
C
378 [ Sequelize.Op.ne ]: comment.id
379 }
380 },
381 transaction: t
382 }
383
384 return VideoCommentModel
385 .scope([ ScopeNames.WITH_ACCOUNT ])
386 .findAll(query)
387 }
388
8fffe21a
C
389 static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
390 const query = {
391 order: [ [ 'createdAt', order ] ],
9a4a9b6c
C
392 offset: start,
393 limit: count,
8fffe21a
C
394 where: {
395 videoId
396 },
397 transaction: t
398 }
399
400 return VideoCommentModel.findAndCountAll(query)
401 }
402
fe3a55b0
C
403 static listForFeed (start: number, count: number, videoId?: number) {
404 const query = {
405 order: [ [ 'createdAt', 'DESC' ] ],
9a4a9b6c
C
406 offset: start,
407 limit: count,
fe3a55b0
C
408 where: {},
409 include: [
410 {
4dae00e6 411 attributes: [ 'name', 'uuid' ],
fe3a55b0
C
412 model: VideoModel.unscoped(),
413 required: true
414 }
415 ]
416 }
417
418 if (videoId) query.where['videoId'] = videoId
419
420 return VideoCommentModel
421 .scope([ ScopeNames.WITH_ACCOUNT ])
422 .findAll(query)
423 }
424
09cababd
C
425 static async getStats () {
426 const totalLocalVideoComments = await VideoCommentModel.count({
427 include: [
428 {
429 model: AccountModel,
430 required: true,
431 include: [
432 {
433 model: ActorModel,
434 required: true,
435 where: {
436 serverId: null
437 }
438 }
439 ]
440 }
441 ]
442 })
443 const totalVideoComments = await VideoCommentModel.count()
444
445 return {
446 totalLocalVideoComments,
447 totalVideoComments
448 }
449 }
450
d7e70384
C
451 getThreadId (): number {
452 return this.originCommentId || this.id
453 }
454
4cb6d457
C
455 isOwned () {
456 return this.Account.isOwned()
457 }
458
bf1f6508
C
459 toFormattedJSON () {
460 return {
461 id: this.id,
462 url: this.url,
463 text: this.text,
464 threadId: this.originCommentId || this.id,
d50acfab 465 inReplyToCommentId: this.inReplyToCommentId || null,
bf1f6508
C
466 videoId: this.videoId,
467 createdAt: this.createdAt,
d3ea8975 468 updatedAt: this.updatedAt,
4635f59d 469 totalReplies: this.get('totalReplies') || 0,
cf117aaa 470 account: this.Account.toFormattedJSON()
bf1f6508
C
471 } as VideoComment
472 }
ea44f375 473
d7e70384 474 toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
ea44f375
C
475 let inReplyTo: string
476 // New thread, so in AS we reply to the video
2cebd797 477 if (this.inReplyToCommentId === null) {
ea44f375
C
478 inReplyTo = this.Video.url
479 } else {
480 inReplyTo = this.InReplyToVideoComment.url
481 }
482
d7e70384
C
483 const tag: ActivityTagObject[] = []
484 for (const parentComment of threadParentComments) {
485 const actor = parentComment.Account.Actor
486
487 tag.push({
488 type: 'Mention',
489 href: actor.url,
490 name: `@${actor.preferredUsername}@${actor.getHost()}`
491 })
492 }
493
ea44f375
C
494 return {
495 type: 'Note' as 'Note',
496 id: this.url,
497 content: this.text,
498 inReplyTo,
da854ddd 499 updated: this.updatedAt.toISOString(),
ea44f375 500 published: this.createdAt.toISOString(),
da854ddd 501 url: this.url,
d7e70384
C
502 attributedTo: this.Account.Actor.url,
503 tag
ea44f375
C
504 }
505 }
6d852470 506}