Skip to content

Commit

Permalink
Changed in-reply-to snippet to show [hidden/removed] (#21731)
Browse files Browse the repository at this point in the history
closes https://linear.app/ghost/issue/PLG-263/

When hiding a reply as an Admin, if there were other replies that referenced it then those snippets would still show the hidden content because there was no immediate update in the comments-ui client. This made it look like hidden content would still be visible even though at the API level snippets were entirely removed so no other user would see it.

- added client-side handling so in-reply-to snippets immediately show `[hidden/removed]` which means we don't have to fetch every reply from the API
- updated the API to use `[hidden/removed]` as the snippet when referencing a hidden reply instead of removing all `in_reply_to_` data
  - keeping `in_reply_to_id` and `in_reply_to_snippet` means comments-ui still displays the replied-to reference text (albeit not directly showing the API-supplied string so `[hidden/removed]` can be translated)
  - returns the full `in_reply_to_snippet` text for Admin API requests so that showing a comment that has been loaded from the API can immediately show the contents for any displayed references to the comment
  • Loading branch information
kevinansfield authored Dec 2, 2024
1 parent 4e806f7 commit 9da9757
Show file tree
Hide file tree
Showing 66 changed files with 233 additions and 10 deletions.
15 changes: 12 additions & 3 deletions apps/comments-ui/src/components/content/Comment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import ReplyForm from './forms/ReplyForm';
import {Avatar, BlankAvatar} from './Avatar';
import {Comment, OpenCommentForm, useAppContext, useLabs} from '../../AppContext';
import {Transition} from '@headlessui/react';
import {formatExplicitTime, getCommentInReplyToSnippet, getMemberNameFromComment} from '../../utils/helpers';
import {findCommentById, formatExplicitTime, getCommentInReplyToSnippet, getMemberNameFromComment} from '../../utils/helpers';
import {useCallback} from 'react';
import {useRelativeTime} from '../../utils/hooks';

Expand Down Expand Up @@ -283,13 +283,22 @@ type CommentHeaderProps = {
}

const CommentHeader: React.FC<CommentHeaderProps> = ({comment, className = ''}) => {
const {t} = useAppContext();
const {comments, t} = useAppContext();
const labs = useLabs();
const createdAtRelative = useRelativeTime(comment.created_at);
const {member} = useAppContext();
const memberExpertise = member && comment.member && comment.member.uuid === member.uuid ? member.expertise : comment?.member?.expertise;
const isReplyToReply = labs.commentImprovements && comment.in_reply_to_id && comment.in_reply_to_snippet;

let inReplyToSnippet = comment.in_reply_to_snippet;

if (isReplyToReply) {
const inReplyToComment = findCommentById(comments, comment.in_reply_to_id);
if (inReplyToComment && inReplyToComment.status !== 'published') {
inReplyToSnippet = `[${t('hidden/removed')}]`;
}
}

const scrollRepliedToCommentIntoView = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();

Expand Down Expand Up @@ -317,7 +326,7 @@ const CommentHeader: React.FC<CommentHeaderProps> = ({comment, className = ''})
</div>
{(isReplyToReply &&
<div className="mb-2 line-clamp-1 font-sans text-base leading-snug text-neutral-900/50 sm:text-sm dark:text-white/60">
<span>{t('Replied to')}</span>:&nbsp;<a className="font-semibold text-neutral-900/60 transition-colors hover:text-neutral-900/70 dark:text-white/70 dark:hover:text-white/80" data-testid="comment-in-reply-to" href={`#${comment.in_reply_to_id}`} onClick={scrollRepliedToCommentIntoView}>{comment.in_reply_to_snippet}</a>
<span>{t('Replied to')}</span>:&nbsp;<a className="font-semibold text-neutral-900/60 transition-colors hover:text-neutral-900/70 dark:text-white/70 dark:hover:text-white/80" data-testid="comment-in-reply-to" href={`#${comment.in_reply_to_id}`} onClick={scrollRepliedToCommentIntoView}>{inReplyToSnippet}</a>
</div>
)}
</>
Expand Down
31 changes: 31 additions & 0 deletions apps/comments-ui/test/e2e/admin-moderation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,5 +197,36 @@ test.describe('Admin moderation', async () => {

await expect(replyToHide).not.toContainText('Hidden for members');
});

test('updates in-reply-to snippets when hiding', async ({page}) => {
mockedApi.addComment({
id: '1',
html: '<p>This is comment 1</p>',
replies: [
buildReply({id: '2', html: '<p>This is reply 1</p>'}),
buildReply({id: '3', html: '<p>This is reply 2</p>', in_reply_to_id: '2', in_reply_to_snippet: 'This is reply 1'}),
buildReply({id: '4', html: '<p>This is reply 3</p>'})
]
});

const {frame} = await initializeTest(page, {labs: true});
const comments = await frame.getByTestId('comment-component');
const replyToHide = comments.nth(1);
const inReplyToComment = comments.nth(2);

// Hide the 1st reply
await replyToHide.getByTestId('more-button').click();
await replyToHide.getByTestId('hide-button').click();

await expect(inReplyToComment).toContainText('[hidden/removed]');
await expect(inReplyToComment).not.toContainText('This is reply 1');

// Show it again
await replyToHide.getByTestId('more-button').click();
await replyToHide.getByTestId('show-button').click();

await expect(inReplyToComment).not.toContainText('[hidden/removed]');
await expect(inReplyToComment).toContainText('This is reply 1');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,28 @@ const countFields = [
const commentMapper = (model, frame) => {
const jsonModel = model.toJSON ? model.toJSON(frame.options) : model;

const isPublicRequest = utils.isMembersAPI(frame);

if (labs.isSet('commentImprovements')) {
if (jsonModel.inReplyTo && jsonModel.inReplyTo.status === 'published') {
if (jsonModel.inReplyTo && (jsonModel.inReplyTo.status === 'published' || (!isPublicRequest && jsonModel.inReplyTo.status === 'hidden'))) {
jsonModel.in_reply_to_snippet = htmlToPlaintext.commentSnippet(jsonModel.inReplyTo.html);
} else if (jsonModel.inReplyTo && jsonModel.inReplyTo.status !== 'published') {
jsonModel.in_reply_to_snippet = '[hidden/removed]';
} else {
jsonModel.in_reply_to_id = null;
jsonModel.in_reply_to_snippet = null;
}

if (!jsonModel.inReplyTo) {
jsonModel.in_reply_to_id = null;
}
} else {
delete jsonModel.in_reply_to_id;
}

const response = _.pick(jsonModel, commentFields);

if (jsonModel.member) {
response.member = _.pick(jsonModel.member, utils.isMembersAPI(frame) ? memberFields : memberFieldsAdmin);
response.member = _.pick(jsonModel.member, isPublicRequest ? memberFields : memberFieldsAdmin);
} else {
response.member = null;
}
Expand All @@ -87,7 +94,7 @@ const commentMapper = (model, frame) => {
response.count = _.pick(jsonModel.count, countFields);
}

if (utils.isMembersAPI(frame)) {
if (isPublicRequest) {
if (jsonModel.status !== 'published') {
response.html = null;
}
Expand Down
36 changes: 35 additions & 1 deletion ghost/core/test/e2e-api/admin/comments.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,15 @@ const dbFns = {
* @property {string} [post_id=post_id]
* @property {string} member_id
* @property {string} [parent_id]
* @property {string} [in_reply_to_id]
* @property {string} [html='This is a comment']
* @property {string} [status='published']
* @property {Date} [created_at]
*/
/**
* @typedef {Object} AddCommentReplyData
* @property {string} member_id
* @property {string} [in_reply_to_id]
* @property {string} [html='This is a reply']
* @property {Date} [created_at]
* @property {string} [status]
Expand All @@ -54,6 +56,7 @@ const dbFns = {
addComment: async (data) => {
return await models.Comment.add({
post_id: data.post_id || postId,
in_reply_to_id: data.in_reply_to_id,
member_id: data.member_id,
parent_id: data.parent_id,
html: data.html || '<p>This is a comment</p>',
Expand Down Expand Up @@ -179,7 +182,7 @@ async function getMemberComments(url, commentsMatcher = [membersCommentMatcher])
});
});

describe('browse', function () {
describe('browse by post', function () {
it('returns comments', async function () {
await dbFns.addComment({
member_id: fixtureManager.get('members', 0).id,
Expand Down Expand Up @@ -586,6 +589,37 @@ async function getMemberComments(url, commentsMatcher = [membersCommentMatcher])
assert.equal(comment.count.replies, 3);
});
}

if (enableCommentImprovements) {
it('includes in_reply_to_snippet for hidden replies', async function () {
const post = fixtureManager.get('posts', 1);
const {parent, replies: [inReplyTo]} = await dbFns.addCommentWithReplies({
member_id: fixtureManager.get('members', 0).id,
post_id: post.id,
html: 'Comment 1',
status: 'published',
replies: [{
member_id: fixtureManager.get('members', 0).id,
html: 'Reply 1',
status: 'hidden'
}]
});

await dbFns.addComment({
member_id: fixtureManager.get('members', 0).id,
post_id: post.id,
parent_id: parent.id,
in_reply_to_id: inReplyTo.id,
html: 'Reply 2',
status: 'published'
});

const res = await adminApi.get('/comments/post/' + post.id + '/');
const comment = res.body.comments[0];
const reply = comment.replies[1];
assert.equal(reply.in_reply_to_snippet, 'Reply 1');
});
}
});

describe('get by id', function () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2112,6 +2112,88 @@ Object {
}
`;

exports[`Comments API when commenting enabled for all when authenticated replies to replies has redacted in_reply_to_snippet when referenced comment is deleted 1: [body] 1`] = `
Object {
"comments": Array [
Object {
"count": Object {
"likes": Any<Number>,
"replies": 0,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"edited_at": null,
"html": "<p>This is a comment</p>",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"in_reply_to_id": Nullable<StringMatching>,
"in_reply_to_snippet": "[hidden/removed]",
"liked": Any<Boolean>,
"member": Object {
"avatar_image": null,
"expertise": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": null,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
"replies": Array [],
"status": "published",
},
],
}
`;

exports[`Comments API when commenting enabled for all when authenticated replies to replies has redacted in_reply_to_snippet when referenced comment is deleted 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "*",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "442",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Encoding",
"x-powered-by": "Express",
}
`;

exports[`Comments API when commenting enabled for all when authenticated replies to replies has redacted in_reply_to_snippet when referenced comment is hidden 1: [body] 1`] = `
Object {
"comments": Array [
Object {
"count": Object {
"likes": Any<Number>,
"replies": 0,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"edited_at": null,
"html": "<p>This is a comment</p>",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"in_reply_to_id": Nullable<StringMatching>,
"in_reply_to_snippet": "[hidden/removed]",
"liked": Any<Boolean>,
"member": Object {
"avatar_image": null,
"expertise": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": null,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
"replies": Array [],
"status": "published",
},
],
}
`;

exports[`Comments API when commenting enabled for all when authenticated replies to replies has redacted in_reply_to_snippet when referenced comment is hidden 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "*",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "442",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Encoding",
"x-powered-by": "Express",
}
`;

exports[`Comments API when commenting enabled for all when authenticated replies to replies in_reply_to_id is ignored id in_reply_to_id has a different parent 1: [body] 1`] = `
Object {
"comments": Array [
Expand Down
4 changes: 2 additions & 2 deletions ghost/core/test/e2e-api/members-comments/comments.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1500,7 +1500,7 @@ describe('Comments API', function () {
});

['deleted', 'hidden'].forEach((status) => {
it(`does not include in_reply_to_snippet for ${status} comments`, async function () {
it(`has redacted in_reply_to_snippet when referenced comment is ${status}`, async function () {
const {replies: [reply]} = await dbFns.addCommentWithReplies({
member_id: fixtureManager.get('members', 1).id,
replies: [{
Expand All @@ -1518,7 +1518,7 @@ describe('Comments API', function () {

const {body: {comments: [comment]}} = await testGetComments(`/api/comments/${newComment.id}`, [labsCommentMatcher]);

should.not.exist(comment.in_reply_to_snippet);
comment.in_reply_to_snippet.should.eql('[hidden/removed]');
});
});
});
Expand Down
1 change: 1 addition & 0 deletions ghost/i18n/locales/af/comments.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"Full-time parent": "Voltydse ouer",
"Head of Marketing at Acme, Inc": "Hoof van Bemarking by Acme, Inc",
"Hidden for members": "",
"hidden/removed": "",
"Hide": "Versteek",
"Hide comment": "Versteek kommentaar",
"Jamie Larson": "Jamie Larson",
Expand Down
1 change: 1 addition & 0 deletions ghost/i18n/locales/ar/comments.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"Full-time parent": "أب بدوام كامل",
"Head of Marketing at Acme, Inc": "رئيس وحدة التسويق لدى شركة أكمى",
"Hidden for members": "",
"hidden/removed": "",
"Hide": "اخفاء",
"Hide comment": "اخف التعليق",
"Jamie Larson": "فلان الفلانى",
Expand Down
1 change: 1 addition & 0 deletions ghost/i18n/locales/bg/comments.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"Full-time parent": "Родител на пълно работно време",
"Head of Marketing at Acme, Inc": "Директор маркетинг в Компания ООД",
"Hidden for members": "",
"hidden/removed": "",
"Hide": "Скриване",
"Hide comment": "Скриване на коментара",
"Jamie Larson": "Иван Иванов",
Expand Down
1 change: 1 addition & 0 deletions ghost/i18n/locales/bn/comments.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"Full-time parent": "পূর্ণকালীন অভিভাবক",
"Head of Marketing at Acme, Inc": "মার্কেটিং প্রধান @ বাংলাদেশ ট্রেড হাব",
"Hidden for members": "",
"hidden/removed": "",
"Hide": "লুকান",
"Hide comment": "মন্তব্য লুকান",
"Jamie Larson": "শাহ নেওয়াজ",
Expand Down
1 change: 1 addition & 0 deletions ghost/i18n/locales/bs/comments.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"Full-time parent": "Full time roditelj",
"Head of Marketing at Acme, Inc": "Šef marketinga u kompaniji Acme d.o.o",
"Hidden for members": "",
"hidden/removed": "",
"Hide": "Sakrij",
"Hide comment": "Sakrij komentar",
"Jamie Larson": "Vanja Larsić",
Expand Down
1 change: 1 addition & 0 deletions ghost/i18n/locales/ca/comments.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"Full-time parent": "Pare a temps complert",
"Head of Marketing at Acme, Inc": "Cap de màrqueting a Acme, Inc",
"Hidden for members": "",
"hidden/removed": "",
"Hide": "Amaga",
"Hide comment": "Amaga el comentari",
"Jamie Larson": "Jamie Larson",
Expand Down
1 change: 1 addition & 0 deletions ghost/i18n/locales/context.json
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@
"complimentary": "",
"edited": "",
"free": "",
"hidden/removed": "",
"[email protected]": "Placeholder for email input field",
"month": "the subscription interval (monthly), following the /",
"paid": "",
Expand Down
1 change: 1 addition & 0 deletions ghost/i18n/locales/cs/comments.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"Full-time parent": "Rodič na plný úvazek",
"Head of Marketing at Acme, Inc": "Vedoucí marketingu v Acme, Inc",
"Hidden for members": "",
"hidden/removed": "",
"Hide": "Skrýt",
"Hide comment": "Skrýt komentář",
"Jamie Larson": "Jamie Larson",
Expand Down
1 change: 1 addition & 0 deletions ghost/i18n/locales/da/comments.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"Full-time parent": "Forældre på fuld tid",
"Head of Marketing at Acme, Inc": "Chef for marking hos Acme, Inc",
"Hidden for members": "",
"hidden/removed": "",
"Hide": "Skjul",
"Hide comment": "Skjul kommentar",
"Jamie Larson": "Jamie Larson",
Expand Down
1 change: 1 addition & 0 deletions ghost/i18n/locales/de-CH/comments.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"Full-time parent": "",
"Head of Marketing at Acme, Inc": "",
"Hidden for members": "",
"hidden/removed": "",
"Hide": "",
"Hide comment": "",
"Jamie Larson": "",
Expand Down
1 change: 1 addition & 0 deletions ghost/i18n/locales/de/comments.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"Full-time parent": "Vollzeit-Elternteil",
"Head of Marketing at Acme, Inc": "Leiter Marketing bei Acme, Inc",
"Hidden for members": "",
"hidden/removed": "",
"Hide": "Verbergen",
"Hide comment": "Kommentar verbergen",
"Jamie Larson": "Jamie Larson",
Expand Down
1 change: 1 addition & 0 deletions ghost/i18n/locales/el/comments.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"Full-time parent": "Γονέας πλήρους απασχόλησης",
"Head of Marketing at Acme, Inc": "Επικεφαλής Μάρκετινγκ στην Acme, Inc",
"Hidden for members": "",
"hidden/removed": "",
"Hide": "Απόκρυψη",
"Hide comment": "Απόκρυψη σχολίου",
"Jamie Larson": "Τζέιμι Λάρσον",
Expand Down
Loading

0 comments on commit 9da9757

Please sign in to comment.