Soft delete video comments instead of detroy
authorJulien Maulny <julien.maulny@protonmail.com>
Fri, 15 Nov 2019 18:05:08 +0000 (19:05 +0100)
committerChocobozzz <chocobozzz@cpy.re>
Wed, 4 Dec 2019 08:36:45 +0000 (09:36 +0100)
16 files changed:
client/src/app/videos/+video-watch/comment/video-comment.component.html
client/src/app/videos/+video-watch/comment/video-comment.component.scss
client/src/app/videos/+video-watch/comment/video-comment.component.ts
client/src/app/videos/+video-watch/comment/video-comment.model.ts
client/src/app/videos/+video-watch/comment/video-comments.component.ts
server/controllers/activitypub/client.ts
server/controllers/api/videos/comment.ts
server/initializers/migrations/0450-soft-delete-video-comments.ts [new file with mode: 0644]
server/lib/activitypub/process/process-delete.ts
server/lib/video-comment.ts
server/models/account/account.ts
server/models/video/video-comment.ts
server/tests/api/videos/multiple-servers.ts
server/tests/api/videos/video-comments.ts
shared/models/activitypub/objects/common-objects.ts
shared/models/videos/video-comment.model.ts

index 60b803206220489185679d5baf40006137ecb620..6ec35d63b89f931dc228f4e94a7a26c275a78573 100644 (file)
@@ -1,22 +1,45 @@
 <div class="root-comment">
-  <img [src]="comment.accountAvatarUrl" alt="Avatar" />
+  <img
+    *ngIf="!comment.isDeleted"
+    class="comment-avatar"
+    [src]="comment.accountAvatarUrl"
+    alt="Avatar"
+  />
+
+  <span
+    *ngIf="comment.isDeleted"
+    class="comment-avatar"
+  ></span>
 
   <div class="comment">
-    <div *ngIf="highlightedComment === true" class="highlighted-comment" i18n>Highlighted comment</div>
+    <ng-container *ngIf="!comment.isDeleted">
+      <div *ngIf="highlightedComment === true" class="highlighted-comment" i18n>Highlighted comment</div>
 
-    <div class="comment-account-date">
-      <a [href]="comment.account.url"  target="_blank" rel="noopener noreferrer" class="comment-account">{{ comment.by }}</a>
-      <a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]" class="comment-date">{{ comment.createdAt | myFromNow }}</a>
-    </div>
-    <div class="comment-html" [innerHTML]="sanitizedCommentHTML"></div>
+      <div class="comment-account-date">
+        <a [href]="comment.account.url"  target="_blank" rel="noopener noreferrer" class="comment-account">{{ comment.by }}</a>
+        <a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]" class="comment-date">{{ comment.createdAt | myFromNow }}</a>
+      </div>
+      <div class="comment-html" [innerHTML]="sanitizedCommentHTML"></div>
 
-    <div class="comment-actions">
-      <div *ngIf="isUserLoggedIn()" (click)="onWantToReply()" class="comment-action-reply" i18n>Reply</div>
-      <div *ngIf="isRemovableByUser()" (click)="onWantToDelete()" class="comment-action-delete" i18n>Delete</div>
-    </div>
+      <div class="comment-actions">
+        <div *ngIf="isUserLoggedIn()" (click)="onWantToReply()" class="comment-action-reply" i18n>Reply</div>
+        <div *ngIf="isRemovableByUser()" (click)="onWantToDelete()" class="comment-action-delete" i18n>Delete</div>
+      </div>
+    </ng-container>
+
+    <ng-container *ngIf="comment.isDeleted">
+      <div class="comment-account-date">
+        <span class="comment-account" i18n>Deleted</span>
+        <a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]" class="comment-date">{{ comment.createdAt | myFromNow }}</a>
+      </div>
+
+      <div *ngIf="comment.isDeleted" class="comment-html comment-html-deleted">
+        <i i18n>This comment has been deleted</i>
+      </div>
+    </ng-container>
 
     <my-video-comment-add
-      *ngIf="isUserLoggedIn() && inReplyToCommentId === comment.id"
+      *ngIf="!comment.isDeleted && isUserLoggedIn() && inReplyToCommentId === comment.id"
       [user]="user"
       [video]="video"
       [parentComment]="comment"
index c3ab1ab0105fcec9fb8463ca3b26e71ea529c036..ac633fd64a20ae0797ea06ab747f6fd03714cd5e 100644 (file)
@@ -5,7 +5,7 @@
   font-size: 15px;
   display: flex;
 
-  img {
+  .comment-avatar {
     @include avatar(36px);
 
     margin-top: 5px;
@@ -48,6 +48,7 @@
 
     .comment-html {
       @include peertube-word-wrap;
+      margin-bottom: 10px;
 
       // Mentions
       ::ng-deep a {
         }
 
       }
+
+      &.comment-html-deleted {
+        color: $grey-foreground-color;
+      }
     }
 
     .comment-actions {
-      margin: 10px 0;
+      margin-bottom: 10px;
       display: flex;
 
       .comment-action-reply,
   }
 
   .root-comment {
-    img { margin-right: 10px; }
+    .comment-avatar { margin-right: 10px; }
   }
 }
 
index 172eb0a39acbce2a5abe0d1352a729bd423a6abe..4d3c049a1913f99f5d692f5867fec45464160e92 100644 (file)
@@ -78,7 +78,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
   }
 
   isRemovableByUser () {
-    return this.isUserLoggedIn() &&
+    return this.comment.account && this.isUserLoggedIn() &&
       (
         this.user.account.id === this.comment.account.id ||
         this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)
index 3ed3ddcc78b72e934200273e0ac6dfcdf4aacf02..719d1f04e1cf6aefc0c36d2397a258f267c3237a 100644 (file)
@@ -12,6 +12,8 @@ export class VideoComment implements VideoCommentServerModel {
   videoId: number
   createdAt: Date | string
   updatedAt: Date | string
+  deletedAt: Date | string
+  isDeleted: boolean
   account: AccountInterface
   totalReplies: number
   by: string
@@ -28,14 +30,18 @@ export class VideoComment implements VideoCommentServerModel {
     this.videoId = hash.videoId
     this.createdAt = new Date(hash.createdAt.toString())
     this.updatedAt = new Date(hash.updatedAt.toString())
+    this.deletedAt = hash.deletedAt ? new Date(hash.deletedAt.toString()) : null
+    this.isDeleted = hash.isDeleted
     this.account = hash.account
     this.totalReplies = hash.totalReplies
 
-    this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host)
-    this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account)
+    if (this.account) {
+      this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host)
+      this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account)
 
-    const absoluteAPIUrl = getAbsoluteAPIUrl()
-    const thisHost = new URL(absoluteAPIUrl).host
-    this.isLocal = this.account.host.trim() === thisHost
+      const absoluteAPIUrl = getAbsoluteAPIUrl()
+      const thisHost = new URL(absoluteAPIUrl).host
+      this.isLocal = this.account.host.trim() === thisHost
+    }
   }
 }
index 57b98afceb99c0ae9e542c92d0c4eeb99afda6b6..cc8b98b4e93b9df0d69935a10cfae791efc08e0b 100644 (file)
@@ -153,10 +153,6 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
   async onWantedToDelete (commentToDelete: VideoComment) {
     let message = 'Do you really want to delete this comment?'
 
-    if (commentToDelete.totalReplies !== 0) {
-      message += this.i18n(' {{totalReplies}} replies will be deleted too.', { totalReplies: commentToDelete.totalReplies })
-    }
-
     if (commentToDelete.isLocal) {
       message += this.i18n(' The deletion will be sent to remote instances, so they remove the comment too.')
     } else {
@@ -169,21 +165,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
     this.videoCommentService.deleteVideoComment(commentToDelete.videoId, commentToDelete.id)
       .subscribe(
         () => {
-          // Delete the comment in the tree
-          if (commentToDelete.inReplyToCommentId) {
-            const thread = this.threadComments[commentToDelete.threadId]
-            if (!thread) {
-              console.error(`Cannot find thread ${commentToDelete.threadId} of the comment to delete ${commentToDelete.id}`)
-              return
-            }
-
-            this.deleteLocalCommentThread(thread, commentToDelete)
-            return
-          }
-
-          // Delete the thread
-          this.comments = this.comments.filter(c => c.id !== commentToDelete.id)
-          this.componentPagination.totalItems--
+          // Mark the comment as deleted
+          this.softDeleteComment(commentToDelete)
 
           if (this.highlightedThread.id === commentToDelete.id) this.highlightedThread = undefined
         },
@@ -204,15 +187,11 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
     }
   }
 
-  private deleteLocalCommentThread (parentComment: VideoCommentThreadTree, commentToDelete: VideoComment) {
-    for (const commentChild of parentComment.children) {
-      if (commentChild.comment.id === commentToDelete.id) {
-        parentComment.children = parentComment.children.filter(c => c.comment.id !== commentToDelete.id)
-        return
-      }
-
-      this.deleteLocalCommentThread(commentChild, commentToDelete)
-    }
+  private softDeleteComment (comment: VideoComment) {
+    comment.isDeleted = true
+    comment.deletedAt = new Date()
+    comment.text = ''
+    comment.account = null
   }
 
   private resetVideo () {
index 453ced8bf627c7163b02db9e4a1339fca3202a48..5ed0435ffa89698476d38939ad81c4494b5c4256 100644 (file)
@@ -308,13 +308,16 @@ async function videoCommentController (req: express.Request, res: express.Respon
 
   const threadParentComments = await VideoCommentModel.listThreadParentComments(videoComment, undefined)
   const isPublic = true // Comments are always public
-  const audience = getAudience(videoComment.Account.Actor, isPublic)
+  let videoCommentObject = videoComment.toActivityPubObject(threadParentComments)
 
-  const videoCommentObject = audiencify(videoComment.toActivityPubObject(threadParentComments), audience)
+  if (videoComment.Account) {
+    const audience = getAudience(videoComment.Account.Actor, isPublic)
+    videoCommentObject = audiencify(videoCommentObject, audience)
 
-  if (req.path.endsWith('/activity')) {
-    const data = buildCreateActivity(videoComment.url, videoComment.Account.Actor, videoCommentObject, audience)
-    return activityPubResponse(activityPubContextify(data), res)
+    if (req.path.endsWith('/activity')) {
+      const data = buildCreateActivity(videoComment.url, videoComment.Account.Actor, videoCommentObject, audience)
+      return activityPubResponse(activityPubContextify(data), res)
+    }
   }
 
   return activityPubResponse(activityPubContextify(videoCommentObject), res)
index b2b06b170ead39981e2fe35c3d01f56afe52b795..5f3fed5c098f246a5984667e9ca8414e06cec025 100644 (file)
@@ -1,10 +1,11 @@
 import * as express from 'express'
+import { cloneDeep } from 'lodash'
 import { ResultList } from '../../../../shared/models'
 import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model'
 import { logger } from '../../../helpers/logger'
 import { getFormattedObjects } from '../../../helpers/utils'
 import { sequelizeTypescript } from '../../../initializers'
-import { buildFormattedCommentTree, createVideoComment } from '../../../lib/video-comment'
+import { buildFormattedCommentTree, createVideoComment, markCommentAsDeleted } from '../../../lib/video-comment'
 import {
   asyncMiddleware,
   asyncRetryTransactionMiddleware,
@@ -177,19 +178,22 @@ async function addVideoCommentReply (req: express.Request, res: express.Response
 
 async function removeVideoComment (req: express.Request, res: express.Response) {
   const videoCommentInstance = res.locals.videoCommentFull
+  const videoCommentInstanceBefore = cloneDeep(videoCommentInstance)
 
   await sequelizeTypescript.transaction(async t => {
-    await videoCommentInstance.destroy({ transaction: t })
-
     if (videoCommentInstance.isOwned() || videoCommentInstance.Video.isOwned()) {
       await sendDeleteVideoComment(videoCommentInstance, t)
     }
+
+    markCommentAsDeleted(videoCommentInstance)
+
+    await videoCommentInstance.save()
   })
 
   auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(videoCommentInstance.toFormattedJSON()))
   logger.info('Video comment %d deleted.', videoCommentInstance.id)
 
-  Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstance })
+  Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore })
 
   return res.type('json').status(204).end()
 }
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 (file)
index 0000000..bcfb97b
--- /dev/null
@@ -0,0 +1,36 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction,
+  queryInterface: Sequelize.QueryInterface,
+  sequelize: Sequelize.Sequelize,
+  db: any
+}): Promise<void> {
+  {
+    const data = {
+      type: Sequelize.INTEGER,
+      allowNull: true,
+      defaultValue: null
+    }
+
+    await utils.queryInterface.changeColumn('videoComment', 'accountId', data)
+  }
+
+  {
+    const data = {
+      type: Sequelize.DATE,
+      allowNull: true,
+      defaultValue: null
+    }
+    await utils.queryInterface.addColumn('videoComment', 'deletedAt', data)
+  }
+}
+
+function down (options) {
+  throw new Error('Not implemented.')
+}
+
+export {
+  up,
+  down
+}
index 79d0e0d79424901d8226f7a3b9ba8ab664b92cc2..e76132f915d9bbbb28685f7a058de3cd36a3dfaa 100644 (file)
@@ -5,6 +5,7 @@ import { sequelizeTypescript } from '../../../initializers'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { VideoModel } from '../../../models/video/video'
 import { VideoCommentModel } from '../../../models/video/video-comment'
+import { markCommentAsDeleted } from '../../video-comment'
 import { forwardVideoRelatedActivity } from '../send/utils'
 import { VideoPlaylistModel } from '../../../models/video/video-playlist'
 import { APProcessorOptions } from '../../../typings/activitypub-processor.model'
@@ -128,7 +129,11 @@ function processDeleteVideoComment (byActor: MActorSignature, videoComment: Vide
       throw new Error(`Account ${byActor.url} does not own video comment ${videoComment.url} or video ${videoComment.Video.url}`)
     }
 
-    await videoComment.destroy({ transaction: t })
+    await sequelizeTypescript.transaction(async t => {
+      markCommentAsDeleted(videoComment)
+
+      await videoComment.save()
+    })
 
     if (videoComment.Video.isOwned()) {
       // Don't resend the activity to the sender
index bb811bd2c3d4b5749eb075b56cf3e428d15d80fe..b8074e6d20b8ff7d166a5b63981b390e89461c6e 100644 (file)
@@ -73,9 +73,16 @@ function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>):
   return thread
 }
 
+function markCommentAsDeleted (comment: MCommentOwnerVideoReply): void {
+  comment.text = ''
+  comment.deletedAt = new Date()
+  comment.accountId = null
+}
+
 // ---------------------------------------------------------------------------
 
 export {
   createVideoComment,
-  buildFormattedCommentTree
+  buildFormattedCommentTree,
+  markCommentAsDeleted
 }
index ba1094536f9721e4e6c3e0a248ffc84bf848e016..a818a5a4dfd8b4d310184b456acdbe37e0e0f460 100644 (file)
@@ -201,7 +201,7 @@ export class AccountModel extends Model<AccountModel> {
 
   @HasMany(() => VideoCommentModel, {
     foreignKey: {
-      allowNull: false
+      allowNull: true
     },
     onDelete: 'cascade',
     hooks: true
index 2e4220434e3dee354d93c51977f87382c8dd6cdb..b44d65138f1bdfd476a3a9680692ba2d587055b3 100644 (file)
@@ -1,5 +1,5 @@
 import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
-import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
+import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
 import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
 import { VideoComment } from '../../../shared/models/videos/video-comment.model'
 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
@@ -122,6 +122,10 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
   @UpdatedAt
   updatedAt: Date
 
+  @AllowNull(true)
+  @Column(DataType.DATE)
+  deletedAt: Date
+
   @AllowNull(false)
   @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
@@ -177,7 +181,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
 
   @BelongsTo(() => AccountModel, {
     foreignKey: {
-      allowNull: false
+      allowNull: true
     },
     onDelete: 'CASCADE'
   })
@@ -436,9 +440,17 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
   }
 
   isOwned () {
+    if (!this.Account) {
+      return false
+    }
+
     return this.Account.isOwned()
   }
 
+  isDeleted () {
+    return null !== this.deletedAt
+  }
+
   extractMentions () {
     let result: string[] = []
 
@@ -487,12 +499,25 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
       videoId: this.videoId,
       createdAt: this.createdAt,
       updatedAt: this.updatedAt,
+      deletedAt: this.deletedAt,
+      isDeleted: this.isDeleted(),
       totalReplies: this.get('totalReplies') || 0,
-      account: this.Account.toFormattedJSON()
+      account: this.Account ? this.Account.toFormattedJSON() : null
     } as VideoComment
   }
 
-  toActivityPubObject (this: MCommentAP, threadParentComments: MCommentOwner[]): VideoCommentObject {
+  toActivityPubObject (this: MCommentAP, threadParentComments: MCommentOwner[]): VideoCommentObject | ActivityTombstoneObject {
+    if (this.isDeleted()) {
+      return {
+        id: this.url,
+        type: 'Tombstone',
+        formerType: 'Note',
+        published: this.createdAt.toISOString(),
+        updated: this.updatedAt.toISOString(),
+        deleted: this.deletedAt.toISOString()
+      }
+    }
+
     let inReplyTo: string
     // New thread, so in AS we reply to the video
     if (this.inReplyToCommentId === null) {
index aeda188c2d5847b5b25485ddc02aa52d19ae34b7..e7b57ba1f071be7e9468fc572f2b24fc515b1b62 100644 (file)
@@ -868,7 +868,7 @@ describe('Test multiple servers', function () {
       await waitJobs(servers)
     })
 
-    it('Should not have this comment anymore', async function () {
+    it('Should have this comment marked as deleted', async function () {
       for (const server of servers) {
         const res1 = await getVideoCommentThreads(server.url, videoUUID, 0, 5)
         const threadId = res1.body.data.find(c => c.text === 'my super first comment').id
@@ -880,7 +880,13 @@ describe('Test multiple servers', function () {
 
         const firstChild = tree.children[0]
         expect(firstChild.comment.text).to.equal('my super answer to thread 1')
-        expect(firstChild.children).to.have.lengthOf(0)
+        expect(firstChild.children).to.have.lengthOf(1)
+
+        const deletedComment = firstChild.children[0].comment
+        expect(deletedComment.isDeleted).to.be.true
+        expect(deletedComment.deletedAt).to.not.be.null
+        expect(deletedComment.account).to.be.null
+        expect(deletedComment.text).to.equal('')
 
         const secondChild = tree.children[1]
         expect(secondChild.comment.text).to.equal('my second answer to thread 1')
@@ -897,13 +903,13 @@ describe('Test multiple servers', function () {
       await waitJobs(servers)
     })
 
-    it('Should have the threads deleted on other servers too', async function () {
+    it('Should have the threads marked as deleted on other servers too', async function () {
       for (const server of servers) {
         const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5)
 
-        expect(res.body.total).to.equal(1)
+        expect(res.body.total).to.equal(2)
         expect(res.body.data).to.be.an('array')
-        expect(res.body.data).to.have.lengthOf(1)
+        expect(res.body.data).to.have.lengthOf(2)
 
         {
           const comment: VideoComment = res.body.data[0]
@@ -915,6 +921,20 @@ describe('Test multiple servers', function () {
           expect(dateIsValid(comment.createdAt as string)).to.be.true
           expect(dateIsValid(comment.updatedAt as string)).to.be.true
         }
+
+        {
+          const deletedComment: VideoComment = res.body.data[1]
+          expect(deletedComment).to.not.be.undefined
+          expect(deletedComment.isDeleted).to.be.true
+          expect(deletedComment.deletedAt).to.not.be.null
+          expect(deletedComment.text).to.equal('')
+          expect(deletedComment.inReplyToCommentId).to.be.null
+          expect(deletedComment.account).to.be.null
+          expect(deletedComment.totalReplies).to.equal(3)
+          expect(dateIsValid(deletedComment.createdAt as string)).to.be.true
+          expect(dateIsValid(deletedComment.updatedAt as string)).to.be.true
+          expect(dateIsValid(deletedComment.deletedAt as string)).to.be.true
+        }
       }
     })
 
@@ -926,12 +946,32 @@ describe('Test multiple servers', function () {
       await waitJobs(servers)
     })
 
-    it('Should have the threads deleted on other servers too', async function () {
+    it('Should have the threads marked as deleted on other servers too', async function () {
       for (const server of servers) {
         const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5)
 
-        expect(res.body.total).to.equal(0)
-        expect(res.body.data).to.have.lengthOf(0)
+        expect(res.body.total).to.equal(2)
+        expect(res.body.data).to.have.lengthOf(2)
+
+        {
+          const comment: VideoComment = res.body.data[0]
+          expect(comment.text).to.equal('')
+          expect(comment.isDeleted).to.be.true
+          expect(comment.createdAt).to.not.be.null
+          expect(comment.deletedAt).to.not.be.null
+          expect(comment.account).to.be.null
+          expect(comment.totalReplies).to.equal(0)
+        }
+
+        {
+          const comment: VideoComment = res.body.data[1]
+          expect(comment.text).to.equal('')
+          expect(comment.isDeleted).to.be.true
+          expect(comment.createdAt).to.not.be.null
+          expect(comment.deletedAt).to.not.be.null
+          expect(comment.account).to.be.null
+          expect(comment.totalReplies).to.equal(3)
+        }
       }
     })
 
index 82182cc7cb90da36c82f1bc4d29f6fd20ed4a093..95be14c0e11a0ff42cc44fad2a275294a1bd1bfd 100644 (file)
@@ -172,7 +172,7 @@ describe('Test video comments', function () {
 
     const tree: VideoCommentThreadTree = res.body
     expect(tree.comment.text).equal('my super first comment')
-    expect(tree.children).to.have.lengthOf(1)
+    expect(tree.children).to.have.lengthOf(2)
 
     const firstChild = tree.children[0]
     expect(firstChild.comment.text).to.equal('my super answer to thread 1')
@@ -181,20 +181,32 @@ describe('Test video comments', function () {
     const childOfFirstChild = firstChild.children[0]
     expect(childOfFirstChild.comment.text).to.equal('my super answer to answer of thread 1')
     expect(childOfFirstChild.children).to.have.lengthOf(0)
+
+    const deletedChildOfFirstChild = tree.children[1]
+    expect(deletedChildOfFirstChild.comment.text).to.equal('')
+    expect(deletedChildOfFirstChild.comment.isDeleted).to.be.true
+    expect(deletedChildOfFirstChild.comment.deletedAt).to.not.be.null
+    expect(deletedChildOfFirstChild.comment.account).to.be.null
+    expect(deletedChildOfFirstChild.children).to.have.lengthOf(0)
   })
 
   it('Should delete a complete thread', async function () {
     await deleteVideoComment(server.url, server.accessToken, videoId, threadId)
 
     const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5, 'createdAt')
-    expect(res.body.total).to.equal(2)
+    expect(res.body.total).to.equal(3)
     expect(res.body.data).to.be.an('array')
-    expect(res.body.data).to.have.lengthOf(2)
+    expect(res.body.data).to.have.lengthOf(3)
 
-    expect(res.body.data[0].text).to.equal('super thread 2')
-    expect(res.body.data[0].totalReplies).to.equal(0)
-    expect(res.body.data[1].text).to.equal('super thread 3')
+    expect(res.body.data[0].text).to.equal('')
+    expect(res.body.data[0].isDeleted).to.be.true
+    expect(res.body.data[0].deletedAt).to.not.be.null
+    expect(res.body.data[0].account).to.be.null
+    expect(res.body.data[0].totalReplies).to.equal(3)
+    expect(res.body.data[1].text).to.equal('super thread 2')
     expect(res.body.data[1].totalReplies).to.equal(0)
+    expect(res.body.data[2].text).to.equal('super thread 3')
+    expect(res.body.data[2].totalReplies).to.equal(0)
   })
 
   after(async function () {
index 2a6529fed175c27971c7839481083faca71fa690..df287d5709fb1b71856e452c22b21412d79ac16b 100644 (file)
@@ -89,3 +89,14 @@ export interface ActivityPubAttributedTo {
   type: 'Group' | 'Person'
   id: string
 }
+
+export interface ActivityTombstoneObject {
+  '@context'?: any
+  id: string
+  type: 'Tombstone'
+  name?: string
+  formerType?: string
+  published: string
+  updated: string
+  deleted: string
+}
index 7ac4024fbd7c69c7e95ee94e9f27aa6fa4099262..04496263324ae8f347b4752afa337a3b78ae86ba 100644 (file)
@@ -9,6 +9,8 @@ export interface VideoComment {
   videoId: number
   createdAt: Date | string
   updatedAt: Date | string
+  deletedAt: Date | string
+  isDeleted: boolean
   totalReplies: number
   account: Account
 }