diff --git a/src/hooks/accounts/accounts.test.ts b/src/hooks/accounts/accounts.test.ts index 55982ec0..b1ec7301 100644 --- a/src/hooks/accounts/accounts.test.ts +++ b/src/hooks/accounts/accounts.test.ts @@ -21,6 +21,7 @@ import { } from "../.."; import commentsStore from "../../stores/comments"; import * as accountsActions from "../../stores/accounts/accounts-actions"; +import { COMMENT_MODERATION_AUTHOR_SUMMARY_KEY } from "../../stores/accounts/utils"; import PkcJsMock, { PKC, Comment, @@ -3398,6 +3399,139 @@ describe("accounts", () => { expect(Object.keys(rendered.result.current.editedComment.failedEdits).length).toBe(0); }); + test("comment moderation author ban is reflected by useEditedComment", async () => { + const commentCid = rendered.result.current.accountComments[0].cid; + const communityAddress = rendered.result.current.accountComments[0].communityAddress; + const commentModerationTimestamp = Math.ceil(Date.now() / 1000); + const banExpiresAt = commentModerationTimestamp + 60 * 60; + + rendered.rerender(commentCid); + await waitFor( + () => + rendered.result.current.comment?.cid && + rendered.result.current.comment.index === undefined, + ); + + await act(async () => { + await accountsActions.publishCommentModeration({ + timestamp: commentModerationTimestamp, + commentCid, + communityAddress, + commentModeration: { author: { banExpiresAt } }, + onChallenge: (challenge: any, comment: any) => comment.publishChallengeAnswers(), + onChallengeVerification: () => {}, + }); + }); + + await waitFor(() => rendered.result.current.editedComment.editedComment); + expect(rendered.result.current.editedComment.state).toBe("pending"); + expect( + rendered.result.current.editedComment.pendingEdits[COMMENT_MODERATION_AUTHOR_SUMMARY_KEY], + ).toEqual({ banExpiresAt }); + expect( + rendered.result.current.editedComment.editedComment.commentModeration?.author?.banExpiresAt, + ).toBe(banExpiresAt); + expect( + rendered.result.current.editedComment.editedComment.author?.community?.banExpiresAt, + ).toBe(banExpiresAt); + + const updatedComment = { ...commentsStore.getState().comments[commentCid] }; + updatedComment.author = { + ...updatedComment.author, + community: { ...updatedComment.author?.community, banExpiresAt }, + }; + updatedComment.updatedAt = commentModerationTimestamp + 1; + commentsStore.setState(({ comments }) => ({ + comments: { ...comments, [commentCid]: updatedComment }, + })); + + await waitFor(() => rendered.result.current.editedComment.state === "succeeded"); + expect( + rendered.result.current.editedComment.succeededEdits[COMMENT_MODERATION_AUTHOR_SUMMARY_KEY], + ).toEqual({ banExpiresAt }); + }); + + test("comment moderation author unban stays pending while refreshed data still has stale ban", async () => { + const commentCid = rendered.result.current.accountComments[0].cid; + const communityAddress = rendered.result.current.accountComments[0].communityAddress; + const commentModerationTimestamp = Math.ceil(Date.now() / 1000); + const banExpiresAt = commentModerationTimestamp + 60 * 60; + + rendered.rerender(commentCid); + await waitFor( + () => + rendered.result.current.comment?.cid && + rendered.result.current.comment.index === undefined, + ); + + const staleBannedComment = { ...commentsStore.getState().comments[commentCid] }; + staleBannedComment.commentModeration = { + ...staleBannedComment.commentModeration, + author: { banExpiresAt }, + }; + staleBannedComment.author = { + ...staleBannedComment.author, + community: { ...staleBannedComment.author?.community, banExpiresAt }, + }; + staleBannedComment.updatedAt = commentModerationTimestamp + 1; + commentsStore.setState(({ comments }) => ({ + comments: { ...comments, [commentCid]: staleBannedComment }, + })); + + await act(async () => { + await accountsActions.publishCommentModeration({ + timestamp: commentModerationTimestamp, + commentCid, + communityAddress, + commentModeration: { author: undefined }, + onChallenge: (challenge: any, comment: any) => comment.publishChallengeAnswers(), + onChallengeVerification: () => {}, + }); + }); + + await waitFor(() => rendered.result.current.editedComment.editedComment); + expect(rendered.result.current.editedComment.state).toBe("pending"); + expect( + Object.prototype.hasOwnProperty.call( + rendered.result.current.editedComment.pendingEdits, + COMMENT_MODERATION_AUTHOR_SUMMARY_KEY, + ), + ).toBe(true); + expect( + rendered.result.current.editedComment.pendingEdits[COMMENT_MODERATION_AUTHOR_SUMMARY_KEY], + ).toBeUndefined(); + expect( + rendered.result.current.editedComment.editedComment.commentModeration?.author, + ).toBeUndefined(); + expect( + rendered.result.current.editedComment.editedComment.author?.community?.banExpiresAt, + ).toBeUndefined(); + + const refreshedUnbannedComment = { ...staleBannedComment }; + refreshedUnbannedComment.commentModeration = { + ...refreshedUnbannedComment.commentModeration, + author: undefined, + }; + refreshedUnbannedComment.updatedAt = commentModerationTimestamp + 2; + commentsStore.setState(({ comments }) => ({ + comments: { ...comments, [commentCid]: refreshedUnbannedComment }, + })); + + await waitFor(() => rendered.result.current.editedComment.state === "succeeded"); + expect( + Object.prototype.hasOwnProperty.call( + rendered.result.current.editedComment.succeededEdits, + COMMENT_MODERATION_AUTHOR_SUMMARY_KEY, + ), + ).toBe(true); + expect( + rendered.result.current.editedComment.succeededEdits[COMMENT_MODERATION_AUTHOR_SUMMARY_KEY], + ).toBeUndefined(); + expect( + rendered.result.current.editedComment.editedComment.author?.community?.banExpiresAt, + ).toBeUndefined(); + }); + test("edited comment failed", async () => { const commentCid = rendered.result.current.accountComments[0].cid; expect(commentCid).not.toBe(undefined); diff --git a/src/hooks/accounts/accounts.ts b/src/hooks/accounts/accounts.ts index dcf5a5fd..b72d0f98 100644 --- a/src/hooks/accounts/accounts.ts +++ b/src/hooks/accounts/accounts.ts @@ -40,7 +40,10 @@ import { useAccountWithCalculatedProperties, useCalculatedNotifications, } from "./utils"; -import { getAccountEditPropertySummary } from "../../stores/accounts/utils"; +import { + COMMENT_MODERATION_AUTHOR_SUMMARY_KEY, + getAccountEditPropertySummary, +} from "../../stores/accounts/utils"; import { getCanonicalCommunityAddress, getEquivalentCommunityAddressGroupKey, @@ -49,6 +52,47 @@ import { import { addCommentModeration } from "../../lib/utils/comment-moderation"; import useInterval from "../utils/use-interval"; +const getCommentEditPropertyValue = (comment: any, propertyName: string) => { + if (propertyName !== COMMENT_MODERATION_AUTHOR_SUMMARY_KEY) { + return comment?.[propertyName]; + } + + const commentModeration = comment?.commentModeration; + if ( + commentModeration && + typeof commentModeration === "object" && + Object.prototype.hasOwnProperty.call(commentModeration, "author") + ) { + return commentModeration.author; + } + + return comment?.author?.community?.banExpiresAt !== undefined + ? { banExpiresAt: comment.author.community.banExpiresAt } + : undefined; +}; + +const applyEditedCommentProperty = (comment: any, propertyName: string, value: any) => { + if (propertyName !== COMMENT_MODERATION_AUTHOR_SUMMARY_KEY) { + comment[propertyName] = value; + return; + } + + comment.commentModeration = comment.commentModeration ? { ...comment.commentModeration } : {}; + if (value === undefined) { + delete comment.commentModeration.author; + } else { + comment.commentModeration.author = value; + } + + comment.author = comment.author ? { ...comment.author } : {}; + comment.author.community = comment.author.community ? { ...comment.author.community } : {}; + if (value?.banExpiresAt === undefined) { + delete comment.author.community.banExpiresAt; + } else { + comment.author.community.banExpiresAt = value.banExpiresAt; + } +}; + /** * @param accountName - The nickname of the account, e.g. 'Account 1'. If no accountName is provided, return * the active account id. @@ -857,7 +901,7 @@ export function useEditedComment(options?: UseEditedCommentOptions): UseEditedCo // Without a newer update we can only treat recent edits as pending. Older edits that never // produced any update are effectively stale and should stop shadowing the live comment. if (!comment?.updatedAt) { - if (isEqual(comment?.[propertyName], propertyNameEdit.value)) { + if (isEqual(getCommentEditPropertyValue(comment, propertyName), propertyNameEdit.value)) { setPropertyNameEditState("succeeded"); } else if (propertyNameEdit.timestamp > now - expiryTime) { setPropertyNameEditState("pending"); @@ -878,7 +922,7 @@ export function useEditedComment(options?: UseEditedCommentOptions): UseEditedCo // has been received after the edit was published so we can evaluate else { // comment has propertyNameEdit, propertyNameEdit succeeded - if (isEqual(comment[propertyName], propertyNameEdit.value)) { + if (isEqual(getCommentEditPropertyValue(comment, propertyName), propertyNameEdit.value)) { setPropertyNameEditState("succeeded"); continue; } @@ -918,10 +962,18 @@ export function useEditedComment(options?: UseEditedCommentOptions): UseEditedCo // add pending and succeeded props so the editor can see his changes right away // don't add failed edits to reflect the current state of the edited comment for (const propertyName in editedResult.pendingEdits) { - editedResult.editedComment[propertyName] = editedResult.pendingEdits[propertyName]; + applyEditedCommentProperty( + editedResult.editedComment, + propertyName, + editedResult.pendingEdits[propertyName], + ); } for (const propertyName in editedResult.succeededEdits) { - editedResult.editedComment[propertyName] = editedResult.succeededEdits[propertyName]; + applyEditedCommentProperty( + editedResult.editedComment, + propertyName, + editedResult.succeededEdits[propertyName], + ); } editedResult.editedComment = addCommentModeration(editedResult.editedComment); diff --git a/src/stores/accounts/accounts-actions.test.ts b/src/stores/accounts/accounts-actions.test.ts index d42e3a16..109eec35 100644 --- a/src/stores/accounts/accounts-actions.test.ts +++ b/src/stores/accounts/accounts-actions.test.ts @@ -177,6 +177,20 @@ describe("accounts-actions", () => { expect(stale.accountsEditsSummaries.acc1["cid-1"].spoiler.value).toBe(true); }); + test("addStoredAccountEditSummaryToState preserves comment moderation author unban edits", () => { + const result = accountsActions.addStoredAccountEditSummaryToState({} as any, "acc1", { + commentCid: "cid-1", + timestamp: 10, + commentModeration: { author: undefined }, + }); + + expect(result.accountsEditsSummaries.acc1["cid-1"]["commentModeration.author"]).toEqual({ + timestamp: 10, + value: undefined, + }); + expect(result.accountsEditsSummaries.acc1["cid-1"].author).toBeUndefined(); + }); + test("addStoredAccountEditSummaryToState is a no-op when edit has no target", () => { const summaries = { acc1: { existing: { spoiler: { timestamp: 1, value: true } } } }; expect( diff --git a/src/stores/accounts/accounts-actions.ts b/src/stores/accounts/accounts-actions.ts index 5e5c48f5..3e1a3176 100644 --- a/src/stores/accounts/accounts-actions.ts +++ b/src/stores/accounts/accounts-actions.ts @@ -40,6 +40,7 @@ import { getAccountCommunities, getCommentCidsToAccountsComments, getAccountsCommentsIndexes, + COMMENT_MODERATION_AUTHOR_SUMMARY_KEY, getAccountEditPropertySummary, fetchCommentLinkDimensions, getAccountCommentDepth, @@ -247,9 +248,16 @@ const accountEditNonPropertyNames = new Set([ ]); const normalizeStoredAccountEditForSummary = (storedAccountEdit: any) => { - const normalizedEdit = storedAccountEdit.commentModeration - ? { ...storedAccountEdit, ...storedAccountEdit.commentModeration, commentModeration: undefined } - : { ...storedAccountEdit }; + const normalizedEdit = { ...storedAccountEdit }; + const commentModeration = normalizedEdit.commentModeration; + if (commentModeration && typeof commentModeration === "object") { + const { author, ...commentModerationProperties } = commentModeration; + Object.assign(normalizedEdit, commentModerationProperties); + if (Object.prototype.hasOwnProperty.call(commentModeration, "author")) { + normalizedEdit[COMMENT_MODERATION_AUTHOR_SUMMARY_KEY] = author; + } + normalizedEdit.commentModeration = undefined; + } const communityEdit = normalizedEdit.communityEdit ?? normalizedEdit.communityEdit; if (communityEdit && typeof communityEdit === "object") { Object.assign(normalizedEdit, communityEdit); @@ -281,7 +289,8 @@ export const addStoredAccountEditSummaryToState = ( for (const propertyName in normalizedEdit) { if ( - normalizedEdit[propertyName] === undefined || + (normalizedEdit[propertyName] === undefined && + propertyName !== COMMENT_MODERATION_AUTHOR_SUMMARY_KEY) || accountEditNonPropertyNames.has(propertyName) ) { continue; diff --git a/src/stores/accounts/utils.test.ts b/src/stores/accounts/utils.test.ts index 034357b4..974f6aa5 100644 --- a/src/stores/accounts/utils.test.ts +++ b/src/stores/accounts/utils.test.ts @@ -1,4 +1,5 @@ import utils from "./utils"; +import { COMMENT_MODERATION_AUTHOR_SUMMARY_KEY } from "./utils"; import { Role } from "../../types"; import commentsStore from "../comments"; import repliesPagesStore from "../replies-pages"; @@ -336,6 +337,27 @@ describe("accountsStore utils", () => { expect((summary as any).author).toBeUndefined(); }); + test("getAccountEditPropertySummary preserves comment moderation author ban edits", () => { + const summary = utils.getAccountEditPropertySummary([ + { + commentCid: "cid-1", + timestamp: 10, + commentModeration: { author: { banExpiresAt: 20 } }, + }, + { + commentCid: "cid-1", + timestamp: 15, + commentModeration: { author: undefined }, + }, + ] as any); + + expect(summary[COMMENT_MODERATION_AUTHOR_SUMMARY_KEY]).toEqual({ + timestamp: 15, + value: undefined, + }); + expect((summary as any).author).toBeUndefined(); + }); + test("getAccountEditPropertySummary handles undefined input", () => { expect(utils.getAccountEditPropertySummary(undefined as any)).toEqual({}); }); diff --git a/src/stores/accounts/utils.ts b/src/stores/accounts/utils.ts index b13dfe05..a555973a 100644 --- a/src/stores/accounts/utils.ts +++ b/src/stores/accounts/utils.ts @@ -241,10 +241,17 @@ const accountEditNonPropertyNames = new Set([ "timestamp", ]); +export const COMMENT_MODERATION_AUTHOR_SUMMARY_KEY = "commentModeration.author"; + const normalizeAccountEditForSummary = (accountEdit: AccountEdit) => { const normalizedAccountEdit = { ...accountEdit }; - if (normalizedAccountEdit.commentModeration) { - Object.assign(normalizedAccountEdit, normalizedAccountEdit.commentModeration); + const commentModeration = normalizedAccountEdit.commentModeration; + if (commentModeration && typeof commentModeration === "object") { + const { author, ...commentModerationProperties } = commentModeration; + Object.assign(normalizedAccountEdit, commentModerationProperties); + if (Object.prototype.hasOwnProperty.call(commentModeration, "author")) { + normalizedAccountEdit[COMMENT_MODERATION_AUTHOR_SUMMARY_KEY] = author; + } delete normalizedAccountEdit.commentModeration; } const communityEdit = normalizedAccountEdit.communityEdit ?? normalizedAccountEdit.communityEdit; @@ -262,7 +269,8 @@ export const getAccountEditPropertySummary = (accountEdits: AccountEdit[] | unde const normalizedAccountEdit = normalizeAccountEditForSummary(accountEdit); for (const propertyName in normalizedAccountEdit) { if ( - normalizedAccountEdit[propertyName] === undefined || + (normalizedAccountEdit[propertyName] === undefined && + propertyName !== COMMENT_MODERATION_AUTHOR_SUMMARY_KEY) || accountEditNonPropertyNames.has(propertyName) ) { continue;