From 58e85cc67bcba787e45fd2853daa751c9b51a618 Mon Sep 17 00:00:00 2001 From: flashwave <me@flash.moe> Date: Mon, 17 Feb 2025 02:03:38 +0000 Subject: [PATCH] Things are taking shape. --- assets/misuzu.css/comments/entry.css | 8 +- assets/misuzu.css/comments/listing.css | 3 + assets/misuzu.js/comments/api.js | 68 +++++++-- assets/misuzu.js/comments/listing.jsx | 198 +++++++++++++++++++------ src/Comments/CommentsRoutes.php | 142 +++++++++++------- 5 files changed, 306 insertions(+), 113 deletions(-) diff --git a/assets/misuzu.css/comments/entry.css b/assets/misuzu.css/comments/entry.css index 79aeac92..1e9bf535 100644 --- a/assets/misuzu.css/comments/entry.css +++ b/assets/misuzu.css/comments/entry.css @@ -91,11 +91,15 @@ border-radius: 3px; padding: 1px; gap: 1px; + transition: opacity .1s; } .comments-entry-actions-group-votes, .comments-entry-actions-group-replies { border: 1px solid var(--accent-colour); } +.comments-entry-actions-group-disabled { + opacity: .5; +} .comments-entry-action { background: transparent; @@ -108,12 +112,14 @@ padding: 3px 6px; cursor: pointer; transition: background-color .2s; + min-width: 24px; + min-height: 22px; } .comments-entry-action:hover, .comments-entry-action:focus { background: var(--comments-entry-action-background-hover, #fff4); } -.comments-entry-action-replies-open { +.comments-entry-action-reply-active { background: #fff2; } .comments-entry-action-vote-like.comments-entry-action-vote-cast { diff --git a/assets/misuzu.css/comments/listing.css b/assets/misuzu.css/comments/listing.css index 353ebc87..e7569242 100644 --- a/assets/misuzu.css/comments/listing.css +++ b/assets/misuzu.css/comments/listing.css @@ -3,3 +3,6 @@ flex-direction: column; gap: 2px; } +.comments-listing-root { + margin: 2px; +} diff --git a/assets/misuzu.js/comments/api.js b/assets/misuzu.js/comments/api.js index 6b9d933d..9ffa4d98 100644 --- a/assets/misuzu.js/comments/api.js +++ b/assets/misuzu.js/comments/api.js @@ -6,7 +6,10 @@ const MszCommentsApi = (() => { if(name.trim() === '') throw 'name may not be empty'; - const { status, body } = await $xhr.get(`/comments/categories/${name}`, { type: 'json' }); + const { status, body } = await $xhr.get( + `/comments/categories/${name}`, + { type: 'json' } + ); if(status === 404) throw 'that category does not exist'; if(status !== 200) @@ -22,7 +25,11 @@ const MszCommentsApi = (() => { if(typeof args !== 'object' || args === null) throw 'args must be a non-null object'; - const { status } = await $xhr.patch(`/comments/categories/${name}`, { csrf: true }, args); + const { status } = await $xhr.patch( + `/comments/categories/${name}`, + { csrf: true }, + args + ); return status; }, @@ -32,7 +39,10 @@ const MszCommentsApi = (() => { if(post.trim() === '') throw 'post id may not be empty'; - const { status, body } = await $xhr.get(`/comments/posts/${post}`, { type: 'json' }); + const { status, body } = await $xhr.get( + `/comments/posts/${post}`, + { type: 'json' } + ); if(status === 404) throw 'that post does not exist'; if(status !== 200) @@ -46,7 +56,10 @@ const MszCommentsApi = (() => { if(post.trim() === '') throw 'post id may not be empty'; - const { status, body } = await $xhr.get(`/comments/posts/${post}/replies`, { type: 'json' }); + const { status, body } = await $xhr.get( + `/comments/posts/${post}/replies`, + { type: 'json' } + ); if(status === 404) throw 'that post does not exist'; if(status !== 200) @@ -62,7 +75,11 @@ const MszCommentsApi = (() => { if(typeof args !== 'object' || args === null) throw 'args must be a non-null object'; - const { status, body } = await $xhr.post('/comments/posts', { csrf: true }, args); + const { status, body } = await $xhr.post( + '/comments/posts', + { csrf: true }, + args + ); return status; }, @@ -72,17 +89,37 @@ const MszCommentsApi = (() => { deletePost: async post => { // }, + restorePost: async post => { + if(typeof post !== 'string') + throw 'post id must be a string'; + if(post.trim() === '') + throw 'post id may not be empty'; + + const { status } = await $xhr.post(`/comments/posts/${post}/restore`, { csrf: true }); + if(status === 400) + throw 'that post is not deleted'; + if(status === 403) + throw 'you are not allowed to restore posts'; + if(status === 404) + throw 'that post does not exist'; + if(status !== 200) + throw 'something went wrong'; + }, createVote: async (post, vote) => { if(typeof post !== 'string') - throw 'name must be a string'; + throw 'post id must be a string'; if(post.trim() === '') - throw 'name may not be empty'; + throw 'post id may not be empty'; if(typeof vote === 'string') vote = parseInt(vote); if(typeof vote !== 'number' || isNaN(vote)) throw 'vote must be a number'; - const { status } = await $xhr.post(`/comments/posts/${post}/vote`, { csrf: true }, { vote }); + const { status, body } = await $xhr.post( + `/comments/posts/${post}/vote`, + { csrf: true, type: 'json' }, + { vote } + ); if(status === 400) throw 'your vote is not acceptable'; if(status === 403) @@ -91,20 +128,27 @@ const MszCommentsApi = (() => { throw 'that post does not exist'; if(status !== 200) throw 'something went wrong'; + + return body; }, deleteVote: async post => { if(typeof post !== 'string') - throw 'name must be a string'; + throw 'post id must be a string'; if(post.trim() === '') - throw 'name may not be empty'; + throw 'post id may not be empty'; - const { status } = await $xhr.delete(`/comments/posts/${post}/vote`, { csrf: true }); + const { status, body } = await $xhr.delete( + `/comments/posts/${post}/vote`, + { csrf: true, type: 'json' } + ); if(status === 403) throw 'you are not allowed to like or dislike comments'; if(status === 404) throw 'that post does not exist'; - if(status !== 204) + if(status !== 200) throw 'something went wrong'; + + return body; }, }; })(); diff --git a/assets/misuzu.js/comments/listing.jsx b/assets/misuzu.js/comments/listing.jsx index 5c363f30..300af049 100644 --- a/assets/misuzu.js/comments/listing.jsx +++ b/assets/misuzu.js/comments/listing.jsx @@ -5,59 +5,160 @@ const MszCommentsEntry = function(userInfo, postInfo, root) { userInfo ??= {}; const actions = <div class="comments-entry-actions" />; - actions.appendChild(<div class="comments-entry-actions-group comments-entry-actions-group-votes"> - <button class={{ 'comments-entry-action': true, 'comments-entry-action-vote-like': true, 'comments-entry-action-vote-cast': postInfo.vote > 0 }} disabled={!userInfo.can_vote}> - <i class="fas fa-chevron-up" /> - {postInfo.positive > 0 ? <span>{postInfo.positive.toLocaleString()}</span> : null} - </button> - <button class={{ 'comments-entry-action': true, 'comments-entry-action-vote-dislike': true, 'comments-entry-action-vote-cast': postInfo.vote < 0 }} disabled={!userInfo.can_vote}> - <i class="fas fa-chevron-down" /> - {postInfo.negative < 0 ? <span>{Math.abs(postInfo.negative).toLocaleString()}</span> : null} - </button> - </div>); + + const likeAction = <button class="comments-entry-action comments-entry-action-vote-like" disabled={!userInfo.can_vote}> + <i class="fas fa-chevron-up" /> + </button>; + const dislikeAction = <button class="comments-entry-action comments-entry-action-vote-dislike" disabled={!userInfo.can_vote}> + <i class="fas fa-chevron-down" /> + </button>; + const voteActions = <div class="comments-entry-actions-group comments-entry-actions-group-votes"> + {likeAction} + {dislikeAction} + </div>; + actions.appendChild(voteActions); + + const updateVoteElem = (elem, count, cast) => { + elem.classList.toggle('comments-entry-action-vote-cast', cast); + + let counter = elem.querySelector('.js-votes'); + if(!counter) { + if(count === 0) + return; + + elem.appendChild(counter = <span class="js-votes" />); + } + + if(count === 0) + elem.removeChild(counter); + else + counter.textContent = count.toLocaleString(); + }; + const updateVotes = votes => { + updateVoteElem(likeAction, votes?.positive ?? 0, votes?.vote > 0); + updateVoteElem(dislikeAction, Math.abs(votes?.negative ?? 0), votes?.vote < 0); + }; + + updateVotes(postInfo); + + const castVote = async vote => { + voteActions.classList.add('comments-entry-actions-group-disabled'); + likeAction.disabled = dislikeAction.disabled = true; + try { + updateVotes(vote === 0 + ? await MszCommentsApi.deleteVote(postInfo.id) + : await MszCommentsApi.createVote(postInfo.id, vote)); + } catch(ex) { + console.error(ex); + } finally { + voteActions.classList.remove('comments-entry-actions-group-disabled'); + likeAction.disabled = dislikeAction.disabled = false; + } + }; + + if(postInfo.deleted && userInfo.can_vote) { + likeAction.onclick = () => { castVote(likeAction.classList.contains('comments-entry-action-vote-cast') ? 0 : 1); }; + dislikeAction.onclick = () => { castVote(dislikeAction.classList.contains('comments-entry-action-vote-cast') ? 0 : -1); }; + } const repliesIsArray = Array.isArray(postInfo.replies); - const form = userInfo?.can_create ? new MszCommentsForm(userInfo) : null; const listing = new MszCommentsListing({ hidden: !repliesIsArray }); if(repliesIsArray) listing.addPosts(userInfo, postInfo.replies); + let form = null; + const repliesElem = <div class="comments-entry-replies"> + {listing} + </div>; + const replyCount = repliesIsArray ? postInfo.replies.length : (postInfo.replies ?? 0); - if(replyCount > 0 || userInfo.can_create) { - const replyElem = <button class={{ 'comments-entry-action': true, 'comments-entry-action-replies-open': listing.visible }}> - <i class="fas fa-reply" /> - {replyCount > 0 ? <span>{replyCount.toLocaleString()}</span> : null} + const replyActionsGroup = <div class="comments-entry-actions-group comments-entry-actions-group-replies" />; + actions.appendChild(replyActionsGroup); + + const replyToggleOpenElem = <span class="hidden"><i class="fas fa-minus" /></span>; + const replyToggleClosedElem = <span class="hidden"><i class="fas fa-plus" /></span>; + const replyCountElem = <span />; + const replyToggleElem = <button class="comments-entry-action"> + {replyToggleOpenElem} + {replyToggleClosedElem} + {replyCountElem} + </button>; + replyActionsGroup.appendChild(replyToggleElem); + + const setReplyToggleState = visible => { + replyToggleOpenElem.classList.toggle('hidden', !visible); + replyToggleClosedElem.classList.toggle('hidden', visible); + }; + setReplyToggleState(listing.visible); + + const setReplyCount = count => { + count ??= 0; + if(count > 0) { + replyCountElem.textContent = count.toLocaleString(); + replyToggleElem.classList.remove('hidden'); + replyActionsGroup.classList.remove('hidden'); + } else { + replyToggleElem.classList.add('hidden'); + if(replyActionsGroup.childElementCount < 2) + replyActionsGroup.classList.add('hidden'); + } + }; + + let replyLoaded = listing.loaded; + replyToggleElem.onclick = async () => { + setReplyToggleState(listing.visible = !listing.visible); + if(!replyLoaded) { + replyLoaded = true; + try { + listing.addPosts(userInfo, await MszCommentsApi.getPostReplies(postInfo.id)); + } catch(ex) { + console.error(ex); + setReplyToggleState(false); + replyLoaded = false; + + // THIS IS NOT FINAL DO NOT PUSH THIS TO PUBLIC THIS WOULD BE HORRIBLE + if(typeof ex === 'string') + MszShowMessageBox(ex); + } + } + }; + + if(userInfo.can_create) { + const replyElem = <button class="comments-entry-action"> + <span><i class="fas fa-reply" /></span> + <span>Reply</span> </button>; + replyActionsGroup.appendChild(replyElem); - let loaded = listing.loaded; - replyElem.onclick = async () => { - replyElem.classList.toggle('comments-entry-action-replies-open', listing.visible = !listing.visible); - if(!loaded) { - loaded = true; - try { - listing.addPosts(userInfo, await MszCommentsApi.getPostReplies(postInfo.id)); - } catch(ex) { - loaded = false; - console.error(ex); - - // THIS IS NOT FINAL DO NOT PUSH THIS TO PUBLIC THIS WOULD BE HORRIBLE - if(typeof ex === 'string') - MszShowMessageBox(ex); - } + replyElem.onclick = () => { + if(form === null) { + replyElem.classList.add('comments-entry-action-reply-active'); + form = new MszCommentsForm(userInfo); + $insertBefore(listing.element, form.element); + } else { + replyElem.classList.remove('comments-entry-action-reply-active'); + repliesElem.removeChild(form.element); + form = null; } }; - - actions.appendChild(<div class="comments-entry-actions-group comments-entry-actions-group-replies"> - {replyElem} - </div>); } + // this has to be called no earlier cus if there's less than 2 elements in the group it gets hidden on 0 + setReplyCount(replyCount); + if(postInfo.can_delete || userInfo.can_pin) { const misc = <div class="comments-entry-actions-group" />; - if(postInfo.can_delete) - misc.appendChild(<button class="comments-entry-action"> - <i class="fas fa-trash" /> - </button>); + if(postInfo.can_delete) { + if(postInfo.deleted) { + misc.appendChild(<button class="comments-entry-action"> + <i class="fas fa-trash-restore" /> + </button>); + } else { + misc.appendChild(<button class="comments-entry-action"> + <i class="fas fa-trash" /> + </button>); + } + } if(userInfo.can_pin) misc.appendChild(<button class="comments-entry-action" disabled={!userInfo.can_pin}> <i class="fas fa-thumbtack" /> @@ -65,10 +166,10 @@ const MszCommentsEntry = function(userInfo, postInfo, root) { actions.appendChild(misc); } - const created = new Date(postInfo.created); - const edited = postInfo.edited ? new Date(postInfo.edited) : null; - const deleted = postInfo.deleted ? new Date(postInfo.deleted) : null; - const pinned = postInfo.pinned ? new Date(postInfo.pinned) : null; + const created = typeof postInfo.created === 'string' ? new Date(postInfo.created) : null; + const edited = typeof postInfo.edited === 'string' ? new Date(postInfo.edited) : null; + const deleted = typeof postInfo.deleted === 'string' ? new Date(postInfo.deleted) : null; + const pinned = typeof postInfo.pinned === 'string' ? new Date(postInfo.pinned) : null; const element = <div id={`comment-${postInfo.id}`} data-comment={postInfo.id} class={{ 'comments-entry': true, 'comments-entry-root': root, 'comments-entry-deleted': postInfo.deleted }} style={{ '--user-colour': postInfo.user?.colour }}> <div class="comments-entry-main"> @@ -85,7 +186,9 @@ const MszCommentsEntry = function(userInfo, postInfo, root) { <div class="comments-entry-time"> <div class="comments-entry-time-icon">—</div> <a href={`#comment-${postInfo.id}`} class="comments-entry-time-link"> - <time class="comments-entry-time-text" datetime={created.toISOString()} title={created.toString()}>{MszSakuya.formatTimeAgo(created)}</time> + {created !== null + ? <time class="comments-entry-time-text" datetime={created.toISOString()} title={created.toString()}>{MszSakuya.formatTimeAgo(created)}</time> + : 'deleted'} </a> </div> {edited !== null ? <div class="comments-entry-time comments-entry-time-edited"> @@ -101,14 +204,11 @@ const MszCommentsEntry = function(userInfo, postInfo, root) { <time class="comments-entry-time-text" datetime={deleted.toISOString()} title={deleted.toString()}>{MszSakuya.formatTimeAgo(deleted)}</time> </div> : null} </div> - <div class="comments-entry-body">{postInfo.body}</div> + <div class="comments-entry-body">{postInfo?.body ?? '[deleted]'}</div> {actions.childElementCount > 0 ? actions : null} </div> </div> - <div class="comments-entry-replies"> - {form} - {listing} - </div> + {repliesElem} </div>; MszSakuya.trackElements(element.querySelectorAll('time')); @@ -125,7 +225,7 @@ const MszCommentsListing = function(options) { let loading = new MszLoading; const entries = new Map; - const element = <div class={{ 'comments-listing': true, 'hidden': hidden }}> + const element = <div class={{ 'comments-listing': true, 'comments-listing-root': root, 'hidden': hidden }}> {loading} </div>; diff --git a/src/Comments/CommentsRoutes.php b/src/Comments/CommentsRoutes.php index 8e0b5150..753390f3 100644 --- a/src/Comments/CommentsRoutes.php +++ b/src/Comments/CommentsRoutes.php @@ -47,46 +47,53 @@ class CommentsRoutes implements RouteHandler, UrlSource { CommentsPostInfo $postInfo, ?iterable $replyInfos = null ): array { - $post = [ - 'id' => $postInfo->id, - 'body' => $postInfo->body, - 'created' => $postInfo->createdAt->toIso8601ZuluString(), - ]; - if($postInfo->pinned) - $post['pinned'] = $postInfo->pinnedAt->toIso8601ZuluString(); - if($postInfo->edited) - $post['edited'] = $postInfo->editedAt->toIso8601ZuluString(); + $canViewDeleted = $perms->check(Perm::G_COMMENTS_DELETE_ANY); + $isDeleted = $postInfo->deleted && !$canViewDeleted; + + $post = ['id' => $postInfo->id]; + if(!$isDeleted) { + $post['body'] = $postInfo->body; + $post['created'] = $postInfo->createdAt->toIso8601ZuluString(); + if($postInfo->pinned) + $post['pinned'] = $postInfo->pinnedAt->toIso8601ZuluString(); + if($postInfo->edited) + $post['edited'] = $postInfo->editedAt->toIso8601ZuluString(); + + if(!$isDeleted && $postInfo->userId !== null) + try { + $post['user'] = $this->convertUser( + $this->usersCtx->getUserInfo($postInfo->userId) + ); + } catch(RuntimeException $ex) {} + + $votes = $this->commentsCtx->votes->getVotesAggregate($postInfo); + $post['positive'] = $votes->positive; + $post['negative'] = $votes->negative; + + if($this->authInfo->loggedIn) { + $voteInfo = $this->commentsCtx->votes->getVote($postInfo, $this->authInfo->userInfo); + if($voteInfo->weight !== 0) + $post['vote'] = $voteInfo->weight; + + $isAuthor = $this->authInfo->userId === $postInfo->userId; + if($isAuthor && $perms->check(Perm::G_COMMENTS_EDIT_OWN)) + $post['can_edit'] = true; + if($isAuthor && $perms->check(Perm::G_COMMENTS_DELETE_OWN)) + $post['can_delete'] = true; + } + } if($postInfo->deleted) - $post['deleted'] = $postInfo->deletedAt->toIso8601ZuluString(); - - if($postInfo->userId !== null) - try { - $post['user'] = $this->convertUser( - $this->usersCtx->getUserInfo($postInfo->userId) - ); - } catch(RuntimeException $ex) {} - - $votes = $this->commentsCtx->votes->getVotesAggregate($postInfo); - $post['positive'] = $votes->positive; - $post['negative'] = $votes->negative; + $post['deleted'] = $canViewDeleted ? $postInfo->deletedAt->toIso8601ZuluString() : true; if($this->authInfo->loggedIn) { - $voteInfo = $this->commentsCtx->votes->getVote($postInfo, $this->authInfo->userInfo); - if($voteInfo->weight !== 0) - $post['vote'] = $voteInfo->weight; - - $isAuthor = $this->authInfo->userId === $postInfo->userId; - if($perms->check(Perm::G_COMMENTS_EDIT_ANY) || ($isAuthor && $perms->check(Perm::G_COMMENTS_EDIT_OWN))) + if($perms->check(Perm::G_COMMENTS_EDIT_ANY)) $post['can_edit'] = true; - if($perms->check(Perm::G_COMMENTS_DELETE_ANY) || ($isAuthor && $perms->check(Perm::G_COMMENTS_DELETE_OWN))) + if($perms->check(Perm::G_COMMENTS_DELETE_ANY)) $post['can_delete'] = true; } if($replyInfos === null) { - $replies = $this->commentsCtx->posts->countPosts( - parentInfo: $postInfo, - deleted: $perms->check(Perm::G_COMMENTS_DELETE_ANY) ? null : false, - ); + $replies = $this->commentsCtx->posts->countPosts(parentInfo: $postInfo); if($replies > 0) $post['replies'] = $replies; } else { @@ -164,14 +171,9 @@ class CommentsRoutes implements RouteHandler, UrlSource { $postInfos = $this->commentsCtx->posts->getPosts( categoryInfo: $categoryInfo, replies: false, - deleted: $perms->check(Perm::G_COMMENTS_DELETE_ANY) ? null : false, ); foreach($postInfos as $postInfo) { - $replyInfos = $this->commentsCtx->posts->getPosts( - parentInfo: $postInfo, - deleted: $perms->check(Perm::G_COMMENTS_DELETE_ANY) ? null : false, - ); - + $replyInfos = $this->commentsCtx->posts->getPosts(parentInfo: $postInfo); $posts[] = $this->convertPost($perms, $postInfo, $replyInfos); } } catch(RuntimeException $ex) {} @@ -206,10 +208,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { } $perms = $this->getGlobalPerms(); - $replyInfos = $this->commentsCtx->posts->getPosts( - parentInfo: $postInfo, - deleted: $perms->check(Perm::G_COMMENTS_DELETE_ANY) ? null : false, - ); + $replyInfos = $this->commentsCtx->posts->getPosts(parentInfo: $postInfo); return $this->convertPost($perms, $postInfo, $replyInfos); } @@ -223,10 +222,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { } $perms = $this->getGlobalPerms(); - $replyInfos = $this->commentsCtx->posts->getPosts( - parentInfo: $postInfo, - deleted: $perms->check(Perm::G_COMMENTS_DELETE_ANY) ? null : false, - ); + $replyInfos = $this->commentsCtx->posts->getPosts(parentInfo: $postInfo); $replies = []; foreach($replyInfos as $replyInfo) @@ -249,6 +245,12 @@ class CommentsRoutes implements RouteHandler, UrlSource { #[HttpDelete('/comments/posts/([0-9]+)')] public function deletePost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array { + try { + $postInfo = $this->commentsCtx->posts->getPost($commentId); + } catch(RuntimeException $ex) { + return 404; + } + $perms = $this->getGlobalPerms(); $canDeleteAny = $perms->check(Perm::G_COMMENTS_DELETE_ANY); if(!$canDeleteAny && !$perms->check(Perm::G_COMMENTS_DELETE_OWN)) @@ -257,8 +259,27 @@ class CommentsRoutes implements RouteHandler, UrlSource { return 501; } + #[HttpPost('/comments/posts/([0-9]+)/restore')] + public function postPostRestore(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int { + if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_DELETE_ANY)) + return 403; + + try { + $postInfo = $this->commentsCtx->posts->getPost($commentId); + } catch(RuntimeException $ex) { + return 404; + } + + if(!$postInfo->deleted) + return 400; + + $this->commentsCtx->posts->restorePost($postInfo); + + return 200; + } + #[HttpPost('/comments/posts/([0-9]+)/vote')] - public function postPostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int { + public function postPostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array { $vote = (int)$request->content->getParam('vote', FILTER_SANITIZE_NUMBER_INT); if($vote === 0) return 400; @@ -278,11 +299,19 @@ class CommentsRoutes implements RouteHandler, UrlSource { max(-1, min(1, $vote)) ); - return 200; + $voteInfo = $this->commentsCtx->votes->getVote($postInfo, $this->authInfo->userInfo); + $votes = $this->commentsCtx->votes->getVotesAggregate($postInfo); + + $response->statusCode = 200; + return [ + 'vote' => $voteInfo->weight, + 'positive' => $votes->positive, + 'negative' => $votes->negative, + ]; } #[HttpDelete('/comments/posts/([0-9]+)/vote')] - public function deletePostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int { + public function deletePostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array { if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_VOTE)) return 403; @@ -292,8 +321,19 @@ class CommentsRoutes implements RouteHandler, UrlSource { return 404; } - $this->commentsCtx->votes->removeVote($postInfo, $this->authInfo->userInfo); + $this->commentsCtx->votes->removeVote( + $postInfo, + $this->authInfo->userInfo + ); - return 204; + $voteInfo = $this->commentsCtx->votes->getVote($postInfo, $this->authInfo->userInfo); + $votes = $this->commentsCtx->votes->getVotesAggregate($postInfo); + + $response->statusCode = 200; + return [ + 'vote' => $voteInfo->weight, + 'positive' => $votes->positive, + 'negative' => $votes->negative, + ]; } }