]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video-comment.ts
ab909b0b81c3ea9ecda79ff180a7a07d3da58892
[github/Chocobozzz/PeerTube.git] / server / models / video / video-comment.ts
1 import * as Sequelize from 'sequelize'
2 import {
3 AllowNull, BeforeDestroy, BelongsTo, Column, CreatedAt, DataType, ForeignKey, IFindOptions, Is, Model, Scopes, Table,
4 UpdatedAt
5 } from 'sequelize-typescript'
6 import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
7 import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
8 import { VideoComment } from '../../../shared/models/videos/video-comment.model'
9 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
10 import { CONSTRAINTS_FIELDS } from '../../initializers'
11 import { sendDeleteVideoComment } from '../../lib/activitypub/send'
12 import { AccountModel } from '../account/account'
13 import { ActorModel } from '../activitypub/actor'
14 import { AvatarModel } from '../avatar/avatar'
15 import { ServerModel } from '../server/server'
16 import { getSort, throwIfNotValid } from '../utils'
17 import { VideoModel } from './video'
18 import { VideoChannelModel } from './video-channel'
19
20 enum ScopeNames {
21 WITH_ACCOUNT = 'WITH_ACCOUNT',
22 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
23 WITH_VIDEO = 'WITH_VIDEO',
24 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
25 }
26
27 @Scopes({
28 [ScopeNames.ATTRIBUTES_FOR_API]: {
29 attributes: {
30 include: [
31 [
32 Sequelize.literal(
33 '(SELECT COUNT("replies"."id") ' +
34 'FROM "videoComment" AS "replies" ' +
35 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id")'
36 ),
37 'totalReplies'
38 ]
39 ]
40 }
41 },
42 [ScopeNames.WITH_ACCOUNT]: {
43 include: [
44 {
45 model: () => AccountModel,
46 include: [
47 {
48 model: () => ActorModel,
49 include: [
50 {
51 model: () => ServerModel,
52 required: false
53 },
54 {
55 model: () => AvatarModel,
56 required: false
57 }
58 ]
59 }
60 ]
61 }
62 ]
63 },
64 [ScopeNames.WITH_IN_REPLY_TO]: {
65 include: [
66 {
67 model: () => VideoCommentModel,
68 as: 'InReplyToVideoComment'
69 }
70 ]
71 },
72 [ScopeNames.WITH_VIDEO]: {
73 include: [
74 {
75 model: () => VideoModel,
76 required: true,
77 include: [
78 {
79 model: () => VideoChannelModel.unscoped(),
80 required: true,
81 include: [
82 {
83 model: () => AccountModel,
84 required: true,
85 include: [
86 {
87 model: () => ActorModel,
88 required: true
89 }
90 ]
91 }
92 ]
93 }
94 ]
95 }
96 ]
97 }
98 })
99 @Table({
100 tableName: 'videoComment',
101 indexes: [
102 {
103 fields: [ 'videoId' ]
104 },
105 {
106 fields: [ 'videoId', 'originCommentId' ]
107 }
108 ]
109 })
110 export class VideoCommentModel extends Model<VideoCommentModel> {
111 @CreatedAt
112 createdAt: Date
113
114 @UpdatedAt
115 updatedAt: Date
116
117 @AllowNull(false)
118 @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
119 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
120 url: string
121
122 @AllowNull(false)
123 @Column(DataType.TEXT)
124 text: string
125
126 @ForeignKey(() => VideoCommentModel)
127 @Column
128 originCommentId: number
129
130 @BelongsTo(() => VideoCommentModel, {
131 foreignKey: {
132 name: 'originCommentId',
133 allowNull: true
134 },
135 as: 'OriginVideoComment',
136 onDelete: 'CASCADE'
137 })
138 OriginVideoComment: VideoCommentModel
139
140 @ForeignKey(() => VideoCommentModel)
141 @Column
142 inReplyToCommentId: number
143
144 @BelongsTo(() => VideoCommentModel, {
145 foreignKey: {
146 name: 'inReplyToCommentId',
147 allowNull: true
148 },
149 as: 'InReplyToVideoComment',
150 onDelete: 'CASCADE'
151 })
152 InReplyToVideoComment: VideoCommentModel
153
154 @ForeignKey(() => VideoModel)
155 @Column
156 videoId: number
157
158 @BelongsTo(() => VideoModel, {
159 foreignKey: {
160 allowNull: false
161 },
162 onDelete: 'CASCADE'
163 })
164 Video: VideoModel
165
166 @ForeignKey(() => AccountModel)
167 @Column
168 accountId: number
169
170 @BelongsTo(() => AccountModel, {
171 foreignKey: {
172 allowNull: false
173 },
174 onDelete: 'CASCADE'
175 })
176 Account: AccountModel
177
178 @BeforeDestroy
179 static async sendDeleteIfOwned (instance: VideoCommentModel, options) {
180 if (!instance.Account || !instance.Account.Actor) {
181 instance.Account = await instance.$get('Account', {
182 include: [ ActorModel ],
183 transaction: options.transaction
184 }) as AccountModel
185 }
186
187 if (instance.isOwned()) {
188 await sendDeleteVideoComment(instance, options.transaction)
189 }
190 }
191
192 static loadById (id: number, t?: Sequelize.Transaction) {
193 const query: IFindOptions<VideoCommentModel> = {
194 where: {
195 id
196 }
197 }
198
199 if (t !== undefined) query.transaction = t
200
201 return VideoCommentModel.findOne(query)
202 }
203
204 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) {
205 const query: IFindOptions<VideoCommentModel> = {
206 where: {
207 id
208 }
209 }
210
211 if (t !== undefined) query.transaction = t
212
213 return VideoCommentModel
214 .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
215 .findOne(query)
216 }
217
218 static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
219 const query: IFindOptions<VideoCommentModel> = {
220 where: {
221 url
222 }
223 }
224
225 if (t !== undefined) query.transaction = t
226
227 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
228 }
229
230 static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Sequelize.Transaction) {
231 const query: IFindOptions<VideoCommentModel> = {
232 where: {
233 url
234 }
235 }
236
237 if (t !== undefined) query.transaction = t
238
239 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
240 }
241
242 static listThreadsForApi (videoId: number, start: number, count: number, sort: string) {
243 const query = {
244 offset: start,
245 limit: count,
246 order: [ getSort(sort) ],
247 where: {
248 videoId,
249 inReplyToCommentId: null
250 }
251 }
252
253 return VideoCommentModel
254 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
255 .findAndCountAll(query)
256 .then(({ rows, count }) => {
257 return { total: count, data: rows }
258 })
259 }
260
261 static listThreadCommentsForApi (videoId: number, threadId: number) {
262 const query = {
263 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
264 where: {
265 videoId,
266 [ Sequelize.Op.or ]: [
267 { id: threadId },
268 { originCommentId: threadId }
269 ]
270 }
271 }
272
273 return VideoCommentModel
274 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
275 .findAndCountAll(query)
276 .then(({ rows, count }) => {
277 return { total: count, data: rows }
278 })
279 }
280
281 static listThreadParentComments (comment: VideoCommentModel, t: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
282 const query = {
283 order: [ [ 'createdAt', order ] ],
284 where: {
285 [ Sequelize.Op.or ]: [
286 { id: comment.getThreadId() },
287 { originCommentId: comment.getThreadId() }
288 ],
289 id: {
290 [ Sequelize.Op.ne ]: comment.id
291 },
292 createdAt: {
293 [ Sequelize.Op.lt ]: comment.createdAt
294 }
295 },
296 transaction: t
297 }
298
299 return VideoCommentModel
300 .scope([ ScopeNames.WITH_ACCOUNT ])
301 .findAll(query)
302 }
303
304 getThreadId (): number {
305 return this.originCommentId || this.id
306 }
307
308 isOwned () {
309 return this.Account.isOwned()
310 }
311
312 toFormattedJSON () {
313 return {
314 id: this.id,
315 url: this.url,
316 text: this.text,
317 threadId: this.originCommentId || this.id,
318 inReplyToCommentId: this.inReplyToCommentId || null,
319 videoId: this.videoId,
320 createdAt: this.createdAt,
321 updatedAt: this.updatedAt,
322 totalReplies: this.get('totalReplies') || 0,
323 account: this.Account.toFormattedJSON()
324 } as VideoComment
325 }
326
327 toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
328 let inReplyTo: string
329 // New thread, so in AS we reply to the video
330 if (this.inReplyToCommentId === null) {
331 inReplyTo = this.Video.url
332 } else {
333 inReplyTo = this.InReplyToVideoComment.url
334 }
335
336 const tag: ActivityTagObject[] = []
337 for (const parentComment of threadParentComments) {
338 const actor = parentComment.Account.Actor
339
340 tag.push({
341 type: 'Mention',
342 href: actor.url,
343 name: `@${actor.preferredUsername}@${actor.getHost()}`
344 })
345 }
346
347 return {
348 type: 'Note' as 'Note',
349 id: this.url,
350 content: this.text,
351 inReplyTo,
352 updated: this.updatedAt.toISOString(),
353 published: this.createdAt.toISOString(),
354 url: this.url,
355 attributedTo: this.Account.Actor.url,
356 tag
357 }
358 }
359 }