aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJulien Maulny <julien.maulny@protonmail.com>2019-11-15 19:05:08 +0100
committerChocobozzz <chocobozzz@cpy.re>2019-12-04 09:36:45 +0100
commit69222afac8f8c41d90295b33f0695bbff352851e (patch)
tree63fe1faea94dd3bfc54e633631eecb275c969e54
parent69c7f7525ddf13b7ced787d8b72ac74b43665517 (diff)
downloadPeerTube-69222afac8f8c41d90295b33f0695bbff352851e.tar.gz
PeerTube-69222afac8f8c41d90295b33f0695bbff352851e.tar.zst
PeerTube-69222afac8f8c41d90295b33f0695bbff352851e.zip
Soft delete video comments instead of detroy
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.component.html47
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.component.scss11
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.component.ts2
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.model.ts16
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comments.component.ts35
-rw-r--r--server/controllers/activitypub/client.ts13
-rw-r--r--server/controllers/api/videos/comment.ts12
-rw-r--r--server/initializers/migrations/0450-soft-delete-video-comments.ts36
-rw-r--r--server/lib/activitypub/process/process-delete.ts7
-rw-r--r--server/lib/video-comment.ts9
-rw-r--r--server/models/account/account.ts2
-rw-r--r--server/models/video/video-comment.ts33
-rw-r--r--server/tests/api/videos/multiple-servers.ts56
-rw-r--r--server/tests/api/videos/video-comments.ts24
-rw-r--r--shared/models/activitypub/objects/common-objects.ts11
-rw-r--r--shared/models/videos/video-comment.model.ts2
16 files changed, 237 insertions, 79 deletions
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.html b/client/src/app/videos/+video-watch/comment/video-comment.component.html
index 60b803206..6ec35d63b 100644
--- a/client/src/app/videos/+video-watch/comment/video-comment.component.html
+++ b/client/src/app/videos/+video-watch/comment/video-comment.component.html
@@ -1,22 +1,45 @@
1<div class="root-comment"> 1<div class="root-comment">
2 <img [src]="comment.accountAvatarUrl" alt="Avatar" /> 2 <img
3 *ngIf="!comment.isDeleted"
4 class="comment-avatar"
5 [src]="comment.accountAvatarUrl"
6 alt="Avatar"
7 />
8
9 <span
10 *ngIf="comment.isDeleted"
11 class="comment-avatar"
12 ></span>
3 13
4 <div class="comment"> 14 <div class="comment">
5 <div *ngIf="highlightedComment === true" class="highlighted-comment" i18n>Highlighted comment</div> 15 <ng-container *ngIf="!comment.isDeleted">
16 <div *ngIf="highlightedComment === true" class="highlighted-comment" i18n>Highlighted comment</div>
6 17
7 <div class="comment-account-date"> 18 <div class="comment-account-date">
8 <a [href]="comment.account.url" target="_blank" rel="noopener noreferrer" class="comment-account">{{ comment.by }}</a> 19 <a [href]="comment.account.url" target="_blank" rel="noopener noreferrer" class="comment-account">{{ comment.by }}</a>
9 <a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]" class="comment-date">{{ comment.createdAt | myFromNow }}</a> 20 <a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]" class="comment-date">{{ comment.createdAt | myFromNow }}</a>
10 </div> 21 </div>
11 <div class="comment-html" [innerHTML]="sanitizedCommentHTML"></div> 22 <div class="comment-html" [innerHTML]="sanitizedCommentHTML"></div>
12 23
13 <div class="comment-actions"> 24 <div class="comment-actions">
14 <div *ngIf="isUserLoggedIn()" (click)="onWantToReply()" class="comment-action-reply" i18n>Reply</div> 25 <div *ngIf="isUserLoggedIn()" (click)="onWantToReply()" class="comment-action-reply" i18n>Reply</div>
15 <div *ngIf="isRemovableByUser()" (click)="onWantToDelete()" class="comment-action-delete" i18n>Delete</div> 26 <div *ngIf="isRemovableByUser()" (click)="onWantToDelete()" class="comment-action-delete" i18n>Delete</div>
16 </div> 27 </div>
28 </ng-container>
29
30 <ng-container *ngIf="comment.isDeleted">
31 <div class="comment-account-date">
32 <span class="comment-account" i18n>Deleted</span>
33 <a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]" class="comment-date">{{ comment.createdAt | myFromNow }}</a>
34 </div>
35
36 <div *ngIf="comment.isDeleted" class="comment-html comment-html-deleted">
37 <i i18n>This comment has been deleted</i>
38 </div>
39 </ng-container>
17 40
18 <my-video-comment-add 41 <my-video-comment-add
19 *ngIf="isUserLoggedIn() && inReplyToCommentId === comment.id" 42 *ngIf="!comment.isDeleted && isUserLoggedIn() && inReplyToCommentId === comment.id"
20 [user]="user" 43 [user]="user"
21 [video]="video" 44 [video]="video"
22 [parentComment]="comment" 45 [parentComment]="comment"
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.scss b/client/src/app/videos/+video-watch/comment/video-comment.component.scss
index c3ab1ab01..ac633fd64 100644
--- a/client/src/app/videos/+video-watch/comment/video-comment.component.scss
+++ b/client/src/app/videos/+video-watch/comment/video-comment.component.scss
@@ -5,7 +5,7 @@
5 font-size: 15px; 5 font-size: 15px;
6 display: flex; 6 display: flex;
7 7
8 img { 8 .comment-avatar {
9 @include avatar(36px); 9 @include avatar(36px);
10 10
11 margin-top: 5px; 11 margin-top: 5px;
@@ -48,6 +48,7 @@
48 48
49 .comment-html { 49 .comment-html {
50 @include peertube-word-wrap; 50 @include peertube-word-wrap;
51 margin-bottom: 10px;
51 52
52 // Mentions 53 // Mentions
53 ::ng-deep a { 54 ::ng-deep a {
@@ -61,10 +62,14 @@
61 } 62 }
62 63
63 } 64 }
65
66 &.comment-html-deleted {
67 color: $grey-foreground-color;
68 }
64 } 69 }
65 70
66 .comment-actions { 71 .comment-actions {
67 margin: 10px 0; 72 margin-bottom: 10px;
68 display: flex; 73 display: flex;
69 74
70 .comment-action-reply, 75 .comment-action-reply,
@@ -100,7 +105,7 @@
100 } 105 }
101 106
102 .root-comment { 107 .root-comment {
103 img { margin-right: 10px; } 108 .comment-avatar { margin-right: 10px; }
104 } 109 }
105} 110}
106 111
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.ts b/client/src/app/videos/+video-watch/comment/video-comment.component.ts
index 172eb0a39..4d3c049a1 100644
--- a/client/src/app/videos/+video-watch/comment/video-comment.component.ts
+++ b/client/src/app/videos/+video-watch/comment/video-comment.component.ts
@@ -78,7 +78,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
78 } 78 }
79 79
80 isRemovableByUser () { 80 isRemovableByUser () {
81 return this.isUserLoggedIn() && 81 return this.comment.account && this.isUserLoggedIn() &&
82 ( 82 (
83 this.user.account.id === this.comment.account.id || 83 this.user.account.id === this.comment.account.id ||
84 this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) 84 this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.model.ts b/client/src/app/videos/+video-watch/comment/video-comment.model.ts
index 3ed3ddcc7..719d1f04e 100644
--- a/client/src/app/videos/+video-watch/comment/video-comment.model.ts
+++ b/client/src/app/videos/+video-watch/comment/video-comment.model.ts
@@ -12,6 +12,8 @@ export class VideoComment implements VideoCommentServerModel {
12 videoId: number 12 videoId: number
13 createdAt: Date | string 13 createdAt: Date | string
14 updatedAt: Date | string 14 updatedAt: Date | string
15 deletedAt: Date | string
16 isDeleted: boolean
15 account: AccountInterface 17 account: AccountInterface
16 totalReplies: number 18 totalReplies: number
17 by: string 19 by: string
@@ -28,14 +30,18 @@ export class VideoComment implements VideoCommentServerModel {
28 this.videoId = hash.videoId 30 this.videoId = hash.videoId
29 this.createdAt = new Date(hash.createdAt.toString()) 31 this.createdAt = new Date(hash.createdAt.toString())
30 this.updatedAt = new Date(hash.updatedAt.toString()) 32 this.updatedAt = new Date(hash.updatedAt.toString())
33 this.deletedAt = hash.deletedAt ? new Date(hash.deletedAt.toString()) : null
34 this.isDeleted = hash.isDeleted
31 this.account = hash.account 35 this.account = hash.account
32 this.totalReplies = hash.totalReplies 36 this.totalReplies = hash.totalReplies
33 37
34 this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host) 38 if (this.account) {
35 this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account) 39 this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host)
40 this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account)
36 41
37 const absoluteAPIUrl = getAbsoluteAPIUrl() 42 const absoluteAPIUrl = getAbsoluteAPIUrl()
38 const thisHost = new URL(absoluteAPIUrl).host 43 const thisHost = new URL(absoluteAPIUrl).host
39 this.isLocal = this.account.host.trim() === thisHost 44 this.isLocal = this.account.host.trim() === thisHost
45 }
40 } 46 }
41} 47}
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.ts b/client/src/app/videos/+video-watch/comment/video-comments.component.ts
index 57b98afce..cc8b98b4e 100644
--- a/client/src/app/videos/+video-watch/comment/video-comments.component.ts
+++ b/client/src/app/videos/+video-watch/comment/video-comments.component.ts
@@ -153,10 +153,6 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
153 async onWantedToDelete (commentToDelete: VideoComment) { 153 async onWantedToDelete (commentToDelete: VideoComment) {
154 let message = 'Do you really want to delete this comment?' 154 let message = 'Do you really want to delete this comment?'
155 155
156 if (commentToDelete.totalReplies !== 0) {
157 message += this.i18n(' {{totalReplies}} replies will be deleted too.', { totalReplies: commentToDelete.totalReplies })
158 }
159
160 if (commentToDelete.isLocal) { 156 if (commentToDelete.isLocal) {
161 message += this.i18n(' The deletion will be sent to remote instances, so they remove the comment too.') 157 message += this.i18n(' The deletion will be sent to remote instances, so they remove the comment too.')
162 } else { 158 } else {
@@ -169,21 +165,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
169 this.videoCommentService.deleteVideoComment(commentToDelete.videoId, commentToDelete.id) 165 this.videoCommentService.deleteVideoComment(commentToDelete.videoId, commentToDelete.id)
170 .subscribe( 166 .subscribe(
171 () => { 167 () => {
172 // Delete the comment in the tree 168 // Mark the comment as deleted
173 if (commentToDelete.inReplyToCommentId) { 169 this.softDeleteComment(commentToDelete)
174 const thread = this.threadComments[commentToDelete.threadId]
175 if (!thread) {
176 console.error(`Cannot find thread ${commentToDelete.threadId} of the comment to delete ${commentToDelete.id}`)
177 return
178 }
179
180 this.deleteLocalCommentThread(thread, commentToDelete)
181 return
182 }
183
184 // Delete the thread
185 this.comments = this.comments.filter(c => c.id !== commentToDelete.id)
186 this.componentPagination.totalItems--
187 170
188 if (this.highlightedThread.id === commentToDelete.id) this.highlightedThread = undefined 171 if (this.highlightedThread.id === commentToDelete.id) this.highlightedThread = undefined
189 }, 172 },
@@ -204,15 +187,11 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
204 } 187 }
205 } 188 }
206 189
207 private deleteLocalCommentThread (parentComment: VideoCommentThreadTree, commentToDelete: VideoComment) { 190 private softDeleteComment (comment: VideoComment) {
208 for (const commentChild of parentComment.children) { 191 comment.isDeleted = true
209 if (commentChild.comment.id === commentToDelete.id) { 192 comment.deletedAt = new Date()
210 parentComment.children = parentComment.children.filter(c => c.comment.id !== commentToDelete.id) 193 comment.text = ''
211 return 194 comment.account = null
212 }
213
214 this.deleteLocalCommentThread(commentChild, commentToDelete)
215 }
216 } 195 }
217 196
218 private resetVideo () { 197 private resetVideo () {
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts
index 453ced8bf..5ed0435ff 100644
--- a/server/controllers/activitypub/client.ts
+++ b/server/controllers/activitypub/client.ts
@@ -308,13 +308,16 @@ async function videoCommentController (req: express.Request, res: express.Respon
308 308
309 const threadParentComments = await VideoCommentModel.listThreadParentComments(videoComment, undefined) 309 const threadParentComments = await VideoCommentModel.listThreadParentComments(videoComment, undefined)
310 const isPublic = true // Comments are always public 310 const isPublic = true // Comments are always public
311 const audience = getAudience(videoComment.Account.Actor, isPublic) 311 let videoCommentObject = videoComment.toActivityPubObject(threadParentComments)
312 312
313 const videoCommentObject = audiencify(videoComment.toActivityPubObject(threadParentComments), audience) 313 if (videoComment.Account) {
314 const audience = getAudience(videoComment.Account.Actor, isPublic)
315 videoCommentObject = audiencify(videoCommentObject, audience)
314 316
315 if (req.path.endsWith('/activity')) { 317 if (req.path.endsWith('/activity')) {
316 const data = buildCreateActivity(videoComment.url, videoComment.Account.Actor, videoCommentObject, audience) 318 const data = buildCreateActivity(videoComment.url, videoComment.Account.Actor, videoCommentObject, audience)
317 return activityPubResponse(activityPubContextify(data), res) 319 return activityPubResponse(activityPubContextify(data), res)
320 }
318 } 321 }
319 322
320 return activityPubResponse(activityPubContextify(videoCommentObject), res) 323 return activityPubResponse(activityPubContextify(videoCommentObject), res)
diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts
index b2b06b170..5f3fed5c0 100644
--- a/server/controllers/api/videos/comment.ts
+++ b/server/controllers/api/videos/comment.ts
@@ -1,10 +1,11 @@
1import * as express from 'express' 1import * as express from 'express'
2import { cloneDeep } from 'lodash'
2import { ResultList } from '../../../../shared/models' 3import { ResultList } from '../../../../shared/models'
3import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model' 4import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model'
4import { logger } from '../../../helpers/logger' 5import { logger } from '../../../helpers/logger'
5import { getFormattedObjects } from '../../../helpers/utils' 6import { getFormattedObjects } from '../../../helpers/utils'
6import { sequelizeTypescript } from '../../../initializers' 7import { sequelizeTypescript } from '../../../initializers'
7import { buildFormattedCommentTree, createVideoComment } from '../../../lib/video-comment' 8import { buildFormattedCommentTree, createVideoComment, markCommentAsDeleted } from '../../../lib/video-comment'
8import { 9import {
9 asyncMiddleware, 10 asyncMiddleware,
10 asyncRetryTransactionMiddleware, 11 asyncRetryTransactionMiddleware,
@@ -177,19 +178,22 @@ async function addVideoCommentReply (req: express.Request, res: express.Response
177 178
178async function removeVideoComment (req: express.Request, res: express.Response) { 179async function removeVideoComment (req: express.Request, res: express.Response) {
179 const videoCommentInstance = res.locals.videoCommentFull 180 const videoCommentInstance = res.locals.videoCommentFull
181 const videoCommentInstanceBefore = cloneDeep(videoCommentInstance)
180 182
181 await sequelizeTypescript.transaction(async t => { 183 await sequelizeTypescript.transaction(async t => {
182 await videoCommentInstance.destroy({ transaction: t })
183
184 if (videoCommentInstance.isOwned() || videoCommentInstance.Video.isOwned()) { 184 if (videoCommentInstance.isOwned() || videoCommentInstance.Video.isOwned()) {
185 await sendDeleteVideoComment(videoCommentInstance, t) 185 await sendDeleteVideoComment(videoCommentInstance, t)
186 } 186 }
187
188 markCommentAsDeleted(videoCommentInstance)
189
190 await videoCommentInstance.save()
187 }) 191 })
188 192
189 auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(videoCommentInstance.toFormattedJSON())) 193 auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(videoCommentInstance.toFormattedJSON()))
190 logger.info('Video comment %d deleted.', videoCommentInstance.id) 194 logger.info('Video comment %d deleted.', videoCommentInstance.id)
191 195
192 Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstance }) 196 Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore })
193 197
194 return res.type('json').status(204).end() 198 return res.type('json').status(204).end()
195} 199}
diff --git a/server/initializers/migrations/0450-soft-delete-video-comments.ts b/server/initializers/migrations/0450-soft-delete-video-comments.ts
new file mode 100644
index 000000000..bcfb97b56
--- /dev/null
+++ b/server/initializers/migrations/0450-soft-delete-video-comments.ts
@@ -0,0 +1,36 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction,
5 queryInterface: Sequelize.QueryInterface,
6 sequelize: Sequelize.Sequelize,
7 db: any
8}): Promise<void> {
9 {
10 const data = {
11 type: Sequelize.INTEGER,
12 allowNull: true,
13 defaultValue: null
14 }
15
16 await utils.queryInterface.changeColumn('videoComment', 'accountId', data)
17 }
18
19 {
20 const data = {
21 type: Sequelize.DATE,
22 allowNull: true,
23 defaultValue: null
24 }
25 await utils.queryInterface.addColumn('videoComment', 'deletedAt', data)
26 }
27}
28
29function down (options) {
30 throw new Error('Not implemented.')
31}
32
33export {
34 up,
35 down
36}
diff --git a/server/lib/activitypub/process/process-delete.ts b/server/lib/activitypub/process/process-delete.ts
index 79d0e0d79..e76132f91 100644
--- a/server/lib/activitypub/process/process-delete.ts
+++ b/server/lib/activitypub/process/process-delete.ts
@@ -5,6 +5,7 @@ import { sequelizeTypescript } from '../../../initializers'
5import { ActorModel } from '../../../models/activitypub/actor' 5import { ActorModel } from '../../../models/activitypub/actor'
6import { VideoModel } from '../../../models/video/video' 6import { VideoModel } from '../../../models/video/video'
7import { VideoCommentModel } from '../../../models/video/video-comment' 7import { VideoCommentModel } from '../../../models/video/video-comment'
8import { markCommentAsDeleted } from '../../video-comment'
8import { forwardVideoRelatedActivity } from '../send/utils' 9import { forwardVideoRelatedActivity } from '../send/utils'
9import { VideoPlaylistModel } from '../../../models/video/video-playlist' 10import { VideoPlaylistModel } from '../../../models/video/video-playlist'
10import { APProcessorOptions } from '../../../typings/activitypub-processor.model' 11import { APProcessorOptions } from '../../../typings/activitypub-processor.model'
@@ -128,7 +129,11 @@ function processDeleteVideoComment (byActor: MActorSignature, videoComment: Vide
128 throw new Error(`Account ${byActor.url} does not own video comment ${videoComment.url} or video ${videoComment.Video.url}`) 129 throw new Error(`Account ${byActor.url} does not own video comment ${videoComment.url} or video ${videoComment.Video.url}`)
129 } 130 }
130 131
131 await videoComment.destroy({ transaction: t }) 132 await sequelizeTypescript.transaction(async t => {
133 markCommentAsDeleted(videoComment)
134
135 await videoComment.save()
136 })
132 137
133 if (videoComment.Video.isOwned()) { 138 if (videoComment.Video.isOwned()) {
134 // Don't resend the activity to the sender 139 // Don't resend the activity to the sender
diff --git a/server/lib/video-comment.ts b/server/lib/video-comment.ts
index bb811bd2c..b8074e6d2 100644
--- a/server/lib/video-comment.ts
+++ b/server/lib/video-comment.ts
@@ -73,9 +73,16 @@ function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>):
73 return thread 73 return thread
74} 74}
75 75
76function markCommentAsDeleted (comment: MCommentOwnerVideoReply): void {
77 comment.text = ''
78 comment.deletedAt = new Date()
79 comment.accountId = null
80}
81
76// --------------------------------------------------------------------------- 82// ---------------------------------------------------------------------------
77 83
78export { 84export {
79 createVideoComment, 85 createVideoComment,
80 buildFormattedCommentTree 86 buildFormattedCommentTree,
87 markCommentAsDeleted
81} 88}
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index ba1094536..a818a5a4d 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -201,7 +201,7 @@ export class AccountModel extends Model<AccountModel> {
201 201
202 @HasMany(() => VideoCommentModel, { 202 @HasMany(() => VideoCommentModel, {
203 foreignKey: { 203 foreignKey: {
204 allowNull: false 204 allowNull: true
205 }, 205 },
206 onDelete: 'cascade', 206 onDelete: 'cascade',
207 hooks: true 207 hooks: true
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index 2e4220434..b44d65138 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -1,5 +1,5 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
2import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects' 2import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
3import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' 3import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
4import { VideoComment } from '../../../shared/models/videos/video-comment.model' 4import { VideoComment } from '../../../shared/models/videos/video-comment.model'
5import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 5import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
@@ -122,6 +122,10 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
122 @UpdatedAt 122 @UpdatedAt
123 updatedAt: Date 123 updatedAt: Date
124 124
125 @AllowNull(true)
126 @Column(DataType.DATE)
127 deletedAt: Date
128
125 @AllowNull(false) 129 @AllowNull(false)
126 @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url')) 130 @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
127 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max)) 131 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
@@ -177,7 +181,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
177 181
178 @BelongsTo(() => AccountModel, { 182 @BelongsTo(() => AccountModel, {
179 foreignKey: { 183 foreignKey: {
180 allowNull: false 184 allowNull: true
181 }, 185 },
182 onDelete: 'CASCADE' 186 onDelete: 'CASCADE'
183 }) 187 })
@@ -436,9 +440,17 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
436 } 440 }
437 441
438 isOwned () { 442 isOwned () {
443 if (!this.Account) {
444 return false
445 }
446
439 return this.Account.isOwned() 447 return this.Account.isOwned()
440 } 448 }
441 449
450 isDeleted () {
451 return null !== this.deletedAt
452 }
453
442 extractMentions () { 454 extractMentions () {
443 let result: string[] = [] 455 let result: string[] = []
444 456
@@ -487,12 +499,25 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
487 videoId: this.videoId, 499 videoId: this.videoId,
488 createdAt: this.createdAt, 500 createdAt: this.createdAt,
489 updatedAt: this.updatedAt, 501 updatedAt: this.updatedAt,
502 deletedAt: this.deletedAt,
503 isDeleted: this.isDeleted(),
490 totalReplies: this.get('totalReplies') || 0, 504 totalReplies: this.get('totalReplies') || 0,
491 account: this.Account.toFormattedJSON() 505 account: this.Account ? this.Account.toFormattedJSON() : null
492 } as VideoComment 506 } as VideoComment
493 } 507 }
494 508
495 toActivityPubObject (this: MCommentAP, threadParentComments: MCommentOwner[]): VideoCommentObject { 509 toActivityPubObject (this: MCommentAP, threadParentComments: MCommentOwner[]): VideoCommentObject | ActivityTombstoneObject {
510 if (this.isDeleted()) {
511 return {
512 id: this.url,
513 type: 'Tombstone',
514 formerType: 'Note',
515 published: this.createdAt.toISOString(),
516 updated: this.updatedAt.toISOString(),
517 deleted: this.deletedAt.toISOString()
518 }
519 }
520
496 let inReplyTo: string 521 let inReplyTo: string
497 // New thread, so in AS we reply to the video 522 // New thread, so in AS we reply to the video
498 if (this.inReplyToCommentId === null) { 523 if (this.inReplyToCommentId === null) {
diff --git a/server/tests/api/videos/multiple-servers.ts b/server/tests/api/videos/multiple-servers.ts
index aeda188c2..e7b57ba1f 100644
--- a/server/tests/api/videos/multiple-servers.ts
+++ b/server/tests/api/videos/multiple-servers.ts
@@ -868,7 +868,7 @@ describe('Test multiple servers', function () {
868 await waitJobs(servers) 868 await waitJobs(servers)
869 }) 869 })
870 870
871 it('Should not have this comment anymore', async function () { 871 it('Should have this comment marked as deleted', async function () {
872 for (const server of servers) { 872 for (const server of servers) {
873 const res1 = await getVideoCommentThreads(server.url, videoUUID, 0, 5) 873 const res1 = await getVideoCommentThreads(server.url, videoUUID, 0, 5)
874 const threadId = res1.body.data.find(c => c.text === 'my super first comment').id 874 const threadId = res1.body.data.find(c => c.text === 'my super first comment').id
@@ -880,7 +880,13 @@ describe('Test multiple servers', function () {
880 880
881 const firstChild = tree.children[0] 881 const firstChild = tree.children[0]
882 expect(firstChild.comment.text).to.equal('my super answer to thread 1') 882 expect(firstChild.comment.text).to.equal('my super answer to thread 1')
883 expect(firstChild.children).to.have.lengthOf(0) 883 expect(firstChild.children).to.have.lengthOf(1)
884
885 const deletedComment = firstChild.children[0].comment
886 expect(deletedComment.isDeleted).to.be.true
887 expect(deletedComment.deletedAt).to.not.be.null
888 expect(deletedComment.account).to.be.null
889 expect(deletedComment.text).to.equal('')
884 890
885 const secondChild = tree.children[1] 891 const secondChild = tree.children[1]
886 expect(secondChild.comment.text).to.equal('my second answer to thread 1') 892 expect(secondChild.comment.text).to.equal('my second answer to thread 1')
@@ -897,13 +903,13 @@ describe('Test multiple servers', function () {
897 await waitJobs(servers) 903 await waitJobs(servers)
898 }) 904 })
899 905
900 it('Should have the threads deleted on other servers too', async function () { 906 it('Should have the threads marked as deleted on other servers too', async function () {
901 for (const server of servers) { 907 for (const server of servers) {
902 const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5) 908 const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5)
903 909
904 expect(res.body.total).to.equal(1) 910 expect(res.body.total).to.equal(2)
905 expect(res.body.data).to.be.an('array') 911 expect(res.body.data).to.be.an('array')
906 expect(res.body.data).to.have.lengthOf(1) 912 expect(res.body.data).to.have.lengthOf(2)
907 913
908 { 914 {
909 const comment: VideoComment = res.body.data[0] 915 const comment: VideoComment = res.body.data[0]
@@ -915,6 +921,20 @@ describe('Test multiple servers', function () {
915 expect(dateIsValid(comment.createdAt as string)).to.be.true 921 expect(dateIsValid(comment.createdAt as string)).to.be.true
916 expect(dateIsValid(comment.updatedAt as string)).to.be.true 922 expect(dateIsValid(comment.updatedAt as string)).to.be.true
917 } 923 }
924
925 {
926 const deletedComment: VideoComment = res.body.data[1]
927 expect(deletedComment).to.not.be.undefined
928 expect(deletedComment.isDeleted).to.be.true
929 expect(deletedComment.deletedAt).to.not.be.null
930 expect(deletedComment.text).to.equal('')
931 expect(deletedComment.inReplyToCommentId).to.be.null
932 expect(deletedComment.account).to.be.null
933 expect(deletedComment.totalReplies).to.equal(3)
934 expect(dateIsValid(deletedComment.createdAt as string)).to.be.true
935 expect(dateIsValid(deletedComment.updatedAt as string)).to.be.true
936 expect(dateIsValid(deletedComment.deletedAt as string)).to.be.true
937 }
918 } 938 }
919 }) 939 })
920 940
@@ -926,12 +946,32 @@ describe('Test multiple servers', function () {
926 await waitJobs(servers) 946 await waitJobs(servers)
927 }) 947 })
928 948
929 it('Should have the threads deleted on other servers too', async function () { 949 it('Should have the threads marked as deleted on other servers too', async function () {
930 for (const server of servers) { 950 for (const server of servers) {
931 const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5) 951 const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5)
932 952
933 expect(res.body.total).to.equal(0) 953 expect(res.body.total).to.equal(2)
934 expect(res.body.data).to.have.lengthOf(0) 954 expect(res.body.data).to.have.lengthOf(2)
955
956 {
957 const comment: VideoComment = res.body.data[0]
958 expect(comment.text).to.equal('')
959 expect(comment.isDeleted).to.be.true
960 expect(comment.createdAt).to.not.be.null
961 expect(comment.deletedAt).to.not.be.null
962 expect(comment.account).to.be.null
963 expect(comment.totalReplies).to.equal(0)
964 }
965
966 {
967 const comment: VideoComment = res.body.data[1]
968 expect(comment.text).to.equal('')
969 expect(comment.isDeleted).to.be.true
970 expect(comment.createdAt).to.not.be.null
971 expect(comment.deletedAt).to.not.be.null
972 expect(comment.account).to.be.null
973 expect(comment.totalReplies).to.equal(3)
974 }
935 } 975 }
936 }) 976 })
937 977
diff --git a/server/tests/api/videos/video-comments.ts b/server/tests/api/videos/video-comments.ts
index 82182cc7c..95be14c0e 100644
--- a/server/tests/api/videos/video-comments.ts
+++ b/server/tests/api/videos/video-comments.ts
@@ -172,7 +172,7 @@ describe('Test video comments', function () {
172 172
173 const tree: VideoCommentThreadTree = res.body 173 const tree: VideoCommentThreadTree = res.body
174 expect(tree.comment.text).equal('my super first comment') 174 expect(tree.comment.text).equal('my super first comment')
175 expect(tree.children).to.have.lengthOf(1) 175 expect(tree.children).to.have.lengthOf(2)
176 176
177 const firstChild = tree.children[0] 177 const firstChild = tree.children[0]
178 expect(firstChild.comment.text).to.equal('my super answer to thread 1') 178 expect(firstChild.comment.text).to.equal('my super answer to thread 1')
@@ -181,20 +181,32 @@ describe('Test video comments', function () {
181 const childOfFirstChild = firstChild.children[0] 181 const childOfFirstChild = firstChild.children[0]
182 expect(childOfFirstChild.comment.text).to.equal('my super answer to answer of thread 1') 182 expect(childOfFirstChild.comment.text).to.equal('my super answer to answer of thread 1')
183 expect(childOfFirstChild.children).to.have.lengthOf(0) 183 expect(childOfFirstChild.children).to.have.lengthOf(0)
184
185 const deletedChildOfFirstChild = tree.children[1]
186 expect(deletedChildOfFirstChild.comment.text).to.equal('')
187 expect(deletedChildOfFirstChild.comment.isDeleted).to.be.true
188 expect(deletedChildOfFirstChild.comment.deletedAt).to.not.be.null
189 expect(deletedChildOfFirstChild.comment.account).to.be.null
190 expect(deletedChildOfFirstChild.children).to.have.lengthOf(0)
184 }) 191 })
185 192
186 it('Should delete a complete thread', async function () { 193 it('Should delete a complete thread', async function () {
187 await deleteVideoComment(server.url, server.accessToken, videoId, threadId) 194 await deleteVideoComment(server.url, server.accessToken, videoId, threadId)
188 195
189 const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5, 'createdAt') 196 const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5, 'createdAt')
190 expect(res.body.total).to.equal(2) 197 expect(res.body.total).to.equal(3)
191 expect(res.body.data).to.be.an('array') 198 expect(res.body.data).to.be.an('array')
192 expect(res.body.data).to.have.lengthOf(2) 199 expect(res.body.data).to.have.lengthOf(3)
193 200
194 expect(res.body.data[0].text).to.equal('super thread 2') 201 expect(res.body.data[0].text).to.equal('')
195 expect(res.body.data[0].totalReplies).to.equal(0) 202 expect(res.body.data[0].isDeleted).to.be.true
196 expect(res.body.data[1].text).to.equal('super thread 3') 203 expect(res.body.data[0].deletedAt).to.not.be.null
204 expect(res.body.data[0].account).to.be.null
205 expect(res.body.data[0].totalReplies).to.equal(3)
206 expect(res.body.data[1].text).to.equal('super thread 2')
197 expect(res.body.data[1].totalReplies).to.equal(0) 207 expect(res.body.data[1].totalReplies).to.equal(0)
208 expect(res.body.data[2].text).to.equal('super thread 3')
209 expect(res.body.data[2].totalReplies).to.equal(0)
198 }) 210 })
199 211
200 after(async function () { 212 after(async function () {
diff --git a/shared/models/activitypub/objects/common-objects.ts b/shared/models/activitypub/objects/common-objects.ts
index 2a6529fed..df287d570 100644
--- a/shared/models/activitypub/objects/common-objects.ts
+++ b/shared/models/activitypub/objects/common-objects.ts
@@ -89,3 +89,14 @@ export interface ActivityPubAttributedTo {
89 type: 'Group' | 'Person' 89 type: 'Group' | 'Person'
90 id: string 90 id: string
91} 91}
92
93export interface ActivityTombstoneObject {
94 '@context'?: any
95 id: string
96 type: 'Tombstone'
97 name?: string
98 formerType?: string
99 published: string
100 updated: string
101 deleted: string
102}
diff --git a/shared/models/videos/video-comment.model.ts b/shared/models/videos/video-comment.model.ts
index 7ac4024fb..044962633 100644
--- a/shared/models/videos/video-comment.model.ts
+++ b/shared/models/videos/video-comment.model.ts
@@ -9,6 +9,8 @@ export interface VideoComment {
9 videoId: number 9 videoId: number
10 createdAt: Date | string 10 createdAt: Date | string
11 updatedAt: Date | string 11 updatedAt: Date | string
12 deletedAt: Date | string
13 isDeleted: boolean
12 totalReplies: number 14 totalReplies: number
13 account: Account 15 account: Account
14} 16}