Fix video comments display with deleted comments

pull/3759/head
Chocobozzz 2021-02-19 09:50:13 +01:00
parent fae6e4da8f
commit 9d6b9d10ef
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
11 changed files with 118 additions and 60 deletions

View File

@ -1,4 +1,4 @@
<div *ngIf="isNotDeletedOrDeletedWithReplies()" class="root-comment"> <div *ngIf="isCommentDisplayed()" class="root-comment">
<div class="left"> <div class="left">
<a *ngIf="!comment.isDeleted" [href]="comment.account.url" target="_blank" rel="noopener noreferrer"> <a *ngIf="!comment.isDeleted" [href]="comment.account.url" target="_blank" rel="noopener noreferrer">
<img <img

View File

@ -62,6 +62,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
if (!this.commentTree) { if (!this.commentTree) {
this.commentTree = { this.commentTree = {
comment: this.comment, comment: this.comment,
hasDisplayedChildren: false,
children: [] children: []
} }
@ -70,6 +71,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
this.commentTree.children.unshift({ this.commentTree.children.unshift({
comment: createdComment, comment: createdComment,
hasDisplayedChildren: false,
children: [] children: []
}) })
@ -133,8 +135,11 @@ export class VideoCommentComponent implements OnInit, OnChanges {
($event.target as HTMLImageElement).src = Account.GET_DEFAULT_AVATAR_URL() ($event.target as HTMLImageElement).src = Account.GET_DEFAULT_AVATAR_URL()
} }
isNotDeletedOrDeletedWithReplies () { isCommentDisplayed () {
return !this.comment.isDeleted || this.comment.isDeleted && this.comment.totalReplies !== 0 // Not deleted
return !this.comment.isDeleted ||
this.comment.totalReplies !== 0 || // Or root comment thread has replies
(this.commentTree?.hasDisplayedChildren) // Or this is a reply that have other replies
} }
private getUserIfNeeded (account: Account) { private getUserIfNeeded (account: Account) {

View File

@ -1,10 +1,10 @@
<div> <div>
<div class="title-block"> <div class="title-block">
<h2 class="title-page title-page-single"> <h2 class="title-page title-page-single">
<ng-container *ngIf="componentPagination.totalItems > 0; then hasComments; else noComments"></ng-container> <ng-container *ngIf="totalNotDeletedComments > 0; then hasComments; else noComments"></ng-container>
<ng-template #hasComments> <ng-template #hasComments>
<ng-container i18n *ngIf="componentPagination.totalItems === 1; else manyComments">1 Comment</ng-container> <ng-container i18n *ngIf="totalNotDeletedComments === 1; else manyComments">1 Comment</ng-container>
<ng-template i18n #manyComments>{{ componentPagination.totalItems }} Comments</ng-template> <ng-template i18n #manyComments>{{ totalNotDeletedComments }} Comments</ng-template>
</ng-template> </ng-template>
<ng-template i18n #noComments>Comments</ng-template> <ng-template i18n #noComments>Comments</ng-template>
</h2> </h2>
@ -30,7 +30,7 @@
[textValue]="commentThreadRedraftValue" [textValue]="commentThreadRedraftValue"
></my-video-comment-add> ></my-video-comment-add>
<div *ngIf="componentPagination.totalItems === 0 && comments.length === 0" i18n>No comments.</div> <div *ngIf="totalNotDeletedComments === 0 && comments.length === 0" i18n>No comments.</div>
<div <div
class="comment-threads" class="comment-threads"

View File

@ -21,15 +21,20 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
comments: VideoComment[] = [] comments: VideoComment[] = []
highlightedThread: VideoComment highlightedThread: VideoComment
sort = '-createdAt' sort = '-createdAt'
componentPagination: ComponentPagination = { componentPagination: ComponentPagination = {
currentPage: 1, currentPage: 1,
itemsPerPage: 10, itemsPerPage: 10,
totalItems: null totalItems: null
} }
totalNotDeletedComments: number
inReplyToCommentId: number inReplyToCommentId: number
commentReplyRedraftValue: string commentReplyRedraftValue: string
commentThreadRedraftValue: string commentThreadRedraftValue: string
threadComments: { [ id: number ]: VideoCommentThreadTree } = {} threadComments: { [ id: number ]: VideoCommentThreadTree } = {}
threadLoading: { [ id: number ]: boolean } = {} threadLoading: { [ id: number ]: boolean } = {}
@ -122,8 +127,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
obs.subscribe( obs.subscribe(
res => { res => {
this.comments = this.comments.concat(res.data) this.comments = this.comments.concat(res.data)
// Client does not display removed comments this.componentPagination.totalItems = res.total
this.componentPagination.totalItems = res.total - this.comments.filter(c => c.isDeleted).length this.totalNotDeletedComments = res.totalNotDeletedComments
this.onDataSubject.next(res.data) this.onDataSubject.next(res.data)
this.hooks.runAction('action:video-watch.video-threads.loaded', 'video-watch', { data: this.componentPagination }) this.hooks.runAction('action:video-watch.video-threads.loaded', 'video-watch', { data: this.componentPagination })
@ -241,6 +246,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
this.inReplyToCommentId = undefined this.inReplyToCommentId = undefined
this.componentPagination.currentPage = 1 this.componentPagination.currentPage = 1
this.componentPagination.totalItems = null this.componentPagination.totalItems = null
this.totalNotDeletedComments = null
this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video.uuid) this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video.uuid)
this.loadMoreThreads() this.loadMoreThreads()

View File

@ -3,5 +3,6 @@ import { VideoComment } from './video-comment.model'
export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel { export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel {
comment: VideoComment comment: VideoComment
hasDisplayedChildren: boolean
children: VideoCommentThreadTree[] children: VideoCommentThreadTree[]
} }

View File

@ -8,6 +8,7 @@ import { objectLineFeedToHtml } from '@app/helpers'
import { import {
FeedFormat, FeedFormat,
ResultList, ResultList,
ThreadsResultList,
VideoComment as VideoCommentServerModel, VideoComment as VideoCommentServerModel,
VideoCommentAdmin, VideoCommentAdmin,
VideoCommentCreate, VideoCommentCreate,
@ -76,7 +77,7 @@ export class VideoCommentService {
videoId: number | string, videoId: number | string,
componentPagination: ComponentPaginationLight, componentPagination: ComponentPaginationLight,
sort: string sort: string
}): Observable<ResultList<VideoComment>> { }): Observable<ThreadsResultList<VideoComment>> {
const { videoId, componentPagination, sort } = parameters const { videoId, componentPagination, sort } = parameters
const pagination = this.restService.componentPaginationToRestPagination(componentPagination) const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
@ -85,7 +86,7 @@ export class VideoCommentService {
params = this.restService.addRestGetParams(params, pagination, sort) params = this.restService.addRestGetParams(params, pagination, sort)
const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
return this.authHttp.get<ResultList<VideoComment>>(url, { params }) return this.authHttp.get<ThreadsResultList<VideoComment>>(url, { params })
.pipe( .pipe(
map(result => this.extractVideoComments(result)), map(result => this.extractVideoComments(result)),
catchError(err => this.restExtractor.handleError(err)) catchError(err => this.restExtractor.handleError(err))
@ -158,7 +159,7 @@ export class VideoCommentService {
return new VideoComment(videoComment) return new VideoComment(videoComment)
} }
private extractVideoComments (result: ResultList<VideoCommentServerModel>) { private extractVideoComments (result: ThreadsResultList<VideoCommentServerModel>) {
const videoCommentsJson = result.data const videoCommentsJson = result.data
const totalComments = result.total const totalComments = result.total
const comments: VideoComment[] = [] const comments: VideoComment[] = []
@ -167,16 +168,22 @@ export class VideoCommentService {
comments.push(new VideoComment(videoCommentJson)) comments.push(new VideoComment(videoCommentJson))
} }
return { data: comments, total: totalComments } return { data: comments, total: totalComments, totalNotDeletedComments: result.totalNotDeletedComments }
} }
private extractVideoCommentTree (tree: VideoCommentThreadTreeServerModel) { private extractVideoCommentTree (serverTree: VideoCommentThreadTreeServerModel): VideoCommentThreadTree {
if (!tree) return tree as VideoCommentThreadTree if (!serverTree) return null
tree.comment = new VideoComment(tree.comment) const tree = {
tree.children.forEach(c => this.extractVideoCommentTree(c)) comment: new VideoComment(serverTree.comment),
children: serverTree.children.map(c => this.extractVideoCommentTree(c))
}
return tree as VideoCommentThreadTree const hasDisplayedChildren = tree.children.length === 0
? !tree.comment.isDeleted
: tree.children.some(c => c.hasDisplayedChildren)
return Object.assign(tree, { hasDisplayedChildren })
} }
private buildParamsFromSearch (search: string, params: HttpParams) { private buildParamsFromSearch (search: string, params: HttpParams) {

View File

@ -1,5 +1,5 @@
import * as express from 'express' import * as express from 'express'
import { ResultList, UserRight } from '../../../../shared/models' import { ResultList, ThreadsResultList, UserRight } from '../../../../shared/models'
import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model' import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model'
import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'
import { getFormattedObjects } from '../../../helpers/utils' import { getFormattedObjects } from '../../../helpers/utils'
@ -30,6 +30,7 @@ import {
import { AccountModel } from '../../../models/account/account' import { AccountModel } from '../../../models/account/account'
import { VideoCommentModel } from '../../../models/video/video-comment' import { VideoCommentModel } from '../../../models/video/video-comment'
import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
import { logger } from '@server/helpers/logger'
const auditLogger = auditLoggerFactory('comments') const auditLogger = auditLoggerFactory('comments')
const videoCommentRouter = express.Router() const videoCommentRouter = express.Router()
@ -108,7 +109,7 @@ async function listVideoThreads (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo const video = res.locals.onlyVideo
const user = res.locals.oauth ? res.locals.oauth.token.User : undefined const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
let resultList: ResultList<VideoCommentModel> let resultList: ThreadsResultList<VideoCommentModel>
if (video.commentsEnabled === true) { if (video.commentsEnabled === true) {
const apiOptions = await Hooks.wrapObject({ const apiOptions = await Hooks.wrapObject({
@ -128,11 +129,15 @@ async function listVideoThreads (req: express.Request, res: express.Response) {
} else { } else {
resultList = { resultList = {
total: 0, total: 0,
totalNotDeletedComments: 0,
data: [] data: []
} }
} }
return res.json(getFormattedObjects(resultList.data, resultList.total)) return res.json({
...getFormattedObjects(resultList.data, resultList.total),
totalNotDeletedComments: resultList.totalNotDeletedComments
})
} }
async function listVideoThreadComments (req: express.Request, res: express.Response) { async function listVideoThreadComments (req: express.Request, res: express.Response) {
@ -161,6 +166,8 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
} }
} }
logger.info('coucou', { resultList })
if (resultList.data.length === 0) { if (resultList.data.length === 0) {
return res.sendStatus(HttpStatusCode.NOT_FOUND_404) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
} }

View File

@ -134,7 +134,7 @@ function buildBlockedAccountSQL (blockerIds: number[]) {
const blockerIdsString = blockerIds.join(', ') const blockerIdsString = blockerIds.join(', ')
return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' + return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
' UNION ALL ' + ' UNION ' +
'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' + 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' + 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')' 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'

View File

@ -414,7 +414,15 @@ export class VideoCommentModel extends Model {
const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned }) const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned })
const query = { const accountBlockedWhere = {
accountId: {
[Op.notIn]: Sequelize.literal(
'(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
)
}
}
const queryList = {
offset: start, offset: start,
limit: count, limit: count,
order: getCommentSort(sort), order: getCommentSort(sort),
@ -428,13 +436,7 @@ export class VideoCommentModel extends Model {
}, },
{ {
[Op.or]: [ [Op.or]: [
{ accountBlockedWhere,
accountId: {
[Op.notIn]: Sequelize.literal(
'(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
)
}
},
{ {
accountId: null accountId: null
} }
@ -444,19 +446,27 @@ export class VideoCommentModel extends Model {
} }
} }
const scopes: (string | ScopeOptions)[] = [ const scopesList: (string | ScopeOptions)[] = [
ScopeNames.WITH_ACCOUNT_FOR_API, ScopeNames.WITH_ACCOUNT_FOR_API,
{ {
method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ] method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
} }
] ]
return VideoCommentModel const queryCount = {
.scope(scopes) where: {
.findAndCountAll(query) videoId,
.then(({ rows, count }) => { deletedAt: null,
return { total: count, data: rows } ...accountBlockedWhere
}) }
}
return Promise.all([
VideoCommentModel.scope(scopesList).findAndCountAll(queryList),
VideoCommentModel.count(queryCount)
]).then(([ { rows, count }, totalNotDeletedComments ]) => {
return { total: count, data: rows, totalNotDeletedComments }
})
} }
static async listThreadCommentsForApi (parameters: { static async listThreadCommentsForApi (parameters: {
@ -477,11 +487,18 @@ export class VideoCommentModel extends Model {
{ id: threadId }, { id: threadId },
{ originCommentId: threadId } { originCommentId: threadId }
], ],
accountId: { [Op.or]: [
[Op.notIn]: Sequelize.literal( {
'(' + buildBlockedAccountSQL(blockerAccountIds) + ')' accountId: {
) [Op.notIn]: Sequelize.literal(
} '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
)
}
},
{
accountId: null
}
]
} }
} }
@ -492,8 +509,7 @@ export class VideoCommentModel extends Model {
} }
] ]
return VideoCommentModel return VideoCommentModel.scope(scopes)
.scope(scopes)
.findAndCountAll(query) .findAndCountAll(query)
.then(({ rows, count }) => { .then(({ rows, count }) => {
return { total: count, data: rows } return { total: count, data: rows }

View File

@ -67,6 +67,7 @@ describe('Test video comments', function () {
const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5) const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5)
expect(res.body.total).to.equal(0) expect(res.body.total).to.equal(0)
expect(res.body.totalNotDeletedComments).to.equal(0)
expect(res.body.data).to.be.an('array') expect(res.body.data).to.be.an('array')
expect(res.body.data).to.have.lengthOf(0) expect(res.body.data).to.have.lengthOf(0)
}) })
@ -94,6 +95,7 @@ describe('Test video comments', function () {
const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5) const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5)
expect(res.body.total).to.equal(1) expect(res.body.total).to.equal(1)
expect(res.body.totalNotDeletedComments).to.equal(1)
expect(res.body.data).to.be.an('array') expect(res.body.data).to.be.an('array')
expect(res.body.data).to.have.lengthOf(1) expect(res.body.data).to.have.lengthOf(1)
@ -172,6 +174,7 @@ describe('Test video comments', function () {
const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5, 'createdAt') const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5, 'createdAt')
expect(res.body.total).to.equal(3) expect(res.body.total).to.equal(3)
expect(res.body.totalNotDeletedComments).to.equal(6)
expect(res.body.data).to.be.an('array') expect(res.body.data).to.be.an('array')
expect(res.body.data).to.have.lengthOf(3) expect(res.body.data).to.have.lengthOf(3)
@ -186,26 +189,35 @@ describe('Test video comments', function () {
it('Should delete a reply', async function () { it('Should delete a reply', async function () {
await deleteVideoComment(server.url, server.accessToken, videoId, replyToDeleteId) await deleteVideoComment(server.url, server.accessToken, videoId, replyToDeleteId)
const res = await getVideoThreadComments(server.url, videoUUID, threadId) {
const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5, 'createdAt')
const tree: VideoCommentThreadTree = res.body expect(res.body.total).to.equal(3)
expect(tree.comment.text).equal('my super first comment') expect(res.body.totalNotDeletedComments).to.equal(5)
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 res = await getVideoThreadComments(server.url, videoUUID, threadId)
expect(firstChild.children).to.have.lengthOf(1)
const childOfFirstChild = firstChild.children[0] const tree: VideoCommentThreadTree = res.body
expect(childOfFirstChild.comment.text).to.equal('my super answer to answer of thread 1') expect(tree.comment.text).equal('my super first comment')
expect(childOfFirstChild.children).to.have.lengthOf(0) expect(tree.children).to.have.lengthOf(2)
const deletedChildOfFirstChild = tree.children[1] const firstChild = tree.children[0]
expect(deletedChildOfFirstChild.comment.text).to.equal('') expect(firstChild.comment.text).to.equal('my super answer to thread 1')
expect(deletedChildOfFirstChild.comment.isDeleted).to.be.true expect(firstChild.children).to.have.lengthOf(1)
expect(deletedChildOfFirstChild.comment.deletedAt).to.not.be.null
expect(deletedChildOfFirstChild.comment.account).to.be.null const childOfFirstChild = firstChild.children[0]
expect(deletedChildOfFirstChild.children).to.have.lengthOf(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 () { it('Should delete a complete thread', async function () {

View File

@ -2,3 +2,7 @@ export interface ResultList<T> {
total: number total: number
data: T[] data: T[]
} }
export interface ThreadsResultList <T> extends ResultList <T> {
totalNotDeletedComments: number
}