diff --git a/README.md b/README.md index fe9ce0b6..1e21c8f3 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ useSubscribe({communityAddress: string}): {subscribed: boolean | undefined, subs useBlock({address?: string, cid?: string}): {blocked: boolean | undefined, block: Function, unblock: Function} useSaveComment({commentCid: string}): {saved: boolean | undefined, saveComment: Function, unsaveComment: Function} usePublishComment(options: UsePublishCommentOptions): {index: number, abandonPublish: () => Promise, ...UsePublishCommentResult} -usePublishVote(options: UsePublishVoteOptions): UsePublishVoteResult +usePublishVote(options: UsePublishVoteOptions): {abandonPublish: () => Promise, ...UsePublishVoteResult} usePublishCommentEdit(options: UsePublishCommentEditOptions): UsePublishCommentEditResult usePublishCommentModeration(options: UsePublishCommentModerationOptions): UsePublishCommentModerationResult usePublishCommunityEdit(options: UsePublishCommunityEditOptions): UsePublishCommunityEditResult @@ -682,12 +682,17 @@ const publishVoteOptions = { onChallengeVerification, onError, }; -const { state, error, publishVote } = usePublishVote(publishVoteOptions); +const { state, error, publishVote, abandonPublish } = usePublishVote(publishVoteOptions); await publishVote(); console.log(state); console.log(error); +// if the user closes the challenge modal and wants to cancel voting: +await abandonPublish(); +// the vote publication is stopped and the account vote goes back to what it was +// before publishVote(), so the optimistic vote does not survive a cancelled challenge + // display the user's vote const { vote } = useAccountVote({ commentCid }); diff --git a/llms-full.txt b/llms-full.txt index 663e762d..72199e15 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -207,7 +207,7 @@ useSubscribe({communityAddress: string}): {subscribed: boolean | undefined, subs useBlock({address?: string, cid?: string}): {blocked: boolean | undefined, block: Function, unblock: Function} useSaveComment({commentCid: string}): {saved: boolean | undefined, saveComment: Function, unsaveComment: Function} usePublishComment(options: UsePublishCommentOptions): {index: number, abandonPublish: () => Promise, ...UsePublishCommentResult} -usePublishVote(options: UsePublishVoteOptions): UsePublishVoteResult +usePublishVote(options: UsePublishVoteOptions): {abandonPublish: () => Promise, ...UsePublishVoteResult} usePublishCommentEdit(options: UsePublishCommentEditOptions): UsePublishCommentEditResult usePublishCommentModeration(options: UsePublishCommentModerationOptions): UsePublishCommentModerationResult usePublishCommunityEdit(options: UsePublishCommunityEditOptions): UsePublishCommunityEditResult @@ -712,12 +712,17 @@ const publishVoteOptions = { onChallengeVerification, onError, }; -const { state, error, publishVote } = usePublishVote(publishVoteOptions); +const { state, error, publishVote, abandonPublish } = usePublishVote(publishVoteOptions); await publishVote(); console.log(state); console.log(error); +// if the user closes the challenge modal and wants to cancel voting: +await abandonPublish(); +// the vote publication is stopped and the account vote goes back to what it was +// before publishVote(), so the optimistic vote does not survive a cancelled challenge + // display the user's vote const { vote } = useAccountVote({ commentCid }); @@ -3282,6 +3287,15 @@ Avoid GitHub MCP and browser MCP servers for this project because they add signi Source: https://github.com/bitsocialnet/bitsocial-react-hooks/blob/master/CHANGELOG.md ```markdown +## [0.1.43](https://github.com/bitsocialnet/bitsocial-react-hooks/compare/v0.1.42...v0.1.43) (2026-08-29) + + +### Bug Fixes + +* **comments:** preserve published author identity ([#109](https://github.com/bitsocialnet/bitsocial-react-hooks/issues/109)) ([a3bb5c6](https://github.com/bitsocialnet/bitsocial-react-hooks/commit/a3bb5c697f38eec45e4629ddebe7d5d11456036d)) + + + ## [0.1.42](https://github.com/bitsocialnet/bitsocial-react-hooks/compare/v0.1.41...v0.1.42) (2026-08-28) diff --git a/llms.txt b/llms.txt index ddb05f20..389ee351 100644 --- a/llms.txt +++ b/llms.txt @@ -38,5 +38,5 @@ This file is generated by `scripts/generate-llms-files.mjs`. Do not hand-edit it ## Optional -- [Changelog](https://github.com/bitsocialnet/bitsocial-react-hooks/blob/master/CHANGELOG.md): * **accounts:** add saved comment support ([#106](https://github.com/bitsocialnet/bitsocial-react-hooks/issues/106)) ([661d720](https://github.com/bitsocialnet/bitsocial-react-hooks/commit/661d72031cc39076b2263013160a... +- [Changelog](https://github.com/bitsocialnet/bitsocial-react-hooks/blob/master/CHANGELOG.md): * **comments:** preserve published author identity ([#109](https://github.com/bitsocialnet/bitsocial-react-hooks/issues/109)) ([a3bb5c6](https://github.com/bitsocialnet/bitsocial-react-hooks/commit/a3bb5c697f38eec45e4... - [TODO](https://github.com/bitsocialnet/bitsocial-react-hooks/blob/master/docs/TODO.md): - e2e test to publish to an electron sub - async useAuthorAddress hook (because resolving ETH address synchronously is too slow) - implement sort by active - implement showing your own pending replies in a comment (wh... diff --git a/src/hooks/actions/actions.test.ts b/src/hooks/actions/actions.test.ts index d2f4d5ea..eb12aadd 100644 --- a/src/hooks/actions/actions.test.ts +++ b/src/hooks/actions/actions.test.ts @@ -2147,6 +2147,75 @@ describe("actions", () => { await testUtils.resetDatabasesAndStores(); }); + test(`abandonPublish reverts the account vote and clears the challenge`, async () => { + const publishVoteOptions = { + communityAddress: "12D3KooW... acions.test", + commentCid: "Qm... abandon.test", + vote: 1, + // never answer the challenge so the vote stays abandonable + onChallenge: vi.fn(), + onChallengeVerification: vi.fn(), + }; + rendered.rerender(publishVoteOptions); + await waitFor(() => rendered.result.current.state === "ready"); + + await act(async () => { + await rendered.result.current.publishVote(); + }); + await waitFor(() => rendered.result.current.challenge !== undefined); + expect(rendered.result.current.accountVote.vote).toBe(1); + + await act(async () => { + await rendered.result.current.abandonPublish(); + }); + + await waitFor(() => rendered.result.current.accountVote.vote === 0); + expect(rendered.result.current.accountVote.vote).toBe(0); + expect(rendered.result.current.challenge).toBe(undefined); + // the "stopped" publishing state emitted by the stopped publication is ignored + expect(rendered.result.current.state).toBe("ready"); + expect(publishVoteOptions.onChallengeVerification).not.toHaveBeenCalled(); + }); + + test(`abandoned vote's late events do not leak into the next publish`, async () => { + const votes: any[] = []; + const publishVoteOptions = { + communityAddress: "12D3KooW... acions.test", + commentCid: "Qm... leak.test", + vote: 1, + onChallenge: (_challenge: any, vote: any) => votes.push(vote), + onChallengeVerification: vi.fn(), + }; + rendered.rerender(publishVoteOptions); + await waitFor(() => rendered.result.current.state === "ready"); + + await act(async () => { + await rendered.result.current.publishVote(); + }); + await waitFor(() => rendered.result.current.challenge !== undefined); + await act(async () => { + await rendered.result.current.abandonPublish(); + }); + await act(async () => { + await rendered.result.current.publishVote(); + }); + await waitFor(() => rendered.result.current.challenge !== undefined); + expect(votes.length).toBe(2); + expect(rendered.result.current.state).toBe("waiting-challenge-answers"); + + // a late event from the abandoned publication must not touch the new publication's state + await act(async () => { + votes[0].emit("publishingstatechange", "stopped"); + }); + expect(rendered.result.current.state).toBe("waiting-challenge-answers"); + }); + + test(`abandonPublish without a commentCid does not throw`, async () => { + rendered.rerender({ communityAddress: "12D3KooW... acions.test", vote: 1 }); + await waitFor(() => rendered.result.current.state === "ready"); + await expect(rendered.result.current.abandonPublish()).resolves.toBeUndefined(); + }); + test(`publishChallengeAnswers throws when challenge not yet received`, async () => { const publishVoteOptions = { communityAddress: "12D3KooW... acions.test", diff --git a/src/hooks/actions/actions.ts b/src/hooks/actions/actions.ts index c4b370ac..4a858e01 100644 --- a/src/hooks/actions/actions.ts +++ b/src/hooks/actions/actions.ts @@ -452,6 +452,11 @@ export function usePublishVote(options?: UsePublishVoteOptions): UsePublishVoteR const [challenge, setChallenge] = useState(); const [challengeVerification, setChallengeVerification] = useState(); const [publishChallengeAnswers, setPublishChallengeAnswers] = useState(); + // each publishVote() call gets its own request id and abandonPublish() clears the active one, so + // the stopped publication's late events (like the "stopped" publishing state) neither overwrite + // the cleared hook state nor leak into the next publication + const publishVoteRequestIdRef = useRef(0); + const activePublishVoteRequestIdRef = useRef(undefined); let initialState = "initializing"; // before the accountId and options is defined, nothing can happen @@ -492,18 +497,45 @@ export function usePublishVote(options?: UsePublishVoteOptions): UsePublishVoteR }; const publishVote = async () => { + const requestId = publishVoteRequestIdRef.current + 1; + publishVoteRequestIdRef.current = requestId; + activePublishVoteRequestIdRef.current = requestId; + const isActiveRequest = () => activePublishVoteRequestIdRef.current === requestId; + const activePublishVoteOptions = { + ...publishVoteOptions, + onChallenge: (challenge: Challenge, vote: Vote) => { + if (isActiveRequest()) onChallenge(challenge, vote); + }, + onChallengeVerification: (challengeVerification: ChallengeVerification, vote: Vote) => { + if (isActiveRequest()) onChallengeVerification(challengeVerification, vote); + }, + onPublishingStateChange: (publishingState: string) => { + if (isActiveRequest()) setPublishingState(publishingState); + }, + }; try { - await accountsActions.publishVote(publishVoteOptions, accountName); + await accountsActions.publishVote(activePublishVoteOptions, accountName); } catch (e: any) { handlePublishVoteError(e, setErrors, originalOnError); } }; + const abandonPublish = async () => { + activePublishVoteRequestIdRef.current = undefined; + setChallenge(undefined); + setChallengeVerification(undefined); + setPublishChallengeAnswers(undefined); + setPublishingState(undefined); + if (!publishVoteOptions.commentCid) return; + await accountsActions.abandonVote(publishVoteOptions.commentCid, accountName); + }; + return useMemo( () => ({ challenge, challengeVerification, publishVote, + abandonPublish, publishChallengeAnswers: publishChallengeAnswers || publishChallengeAnswersNotReady, state: publishingState || initialState, error: errors[errors.length - 1], diff --git a/src/stores/accounts/accounts-actions.test.ts b/src/stores/accounts/accounts-actions.test.ts index 9d25a715..d4d0d0e7 100644 --- a/src/stores/accounts/accounts-actions.test.ts +++ b/src/stores/accounts/accounts-actions.test.ts @@ -3671,4 +3671,204 @@ describe("accounts-actions", () => { expect(persistedEdits["remote-sub.eth"]).toBeUndefined(); }); }); + + describe("abandonVote", () => { + beforeEach(async () => { + await testUtils.resetDatabasesAndStores(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // the challenge is never answered, so the vote stays abandonable + const publishUnansweredVote = async (commentCid: string, vote: number) => { + const votes: any[] = []; + await act(async () => { + await accountsActions.publishVote({ + communityAddress: "sub.eth", + commentCid, + vote, + onChallenge: (_challenge: any, publication: any) => votes.push(publication), + onChallengeVerification: () => {}, + }); + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + return votes; + }; + + test("stops the publication and neutralizes a vote the comment did not have before", async () => { + const accountId = accountsStore.getState().activeAccountId!; + const [vote] = await publishUnansweredVote("abandon cid", 1); + const stop = vi.spyOn(vote, "stop"); + expect(accountsStore.getState().accountsVotes[accountId]["abandon cid"].vote).toBe(1); + + await act(async () => { + await accountsActions.abandonVote("abandon cid"); + }); + + expect(stop).toHaveBeenCalledOnce(); + const accountVote = accountsStore.getState().accountsVotes[accountId]["abandon cid"]; + expect(accountVote.vote).toBe(0); + expect(accountVote._optimisticVoteTransitions).toBeUndefined(); + const persistedVotes = await accountsDatabase.getAccountVotes(accountId); + expect(persistedVotes["abandon cid"].vote).toBe(0); + }); + + test("restores the vote the comment had before the abandoned publication", async () => { + const accountId = accountsStore.getState().activeAccountId!; + await act(async () => { + await accountsActions.publishVote({ + communityAddress: "sub.eth", + commentCid: "restore cid", + vote: 1, + onChallenge: (_challenge: any, publication: any) => + publication.publishChallengeAnswers(["4"]), + onChallengeVerification: () => {}, + }); + }); + await new Promise((resolve) => setTimeout(resolve, 200)); + const publishedVote = accountsStore.getState().accountsVotes[accountId]["restore cid"]; + expect(publishedVote.vote).toBe(1); + + await publishUnansweredVote("restore cid", -1); + expect(accountsStore.getState().accountsVotes[accountId]["restore cid"].vote).toBe(-1); + + await act(async () => { + await accountsActions.abandonVote("restore cid"); + }); + + expect(accountsStore.getState().accountsVotes[accountId]["restore cid"]).toEqual( + publishedVote, + ); + const persistedVotes = await accountsDatabase.getAccountVotes(accountId); + expect(persistedVotes["restore cid"].vote).toBe(1); + }); + + test("keeps abandoning the publication still waiting on its challenge after another one of the burst is verified", async () => { + const accountId = accountsStore.getState().activeAccountId!; + const [firstVote] = await publishUnansweredVote("burst cid", 1); + const [secondVote] = await publishUnansweredVote("burst cid", -1); + const secondStop = vi.spyOn(secondVote, "stop"); + + await act(async () => { + await firstVote.publishChallengeAnswers(["4"]); + }); + await new Promise((resolve) => setTimeout(resolve, 200)); + + await act(async () => { + await accountsActions.abandonVote("burst cid"); + }); + + expect(secondStop).toHaveBeenCalledOnce(); + // the verified first vote is restored, not the neutral vote from before the burst + expect(accountsStore.getState().accountsVotes[accountId]["burst cid"].vote).toBe(1); + }); + + // resolves account.pkc.createVote() only once the returned release() is called + const gateCreateVote = () => { + const account = accountsStore.getState().accounts[accountsStore.getState().activeAccountId!]; + const originalCreateVote = account.pkc.createVote.bind(account.pkc); + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + const createVote = vi + .spyOn(account.pkc, "createVote") + .mockImplementation(async (...args: any[]) => { + await gate; + return originalCreateVote(...args); + }); + return { createVote, release }; + }; + + test("does not publish or write a vote abandoned while it was still being created", async () => { + const accountId = accountsStore.getState().activeAccountId!; + const { createVote, release } = gateCreateVote(); + const onChallenge = vi.fn(); + const publishPromise = accountsActions.publishVote({ + communityAddress: "sub.eth", + commentCid: "creating cid", + vote: 1, + onChallenge, + onChallengeVerification: () => {}, + }); + await vi.waitFor(() => expect(createVote).toHaveBeenCalled()); + + await act(async () => { + await accountsActions.abandonVote("creating cid"); + }); + release(); + await publishPromise; + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(onChallenge).not.toHaveBeenCalled(); + expect(accountsStore.getState().accountsVotes[accountId]?.["creating cid"]).toBeUndefined(); + }); + + test("keeps the session for a vote still being created when the rest of the burst is verified", async () => { + const accountId = accountsStore.getState().activeAccountId!; + const [firstVote] = await publishUnansweredVote("creating burst cid", 1); + const { createVote, release } = gateCreateVote(); + const votes: any[] = []; + const publishPromise = accountsActions.publishVote({ + communityAddress: "sub.eth", + commentCid: "creating burst cid", + vote: -1, + onChallenge: (_challenge: any, publication: any) => votes.push(publication), + onChallengeVerification: () => {}, + }); + await vi.waitFor(() => expect(createVote).toHaveBeenCalled()); + + await act(async () => { + await firstVote.publishChallengeAnswers(["4"]); + }); + await new Promise((resolve) => setTimeout(resolve, 200)); + release(); + await publishPromise; + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(accountsStore.getState().accountsVotes[accountId]["creating burst cid"].vote).toBe(-1); + const secondStop = vi.spyOn(votes[0], "stop"); + + await act(async () => { + await accountsActions.abandonVote("creating burst cid"); + }); + + expect(secondStop).toHaveBeenCalledOnce(); + expect(accountsStore.getState().accountsVotes[accountId]["creating burst cid"].vote).toBe(1); + }); + + test("does not revert a vote that already passed challenge verification", async () => { + const accountId = accountsStore.getState().activeAccountId!; + await act(async () => { + await accountsActions.publishVote({ + communityAddress: "sub.eth", + commentCid: "verified cid", + vote: 1, + onChallenge: (_challenge: any, publication: any) => + publication.publishChallengeAnswers(["4"]), + onChallengeVerification: () => {}, + }); + }); + await new Promise((resolve) => setTimeout(resolve, 200)); + + await act(async () => { + await accountsActions.abandonVote("verified cid"); + }); + + expect(accountsStore.getState().accountsVotes[accountId]["verified cid"].vote).toBe(1); + }); + + test("does nothing when the comment has no vote publication waiting on a challenge", async () => { + const accountId = accountsStore.getState().activeAccountId!; + const addAccountVote = vi.spyOn(accountsDatabase, "addAccountVote"); + + await act(async () => { + await accountsActions.abandonVote("never voted cid"); + }); + + expect(addAccountVote).not.toHaveBeenCalled(); + expect( + accountsStore.getState().accountsVotes[accountId]?.["never voted cid"], + ).toBeUndefined(); + }); + }); }); diff --git a/src/stores/accounts/accounts-actions.ts b/src/stores/accounts/accounts-actions.ts index 5e5014ee..bacfbc50 100644 --- a/src/stores/accounts/accounts-actions.ts +++ b/src/stores/accounts/accounts-actions.ts @@ -24,6 +24,7 @@ import { CommunityExport, Communities, AccountComment, + AccountVote, } from "../../types"; import * as accountsActionsInternal from "./accounts-actions-internal"; import { @@ -146,6 +147,23 @@ type PublishSession = { const activePublishSessions = new Map(); const abandonedPublishSessionIds = new Set(); +type VotePublishSession = { + // the account vote restored when the burst is abandoned: the one the first publication replaced, or + // the latest verified vote of the burst + previousAccountVote: AccountVote | undefined; + publications: Set; + // votes still waiting on account.pkc.createVote(), they join publications once it resolves + creating: number; +}; + +// A vote has no pending account comment to delete, so abandoning one restores the account vote that +// the optimistic write in publishVote replaced. Keyed per comment because that is what the caller +// abandons, and because rapid votes on the same comment share one revert target. +const activeVotePublishSessions = new Map(); + +const getVotePublishSessionKey = (accountId: string, commentCid: string) => + `${accountId}:${commentCid}`; + const getClientsSnapshotForState = (clients: any): any => { if (!clients || typeof clients !== "object") { return undefined; @@ -263,6 +281,53 @@ const abandonAndStopPublishSession = (accountId: string, index: number) => { activePublishSessions.delete(session.sessionId); }; +const startVotePublishSession = ( + accountId: string, + commentCid: string, + previousAccountVote: AccountVote | undefined, +) => { + const key = getVotePublishSessionKey(accountId, commentCid); + const session = activeVotePublishSessions.get(key); + // a retry or a rapid second vote joins the running session so the revert target stays the vote the + // user had before any of them was published + if (session) return session; + const startedSession: VotePublishSession = { + previousAccountVote, + publications: new Set(), + creating: 0, + }; + activeVotePublishSessions.set(key, startedSession); + return startedSession; +}; + +const endVotePublishSession = (accountId: string, commentCid: string) => { + activeVotePublishSessions.delete(getVotePublishSessionKey(accountId, commentCid)); +}; + +const restoreAccountVote = async ( + accountId: string, + commentCid: string, + previousAccountVote: AccountVote | undefined, +) => { + const currentAccountVote = accountsStore.getState().accountsVotes[accountId]?.[commentCid]; + // nothing was written optimistically, so there is nothing to restore + if (currentAccountVote === previousAccountVote) return; + // the account votes database only appends, so a comment with no earlier vote is restored to an + // explicit neutral vote rather than by deleting the abandoned one + const restoredAccountVote: AccountVote = previousAccountVote ?? { + commentCid, + vote: 0, + timestamp: Math.floor(Date.now() / 1000), + }; + await accountsDatabase.addAccountVote(accountId, restoredAccountVote); + accountsStore.setState(({ accountsVotes }) => ({ + accountsVotes: { + ...accountsVotes, + [accountId]: { ...accountsVotes[accountId], [commentCid]: restoredAccountVote }, + }, + })); +}; + const isPublishSessionAbandoned = (sessionId: string) => abandonedPublishSessionIds.has(sessionId); const getPublishSession = (sessionId: string) => activePublishSessions.get(sessionId); @@ -1721,18 +1786,45 @@ export const publishVote = async (publishVoteOptions: PublishVoteOptions, accoun accountCommentInfo.accountCommentIndex ] : undefined; + const previousAccountVote = + accountsState.accountsVotes[account.id]?.[createVoteOptions.commentCid]; const storedCreateVoteOptions = addOptimisticVoteMetadata( normalizePublicationOptionsForStore(createVoteOptions), - accountsState.accountsVotes[account.id]?.[createVoteOptions.commentCid], + previousAccountVote, getFreshestLoadedComment(createVoteOptions.commentCid, accountComment), ); - - let vote = backfillPublicationCommunityAddress( - await account.pkc.createVote(createVoteOptions), - createVoteOptions, + const accountVote: AccountVote = { + ...storedCreateVoteOptions, + // remove signer and author because not needed and they expose private key + signer: undefined, + author: undefined, + }; + const votePublishSession = startVotePublishSession( + account.id, + createVoteOptions.commentCid, + previousAccountVote, ); + const votePublishSessionKey = getVotePublishSessionKey(account.id, createVoteOptions.commentCid); + const isVotePublishSessionActive = () => + activeVotePublishSessions.get(votePublishSessionKey) === votePublishSession; + const createSessionVote = async () => { + votePublishSession.creating++; + try { + return backfillPublicationCommunityAddress( + await account.pkc.createVote(createVoteOptions), + createVoteOptions, + ); + } finally { + votePublishSession.creating--; + } + }; + + let vote = await createSessionVote(); + // abandoned while the vote was being created: nothing was published or written, so stop here + if (!isVotePublishSessionActive()) return; let lastChallenge: Challenge | undefined; const publishAndRetryFailedChallengeVerification = async () => { + votePublishSession.publications.add(vote); vote.once("challenge", async (challenge: Challenge) => { lastChallenge = challenge; publishVoteOptions.onChallenge(challenge, withPublishChallengeAnswersCompat(vote)); @@ -1746,13 +1838,29 @@ export const publishVote = async (publishVoteOptions: PublishVoteOptions, accoun !hasTerminalChallengeVerificationError(challengeVerification) ) { // publish again automatically on fail + votePublishSession.publications.delete(vote); createVoteOptions = { ...createVoteOptions, timestamp: Math.floor(Date.now() / 1000) }; - vote = backfillPublicationCommunityAddress( - await account.pkc.createVote(createVoteOptions), - createVoteOptions, - ); + vote = await createSessionVote(); + if (!isVotePublishSessionActive()) return; lastChallenge = undefined; publishAndRetryFailedChallengeVerification(); + } else { + // terminal: this vote is no longer abandonable + votePublishSession.publications.delete(vote); + if (challengeVerification.challengeSuccess) { + // the verified vote is the one the account has now, so abandoning the rest of the burst + // restores it instead of the vote from before the burst + votePublishSession.previousAccountVote = accountVote; + } + // the session ends only once no other publication of the burst is waiting on its challenge + // or still being created, and only if a newer session has not replaced it in the meantime + if ( + votePublishSession.publications.size === 0 && + votePublishSession.creating === 0 && + isVotePublishSessionActive() + ) { + activeVotePublishSessions.delete(votePublishSessionKey); + } } }); vote.on("error", (error: Error) => publishVoteOptions.onError?.(error, vote)); @@ -1778,14 +1886,53 @@ export const publishVote = async (publishVoteOptions: PublishVoteOptions, accoun ...accountsVotes, [account.id]: { ...accountsVotes[account.id], - [storedCreateVoteOptions.commentCid]: - // remove signer and author because not needed and they expose private key - { ...storedCreateVoteOptions, signer: undefined, author: undefined }, + [storedCreateVoteOptions.commentCid]: accountVote, }, }, })); }; +/** + * Stops the vote publications still waiting on a challenge for a comment and restores the account + * vote they optimistically replaced. Does nothing once the vote has been verified. + */ +export const abandonVote = async (commentCid: string, accountName?: string) => { + const { accounts, accountNamesToAccountIds, activeAccountId } = accountsStore.getState(); + assert( + accounts && accountNamesToAccountIds && activeAccountId, + `can't use accountsStore.accountActions before initialized`, + ); + let account = accounts[activeAccountId]; + if (accountName) { + const accountId = accountNamesToAccountIds[accountName]; + account = accounts[accountId]; + } + assert(account?.id, `accountsActions.abandonVote account.id '${account?.id}' doesn't exist`); + assert( + commentCid && typeof commentCid === "string", + `accountsActions.abandonVote commentCid '${commentCid}' not a string`, + ); + + const session = activeVotePublishSessions.get(getVotePublishSessionKey(account.id, commentCid)); + if (!session) return; + endVotePublishSession(account.id, commentCid); + + for (const publication of session.publications) { + try { + const stop = publication?.stop?.bind(publication); + if (typeof stop === "function") await stop(); + } catch (e) { + log.error("vote.stop() error during abandon", { + accountId: account.id, + commentCid, + error: e, + }); + } + } + await restoreAccountVote(account.id, commentCid, session.previousAccountVote); + log("accountsActions.abandonVote", { accountId: account.id, commentCid }); +}; + export const publishCommentEdit = async ( publishCommentEditOptions: PublishCommentEditOptions, accountName?: string, diff --git a/src/types.ts b/src/types.ts index c8101024..73d54ea2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -435,6 +435,7 @@ export interface UsePublishVoteResult extends Result { challenge: Challenge | undefined; challengeVerification: ChallengeVerification | undefined; publishVote(): Promise; + abandonPublish(): Promise; publishChallengeAnswers(challengeAnswers?: string[]): Promise; }