diff --git a/src/lib/plebbit-compat.ts b/src/lib/plebbit-compat.ts index fcfbbfb..ac18e46 100644 --- a/src/lib/plebbit-compat.ts +++ b/src/lib/plebbit-compat.ts @@ -19,30 +19,87 @@ export const getPlebbitCommunityAddresses = (plebbit: any): string[] => { return []; }; -export const withLegacySubplebbitAddress = >(options: T): T => { +export const normalizePublicationOptionsForPlebbit = >( + _plebbit: any, + options: T, +): T => { const communityAddress = options.communityAddress ?? options.subplebbitAddress; if (!communityAddress) { return options; } - return { - ...options, - communityAddress, - subplebbitAddress: options.subplebbitAddress ?? communityAddress, - }; + // The pinned plebbit-js dependency still documents and validates publication payloads with + // legacy subplebbit* field names even when some community lifecycle methods are renamed. + const normalized: Record = { ...options, subplebbitAddress: communityAddress }; + delete normalized.communityAddress; + return normalized as T; +}; + +export const normalizePublicationOptionsForStore = >( + options: T, +): T => { + const communityAddress = options.communityAddress ?? options.subplebbitAddress; + if (!communityAddress) { + return options; + } + const normalized: Record = { ...options, communityAddress }; + delete normalized.subplebbitAddress; + return normalized as T; +}; + +export const normalizeCommunityEditOptionsForPlebbit = >( + plebbit: any, + options: T, +): T => { + const normalized: Record = normalizePublicationOptionsForPlebbit(plebbit, options); + const editOptions = normalized.communityEdit ?? normalized.subplebbitEdit; + if (!editOptions) { + return normalized as T; + } + normalized.subplebbitEdit = editOptions; + delete normalized.communityEdit; + return normalized as T; }; export const getCommentCommunityAddress = (comment: any): string | undefined => comment?.communityAddress || comment?.subplebbitAddress; +const isLiveCommentInstance = (comment: any) => + typeof comment?.on === "function" || + typeof comment?.once === "function" || + typeof comment?.update === "function"; + export const normalizeCommentCommunityAddress = | undefined>( comment: T, ): T => { if (!comment || comment.communityAddress || !comment.subplebbitAddress) { return comment; } + if (isLiveCommentInstance(comment)) { + comment.communityAddress = comment.subplebbitAddress; + return comment; + } return { ...comment, communityAddress: comment.subplebbitAddress } as T; }; +export const backfillPublicationCommunityAddress = < + T extends Record | undefined, + O extends Record | undefined, +>( + publication: T, + options: O, +): T => { + if (!publication || publication.communityAddress) { + return publication; + } + const communityAddress = + publication.subplebbitAddress ?? options?.communityAddress ?? options?.subplebbitAddress; + if (!communityAddress) { + return publication; + } + publication.communityAddress = communityAddress; + return publication; +}; + export const createPlebbitCommunity = async (plebbit: any, options: any) => { const createCommunity = getPlebbitCreateCommunity(plebbit); assert(typeof createCommunity === "function", "plebbit createCommunity/createSubplebbit missing"); diff --git a/src/stores/accounts/accounts-actions-internal.test.ts b/src/stores/accounts/accounts-actions-internal.test.ts index 34de69a..dcb593d 100644 --- a/src/stores/accounts/accounts-actions-internal.test.ts +++ b/src/stores/accounts/accounts-actions-internal.test.ts @@ -71,7 +71,15 @@ describe("accounts-actions-internal", () => { 0, ); - expect(account.plebbit.createComment).toHaveBeenCalledWith(plainComment); + expect(account.plebbit.createComment).toHaveBeenCalledWith( + expect.objectContaining({ + cid: "plain-cid", + author: expect.objectContaining({ address: account.author.address }), + subplebbitAddress: "sub.eth", + depth: 0, + }), + ); + expect(createdComment.communityAddress).toBe("sub.eth"); await act(async () => { createdComment.emit("update", { ...plainComment, cid: "plain-cid" }); @@ -80,6 +88,60 @@ describe("accounts-actions-internal", () => { await new Promise((r) => setTimeout(r, 50)); }); + test("frozen live comment without communityAddress does not crash backfill fallback", async () => { + const account = Object.values(accountsStore.getState().accounts)[0]; + const updateListeners: Array<(c: any) => void> = []; + const plainComment = Object.freeze({ + cid: "cid-frozen-community", + on: (_: string, fn: (c: any) => void) => { + updateListeners.push(fn); + }, + emit: (_: string, c: any) => { + updateListeners.forEach((fn) => fn(c)); + }, + removeAllListeners: () => {}, + stop: () => {}, + update: () => Promise.resolve(), + }); + await accountsDatabase.addAccountComment(account.id, { + cid: "cid-frozen-community", + index: 0, + accountId: account.id, + timestamp: 1, + author: { address: account.author.address }, + communityAddress: "sub.eth", + } as any); + accountsStore.setState((s) => ({ + accountsComments: { + ...s.accountsComments, + [account.id]: [ + { + cid: "cid-frozen-community", + index: 0, + accountId: account.id, + timestamp: 1, + communityAddress: "sub.eth", + }, + ], + }, + commentCidsToAccountsComments: { + "cid-frozen-community": { accountId: account.id, accountCommentIndex: 0 }, + }, + accountsCommentsReplies: { + ...s.accountsCommentsReplies, + [account.id]: {}, + }, + })); + + await accountsActionsInternal.startUpdatingAccountCommentOnCommentUpdateEvents( + plainComment as any, + account, + 0, + ); + + expect((plainComment as any).communityAddress).toBeUndefined(); + }); + test("returns early when account comment already updating", async () => { const account = Object.values(accountsStore.getState().accounts)[0]; const comment = new Comment({ cid: "cid1", author: { address: account.author.address } }); @@ -549,6 +611,84 @@ describe("accounts-actions-internal", () => { expect(replies["reply-1"]).toBeDefined(); }); + test("update backfills root communityAddress and normalizes legacy reply fields", async () => { + const account = Object.values(accountsStore.getState().accounts)[0]; + const comment = new Comment({ + cid: "cid-legacy-replies", + author: { address: account.author.address }, + depth: 0, + }); + const legacyReply = { + cid: "reply-legacy", + author: { address: "r1" }, + subplebbitAddress: "sub.eth", + depth: 1, + parentCid: "cid-legacy-replies", + timestamp: 2, + }; + await accountsDatabase.addAccountComment(account.id, { + cid: "cid-legacy-replies", + index: 0, + accountId: account.id, + timestamp: 1, + author: { address: account.author.address }, + communityAddress: "sub.eth", + } as any); + accountsStore.setState((s) => ({ + accountsComments: { + ...s.accountsComments, + [account.id]: [ + { + cid: "cid-legacy-replies", + index: 0, + accountId: account.id, + timestamp: 1, + communityAddress: "sub.eth", + }, + ], + }, + commentCidsToAccountsComments: { + "cid-legacy-replies": { accountId: account.id, accountCommentIndex: 0 }, + }, + accountsCommentsReplies: { + ...s.accountsCommentsReplies, + [account.id]: {}, + }, + })); + + await accountsActionsInternal.startUpdatingAccountCommentOnCommentUpdateEvents( + comment, + account, + 0, + ); + + expect(comment.communityAddress).toBe("sub.eth"); + + await act(async () => { + comment.emit("update", { + cid: "cid-legacy-replies", + author: { address: account.author.address }, + depth: 0, + replies: { + pages: { + page1: { comments: [legacyReply] }, + }, + }, + }); + }); + + await new Promise((r) => setTimeout(r, 150)); + + const storedComment = accountsStore.getState().accountsComments[account.id]?.[0]; + expect(storedComment?.communityAddress).toBe("sub.eth"); + expect(storedComment?.subplebbitAddress).toBeUndefined(); + + const replies = accountsStore.getState().accountsCommentsReplies[account.id] || {}; + expect(replies["reply-legacy"]).toBeDefined(); + expect(replies["reply-legacy"].communityAddress).toBe("sub.eth"); + expect(replies["reply-legacy"].subplebbitAddress).toBeUndefined(); + }); + test("update with hasReplies but accountsCommentsReplies[account.id] missing: logs error", async () => { const utilsMod = await import("../../lib/utils"); vi.spyOn(utilsMod.default as any, "repliesAreValid").mockResolvedValue(true); diff --git a/src/stores/accounts/accounts-actions-internal.ts b/src/stores/accounts/accounts-actions-internal.ts index e3daaf4..ba8f10c 100644 --- a/src/stores/accounts/accounts-actions-internal.ts +++ b/src/stores/accounts/accounts-actions-internal.ts @@ -15,6 +15,41 @@ import { Community, } from "../../types"; import utils from "../../lib/utils"; +import { + backfillPublicationCommunityAddress, + getCommentCommunityAddress, + normalizePublicationOptionsForPlebbit, + normalizePublicationOptionsForStore, +} from "../../lib/plebbit-compat"; +import { addShortAddressesToAccountComment } from "./utils"; + +const backfillLiveCommentCommunityAddress = ( + comment: Comment | undefined, + communityAddress: string | undefined, +) => { + if (!comment || comment.communityAddress || !communityAddress) { + return; + } + + try { + Object.defineProperty(comment, "communityAddress", { + value: communityAddress, + writable: true, + configurable: true, + enumerable: false, + }); + } catch (error) { + try { + comment.communityAddress = communityAddress; + } catch (assignmentError) { + log.trace("backfillLiveCommentCommunityAddress failed", { + cid: comment.cid, + error, + assignmentError, + }); + } + } +}; // TODO: we currently subscribe to updates for every single comment // in the user's account history. This probably does not scale, we @@ -50,9 +85,23 @@ export const startUpdatingAccountCommentOnCommentUpdateEvents = async ( // comment is not a `Comment` instance if (!comment.on) { - comment = await account.plebbit.createComment(comment); + comment = backfillPublicationCommunityAddress( + await account.plebbit.createComment( + normalizePublicationOptionsForPlebbit(account.plebbit, comment), + ), + comment, + ); } + const initialStoredComment = + accountsStore.getState().accountsComments[account.id]?.[accountCommentIndex]; + backfillLiveCommentCommunityAddress( + comment, + getCommentCommunityAddress(commentArgument) || + initialStoredComment?.communityAddress || + initialStoredComment?.subplebbitAddress, + ); + comment.on("update", async (updatedComment: Comment) => { const mapping = accountsStore.getState().commentCidsToAccountsComments[updatedComment.cid || ""]; @@ -76,7 +125,38 @@ export const startUpdatingAccountCommentOnCommentUpdateEvents = async ( const currentIndex = mapping.accountCommentIndex; // merge should not be needed if plebbit-js is implemented properly, but no harm in fixing potential errors + const storedComment = accountsStore.getState().accountsComments[account.id]?.[currentIndex]; updatedComment = utils.merge(commentArgument, comment, updatedComment); + updatedComment.communityAddress = + getCommentCommunityAddress(updatedComment) || + getCommentCommunityAddress(comment) || + getCommentCommunityAddress(commentArgument) || + storedComment?.communityAddress || + storedComment?.subplebbitAddress; + updatedComment = 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); log("startUpdatingAccountCommentOnCommentUpdateEvents comment update", { commentCid: comment.cid, diff --git a/src/stores/accounts/accounts-actions.test.ts b/src/stores/accounts/accounts-actions.test.ts index 5a48d58..03a634f 100644 --- a/src/stores/accounts/accounts-actions.test.ts +++ b/src/stores/accounts/accounts-actions.test.ts @@ -142,6 +142,135 @@ function createRetryPlebbitMock() { return createRetryPlebbit; } +function createLegacyOnlyPlebbitMock() { + class LegacyOnlyPlebbit extends BasePlebbit { + constructor(...args: any[]) { + super(...args); + (this as any).createCommunity = undefined; + (this as any).getCommunity = undefined; + (this as any).createCommunityEdit = undefined; + } + + async createComment(opts: any) { + if ("communityAddress" in opts) { + throw new Error("legacy createComment received communityAddress"); + } + return super.createComment(opts); + } + + async createVote(opts: any) { + if ("communityAddress" in opts) { + throw new Error("legacy createVote received communityAddress"); + } + return super.createVote(opts); + } + + async createCommentEdit(opts: any) { + if ("communityAddress" in opts) { + throw new Error("legacy createCommentEdit received communityAddress"); + } + return super.createCommentEdit(opts); + } + + async createCommentModeration(opts: any) { + if ("communityAddress" in opts) { + throw new Error("legacy createCommentModeration received communityAddress"); + } + return super.createCommentModeration(opts); + } + + async createSubplebbitEdit(opts: any) { + if ("communityAddress" in opts) { + throw new Error("legacy createSubplebbitEdit received communityAddress"); + } + if ("communityEdit" in opts) { + throw new Error("legacy createSubplebbitEdit received communityEdit"); + } + const communityEdit: any = await BasePlebbit.prototype.createCommunityEdit.call(this, opts); + communityEdit.subplebbitAddress = opts.subplebbitAddress; + return communityEdit; + } + } + + const createLegacyOnlyPlebbit: any = async (...args: any[]) => new LegacyOnlyPlebbit(...args); + createLegacyOnlyPlebbit.getShortAddress = PlebbitJsMock.getShortAddress; + createLegacyOnlyPlebbit.getShortCid = PlebbitJsMock.getShortCid; + return createLegacyOnlyPlebbit; +} + +function createLegacyPublicationSchemaPlebbitMock() { + class LegacyPublicationSchemaPlebbit extends BasePlebbit { + async createComment(opts: any) { + if ("communityAddress" in opts) { + throw new Error("createComment received communityAddress"); + } + const comment: any = await super.createComment(opts); + comment.subplebbitAddress = opts.subplebbitAddress; + return comment; + } + + async createVote(opts: any) { + if ("communityAddress" in opts) { + throw new Error("createVote received communityAddress"); + } + const vote: any = await super.createVote(opts); + vote.subplebbitAddress = opts.subplebbitAddress; + return vote; + } + + async createCommentEdit(opts: any) { + if ("communityAddress" in opts) { + throw new Error("createCommentEdit received communityAddress"); + } + if ("communityEdit" in opts) { + throw new Error("createCommentEdit received communityEdit"); + } + const commentEdit: any = await super.createCommentEdit(opts); + commentEdit.subplebbitAddress = opts.subplebbitAddress; + return commentEdit; + } + + async createCommentModeration(opts: any) { + if ("communityAddress" in opts) { + throw new Error("createCommentModeration received communityAddress"); + } + const commentModeration: any = await super.createCommentModeration(opts); + commentModeration.subplebbitAddress = opts.subplebbitAddress; + return commentModeration; + } + + async createCommunityEdit(opts: any) { + if ("communityAddress" in opts) { + throw new Error("createCommunityEdit received communityAddress"); + } + if ("communityEdit" in opts) { + throw new Error("createCommunityEdit received communityEdit"); + } + const communityEdit: any = await super.createCommunityEdit(opts); + communityEdit.subplebbitAddress = opts.subplebbitAddress; + return communityEdit; + } + + async createSubplebbitEdit(opts: any) { + if ("communityAddress" in opts) { + throw new Error("createSubplebbitEdit received communityAddress"); + } + if ("communityEdit" in opts) { + throw new Error("createSubplebbitEdit received communityEdit"); + } + const communityEdit: any = await BasePlebbit.prototype.createCommunityEdit.call(this, opts); + communityEdit.subplebbitAddress = opts.subplebbitAddress; + return communityEdit; + } + } + + const createLegacyPublicationSchemaPlebbit: any = async (...args: any[]) => + new LegacyPublicationSchemaPlebbit(...args); + createLegacyPublicationSchemaPlebbit.getShortAddress = PlebbitJsMock.getShortAddress; + createLegacyPublicationSchemaPlebbit.getShortCid = PlebbitJsMock.getShortCid; + return createLegacyPublicationSchemaPlebbit; +} + describe("accounts-actions", () => { beforeAll(async () => { setPlebbitJs(PlebbitJsMock); @@ -743,6 +872,217 @@ describe("accounts-actions", () => { }); }); + describe("legacy plebbit-js compatibility", () => { + beforeEach(async () => { + setPlebbitJs(createLegacyOnlyPlebbitMock()); + await testUtils.resetDatabasesAndStores(); + }); + + afterEach(() => { + setPlebbitJs(PlebbitJsMock); + }); + + test("publication actions map community fields back to legacy subplebbit fields", async () => { + await act(async () => { + await accountsActions.publishComment({ + communityAddress: "sub.eth", + content: "legacy comment", + onChallenge: (ch: any, c: any) => c.publishChallengeAnswers(["4"]), + onChallengeVerification: () => {}, + }); + }); + + await act(async () => { + await accountsActions.publishVote({ + communityAddress: "sub.eth", + commentCid: "legacy cid", + vote: 1, + onChallenge: (ch: any, v: any) => v.publishChallengeAnswers(["4"]), + onChallengeVerification: () => {}, + }); + }); + + await act(async () => { + await accountsActions.publishCommentEdit({ + communityAddress: "sub.eth", + commentCid: "legacy cid", + spoiler: true, + onChallenge: (ch: any, e: any) => e.publishChallengeAnswers(["4"]), + onChallengeVerification: () => {}, + }); + }); + + await act(async () => { + await accountsActions.publishCommentModeration({ + communityAddress: "sub.eth", + commentCid: "legacy cid", + commentModeration: { locked: true }, + onChallenge: (ch: any, m: any) => m.publishChallengeAnswers(["4"]), + onChallengeVerification: () => {}, + }); + }); + + await act(async () => { + await accountsActions.publishCommunityEdit("remote-sub.eth", { + title: "legacy edit", + onChallenge: (ch: any, e: any) => e.publishChallengeAnswers(["4"]), + onChallengeVerification: () => {}, + }); + }); + + const { activeAccountId, accountsComments, accountsVotes, accountsEdits } = + accountsStore.getState(); + const accountId = activeAccountId!; + const storedComment = accountsComments[accountId][0]; + const storedVote = accountsVotes[accountId]["legacy cid"]; + const storedEdits = accountsEdits[accountId]["legacy cid"] || []; + + expect(storedComment.communityAddress).toBe("sub.eth"); + expect(storedComment.subplebbitAddress).toBeUndefined(); + expect(storedComment.shortCommunityAddress).toBeDefined(); + + expect(storedVote.communityAddress).toBe("sub.eth"); + expect(storedVote.subplebbitAddress).toBeUndefined(); + + expect(storedEdits).toHaveLength(2); + for (const storedEdit of storedEdits) { + expect(storedEdit.communityAddress).toBe("sub.eth"); + expect(storedEdit.subplebbitAddress).toBeUndefined(); + } + }); + }); + + describe("partial community rename compatibility", () => { + beforeEach(async () => { + setPlebbitJs(createLegacyPublicationSchemaPlebbitMock()); + await testUtils.resetDatabasesAndStores(); + }); + + afterEach(() => { + setPlebbitJs(PlebbitJsMock); + }); + + test("publication actions still use subplebbit payloads when createCommunity methods exist", async () => { + const waitForStore = async (condition: () => boolean) => { + const start = Date.now(); + while (Date.now() - start < 2000) { + await act(async () => {}); + if (condition()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error("timed out waiting for store update"); + }; + + let remoteVotePublication: any; + let remoteCommentEditPublication: any; + let remoteCommentModerationPublication: any; + + await act(async () => { + await accountsActions.publishComment({ + communityAddress: "sub.eth", + content: "mixed comment", + onChallenge: (ch: any, c: any) => c.publishChallengeAnswers(["4"]), + onChallengeVerification: () => {}, + }); + }); + await waitForStore( + () => + !!accountsStore.getState().accountsComments[ + accountsStore.getState().activeAccountId! + ]?.[0]?.cid, + ); + + await act(async () => { + await accountsActions.publishVote({ + communityAddress: "sub.eth", + commentCid: "mixed cid", + vote: 1, + onChallenge: (ch: any, v: any) => { + remoteVotePublication = v; + v.publishChallengeAnswers(["4"]); + }, + onChallengeVerification: (_verification: any, v: any) => { + remoteVotePublication = v; + }, + }); + }); + await waitForStore(() => remoteVotePublication?.communityAddress === "sub.eth"); + + await act(async () => { + await accountsActions.publishCommentEdit({ + communityAddress: "sub.eth", + commentCid: "mixed cid", + spoiler: true, + onChallenge: (ch: any, e: any) => { + remoteCommentEditPublication = e; + e.publishChallengeAnswers(["4"]); + }, + onChallengeVerification: (_verification: any, e: any) => { + remoteCommentEditPublication = e; + }, + }); + }); + await waitForStore(() => remoteCommentEditPublication?.communityAddress === "sub.eth"); + + await act(async () => { + await accountsActions.publishCommentModeration({ + communityAddress: "sub.eth", + commentCid: "mixed cid", + commentModeration: { locked: true }, + onChallenge: (ch: any, m: any) => { + remoteCommentModerationPublication = m; + m.publishChallengeAnswers(["4"]); + }, + onChallengeVerification: (_verification: any, m: any) => { + remoteCommentModerationPublication = m; + }, + }); + }); + await waitForStore(() => remoteCommentModerationPublication?.communityAddress === "sub.eth"); + + let remoteCommunityEditPublication: any; + await act(async () => { + await accountsActions.publishCommunityEdit("remote-sub.eth", { + title: "mixed edit", + onChallenge: (ch: any, e: any) => { + remoteCommunityEditPublication = e; + e.publishChallengeAnswers(["4"]); + }, + onChallengeVerification: (_verification: any, e: any) => { + remoteCommunityEditPublication = e; + }, + }); + }); + await waitForStore( + () => remoteCommunityEditPublication?.communityAddress === "remote-sub.eth", + ); + + const { accountsComments, accountsVotes, accountsEdits, activeAccountId } = + accountsStore.getState(); + const accountId = activeAccountId!; + const storedComment = accountsComments[accountId][0]; + const storedVote = accountsVotes[accountId]["mixed cid"]; + const storedEdits = accountsEdits[accountId]["mixed cid"] || []; + + expect(storedComment.communityAddress).toBe("sub.eth"); + expect(storedComment.shortCommunityAddress).toBeDefined(); + expect(storedComment.subplebbitAddress).toBeUndefined(); + expect(storedVote.communityAddress).toBe("sub.eth"); + expect(storedVote.subplebbitAddress).toBeUndefined(); + expect(remoteVotePublication.communityAddress).toBe("sub.eth"); + expect(storedEdits).toHaveLength(2); + for (const storedEdit of storedEdits) { + expect(storedEdit.communityAddress).toBe("sub.eth"); + expect(storedEdit.subplebbitAddress).toBeUndefined(); + } + expect(remoteCommentEditPublication.communityAddress).toBe("sub.eth"); + expect(remoteCommentModerationPublication.communityAddress).toBe("sub.eth"); + expect(remoteCommunityEditPublication.communityAddress).toBe("remote-sub.eth"); + }); + }); + describe("abandoned publish-session branches", () => { beforeEach(async () => { await testUtils.resetDatabasesAndStores(); diff --git a/src/stores/accounts/accounts-actions.ts b/src/stores/accounts/accounts-actions.ts index 0cef883..142c7b0 100644 --- a/src/stores/accounts/accounts-actions.ts +++ b/src/stores/accounts/accounts-actions.ts @@ -25,9 +25,12 @@ import { } from "../../types"; import * as accountsActionsInternal from "./accounts-actions-internal"; import { + backfillPublicationCommunityAddress, createPlebbitCommunityEdit, getPlebbitCommunityAddresses, - withLegacySubplebbitAddress, + normalizeCommunityEditOptionsForPlebbit, + normalizePublicationOptionsForStore, + normalizePublicationOptionsForPlebbit, } from "../../lib/plebbit-compat"; import { getAccountCommunities, @@ -635,7 +638,7 @@ export const publishComment = async ( author.previousCommentCid = previousCommentCid; } - let createCommentOptions: any = withLegacySubplebbitAddress({ + let createCommentOptions: any = normalizePublicationOptionsForPlebbit(account.plebbit, { timestamp: Math.floor(Date.now() / 1000), author, signer: account.signer, @@ -646,6 +649,7 @@ export const publishComment = async ( delete createCommentOptions.onError; delete createCommentOptions.onPublishingStateChange; delete createCommentOptions._onPendingCommentIndex; + const storedCreateCommentOptions = normalizePublicationOptionsForStore(createCommentOptions); // make sure the options dont throw await account.plebbit.createComment(createCommentOptions); @@ -680,7 +684,7 @@ export const publishComment = async ( }); }; let createdAccountComment = { - ...createCommentOptions, + ...storedCreateCommentOptions, depth, index: accountCommentIndex, accountId: account.id, @@ -699,7 +703,10 @@ export const publishComment = async ( createdAccountComment = { ...createdAccountComment, ...commentLinkDimensions }; await saveCreatedAccountComment(createdAccountComment); } - comment = await account.plebbit.createComment(createCommentOptions); + comment = backfillPublicationCommunityAddress( + await account.plebbit.createComment(createCommentOptions), + createCommentOptions, + ); publishAndRetryFailedChallengeVerification(); log("accountsActions.publishComment", { createCommentOptions }); })(); @@ -720,7 +727,10 @@ export const publishComment = async ( createCommentOptions = { ...createCommentOptions, timestamp }; createdAccountComment = { ...createdAccountComment, timestamp }; await saveCreatedAccountComment(createdAccountComment); - comment = await account.plebbit.createComment(createCommentOptions); + comment = backfillPublicationCommunityAddress( + await account.plebbit.createComment(createCommentOptions), + createCommentOptions, + ); lastChallenge = undefined; publishAndRetryFailedChallengeVerification(); } else { @@ -731,7 +741,9 @@ export const publishComment = async ( const currentIndex = sessionInfo?.currentIndex ?? accountCommentIndex; if (!sessionInfo || abandonedPublishKeys.has(sessionInfo.sessionKey)) return; cleanupPublishSessionOnTerminal(account.id, sessionInfo.keyIndex); - const commentWithCid = comment; + const commentWithCid = addShortAddressesToAccountComment( + normalizePublicationOptionsForStore(comment as any), + ); await accountsDatabase.addAccountComment(account.id, commentWithCid, currentIndex); accountsStore.setState(({ accountsComments, commentCidsToAccountsComments }) => { const updatedAccountComments = [...accountsComments[account.id]]; @@ -754,7 +766,9 @@ export const publishComment = async ( }); // clone the comment or it bugs publishing callbacks - const updatingComment = await account.plebbit.createComment({ ...comment }); + const updatingComment = await account.plebbit.createComment( + normalizePublicationOptionsForPlebbit(account.plebbit, { ...comment }), + ); accountsActionsInternal .startUpdatingAccountCommentOnCommentUpdateEvents( updatingComment, @@ -906,7 +920,7 @@ export const publishVote = async (publishVoteOptions: PublishVoteOptions, accoun account, }); - let createVoteOptions: any = withLegacySubplebbitAddress({ + let createVoteOptions: any = normalizePublicationOptionsForPlebbit(account.plebbit, { timestamp: Math.floor(Date.now() / 1000), author: account.author, signer: account.signer, @@ -916,8 +930,12 @@ export const publishVote = async (publishVoteOptions: PublishVoteOptions, accoun delete createVoteOptions.onChallengeVerification; delete createVoteOptions.onError; delete createVoteOptions.onPublishingStateChange; + const storedCreateVoteOptions = normalizePublicationOptionsForStore(createVoteOptions); - let vote = await account.plebbit.createVote(createVoteOptions); + let vote = backfillPublicationCommunityAddress( + await account.plebbit.createVote(createVoteOptions), + createVoteOptions, + ); let lastChallenge: Challenge | undefined; const publishAndRetryFailedChallengeVerification = async () => { vote.once("challenge", async (challenge: Challenge) => { @@ -929,7 +947,10 @@ export const publishVote = async (publishVoteOptions: PublishVoteOptions, accoun if (!challengeVerification.challengeSuccess && lastChallenge) { // publish again automatically on fail createVoteOptions = { ...createVoteOptions, timestamp: Math.floor(Date.now() / 1000) }; - vote = await account.plebbit.createVote(createVoteOptions); + vote = backfillPublicationCommunityAddress( + await account.plebbit.createVote(createVoteOptions), + createVoteOptions, + ); lastChallenge = undefined; publishAndRetryFailedChallengeVerification(); } @@ -950,16 +971,16 @@ export const publishVote = async (publishVoteOptions: PublishVoteOptions, accoun }; publishAndRetryFailedChallengeVerification(); - await accountsDatabase.addAccountVote(account.id, createVoteOptions); + await accountsDatabase.addAccountVote(account.id, storedCreateVoteOptions); log("accountsActions.publishVote", { createVoteOptions }); accountsStore.setState(({ accountsVotes }) => ({ accountsVotes: { ...accountsVotes, [account.id]: { ...accountsVotes[account.id], - [createVoteOptions.commentCid]: + [storedCreateVoteOptions.commentCid]: // remove signer and author because not needed and they expose private key - { ...createVoteOptions, signer: undefined, author: undefined }, + { ...storedCreateVoteOptions, signer: undefined, author: undefined }, }, }, })); @@ -985,7 +1006,7 @@ export const publishCommentEdit = async ( account, }); - let createCommentEditOptions: any = withLegacySubplebbitAddress({ + let createCommentEditOptions: any = normalizePublicationOptionsForPlebbit(account.plebbit, { timestamp: Math.floor(Date.now() / 1000), author: account.author, signer: account.signer, @@ -995,8 +1016,13 @@ export const publishCommentEdit = async ( delete createCommentEditOptions.onChallengeVerification; delete createCommentEditOptions.onError; delete createCommentEditOptions.onPublishingStateChange; + const storedCreateCommentEditOptions = + normalizePublicationOptionsForStore(createCommentEditOptions); - let commentEdit = await account.plebbit.createCommentEdit(createCommentEditOptions); + let commentEdit = backfillPublicationCommunityAddress( + await account.plebbit.createCommentEdit(createCommentEditOptions), + createCommentEditOptions, + ); let lastChallenge: Challenge | undefined; const publishAndRetryFailedChallengeVerification = async () => { commentEdit.once("challenge", async (challenge: Challenge) => { @@ -1013,7 +1039,10 @@ export const publishCommentEdit = async ( ...createCommentEditOptions, timestamp: Math.floor(Date.now() / 1000), }; - commentEdit = await account.plebbit.createCommentEdit(createCommentEditOptions); + commentEdit = backfillPublicationCommunityAddress( + await account.plebbit.createCommentEdit(createCommentEditOptions), + createCommentEditOptions, + ); lastChallenge = undefined; publishAndRetryFailedChallengeVerification(); } @@ -1038,19 +1067,23 @@ export const publishCommentEdit = async ( publishAndRetryFailedChallengeVerification(); - await accountsDatabase.addAccountEdit(account.id, createCommentEditOptions); + await accountsDatabase.addAccountEdit(account.id, storedCreateCommentEditOptions); log("accountsActions.publishCommentEdit", { createCommentEditOptions }); accountsStore.setState(({ accountsEdits }) => { // remove signer and author because not needed and they expose private key - const commentEdit = { ...createCommentEditOptions, signer: undefined, author: undefined }; - let commentEdits = accountsEdits[account.id][createCommentEditOptions.commentCid] || []; + const commentEdit = { + ...storedCreateCommentEditOptions, + signer: undefined, + author: undefined, + }; + let commentEdits = accountsEdits[account.id][storedCreateCommentEditOptions.commentCid] || []; commentEdits = [...commentEdits, commentEdit]; return { accountsEdits: { ...accountsEdits, [account.id]: { ...accountsEdits[account.id], - [createCommentEditOptions.commentCid]: commentEdits, + [storedCreateCommentEditOptions.commentCid]: commentEdits, }, }, }; @@ -1077,7 +1110,7 @@ export const publishCommentModeration = async ( account, }); - let createCommentModerationOptions: any = withLegacySubplebbitAddress({ + let createCommentModerationOptions: any = normalizePublicationOptionsForPlebbit(account.plebbit, { timestamp: Math.floor(Date.now() / 1000), author: account.author, signer: account.signer, @@ -1087,8 +1120,12 @@ export const publishCommentModeration = async ( delete createCommentModerationOptions.onChallengeVerification; delete createCommentModerationOptions.onError; delete createCommentModerationOptions.onPublishingStateChange; + const storedCreateCommentModerationOptions = normalizePublicationOptionsForStore( + createCommentModerationOptions, + ); - let commentModeration = await account.plebbit.createCommentModeration( + let commentModeration = backfillPublicationCommunityAddress( + await account.plebbit.createCommentModeration(createCommentModerationOptions), createCommentModerationOptions, ); let lastChallenge: Challenge | undefined; @@ -1110,7 +1147,8 @@ export const publishCommentModeration = async ( ...createCommentModerationOptions, timestamp: Math.floor(Date.now() / 1000), }; - commentModeration = await account.plebbit.createCommentModeration( + commentModeration = backfillPublicationCommunityAddress( + await account.plebbit.createCommentModeration(createCommentModerationOptions), createCommentModerationOptions, ); lastChallenge = undefined; @@ -1137,24 +1175,24 @@ export const publishCommentModeration = async ( publishAndRetryFailedChallengeVerification(); - await accountsDatabase.addAccountEdit(account.id, createCommentModerationOptions); + await accountsDatabase.addAccountEdit(account.id, storedCreateCommentModerationOptions); log("accountsActions.publishCommentModeration", { createCommentModerationOptions }); accountsStore.setState(({ accountsEdits }) => { // remove signer and author because not needed and they expose private key const commentModeration = { - ...createCommentModerationOptions, + ...storedCreateCommentModerationOptions, signer: undefined, author: undefined, }; let commentModerations = - accountsEdits[account.id][createCommentModerationOptions.commentCid] || []; + accountsEdits[account.id][storedCreateCommentModerationOptions.commentCid] || []; commentModerations = [...commentModerations, commentModeration]; return { accountsEdits: { ...accountsEdits, [account.id]: { ...accountsEdits[account.id], - [createCommentModerationOptions.commentCid]: commentModerations, + [storedCreateCommentModerationOptions.commentCid]: commentModerations, }, }, }; @@ -1206,18 +1244,20 @@ export const publishCommunityEdit = async ( publishCommunityEditOptions.address === communityAddress, `accountsActions.publishCommunityEdit can't edit address of a remote community`, ); - let createCommunityEditOptions: any = withLegacySubplebbitAddress({ + let createCommunityEditOptions: any = normalizeCommunityEditOptionsForPlebbit(account.plebbit, { timestamp: Math.floor(Date.now() / 1000), author: account.author, signer: account.signer, // not possible to edit community.address over pubsub, only locally communityAddress, - subplebbitAddress: communityAddress, communityEdit: communityEditOptions, subplebbitEdit: communityEditOptions, }); - let communityEdit = await createPlebbitCommunityEdit(account.plebbit, createCommunityEditOptions); + let communityEdit = backfillPublicationCommunityAddress( + await createPlebbitCommunityEdit(account.plebbit, createCommunityEditOptions), + createCommunityEditOptions, + ); let lastChallenge: Challenge | undefined; const publishAndRetryFailedChallengeVerification = async () => { communityEdit.once("challenge", async (challenge: Challenge) => { @@ -1234,8 +1274,8 @@ export const publishCommunityEdit = async ( ...createCommunityEditOptions, timestamp: Math.floor(Date.now() / 1000), }; - communityEdit = await createPlebbitCommunityEdit( - account.plebbit, + communityEdit = backfillPublicationCommunityAddress( + await createPlebbitCommunityEdit(account.plebbit, createCommunityEditOptions), createCommunityEditOptions, ); lastChallenge = undefined; diff --git a/src/stores/comments/comments-store.test.ts b/src/stores/comments/comments-store.test.ts index c87ccdb..3f7f418 100644 --- a/src/stores/comments/comments-store.test.ts +++ b/src/stores/comments/comments-store.test.ts @@ -181,6 +181,41 @@ describe("comments store", () => { (repliesPagesStore as any).getState = repliesPagesGetState; }); + test("addCommentToStore preserves live legacy comment instances with event methods", async () => { + const commentCid = "legacy-live-comment-cid"; + const onSpy = vi.fn(); + const updateSpy = vi.fn().mockResolvedValue(undefined); + const liveComment = { + cid: commentCid, + timestamp: 1, + subplebbitAddress: "legacy-community-address", + clients: {}, + on: onSpy, + once: vi.fn(), + update: updateSpy, + removeAllListeners: vi.fn(), + }; + const createCommentOrig = mockAccount.plebbit.createComment; + mockAccount.plebbit.createComment = vi.fn().mockResolvedValue(liveComment); + + await act(async () => { + await commentsStore.getState().addCommentToStore(commentCid, mockAccount); + }); + + expect(mockAccount.plebbit.createComment).toHaveBeenCalledWith({ cid: commentCid }); + expect(commentsStore.getState().comments[commentCid]).toEqual( + expect.objectContaining({ + cid: commentCid, + communityAddress: "legacy-community-address", + }), + ); + expect(liveComment.communityAddress).toBe("legacy-community-address"); + expect(onSpy).toHaveBeenCalledTimes(3); + expect(updateSpy).toHaveBeenCalledTimes(1); + + mockAccount.plebbit.createComment = createCommentOrig; + }); + test("missing-comment client update guard returns empty object", async () => { const commentCid = "client-update-cid"; let storedCb: ((...args: any[]) => void) | null = null; diff --git a/src/stores/comments/comments-store.ts b/src/stores/comments/comments-store.ts index 1721f40..1ffa49b 100644 --- a/src/stores/comments/comments-store.ts +++ b/src/stores/comments/comments-store.ts @@ -70,7 +70,7 @@ const commentsStore = createStore((setState: Function, getState: } // the comment is still missing up to date mutable data like upvotes, edits, replies, etc - comment?.on("update", async (updatedComment: Comment) => { + comment?.on?.("update", async (updatedComment: Comment) => { updatedComment = normalizeCommentCommunityAddress(utils.clone(updatedComment)) as Comment; await commentsDatabase.setItem(commentCid, updatedComment); log("commentsStore comment update", { commentCid, updatedComment, account }); @@ -82,7 +82,7 @@ const commentsStore = createStore((setState: Function, getState: repliesPagesStore.getState().addRepliesPageCommentsToStore(comment); }); - comment?.on("updatingstatechange", (updatingState: string) => { + comment?.on?.("updatingstatechange", (updatingState: string) => { setState((state: CommentsState) => ({ comments: { ...state.comments, @@ -91,7 +91,7 @@ const commentsStore = createStore((setState: Function, getState: })); }); - comment?.on("error", (error: Error) => { + comment?.on?.("error", (error: Error) => { setState((state: CommentsState) => { let commentErrors = state.errors[commentCid] || []; commentErrors = [...commentErrors, error]; @@ -132,7 +132,7 @@ const commentsStore = createStore((setState: Function, getState: // if comment.timestamp isn't defined, it means the next update will contain the timestamp and author // which is used in addCidToAccountComment if (!comment?.timestamp) { - comment?.once("update", () => + comment?.once?.("update", () => accountsStore .getState() .accountsActionsInternal.addCidToAccountComment(comment) @@ -144,7 +144,7 @@ const commentsStore = createStore((setState: Function, getState: listeners.push(comment); comment - ?.update() + ?.update?.() .catch((error: unknown) => log.trace("comment.update error", { comment, error })); }, })); diff --git a/src/stores/communities/communities-store.test.ts b/src/stores/communities/communities-store.test.ts index 6bc5cb4..7c6201e 100644 --- a/src/stores/communities/communities-store.test.ts +++ b/src/stores/communities/communities-store.test.ts @@ -3,12 +3,37 @@ import testUtils, { renderHook } from "../../lib/test-utils"; import communitiesStore, { resetCommunitiesDatabaseAndStore } from "./communities-store"; import localForageLru from "../../lib/localforage-lru"; import { setPlebbitJs } from "../.."; -import PlebbitJsMock from "../../lib/plebbit-js/plebbit-js-mock"; +import PlebbitJsMock, { Plebbit as BasePlebbit } from "../../lib/plebbit-js/plebbit-js-mock"; import accountsStore from "../accounts"; import communitiesPagesStore from "../communities-pages"; let mockAccount: any; +const createLegacyOnlyAccount = () => { + class LegacyOnlyPlebbit extends BasePlebbit { + constructor(...args: any[]) { + super(...args); + (this as any).createCommunity = undefined; + (this as any).getCommunity = undefined; + (this as any).createCommunityEdit = undefined; + } + + async createSubplebbit(opts: any) { + return BasePlebbit.prototype.createCommunity.call(this, opts); + } + + async getSubplebbit(opts: any) { + return BasePlebbit.prototype.getCommunity.call(this, opts); + } + + async createSubplebbitEdit(opts: any) { + return BasePlebbit.prototype.createCommunityEdit.call(this, opts); + } + } + + return { id: "legacy-account-id", plebbit: new LegacyOnlyPlebbit() }; +}; + describe("communities store", () => { beforeAll(async () => { setPlebbitJs(PlebbitJsMock); @@ -240,6 +265,34 @@ describe("communities store", () => { ).rejects.toThrow("createCommunityOptions.address 'addr-no-signer' must be undefined"); }); + test("legacy createSubplebbit accounts can create, edit, and delete communities", async () => { + const legacyAccount = createLegacyOnlyAccount(); + let community: any; + + await act(async () => { + community = await communitiesStore + .getState() + .createCommunity({ title: "legacy title" }, legacyAccount); + }); + + expect(community.address).toBeDefined(); + expect(communitiesStore.getState().communities[community.address]?.title).toBe("legacy title"); + + await act(async () => { + await communitiesStore + .getState() + .editCommunity(community.address, { title: "legacy edited" }, legacyAccount); + }); + + expect(communitiesStore.getState().communities[community.address]?.title).toBe("legacy edited"); + + await act(async () => { + await communitiesStore.getState().deleteCommunity(community.address, legacyAccount); + }); + + expect(communitiesStore.getState().communities[community.address]).toBeUndefined(); + }); + test("clientsOnStateChange with chainTicker branch", async () => { const address = "chain-ticker-address"; let storedCb: ((...args: any[]) => void) | null = null; diff --git a/src/stores/communities/communities-store.ts b/src/stores/communities/communities-store.ts index 1d8bade..ac4a966 100644 --- a/src/stores/communities/communities-store.ts +++ b/src/stores/communities/communities-store.ts @@ -254,7 +254,7 @@ const communitiesStore = createStore( `communitiesStore.editCommunity invalid communityEditOptions argument '${communityEditOptions}'`, ); assert( - typeof account?.plebbit?.createCommunity === "function", + typeof getPlebbitCreateCommunity(account?.plebbit) === "function", `communitiesStore.editCommunity invalid account argument '${account}'`, ); @@ -304,7 +304,7 @@ const communitiesStore = createStore( ); } assert( - typeof account?.plebbit?.createCommunity === "function", + typeof getPlebbitCreateCommunity(account?.plebbit) === "function", `communitiesStore.createCommunity invalid account argument '${account}'`, ); @@ -331,7 +331,7 @@ const communitiesStore = createStore( `communitiesStore.deleteCommunity invalid communityAddress argument '${communityAddress}'`, ); assert( - typeof account?.plebbit?.createCommunity === "function", + typeof getPlebbitCreateCommunity(account?.plebbit) === "function", `communitiesStore.deleteCommunity invalid account argument '${account}'`, ); diff --git a/src/stores/replies-pages/replies-pages-store.test.ts b/src/stores/replies-pages/replies-pages-store.test.ts index b74fdae..5fb7d9c 100644 --- a/src/stores/replies-pages/replies-pages-store.test.ts +++ b/src/stores/replies-pages/replies-pages-store.test.ts @@ -451,6 +451,26 @@ describe("replies pages store", () => { expect(Object.keys(rendered.result.current.repliesPages).length).toBe(0); }); + test("addNextRepliesPageToStore accepts legacy plebbit accounts without createCommunity", async () => { + const createCommunity = mockAccount.plebbit.createCommunity; + delete mockAccount.plebbit.createCommunity; + + try { + const legacyComment = await mockAccount.plebbit.createComment({ cid: "legacy-page-cid" }); + const sortType = "new"; + const firstPageCid = legacyComment.replies.pageCids[sortType]; + + act(() => { + rendered.result.current.addNextRepliesPageToStore(legacyComment, sortType, mockAccount); + }); + + await waitFor(() => rendered.result.current.repliesPages[firstPageCid]); + expect(rendered.result.current.repliesPages[firstPageCid].comments.length).toBe(100); + } finally { + mockAccount.plebbit.createCommunity = createCommunity; + } + }); + test("addRepliesPageCommentsToStore skips comments without cid", () => { const commentWithNoCid = { cid: "parent", diff --git a/src/stores/replies-pages/replies-pages-store.ts b/src/stores/replies-pages/replies-pages-store.ts index fab8094..7285f86 100644 --- a/src/stores/replies-pages/replies-pages-store.ts +++ b/src/stores/replies-pages/replies-pages-store.ts @@ -41,7 +41,7 @@ const repliesPagesStore = createStore( `repliesPagesStore.addNextRepliesPageToStore sortType '${sortType}' invalid`, ); assert( - typeof account?.plebbit?.createCommunity === "function", + typeof account?.plebbit?.createComment === "function", `repliesPagesStore.addNextRepliesPageToStore account '${account}' invalid`, ); diff --git a/src/stores/replies/replies-store.test.ts b/src/stores/replies/replies-store.test.ts index 322949d..d2e7096 100644 --- a/src/stores/replies/replies-store.test.ts +++ b/src/stores/replies/replies-store.test.ts @@ -232,6 +232,29 @@ describe("replies store", () => { const repliesPagesCount = Object.keys(repliesPagesStore.getState().repliesPages).length; }); + test("addFeedToStoreOrUpdateComment accepts legacy plebbit accounts without getCommunity", async () => { + const getCommunity = mockAccount.plebbit.getCommunity; + delete mockAccount.plebbit.getCommunity; + + try { + const commentCid = "legacy comment cid"; + const sortType = "new"; + const feedOptions = { sortType, commentCid, accountId: mockAccount.id }; + const feedName = feedOptionsToFeedName(feedOptions); + const comment = new MockComment({ cid: commentCid }); + + act(() => { + rendered.result.current.addFeedToStoreOrUpdateComment(comment, feedOptions); + }); + + await waitFor(() => rendered.result.current.feedsOptions[feedName]); + await waitFor(() => rendered.result.current.loadedFeeds[feedName]?.length > 0); + expect(rendered.result.current.loadedFeeds[feedName].length).toBe(repliesPerPage); + } finally { + mockAccount.plebbit.getCommunity = getCommunity; + } + }); + test("addFeedsToStore returns early when feedOptionsArray is empty", () => { const result = rendered.result.current.addFeedsToStore([]); expect(result).toBeUndefined(); diff --git a/src/stores/replies/replies-store.ts b/src/stores/replies/replies-store.ts index 9ab2a2f..32bc912 100644 --- a/src/stores/replies/replies-store.ts +++ b/src/stores/replies/replies-store.ts @@ -140,7 +140,7 @@ const repliesStore = createStore((setState: Function, getState: Fu ); const account = accountsStore.getState().accounts[feedOptions.accountId]; assert( - typeof account?.plebbit?.getCommunity === "function", + typeof account?.plebbit?.createComment === "function", `repliesStore.addFeedToStoreOrUpdateComment feedOptions.accountId '${feedOptions.accountId}' invalid`, ); assert(