From 4a85fbce10afb957a9d292c57e70f8ea2f741645 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Thu, 19 Mar 2026 21:53:57 +0800 Subject: [PATCH 1/4] perf(accounts): slim account history storage and query hot paths Preserve full backup compatibility while reducing account history bloat and lookup overhead. --- README.md | 39 ++- src/hooks/accounts/accounts.test.ts | 259 +++++++++++++++ src/hooks/accounts/accounts.ts | 258 +++++++++++---- .../accounts/accounts-actions-internal.ts | 103 ++++-- src/stores/accounts/accounts-actions.test.ts | 180 +++++++++- src/stores/accounts/accounts-actions.ts | 308 ++++++++++++++---- src/stores/accounts/accounts-database.test.ts | 111 +++++++ src/stores/accounts/accounts-database.ts | 230 ++++++++++--- src/stores/accounts/accounts-store.test.ts | 18 + src/stores/accounts/accounts-store.ts | 25 +- src/stores/accounts/utils.test.ts | 109 +++++++ src/stores/accounts/utils.ts | 128 ++++++++ src/types.ts | 31 ++ 13 files changed, 1576 insertions(+), 223 deletions(-) diff --git a/README.md b/README.md index 5473cdaf..4f9d139d 100644 --- a/README.md +++ b/README.md @@ -109,9 +109,9 @@ Run `corepack enable` once per machine so plain `yarn` resolves to the pinned Ya ``` useAccount(): Account | undefined -useAccountComment({commentIndex: string}): Comment // get a pending published comment by its index -useAccountComments({filter: AccountPublicationsFilter}): {accountComments: Comment[]} // export or display list of own comments -useAccountVotes({filter: AccountPublicationsFilter}): {accountVotes: Vote[]} // export or display list of own votes +useAccountComment({commentIndex?: number, commentCid?: string}): Comment // get one own comment by index or cid +useAccountComments({filter?: AccountPublicationsFilter, commentCid?: string, commentIndices?: number[], communityAddress?: string, parentCid?: string, newerThan?: number, page?: number, pageSize?: number, order?: "asc" | "desc"}): {accountComments: Comment[]} // export or display list of own comments +useAccountVotes({filter?: AccountPublicationsFilter, vote?: number, commentCid?: string, communityAddress?: string, newerThan?: number, page?: number, pageSize?: number, order?: "asc" | "desc"}): {accountVotes: Vote[]} // export or display list of own votes useAccountVote({commentCid: string}): Vote // know if you already voted on some comment useAccountEdits({filer: AccountPublicationsFilter}): {accountEdits: AccountEdit[]} useAccountCommunities(): {accountCommunities: {[communityAddress: string]: AccountCommunity}, onlyIfCached?: boolean} @@ -904,11 +904,7 @@ const { accountVotes } = useAccountVotes(); // my own comments in memes.eth const communityAddress = "memes.eth"; -const filter = useCallback( - (comment) => comment.communityAddress === communityAddress, - [communityAddress], -); // important to use useCallback or the same function or will cause rerenders -const myCommentsInMemesEth = useAccountComments({ filter }); +const myCommentsInMemesEth = useAccountComments({ communityAddress }); // my own posts in memes.eth const filter = useCallback( @@ -924,8 +920,31 @@ const myCommentsInSomePost = useAccountComments({ filter }); // my own replies to a comment with cid 'Qm...' const parentCommentCid = "Qm..."; -const filter = useCallback((comment) => comment.parentCid === parentCommentCid, [parentCommentCid]); -const myRepliesToSomeComment = useAccountComments({ filter }); +const myRepliesToSomeComment = useAccountComments({ parentCid: parentCommentCid }); + +// recent own comments in memes.eth, newest first, one page at a time +const recentMyCommentsInMemesEth = useAccountComments({ + communityAddress, + newerThan: 60 * 60 * 24 * 30, + order: "desc", + page: 0, + pageSize: 20, +}); + +// get one own comment directly by cid +const accountComment = useAccountComment({ commentCid: "Qm..." }); + +// get a specific set of own comments by account comment index +const replacementReplies = useAccountComments({ commentIndices: [5, 7, 9] }); + +// voted profile tab helpers +const recentUpvotes = useAccountVotes({ + vote: 1, + newerThan: 60 * 60 * 24 * 30, + order: "desc", + page: 0, + pageSize: 20, +}); // know if you upvoted a comment already with cid 'Qm...' const { vote } = useAccountVote({ commentCid: "Qm..." }); diff --git a/src/hooks/accounts/accounts.test.ts b/src/hooks/accounts/accounts.test.ts index 04cb197f..cb8e505d 100644 --- a/src/hooks/accounts/accounts.test.ts +++ b/src/hooks/accounts/accounts.test.ts @@ -708,6 +708,12 @@ describe("accounts", () => { } }); + test("haveAccountCommentStatesChanged detects stable and changed states", async () => { + const hooks = await import("./accounts"); + expect(hooks.haveAccountCommentStatesChanged(["pending"], ["pending"])).toBe(false); + expect(hooks.haveAccountCommentStatesChanged(["failed"], ["pending"])).toBe(true); + }); + test(`deleteComment(index) removes pending comment, reindexes list, and persists after store reset`, async () => { const publishCommentOptions = { communityAddress, @@ -1302,6 +1308,44 @@ describe("accounts", () => { expect(typeof rendered2.result.current.accountEdits[0].timestamp).toBe("number"); }); + test("useAccountEdits lazily hydrates cold edit history after store reset", async () => { + await waitFor(() => rendered.result.current.accountEdits.length === 1); + await testUtils.resetStores(); + + const rendered2 = renderHook(() => useAccountEdits()); + expect(rendered2.result.current.state).toBe("initializing"); + + await waitFor(() => rendered2.result.current.accountEdits.length === 1); + expect(rendered2.result.current.state).toBe("succeeded"); + expect(rendered2.result.current.accountEdits[0].spoiler).toBe(true); + }); + + test("useAccountEdits logs and keeps initializing when lazy load rejects", async () => { + const store = await import("../../stores/accounts"); + const accountId = store.default.getState().activeAccountId!; + const internal = store.default.getState().accountsActionsInternal; + const mockedEnsure = vi.fn().mockRejectedValueOnce(new Error("lazy load failed")); + store.default.setState({ + accountsEditsLoaded: { + ...store.default.getState().accountsEditsLoaded, + [accountId]: false, + }, + accountsActionsInternal: { + ...internal, + ensureAccountEditsLoaded: mockedEnsure, + }, + }); + + try { + const rendered2 = renderHook(() => useAccountEdits()); + expect(rendered2.result.current.state).toBe("initializing"); + await Promise.resolve(); + expect(mockedEnsure).toHaveBeenCalled(); + } finally { + store.default.setState({ accountsActionsInternal: internal }); + } + }); + test("useAccountEdits with filter", async () => { await waitFor(() => rendered.result.current.accountEdits.length === 1); const filter = (edit: any) => edit.spoiler === true; @@ -1964,6 +2008,181 @@ describe("accounts", () => { expect(rendered.result.current.accountComments[1].parentCid).toBe(undefined); }); + test("useAccountComments supports indexed query helpers and useAccountComment supports commentCid", async () => { + const accountId = accountsStore.getState().activeAccountId!; + accountsStore.setState((state: any) => ({ + ...state, + accountsComments: { + ...state.accountsComments, + [accountId]: state.accountsComments[accountId].map((accountComment: any, index: number) => + index === 0 ? { ...accountComment, cid: "own-comment-cid" } : accountComment, + ), + }, + commentCidsToAccountsComments: { + ...state.commentCidsToAccountsComments, + "own-comment-cid": { accountId, accountCommentIndex: 0 }, + }, + })); + + const rendered2 = renderHook(() => { + const recentCommunityComments = useAccountComments({ + communityAddress: "community address 1", + order: "desc", + pageSize: 1, + }); + const commentByCid = useAccountComments({ commentCid: "own-comment-cid" }); + const indexedComments = useAccountComments({ commentIndices: [1, 0] }); + const parentReplies = useAccountComments({ parentCid: "parent comment cid 1" }); + const accountCommentByCid = useAccountComment({ commentCid: "own-comment-cid" }); + return { + recentCommunityComments, + commentByCid, + indexedComments, + parentReplies, + accountCommentByCid, + }; + }); + + await waitFor( + () => + rendered2.result.current.recentCommunityComments.accountComments.length === 1 && + rendered2.result.current.commentByCid.accountComments.length === 1 && + rendered2.result.current.parentReplies.accountComments.length === 1 && + rendered2.result.current.indexedComments.accountComments.length === 2, + ); + + expect(rendered2.result.current.recentCommunityComments.accountComments[0].parentCid).toBe( + undefined, + ); + expect(rendered2.result.current.commentByCid.accountComments[0].cid).toBe("own-comment-cid"); + expect(rendered2.result.current.indexedComments.accountComments[0].index).toBe(1); + expect(rendered2.result.current.indexedComments.accountComments[1].index).toBe(0); + expect(rendered2.result.current.parentReplies.accountComments[0].parentCid).toBe( + "parent comment cid 1", + ); + expect(rendered2.result.current.accountCommentByCid.cid).toBe("own-comment-cid"); + }); + + test("useAccountComments falls back to full scans when indexes are missing", () => { + const accountId = accountsStore.getState().activeAccountId!; + accountsStore.setState((state: any) => ({ + ...state, + accountsCommentsIndexes: { + ...state.accountsCommentsIndexes, + [accountId]: { byCommunityAddress: {}, byParentCid: {} }, + }, + })); + + const rendered2 = renderHook(() => { + const communityComments = useAccountComments({ communityAddress: "community address 1" }); + const parentReplies = useAccountComments({ parentCid: "parent comment cid 1" }); + return { communityComments, parentReplies }; + }); + + expect(rendered2.result.current.communityComments.accountComments[0].communityAddress).toBe( + "community address 1", + ); + expect(rendered2.result.current.parentReplies.accountComments[0].parentCid).toBe( + "parent comment cid 1", + ); + }); + + test("useAccountComments newerThan and pagination options narrow results", () => { + const now = Math.floor(Date.now() / 1000); + const accountId = accountsStore.getState().activeAccountId!; + accountsStore.setState((state: any) => ({ + ...state, + accountsComments: { + ...state.accountsComments, + [accountId]: state.accountsComments[accountId].map( + (accountComment: any, index: number) => ({ + ...accountComment, + timestamp: now - 300 + index * 100, + }), + ), + }, + })); + + const rendered2 = renderHook(() => + useAccountComments({ newerThan: 150, order: "desc", page: 0, pageSize: 1 }), + ); + + expect(rendered2.result.current.accountComments).toHaveLength(1); + expect(rendered2.result.current.accountComments[0].timestamp).toBe(now - 100); + }); + + test("useAccountComments newerThan Infinity and missing commentCid keep compatibility", () => { + const rendered2 = renderHook(() => { + const allComments = useAccountComments({ newerThan: Infinity }); + const missingComment = useAccountComments({ commentCid: "missing-own-comment-cid" }); + return { allComments, missingComment }; + }); + + expect(rendered2.result.current.allComments.accountComments.length).toBeGreaterThan(0); + expect(rendered2.result.current.missingComment.accountComments).toEqual([]); + }); + + test("useAccountVotes supports additive filters and pagination", () => { + const now = Math.floor(Date.now() / 1000); + const accountId = accountsStore.getState().activeAccountId!; + accountsStore.setState((state: any) => ({ + ...state, + accountsVotes: { + ...state.accountsVotes, + [accountId]: { + "comment cid 1": { + ...state.accountsVotes[accountId]["comment cid 1"], + vote: 1, + communityAddress: "community address 1", + timestamp: now, + }, + "comment cid 2": { + ...state.accountsVotes[accountId]["comment cid 2"], + vote: -1, + communityAddress: "community address 1", + timestamp: now - 100, + }, + "comment cid 3": { + ...state.accountsVotes[accountId]["comment cid 3"], + vote: 1, + communityAddress: "community address 2", + timestamp: now - 200, + }, + }, + }, + })); + + const rendered2 = renderHook(() => + useAccountVotes({ + vote: 1, + communityAddress: "community address 1", + newerThan: 50, + order: "desc", + page: 0, + pageSize: 1, + }), + ); + expect(rendered2.result.current.accountVotes).toHaveLength(1); + expect(rendered2.result.current.accountVotes[0].commentCid).toBe("comment cid 1"); + + const rendered3 = renderHook(() => + useAccountVotes({ order: "desc", page: 1, pageSize: 1 }), + ); + expect(rendered3.result.current.accountVotes).toHaveLength(1); + expect(rendered3.result.current.accountVotes[0].commentCid).toBe("comment cid 2"); + }); + + test("useAccountVotes commentCid and newerThan Infinity keep compatibility", () => { + const rendered2 = renderHook(() => { + const voteByCid = useAccountVotes({ commentCid: "comment cid 1" }); + const allVotes = useAccountVotes({ newerThan: Infinity }); + return { voteByCid, allVotes }; + }); + + expect(rendered2.result.current.voteByCid.accountVotes[0].commentCid).toBe("comment cid 1"); + expect(rendered2.result.current.allVotes.accountVotes.length).toBeGreaterThan(0); + }); + test(`get account vote on a specific comment`, () => { rendered.rerender({ filter: (vote: AccountVote) => vote.commentCid === "comment cid 3", @@ -3422,6 +3641,16 @@ describe("accounts", () => { expect(rendered.result.current.state).toBe("initializing"); }); + test("useEditedComment with missing accountName falls back to initializing", () => { + const rendered = renderHook(() => + useEditedComment({ + accountName: "missing-account", + comment: { cid: "missing-cid" } as any, + }), + ); + expect(rendered.result.current.state).toBe("initializing"); + }); + test("useEditedComment with comment and accountName returns unedited when both present", async () => { const rendered = renderHook(() => useEditedComment({ comment: { cid: "test-cid" }, accountName: "Account 1" }), @@ -3568,5 +3797,35 @@ describe("accounts", () => { previousEthSignature, ); }); + + test("changing author address keeps a non-signer eth wallet untouched", async () => { + const rendered = renderHook(() => useAccount()); + const waitFor = testUtils.createWaitFor(rendered); + + await waitFor(() => rendered.result.current.author.address); + const customWallet = { + ...rendered.result.current.author.wallets.eth, + address: "0x0000000000000000000000000000000000000001", + }; + + await act(async () => { + const author = { + ...rendered.result.current.author, + address: "custom-author.eth", + wallets: { + ...rendered.result.current.author.wallets, + eth: customWallet, + }, + }; + const account = { ...rendered.result.current, author }; + await accountsActions.setAccount(account); + }); + + await waitFor(() => rendered.result.current.author.address === "custom-author.eth"); + expect(rendered.result.current.author.wallets.eth.address).toBe(customWallet.address); + expect(rendered.result.current.author.wallets.eth.signature.signature).toBe( + customWallet.signature.signature, + ); + }); }); }); diff --git a/src/hooks/accounts/accounts.ts b/src/hooks/accounts/accounts.ts index cf6bff00..f394659f 100644 --- a/src/hooks/accounts/accounts.ts +++ b/src/hooks/accounts/accounts.ts @@ -39,6 +39,7 @@ import { useAccountWithCalculatedProperties, useCalculatedNotifications, } from "./utils"; +import { getAccountEditPropertySummary } from "../../stores/accounts/utils"; import { getCanonicalCommunityAddress, getEquivalentCommunityAddressGroupKey, @@ -199,9 +200,7 @@ export function useAccountCommunities( const communities: any = {}; for (const [i, community] of communitiesArray.entries()) { const { groupKey, preferredAddress } = groupedCommunityAddresses[i]; - const canonicalAddress = - canonicalAddressByGroupKey[groupKey] || - getCanonicalCommunityAddress(community?.address || preferredAddress); + const canonicalAddress = canonicalAddressByGroupKey[groupKey]; communities[canonicalAddress] = { ...communities[canonicalAddress], ...community, @@ -218,8 +217,7 @@ export function useAccountCommunities( if (accountsStoreAccountCommunities) { for (const communityAddress in accountsStoreAccountCommunities) { const groupKey = getEquivalentCommunityAddressGroupKey(communityAddress); - const canonicalAddress = - canonicalAddressByGroupKey[groupKey] || getCanonicalCommunityAddress(communityAddress); + const canonicalAddress = canonicalAddressByGroupKey[groupKey]; accountCommunities[canonicalAddress] = { ...accountCommunities[canonicalAddress], ...accountsStoreAccountCommunities[communityAddress], @@ -230,8 +228,7 @@ export function useAccountCommunities( // add plebbit.communities data for (const communityAddress of ownerCommunityAddresses) { const groupKey = getEquivalentCommunityAddressGroupKey(communityAddress); - const canonicalAddress = - canonicalAddressByGroupKey[groupKey] || getCanonicalCommunityAddress(communityAddress); + const canonicalAddress = canonicalAddressByGroupKey[groupKey]; accountCommunities[canonicalAddress] = { ...accountCommunities[canonicalAddress], address: canonicalAddress, @@ -335,17 +332,37 @@ const getAccountCommentsStates = (accountComments: AccountComment[]) => { return states; }; +export const haveAccountCommentStatesChanged = (nextStates: string[], previousStates: string[]) => + nextStates.toString() !== previousStates.toString(); + export function useAccountComments(options?: UseAccountCommentsOptions): UseAccountCommentsResult { assert( !options || typeof options === "object", `useAccountComments options argument '${options}' not an object`, ); - const { accountName, filter } = options || {}; + const { + accountName, + filter, + commentCid, + commentIndices, + communityAddress, + parentCid, + newerThan, + page, + pageSize, + order = "asc", + } = options || {}; assert( !filter || typeof filter === "function", `useAccountComments options.filter argument '${filter}' not an function`, ); const accountId = useAccountId(accountName); + const accountCommentsIndexes = useAccountsStore( + (state) => state.accountsCommentsIndexes[accountId || ""], + ); + const commentCidToAccountComment = useAccountsStore( + (state) => state.commentCidsToAccountsComments[commentCid || ""], + ); const accountComments = useAccountsStore((state) => state.accountsComments[accountId || ""]); const [accountCommentStates, setAccountCommentStates] = useState([]); @@ -353,11 +370,70 @@ export function useAccountComments(options?: UseAccountCommentsOptions): UseAcco if (!accountComments) { return []; } + + let scopedAccountComments = accountComments; + if (Array.isArray(commentIndices) && commentIndices.length > 0) { + const normalizedCommentIndices = commentIndices + .map((commentIndex) => Number(commentIndex)) + .filter((commentIndex) => Number.isInteger(commentIndex) && commentIndex >= 0); + scopedAccountComments = normalizedCommentIndices + .map((commentIndex) => accountComments[commentIndex]) + .filter(Boolean); + } else if (commentCid) { + const mappedIndex = + commentCidToAccountComment?.accountId === accountId + ? commentCidToAccountComment.accountCommentIndex + : undefined; + scopedAccountComments = + typeof mappedIndex === "number" ? [accountComments[mappedIndex]].filter(Boolean) : []; + } else if (parentCid) { + const parentIndexes = accountCommentsIndexes?.byParentCid?.[parentCid]; + scopedAccountComments = parentIndexes?.length + ? parentIndexes.map((index) => accountComments[index]).filter(Boolean) + : accountComments.filter((accountComment) => accountComment.parentCid === parentCid); + } else if (communityAddress) { + const communityIndexes = accountCommentsIndexes?.byCommunityAddress?.[communityAddress]; + scopedAccountComments = communityIndexes?.length + ? communityIndexes.map((index) => accountComments[index]).filter(Boolean) + : accountComments.filter( + (accountComment) => accountComment.communityAddress === communityAddress, + ); + } + + if (typeof newerThan === "number") { + const newerThanTimestamp = + newerThan === Infinity ? 0 : Math.floor(Date.now() / 1000) - newerThan; + scopedAccountComments = scopedAccountComments.filter( + (accountComment) => accountComment.timestamp > newerThanTimestamp, + ); + } if (filter) { - return accountComments.filter(filter); + scopedAccountComments = scopedAccountComments.filter(filter); + } + if (order === "desc") { + scopedAccountComments = [...scopedAccountComments].reverse(); + } + if (typeof pageSize === "number" && pageSize > 0) { + const pageNumber = Math.max(page || 0, 0); + const startIndex = pageNumber * pageSize; + return scopedAccountComments.slice(startIndex, startIndex + pageSize); } - return accountComments; - }, [accountComments, filter]); + return scopedAccountComments; + }, [ + accountComments, + accountCommentsIndexes, + accountId, + commentCid, + commentIndices, + commentCidToAccountComment, + communityAddress, + filter, + newerThan, + order, + page, + pageSize, + parentCid, + ]); // recheck the states for changes every 1 minute because succeeded / failed / pending aren't events, they are time elapsed const delay = 60_000; @@ -365,7 +441,7 @@ export function useAccountComments(options?: UseAccountCommentsOptions): UseAcco useInterval( () => { const states = getAccountCommentsStates(filteredAccountComments); - if (states.toString() !== accountCommentStates.toString()) { + if (haveAccountCommentStatesChanged(states, accountCommentStates)) { setAccountCommentStates(states); } }, @@ -377,7 +453,7 @@ export function useAccountComments(options?: UseAccountCommentsOptions): UseAcco const states = getAccountCommentsStates(filteredAccountComments); return filteredAccountComments.map((comment, i) => ({ ...comment, - state: states[i] || "initializing", + state: states[i], })); }, [filteredAccountComments, accountCommentStates]); @@ -386,7 +462,15 @@ export function useAccountComments(options?: UseAccountCommentsOptions): UseAcco accountId, filteredAccountCommentsWithStates, accountComments, + commentCid, + commentIndices, + communityAddress, filter, + newerThan, + order, + page, + pageSize, + parentCid, }); } @@ -412,12 +496,25 @@ export function useAccountComment(options?: UseAccountCommentOptions): UseAccoun `useAccountComment options argument '${options}' not an object`, ); const opts = options ?? {}; - const { commentIndex, accountName } = opts; - const { accountComments } = useAccountComments({ accountName }); - const accountComment = useMemo( - () => accountComments?.[Number(commentIndex)] || {}, - [accountComments, commentIndex], + const { commentIndex, commentCid, accountName } = opts; + const accountId = useAccountId(accountName); + const commentCidToAccountComment = useAccountsStore( + (state) => state.commentCidsToAccountsComments[commentCid || ""], ); + const accountComments = useAccountsStore((state) => state.accountsComments[accountId || ""]); + const normalizedCommentIndex = commentIndex === undefined ? undefined : Number(commentIndex); + const resolvedCommentIndex = + typeof normalizedCommentIndex === "number" && !Number.isNaN(normalizedCommentIndex) + ? normalizedCommentIndex + : commentCidToAccountComment?.accountId === accountId + ? commentCidToAccountComment.accountCommentIndex + : undefined; + const accountComment: any = useMemo(() => { + if (typeof resolvedCommentIndex !== "number") { + return {}; + } + return accountComments?.[resolvedCommentIndex] || {}; + }, [accountComments, resolvedCommentIndex]); const state = accountComment.state || "initializing"; return useMemo( @@ -441,7 +538,17 @@ export function useAccountVotes(options?: UseAccountVotesOptions): UseAccountVot `useAccountVotes options argument '${options}' not an object`, ); const opts = options ?? {}; - const { accountName, filter } = opts; + const { + accountName, + filter, + vote, + commentCid, + communityAddress, + newerThan, + page, + pageSize, + order = "asc", + } = opts; assert( !filter || typeof filter === "function", `useAccountVotes options.filter argument '${filter}' not an function`, @@ -457,14 +564,54 @@ export function useAccountVotes(options?: UseAccountVotesOptions): UseAccountVot for (const i in accountVotes) { accountVotesArray.push(accountVotes[i]); } + if (typeof vote === "number") { + accountVotesArray = accountVotesArray.filter((accountVote) => accountVote.vote === vote); + } + if (commentCid) { + accountVotesArray = accountVotesArray.filter( + (accountVote) => accountVote.commentCid === commentCid, + ); + } + if (communityAddress) { + accountVotesArray = accountVotesArray.filter( + (accountVote) => accountVote.communityAddress === communityAddress, + ); + } + if (typeof newerThan === "number") { + const newerThanTimestamp = + newerThan === Infinity ? 0 : Math.floor(Date.now() / 1000) - newerThan; + accountVotesArray = accountVotesArray.filter( + (accountVote) => accountVote.timestamp > newerThanTimestamp, + ); + } if (filter) { accountVotesArray = accountVotesArray.filter(filter); } + if (order === "desc") { + accountVotesArray.reverse(); + } + if (typeof pageSize === "number" && pageSize > 0) { + const pageNumber = Math.max(page || 0, 0); + const startIndex = pageNumber * pageSize; + accountVotesArray = accountVotesArray.slice(startIndex, startIndex + pageSize); + } return accountVotesArray; - }, [accountVotes, filter]); + }, [accountVotes, commentCid, communityAddress, filter, newerThan, order, page, pageSize, vote]); - if (accountVotes && filter) { - log("useAccountVotes", { accountId, filteredAccountVotesArray, accountVotes, filter }); + if (accountVotes && options) { + log("useAccountVotes", { + accountId, + filteredAccountVotesArray, + accountVotes, + commentCid, + communityAddress, + filter, + newerThan, + order, + page, + pageSize, + vote, + }); } // TODO: add failed / pending states @@ -527,11 +674,23 @@ export function useAccountEdits(options?: UseAccountEditsOptions): UseAccountEdi `useAccountEdits options.filter argument '${filter}' not an function`, ); const accountId = useAccountId(accountName); + const accountActionsInternal = useAccountsStore((state) => state.accountsActionsInternal); const accountEdits = useAccountsStore((state) => state.accountsEdits[accountId || ""]); + const accountEditsLoaded = useAccountsStore( + (state) => state.accountsEditsLoaded[accountId || ""], + ); + + if (accountId && !accountEditsLoaded) { + accountActionsInternal + .ensureAccountEditsLoaded(accountId) + .catch((error: unknown) => + log.error("useAccountEdits ensureAccountEditsLoaded error", { accountId, error }), + ); + } const accountEditsArray = useMemo(() => { const accountEditsArray = []; - for (const i in accountEdits) { + for (const i in accountEdits || {}) { accountEditsArray.push(...accountEdits[i]); } // sort by oldest first @@ -547,7 +706,7 @@ export function useAccountEdits(options?: UseAccountEditsOptions): UseAccountEdi // TODO: add failed / pending states - const state = accountId ? "succeeded" : "initializing"; + const state = accountId ? (accountEditsLoaded ? "succeeded" : "initializing") : "initializing"; return useMemo( () => ({ @@ -576,6 +735,9 @@ export function useEditedComment(options?: UseEditedCommentOptions): UseEditedCo const commentEdits = useAccountsStore( (state) => state.accountsEdits[accountIdKey]?.[commentCidKey], ); + const commentEditSummary = useAccountsStore( + (state) => state.accountsEditsSummaries[accountIdKey]?.[commentCidKey], + ); let initialState = "initializing"; if (accountId && comment && comment.cid) { @@ -592,49 +754,11 @@ export function useEditedComment(options?: UseEditedCommentOptions): UseEditedCo }; // there are no edits - if (!commentEdits?.length) { - return editedResult; - } - - // don't include these props as they are not edit props, they are publication props - const nonEditPropertyNames = new Set([ - "author", - "signer", - "clientId", - "commentCid", - "communityAddress", - "subplebbitAddress", - "timestamp", - ]); - - // iterate over commentEdits and consolidate them into 1 propertyNameEdits object - const propertyNameEdits: any = {}; - for (let commentEdit of commentEdits) { - // TODO: commentEdit and commentModeration are now separate, but both still in accountEdits store - // merge them until we find a better design - let editToUse: any = commentEdit; - if (commentEdit.commentModeration) { - editToUse = { ...commentEdit, ...commentEdit.commentModeration }; - delete editToUse.commentModeration; - } + const propertyNameEdits: any = + commentEdits?.length > 0 ? getAccountEditPropertySummary(commentEdits) : commentEditSummary; - for (const propertyName in editToUse) { - // not valid edited properties - if (editToUse[propertyName] === undefined || nonEditPropertyNames.has(propertyName)) { - continue; - } - const previousTimestamp = propertyNameEdits[propertyName]?.timestamp || 0; - // only use the latest propertyNameEdit timestamp - if (editToUse.timestamp > previousTimestamp) { - propertyNameEdits[propertyName] = { - timestamp: editToUse.timestamp, - value: editToUse[propertyName], - // NOTE: don't use comment edit challengeVerification.challengeSuccess - // to know if an edit has failed or succeeded, since another mod can also edit - // if another mod overrides an edit, consider the edit failed - }; - } - } + if (!propertyNameEdits || Object.keys(propertyNameEdits).length === 0) { + return editedResult; } const now = Math.round(Date.now() / 1000); @@ -729,7 +853,7 @@ export function useEditedComment(options?: UseEditedCommentOptions): UseEditedCo editedResult.editedComment = addCommentModeration(editedResult.editedComment); return editedResult; - }, [comment, commentEdits]); + }, [comment, commentEditSummary, commentEdits]); return useMemo( () => ({ diff --git a/src/stores/accounts/accounts-actions-internal.ts b/src/stores/accounts/accounts-actions-internal.ts index ba8f10ca..f6d1d6d3 100644 --- a/src/stores/accounts/accounts-actions-internal.ts +++ b/src/stores/accounts/accounts-actions-internal.ts @@ -7,7 +7,6 @@ import assert from "assert"; const log = Logger("bitsocial-react-hooks:accounts:stores"); import { Account, - PublishCommentOptions, AccountCommentReply, Comment, AccountsComments, @@ -21,7 +20,13 @@ import { normalizePublicationOptionsForPlebbit, normalizePublicationOptionsForStore, } from "../../lib/plebbit-compat"; -import { addShortAddressesToAccountComment } from "./utils"; +import { + addShortAddressesToAccountComment, + getAccountsCommentsIndexes, + sanitizeStoredAccountComment, +} from "./utils"; + +const accountEditsLoadPromises = new Map>(); const backfillLiveCommentCommunityAddress = ( comment: Comment | undefined, @@ -133,35 +138,15 @@ export const startUpdatingAccountCommentOnCommentUpdateEvents = async ( getCommentCommunityAddress(commentArgument) || storedComment?.communityAddress || storedComment?.subplebbitAddress; - updatedComment = addShortAddressesToAccountComment( + const normalizedUpdatedComment = addShortAddressesToAccountComment( normalizePublicationOptionsForStore(updatedComment) as Comment, ) as Comment; - if (updatedComment.replies?.pages) { - updatedComment = { - ...updatedComment, - replies: { - ...updatedComment.replies, - pages: Object.fromEntries( - Object.entries(updatedComment.replies.pages).map(([pageCid, page]: [string, any]) => [ - pageCid, - page?.comments - ? { - ...page, - comments: page.comments.map((reply: any) => - normalizePublicationOptionsForStore(reply), - ), - } - : page, - ]), - ), - }, - } as Comment; - } - await accountsDatabase.addAccountComment(account.id, updatedComment, currentIndex); + const storedUpdatedComment = sanitizeStoredAccountComment(normalizedUpdatedComment) as Comment; + await accountsDatabase.addAccountComment(account.id, storedUpdatedComment, currentIndex); log("startUpdatingAccountCommentOnCommentUpdateEvents comment update", { commentCid: comment.cid, accountCommentIndex: currentIndex, - updatedComment, + updatedComment: storedUpdatedComment, account, }); accountsStore.setState(({ accountsComments }) => { @@ -178,16 +163,26 @@ export const startUpdatingAccountCommentOnCommentUpdateEvents = async ( const updatedAccountComments = [...accountsComments[account.id]]; const previousComment = updatedAccountComments[currentIndex]; const updatedAccountComment = utils.clone({ - ...updatedComment, + ...storedUpdatedComment, index: currentIndex, accountId: account.id, }); updatedAccountComments[currentIndex] = updatedAccountComment; - return { accountsComments: { ...accountsComments, [account.id]: updatedAccountComments } }; + const nextAccountsComments = { + ...accountsComments, + [account.id]: updatedAccountComments, + }; + return { + accountsComments: nextAccountsComments, + accountsCommentsIndexes: { + ...accountsStore.getState().accountsCommentsIndexes, + [account.id]: getAccountsCommentsIndexes(nextAccountsComments)[account.id], + }, + }; }); // update AccountCommentsReplies with new replies if has any new replies - const replyPageArray: any[] = Object.values(updatedComment.replies?.pages || {}); + const replyPageArray: any[] = Object.values(normalizedUpdatedComment.replies?.pages || {}); const getReplyCount = (replyPage: any) => replyPage?.comments?.length ?? 0; const replyCount = replyPageArray.length > 0 @@ -195,7 +190,7 @@ export const startUpdatingAccountCommentOnCommentUpdateEvents = async ( : 0; const hasReplies = replyCount > 0; const repliesAreValid = await utils.repliesAreValid( - updatedComment, + normalizedUpdatedComment, { validateReplies: false, blockCommunity: true }, account.plebbit, ); @@ -216,11 +211,19 @@ export const startUpdatingAccountCommentOnCommentUpdateEvents = async ( const updatedAccountCommentsReplies: { [replyCid: string]: AccountCommentReply } = {}; for (const replyPage of replyPageArray) { for (const reply of replyPage?.comments || []) { + const normalizedReply = normalizePublicationOptionsForStore(reply) as Comment; + normalizedReply.communityAddress = + getCommentCommunityAddress(normalizedReply) || + normalizedUpdatedComment.communityAddress || + storedUpdatedComment.communityAddress; const markedAsRead = accountsCommentsReplies[account.id]?.[reply.cid]?.markedAsRead === true ? true : false; - updatedAccountCommentsReplies[reply.cid] = { ...reply, markedAsRead }; + updatedAccountCommentsReplies[normalizedReply.cid] = { + ...normalizedReply, + markedAsRead, + }; } } @@ -286,6 +289,11 @@ export const addCidToAccountComment = async (comment: Comment) => { }; return { accountsComments: newAccountsComments, + accountsCommentsIndexes: { + ...accountsStore.getState().accountsCommentsIndexes, + [accountComment.accountId]: + getAccountsCommentsIndexes(newAccountsComments)[accountComment.accountId], + }, commentCidsToAccountsComments: { ...commentCidsToAccountsComments, [comment.cid]: { @@ -354,6 +362,39 @@ const getAccountsCommentsWithoutCids = () => { return accountsCommentsWithoutCids; }; +export const ensureAccountEditsLoaded = async (accountId: string) => { + assert( + accountId && typeof accountId === "string", + `ensureAccountEditsLoaded invalid '${accountId}'`, + ); + + if (accountsStore.getState().accountsEditsLoaded[accountId]) { + return; + } + const existingPromise = accountEditsLoadPromises.get(accountId); + if (existingPromise) { + return existingPromise; + } + + const loadPromise = accountsDatabase + .getAccountEdits(accountId) + .then((accountEdits) => { + accountsStore.setState(({ accountsEdits, accountsEditsLoaded }) => ({ + accountsEdits: { ...accountsEdits, [accountId]: accountEdits }, + accountsEditsLoaded: { ...accountsEditsLoaded, [accountId]: true }, + })); + }) + .finally(() => { + accountEditsLoadPromises.delete(accountId); + }); + accountEditsLoadPromises.set(accountId, loadPromise); + return loadPromise; +}; + +export const resetLazyAccountHistoryLoaders = () => { + accountEditsLoadPromises.clear(); +}; + // internal accounts action: mark an account's notifications as read export const markNotificationsAsRead = async (account: Account) => { const { accountsCommentsReplies } = accountsStore.getState(); diff --git a/src/stores/accounts/accounts-actions.test.ts b/src/stores/accounts/accounts-actions.test.ts index d66cd772..ef421b81 100644 --- a/src/stores/accounts/accounts-actions.test.ts +++ b/src/stores/accounts/accounts-actions.test.ts @@ -283,6 +283,109 @@ describe("accounts-actions", () => { testUtils.restoreAll(); }); + describe("summary helpers", () => { + test("addStoredAccountEditSummaryToState initializes missing account summary and keeps newer values", () => { + const initial = accountsActions.addStoredAccountEditSummaryToState({} as any, "acc1", { + commentCid: "cid-1", + spoiler: true, + }); + expect(initial.accountsEditsSummaries.acc1["cid-1"].spoiler.value).toBe(true); + + const stale = accountsActions.addStoredAccountEditSummaryToState( + initial.accountsEditsSummaries as any, + "acc1", + { + commentCid: "cid-1", + spoiler: false, + timestamp: -1, + }, + ); + expect(stale.accountsEditsSummaries.acc1["cid-1"].spoiler.value).toBe(true); + }); + + test("addStoredAccountEditSummaryToState is a no-op when edit has no target", () => { + const summaries = { acc1: { existing: { spoiler: { timestamp: 1, value: true } } } }; + expect( + accountsActions.addStoredAccountEditSummaryToState(summaries as any, "acc1", { + timestamp: 2, + spoiler: false, + }), + ).toEqual({ accountsEditsSummaries: summaries }); + }); + + test("removeStoredAccountEditSummaryFromState removes target when last summary disappears", () => { + const result = accountsActions.removeStoredAccountEditSummaryFromState( + { acc1: { "cid-1": { spoiler: { timestamp: 1, value: true } } } } as any, + { acc1: {} } as any, + "acc1", + { commentCid: "cid-1" }, + ); + expect(result.accountsEditsSummaries.acc1["cid-1"]).toBeUndefined(); + }); + + test("removeStoredAccountEditSummaryFromState is a no-op when edit has no target", () => { + const summaries = { acc1: { "cid-1": { spoiler: { timestamp: 1, value: true } } } }; + expect( + accountsActions.removeStoredAccountEditSummaryFromState( + summaries as any, + { acc1: {} } as any, + "acc1", + { spoiler: true }, + ), + ).toEqual({ accountsEditsSummaries: summaries }); + }); + + test("removeStoredAccountEditSummaryFromState handles missing account summary", () => { + const result = accountsActions.removeStoredAccountEditSummaryFromState( + {} as any, + { acc1: { "cid-1": [{ commentCid: "cid-1", spoiler: true, timestamp: 1 }] } } as any, + "acc1", + { commentCid: "cid-1" }, + ); + expect(result.accountsEditsSummaries.acc1["cid-1"].spoiler.value).toBe(true); + }); + }); + + describe("edit helper branches", () => { + test("maybeUpdateAccountComment handles missing account bucket", () => { + const result = accountsActions.maybeUpdateAccountComment({}, "acc1", 0, () => {}); + expect(result).toEqual({}); + }); + + test("doesStoredAccountEditMatch falls back to deep equality without clientId", () => { + const nextState = accountsActions.removeStoredAccountEditFromState( + { acc1: { "cid-1": [{ commentCid: "cid-1", spoiler: true, timestamp: 1 }] } } as any, + "acc1", + { commentCid: "cid-1", spoiler: true, timestamp: 1 }, + ); + expect(nextState.accountsEdits.acc1["cid-1"]).toBeUndefined(); + }); + + test("addStoredAccountEditToState initializes missing account edit buckets", () => { + const nextState = accountsActions.addStoredAccountEditToState({} as any, "acc1", { + commentCid: "cid-1", + spoiler: true, + }); + expect(nextState.accountsEdits.acc1["cid-1"][0].spoiler).toBe(true); + }); + + test("removeStoredAccountEditFromState handles missing account and comment buckets", () => { + const nextState = accountsActions.removeStoredAccountEditFromState({} as any, "acc1", { + commentCid: "cid-1", + }); + expect(nextState.accountsEdits.acc1).toEqual({}); + }); + + test("hasTerminalChallengeVerificationError accepts array challengeErrors", () => { + expect( + accountsActions.hasTerminalChallengeVerificationError({ + challengeSuccess: false, + challengeErrors: ["boom"], + }), + ).toBe(true); + }); + }); + describe("optional accountName branches", () => { beforeEach(async () => { await testUtils.resetDatabasesAndStores(); @@ -1329,18 +1432,48 @@ describe("accounts-actions", () => { await expect(accountsActions.subscribe("sub1.eth")).rejects.toThrow("already subscribed"); }); + test("subscribe initializes undefined subscriptions", async () => { + const account = Object.values(accountsStore.getState().accounts)[0] as any; + accountsStore.setState(({ accounts }) => ({ + accounts: { + ...accounts, + [account.id]: { ...account, subscriptions: undefined }, + }, + })); + + await act(async () => { + await accountsActions.subscribe("sub-init.eth"); + }); + + expect(accountsStore.getState().accounts[account.id].subscriptions).toContain("sub-init.eth"); + }); + test("unsubscribe already unsubscribed throws", async () => { await expect(accountsActions.unsubscribe("never-subscribed.eth")).rejects.toThrow( "already unsubscribed", ); }); + test("unsubscribe handles undefined subscriptions", async () => { + const account = Object.values(accountsStore.getState().accounts)[0] as any; + accountsStore.setState(({ accounts }) => ({ + accounts: { + ...accounts, + [account.id]: { ...account, subscriptions: undefined }, + }, + })); + + await expect(accountsActions.unsubscribe("never-subscribed.eth")).rejects.toThrow( + "already unsubscribed", + ); + }); + test("abandonAndStopPublishSession when comment has no stop: skips stop (branch 58)", async () => { const account = Object.values(accountsStore.getState().accounts)[0]; const origCreateComment = account.plebbit.createComment.bind(account.plebbit); vi.spyOn(account.plebbit, "createComment").mockImplementation(async (opts: any) => { const c = await origCreateComment(opts); - delete (c as any).stop; + (c as any).stop = undefined; return c; }); @@ -1404,6 +1537,15 @@ describe("accounts-actions", () => { }); test("error handler no-op when session abandoned", async () => { + let commentRef: any; + const account = Object.values(accountsStore.getState().accounts)[0]; + const origCreate = account.plebbit.createComment.bind(account.plebbit); + vi.spyOn(account.plebbit, "createComment").mockImplementation(async (opts: any) => { + const c = await origCreate(opts); + commentRef = c; + return c; + }); + const rendered = renderHook(() => { const { accountsComments, activeAccountId } = accountsStore.getState(); const comments = @@ -1432,10 +1574,24 @@ describe("accounts-actions", () => { await accountsActions.deleteComment(0); }); + commentRef?.listeners("error")?.[0]?.(new Error("abandoned error")); + commentRef?.listeners("publishingstatechange")?.[0]?.("abandoned"); await new Promise((r) => setTimeout(r, 50)); expect(rendered.result.current.comments?.length).toBe(0); }); + test("deleteComment handles missing account comments bucket", async () => { + const account = Object.values(accountsStore.getState().accounts)[0]; + accountsStore.setState(({ accountsComments }) => ({ + accountsComments: { + ...accountsComments, + [account.id]: undefined as any, + }, + })); + + await expect(accountsActions.deleteComment(0)).rejects.toThrow("no comments for account"); + }); + test("publishComment error handler no-op when accountComment not in state yet", async () => { const account = Object.values(accountsStore.getState().accounts)[0]; let commentRef: any; @@ -1511,6 +1667,28 @@ describe("accounts-actions", () => { expect(onPublishingStateChange).toHaveBeenCalled(); }); + test("publishCommentModeration initializes missing account edits bucket", async () => { + const account = Object.values(accountsStore.getState().accounts)[0]; + accountsStore.setState(({ accountsEdits }) => ({ + accountsEdits: { + ...accountsEdits, + [account.id]: undefined as any, + }, + })); + + await act(async () => { + await accountsActions.publishCommentModeration({ + communityAddress: "sub.eth", + commentCid: "cid", + commentModeration: { removed: true }, + onChallenge: (challenge: any, moderation: any) => moderation.publishChallengeAnswers(), + onChallengeVerification: () => {}, + } as any); + }); + + expect(accountsStore.getState().accountsEdits[account.id].cid).toHaveLength(1); + }); + test("publishComment with link fetches dimensions and onPublishingStateChange", async () => { const utilsMod = await import("./utils"); vi.spyOn(utilsMod, "fetchCommentLinkDimensions").mockResolvedValue({ diff --git a/src/stores/accounts/accounts-actions.ts b/src/stores/accounts/accounts-actions.ts index 256d2589..fe0ad206 100644 --- a/src/stores/accounts/accounts-actions.ts +++ b/src/stores/accounts/accounts-actions.ts @@ -33,11 +33,15 @@ import { normalizePublicationOptionsForPlebbit, } from "../../lib/plebbit-compat"; import { + getAccountCommentsIndex, getAccountCommunities, getCommentCidsToAccountsComments, + getAccountsCommentsIndexes, + getAccountEditPropertySummary, fetchCommentLinkDimensions, getAccountCommentDepth, addShortAddressesToAccountComment, + sanitizeStoredAccountComment, } from "./utils"; import isEqual from "lodash.isequal"; import { v4 as uuid } from "uuid"; @@ -123,19 +127,108 @@ const cleanupPublishSessionOnTerminal = (accountId: string, index: number) => { abandonedPublishKeys.delete(key); }; -const doesStoredAccountEditMatch = (storedAccountEdit: any, targetStoredAccountEdit: any) => +export const doesStoredAccountEditMatch = (storedAccountEdit: any, targetStoredAccountEdit: any) => storedAccountEdit?.clientId && targetStoredAccountEdit?.clientId ? storedAccountEdit.clientId === targetStoredAccountEdit.clientId : isEqual(storedAccountEdit, targetStoredAccountEdit); -const sanitizeStoredAccountEdit = (storedAccountEdit: any) => { +export const sanitizeStoredAccountEdit = (storedAccountEdit: any) => { const sanitizedStoredAccountEdit = { ...storedAccountEdit }; delete sanitizedStoredAccountEdit.signer; delete sanitizedStoredAccountEdit.author; return sanitizedStoredAccountEdit; }; -const hasTerminalChallengeVerificationError = (challengeVerification: any) => { +const accountEditNonPropertyNames = new Set([ + "author", + "signer", + "clientId", + "commentCid", + "communityAddress", + "subplebbitAddress", + "timestamp", +]); + +export const addStoredAccountEditSummaryToState = ( + accountsEditsSummaries: Record>, + accountId: string, + storedAccountEdit: any, +) => { + const editTarget = + storedAccountEdit.commentCid || + storedAccountEdit.communityAddress || + storedAccountEdit.subplebbitAddress; + if (!editTarget) { + return { accountsEditsSummaries }; + } + + const accountEditsSummary = accountsEditsSummaries[accountId] || {}; + const targetSummary = accountEditsSummary[editTarget] || {}; + const nextSummary = { ...targetSummary }; + const normalizedEdit = storedAccountEdit.commentModeration + ? { ...storedAccountEdit, ...storedAccountEdit.commentModeration, commentModeration: undefined } + : storedAccountEdit; + + for (const propertyName in normalizedEdit) { + if ( + normalizedEdit[propertyName] === undefined || + accountEditNonPropertyNames.has(propertyName) + ) { + continue; + } + const previousTimestamp = nextSummary[propertyName]?.timestamp || 0; + if ((normalizedEdit.timestamp || 0) >= previousTimestamp) { + nextSummary[propertyName] = { + timestamp: normalizedEdit.timestamp, + value: normalizedEdit[propertyName], + }; + } + } + + return { + accountsEditsSummaries: { + ...accountsEditsSummaries, + [accountId]: { + ...accountEditsSummary, + [editTarget]: nextSummary, + }, + }, + }; +}; + +export const removeStoredAccountEditSummaryFromState = ( + accountsEditsSummaries: Record>, + accountsEdits: Record>, + accountId: string, + storedAccountEdit: any, +) => { + const editTarget = + storedAccountEdit.commentCid || + storedAccountEdit.communityAddress || + storedAccountEdit.subplebbitAddress; + if (!editTarget) { + return { accountsEditsSummaries }; + } + + const nextTargetSummary = getAccountEditPropertySummary( + accountsEdits[accountId]?.[editTarget] || [], + ); + const nextAccountSummary = { ...(accountsEditsSummaries[accountId] || {}) }; + if (Object.keys(nextTargetSummary).length > 0) { + nextAccountSummary[editTarget] = nextTargetSummary; + } else { + delete nextAccountSummary[editTarget]; + } + + return { + accountsEditsSummaries: { + ...accountsEditsSummaries, + [accountId]: nextAccountSummary, + }, + }; +}; + +export const hasTerminalChallengeVerificationError = (challengeVerification: any) => { const challengeErrors = challengeVerification?.challengeErrors; const hasChallengeErrors = Array.isArray(challengeErrors) ? challengeErrors.length > 0 @@ -149,7 +242,7 @@ const hasTerminalChallengeVerificationError = (challengeVerification: any) => { ); }; -const addStoredAccountEditToState = ( +export const addStoredAccountEditToState = ( accountsEdits: Record>, accountId: string, storedAccountEdit: any, @@ -167,7 +260,7 @@ const addStoredAccountEditToState = ( }; }; -const removeStoredAccountEditFromState = ( +export const removeStoredAccountEditFromState = ( accountsEdits: Record>, accountId: string, storedAccountEdit: any, @@ -213,16 +306,30 @@ const addNewAccountToDatabaseAndState = async (newAccount: Account) => { ]); // set the new state - const { accounts, accountsComments, accountsVotes, accountsEdits, accountsCommentsReplies } = - accountsStore.getState(); + const { + accounts, + accountsComments, + accountsCommentsIndexes, + accountsVotes, + accountsEdits, + accountsEditsSummaries, + accountsEditsLoaded, + accountsCommentsReplies, + } = accountsStore.getState(); const newAccounts = { ...accounts, [newAccount.id]: newAccount }; const newState: any = { accounts: newAccounts, accountIds: newAccountIds, accountNamesToAccountIds: newAccountNamesToAccountIds, accountsComments: { ...accountsComments, [newAccount.id]: [] }, + accountsCommentsIndexes: { + ...accountsCommentsIndexes, + [newAccount.id]: getAccountCommentsIndex([]), + }, accountsVotes: { ...accountsVotes, [newAccount.id]: {} }, accountsEdits: { ...accountsEdits, [newAccount.id]: {} }, + accountsEditsSummaries: { ...accountsEditsSummaries, [newAccount.id]: {} }, + accountsEditsLoaded: { ...accountsEditsLoaded, [newAccount.id]: false }, accountsCommentsReplies: { ...accountsCommentsReplies, [newAccount.id]: {} }, }; // if there is only 1 account, make it active @@ -243,8 +350,17 @@ export const createAccount = async (accountName?: string) => { }; export const deleteAccount = async (accountName?: string) => { - const { accounts, accountNamesToAccountIds, activeAccountId, accountsComments, accountsVotes } = - accountsStore.getState(); + const { + accounts, + accountNamesToAccountIds, + activeAccountId, + accountsComments, + accountsCommentsIndexes, + accountsVotes, + accountsEdits, + accountsEditsSummaries, + accountsEditsLoaded, + } = accountsStore.getState(); assert( accounts && accountNamesToAccountIds && activeAccountId, `can't use accountsStore.accountActions before initialized`, @@ -268,8 +384,16 @@ export const deleteAccount = async (accountName?: string) => { ]); const newAccountsComments = { ...accountsComments }; delete newAccountsComments[account.id]; + const newAccountsCommentsIndexes = { ...accountsCommentsIndexes }; + delete newAccountsCommentsIndexes[account.id]; const newAccountsVotes = { ...accountsVotes }; delete newAccountsVotes[account.id]; + const newAccountsEdits = { ...accountsEdits }; + delete newAccountsEdits[account.id]; + const newAccountsEditsSummaries = { ...accountsEditsSummaries }; + delete newAccountsEditsSummaries[account.id]; + const newAccountsEditsLoaded = { ...accountsEditsLoaded }; + delete newAccountsEditsLoaded[account.id]; accountsStore.setState({ accounts: newAccounts, @@ -277,7 +401,11 @@ export const deleteAccount = async (accountName?: string) => { activeAccountId: newActiveAccountId, accountNamesToAccountIds: newAccountNamesToAccountIds, accountsComments: newAccountsComments, + accountsCommentsIndexes: newAccountsCommentsIndexes, accountsVotes: newAccountsVotes, + accountsEdits: newAccountsEdits, + accountsEditsSummaries: newAccountsEditsSummaries, + accountsEditsLoaded: newAccountsEditsLoaded, }); }; @@ -413,26 +541,40 @@ export const importAccount = async (serializedAccount: string) => { // set new state // get new state data from database because it's easier - const [accountComments, accountVotes, accountEdits, accountIds, newAccountNamesToAccountIds] = - await Promise.all([ - accountsDatabase.getAccountComments(newAccount.id), - accountsDatabase.getAccountVotes(newAccount.id), - accountsDatabase.getAccountEdits(newAccount.id), - accountsDatabase.accountsMetadataDatabase.getItem("accountIds"), - accountsDatabase.accountsMetadataDatabase.getItem("accountNamesToAccountIds"), - ]); + const [ + accountComments, + accountVotes, + accountEditsSummary, + accountIds, + newAccountNamesToAccountIds, + ] = await Promise.all([ + accountsDatabase.getAccountComments(newAccount.id), + accountsDatabase.getAccountVotes(newAccount.id), + accountsDatabase.getAccountEditsSummary(newAccount.id), + accountsDatabase.accountsMetadataDatabase.getItem("accountIds"), + accountsDatabase.accountsMetadataDatabase.getItem("accountNamesToAccountIds"), + ]); accountsStore.setState((state) => ({ accounts: { ...state.accounts, [newAccount.id]: newAccount }, accountIds, accountNamesToAccountIds: newAccountNamesToAccountIds, accountsComments: { ...state.accountsComments, [newAccount.id]: accountComments }, + accountsCommentsIndexes: { + ...state.accountsCommentsIndexes, + [newAccount.id]: getAccountCommentsIndex(accountComments), + }, commentCidsToAccountsComments: getCommentCidsToAccountsComments({ ...state.accountsComments, [newAccount.id]: accountComments, }), accountsVotes: { ...state.accountsVotes, [newAccount.id]: accountVotes }, - accountsEdits: { ...state.accountsEdits, [newAccount.id]: accountEdits }, + accountsEdits: { ...state.accountsEdits, [newAccount.id]: {} }, + accountsEditsSummaries: { + ...state.accountsEditsSummaries, + [newAccount.id]: accountEditsSummary, + }, + accountsEditsLoaded: { ...state.accountsEditsLoaded, [newAccount.id]: false }, // don't import/export replies to own comments, those are just cached and can be refetched accountsCommentsReplies: { ...state.accountsCommentsReplies, [newAccount.id]: {} }, })); @@ -441,7 +583,7 @@ export const importAccount = async (serializedAccount: string) => { account: newAccount, accountComments, accountVotes, - accountEdits, + accountEditsSummary, }); // start looking for updates for all accounts comments in database @@ -753,16 +895,25 @@ export const publishComment = async ( let accountCommentIndex = accountsComments[account.id].length; let savedOnce = false; const saveCreatedAccountComment = async (accountComment: AccountComment) => { + const sanitizedAccountComment = addShortAddressesToAccountComment( + sanitizeStoredAccountComment(accountComment), + ) as AccountComment; await accountsDatabase.addAccountComment( account.id, - createdAccountComment, + sanitizedAccountComment, savedOnce ? accountCommentIndex : undefined, ); savedOnce = true; - accountsStore.setState(({ accountsComments }) => { + accountsStore.setState(({ accountsComments, accountsCommentsIndexes }) => { const accountComments = [...accountsComments[account.id]]; - accountComments[accountCommentIndex] = accountComment; - return { accountsComments: { ...accountsComments, [account.id]: accountComments } }; + accountComments[accountCommentIndex] = sanitizedAccountComment; + return { + accountsComments: { ...accountsComments, [account.id]: accountComments }, + accountsCommentsIndexes: { + ...accountsCommentsIndexes, + [account.id]: getAccountCommentsIndex(accountComments), + }, + }; }); }; let createdAccountComment = { @@ -771,7 +922,9 @@ export const publishComment = async ( index: accountCommentIndex, accountId: account.id, }; - createdAccountComment = addShortAddressesToAccountComment(createdAccountComment); + createdAccountComment = addShortAddressesToAccountComment( + sanitizeStoredAccountComment(createdAccountComment), + ); await saveCreatedAccountComment(createdAccountComment); publishCommentOptions._onPendingCommentIndex?.(accountCommentIndex, createdAccountComment); @@ -824,28 +977,38 @@ export const publishComment = async ( if (!sessionInfo || abandonedPublishKeys.has(sessionInfo.sessionKey)) return; cleanupPublishSessionOnTerminal(account.id, sessionInfo.keyIndex); const commentWithCid = addShortAddressesToAccountComment( - normalizePublicationOptionsForStore(comment as any), + sanitizeStoredAccountComment(normalizePublicationOptionsForStore(comment as any)), ); + delete (commentWithCid as any).clients; + delete (commentWithCid as any).publishingState; + delete (commentWithCid as any).error; + delete (commentWithCid as any).errors; await accountsDatabase.addAccountComment(account.id, commentWithCid, currentIndex); - accountsStore.setState(({ accountsComments, commentCidsToAccountsComments }) => { - const updatedAccountComments = [...accountsComments[account.id]]; - const updatedAccountComment = { - ...commentWithCid, - index: currentIndex, - accountId: account.id, - }; - updatedAccountComments[currentIndex] = updatedAccountComment; - return { - accountsComments: { ...accountsComments, [account.id]: updatedAccountComments }, - commentCidsToAccountsComments: { - ...commentCidsToAccountsComments, - [challengeVerification?.commentUpdate?.cid]: { - accountId: account.id, - accountCommentIndex: currentIndex, + accountsStore.setState( + ({ accountsComments, accountsCommentsIndexes, commentCidsToAccountsComments }) => { + const updatedAccountComments = [...accountsComments[account.id]]; + const updatedAccountComment = { + ...commentWithCid, + index: currentIndex, + accountId: account.id, + }; + updatedAccountComments[currentIndex] = updatedAccountComment; + return { + accountsComments: { ...accountsComments, [account.id]: updatedAccountComments }, + accountsCommentsIndexes: { + ...accountsCommentsIndexes, + [account.id]: getAccountCommentsIndex(updatedAccountComments), }, - }, - }; - }); + commentCidsToAccountsComments: { + ...commentCidsToAccountsComments, + [challengeVerification?.commentUpdate?.cid]: { + accountId: account.id, + accountCommentIndex: currentIndex, + }, + }, + }; + }, + ); // clone the comment or it bugs publishing callbacks const updatingComment = await account.plebbit.createComment( @@ -977,6 +1140,10 @@ export const deleteComment = async ( accountsStore.setState({ accountsComments: newAccountsComments, + accountsCommentsIndexes: { + ...accountsStore.getState().accountsCommentsIndexes, + [account.id]: getAccountCommentsIndex(reindexed), + }, commentCidsToAccountsComments: newCommentCidsToAccountsComments, }); @@ -1116,8 +1283,20 @@ export const publishCommentEdit = async ( rollbackPendingEditPromise = Promise.all([ accountsDatabase.deleteAccountEdit(account.id, storedCommentEdit), Promise.resolve( - accountsStore.setState(({ accountsEdits }) => - removeStoredAccountEditFromState(accountsEdits, account.id, storedCommentEdit), + accountsStore.setState( + ({ accountsEdits, accountsEditsLoaded, accountsEditsSummaries }) => { + const nextState: any = removeStoredAccountEditSummaryFromState( + accountsEditsSummaries, + accountsEdits, + account.id, + storedCommentEdit, + ); + Object.assign( + nextState, + removeStoredAccountEditFromState(accountsEdits, account.id, storedCommentEdit), + ); + return nextState; + }, ), ), ]).then(() => {}); @@ -1127,9 +1306,19 @@ export const publishCommentEdit = async ( await accountsDatabase.addAccountEdit(account.id, storedCreateCommentEditOptions); log("accountsActions.publishCommentEdit", { createCommentEditOptions }); - accountsStore.setState(({ accountsEdits }) => - addStoredAccountEditToState(accountsEdits, account.id, storedCommentEdit), - ); + accountsStore.setState(({ accountsEdits, accountsEditsLoaded, accountsEditsSummaries }) => { + const nextState: any = addStoredAccountEditSummaryToState( + accountsEditsSummaries, + account.id, + storedCommentEdit, + ); + Object.assign( + nextState, + addStoredAccountEditToState(accountsEdits, account.id, storedCommentEdit), + ); + nextState.accountsEditsLoaded = { ...accountsEditsLoaded, [account.id]: true }; + return nextState; + }); const publishAndRetryFailedChallengeVerification = async () => { commentEdit.once("challenge", async (challenge: Challenge) => { @@ -1272,25 +1461,30 @@ export const publishCommentModeration = async ( await accountsDatabase.addAccountEdit(account.id, storedCreateCommentModerationOptions); log("accountsActions.publishCommentModeration", { createCommentModerationOptions }); - accountsStore.setState(({ accountsEdits }) => { + accountsStore.setState(({ accountsEdits, accountsEditsLoaded, accountsEditsSummaries }) => { // remove signer and author because not needed and they expose private key const commentModeration = { ...storedCreateCommentModerationOptions, signer: undefined, author: undefined, }; + const nextState: any = addStoredAccountEditSummaryToState( + accountsEditsSummaries, + account.id, + commentModeration, + ); let commentModerations = - accountsEdits[account.id][storedCreateCommentModerationOptions.commentCid] || []; + accountsEdits[account.id]?.[storedCreateCommentModerationOptions.commentCid] || []; commentModerations = [...commentModerations, commentModeration]; - return { - accountsEdits: { - ...accountsEdits, - [account.id]: { - ...accountsEdits[account.id], - [storedCreateCommentModerationOptions.commentCid]: commentModerations, - }, + nextState.accountsEdits = { + ...accountsEdits, + [account.id]: { + ...(accountsEdits[account.id] || {}), + [storedCreateCommentModerationOptions.commentCid]: commentModerations, }, }; + nextState.accountsEditsLoaded = { ...accountsEditsLoaded, [account.id]: true }; + return nextState; }); }; diff --git a/src/stores/accounts/accounts-database.test.ts b/src/stores/accounts/accounts-database.test.ts index c4a0d657..094edc7a 100644 --- a/src/stores/accounts/accounts-database.test.ts +++ b/src/stores/accounts/accounts-database.test.ts @@ -566,6 +566,92 @@ describe("accounts-database", () => { expect(Array.isArray(edits["cid1"])).toBe(true); expect(edits["cid1"][0].onChallenge).toBeUndefined(); }); + + test("migrates legacy duplicate vote and edit keys into compact indexes", async () => { + const acc = makeAccount({ id: "legacy-history", name: "LegacyHistory" }); + await accountsDatabase.addAccount(acc); + + const votesDb = localForage.createInstance({ + name: `plebbitReactHooks-accountVotes-${acc.id}`, + }); + await votesDb.setItem("0", { commentCid: "vote-cid", vote: 1, timestamp: 1 }); + await votesDb.setItem("length", 1); + await votesDb.setItem("vote-cid", { commentCid: "vote-cid", vote: 1, timestamp: 1 }); + + const editsDb = localForage.createInstance({ + name: `plebbitReactHooks-accountEdits-${acc.id}`, + }); + await editsDb.setItem("0", { commentCid: "edit-cid", spoiler: true, timestamp: 10 }); + await editsDb.setItem("length", 1); + await editsDb.setItem("edit-cid", [{ commentCid: "edit-cid", spoiler: true, timestamp: 10 }]); + + const votes = await accountsDatabase.getAccountVotes(acc.id); + const edits = await accountsDatabase.getAccountEdits(acc.id); + const editSummary = await accountsDatabase.getAccountEditsSummary(acc.id); + + expect(votes["vote-cid"].vote).toBe(1); + expect(edits["edit-cid"]).toHaveLength(1); + expect(editSummary["edit-cid"].spoiler.value).toBe(true); + expect(await votesDb.getItem("vote-cid")).toBeNull(); + expect(await editsDb.getItem("edit-cid")).toBeNull(); + }); + + test("ignores malformed legacy votes without commentCid when rebuilding compact indexes", async () => { + const acc = makeAccount({ id: "legacy-vote-no-cid", name: "LegacyVoteNoCid" }); + await accountsDatabase.addAccount(acc); + const votesDb = localForage.createInstance({ + name: `plebbitReactHooks-accountVotes-${acc.id}`, + }); + await votesDb.setItem("0", { vote: 1, timestamp: 1 }); + await votesDb.setItem("length", 1); + + const votes = await accountsDatabase.getAccountVotes(acc.id); + + expect(votes).toEqual({}); + }); + + test("legacy edit entries without a target are ignored when rebuilding indexes", async () => { + const acc = makeAccount({ id: "legacy-edit-no-target", name: "LegacyEditNoTarget" }); + await accountsDatabase.addAccount(acc); + const editsDb = localForage.createInstance({ + name: `plebbitReactHooks-accountEdits-${acc.id}`, + }); + await editsDb.setItem("0", { spoiler: true, timestamp: 10 }); + await editsDb.setItem("length", 1); + + const edits = await accountsDatabase.getAccountEdits(acc.id); + const summary = await accountsDatabase.getAccountEditsSummary(acc.id); + + expect(edits).toEqual({}); + expect(summary).toEqual({}); + }); + + test("builds compact edit indexes for community and subplebbit targets", async () => { + const acc = makeAccount({ id: "legacy-edit-targets", name: "LegacyEditTargets" }); + await accountsDatabase.addAccount(acc); + const editsDb = localForage.createInstance({ + name: `plebbitReactHooks-accountEdits-${acc.id}`, + }); + await editsDb.setItem("0", { + communityAddress: "community.eth", + title: "community", + timestamp: 10, + }); + await editsDb.setItem("1", { + subplebbitAddress: "legacy-community.eth", + description: "legacy", + timestamp: 20, + }); + await editsDb.setItem("length", 2); + + const edits = await accountsDatabase.getAccountEdits(acc.id); + const summary = await accountsDatabase.getAccountEditsSummary(acc.id); + + expect(edits["community.eth"][0].title).toBe("community"); + expect(edits["legacy-community.eth"][0].description).toBe("legacy"); + expect(summary["community.eth"].title.value).toBe("community"); + expect(summary["legacy-community.eth"].description.value).toBe("legacy"); + }); }); describe("getExportedAccountJson", () => { @@ -634,6 +720,31 @@ describe("accounts-database", () => { expect(comments[1].cid).toBe("cid2"); }); + test("addAccountComment strips nested replies.pages payloads but keeps core comment fields", async () => { + const acc = makeAccount({ id: "comment-slim", name: "CommentSlim" }); + await accountsDatabase.addAccount(acc); + await accountsDatabase.addAccountComment(acc.id, { + cid: "cid-slim", + content: "hello", + communityAddress: "sub", + timestamp: 1, + author: { address: "addr" }, + replies: { + pages: { + best: { + comments: [{ cid: "reply-1", content: "reply" }], + }, + }, + pageCids: { best: "page-1" }, + }, + } as any); + const comments = await accountsDatabase.getAccountComments(acc.id); + const exported = JSON.parse(await accountsDatabase.getExportedAccountJson(acc.id)); + expect(comments[0].replies?.pages).toBeUndefined(); + expect(comments[0].replies?.pageCids).toEqual({ best: "page-1" }); + expect(exported.accountComments[0].replies?.pages).toBeUndefined(); + }); + test("addAccountComment asserts accountCommentIndex < length", async () => { const acc = makeAccount({ id: "edit-assert", name: "EditAssert" }); await accountsDatabase.addAccount(acc); diff --git a/src/stores/accounts/accounts-database.ts b/src/stores/accounts/accounts-database.ts index f46e1ffe..8d336eaa 100644 --- a/src/stores/accounts/accounts-database.ts +++ b/src/stores/accounts/accounts-database.ts @@ -14,15 +14,24 @@ import { AccountsComments, AccountCommentReply, AccountsCommentsReplies, + AccountEdit, + AccountEditsSummary, } from "../../types"; import utils from "../../lib/utils"; import { getDefaultPlebbitOptions, overwritePlebbitOptions } from "./account-generator"; +import { getAccountsEditsSummary, sanitizeStoredAccountComment } from "./utils"; import Logger from "@plebbit/plebbit-logger"; const log = Logger("bitsocial-react-hooks:accounts:stores"); const accountsDatabase = localForage.createInstance({ name: "plebbitReactHooks-accounts" }); const accountsMetadataDatabase = localForage.createInstance({ name: "plebbitReactHooks-accountsMetadata", }); +const storageVersionKey = "__storageVersion"; +const votesLatestIndexKey = "__commentCidToLatestIndex"; +const editsTargetToIndicesKey = "__targetToIndices"; +const editsSummaryKey = "__summary"; +const voteStorageVersion = 1; +const editStorageVersion = 1; // TODO: remove this eventually after everyone has migrated // migrate to name with safe prefix @@ -193,6 +202,44 @@ const getDatabaseAsArray = async (database: any) => { return items; }; +const removeFunctionsAndSensitiveFields = (publication: CreateCommentOptions) => { + const sanitizedPublication: Record = {}; + for (const key in publication) { + if (key === "signer" || key === "author" || typeof publication[key] === "function") { + continue; + } + sanitizedPublication[key] = publication[key]; + } + return sanitizedPublication; +}; + +const isNumericDatabaseKey = (key: string) => /^[0-9]+$/.test(key); + +const rebuildVotesLatestIndex = (votes: any[]) => { + const latestIndexByCommentCid: Record = {}; + for (const [index, vote] of votes.entries()) { + if (vote?.commentCid) { + latestIndexByCommentCid[vote.commentCid] = index; + } + } + return latestIndexByCommentCid; +}; + +const rebuildEditsTargetIndexes = (edits: any[]) => { + const targetToIndices: Record = {}; + for (const [index, edit] of edits.entries()) { + const editTarget = getAccountEditTarget(edit); + if (!editTarget) { + continue; + } + if (!targetToIndices[editTarget]) { + targetToIndices[editTarget] = []; + } + targetToIndices[editTarget].push(index); + } + return targetToIndices; +}; + const addAccount = async (account: Account) => { validator.validateAccountsDatabaseAddAccountArguments(account); let accountIds: string[] | null = await accountsMetadataDatabase.getItem("accountIds"); @@ -286,6 +333,12 @@ const removeAccount = async (account: Account) => { const accountVotesDatabase = getAccountVotesDatabase(account.id); await accountVotesDatabase.clear(); + + const accountCommentsRepliesDatabase = getAccountCommentsRepliesDatabase(account.id); + await accountCommentsRepliesDatabase.clear(); + + const accountEditsDatabase = getAccountEditsDatabase(account.id); + await accountEditsDatabase.clear(); }; const accountsCommentsDatabases: any = {}; @@ -328,7 +381,7 @@ const addAccountComment = async ( ) => { const accountCommentsDatabase = getAccountCommentsDatabase(accountId); const length = (await accountCommentsDatabase.getItem("length")) || 0; - comment = utils.clone({ ...comment, signer: undefined }); + comment = sanitizeStoredAccountComment(comment); if (typeof accountCommentIndex === "number") { assert( accountCommentIndex < length, @@ -357,6 +410,7 @@ const getAccountComments = async (accountId: string) => { const comments = await Promise.all(promises); // add index and account id to account comments for easier updating for (const i in comments) { + comments[i] = sanitizeStoredAccountComment(comments[i]); comments[i].index = Number(i); comments[i].accountId = accountId; } @@ -394,41 +448,62 @@ const getAccountVotesDatabase = (accountId: string) => { return accountsVotesDatabases[accountId]; }; +const ensureAccountVotesDatabaseLayout = async (accountId: string) => { + const accountVotesDatabase = getAccountVotesDatabase(accountId); + if ((await accountVotesDatabase.getItem(storageVersionKey)) === voteStorageVersion) { + return; + } + + const votes = await getDatabaseAsArray(accountVotesDatabase); + const latestIndexByCommentCid = rebuildVotesLatestIndex(votes); + const keys = await accountVotesDatabase.keys(); + const duplicateKeysToDelete = keys.filter( + (key: string) => + !isNumericDatabaseKey(key) && + key !== "length" && + key !== storageVersionKey && + key !== votesLatestIndexKey && + latestIndexByCommentCid[key] !== undefined, + ); + await Promise.all([ + ...duplicateKeysToDelete.map((key: string) => accountVotesDatabase.removeItem(key)), + accountVotesDatabase.setItem(votesLatestIndexKey, latestIndexByCommentCid), + accountVotesDatabase.setItem(storageVersionKey, voteStorageVersion), + ]); +}; + const addAccountVote = async (accountId: string, createVoteOptions: CreateCommentOptions) => { assert( createVoteOptions?.commentCid && typeof createVoteOptions?.commentCid === "string", `addAccountVote createVoteOptions.commentCid '${createVoteOptions?.commentCid}' not a string`, ); const accountVotesDatabase = getAccountVotesDatabase(accountId); + await ensureAccountVotesDatabaseLayout(accountId); const length = (await accountVotesDatabase.getItem("length")) || 0; - const vote = { ...createVoteOptions }; - delete vote.signer; - delete vote.author; - // delete all functions because they can't be added to indexeddb - for (const i in vote) { - if (typeof vote[i] === "function") { - delete vote[i]; - } - } + const vote = removeFunctionsAndSensitiveFields(createVoteOptions); + const existingLatestIndexByCommentCid = await accountVotesDatabase.getItem(votesLatestIndexKey); + const latestIndexByCommentCid = { + ...existingLatestIndexByCommentCid, + [vote.commentCid]: length, + }; await Promise.all([ - accountVotesDatabase.setItem(vote.commentCid, vote), accountVotesDatabase.setItem(String(length), vote), + accountVotesDatabase.setItem(votesLatestIndexKey, latestIndexByCommentCid), + accountVotesDatabase.setItem(storageVersionKey, voteStorageVersion), accountVotesDatabase.setItem("length", length + 1), ]); }; const getAccountVotes = async (accountId: string) => { const accountVotesDatabase = getAccountVotesDatabase(accountId); - const length = (await accountVotesDatabase.getItem("length")) || 0; + await ensureAccountVotesDatabaseLayout(accountId); + const latestIndexByCommentCid = await accountVotesDatabase.getItem(votesLatestIndexKey); const votes: any = {}; - if (length === 0) { + const latestIndexes = Object.values(latestIndexByCommentCid); + if (latestIndexes.length === 0) { return votes; } - let promises = []; - let i = 0; - while (i < length) { - promises.push(accountVotesDatabase.getItem(String(i++))); - } + const promises = latestIndexes.map((index) => accountVotesDatabase.getItem(String(index))); const votesArray = await Promise.all(promises); for (const vote of votesArray) { votes[vote?.commentCid] = vote; @@ -515,32 +590,68 @@ const getAccountEditsDatabase = (accountId: string) => { return accountsEditsDatabases[accountId]; }; +const getAccountEditTarget = (edit: AccountEdit) => + edit?.commentCid || edit?.communityAddress || edit?.subplebbitAddress; + +const persistAccountEditsIndexes = async (accountId: string, edits: AccountEdit[]) => { + const accountEditsDatabase = getAccountEditsDatabase(accountId); + const targetToIndices = rebuildEditsTargetIndexes(edits); + const summary = getAccountsEditsSummary( + Object.fromEntries( + Object.entries(targetToIndices).map(([target, indices]) => [ + target, + indices.map((index) => edits[index]).filter(Boolean), + ]), + ), + ); + await Promise.all([ + accountEditsDatabase.setItem(editsTargetToIndicesKey, targetToIndices), + accountEditsDatabase.setItem(editsSummaryKey, summary), + accountEditsDatabase.setItem(storageVersionKey, editStorageVersion), + ]); + return { targetToIndices, summary }; +}; + +const ensureAccountEditsDatabaseLayout = async (accountId: string) => { + const accountEditsDatabase = getAccountEditsDatabase(accountId); + if ((await accountEditsDatabase.getItem(storageVersionKey)) === editStorageVersion) { + return; + } + + const edits = (await getDatabaseAsArray(accountEditsDatabase)).filter(Boolean); + const keys = await accountEditsDatabase.keys(); + const duplicateKeysToDelete = keys.filter( + (key: string) => + !isNumericDatabaseKey(key) && + key !== "length" && + key !== storageVersionKey && + key !== editsTargetToIndicesKey && + key !== editsSummaryKey && + edits.some((edit) => getAccountEditTarget(edit) === key), + ); + await Promise.all( + duplicateKeysToDelete.map((key: string) => accountEditsDatabase.removeItem(key)), + ); + await persistAccountEditsIndexes(accountId, edits); +}; + const addAccountEdit = async (accountId: string, createEditOptions: CreateCommentOptions) => { assert( createEditOptions?.commentCid && typeof createEditOptions?.commentCid === "string", `addAccountEdit createEditOptions.commentCid '${createEditOptions?.commentCid}' not a string`, ); const accountEditsDatabase = getAccountEditsDatabase(accountId); + await ensureAccountEditsDatabaseLayout(accountId); const length = (await accountEditsDatabase.getItem("length")) || 0; - const edit = { ...createEditOptions }; - delete edit.signer; - delete edit.author; - // delete all functions because they can't be added to indexeddb - for (const i in edit) { - if (typeof edit[i] === "function") { - delete edit[i]; - } - } - - // edits are an array because you can edit the same comment multiple times - const edits = (await accountEditsDatabase.getItem(edit.commentCid)) || []; - edits.push(edit); - + const edit = removeFunctionsAndSensitiveFields(createEditOptions); + const existingEdits = (await getDatabaseAsArray(accountEditsDatabase)).filter(Boolean); + existingEdits.push(edit); await Promise.all([ - accountEditsDatabase.setItem(edit.commentCid, edits), accountEditsDatabase.setItem(String(length), edit), + accountEditsDatabase.setItem(storageVersionKey, editStorageVersion), accountEditsDatabase.setItem("length", length + 1), ]); + await persistAccountEditsIndexes(accountId, existingEdits as AccountEdit[]); }; const doesStoredAccountEditMatch = (storedAccountEdit: any, targetStoredAccountEdit: any) => @@ -554,6 +665,7 @@ const deleteAccountEdit = async (accountId: string, editToDelete: CreateCommentO `deleteAccountEdit editToDelete.commentCid '${editToDelete?.commentCid}' not a string`, ); const accountEditsDatabase = getAccountEditsDatabase(accountId); + await ensureAccountEditsDatabaseLayout(accountId); const length = (await accountEditsDatabase.getItem("length")) || 0; const items = await getDatabaseAsArray(accountEditsDatabase); @@ -567,7 +679,6 @@ const deleteAccountEdit = async (accountId: string, editToDelete: CreateCommentO }); const newLength = nextItems.length; - const nextCommentEdits = nextItems.filter((item) => item?.commentCid === editToDelete.commentCid); const promises: Promise[] = []; for (let i = 0; i < newLength; i++) { promises.push(accountEditsDatabase.setItem(String(i), nextItems[i])); @@ -576,38 +687,36 @@ const deleteAccountEdit = async (accountId: string, editToDelete: CreateCommentO promises.push(accountEditsDatabase.removeItem(String(length - 1))); promises.push(accountEditsDatabase.setItem("length", newLength)); } - if (nextCommentEdits.length > 0) { - promises.push(accountEditsDatabase.setItem(editToDelete.commentCid, nextCommentEdits)); - } else { - promises.push(accountEditsDatabase.removeItem(editToDelete.commentCid)); - } await Promise.all(promises); + await persistAccountEditsIndexes(accountId, nextItems as AccountEdit[]); return deletedEdit; }; const getAccountEdits = async (accountId: string) => { const accountEditsDatabase = getAccountEditsDatabase(accountId); - const length = (await accountEditsDatabase.getItem("length")) || 0; + await ensureAccountEditsDatabaseLayout(accountId); + const targetToIndices = await accountEditsDatabase.getItem(editsTargetToIndicesKey); const edits: any = {}; - if (length === 0) { + const targets = Object.keys(targetToIndices); + if (targets.length === 0) { return edits; } - let promises = []; - let i = 0; - while (i < length) { - promises.push(accountEditsDatabase.getItem(String(i++))); - } - const editsArray = await Promise.all(promises); - for (const edit of editsArray) { - // TODO: must change this logic for community edits - if (!edits[edit?.commentCid]) { - edits[edit?.commentCid] = []; - } - edits[edit?.commentCid].push(edit); + for (const target of targets) { + const targetIndices: number[] = targetToIndices[target]; + const targetEdits = await Promise.all( + targetIndices.map((index) => accountEditsDatabase.getItem(String(index))), + ); + edits[target] = targetEdits.filter(Boolean); } return edits; }; +const getAccountEditsSummary = async (accountId: string): Promise => { + const accountEditsDatabase = getAccountEditsDatabase(accountId); + await ensureAccountEditsDatabaseLayout(accountId); + return await accountEditsDatabase.getItem(editsSummaryKey); +}; + const getAccountsEdits = async (accountIds: string[]) => { assert( Array.isArray(accountIds), @@ -625,6 +734,19 @@ const getAccountsEdits = async (accountIds: string[]) => { return accountsEdits; }; +const getAccountsEditsSummaries = async (accountIds: string[]) => { + assert( + Array.isArray(accountIds), + `getAccountsEditsSummaries invalid accountIds '${accountIds}' not an array`, + ); + const accountsEditsSummaries = await Promise.all( + accountIds.map((accountId) => getAccountEditsSummary(accountId)), + ); + return Object.fromEntries( + accountIds.map((accountId, index) => [accountId, accountsEditsSummaries[index]]), + ); +}; + const database = { accountsDatabase, accountsMetadataDatabase, @@ -645,6 +767,8 @@ const database = { getAccountsCommentsReplies, getAccountsEdits, getAccountEdits, + getAccountsEditsSummaries, + getAccountEditsSummary, addAccountEdit, deleteAccountEdit, accountVersion, diff --git a/src/stores/accounts/accounts-store.test.ts b/src/stores/accounts/accounts-store.test.ts index a93817b4..c0b20c1b 100644 --- a/src/stores/accounts/accounts-store.test.ts +++ b/src/stores/accounts/accounts-store.test.ts @@ -1,6 +1,7 @@ import { vi } from "vitest"; import testUtils from "../../lib/test-utils"; import accountsStore, { resetAccountsStore, resetAccountsDatabaseAndStore } from "./accounts-store"; +import accountsDatabase from "./accounts-database"; import { setPlebbitJs } from "../../lib/plebbit-js"; import PlebbitJsMock from "../../lib/plebbit-js/plebbit-js-mock"; @@ -72,6 +73,23 @@ describe("accounts-store", () => { "function", ); }); + + test("init keeps cold edit history out of hot state but loads summaries", async () => { + await testUtils.resetDatabasesAndStores(); + const accountId = accountsStore.getState().activeAccountId!; + await accountsDatabase.addAccountEdit(accountId, { + commentCid: "cold-edit-cid", + spoiler: true, + timestamp: 1, + } as any); + + await resetAccountsStore(); + const state = accountsStore.getState(); + + expect(state.accountsEdits[accountId]).toEqual({}); + expect(state.accountsEditsLoaded[accountId]).toBe(false); + expect(state.accountsEditsSummaries[accountId]["cold-edit-cid"].spoiler.value).toBe(true); + }); }); describe("init error handling", () => { diff --git a/src/stores/accounts/accounts-store.ts b/src/stores/accounts/accounts-store.ts index 1f940561..89f0c4c7 100644 --- a/src/stores/accounts/accounts-store.ts +++ b/src/stores/accounts/accounts-store.ts @@ -11,8 +11,10 @@ import { Comment, AccountsVotes, AccountsEdits, + AccountsEditsSummaries, AccountComment, AccountsComments, + AccountsCommentsIndexes, AccountsCommentsReplies, CommentCidsToAccountsComments, } from "../../types"; @@ -20,7 +22,11 @@ import createStore from "zustand"; import * as accountsActions from "./accounts-actions"; import * as accountsActionsInternal from "./accounts-actions-internal"; import localForage from "localforage"; -import { getCommentCidsToAccountsComments, getInitAccountCommentsToUpdate } from "./utils"; +import { + getAccountsCommentsIndexes, + getCommentCidsToAccountsComments, + getInitAccountCommentsToUpdate, +} from "./utils"; // reset all event listeners in between tests export const listeners: any = []; @@ -31,11 +37,14 @@ type AccountsState = { activeAccountId: string | undefined; accountNamesToAccountIds: AccountNamesToAccountIds; accountsComments: AccountsComments; + accountsCommentsIndexes: AccountsCommentsIndexes; commentCidsToAccountsComments: CommentCidsToAccountsComments; accountsCommentsUpdating: { [commentCid: string]: boolean }; accountsCommentsReplies: AccountsCommentsReplies; accountsVotes: AccountsVotes; accountsEdits: AccountsEdits; + accountsEditsSummaries: AccountsEditsSummaries; + accountsEditsLoaded: { [accountId: string]: boolean }; accountsActions: { [functionName: string]: Function }; accountsActionsInternal: { [functionName: string]: Function }; }; @@ -46,11 +55,14 @@ const accountsStore = createStore((setState: Function, getState: activeAccountId: undefined, accountNamesToAccountIds: {}, accountsComments: {}, + accountsCommentsIndexes: {}, commentCidsToAccountsComments: {}, accountsCommentsUpdating: {}, accountsCommentsReplies: {}, accountsVotes: {}, accountsEdits: {}, + accountsEditsSummaries: {}, + accountsEditsLoaded: {}, accountsActions, accountsActionsInternal, })); @@ -89,24 +101,28 @@ const initializeAccountsStore = async () => { )}'`, ); } - const [accountsComments, accountsVotes, accountsCommentsReplies, accountsEdits] = + const [accountsComments, accountsVotes, accountsCommentsReplies, accountsEditsSummaries] = await Promise.all([ accountsDatabase.getAccountsComments(accountIds), accountsDatabase.getAccountsVotes(accountIds), accountsDatabase.getAccountsCommentsReplies(accountIds), - accountsDatabase.getAccountsEdits(accountIds), + accountsDatabase.getAccountsEditsSummaries(accountIds), ]); const commentCidsToAccountsComments = getCommentCidsToAccountsComments(accountsComments); + const accountsCommentsIndexes = getAccountsCommentsIndexes(accountsComments); accountsStore.setState((state) => ({ accounts, accountIds, activeAccountId, accountNamesToAccountIds, accountsComments, + accountsCommentsIndexes, commentCidsToAccountsComments, accountsVotes, accountsCommentsReplies, - accountsEdits, + accountsEdits: Object.fromEntries(accountIds.map((accountId) => [accountId, {}])), + accountsEditsSummaries, + accountsEditsLoaded: Object.fromEntries(accountIds.map((accountId) => [accountId, false])), })); // start looking for updates for all accounts comments in database @@ -181,6 +197,7 @@ export const resetAccountsStore = async () => { // remove all event listeners listeners.forEach((listener: any) => listener.removeAllListeners()); + accountsStore.getState().accountsActionsInternal.resetLazyAccountHistoryLoaders?.(); // destroy all component subscriptions to the store accountsStore.destroy(); // restore original state diff --git a/src/stores/accounts/utils.test.ts b/src/stores/accounts/utils.test.ts index edfd66da..5542983f 100644 --- a/src/stores/accounts/utils.test.ts +++ b/src/stores/accounts/utils.test.ts @@ -159,6 +159,115 @@ describe("accountsStore utils", () => { }); }); + describe("sanitizeStoredAccountComment", () => { + test("returns undefined for function values nested in arrays", () => { + const sanitized = utils.sanitizeStoredAccountComment({ + flair: [() => "skip", "keep"], + } as any); + expect(sanitized.flair).toEqual(["keep"]); + }); + + test("removes functions, signer, and nested replies.pages while keeping pageCids", () => { + const sanitized = utils.sanitizeStoredAccountComment({ + signer: { privateKey: "secret" }, + onChallenge: () => {}, + replies: { + pages: { best: { comments: [{ cid: "reply-1" }] } }, + pageCids: { best: "page-1" }, + }, + } as any); + expect(sanitized.signer).toBeUndefined(); + expect(sanitized.onChallenge).toBeUndefined(); + expect(sanitized.replies.pages).toBeUndefined(); + expect(sanitized.replies.pageCids).toEqual({ best: "page-1" }); + }); + + test("removes replies object entirely when pages was the only nested payload", () => { + const sanitized = utils.sanitizeStoredAccountComment({ + replies: { pages: { best: { comments: [] } } }, + } as any); + expect(sanitized.replies).toBeUndefined(); + }); + }); + + describe("comment indexes and edit summaries", () => { + test("getAccountCommentsIndex handles undefined input", () => { + expect(utils.getAccountCommentsIndex(undefined as any)).toEqual({ + byCommunityAddress: {}, + byParentCid: {}, + }); + }); + + test("getAccountCommentsIndex skips comments without communityAddress or parentCid", () => { + const index = utils.getAccountCommentsIndex([ + { index: 0, cid: "cid-1" }, + { index: 1, cid: "cid-2", communityAddress: "sub.eth" }, + { index: 2, cid: "cid-3", parentCid: "parent-cid" }, + ] as any); + expect(index.byCommunityAddress).toEqual({ "sub.eth": [1] }); + expect(index.byParentCid).toEqual({ "parent-cid": [2] }); + }); + + test("getAccountCommentsIndex appends repeated community and parent indexes", () => { + const index = utils.getAccountCommentsIndex([ + { index: 0, communityAddress: "sub.eth", parentCid: "parent-cid" }, + { index: 1, communityAddress: "sub.eth", parentCid: "parent-cid" }, + ] as any); + expect(index.byCommunityAddress["sub.eth"]).toEqual([0, 1]); + expect(index.byParentCid["parent-cid"]).toEqual([0, 1]); + }); + + test("getAccountsCommentsIndexes builds indexes per account", () => { + const indexes = utils.getAccountsCommentsIndexes({ + acc1: [{ index: 0, communityAddress: "sub.eth" }], + acc2: [{ index: 1, parentCid: "parent-cid" }], + } as any); + expect(indexes.acc1.byCommunityAddress["sub.eth"]).toEqual([0]); + expect(indexes.acc2.byParentCid["parent-cid"]).toEqual([1]); + }); + + test("getAccountEditPropertySummary merges commentModeration and ignores stale or non-edit fields", () => { + const summary = utils.getAccountEditPropertySummary([ + { + commentCid: "cid-1", + timestamp: 10, + spoiler: false, + author: { address: "addr" }, + }, + { + commentCid: "cid-1", + timestamp: 20, + commentModeration: { removed: true }, + }, + { + commentCid: "cid-1", + timestamp: 15, + spoiler: true, + }, + { + commentCid: "cid-1", + timestamp: 12, + spoiler: false, + }, + ] as any); + expect(summary.spoiler).toEqual({ timestamp: 15, value: true }); + expect(summary.removed).toEqual({ timestamp: 20, value: true }); + expect((summary as any).author).toBeUndefined(); + }); + + test("getAccountEditPropertySummary handles undefined input", () => { + expect(utils.getAccountEditPropertySummary(undefined as any)).toEqual({}); + }); + + test("getAccountsEditsSummary handles empty targets", () => { + expect(utils.getAccountsEditsSummary({} as any)).toEqual({}); + }); + + test("getAccountsEditsSummary handles undefined input", () => { + expect(utils.getAccountsEditsSummary(undefined as any)).toEqual({}); + }); + }); + describe("promiseAny", () => { test("rejects when given empty array", async () => { await expect(utils.promiseAny([])).rejects.toThrow("all promises rejected"); diff --git a/src/stores/accounts/utils.ts b/src/stores/accounts/utils.ts index 20cb38ac..2dccb9b1 100644 --- a/src/stores/accounts/utils.ts +++ b/src/stores/accounts/utils.ts @@ -4,8 +4,12 @@ import { Communities, AccountComment, AccountsComments, + AccountsCommentsIndexes, + AccountCommentsIndex, CommentCidsToAccountsComments, Comment, + AccountEdit, + AccountEditsSummary, } from "../../types"; import assert from "assert"; import Logger from "@plebbit/plebbit-logger"; @@ -60,6 +64,125 @@ export const getCommentCidsToAccountsComments = (accountsComments: AccountsComme return commentCidsToAccountsComments; }; +const cloneWithoutFunctions = (value: any): any => { + if (Array.isArray(value)) { + return value + .map((entry) => cloneWithoutFunctions(entry)) + .filter((entry) => entry !== undefined); + } + if (!value || typeof value !== "object") { + return typeof value === "function" ? undefined : value; + } + + const clonedValue: Record = {}; + for (const key in value) { + if (typeof value[key] === "function") { + continue; + } + const clonedChild = cloneWithoutFunctions(value[key]); + if (clonedChild !== undefined) { + clonedValue[key] = clonedChild; + } + } + return clonedValue; +}; + +export const sanitizeStoredAccountComment = (comment: Comment) => { + const sanitizedComment = cloneWithoutFunctions({ ...comment, signer: undefined }); + if (sanitizedComment?.replies?.pages) { + sanitizedComment.replies = { ...sanitizedComment.replies }; + delete sanitizedComment.replies.pages; + if (Object.keys(sanitizedComment.replies).length === 0) { + delete sanitizedComment.replies; + } + } + return sanitizedComment; +}; + +export const getAccountCommentsIndex = ( + accountComments: AccountComment[] | undefined, +): AccountCommentsIndex => { + const index: AccountCommentsIndex = { + byCommunityAddress: {}, + byParentCid: {}, + }; + for (const accountComment of accountComments || []) { + if (accountComment.communityAddress) { + if (!index.byCommunityAddress[accountComment.communityAddress]) { + index.byCommunityAddress[accountComment.communityAddress] = []; + } + index.byCommunityAddress[accountComment.communityAddress].push(accountComment.index); + } + if (accountComment.parentCid) { + if (!index.byParentCid[accountComment.parentCid]) { + index.byParentCid[accountComment.parentCid] = []; + } + index.byParentCid[accountComment.parentCid].push(accountComment.index); + } + } + return index; +}; + +export const getAccountsCommentsIndexes = (accountsComments: AccountsComments) => { + const indexes: AccountsCommentsIndexes = {}; + for (const accountId in accountsComments) { + indexes[accountId] = getAccountCommentsIndex(accountsComments[accountId]); + } + return indexes; +}; + +const accountEditNonPropertyNames = new Set([ + "author", + "signer", + "clientId", + "commentCid", + "communityAddress", + "subplebbitAddress", + "timestamp", +]); + +const normalizeAccountEditForSummary = (accountEdit: AccountEdit) => { + const normalizedAccountEdit = { ...accountEdit }; + if (normalizedAccountEdit.commentModeration) { + Object.assign(normalizedAccountEdit, normalizedAccountEdit.commentModeration); + delete normalizedAccountEdit.commentModeration; + } + return normalizedAccountEdit; +}; + +export const getAccountEditPropertySummary = (accountEdits: AccountEdit[] | undefined) => { + const accountEditPropertySummary: AccountEditsSummary[string] = {}; + for (const accountEdit of accountEdits || []) { + const normalizedAccountEdit = normalizeAccountEditForSummary(accountEdit); + for (const propertyName in normalizedAccountEdit) { + if ( + normalizedAccountEdit[propertyName] === undefined || + accountEditNonPropertyNames.has(propertyName) + ) { + continue; + } + const previousTimestamp = accountEditPropertySummary[propertyName]?.timestamp || 0; + if ((normalizedAccountEdit.timestamp || 0) >= previousTimestamp) { + accountEditPropertySummary[propertyName] = { + timestamp: normalizedAccountEdit.timestamp, + value: normalizedAccountEdit[propertyName], + }; + } + } + } + return accountEditPropertySummary; +}; + +export const getAccountsEditsSummary = (accountEdits: { + [commentCidOrCommunityAddress: string]: AccountEdit[]; +}) => { + const summary: AccountEditsSummary = {}; + for (const target in accountEdits || {}) { + summary[target] = getAccountEditPropertySummary(accountEdits[target]); + } + return summary; +}; + interface CommentLinkDimensions { linkWidth?: number; linkHeight?: number; @@ -266,6 +389,11 @@ export const addShortAddressesToAccountComment = (comment: Comment) => { const utils = { getAccountCommunities, getCommentCidsToAccountsComments, + getAccountCommentsIndex, + getAccountsCommentsIndexes, + sanitizeStoredAccountComment, + getAccountEditPropertySummary, + getAccountsEditsSummary, fetchCommentLinkDimensions, getInitAccountCommentsToUpdate, getAccountCommentDepth, diff --git a/src/types.ts b/src/types.ts index 64e7550e..e7d61d21 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,6 +28,14 @@ export interface UseAccountsResult extends Result { // useAccountComments(options): result export interface UseAccountCommentsOptions extends Options { filter?: AccountPublicationsFilter; + commentCid?: string; + commentIndices?: number[]; + communityAddress?: string; + parentCid?: string; + newerThan?: number; + page?: number; + pageSize?: number; + order?: "asc" | "desc"; } export interface UseAccountCommentsResult extends Result { accountComments: AccountComment[]; @@ -36,12 +44,20 @@ export interface UseAccountCommentsResult extends Result { // useAccountComment(options): result export interface UseAccountCommentOptions extends Options { commentIndex?: number; + commentCid?: string; } export interface UseAccountCommentResult extends Result, AccountComment {} // useAccountVotes(options): result export interface UseAccountVotesOptions extends Options { filter?: AccountPublicationsFilter; + vote?: number; + commentCid?: string; + communityAddress?: string; + newerThan?: number; + page?: number; + pageSize?: number; + order?: "asc" | "desc"; } export interface UseAccountVotesResult extends Result { accountVotes: AccountVote[]; @@ -571,6 +587,11 @@ export interface AccountComment extends Comment { } export type AccountComments = AccountComment[]; export type AccountsComments = { [accountId: string]: AccountComments }; +export type AccountCommentsIndex = { + byCommunityAddress: { [communityAddress: string]: number[] }; + byParentCid: { [parentCid: string]: number[] }; +}; +export type AccountsCommentsIndexes = { [accountId: string]: AccountCommentsIndex }; export type CommentCidsToAccountsComments = { [commentCid: string]: { accountId: string; accountCommentIndex: number }; }; @@ -594,6 +615,16 @@ export type AccountVote = { }; export type AccountsEdits = { [accountId: string]: AccountEdits }; export type AccountEdits = { [commentCidOrCommunityAddress: string]: AccountEdit[] }; +export type AccountEditPropertySummary = { + timestamp: number; + value: any; +}; +export type AccountEditsSummary = { + [commentCidOrCommunityAddress: string]: { + [propertyName: string]: AccountEditPropertySummary; + }; +}; +export type AccountsEditsSummaries = { [accountId: string]: AccountEditsSummary }; export type AccountEdit = { // has all the publish options like commentCid, vote, timestamp, etc (both comment edits and community edits) [publishOption: string]: any; From 38a67e349a7346393b2cb4946676663c6804f023 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Fri, 20 Mar 2026 12:56:51 +0800 Subject: [PATCH 2/4] fix(accounts): simplify account history query naming --- README.md | 8 +-- src/hooks/accounts/accounts.test.ts | 54 +++++++++++++++++-- src/hooks/accounts/accounts.ts | 42 +++++++++++---- src/stores/accounts/accounts-database.test.ts | 29 ++++------ src/stores/accounts/accounts-database.ts | 22 +++++--- src/types.ts | 4 ++ 6 files changed, 117 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 4f9d139d..1d11b543 100644 --- a/README.md +++ b/README.md @@ -110,8 +110,8 @@ Run `corepack enable` once per machine so plain `yarn` resolves to the pinned Ya ``` useAccount(): Account | undefined useAccountComment({commentIndex?: number, commentCid?: string}): Comment // get one own comment by index or cid -useAccountComments({filter?: AccountPublicationsFilter, commentCid?: string, commentIndices?: number[], communityAddress?: string, parentCid?: string, newerThan?: number, page?: number, pageSize?: number, order?: "asc" | "desc"}): {accountComments: Comment[]} // export or display list of own comments -useAccountVotes({filter?: AccountPublicationsFilter, vote?: number, commentCid?: string, communityAddress?: string, newerThan?: number, page?: number, pageSize?: number, order?: "asc" | "desc"}): {accountVotes: Vote[]} // export or display list of own votes +useAccountComments({filter?: AccountPublicationsFilter, commentCid?: string, commentIndices?: number[], communityAddress?: string, parentCid?: string, newerThan?: number, page?: number, pageSize?: number, sortType?: "new" | "old"}): {accountComments: Comment[]} // export or display list of own comments +useAccountVotes({filter?: AccountPublicationsFilter, vote?: number, commentCid?: string, communityAddress?: string, newerThan?: number, page?: number, pageSize?: number, sortType?: "new" | "old"}): {accountVotes: Vote[]} // export or display list of own votes useAccountVote({commentCid: string}): Vote // know if you already voted on some comment useAccountEdits({filer: AccountPublicationsFilter}): {accountEdits: AccountEdit[]} useAccountCommunities(): {accountCommunities: {[communityAddress: string]: AccountCommunity}, onlyIfCached?: boolean} @@ -926,7 +926,7 @@ const myRepliesToSomeComment = useAccountComments({ parentCid: parentCommentCid const recentMyCommentsInMemesEth = useAccountComments({ communityAddress, newerThan: 60 * 60 * 24 * 30, - order: "desc", + sortType: "new", page: 0, pageSize: 20, }); @@ -941,7 +941,7 @@ const replacementReplies = useAccountComments({ commentIndices: [5, 7, 9] }); const recentUpvotes = useAccountVotes({ vote: 1, newerThan: 60 * 60 * 24 * 30, - order: "desc", + sortType: "new", page: 0, pageSize: 20, }); diff --git a/src/hooks/accounts/accounts.test.ts b/src/hooks/accounts/accounts.test.ts index cb8e505d..673c6617 100644 --- a/src/hooks/accounts/accounts.test.ts +++ b/src/hooks/accounts/accounts.test.ts @@ -2027,7 +2027,7 @@ describe("accounts", () => { const rendered2 = renderHook(() => { const recentCommunityComments = useAccountComments({ communityAddress: "community address 1", - order: "desc", + sortType: "new", pageSize: 1, }); const commentByCid = useAccountComments({ commentCid: "own-comment-cid" }); @@ -2104,7 +2104,7 @@ describe("accounts", () => { })); const rendered2 = renderHook(() => - useAccountComments({ newerThan: 150, order: "desc", page: 0, pageSize: 1 }), + useAccountComments({ newerThan: 150, sortType: "new", page: 0, pageSize: 1 }), ); expect(rendered2.result.current.accountComments).toHaveLength(1); @@ -2122,6 +2122,52 @@ describe("accounts", () => { expect(rendered2.result.current.missingComment.accountComments).toEqual([]); }); + test("useAccountComments and useAccountVotes keep order alias compatibility", () => { + const now = Math.floor(Date.now() / 1000); + const accountId = accountsStore.getState().activeAccountId!; + accountsStore.setState((state: any) => ({ + ...state, + accountsComments: { + ...state.accountsComments, + [accountId]: state.accountsComments[accountId].map( + (accountComment: any, index: number) => ({ + ...accountComment, + timestamp: now - 300 + index * 100, + }), + ), + }, + accountsVotes: { + ...state.accountsVotes, + [accountId]: { + "comment cid 1": { + ...state.accountsVotes[accountId]["comment cid 1"], + vote: 1, + timestamp: now, + }, + "comment cid 2": { + ...state.accountsVotes[accountId]["comment cid 2"], + vote: -1, + timestamp: now - 100, + }, + "comment cid 3": { + ...state.accountsVotes[accountId]["comment cid 3"], + vote: 1, + timestamp: now - 200, + }, + }, + }, + })); + + const rendered2 = renderHook(() => { + const comments = useAccountComments({ order: "desc", page: 0, pageSize: 1 }); + const votes = useAccountVotes({ order: "desc", page: 0, pageSize: 1 }); + return { comments, votes }; + }); + + expect(rendered2.result.current.comments.accountComments[0].timestamp).toBe(now - 100); + expect(rendered2.result.current.votes.accountVotes[0].commentCid).toBe("comment cid 3"); + }); + test("useAccountVotes supports additive filters and pagination", () => { const now = Math.floor(Date.now() / 1000); const accountId = accountsStore.getState().activeAccountId!; @@ -2157,7 +2203,7 @@ describe("accounts", () => { vote: 1, communityAddress: "community address 1", newerThan: 50, - order: "desc", + sortType: "new", page: 0, pageSize: 1, }), @@ -2166,7 +2212,7 @@ describe("accounts", () => { expect(rendered2.result.current.accountVotes[0].commentCid).toBe("comment cid 1"); const rendered3 = renderHook(() => - useAccountVotes({ order: "desc", page: 1, pageSize: 1 }), + useAccountVotes({ sortType: "new", page: 1, pageSize: 1 }), ); expect(rendered3.result.current.accountVotes).toHaveLength(1); expect(rendered3.result.current.accountVotes[0].commentCid).toBe("comment cid 2"); diff --git a/src/hooks/accounts/accounts.ts b/src/hooks/accounts/accounts.ts index f394659f..fecf765a 100644 --- a/src/hooks/accounts/accounts.ts +++ b/src/hooks/accounts/accounts.ts @@ -335,6 +335,16 @@ const getAccountCommentsStates = (accountComments: AccountComment[]) => { export const haveAccountCommentStatesChanged = (nextStates: string[], previousStates: string[]) => nextStates.toString() !== previousStates.toString(); +const getAccountHistorySortType = ( + sortType?: "new" | "old", + order?: "asc" | "desc", +): "new" | "old" => { + if (sortType === "new" || sortType === "old") { + return sortType; + } + return order === "desc" ? "new" : "old"; +}; + export function useAccountComments(options?: UseAccountCommentsOptions): UseAccountCommentsResult { assert( !options || typeof options === "object", @@ -350,7 +360,8 @@ export function useAccountComments(options?: UseAccountCommentsOptions): UseAcco newerThan, page, pageSize, - order = "asc", + sortType, + order, } = options || {}; assert( !filter || typeof filter === "function", @@ -365,6 +376,7 @@ export function useAccountComments(options?: UseAccountCommentsOptions): UseAcco ); const accountComments = useAccountsStore((state) => state.accountsComments[accountId || ""]); const [accountCommentStates, setAccountCommentStates] = useState([]); + const accountHistorySortType = getAccountHistorySortType(sortType, order); const filteredAccountComments = useMemo(() => { if (!accountComments) { @@ -410,7 +422,7 @@ export function useAccountComments(options?: UseAccountCommentsOptions): UseAcco if (filter) { scopedAccountComments = scopedAccountComments.filter(filter); } - if (order === "desc") { + if (accountHistorySortType === "new") { scopedAccountComments = [...scopedAccountComments].reverse(); } if (typeof pageSize === "number" && pageSize > 0) { @@ -429,7 +441,7 @@ export function useAccountComments(options?: UseAccountCommentsOptions): UseAcco communityAddress, filter, newerThan, - order, + accountHistorySortType, page, pageSize, parentCid, @@ -467,7 +479,7 @@ export function useAccountComments(options?: UseAccountCommentsOptions): UseAcco communityAddress, filter, newerThan, - order, + sortType: accountHistorySortType, page, pageSize, parentCid, @@ -547,7 +559,8 @@ export function useAccountVotes(options?: UseAccountVotesOptions): UseAccountVot newerThan, page, pageSize, - order = "asc", + sortType, + order, } = opts; assert( !filter || typeof filter === "function", @@ -555,6 +568,7 @@ export function useAccountVotes(options?: UseAccountVotesOptions): UseAccountVot ); const accountId = useAccountId(accountName); const accountVotes = useAccountsStore((state) => state.accountsVotes[accountId || ""]); + const accountHistorySortType = getAccountHistorySortType(sortType, order); const filteredAccountVotesArray = useMemo(() => { let accountVotesArray: AccountVote[] = []; @@ -587,8 +601,8 @@ export function useAccountVotes(options?: UseAccountVotesOptions): UseAccountVot if (filter) { accountVotesArray = accountVotesArray.filter(filter); } - if (order === "desc") { - accountVotesArray.reverse(); + if (accountHistorySortType === "new") { + accountVotesArray = [...accountVotesArray].reverse(); } if (typeof pageSize === "number" && pageSize > 0) { const pageNumber = Math.max(page || 0, 0); @@ -596,7 +610,17 @@ export function useAccountVotes(options?: UseAccountVotesOptions): UseAccountVot accountVotesArray = accountVotesArray.slice(startIndex, startIndex + pageSize); } return accountVotesArray; - }, [accountVotes, commentCid, communityAddress, filter, newerThan, order, page, pageSize, vote]); + }, [ + accountVotes, + accountHistorySortType, + commentCid, + communityAddress, + filter, + newerThan, + page, + pageSize, + vote, + ]); if (accountVotes && options) { log("useAccountVotes", { @@ -607,7 +631,7 @@ export function useAccountVotes(options?: UseAccountVotesOptions): UseAccountVot communityAddress, filter, newerThan, - order, + sortType: accountHistorySortType, page, pageSize, vote, diff --git a/src/stores/accounts/accounts-database.test.ts b/src/stores/accounts/accounts-database.test.ts index 094edc7a..4718c367 100644 --- a/src/stores/accounts/accounts-database.test.ts +++ b/src/stores/accounts/accounts-database.test.ts @@ -4,6 +4,11 @@ import PlebbitJsMock from "../../lib/plebbit-js/plebbit-js-mock"; import localForage from "localforage"; import { getDefaultPlebbitOptions } from "./account-generator"; +const createPerAccountDatabase = (databaseName: string, accountId: string) => + localForage.createInstance({ + name: accountsDatabase.getPerAccountDatabaseName(databaseName, accountId), + }); + describe("accounts-database", () => { beforeAll(() => setPlebbitJs(PlebbitJsMock)); afterAll(() => restorePlebbitJs()); @@ -96,9 +101,7 @@ describe("accounts-database", () => { await prevAccountComments.setItem("0", { cid: "mig-cid", content: "migrated" }); await prevAccountComments.setItem("length", 1); await accountsDatabase.migrate(); - const newDb = localForage.createInstance({ - name: "plebbitReactHooks-accountComments-legacy-mig", - }); + const newDb = createPerAccountDatabase("accountComments", "legacy-mig"); const migratedComment = await newDb.getItem("0"); expect(migratedComment).toEqual({ cid: "mig-cid", content: "migrated" }); }); @@ -571,16 +574,12 @@ describe("accounts-database", () => { const acc = makeAccount({ id: "legacy-history", name: "LegacyHistory" }); await accountsDatabase.addAccount(acc); - const votesDb = localForage.createInstance({ - name: `plebbitReactHooks-accountVotes-${acc.id}`, - }); + const votesDb = createPerAccountDatabase("accountVotes", acc.id); await votesDb.setItem("0", { commentCid: "vote-cid", vote: 1, timestamp: 1 }); await votesDb.setItem("length", 1); await votesDb.setItem("vote-cid", { commentCid: "vote-cid", vote: 1, timestamp: 1 }); - const editsDb = localForage.createInstance({ - name: `plebbitReactHooks-accountEdits-${acc.id}`, - }); + const editsDb = createPerAccountDatabase("accountEdits", acc.id); await editsDb.setItem("0", { commentCid: "edit-cid", spoiler: true, timestamp: 10 }); await editsDb.setItem("length", 1); await editsDb.setItem("edit-cid", [{ commentCid: "edit-cid", spoiler: true, timestamp: 10 }]); @@ -599,9 +598,7 @@ describe("accounts-database", () => { test("ignores malformed legacy votes without commentCid when rebuilding compact indexes", async () => { const acc = makeAccount({ id: "legacy-vote-no-cid", name: "LegacyVoteNoCid" }); await accountsDatabase.addAccount(acc); - const votesDb = localForage.createInstance({ - name: `plebbitReactHooks-accountVotes-${acc.id}`, - }); + const votesDb = createPerAccountDatabase("accountVotes", acc.id); await votesDb.setItem("0", { vote: 1, timestamp: 1 }); await votesDb.setItem("length", 1); @@ -613,9 +610,7 @@ describe("accounts-database", () => { test("legacy edit entries without a target are ignored when rebuilding indexes", async () => { const acc = makeAccount({ id: "legacy-edit-no-target", name: "LegacyEditNoTarget" }); await accountsDatabase.addAccount(acc); - const editsDb = localForage.createInstance({ - name: `plebbitReactHooks-accountEdits-${acc.id}`, - }); + const editsDb = createPerAccountDatabase("accountEdits", acc.id); await editsDb.setItem("0", { spoiler: true, timestamp: 10 }); await editsDb.setItem("length", 1); @@ -629,9 +624,7 @@ describe("accounts-database", () => { test("builds compact edit indexes for community and subplebbit targets", async () => { const acc = makeAccount({ id: "legacy-edit-targets", name: "LegacyEditTargets" }); await accountsDatabase.addAccount(acc); - const editsDb = localForage.createInstance({ - name: `plebbitReactHooks-accountEdits-${acc.id}`, - }); + const editsDb = createPerAccountDatabase("accountEdits", acc.id); await editsDb.setItem("0", { communityAddress: "community.eth", title: "community", diff --git a/src/stores/accounts/accounts-database.ts b/src/stores/accounts/accounts-database.ts index 8d336eaa..380037f4 100644 --- a/src/stores/accounts/accounts-database.ts +++ b/src/stores/accounts/accounts-database.ts @@ -22,9 +22,15 @@ import { getDefaultPlebbitOptions, overwritePlebbitOptions } from "./account-gen import { getAccountsEditsSummary, sanitizeStoredAccountComment } from "./utils"; import Logger from "@plebbit/plebbit-logger"; const log = Logger("bitsocial-react-hooks:accounts:stores"); -const accountsDatabase = localForage.createInstance({ name: "plebbitReactHooks-accounts" }); +// Storage keeps the legacy namespace so existing installs reuse the same IndexedDB data. +const accountsDatabaseNamespace = "plebbitReactHooks"; +const getAccountsDatabaseName = (databaseName: string) => + `${accountsDatabaseNamespace}-${databaseName}`; +const getPerAccountDatabaseName = (databaseName: string, accountId: string) => + `${getAccountsDatabaseName(databaseName)}-${accountId}`; +const accountsDatabase = localForage.createInstance({ name: getAccountsDatabaseName("accounts") }); const accountsMetadataDatabase = localForage.createInstance({ - name: "plebbitReactHooks-accountsMetadata", + name: getAccountsDatabaseName("accountsMetadata"), }); const storageVersionKey = "__storageVersion"; const votesLatestIndexKey = "__commentCidToLatestIndex"; @@ -74,7 +80,7 @@ const migrate = async () => { name: `${databaseName}-${accountId}`, }); const database = localForage.createInstance({ - name: `plebbitReactHooks-${databaseName}-${accountId}`, + name: getPerAccountDatabaseName(databaseName, accountId), }); for (const key of await previousDatabase.keys()) { promises.push( @@ -349,7 +355,7 @@ const getAccountCommentsDatabase = (accountId: string) => { ); if (!accountsCommentsDatabases[accountId]) { accountsCommentsDatabases[accountId] = localForage.createInstance({ - name: `plebbitReactHooks-accountComments-${accountId}`, + name: getPerAccountDatabaseName("accountComments", accountId), }); } return accountsCommentsDatabases[accountId]; @@ -442,7 +448,7 @@ const getAccountVotesDatabase = (accountId: string) => { ); if (!accountsVotesDatabases[accountId]) { accountsVotesDatabases[accountId] = localForage.createInstance({ - name: `plebbitReactHooks-accountVotes-${accountId}`, + name: getPerAccountDatabaseName("accountVotes", accountId), }); } return accountsVotesDatabases[accountId]; @@ -536,7 +542,7 @@ const getAccountCommentsRepliesDatabase = (accountId: string) => { ); if (!accountsCommentsRepliesDatabases[accountId]) { accountsCommentsRepliesDatabases[accountId] = localForageLru.createInstance({ - name: `plebbitReactHooks-accountCommentsReplies-${accountId}`, + name: getPerAccountDatabaseName("accountCommentsReplies", accountId), size: 1000, }); } @@ -584,7 +590,7 @@ const getAccountEditsDatabase = (accountId: string) => { ); if (!accountsEditsDatabases[accountId]) { accountsEditsDatabases[accountId] = localForage.createInstance({ - name: `plebbitReactHooks-accountEdits-${accountId}`, + name: getPerAccountDatabaseName("accountEdits", accountId), }); } return accountsEditsDatabases[accountId]; @@ -773,6 +779,8 @@ const database = { deleteAccountEdit, accountVersion, migrate, + getAccountsDatabaseName, + getPerAccountDatabaseName, }; export default database; diff --git a/src/types.ts b/src/types.ts index e7d61d21..705649bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,8 @@ export interface UseAccountCommentsOptions extends Options { newerThan?: number; page?: number; pageSize?: number; + sortType?: "new" | "old"; + /** @deprecated use sortType */ order?: "asc" | "desc"; } export interface UseAccountCommentsResult extends Result { @@ -57,6 +59,8 @@ export interface UseAccountVotesOptions extends Options { newerThan?: number; page?: number; pageSize?: number; + sortType?: "new" | "old"; + /** @deprecated use sortType */ order?: "asc" | "desc"; } export interface UseAccountVotesResult extends Result { From fbe9c3d37853ceb360cbe1c560ccc6690a1716f6 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Fri, 20 Mar 2026 13:11:42 +0800 Subject: [PATCH 3/4] fix(accounts): address account history review regressions --- src/hooks/accounts/accounts.test.ts | 50 ++++- src/hooks/accounts/accounts.ts | 30 ++- .../accounts/accounts-actions-internal.ts | 55 +++--- src/stores/accounts/accounts-actions.test.ts | 151 ++++++++++++++ src/stores/accounts/accounts-actions.ts | 184 +++++++++++------- src/stores/accounts/accounts-database.test.ts | 23 +++ src/stores/accounts/accounts-database.ts | 12 +- src/stores/accounts/accounts-store.ts | 1 + src/stores/accounts/utils.ts | 17 +- 9 files changed, 403 insertions(+), 120 deletions(-) diff --git a/src/hooks/accounts/accounts.test.ts b/src/hooks/accounts/accounts.test.ts index 673c6617..a7212e4e 100644 --- a/src/hooks/accounts/accounts.test.ts +++ b/src/hooks/accounts/accounts.test.ts @@ -1324,6 +1324,7 @@ describe("accounts", () => { const store = await import("../../stores/accounts"); const accountId = store.default.getState().activeAccountId!; const internal = store.default.getState().accountsActionsInternal; + const previousLoaded = store.default.getState().accountsEditsLoaded[accountId]; const mockedEnsure = vi.fn().mockRejectedValueOnce(new Error("lazy load failed")); store.default.setState({ accountsEditsLoaded: { @@ -1342,7 +1343,13 @@ describe("accounts", () => { await Promise.resolve(); expect(mockedEnsure).toHaveBeenCalled(); } finally { - store.default.setState({ accountsActionsInternal: internal }); + store.default.setState({ + accountsActionsInternal: internal, + accountsEditsLoaded: { + ...store.default.getState().accountsEditsLoaded, + [accountId]: previousLoaded, + }, + }); } }); @@ -1565,6 +1572,7 @@ describe("accounts", () => { await waitFor(() => rendered2.result.current.content === "content 1"); expect(rendered2.result.current.content).toBe("content 1"); expect(rendered2.result.current.index).toBe(0); + expect(rendered2.result.current.state).toBe("pending"); rendered2.rerender({ commentIndex: 10 }); await waitFor(() => rendered2.result.current.content === undefined); @@ -2061,6 +2069,7 @@ describe("accounts", () => { "parent comment cid 1", ); expect(rendered2.result.current.accountCommentByCid.cid).toBe("own-comment-cid"); + expect(rendered2.result.current.accountCommentByCid.state).toBe("succeeded"); }); test("useAccountComments falls back to full scans when indexes are missing", () => { @@ -2165,7 +2174,7 @@ describe("accounts", () => { }); expect(rendered2.result.current.comments.accountComments[0].timestamp).toBe(now - 100); - expect(rendered2.result.current.votes.accountVotes[0].commentCid).toBe("comment cid 3"); + expect(rendered2.result.current.votes.accountVotes[0].commentCid).toBe("comment cid 1"); }); test("useAccountVotes supports additive filters and pagination", () => { @@ -2229,6 +2238,43 @@ describe("accounts", () => { expect(rendered2.result.current.allVotes.accountVotes.length).toBeGreaterThan(0); }); + test("useAccountVotes sorts by timestamp before applying sortType and pagination", () => { + const now = Math.floor(Date.now() / 1000); + const accountId = accountsStore.getState().activeAccountId!; + accountsStore.setState((state: any) => ({ + ...state, + accountsVotes: { + ...state.accountsVotes, + [accountId]: { + "older-cid": { + ...state.accountsVotes[accountId]["comment cid 1"], + commentCid: "older-cid", + vote: 1, + timestamp: now, + }, + "middle-cid": { + ...state.accountsVotes[accountId]["comment cid 2"], + commentCid: "middle-cid", + vote: 1, + timestamp: now - 100, + }, + "newer-cid": { + ...state.accountsVotes[accountId]["comment cid 3"], + commentCid: "newer-cid", + vote: 1, + timestamp: now - 200, + }, + }, + }, + })); + + const rendered2 = renderHook(() => + useAccountVotes({ sortType: "new", page: 0, pageSize: 1 }), + ); + + expect(rendered2.result.current.accountVotes[0].commentCid).toBe("older-cid"); + }); + test(`get account vote on a specific comment`, () => { rendered.rerender({ filter: (vote: AccountVote) => vote.commentCid === "comment cid 3", diff --git a/src/hooks/accounts/accounts.ts b/src/hooks/accounts/accounts.ts index fecf765a..279717a9 100644 --- a/src/hooks/accounts/accounts.ts +++ b/src/hooks/accounts/accounts.ts @@ -521,21 +521,28 @@ export function useAccountComment(options?: UseAccountCommentOptions): UseAccoun : commentCidToAccountComment?.accountId === accountId ? commentCidToAccountComment.accountCommentIndex : undefined; - const accountComment: any = useMemo(() => { + const storedAccountComment = useMemo(() => { if (typeof resolvedCommentIndex !== "number") { - return {}; + return undefined; } - return accountComments?.[resolvedCommentIndex] || {}; + return accountComments?.[resolvedCommentIndex]; }, [accountComments, resolvedCommentIndex]); - const state = accountComment.state || "initializing"; + const accountComment = (storedAccountComment || {}) as Partial & { + error?: Error; + errors?: Error[]; + }; + const state = storedAccountComment + ? getAccountCommentsStates([storedAccountComment])[0] + : "initializing"; return useMemo( - () => ({ - ...accountComment, - state, - error: accountComment.error, - errors: accountComment.errors || [], - }), + () => + ({ + ...accountComment, + state, + error: accountComment.error, + errors: accountComment.errors || [], + }) as UseAccountCommentResult, [accountComment, state], ); } @@ -601,6 +608,9 @@ export function useAccountVotes(options?: UseAccountVotesOptions): UseAccountVot if (filter) { accountVotesArray = accountVotesArray.filter(filter); } + accountVotesArray = [...accountVotesArray].sort( + (firstVote, secondVote) => (firstVote.timestamp || 0) - (secondVote.timestamp || 0), + ); if (accountHistorySortType === "new") { accountVotesArray = [...accountVotesArray].reverse(); } diff --git a/src/stores/accounts/accounts-actions-internal.ts b/src/stores/accounts/accounts-actions-internal.ts index f6d1d6d3..c8e22395 100644 --- a/src/stores/accounts/accounts-actions-internal.ts +++ b/src/stores/accounts/accounts-actions-internal.ts @@ -149,7 +149,7 @@ export const startUpdatingAccountCommentOnCommentUpdateEvents = async ( updatedComment: storedUpdatedComment, account, }); - accountsStore.setState(({ accountsComments }) => { + accountsStore.setState(({ accountsComments, accountsCommentsIndexes }) => { // account no longer exists if (!accountsComments[account.id]) { log.error( @@ -175,8 +175,10 @@ export const startUpdatingAccountCommentOnCommentUpdateEvents = async ( return { accountsComments: nextAccountsComments, accountsCommentsIndexes: { - ...accountsStore.getState().accountsCommentsIndexes, - [account.id]: getAccountsCommentsIndexes(nextAccountsComments)[account.id], + ...accountsCommentsIndexes, + [account.id]: getAccountsCommentsIndexes({ + [account.id]: updatedAccountComments, + } as AccountsComments)[account.id], }, }; }); @@ -280,29 +282,32 @@ export const addCidToAccountComment = async (comment: Comment) => { accountCommentIndex: accountComment.index, accountComment: commentWithCid, }); - accountsStore.setState(({ accountsComments, commentCidsToAccountsComments }) => { - const updatedAccountComments = [...accountsComments[accountComment.accountId]]; - updatedAccountComments[accountComment.index] = commentWithCid; - const newAccountsComments = { - ...accountsComments, - [accountComment.accountId]: updatedAccountComments, - }; - return { - accountsComments: newAccountsComments, - accountsCommentsIndexes: { - ...accountsStore.getState().accountsCommentsIndexes, - [accountComment.accountId]: - getAccountsCommentsIndexes(newAccountsComments)[accountComment.accountId], - }, - commentCidsToAccountsComments: { - ...commentCidsToAccountsComments, - [comment.cid]: { - accountId: accountComment.accountId, - accountCommentIndex: accountComment.index, + accountsStore.setState( + ({ accountsComments, accountsCommentsIndexes, commentCidsToAccountsComments }) => { + const updatedAccountComments = [...accountsComments[accountComment.accountId]]; + updatedAccountComments[accountComment.index] = commentWithCid; + const newAccountsComments = { + ...accountsComments, + [accountComment.accountId]: updatedAccountComments, + }; + return { + accountsComments: newAccountsComments, + accountsCommentsIndexes: { + ...accountsCommentsIndexes, + [accountComment.accountId]: getAccountsCommentsIndexes({ + [accountComment.accountId]: updatedAccountComments, + } as AccountsComments)[accountComment.accountId], }, - }, - }; - }); + commentCidsToAccountsComments: { + ...commentCidsToAccountsComments, + [comment.cid]: { + accountId: accountComment.accountId, + accountCommentIndex: accountComment.index, + }, + }, + }; + }, + ); startUpdatingAccountCommentOnCommentUpdateEvents( comment, diff --git a/src/stores/accounts/accounts-actions.test.ts b/src/stores/accounts/accounts-actions.test.ts index ef421b81..e6aa9bc1 100644 --- a/src/stores/accounts/accounts-actions.test.ts +++ b/src/stores/accounts/accounts-actions.test.ts @@ -344,6 +344,23 @@ describe("accounts-actions", () => { ); expect(result.accountsEditsSummaries.acc1["cid-1"].spoiler.value).toBe(true); }); + + test("removeStoredAccountEditSummaryFromState recalculates summary after removing one edit", () => { + const result = accountsActions.removeStoredAccountEditSummaryFromState( + { acc1: { "cid-1": { spoiler: { timestamp: 2, value: false } } } } as any, + { + acc1: { + "cid-1": [ + { commentCid: "cid-1", spoiler: true, timestamp: 1, clientId: "older" }, + { commentCid: "cid-1", spoiler: false, timestamp: 2, clientId: "newer" }, + ], + }, + } as any, + "acc1", + { commentCid: "cid-1", spoiler: false, timestamp: 2, clientId: "newer" }, + ); + expect(result.accountsEditsSummaries.acc1["cid-1"].spoiler.value).toBe(true); + }); }); describe("edit helper branches", () => { @@ -369,6 +386,14 @@ describe("accounts-actions", () => { expect(nextState.accountsEdits.acc1["cid-1"][0].spoiler).toBe(true); }); + test("addStoredAccountEditToState uses community edit targets when commentCid is missing", () => { + const nextState = accountsActions.addStoredAccountEditToState({} as any, "acc1", { + communityAddress: "community.eth", + title: "updated", + }); + expect(nextState.accountsEdits.acc1["community.eth"][0].title).toBe("updated"); + }); + test("removeStoredAccountEditFromState handles missing account and comment buckets", () => { const nextState = accountsActions.removeStoredAccountEditFromState({} as any, "acc1", { commentCid: "cid-1", @@ -529,6 +554,37 @@ describe("accounts-actions", () => { expect(accountNamesToAccountIds["ToDelete"]).toBeUndefined(); }); + test("deleteAccount removes comment cid mappings for the deleted account", async () => { + await act(async () => { + await accountsActions.createAccount("ToDeleteWithComment"); + }); + + await act(async () => { + await accountsActions.publishComment( + { + communityAddress: "sub.eth", + content: "delete-account-comment", + onChallenge: (challenge: any, comment: any) => comment.publishChallengeAnswers(), + onChallengeVerification: () => {}, + }, + "ToDeleteWithComment", + ); + }); + + await new Promise((r) => setTimeout(r, 150)); + expect( + accountsStore.getState().commentCidsToAccountsComments["delete-account-comment cid"], + ).toBeDefined(); + + await act(async () => { + await accountsActions.deleteAccount("ToDeleteWithComment"); + }); + + expect( + accountsStore.getState().commentCidsToAccountsComments["delete-account-comment cid"], + ).toBeUndefined(); + }); + test("publishComment with accountName uses named account", async () => { await act(async () => { await accountsActions.createAccount(); @@ -1425,6 +1481,56 @@ describe("accounts-actions", () => { expect(rendered.result.current.comments?.length).toBe(0); }); + test("deleteComment does not recreate a deleted pending comment after delayed link metadata save", async () => { + let resolveDimensions: ((value: any) => void) | undefined; + const utilsMod = await import("./utils"); + vi.spyOn(utilsMod, "fetchCommentLinkDimensions").mockImplementation( + () => + new Promise((resolve) => { + resolveDimensions = resolve; + }) as any, + ); + + const rendered = renderHook(() => { + const { accountsComments, activeAccountId } = accountsStore.getState(); + const comments = + activeAccountId && accountsComments ? accountsComments?.[activeAccountId] || [] : []; + return { + comments, + publishComment: accountsActions.publishComment, + deleteComment: accountsActions.deleteComment, + }; + }); + const waitFor = testUtils.createWaitFor(rendered); + + await act(async () => { + await accountsActions.publishComment({ + communityAddress: "sub.eth", + content: "link-delete", + link: "https://example.com/image.png", + onChallenge: (challenge: any, comment: any) => comment.publishChallengeAnswers(), + onChallengeVerification: () => {}, + }); + }); + + await waitFor(() => (rendered.result.current.comments?.length ?? 0) >= 1); + + await act(async () => { + await accountsActions.deleteComment(0); + }); + + await act(async () => { + resolveDimensions?.({ + linkWidth: 100, + linkHeight: 50, + linkHtmlTagName: "img", + }); + }); + + await new Promise((r) => setTimeout(r, 100)); + expect(rendered.result.current.comments?.length).toBe(0); + }); + test("subscribe already subscribed throws", async () => { await act(async () => { await accountsActions.subscribe("sub1.eth"); @@ -1687,6 +1793,51 @@ describe("accounts-actions", () => { }); expect(accountsStore.getState().accountsEdits[account.id].cid).toHaveLength(1); + expect(accountsStore.getState().accountsEditsSummaries[account.id]?.cid).toBeDefined(); + }); + + test("publishCommentEdit keeps accountsEditsLoaded false until lazy hydration completes", async () => { + const account = Object.values(accountsStore.getState().accounts)[0]; + accountsStore.setState(({ accountsEditsLoaded }) => ({ + accountsEditsLoaded: { + ...accountsEditsLoaded, + [account.id]: false, + }, + })); + + await act(async () => { + await accountsActions.publishCommentEdit({ + communityAddress: "sub.eth", + commentCid: "cold-history-cid", + spoiler: true, + onChallenge: (challenge: any, edit: any) => edit.publishChallengeAnswers(), + onChallengeVerification: () => {}, + } as any); + }); + + expect(accountsStore.getState().accountsEditsLoaded[account.id]).toBe(false); + }); + + test("publishCommentModeration keeps accountsEditsLoaded false until lazy hydration completes", async () => { + const account = Object.values(accountsStore.getState().accounts)[0]; + accountsStore.setState(({ accountsEditsLoaded }) => ({ + accountsEditsLoaded: { + ...accountsEditsLoaded, + [account.id]: false, + }, + })); + + await act(async () => { + await accountsActions.publishCommentModeration({ + communityAddress: "sub.eth", + commentCid: "cold-history-moderation-cid", + commentModeration: { removed: true }, + onChallenge: (challenge: any, moderation: any) => moderation.publishChallengeAnswers(), + onChallengeVerification: () => {}, + } as any); + }); + + expect(accountsStore.getState().accountsEditsLoaded[account.id]).toBe(false); }); test("publishComment with link fetches dimensions and onPublishingStateChange", async () => { diff --git a/src/stores/accounts/accounts-actions.ts b/src/stores/accounts/accounts-actions.ts index fe0ad206..29a8a58e 100644 --- a/src/stores/accounts/accounts-actions.ts +++ b/src/stores/accounts/accounts-actions.ts @@ -50,16 +50,18 @@ import utils from "../../lib/utils"; // Active publish-session tracking for pending comments (Task 3) const activePublishSessions = new Map< string, - { comment: any; abandoned: boolean; currentIndex: number } + { comment?: any; abandoned: boolean; currentIndex: number } >(); const abandonedPublishKeys = new Set(); const getPublishSessionKey = (accountId: string, index: number) => `${accountId}:${index}`; -const registerPublishSession = (accountId: string, index: number, comment: any) => { - activePublishSessions.set(getPublishSessionKey(accountId, index), { +const registerPublishSession = (accountId: string, index: number, comment?: any) => { + const key = getPublishSessionKey(accountId, index); + const previousSession = activePublishSessions.get(key); + activePublishSessions.set(key, { comment, abandoned: false, - currentIndex: index, + currentIndex: previousSession?.currentIndex ?? index, }); }; @@ -81,6 +83,10 @@ const isPublishSessionAbandoned = (accountId: string, index: number) => { return abandonedPublishKeys.has(getPublishSessionKey(accountId, index)); }; +const getPublishSessionByIndex = (accountId: string, index: number) => { + return activePublishSessions.get(getPublishSessionKey(accountId, index)); +}; + /** Returns state update or {} when accountComment not yet in state (no-op). Exported for coverage. */ export const maybeUpdateAccountComment = ( accountsComments: Record, @@ -149,15 +155,17 @@ const accountEditNonPropertyNames = new Set([ "timestamp", ]); +const getStoredAccountEditTarget = (storedAccountEdit: any) => + storedAccountEdit.commentCid || + storedAccountEdit.communityAddress || + storedAccountEdit.subplebbitAddress; + export const addStoredAccountEditSummaryToState = ( accountsEditsSummaries: Record>, accountId: string, storedAccountEdit: any, ) => { - const editTarget = - storedAccountEdit.commentCid || - storedAccountEdit.communityAddress || - storedAccountEdit.subplebbitAddress; + const editTarget = getStoredAccountEditTarget(storedAccountEdit); if (!editTarget) { return { accountsEditsSummaries }; } @@ -202,17 +210,20 @@ export const removeStoredAccountEditSummaryFromState = ( accountId: string, storedAccountEdit: any, ) => { - const editTarget = - storedAccountEdit.commentCid || - storedAccountEdit.communityAddress || - storedAccountEdit.subplebbitAddress; + const editTarget = getStoredAccountEditTarget(storedAccountEdit); if (!editTarget) { return { accountsEditsSummaries }; } - const nextTargetSummary = getAccountEditPropertySummary( - accountsEdits[accountId]?.[editTarget] || [], - ); + let deletedEdit = false; + const editsForTarget = (accountsEdits[accountId]?.[editTarget] || []).filter((storedEdit) => { + if (!deletedEdit && doesStoredAccountEditMatch(storedEdit, storedAccountEdit)) { + deletedEdit = true; + return false; + } + return true; + }); + const nextTargetSummary = getAccountEditPropertySummary(editsForTarget); const nextAccountSummary = { ...(accountsEditsSummaries[accountId] || {}) }; if (Object.keys(nextTargetSummary).length > 0) { nextAccountSummary[editTarget] = nextTargetSummary; @@ -248,13 +259,17 @@ export const addStoredAccountEditToState = ( storedAccountEdit: any, ) => { const accountEdits = accountsEdits[accountId] || {}; - const commentEdits = accountEdits[storedAccountEdit.commentCid] || []; + const editTarget = getStoredAccountEditTarget(storedAccountEdit); + if (!editTarget) { + return { accountsEdits }; + } + const commentEdits = accountEdits[editTarget] || []; return { accountsEdits: { ...accountsEdits, [accountId]: { ...accountEdits, - [storedAccountEdit.commentCid]: [...commentEdits, storedAccountEdit], + [editTarget]: [...commentEdits, storedAccountEdit], }, }, }; @@ -266,7 +281,11 @@ export const removeStoredAccountEditFromState = ( storedAccountEdit: any, ) => { const accountEdits = accountsEdits[accountId] || {}; - const commentEdits = accountEdits[storedAccountEdit.commentCid] || []; + const editTarget = getStoredAccountEditTarget(storedAccountEdit); + if (!editTarget) { + return { accountsEdits }; + } + const commentEdits = accountEdits[editTarget] || []; let deletedEdit = false; const nextCommentEdits = commentEdits.filter((commentEdit) => { if (!deletedEdit && doesStoredAccountEditMatch(commentEdit, storedAccountEdit)) { @@ -280,12 +299,10 @@ export const removeStoredAccountEditFromState = ( nextCommentEdits.length > 0 ? { ...accountEdits, - [storedAccountEdit.commentCid]: nextCommentEdits, + [editTarget]: nextCommentEdits, } : Object.fromEntries( - Object.entries(accountEdits).filter( - ([commentCid]) => commentCid !== storedAccountEdit.commentCid, - ), + Object.entries(accountEdits).filter(([target]) => target !== editTarget), ); return { @@ -386,6 +403,7 @@ export const deleteAccount = async (accountName?: string) => { delete newAccountsComments[account.id]; const newAccountsCommentsIndexes = { ...accountsCommentsIndexes }; delete newAccountsCommentsIndexes[account.id]; + const newCommentCidsToAccountsComments = getCommentCidsToAccountsComments(newAccountsComments); const newAccountsVotes = { ...accountsVotes }; delete newAccountsVotes[account.id]; const newAccountsEdits = { ...accountsEdits }; @@ -402,6 +420,7 @@ export const deleteAccount = async (accountName?: string) => { accountNamesToAccountIds: newAccountNamesToAccountIds, accountsComments: newAccountsComments, accountsCommentsIndexes: newAccountsCommentsIndexes, + commentCidsToAccountsComments: newCommentCidsToAccountsComments, accountsVotes: newAccountsVotes, accountsEdits: newAccountsEdits, accountsEditsSummaries: newAccountsEditsSummaries, @@ -895,18 +914,35 @@ export const publishComment = async ( let accountCommentIndex = accountsComments[account.id].length; let savedOnce = false; const saveCreatedAccountComment = async (accountComment: AccountComment) => { + if (isPublishSessionAbandoned(account.id, accountCommentIndex)) { + return; + } + const isUpdate = savedOnce; + const session = getPublishSessionByIndex(account.id, accountCommentIndex); + const currentIndex = session?.currentIndex ?? accountCommentIndex; const sanitizedAccountComment = addShortAddressesToAccountComment( sanitizeStoredAccountComment(accountComment), ) as AccountComment; + const liveAccountComments = accountsStore.getState().accountsComments[account.id] || []; + if (isUpdate && !liveAccountComments[currentIndex]) { + return; + } await accountsDatabase.addAccountComment( account.id, sanitizedAccountComment, - savedOnce ? accountCommentIndex : undefined, + isUpdate ? currentIndex : undefined, ); savedOnce = true; accountsStore.setState(({ accountsComments, accountsCommentsIndexes }) => { const accountComments = [...accountsComments[account.id]]; - accountComments[accountCommentIndex] = sanitizedAccountComment; + if (isUpdate && !accountComments[currentIndex]) { + return {}; + } + accountComments[currentIndex] = { + ...sanitizedAccountComment, + index: currentIndex, + accountId: account.id, + }; return { accountsComments: { ...accountsComments, [account.id]: accountComments }, accountsCommentsIndexes: { @@ -926,6 +962,7 @@ export const publishComment = async ( sanitizeStoredAccountComment(createdAccountComment), ); await saveCreatedAccountComment(createdAccountComment); + registerPublishSession(account.id, accountCommentIndex); publishCommentOptions._onPendingCommentIndex?.(accountCommentIndex, createdAccountComment); let comment: any; @@ -938,6 +975,9 @@ export const publishComment = async ( createdAccountComment = { ...createdAccountComment, ...commentLinkDimensions }; await saveCreatedAccountComment(createdAccountComment); } + if (isPublishSessionAbandoned(account.id, accountCommentIndex)) { + return; + } comment = backfillPublicationCommunityAddress( await account.plebbit.createComment(createCommentOptions), createCommentOptions, @@ -948,7 +988,9 @@ export const publishComment = async ( let lastChallenge: Challenge | undefined; async function publishAndRetryFailedChallengeVerification() { - cleanupPublishSessionOnTerminal(account.id, accountCommentIndex); + if (isPublishSessionAbandoned(account.id, accountCommentIndex)) { + return; + } registerPublishSession(account.id, accountCommentIndex, comment); comment.once("challenge", async (challenge: Challenge) => { lastChallenge = challenge; @@ -962,6 +1004,9 @@ export const publishComment = async ( createCommentOptions = { ...createCommentOptions, timestamp }; createdAccountComment = { ...createdAccountComment, timestamp }; await saveCreatedAccountComment(createdAccountComment); + if (isPublishSessionAbandoned(account.id, accountCommentIndex)) { + return; + } comment = backfillPublicationCommunityAddress( await account.plebbit.createComment(createCommentOptions), createCommentOptions, @@ -971,11 +1016,11 @@ export const publishComment = async ( } else { // the challengeverification message of a comment publication should in theory send back the CID // of the published comment which is needed to resolve it for replies, upvotes, etc + const sessionInfo = getPublishSessionForComment(account.id, comment); + const currentIndex = sessionInfo?.currentIndex ?? accountCommentIndex; + if (!sessionInfo || abandonedPublishKeys.has(sessionInfo.sessionKey)) return; + queueMicrotask(() => cleanupPublishSessionOnTerminal(account.id, sessionInfo.keyIndex)); if (challengeVerification?.commentUpdate?.cid) { - const sessionInfo = getPublishSessionForComment(account.id, comment); - const currentIndex = sessionInfo?.currentIndex ?? accountCommentIndex; - if (!sessionInfo || abandonedPublishKeys.has(sessionInfo.sessionKey)) return; - cleanupPublishSessionOnTerminal(account.id, sessionInfo.keyIndex); const commentWithCid = addShortAddressesToAccountComment( sanitizeStoredAccountComment(normalizePublicationOptionsForStore(comment as any)), ); @@ -1031,20 +1076,24 @@ export const publishComment = async ( }); comment.on("error", (error: Error) => { - if (isPublishSessionAbandoned(account.id, accountCommentIndex)) return; + const session = getPublishSessionByIndex(account.id, accountCommentIndex); + if (!session || isPublishSessionAbandoned(account.id, accountCommentIndex)) return; + const currentIndex = session.currentIndex; accountsStore.setState(({ accountsComments }) => - maybeUpdateAccountComment(accountsComments, account.id, accountCommentIndex, (ac, acc) => { + maybeUpdateAccountComment(accountsComments, account.id, currentIndex, (ac, acc) => { const errors = [...(acc.errors || []), error]; - ac[accountCommentIndex] = { ...acc, errors, error }; + ac[currentIndex] = { ...acc, errors, error }; }), ); publishCommentOptions.onError?.(error, comment); }); comment.on("publishingstatechange", async (publishingState: string) => { - if (isPublishSessionAbandoned(account.id, accountCommentIndex)) return; + const session = getPublishSessionByIndex(account.id, accountCommentIndex); + if (!session || isPublishSessionAbandoned(account.id, accountCommentIndex)) return; + const currentIndex = session.currentIndex; accountsStore.setState(({ accountsComments }) => - maybeUpdateAccountComment(accountsComments, account.id, accountCommentIndex, (ac, acc) => { - ac[accountCommentIndex] = { ...acc, publishingState }; + maybeUpdateAccountComment(accountsComments, account.id, currentIndex, (ac, acc) => { + ac[currentIndex] = { ...acc, publishingState }; }), ); publishCommentOptions.onPublishingStateChange?.(publishingState); @@ -1054,24 +1103,21 @@ export const publishComment = async ( utils.clientsOnStateChange( comment.clients, (clientState: string, clientType: string, clientUrl: string, chainTicker?: string) => { - if (isPublishSessionAbandoned(account.id, accountCommentIndex)) return; + const session = getPublishSessionByIndex(account.id, accountCommentIndex); + if (!session || isPublishSessionAbandoned(account.id, accountCommentIndex)) return; + const currentIndex = session.currentIndex; accountsStore.setState(({ accountsComments }) => - maybeUpdateAccountComment( - accountsComments, - account.id, - accountCommentIndex, - (ac, acc) => { - const clients = { ...comment.clients }; - const client = { state: clientState }; - if (chainTicker) { - const chainProviders = { ...clients[clientType][chainTicker], [clientUrl]: client }; - clients[clientType] = { ...clients[clientType], [chainTicker]: chainProviders }; - } else { - clients[clientType] = { ...clients[clientType], [clientUrl]: client }; - } - ac[accountCommentIndex] = { ...acc, clients }; - }, - ), + maybeUpdateAccountComment(accountsComments, account.id, currentIndex, (ac, acc) => { + const clients = { ...comment.clients }; + const client = { state: clientState }; + if (chainTicker) { + const chainProviders = { ...clients[clientType][chainTicker], [clientUrl]: client }; + clients[clientType] = { ...clients[clientType], [chainTicker]: chainProviders }; + } else { + clients[clientType] = { ...clients[clientType], [clientUrl]: client }; + } + ac[currentIndex] = { ...acc, clients }; + }), ); }, ); @@ -1283,21 +1329,19 @@ export const publishCommentEdit = async ( rollbackPendingEditPromise = Promise.all([ accountsDatabase.deleteAccountEdit(account.id, storedCommentEdit), Promise.resolve( - accountsStore.setState( - ({ accountsEdits, accountsEditsLoaded, accountsEditsSummaries }) => { - const nextState: any = removeStoredAccountEditSummaryFromState( - accountsEditsSummaries, - accountsEdits, - account.id, - storedCommentEdit, - ); - Object.assign( - nextState, - removeStoredAccountEditFromState(accountsEdits, account.id, storedCommentEdit), - ); - return nextState; - }, - ), + accountsStore.setState(({ accountsEdits, accountsEditsSummaries }) => { + const nextState: any = removeStoredAccountEditSummaryFromState( + accountsEditsSummaries, + accountsEdits, + account.id, + storedCommentEdit, + ); + Object.assign( + nextState, + removeStoredAccountEditFromState(accountsEdits, account.id, storedCommentEdit), + ); + return nextState; + }), ), ]).then(() => {}); } @@ -1306,7 +1350,7 @@ export const publishCommentEdit = async ( await accountsDatabase.addAccountEdit(account.id, storedCreateCommentEditOptions); log("accountsActions.publishCommentEdit", { createCommentEditOptions }); - accountsStore.setState(({ accountsEdits, accountsEditsLoaded, accountsEditsSummaries }) => { + accountsStore.setState(({ accountsEdits, accountsEditsSummaries }) => { const nextState: any = addStoredAccountEditSummaryToState( accountsEditsSummaries, account.id, @@ -1316,7 +1360,6 @@ export const publishCommentEdit = async ( nextState, addStoredAccountEditToState(accountsEdits, account.id, storedCommentEdit), ); - nextState.accountsEditsLoaded = { ...accountsEditsLoaded, [account.id]: true }; return nextState; }); @@ -1461,7 +1504,7 @@ export const publishCommentModeration = async ( await accountsDatabase.addAccountEdit(account.id, storedCreateCommentModerationOptions); log("accountsActions.publishCommentModeration", { createCommentModerationOptions }); - accountsStore.setState(({ accountsEdits, accountsEditsLoaded, accountsEditsSummaries }) => { + accountsStore.setState(({ accountsEdits, accountsEditsSummaries }) => { // remove signer and author because not needed and they expose private key const commentModeration = { ...storedCreateCommentModerationOptions, @@ -1483,7 +1526,6 @@ export const publishCommentModeration = async ( [storedCreateCommentModerationOptions.commentCid]: commentModerations, }, }; - nextState.accountsEditsLoaded = { ...accountsEditsLoaded, [account.id]: true }; return nextState; }); }; diff --git a/src/stores/accounts/accounts-database.test.ts b/src/stores/accounts/accounts-database.test.ts index 4718c367..31ebbfb1 100644 --- a/src/stores/accounts/accounts-database.test.ts +++ b/src/stores/accounts/accounts-database.test.ts @@ -947,6 +947,29 @@ describe("accounts-database", () => { expect(edits[acc.id]["ec1"]).toHaveLength(1); }); + test("addAccountEdit and deleteAccountEdit support community edit targets", async () => { + const acc = makeAccount({ id: "ge-community-edit", name: "GECommunityEdit" }); + await accountsDatabase.addAccount(acc); + await accountsDatabase.addAccountEdit(acc.id, { + communityAddress: "community.eth", + title: "community edit", + timestamp: 1, + } as any); + + let edits = await accountsDatabase.getAccountEdits(acc.id); + expect(edits["community.eth"]).toHaveLength(1); + + const deleted = await accountsDatabase.deleteAccountEdit(acc.id, { + communityAddress: "community.eth", + title: "community edit", + timestamp: 1, + } as any); + + expect(deleted).toBe(true); + edits = await accountsDatabase.getAccountEdits(acc.id); + expect(edits["community.eth"]).toBeUndefined(); + }); + test("getAccountEdits accumulates multiple edits for same commentCid", async () => { const acc = makeAccount({ id: "ge-multi", name: "GEMulti" }); await accountsDatabase.addAccount(acc); diff --git a/src/stores/accounts/accounts-database.ts b/src/stores/accounts/accounts-database.ts index 380037f4..bd05ef21 100644 --- a/src/stores/accounts/accounts-database.ts +++ b/src/stores/accounts/accounts-database.ts @@ -642,10 +642,8 @@ const ensureAccountEditsDatabaseLayout = async (accountId: string) => { }; const addAccountEdit = async (accountId: string, createEditOptions: CreateCommentOptions) => { - assert( - createEditOptions?.commentCid && typeof createEditOptions?.commentCid === "string", - `addAccountEdit createEditOptions.commentCid '${createEditOptions?.commentCid}' not a string`, - ); + const editTarget = getAccountEditTarget(createEditOptions as AccountEdit); + assert(typeof editTarget === "string", `addAccountEdit target '${editTarget}' not a string`); const accountEditsDatabase = getAccountEditsDatabase(accountId); await ensureAccountEditsDatabaseLayout(accountId); const length = (await accountEditsDatabase.getItem("length")) || 0; @@ -666,10 +664,8 @@ const doesStoredAccountEditMatch = (storedAccountEdit: any, targetStoredAccountE : isEqual(storedAccountEdit, targetStoredAccountEdit); const deleteAccountEdit = async (accountId: string, editToDelete: CreateCommentOptions) => { - assert( - editToDelete?.commentCid && typeof editToDelete?.commentCid === "string", - `deleteAccountEdit editToDelete.commentCid '${editToDelete?.commentCid}' not a string`, - ); + const editTarget = getAccountEditTarget(editToDelete as AccountEdit); + assert(typeof editTarget === "string", `deleteAccountEdit target '${editTarget}' not a string`); const accountEditsDatabase = getAccountEditsDatabase(accountId); await ensureAccountEditsDatabaseLayout(accountId); const length = (await accountEditsDatabase.getItem("length")) || 0; diff --git a/src/stores/accounts/accounts-store.ts b/src/stores/accounts/accounts-store.ts index 89f0c4c7..d5fb6e28 100644 --- a/src/stores/accounts/accounts-store.ts +++ b/src/stores/accounts/accounts-store.ts @@ -120,6 +120,7 @@ const initializeAccountsStore = async () => { commentCidsToAccountsComments, accountsVotes, accountsCommentsReplies, + // Keep accountsEditsSummaries hot while accountsEdits stays cold until accountsEditsLoaded flips true. accountsEdits: Object.fromEntries(accountIds.map((accountId) => [accountId, {}])), accountsEditsSummaries, accountsEditsLoaded: Object.fromEntries(accountIds.map((accountId) => [accountId, false])), diff --git a/src/stores/accounts/utils.ts b/src/stores/accounts/utils.ts index 2dccb9b1..fb2bcb40 100644 --- a/src/stores/accounts/utils.ts +++ b/src/stores/accounts/utils.ts @@ -88,13 +88,22 @@ const cloneWithoutFunctions = (value: any): any => { }; export const sanitizeStoredAccountComment = (comment: Comment) => { - const sanitizedComment = cloneWithoutFunctions({ ...comment, signer: undefined }); + const preprocessedComment = { + ...comment, + signer: undefined, + replies: comment?.replies + ? Object.fromEntries( + Object.entries(comment.replies).filter(([replyKey]) => replyKey !== "pages"), + ) + : comment?.replies, + }; + const sanitizedComment = cloneWithoutFunctions(preprocessedComment); if (sanitizedComment?.replies?.pages) { sanitizedComment.replies = { ...sanitizedComment.replies }; delete sanitizedComment.replies.pages; - if (Object.keys(sanitizedComment.replies).length === 0) { - delete sanitizedComment.replies; - } + } + if (sanitizedComment?.replies && Object.keys(sanitizedComment.replies).length === 0) { + delete sanitizedComment.replies; } return sanitizedComment; }; From 7feb65d5f351c4a231f3cc9f34e7c6710b6f7cc2 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Fri, 20 Mar 2026 13:54:57 +0800 Subject: [PATCH 4/4] fix(accounts): address final account history review findings --- src/hooks/accounts/accounts.test.ts | 5 +- src/hooks/accounts/accounts.ts | 19 +-- .../accounts-actions-internal.test.ts | 51 ++++++++ .../accounts/accounts-actions-internal.ts | 43 ++++++- src/stores/accounts/accounts-actions.test.ts | 99 ++++++++++++++ src/stores/accounts/accounts-actions.ts | 121 +++++++++--------- src/stores/accounts/accounts-database.test.ts | 71 ++++++++++ src/stores/accounts/accounts-database.ts | 16 +-- 8 files changed, 344 insertions(+), 81 deletions(-) diff --git a/src/hooks/accounts/accounts.test.ts b/src/hooks/accounts/accounts.test.ts index a7212e4e..fa807165 100644 --- a/src/hooks/accounts/accounts.test.ts +++ b/src/hooks/accounts/accounts.test.ts @@ -1339,9 +1339,10 @@ describe("accounts", () => { try { const rendered2 = renderHook(() => useAccountEdits()); + const waitFor = testUtils.createWaitFor(rendered2); expect(rendered2.result.current.state).toBe("initializing"); - await Promise.resolve(); - expect(mockedEnsure).toHaveBeenCalled(); + await waitFor(() => mockedEnsure.mock.calls.length === 1); + expect(mockedEnsure).toHaveBeenCalledWith(accountId); } finally { store.default.setState({ accountsActionsInternal: internal, diff --git a/src/hooks/accounts/accounts.ts b/src/hooks/accounts/accounts.ts index 279717a9..f242ccbb 100644 --- a/src/hooks/accounts/accounts.ts +++ b/src/hooks/accounts/accounts.ts @@ -708,19 +708,22 @@ export function useAccountEdits(options?: UseAccountEditsOptions): UseAccountEdi `useAccountEdits options.filter argument '${filter}' not an function`, ); const accountId = useAccountId(accountName); - const accountActionsInternal = useAccountsStore((state) => state.accountsActionsInternal); + const ensureAccountEditsLoaded = useAccountsStore( + (state) => state.accountsActionsInternal.ensureAccountEditsLoaded, + ); const accountEdits = useAccountsStore((state) => state.accountsEdits[accountId || ""]); const accountEditsLoaded = useAccountsStore( (state) => state.accountsEditsLoaded[accountId || ""], ); - if (accountId && !accountEditsLoaded) { - accountActionsInternal - .ensureAccountEditsLoaded(accountId) - .catch((error: unknown) => - log.error("useAccountEdits ensureAccountEditsLoaded error", { accountId, error }), - ); - } + useEffect(() => { + if (!accountId || accountEditsLoaded) { + return; + } + ensureAccountEditsLoaded(accountId).catch((error: unknown) => + log.error("useAccountEdits ensureAccountEditsLoaded error", { accountId, error }), + ); + }, [accountEditsLoaded, accountId, ensureAccountEditsLoaded]); const accountEditsArray = useMemo(() => { const accountEditsArray = []; diff --git a/src/stores/accounts/accounts-actions-internal.test.ts b/src/stores/accounts/accounts-actions-internal.test.ts index dcb593d6..9618d28d 100644 --- a/src/stores/accounts/accounts-actions-internal.test.ts +++ b/src/stores/accounts/accounts-actions-internal.test.ts @@ -17,6 +17,57 @@ describe("accounts-actions-internal", () => { testUtils.restoreAll(); }); + describe("ensureAccountEditsLoaded", () => { + beforeEach(async () => { + await testUtils.resetDatabasesAndStores(); + }); + + test("merges fetched edits with optimistic edits added while lazy load is in flight", async () => { + const account = Object.values(accountsStore.getState().accounts)[0]; + let resolveLoadedEdits: ((value: any) => void) | undefined; + const getAccountEditsSpy = vi + .spyOn(accountsDatabase, "getAccountEdits") + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLoadedEdits = resolve; + }) as any, + ); + + const loadPromise = accountsActionsInternal.ensureAccountEditsLoaded(account.id); + const optimisticEdit = { + commentCid: "cid-merge", + content: "optimistic", + timestamp: 20, + clientId: "optimistic-1", + }; + accountsStore.setState(({ accountsEdits }) => ({ + accountsEdits: { + ...accountsEdits, + [account.id]: { + "cid-merge": [optimisticEdit], + }, + }, + })); + + resolveLoadedEdits?.({ + "cid-merge": [{ commentCid: "cid-merge", spoiler: true, timestamp: 10 }], + "cid-loaded": [{ commentCid: "cid-loaded", nsfw: true, timestamp: 15 }], + }); + await loadPromise; + + expect(getAccountEditsSpy).toHaveBeenCalledWith(account.id); + expect(accountsStore.getState().accountsEditsLoaded[account.id]).toBe(true); + expect(accountsStore.getState().accountsEdits[account.id]["cid-merge"]).toEqual([ + { commentCid: "cid-merge", spoiler: true, timestamp: 10 }, + optimisticEdit, + ]); + expect(accountsStore.getState().accountsEdits[account.id]["cid-loaded"]).toEqual([ + { commentCid: "cid-loaded", nsfw: true, timestamp: 15 }, + ]); + }); + }); + describe("startUpdatingAccountCommentOnCommentUpdateEvents", () => { beforeEach(async () => { await testUtils.resetDatabasesAndStores(); diff --git a/src/stores/accounts/accounts-actions-internal.ts b/src/stores/accounts/accounts-actions-internal.ts index c8e22395..6a1f2ffa 100644 --- a/src/stores/accounts/accounts-actions-internal.ts +++ b/src/stores/accounts/accounts-actions-internal.ts @@ -4,6 +4,7 @@ import accountsStore, { listeners } from "./accounts-store"; import accountsDatabase from "./accounts-database"; import Logger from "@plebbit/plebbit-logger"; import assert from "assert"; +import isEqual from "lodash.isequal"; const log = Logger("bitsocial-react-hooks:accounts:stores"); import { Account, @@ -28,6 +29,41 @@ import { const accountEditsLoadPromises = new Map>(); +const doesStoredAccountEditMatch = (storedAccountEdit: any, targetStoredAccountEdit: any) => + storedAccountEdit?.clientId && targetStoredAccountEdit?.clientId + ? storedAccountEdit.clientId === targetStoredAccountEdit.clientId + : isEqual(storedAccountEdit, targetStoredAccountEdit); + +const mergeLoadedAccountEdits = ( + loadedAccountEdits: Record | undefined, + currentAccountEdits: Record | undefined, +) => { + const mergedAccountEdits: Record = { ...(loadedAccountEdits || {}) }; + const editTargets = new Set([ + ...Object.keys(loadedAccountEdits || {}), + ...Object.keys(currentAccountEdits || {}), + ]); + + for (const editTarget of editTargets) { + const mergedTargetEdits = [...(loadedAccountEdits?.[editTarget] || [])]; + for (const currentAccountEdit of currentAccountEdits?.[editTarget] || []) { + const alreadyLoaded = mergedTargetEdits.some((loadedAccountEdit) => + doesStoredAccountEditMatch(loadedAccountEdit, currentAccountEdit), + ); + if (!alreadyLoaded) { + mergedTargetEdits.push(currentAccountEdit); + } + } + if (mergedTargetEdits.length > 0) { + mergedAccountEdits[editTarget] = mergedTargetEdits.sort( + (a, b) => (a?.timestamp || 0) - (b?.timestamp || 0), + ); + } + } + + return mergedAccountEdits; +}; + const backfillLiveCommentCommunityAddress = ( comment: Comment | undefined, communityAddress: string | undefined, @@ -383,9 +419,12 @@ export const ensureAccountEditsLoaded = async (accountId: string) => { const loadPromise = accountsDatabase .getAccountEdits(accountId) - .then((accountEdits) => { + .then((loadedAccountEdits) => { accountsStore.setState(({ accountsEdits, accountsEditsLoaded }) => ({ - accountsEdits: { ...accountsEdits, [accountId]: accountEdits }, + accountsEdits: { + ...accountsEdits, + [accountId]: mergeLoadedAccountEdits(loadedAccountEdits, accountsEdits[accountId]), + }, accountsEditsLoaded: { ...accountsEditsLoaded, [accountId]: true }, })); }) diff --git a/src/stores/accounts/accounts-actions.test.ts b/src/stores/accounts/accounts-actions.test.ts index e6aa9bc1..e7098669 100644 --- a/src/stores/accounts/accounts-actions.test.ts +++ b/src/stores/accounts/accounts-actions.test.ts @@ -1447,6 +1447,10 @@ describe("accounts-actions", () => { await testUtils.resetDatabasesAndStores(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + test("deleteComment abandons pending publish session, no-op mutation when session removed", async () => { const rendered = renderHook(() => { const { accountsComments, activeAccountId } = accountsStore.getState(); @@ -1531,6 +1535,101 @@ describe("accounts-actions", () => { expect(rendered.result.current.comments?.length).toBe(0); }); + test("deleting an earlier comment does not let a later pending publish reuse another session", async () => { + const account = Object.values(accountsStore.getState().accounts)[0]; + const origCreateComment = account.plebbit.createComment.bind(account.plebbit); + const createCommentCallCounts: Record = {}; + const liveCommentsByContent: Record = {}; + const waitForAccountComments = async ( + predicate: (accountComments: any[]) => boolean, + timeout = 2000, + ) => { + const start = Date.now(); + while (Date.now() - start < timeout) { + await act(async () => {}); + const accountComments = accountsStore.getState().accountsComments[account.id] || []; + if (predicate(accountComments)) { + return accountComments; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error("timed out waiting for account comments"); + }; + + vi.spyOn(account.plebbit, "createComment").mockImplementation(async (opts: any) => { + const content = opts.content || ""; + createCommentCallCounts[content] = (createCommentCallCounts[content] || 0) + 1; + const comment = await origCreateComment(opts); + + if (createCommentCallCounts[content] % 2 === 0) { + liveCommentsByContent[content] = comment; + if (content === "second-pending") { + vi.spyOn(comment, "publish").mockImplementation(async () => { + comment.state = "publishing"; + comment.publishingState = "publishing-challenge-request"; + comment.emit("statechange", "publishing"); + comment.emit("publishingstatechange", "publishing-challenge-request"); + }); + } + } + + return comment; + }); + + await act(async () => { + await accountsActions.publishComment({ + communityAddress: "sub.eth", + content: "first-pending", + onChallenge: (challenge: any, comment: any) => comment.publishChallengeAnswers(), + onChallengeVerification: () => {}, + }); + }); + await waitForAccountComments((accountComments) => accountComments.length >= 1); + + await act(async () => { + await accountsActions.publishComment({ + communityAddress: "sub.eth", + content: "second-pending", + onChallenge: (challenge: any, comment: any) => comment.publishChallengeAnswers(), + onChallengeVerification: () => {}, + }); + }); + await waitForAccountComments((accountComments) => accountComments.length >= 2); + + await act(async () => { + await accountsActions.deleteComment(0); + }); + await waitForAccountComments( + (accountComments) => + accountComments.length === 1 && accountComments[0]?.content === "second-pending", + ); + + await act(async () => { + await accountsActions.publishComment({ + communityAddress: "sub.eth", + content: "third-pending", + onChallenge: (challenge: any, comment: any) => comment.publishChallengeAnswers(), + onChallengeVerification: () => {}, + }); + }); + await waitForAccountComments( + (accountComments) => + accountComments.length >= 2 && accountComments[1]?.content === "third-pending", + ); + + await act(async () => { + liveCommentsByContent["second-pending"]?.emit( + "publishingstatechange", + "waiting-challenge-verification", + ); + }); + + const accountComments = accountsStore.getState().accountsComments[account.id] || []; + expect(accountComments[0]?.publishingState).toBe("waiting-challenge-verification"); + expect(accountComments[1]?.content).toBe("third-pending"); + expect(accountComments[1]?.publishingState).not.toBe("waiting-challenge-verification"); + }); + test("subscribe already subscribed throws", async () => { await act(async () => { await accountsActions.subscribe("sub1.eth"); diff --git a/src/stores/accounts/accounts-actions.ts b/src/stores/accounts/accounts-actions.ts index 29a8a58e..5c3629da 100644 --- a/src/stores/accounts/accounts-actions.ts +++ b/src/stores/accounts/accounts-actions.ts @@ -47,45 +47,51 @@ import isEqual from "lodash.isequal"; import { v4 as uuid } from "uuid"; import utils from "../../lib/utils"; +type PublishSession = { + accountId: string; + originalIndex: number; + currentIndex: number; + comment?: any; +}; + // Active publish-session tracking for pending comments (Task 3) -const activePublishSessions = new Map< - string, - { comment?: any; abandoned: boolean; currentIndex: number } ->(); -const abandonedPublishKeys = new Set(); -const getPublishSessionKey = (accountId: string, index: number) => `${accountId}:${index}`; - -const registerPublishSession = (accountId: string, index: number, comment?: any) => { - const key = getPublishSessionKey(accountId, index); - const previousSession = activePublishSessions.get(key); - activePublishSessions.set(key, { - comment, - abandoned: false, - currentIndex: previousSession?.currentIndex ?? index, +const activePublishSessions = new Map(); +const abandonedPublishSessionIds = new Set(); + +const createPublishSession = (accountId: string, index: number) => { + const sessionId = uuid(); + activePublishSessions.set(sessionId, { + accountId, + originalIndex: index, + currentIndex: index, }); + return sessionId; +}; + +const updatePublishSessionComment = (sessionId: string, comment?: any) => { + const session = activePublishSessions.get(sessionId); + if (!session) { + return; + } + activePublishSessions.set(sessionId, { ...session, comment }); }; const abandonAndStopPublishSession = (accountId: string, index: number) => { - const key = getPublishSessionKey(accountId, index); - abandonedPublishKeys.add(key); - const session = activePublishSessions.get(key); + const session = getPublishSessionByCurrentIndex(accountId, index); if (!session) return; + abandonedPublishSessionIds.add(session.sessionId); try { const stop = session.comment?.stop?.bind(session.comment); if (typeof stop === "function") stop(); } catch (e) { log.error("comment.stop() error during abandon", { accountId, index, error: e }); } - activePublishSessions.delete(key); + activePublishSessions.delete(session.sessionId); }; -const isPublishSessionAbandoned = (accountId: string, index: number) => { - return abandonedPublishKeys.has(getPublishSessionKey(accountId, index)); -}; +const isPublishSessionAbandoned = (sessionId: string) => abandonedPublishSessionIds.has(sessionId); -const getPublishSessionByIndex = (accountId: string, index: number) => { - return activePublishSessions.get(getPublishSessionKey(accountId, index)); -}; +const getPublishSession = (sessionId: string) => activePublishSessions.get(sessionId); /** Returns state update or {} when accountComment not yet in state (no-op). Exported for coverage. */ export const maybeUpdateAccountComment = ( @@ -101,36 +107,29 @@ export const maybeUpdateAccountComment = ( return { accountsComments: { ...accountsComments, [accountId]: accountComments } }; }; -const getPublishSessionForComment = ( +const getPublishSessionByCurrentIndex = ( accountId: string, - comment: any, -): { currentIndex: number; sessionKey: string; keyIndex: number } | undefined => { + index: number, +): ({ sessionId: string } & PublishSession) | undefined => { for (const [key, session] of activePublishSessions) { - const [aid, idxStr] = key.split(":"); - if (aid === accountId && session.comment === comment) { - return { - currentIndex: session.currentIndex, - sessionKey: key, - keyIndex: parseInt(idxStr, 10), - }; + if (session.accountId === accountId && session.currentIndex === index) { + return { sessionId: key, ...session }; } } return undefined; }; const shiftPublishSessionIndicesAfterDelete = (accountId: string, deletedIndex: number) => { - for (const [key, session] of activePublishSessions) { - const [aid] = key.split(":"); - if (aid === accountId && session.currentIndex > deletedIndex) { + for (const session of activePublishSessions.values()) { + if (session.accountId === accountId && session.currentIndex > deletedIndex) { session.currentIndex -= 1; } } }; -const cleanupPublishSessionOnTerminal = (accountId: string, index: number) => { - const key = getPublishSessionKey(accountId, index); - activePublishSessions.delete(key); - abandonedPublishKeys.delete(key); +const cleanupPublishSessionOnTerminal = (sessionId: string) => { + activePublishSessions.delete(sessionId); + abandonedPublishSessionIds.delete(sessionId); }; export const doesStoredAccountEditMatch = (storedAccountEdit: any, targetStoredAccountEdit: any) => @@ -912,13 +911,14 @@ export const publishComment = async ( // save comment to db let accountCommentIndex = accountsComments[account.id].length; + const publishSessionId = createPublishSession(account.id, accountCommentIndex); let savedOnce = false; const saveCreatedAccountComment = async (accountComment: AccountComment) => { - if (isPublishSessionAbandoned(account.id, accountCommentIndex)) { + if (isPublishSessionAbandoned(publishSessionId)) { return; } const isUpdate = savedOnce; - const session = getPublishSessionByIndex(account.id, accountCommentIndex); + const session = getPublishSession(publishSessionId); const currentIndex = session?.currentIndex ?? accountCommentIndex; const sanitizedAccountComment = addShortAddressesToAccountComment( sanitizeStoredAccountComment(accountComment), @@ -962,7 +962,6 @@ export const publishComment = async ( sanitizeStoredAccountComment(createdAccountComment), ); await saveCreatedAccountComment(createdAccountComment); - registerPublishSession(account.id, accountCommentIndex); publishCommentOptions._onPendingCommentIndex?.(accountCommentIndex, createdAccountComment); let comment: any; @@ -975,7 +974,7 @@ export const publishComment = async ( createdAccountComment = { ...createdAccountComment, ...commentLinkDimensions }; await saveCreatedAccountComment(createdAccountComment); } - if (isPublishSessionAbandoned(account.id, accountCommentIndex)) { + if (isPublishSessionAbandoned(publishSessionId)) { return; } comment = backfillPublicationCommunityAddress( @@ -988,10 +987,10 @@ export const publishComment = async ( let lastChallenge: Challenge | undefined; async function publishAndRetryFailedChallengeVerification() { - if (isPublishSessionAbandoned(account.id, accountCommentIndex)) { + if (isPublishSessionAbandoned(publishSessionId)) { return; } - registerPublishSession(account.id, accountCommentIndex, comment); + updatePublishSessionComment(publishSessionId, comment); comment.once("challenge", async (challenge: Challenge) => { lastChallenge = challenge; publishCommentOptions.onChallenge(challenge, comment); @@ -1004,7 +1003,7 @@ export const publishComment = async ( createCommentOptions = { ...createCommentOptions, timestamp }; createdAccountComment = { ...createdAccountComment, timestamp }; await saveCreatedAccountComment(createdAccountComment); - if (isPublishSessionAbandoned(account.id, accountCommentIndex)) { + if (isPublishSessionAbandoned(publishSessionId)) { return; } comment = backfillPublicationCommunityAddress( @@ -1016,10 +1015,10 @@ export const publishComment = async ( } else { // the challengeverification message of a comment publication should in theory send back the CID // of the published comment which is needed to resolve it for replies, upvotes, etc - const sessionInfo = getPublishSessionForComment(account.id, comment); - const currentIndex = sessionInfo?.currentIndex ?? accountCommentIndex; - if (!sessionInfo || abandonedPublishKeys.has(sessionInfo.sessionKey)) return; - queueMicrotask(() => cleanupPublishSessionOnTerminal(account.id, sessionInfo.keyIndex)); + const session = getPublishSession(publishSessionId); + const currentIndex = session?.currentIndex ?? accountCommentIndex; + if (!session || isPublishSessionAbandoned(publishSessionId)) return; + queueMicrotask(() => cleanupPublishSessionOnTerminal(publishSessionId)); if (challengeVerification?.commentUpdate?.cid) { const commentWithCid = addShortAddressesToAccountComment( sanitizeStoredAccountComment(normalizePublicationOptionsForStore(comment as any)), @@ -1076,8 +1075,8 @@ export const publishComment = async ( }); comment.on("error", (error: Error) => { - const session = getPublishSessionByIndex(account.id, accountCommentIndex); - if (!session || isPublishSessionAbandoned(account.id, accountCommentIndex)) return; + const session = getPublishSession(publishSessionId); + if (!session || isPublishSessionAbandoned(publishSessionId)) return; const currentIndex = session.currentIndex; accountsStore.setState(({ accountsComments }) => maybeUpdateAccountComment(accountsComments, account.id, currentIndex, (ac, acc) => { @@ -1088,8 +1087,8 @@ export const publishComment = async ( publishCommentOptions.onError?.(error, comment); }); comment.on("publishingstatechange", async (publishingState: string) => { - const session = getPublishSessionByIndex(account.id, accountCommentIndex); - if (!session || isPublishSessionAbandoned(account.id, accountCommentIndex)) return; + const session = getPublishSession(publishSessionId); + if (!session || isPublishSessionAbandoned(publishSessionId)) return; const currentIndex = session.currentIndex; accountsStore.setState(({ accountsComments }) => maybeUpdateAccountComment(accountsComments, account.id, currentIndex, (ac, acc) => { @@ -1103,8 +1102,8 @@ export const publishComment = async ( utils.clientsOnStateChange( comment.clients, (clientState: string, clientType: string, clientUrl: string, chainTicker?: string) => { - const session = getPublishSessionByIndex(account.id, accountCommentIndex); - if (!session || isPublishSessionAbandoned(account.id, accountCommentIndex)) return; + const session = getPublishSession(publishSessionId); + if (!session || isPublishSessionAbandoned(publishSessionId)) return; const currentIndex = session.currentIndex; accountsStore.setState(({ accountsComments }) => maybeUpdateAccountComment(accountsComments, account.id, currentIndex, (ac, acc) => { @@ -1184,14 +1183,14 @@ export const deleteComment = async ( const newAccountsComments = { ...accountsComments, [account.id]: reindexed }; const newCommentCidsToAccountsComments = getCommentCidsToAccountsComments(newAccountsComments); - accountsStore.setState({ + accountsStore.setState(({ accountsCommentsIndexes }) => ({ accountsComments: newAccountsComments, accountsCommentsIndexes: { - ...accountsStore.getState().accountsCommentsIndexes, + ...accountsCommentsIndexes, [account.id]: getAccountCommentsIndex(reindexed), }, commentCidsToAccountsComments: newCommentCidsToAccountsComments, - }); + })); await accountsDatabase.deleteAccountComment(account.id, accountCommentIndex); diff --git a/src/stores/accounts/accounts-database.test.ts b/src/stores/accounts/accounts-database.test.ts index 31ebbfb1..a63fd9cb 100644 --- a/src/stores/accounts/accounts-database.test.ts +++ b/src/stores/accounts/accounts-database.test.ts @@ -530,6 +530,17 @@ describe("accounts-database", () => { expect(votes).toEqual({}); }); + test("getAccountVotes tolerates missing latest-index metadata after a partial write", async () => { + const acc = makeAccount({ id: "gv-missing-index", name: "GVMissingIndex" }); + await accountsDatabase.addAccount(acc); + const votesDb = createPerAccountDatabase("accountVotes", acc.id); + await votesDb.setItem("__storageVersion", 1); + + const votes = await accountsDatabase.getAccountVotes(acc.id); + + expect(votes).toEqual({}); + }); + test("getAccountEdits returns empty when no edits (branch 570)", async () => { const acc = makeAccount({ id: "ge-empty", name: "GEEmpty" }); await accountsDatabase.addAccount(acc); @@ -537,6 +548,19 @@ describe("accounts-database", () => { expect(edits).toEqual({}); }); + test("getAccountEdits tolerates missing target-index metadata after a partial write", async () => { + const acc = makeAccount({ id: "ge-missing-index", name: "GEMissingIndex" }); + await accountsDatabase.addAccount(acc); + const editsDb = createPerAccountDatabase("accountEdits", acc.id); + await editsDb.setItem("__storageVersion", 1); + + const edits = await accountsDatabase.getAccountEdits(acc.id); + const summary = await accountsDatabase.getAccountEditsSummary(acc.id); + + expect(edits).toEqual({}); + expect(summary).toEqual({}); + }); + test("addAccountVote with multiple votes hits getAccountVotes loop", async () => { const acc = makeAccount({ id: "vote-multi", name: "VoteMulti" }); await accountsDatabase.addAccount(acc); @@ -621,6 +645,53 @@ describe("accounts-database", () => { expect(summary).toEqual({}); }); + test("preserves sparse legacy edit indices when rebuilding compact indexes", async () => { + const acc = makeAccount({ id: "legacy-edit-sparse", name: "LegacyEditSparse" }); + await accountsDatabase.addAccount(acc); + const editsDb = createPerAccountDatabase("accountEdits", acc.id); + await editsDb.setItem("0", { commentCid: "cid-a", spoiler: true, timestamp: 10 }); + await editsDb.setItem("2", { commentCid: "cid-b", nsfw: true, timestamp: 20 }); + await editsDb.setItem("length", 3); + + const edits = await accountsDatabase.getAccountEdits(acc.id); + const summary = await accountsDatabase.getAccountEditsSummary(acc.id); + + expect(edits["cid-a"]).toEqual([{ commentCid: "cid-a", spoiler: true, timestamp: 10 }]); + expect(edits["cid-b"]).toEqual([{ commentCid: "cid-b", nsfw: true, timestamp: 20 }]); + expect(summary["cid-a"].spoiler.value).toBe(true); + expect(summary["cid-b"].nsfw.value).toBe(true); + expect(await editsDb.getItem("__targetToIndices")).toEqual({ + "cid-a": [0], + "cid-b": [2], + }); + }); + + test("addAccountEdit keeps sparse legacy edit indices aligned when appending new edits", async () => { + const acc = makeAccount({ id: "legacy-edit-sparse-append", name: "LegacyEditSparseAppend" }); + await accountsDatabase.addAccount(acc); + const editsDb = createPerAccountDatabase("accountEdits", acc.id); + await editsDb.setItem("0", { commentCid: "cid-a", spoiler: true, timestamp: 10 }); + await editsDb.setItem("2", { commentCid: "cid-b", nsfw: true, timestamp: 20 }); + await editsDb.setItem("length", 3); + + await accountsDatabase.addAccountEdit(acc.id, { + commentCid: "cid-c", + content: "new", + timestamp: 30, + } as any); + + const edits = await accountsDatabase.getAccountEdits(acc.id); + + expect(edits["cid-a"]).toEqual([{ commentCid: "cid-a", spoiler: true, timestamp: 10 }]); + expect(edits["cid-b"]).toEqual([{ commentCid: "cid-b", nsfw: true, timestamp: 20 }]); + expect(edits["cid-c"]).toEqual([{ commentCid: "cid-c", content: "new", timestamp: 30 }]); + expect(await editsDb.getItem("__targetToIndices")).toEqual({ + "cid-a": [0], + "cid-b": [2], + "cid-c": [3], + }); + }); + test("builds compact edit indexes for community and subplebbit targets", async () => { const acc = makeAccount({ id: "legacy-edit-targets", name: "LegacyEditTargets" }); await accountsDatabase.addAccount(acc); diff --git a/src/stores/accounts/accounts-database.ts b/src/stores/accounts/accounts-database.ts index bd05ef21..a8fcaafc 100644 --- a/src/stores/accounts/accounts-database.ts +++ b/src/stores/accounts/accounts-database.ts @@ -503,7 +503,7 @@ const addAccountVote = async (accountId: string, createVoteOptions: CreateCommen const getAccountVotes = async (accountId: string) => { const accountVotesDatabase = getAccountVotesDatabase(accountId); await ensureAccountVotesDatabaseLayout(accountId); - const latestIndexByCommentCid = await accountVotesDatabase.getItem(votesLatestIndexKey); + const latestIndexByCommentCid = (await accountVotesDatabase.getItem(votesLatestIndexKey)) || {}; const votes: any = {}; const latestIndexes = Object.values(latestIndexByCommentCid); if (latestIndexes.length === 0) { @@ -624,7 +624,7 @@ const ensureAccountEditsDatabaseLayout = async (accountId: string) => { return; } - const edits = (await getDatabaseAsArray(accountEditsDatabase)).filter(Boolean); + const edits = await getDatabaseAsArray(accountEditsDatabase); const keys = await accountEditsDatabase.keys(); const duplicateKeysToDelete = keys.filter( (key: string) => @@ -633,12 +633,12 @@ const ensureAccountEditsDatabaseLayout = async (accountId: string) => { key !== storageVersionKey && key !== editsTargetToIndicesKey && key !== editsSummaryKey && - edits.some((edit) => getAccountEditTarget(edit) === key), + edits.some((edit) => getAccountEditTarget(edit as AccountEdit) === key), ); await Promise.all( duplicateKeysToDelete.map((key: string) => accountEditsDatabase.removeItem(key)), ); - await persistAccountEditsIndexes(accountId, edits); + await persistAccountEditsIndexes(accountId, edits as AccountEdit[]); }; const addAccountEdit = async (accountId: string, createEditOptions: CreateCommentOptions) => { @@ -648,8 +648,8 @@ const addAccountEdit = async (accountId: string, createEditOptions: CreateCommen await ensureAccountEditsDatabaseLayout(accountId); const length = (await accountEditsDatabase.getItem("length")) || 0; const edit = removeFunctionsAndSensitiveFields(createEditOptions); - const existingEdits = (await getDatabaseAsArray(accountEditsDatabase)).filter(Boolean); - existingEdits.push(edit); + const existingEdits = await getDatabaseAsArray(accountEditsDatabase); + existingEdits[length] = edit; await Promise.all([ accountEditsDatabase.setItem(String(length), edit), accountEditsDatabase.setItem(storageVersionKey, editStorageVersion), @@ -697,7 +697,7 @@ const deleteAccountEdit = async (accountId: string, editToDelete: CreateCommentO const getAccountEdits = async (accountId: string) => { const accountEditsDatabase = getAccountEditsDatabase(accountId); await ensureAccountEditsDatabaseLayout(accountId); - const targetToIndices = await accountEditsDatabase.getItem(editsTargetToIndicesKey); + const targetToIndices = (await accountEditsDatabase.getItem(editsTargetToIndicesKey)) || {}; const edits: any = {}; const targets = Object.keys(targetToIndices); if (targets.length === 0) { @@ -716,7 +716,7 @@ const getAccountEdits = async (accountId: string) => { const getAccountEditsSummary = async (accountId: string): Promise => { const accountEditsDatabase = getAccountEditsDatabase(accountId); await ensureAccountEditsDatabaseLayout(accountId); - return await accountEditsDatabase.getItem(editsSummaryKey); + return (await accountEditsDatabase.getItem(editsSummaryKey)) || {}; }; const getAccountsEdits = async (accountIds: string[]) => {