Skip to content

Commit 20d68dc

Browse files
authored
🐛 Fixed duplicate replies when replying after opening a comment permalink (#28504)
ref https://linear.app/ghost/issue/BER-3706 Renoved the problematic reload since the comments API already returns every reply inline, so reloading them when a reply form opens was redundant and duplicated them.
1 parent 43de94a commit 20d68dc

4 files changed

Lines changed: 22 additions & 85 deletions

File tree

apps/comments-ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tryghost/comments-ui",
3-
"version": "1.5.8",
3+
"version": "1.5.9",
44
"license": "MIT",
55
"repository": "https://github.com/TryGhost/Ghost",
66
"author": "Ghost Foundation",

apps/comments-ui/src/actions.ts

Lines changed: 3 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -69,55 +69,6 @@ async function setOrder({state, data: {order}, options, api, dispatchAction}: {s
6969
}
7070
}
7171

72-
async function loadMoreReplies({state, api, data: {comment, limit}, isReply}: {state: EditableAppContext, api: GhostApi, data: {comment: Comment, limit?: number | 'all'}, isReply: boolean}): Promise<Partial<EditableAppContext>> {
73-
const fetchReplies = async (afterReplyId: string | undefined, requestLimit: number) => {
74-
if (state.admin && state.adminApi && !isReply) { // we don't want the admin api to load reply data for replying to a reply, so we pass isReply: true
75-
return await state.adminApi.replies({commentId: comment.id, afterReplyId, limit: requestLimit, memberUuid: state.member?.uuid});
76-
} else {
77-
return await api.comments.replies({commentId: comment.id, afterReplyId, limit: requestLimit});
78-
}
79-
};
80-
81-
let afterReplyId: string | undefined = comment.replies && comment.replies.length > 0
82-
? comment.replies[comment.replies.length - 1]?.id
83-
: undefined;
84-
85-
let allComments: Comment[] = [];
86-
87-
if (limit === 'all') {
88-
let hasMore = true;
89-
90-
while (hasMore) {
91-
const data = await fetchReplies(afterReplyId, 100);
92-
allComments.push(...data.comments);
93-
hasMore = !!data.meta?.pagination?.next;
94-
95-
if (data.comments && data.comments.length > 0) {
96-
afterReplyId = data.comments[data.comments.length - 1]?.id;
97-
} else {
98-
// If no comments returned, stop pagination to prevent infinite loop
99-
hasMore = false;
100-
}
101-
}
102-
} else {
103-
const data = await fetchReplies(afterReplyId, limit as number || 100);
104-
allComments = data.comments;
105-
}
106-
107-
// Note: we store the comments from new to old, and show them in reverse order
108-
return {
109-
comments: state.comments.map((c) => {
110-
if (c.id === comment.id) {
111-
return {
112-
...comment,
113-
replies: [...comment.replies, ...allComments]
114-
};
115-
}
116-
return c;
117-
})
118-
};
119-
}
120-
12172
async function addComment({state, api, data: comment}: {state: EditableAppContext, api: GhostApi, data: AddComment}) {
12273
const data = await api.comments.add({comment});
12374
const newComment = data.comments[0];
@@ -699,27 +650,7 @@ function closePopup() {
699650
};
700651
}
701652

702-
async function openCommentForm({data: newForm, api, state}: {data: OpenCommentForm, api: GhostApi, state: EditableAppContext}) {
703-
let otherStateChanges = {};
704-
705-
// When opening a reply form, load all replies for the parent comment so the
706-
// reply appears in the correct position after posting
707-
const topLevelCommentId = newForm.parent_id || newForm.id;
708-
if (newForm.type === 'reply' && !state.openCommentForms.some(f => f.id === topLevelCommentId || f.parent_id === topLevelCommentId)) {
709-
const comment = state.comments.find(c => c.id === topLevelCommentId);
710-
711-
if (comment) {
712-
try {
713-
const newCommentsState = await loadMoreReplies({state, api, data: {comment, limit: 'all'}, isReply: true});
714-
otherStateChanges = {...otherStateChanges, ...newCommentsState};
715-
} catch (e) {
716-
// If loading replies fails, continue anyway - the form should still open
717-
// and replies will be loaded when the user submits
718-
console.error('[Comments] Failed to load replies before opening form:', e); // eslint-disable-line no-console
719-
}
720-
}
721-
}
722-
653+
function openCommentForm({data: newForm, state}: {data: OpenCommentForm, state: EditableAppContext}) {
723654
// We want to keep the number of displayed forms to a minimum so when opening a
724655
// new form, we close any existing forms that are empty or have had no changes
725656
const openFormsAfterAutoclose = state.openCommentForms.filter(form => form.hasUnsavedChanges);
@@ -729,9 +660,9 @@ async function openCommentForm({data: newForm, api, state}: {data: OpenCommentFo
729660
const openFormIndexForId = openFormsAfterAutoclose.findIndex(form => form.id === newForm.id);
730661
if (openFormIndexForId > -1) {
731662
openFormsAfterAutoclose[openFormIndexForId] = newForm;
732-
return {openCommentForms: openFormsAfterAutoclose, ...otherStateChanges};
663+
return {openCommentForms: openFormsAfterAutoclose};
733664
} else {
734-
return {openCommentForms: [...openFormsAfterAutoclose, newForm], ...otherStateChanges};
665+
return {openCommentForms: [...openFormsAfterAutoclose, newForm]};
735666
}
736667
}
737668

@@ -809,7 +740,6 @@ export const Actions = {
809740
reportComment,
810741
addReply,
811742
loadMoreComments,
812-
loadMoreReplies,
813743
openCommentForm,
814744
updateMember,
815745
setOrder,

apps/comments-ui/src/utils/api.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,19 +169,19 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}: {site
169169

170170
return response;
171171
},
172-
async replies({commentId, afterReplyId, limit}: {commentId: string; afterReplyId?: string; limit?: number | 'all'}) {
172+
async replies({commentId, afterReplyId, limit, page}: {commentId: string; afterReplyId?: string; limit?: number | 'all'; page?: number}) {
173173
if (limit === 'all') {
174+
// Paginate by page, not an `id:>` cursor: replies are ordered by
175+
// created_at, so an id cursor can re-fetch or skip replies (BER-3706).
174176
const all: Comment[] = [];
175-
let cursor: string | undefined = afterReplyId;
177+
let currentPage = 1;
176178
let hasMore = true;
177179

178180
while (hasMore) {
179-
const data = await this.replies({commentId, afterReplyId: cursor, limit: 100});
181+
const data = await this.replies({commentId, limit: 100, page: currentPage});
180182
all.push(...data.comments);
181183
hasMore = !!data.meta?.pagination?.next && data.comments.length > 0;
182-
if (data.comments.length > 0) {
183-
cursor = data.comments[data.comments.length - 1]?.id;
184-
}
184+
currentPage += 1;
185185
}
186186

187187
return {comments: all, meta: {pagination: {next: false}}};
@@ -190,6 +190,10 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}: {site
190190
const params = new URLSearchParams();
191191
params.set('limit', (limit ?? 5).toString());
192192

193+
if (page) {
194+
params.set('page', page.toString());
195+
}
196+
193197
if (afterReplyId) {
194198
params.set('filter', `id:>'${afterReplyId}'`);
195199
}

apps/comments-ui/test/utils/mocked-api.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ export class MockedApi {
257257
};
258258
}
259259

260-
browseReplies({commentId, filter, limit = 5}: {commentId: string, filter?: string, limit?: number}) {
260+
browseReplies({commentId, filter, limit = 5, page = 1}: {commentId: string, filter?: string, limit?: number, page?: number}) {
261261
const comment = this.comments.find(c => c.id === commentId);
262262
if (!comment) {
263263
return {
@@ -288,18 +288,19 @@ export class MockedApi {
288288
});
289289
}
290290

291-
const limitedReplies = filteredReplies.slice(0, limit);
292-
const hasMore = filteredReplies.length > limit;
291+
const startIndex = (page - 1) * limit;
292+
const limitedReplies = filteredReplies.slice(startIndex, startIndex + limit);
293+
const hasMore = filteredReplies.length > startIndex + limit;
293294

294295
return {
295296
comments: limitedReplies,
296297
meta: {
297298
pagination: {
298-
page: 1,
299+
page,
299300
pages: Math.ceil(filteredReplies.length / limit),
300301
total: filteredReplies.length,
301302
limit,
302-
next: hasMore ? 2 : null,
303+
next: hasMore ? page + 1 : null,
303304
prev: null
304305
}
305306
}
@@ -528,13 +529,15 @@ export class MockedApi {
528529
const url = new URL(route.request().url());
529530

530531
const limit = parseInt(url.searchParams.get('limit') ?? '5');
532+
const page = parseInt(url.searchParams.get('page') ?? '1');
531533
const commentId = url.pathname.split('/').reverse()[2];
532534
const filter = url.searchParams.get('filter') ?? '';
533535

534536
await route.fulfill({
535537
status: 200,
536538
body: JSON.stringify(this.browseReplies({
537539
limit,
540+
page,
538541
filter,
539542
commentId
540543
}))

0 commit comments

Comments
 (0)