<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"
font-size: 15px;
display: flex;
- img {
+ .comment-avatar {
@include avatar(36px);
margin-top: 5px;
.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; }
}
}
}
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)
videoId: number
createdAt: Date | string
updatedAt: Date | string
+ deletedAt: Date | string
+ isDeleted: boolean
account: AccountInterface
totalReplies: number
by: string
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
+ }
}
}
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 {
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
},
}
}
- 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 () {
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)
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,
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()
}
--- /dev/null
+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
+}
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'
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
return thread
}
+function markCommentAsDeleted (comment: MCommentOwnerVideoReply): void {
+ comment.text = ''
+ comment.deletedAt = new Date()
+ comment.accountId = null
+}
+
// ---------------------------------------------------------------------------
export {
createVideoComment,
- buildFormattedCommentTree
+ buildFormattedCommentTree,
+ markCommentAsDeleted
}
@HasMany(() => VideoCommentModel, {
foreignKey: {
- allowNull: false
+ allowNull: true
},
onDelete: 'cascade',
hooks: true
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'
@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))
@BelongsTo(() => AccountModel, {
foreignKey: {
- allowNull: false
+ allowNull: true
},
onDelete: 'CASCADE'
})
}
isOwned () {
+ if (!this.Account) {
+ return false
+ }
+
return this.Account.isOwned()
}
+ isDeleted () {
+ return null !== this.deletedAt
+ }
+
extractMentions () {
let result: string[] = []
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) {
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
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')
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]
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
+ }
}
})
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)
+ }
}
})
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')
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 () {
type: 'Group' | 'Person'
id: string
}
+
+export interface ActivityTombstoneObject {
+ '@context'?: any
+ id: string
+ type: 'Tombstone'
+ name?: string
+ formerType?: string
+ published: string
+ updated: string
+ deleted: string
+}
videoId: number
createdAt: Date | string
updatedAt: Date | string
+ deletedAt: Date | string
+ isDeleted: boolean
totalReplies: number
account: Account
}