diff options
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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { cloneDeep } from 'lodash' | ||
2 | import { ResultList } from '../../../../shared/models' | 3 | import { ResultList } from '../../../../shared/models' |
3 | import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model' | 4 | import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model' |
4 | import { logger } from '../../../helpers/logger' | 5 | import { logger } from '../../../helpers/logger' |
5 | import { getFormattedObjects } from '../../../helpers/utils' | 6 | import { getFormattedObjects } from '../../../helpers/utils' |
6 | import { sequelizeTypescript } from '../../../initializers' | 7 | import { sequelizeTypescript } from '../../../initializers' |
7 | import { buildFormattedCommentTree, createVideoComment } from '../../../lib/video-comment' | 8 | import { buildFormattedCommentTree, createVideoComment, markCommentAsDeleted } from '../../../lib/video-comment' |
8 | import { | 9 | import { |
9 | asyncMiddleware, | 10 | asyncMiddleware, |
10 | asyncRetryTransactionMiddleware, | 11 | asyncRetryTransactionMiddleware, |
@@ -177,19 +178,22 @@ async function addVideoCommentReply (req: express.Request, res: express.Response | |||
177 | 178 | ||
178 | async function removeVideoComment (req: express.Request, res: express.Response) { | 179 | async 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 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | |||
3 | async 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 | |||
29 | function down (options) { | ||
30 | throw new Error('Not implemented.') | ||
31 | } | ||
32 | |||
33 | export { | ||
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' | |||
5 | import { ActorModel } from '../../../models/activitypub/actor' | 5 | import { ActorModel } from '../../../models/activitypub/actor' |
6 | import { VideoModel } from '../../../models/video/video' | 6 | import { VideoModel } from '../../../models/video/video' |
7 | import { VideoCommentModel } from '../../../models/video/video-comment' | 7 | import { VideoCommentModel } from '../../../models/video/video-comment' |
8 | import { markCommentAsDeleted } from '../../video-comment' | ||
8 | import { forwardVideoRelatedActivity } from '../send/utils' | 9 | import { forwardVideoRelatedActivity } from '../send/utils' |
9 | import { VideoPlaylistModel } from '../../../models/video/video-playlist' | 10 | import { VideoPlaylistModel } from '../../../models/video/video-playlist' |
10 | import { APProcessorOptions } from '../../../typings/activitypub-processor.model' | 11 | import { 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 | ||
76 | function markCommentAsDeleted (comment: MCommentOwnerVideoReply): void { | ||
77 | comment.text = '' | ||
78 | comment.deletedAt = new Date() | ||
79 | comment.accountId = null | ||
80 | } | ||
81 | |||
76 | // --------------------------------------------------------------------------- | 82 | // --------------------------------------------------------------------------- |
77 | 83 | ||
78 | export { | 84 | export { |
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 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' | 1 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' |
2 | import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects' | 2 | import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' |
3 | import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' | 3 | import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' |
4 | import { VideoComment } from '../../../shared/models/videos/video-comment.model' | 4 | import { VideoComment } from '../../../shared/models/videos/video-comment.model' |
5 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | 5 | import { 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 | |||
93 | export 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 | } |