]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video-comment.ts
Fix tree comment rendering
[github/Chocobozzz/PeerTube.git] / server / models / video / video-comment.ts
1 import * as Sequelize from 'sequelize'
2 import {
3 AfterDestroy, AllowNull, 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 @AfterDestroy
179 static async sendDeleteIfOwned (instance: VideoCommentModel) {
180 if (instance.isOwned()) {
181 await sendDeleteVideoComment(instance, undefined)
182 }
183 }
184
185 static loadById (id: number, t?: Sequelize.Transaction) {
186 const query: IFindOptions<VideoCommentModel> = {
187 where: {
188 id
189 }
190 }
191
192 if (t !== undefined) query.transaction = t
193
194 return VideoCommentModel.findOne(query)
195 }
196
197 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) {
198 const query: IFindOptions<VideoCommentModel> = {
199 where: {
200 id
201 }
202 }
203
204 if (t !== undefined) query.transaction = t
205
206 return VideoCommentModel
207 .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
208 .findOne(query)
209 }
210
211 static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
212 const query: IFindOptions<VideoCommentModel> = {
213 where: {
214 url
215 }
216 }
217
218 if (t !== undefined) query.transaction = t
219
220 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
221 }
222
223 static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Sequelize.Transaction) {
224 const query: IFindOptions<VideoCommentModel> = {
225 where: {
226 url
227 }
228 }
229
230 if (t !== undefined) query.transaction = t
231
232 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
233 }
234
235 static listThreadsForApi (videoId: number, start: number, count: number, sort: string) {
236 const query = {
237 offset: start,
238 limit: count,
239 order: [ getSort(sort) ],
240 where: {
241 videoId,
242 inReplyToCommentId: null
243 }
244 }
245
246 return VideoCommentModel
247 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
248 .findAndCountAll(query)
249 .then(({ rows, count }) => {
250 return { total: count, data: rows }
251 })
252 }
253
254 static listThreadCommentsForApi (videoId: number, threadId: number) {
255 const query = {
256 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
257 where: {
258 videoId,
259 [ Sequelize.Op.or ]: [
260 { id: threadId },
261 { originCommentId: threadId }
262 ]
263 }
264 }
265
266 return VideoCommentModel
267 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
268 .findAndCountAll(query)
269 .then(({ rows, count }) => {
270 return { total: count, data: rows }
271 })
272 }
273
274 static listThreadParentComments (comment: VideoCommentModel, t: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
275 const query = {
276 order: [ [ 'createdAt', order ] ],
277 where: {
278 [ Sequelize.Op.or ]: [
279 { id: comment.getThreadId() },
280 { originCommentId: comment.getThreadId() }
281 ],
282 id: {
283 [ Sequelize.Op.ne ]: comment.id
284 },
285 createdAt: {
286 [ Sequelize.Op.lt ]: comment.createdAt
287 }
288 },
289 transaction: t
290 }
291
292 return VideoCommentModel
293 .scope([ ScopeNames.WITH_ACCOUNT ])
294 .findAll(query)
295 }
296
297 getThreadId (): number {
298 return this.originCommentId || this.id
299 }
300
301 isOwned () {
302 return this.Account.isOwned()
303 }
304
305 toFormattedJSON () {
306 return {
307 id: this.id,
308 url: this.url,
309 text: this.text,
310 threadId: this.originCommentId || this.id,
311 inReplyToCommentId: this.inReplyToCommentId || null,
312 videoId: this.videoId,
313 createdAt: this.createdAt,
314 updatedAt: this.updatedAt,
315 totalReplies: this.get('totalReplies') || 0,
316 account: this.Account.toFormattedJSON()
317 } as VideoComment
318 }
319
320 toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
321 let inReplyTo: string
322 // New thread, so in AS we reply to the video
323 if (this.inReplyToCommentId === null) {
324 inReplyTo = this.Video.url
325 } else {
326 inReplyTo = this.InReplyToVideoComment.url
327 }
328
329 const tag: ActivityTagObject[] = []
330 for (const parentComment of threadParentComments) {
331 const actor = parentComment.Account.Actor
332
333 tag.push({
334 type: 'Mention',
335 href: actor.url,
336 name: `@${actor.preferredUsername}@${actor.getHost()}`
337 })
338 }
339
340 return {
341 type: 'Note' as 'Note',
342 id: this.url,
343 content: this.text,
344 inReplyTo,
345 updated: this.updatedAt.toISOString(),
346 published: this.createdAt.toISOString(),
347 url: this.url,
348 attributedTo: this.Account.Actor.url,
349 tag
350 }
351 }
352 }