Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ usePublishCommentEdit(options: UsePublishCommentEditOptions): UsePublishCommentE
usePublishCommentModeration(options: UsePublishCommentModerationOptions): UsePublishCommentModerationResult
usePublishCommunityEdit(options: UsePublishCommunityEditOptions): UsePublishCommunityEditResult
useCreateCommunity(options: CreateCommunityOptions): {createdCommunity: Community | undefined, createCommunity: Function}
useExportCommunity(options?: UseExportCommunityOptions): {
communityExports: {communityAddress: string, exportId: string}[],
exportCommunity: () => Promise<void>,
state: string,
error: Error | undefined,
errors: Error[]
}
```

#### States Hooks
Expand Down Expand Up @@ -1120,6 +1127,33 @@ const communities = useCommunities({
const _community = useCommunity({ community: { name: createdCommunity.address } });
```

#### (Desktop only) Export communities

```jsx
// Export one community.
const { communityExports, exportCommunity, state, error } = useExportCommunity({
communityAddress: "your-community-address.eth",
});
await exportCommunity();

// Export several communities at the same time.
const exportMany = useExportCommunity({
communityAddresses: ["community-1.eth", "community-2.eth"],
});
await exportMany.exportCommunity();

// Export every community listed by the active account's pkc client.
const exportAll = useExportCommunity();
await exportAll.exportCommunity();

if (state === "succeeded") {
console.log("started exports", communityExports);
}
if (state === "failed") {
console.log("failed to start export", error.message);
}
```

#### (Desktop only) List the communities you created

```jsx
Expand Down
34 changes: 34 additions & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,13 @@ usePublishCommentEdit(options: UsePublishCommentEditOptions): UsePublishCommentE
usePublishCommentModeration(options: UsePublishCommentModerationOptions): UsePublishCommentModerationResult
usePublishCommunityEdit(options: UsePublishCommunityEditOptions): UsePublishCommunityEditResult
useCreateCommunity(options: CreateCommunityOptions): {createdCommunity: Community | undefined, createCommunity: Function}
useExportCommunity(options?: UseExportCommunityOptions): {
communityExports: {communityAddress: string, exportId: string}[],
exportCommunity: () => Promise<void>,
state: string,
error: Error | undefined,
errors: Error[]
}
```

#### States Hooks
Expand Down Expand Up @@ -1150,6 +1157,33 @@ const communities = useCommunities({
const _community = useCommunity({ community: { name: createdCommunity.address } });
```

#### (Desktop only) Export communities

```jsx
// Export one community.
const { communityExports, exportCommunity, state, error } = useExportCommunity({
communityAddress: "your-community-address.eth",
});
await exportCommunity();

// Export several communities at the same time.
const exportMany = useExportCommunity({
communityAddresses: ["community-1.eth", "community-2.eth"],
});
await exportMany.exportCommunity();

// Export every community listed by the active account's pkc client.
const exportAll = useExportCommunity();
await exportAll.exportCommunity();

if (state === "succeeded") {
console.log("started exports", communityExports);
}
if (state === "failed") {
console.log("failed to start export", error.message);
}
```

#### (Desktop only) List the communities you created

```jsx
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
},
"dependencies": {
"@bitsocial/bso-resolver": "0.0.8",
"@pkcprotocol/pkc-js": "0.0.38",
"@pkcprotocol/pkc-js": "0.0.41",
"@pkcprotocol/pkc-logger": "0.1.0",
"assert": "2.0.0",
"ethers": "5.8.0",
Expand Down
180 changes: 180 additions & 0 deletions src/hooks/actions/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
useBlock,
useAccount,
useCreateCommunity,
useExportCommunity,
setPkcJs,
useAccountVote,
useAccountComments,
Expand Down Expand Up @@ -556,6 +557,185 @@ describe("actions", () => {
});
});

describe("useExportCommunity", () => {
let rendered: any, waitFor: Function;

beforeEach(async () => {
rendered = renderHook<any, any>((options = []) => {
const result1 = useExportCommunity(options[0]);
const result2 = useExportCommunity(options[1]);
return [result1, result2];
});
waitFor = testUtils.createWaitFor(rendered);
});

afterEach(async () => {
await testUtils.resetDatabasesAndStores();
});

test("can export one community", async () => {
const communityAddress = "export-one.eth";
expect(rendered.result.current[0].communityExports).toEqual([]);
expect(typeof rendered.result.current[0].exportCommunity).toBe("function");

rendered.rerender([{ communityAddress }]);
await waitFor(() => rendered.result.current[0].state === "ready");

await act(async () => {
await rendered.result.current[0].exportCommunity();
});

await waitFor(() => rendered.result.current[0].state === "succeeded");
expect(rendered.result.current[0].communityExports).toEqual([
{
communityAddress,
exportId: `${communityAddress} export 1`,
},
]);
expect(rendered.result.current[0].error).toBe(undefined);
});

test("is initializing while the account is unavailable", () => {
const activeAccountId = useAccountsStore.getState().activeAccountId;
useAccountsStore.setState({ activeAccountId: undefined });

try {
const initializing = renderHook(() =>
useExportCommunity({ communityAddress: "initializing-export.eth" }),
);
expect(initializing.result.current.state).toBe("initializing");
} finally {
useAccountsStore.setState({ activeAccountId });
}
});

test("returns initializing and hides stale exports when the account becomes unavailable", async () => {
rendered.rerender([{ communityAddress: "logout-export.eth" }]);
await waitFor(() => rendered.result.current[0].state === "ready");

await act(async () => {
await rendered.result.current[0].exportCommunity();
});
await waitFor(() => rendered.result.current[0].state === "succeeded");

const activeAccountId = useAccountsStore.getState().activeAccountId;
try {
await act(async () => {
useAccountsStore.setState({ activeAccountId: undefined });
});

expect(rendered.result.current[0].state).toBe("initializing");
expect(rendered.result.current[0].communityExports).toEqual([]);
} finally {
useAccountsStore.setState({ activeAccountId });
}
});

test("returns ready and hides stale exports when export targets change", async () => {
rendered.rerender([{ communityAddress: "old-export-target.eth" }]);
await waitFor(() => rendered.result.current[0].state === "ready");

await act(async () => {
await rendered.result.current[0].exportCommunity();
});
await waitFor(() => rendered.result.current[0].state === "succeeded");

rendered.rerender([{ communityAddress: "new-export-target.eth" }]);

expect(rendered.result.current[0].state).toBe("ready");
expect(rendered.result.current[0].communityExports).toEqual([]);

rendered.rerender([{ communityAddress: "old-export-target.eth" }]);

expect(rendered.result.current[0].state).toBe("ready");
expect(rendered.result.current[0].communityExports).toEqual([]);
});

test("can export multiple communities", async () => {
const communityAddresses = ["export-many-1.eth", "export-many-2.eth"];
rendered.rerender([{ communityAddresses }]);
await waitFor(() => rendered.result.current[0].state === "ready");

await act(async () => {
await rendered.result.current[0].exportCommunity();
});

await waitFor(() => rendered.result.current[0].state === "succeeded");
expect(rendered.result.current[0].communityExports).toEqual([
{
communityAddress: communityAddresses[0],
exportId: `${communityAddresses[0]} export 1`,
},
{
communityAddress: communityAddresses[1],
exportId: `${communityAddresses[1]} export 1`,
},
]);
});

test("exports listed account communities when no address is provided", async () => {
await act(async () => {
await useAccountsStore.getState().accountsActions.createCommunity({ title: "Export all" });
});

rendered.rerender([undefined]);
await waitFor(() => rendered.result.current[0].state === "ready");
await act(async () => {
await rendered.result.current[0].exportCommunity();
});

await waitFor(() => rendered.result.current[0].state === "succeeded");
expect(rendered.result.current[0].communityExports).toEqual([
{
communityAddress: "list community address 1",
exportId: "list community address 1 export 1",
},
{
communityAddress: "list community address 2",
exportId: "list community address 2 export 1",
},
{
communityAddress: "created community address",
exportId: "created community address export 1",
},
]);
});

test("useExportCommunity onError callback when export fails", async () => {
const original = useAccountsStore.getState().accountsActions.exportCommunity;
useAccountsStore.setState((state: any) => ({
...state,
accountsActions: {
...state.accountsActions,
exportCommunity: async () => {
throw Error("store exportCommunity error");
},
},
}));

const onError = vi.fn();
rendered.rerender([{ communityAddress: "export-error.eth", onError }]);
await waitFor(() => rendered.result.current[0].state === "ready");

await act(async () => {
await rendered.result.current[0].exportCommunity();
});

expect(rendered.result.current[0].state).toBe("failed");
expect(rendered.result.current[0].errors.length).toBe(1);
expect(rendered.result.current[0].error.message).toBe("store exportCommunity error");
expect(onError).toHaveBeenCalledWith(expect.any(Error));

useAccountsStore.setState((state: any) => ({
...state,
accountsActions: {
...state.accountsActions,
exportCommunity: original,
},
}));
});
});

// retry usePublish because publishing state is flaky
describe("usePublishComment", { retry: 3 }, () => {
let rendered: any, waitFor: Function;
Expand Down
73 changes: 73 additions & 0 deletions src/hooks/actions/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ import type {
UseBlockResult,
UseCreateCommunityOptions,
UseCreateCommunityResult,
UseExportCommunityOptions,
UseExportCommunityResult,
UsePublishVoteOptions,
UsePublishVoteResult,
UsePublishCommentEditOptions,
Expand All @@ -66,6 +68,7 @@ import type {
CommunityEdit,
Vote,
Community,
CommunityExport,
} from "../../types";

type PublishChallengeAnswers = (challengeAnswers: string[]) => Promise<void>;
Expand Down Expand Up @@ -711,3 +714,73 @@ export function useCreateCommunity(options?: UseCreateCommunityOptions): UseCrea
[state, errors, createdCommunity, options, accountName],
);
}

export function useExportCommunity(options?: UseExportCommunityOptions): UseExportCommunityResult {
assert(
!options || typeof options === "object",
`useExportCommunity options argument '${options}' not an object`,
);
const { accountName, communityAddress, communityAddresses, onError, ...exportCommunityOptions } =
options || {};
const accountsActions = useAccountsStore((state) => state.accountsActions);
const accountId = useAccountId(accountName);
const [errors, setErrors] = useState<Error[]>([]);
const [exportingState, setExportingState] = useState<string>();
const [communityExports, setCommunityExports] = useState<CommunityExport[]>([]);
const targetCommunityAddresses =
communityAddresses || (communityAddress ? [communityAddress] : undefined);
const exportContextKey = JSON.stringify([
accountId || null,
targetCommunityAddresses || null,
exportCommunityOptions.includePrivateKey === true,
exportCommunityOptions.exportPath,
]);
const previousExportContextKeyRef = useRef(exportContextKey);
const exportContextVersionRef = useRef(0);
if (previousExportContextKeyRef.current !== exportContextKey) {
previousExportContextKeyRef.current = exportContextKey;
exportContextVersionRef.current += 1;
}
const [exportingContext, setExportingContext] = useState<{
key: string;
version: number;
}>();

let initialState = "initializing";
if (accountId) {
initialState = "ready";
}
const hasCurrentExportState =
accountId &&
exportingContext?.key === exportContextKey &&
exportingContext.version === exportContextVersionRef.current;

const exportCommunity = async () => {
try {
setExportingContext({
key: exportContextKey,
version: exportContextVersionRef.current,
});
setExportingState("exporting");
const communityExports = await accountsActions.exportCommunity(
targetCommunityAddresses,
exportCommunityOptions,
accountName,
);
setCommunityExports(communityExports);
setExportingState("succeeded");
} catch (e: any) {
setExportingState("failed");
setErrors((errors) => [...errors, e]);
onError?.(e);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale export overwrites newer run

High Severity

exportCommunity applies setCommunityExports, setExportingState, and error updates when the awaited store call finishes, without checking that the export still matches the active exportingContext. A slower earlier export can finish after a later one starts and show the wrong communities as succeeded or failed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 14b96b0. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Success keeps prior export error

Medium Severity

Calling exportCommunity again after a failure does not reset errors, yet error still exposes errors[errors.length - 1] whenever hasCurrentExportState is true. A later successful run can leave state as succeeded while error still reflects the earlier failure.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 14b96b0. Configure here.

};

return {
communityExports: hasCurrentExportState ? communityExports : [],
exportCommunity,
state: hasCurrentExportState ? exportingState! : initialState,
Comment thread
cursor[bot] marked this conversation as resolved.
error: hasCurrentExportState ? errors[errors.length - 1] : undefined,
errors: hasCurrentExportState ? errors : [],
};
}
Loading
Loading