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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ useValidateComment({comment: Comment, validateReplies?: boolean}): {valid: boole
#### Communities Hooks

```
useCommunity({community: {name?: string, publicKey?: string}, onlyIfCached?: boolean}): Community
useCommunity({community: {name?: string, publicKey?: string}, onlyIfCached?: boolean}): Community & {syncState: "initializing" | "loading" | "retrying" | "succeeded" | "failed" | "stopped", hasCachedData: boolean, lastFetchAttemptAt: number | undefined, lastSuccessfulFetchAt: number | undefined}
useCommunities({communities?: CommunityIdentifier[], onlyIfCached?: boolean}): {communities: Communities[]}
useCommunityStats({community: {name?: string, publicKey?: string}, onlyIfCached?: boolean}): CommunityStats
useResolvedCommunityAddress({communityAddress: string, cache: boolean}): {resolvedAddress: string | undefined} // use {cache: false} when checking the user's own community address
Expand All @@ -139,6 +139,13 @@ Pass `{ publicKey, name }` when you have both so `pkc-js` can fetch through the

`useCommunity` only exposes community error events that are not superseded by a later `update` event. Transient fetch errors are delayed briefly before surfacing through `error` or `errors`.

`useCommunity().state` remains `succeeded` when cached community data is available, even
while a refresh is running. Use `syncState` for the current refresh lifecycle:
`loading` covers active name, IPNS, and IPFS work, while `retrying` means a transient
failure is being retried. `lastFetchAttemptAt` and `lastSuccessfulFetchAt` are Unix
timestamps in seconds. A successful fetch only proves that a valid community record was
reachable; it does not prove that the community operator is currently online.

#### Authors Hooks

```
Expand Down Expand Up @@ -409,6 +416,12 @@ const { authorComments, lastCommentCid, hasMore, loadMore } = useAuthorComments(

```jsx
const community = useCommunity({ community: { name: communityAddress, publicKey: communityPublicKey } });
const {
syncState,
hasCachedData,
lastFetchAttemptAt,
lastSuccessfulFetchAt,
} = community;
const communityStats = useCommunityStats({
community: { name: communityAddress, publicKey: communityPublicKey },
});
Expand Down
21 changes: 19 additions & 2 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ useValidateComment({comment: Comment, validateReplies?: boolean}): {valid: boole
#### Communities Hooks

```
useCommunity({community: {name?: string, publicKey?: string}, onlyIfCached?: boolean}): Community
useCommunity({community: {name?: string, publicKey?: string}, onlyIfCached?: boolean}): Community & {syncState: "initializing" | "loading" | "retrying" | "succeeded" | "failed" | "stopped", hasCachedData: boolean, lastFetchAttemptAt: number | undefined, lastSuccessfulFetchAt: number | undefined}
useCommunities({communities?: CommunityIdentifier[], onlyIfCached?: boolean}): {communities: Communities[]}
useCommunityStats({community: {name?: string, publicKey?: string}, onlyIfCached?: boolean}): CommunityStats
useResolvedCommunityAddress({communityAddress: string, cache: boolean}): {resolvedAddress: string | undefined} // use {cache: false} when checking the user's own community address
Expand All @@ -169,6 +169,13 @@ Pass `{ publicKey, name }` when you have both so `pkc-js` can fetch through the

`useCommunity` only exposes community error events that are not superseded by a later `update` event. Transient fetch errors are delayed briefly before surfacing through `error` or `errors`.

`useCommunity().state` remains `succeeded` when cached community data is available, even
while a refresh is running. Use `syncState` for the current refresh lifecycle:
`loading` covers active name, IPNS, and IPFS work, while `retrying` means a transient
failure is being retried. `lastFetchAttemptAt` and `lastSuccessfulFetchAt` are Unix
timestamps in seconds. A successful fetch only proves that a valid community record was
reachable; it does not prove that the community operator is currently online.

#### Authors Hooks

```
Expand Down Expand Up @@ -439,6 +446,12 @@ const { authorComments, lastCommentCid, hasMore, loadMore } = useAuthorComments(

```jsx
const community = useCommunity({ community: { name: communityAddress, publicKey: communityPublicKey } });
const {
syncState,
hasCachedData,
lastFetchAttemptAt,
lastSuccessfulFetchAt,
} = community;
const communityStats = useCommunityStats({
community: { name: communityAddress, publicKey: communityPublicKey },
});
Expand Down Expand Up @@ -3181,7 +3194,11 @@ 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.28](https://github.com/bitsocialnet/bitsocial-react-hooks/compare/v0.1.27...v0.1.28) (2026-07-10)
## [0.1.30](https://github.com/bitsocialnet/bitsocial-react-hooks/compare/v0.1.29...v0.1.30) (2026-07-14)



## [0.1.29](https://github.com/bitsocialnet/bitsocial-react-hooks/compare/v0.1.28...v0.1.29) (2026-07-14)



Expand Down
81 changes: 68 additions & 13 deletions src/hooks/communities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ describe("communities", () => {
testUtils.createWaitFor(rendered);

rendered.rerender({ community: { name: "community address 1" }, onlyIfCached: true });
expect(rendered.result.current.syncState).toBe("stopped");
expect(rendered.result.current.hasCachedData).toBe(false);
expect(rendered.result.current.lastFetchAttemptAt).toBeUndefined();
expect(rendered.result.current.lastSuccessfulFetchAt).toBeUndefined();
// TODO: find better way to wait
await new Promise((r) => setTimeout(r, 20));
// community not added to store
Expand Down Expand Up @@ -240,21 +244,71 @@ describe("communities", () => {
);
});

test("has updating state", async () => {
const rendered = renderHook<any, any>((communityAddress) =>
useCommunity({ community: toCommunity(communityAddress) }),
);
const waitFor = testUtils.createWaitFor(rendered);
rendered.rerender("community address");
test("exposes community sync lifecycle separately from cached data state", async () => {
const communityUpdate = Community.prototype.update;
const updatingCommunities: Community[] = [];
let now = 1_800_000_000_000;
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
Community.prototype.update = async function () {
updatingCommunities.push(this);
};

await waitFor(
() =>
rendered.result.current.state === "fetching-ipns" ||
rendered.result.current.state === "succeeded",
);
try {
const rendered = renderHook<any, any>((communityAddress) =>
useCommunity({ community: toCommunity(communityAddress) }),
);
const waitFor = testUtils.createWaitFor(rendered);
rendered.rerender("community address");

await waitFor(() => rendered.result.current.state === "succeeded");
expect(rendered.result.current.state).toBe("succeeded");
expect(rendered.result.current.syncState).toBe("initializing");
expect(rendered.result.current.hasCachedData).toBe(false);
await waitFor(() => updatingCommunities.length > 0);
expect(rendered.result.current.lastFetchAttemptAt).toBeUndefined();

now += 1_000;
updatingCommunities[0].emit("updatingstatechange", "fetching-ipns");
await waitFor(() => rendered.result.current.syncState === "loading");
expect(rendered.result.current.lastFetchAttemptAt).toBe(1_800_000_001);

now += 1_000;
updatingCommunities[0].emit("updatingstatechange", "fetching-ipfs");
await act(async () => {});
expect(rendered.result.current.syncState).toBe("loading");
expect(rendered.result.current.lastFetchAttemptAt).toBe(1_800_000_001);

updatingCommunities[0].emit("updatingstatechange", "waiting-retry");
await waitFor(() => rendered.result.current.syncState === "retrying");

now += 1_000;
updatingCommunities[0].emit("updatingstatechange", "fetching-ipns");
await waitFor(() => rendered.result.current.syncState === "loading");
expect(rendered.result.current.lastFetchAttemptAt).toBe(1_800_000_003);

updatingCommunities[0].updatedAt = 1_799_999_000;
updatingCommunities[0].emit("update", updatingCommunities[0]);
now += 1_000;
updatingCommunities[0].emit("updatingstatechange", "succeeded");
await waitFor(() => rendered.result.current.syncState === "succeeded");
expect(rendered.result.current.state).toBe("succeeded");
expect(rendered.result.current.hasCachedData).toBe(true);
expect(rendered.result.current.lastSuccessfulFetchAt).toBe(1_800_000_004);

updatingCommunities[0].emit("updatingstatechange", "waiting-retry");
await waitFor(() => rendered.result.current.syncState === "retrying");
expect(rendered.result.current.state).toBe("succeeded");

updatingCommunities[0].emit("updatingstatechange", "publishing-ipns");
await waitFor(() => rendered.result.current.syncState === "loading");

updatingCommunities[0].emit("updatingstatechange", "stopped");
await waitFor(() => rendered.result.current.syncState === "stopped");

updatingCommunities[0].emit("updatingstatechange", "failed");
await waitFor(() => rendered.result.current.syncState === "failed");
} finally {
Community.prototype.update = communityUpdate;
nowSpy.mockRestore();
}
});

test("overlays local community edit summary from the active account", async () => {
Expand Down Expand Up @@ -379,6 +433,7 @@ describe("communities", () => {
expect(rendered.result.current.error.message).toBe("pkc.createCommunity error");
expect(rendered.result.current.errors[0].message).toBe("pkc.createCommunity error");
expect(rendered.result.current.errors.length).toBe(1);
expect(rendered.result.current.syncState).toBe("failed");

// restore mock
PKC.prototype.createCommunity = createCommunity;
Expand Down
7 changes: 6 additions & 1 deletion src/hooks/communities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export function useCommunity(options?: UseCommunityOptions): UseCommunityResult
const storedCommunity = useCommunitiesStore((state: any) => state.communities[communityKey]);
const addCommunityToStore = useCommunitiesStore((state: any) => state.addCommunityToStore);
const errors = useCommunitiesStore((state: any) => state.errors[communityKey]);
const syncStatus = useCommunitiesStore((state: any) => state.syncStatuses[communityKey]);
const communityEditSummary = useAccountsStore((state: any) => {
const accountEditsSummaries = state.accountsEditsSummaries[accountId] || {};
const candidateCommunityKeys = [
Expand Down Expand Up @@ -169,10 +170,14 @@ export function useCommunity(options?: UseCommunityOptions): UseCommunityResult
() => ({
...mergedCommunity,
state,
syncState: syncStatus?.syncState || (onlyIfCached ? "stopped" : "initializing"),
hasCachedData: typeof mergedCommunity?.updatedAt === "number",
lastFetchAttemptAt: syncStatus?.lastFetchAttemptAt,
lastSuccessfulFetchAt: syncStatus?.lastSuccessfulFetchAt,
error: errors?.[errors.length - 1],
errors: errors || [],
}),
[mergedCommunity, communityKey, errors],
[mergedCommunity, communityKey, errors, onlyIfCached, syncStatus],
);
}

Expand Down
38 changes: 38 additions & 0 deletions src/stores/communities/communities-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe("communities store", () => {
const { result } = renderHook(() => communitiesStore.getState());
expect(result.current.communities).toEqual({});
expect(result.current.errors).toEqual({});
expect(result.current.syncStatuses).toEqual({});
expect(typeof result.current.addCommunityToStore).toBe("function");
});

Expand Down Expand Up @@ -367,6 +368,7 @@ describe("communities store", () => {

expect(communitiesStore.getState().errors[address]).toHaveLength(1);
expect(communitiesStore.getState().errors[address][0].message).toBe("create failed");
expect(communitiesStore.getState().syncStatuses[address]?.syncState).toBe("failed");
mockAccount.pkc.createCommunity = createOrig;
});

Expand Down Expand Up @@ -699,6 +701,9 @@ describe("communities store", () => {

expect(community.address).toBeDefined();
expect(communitiesStore.getState().communities[community.address]?.title).toBe("created title");
communitiesStore.setState((state) => ({
errors: { ...state.errors, [community.address]: [new Error("stale error")] },
}));

await act(async () => {
await communitiesStore
Expand All @@ -713,6 +718,8 @@ describe("communities store", () => {
});

expect(communitiesStore.getState().communities[community.address]).toBeUndefined();
expect(communitiesStore.getState().errors[community.address]).toBeUndefined();
expect(communitiesStore.getState().syncStatuses[community.address]).toBeUndefined();
});

test("clientsOnStateChange with chainTicker branch", async () => {
Expand Down Expand Up @@ -745,6 +752,37 @@ describe("communities store", () => {
(utils.default as any).clientsOnStateChange = origClientsOnStateChange;
});

test("clientsOnStateChange without chainTicker updates the client state", async () => {
const address = "client-state-address";
let storedCb: ((...args: any[]) => void) | null = null;

const utils = await import("../../lib/utils");
const origClientsOnStateChange = utils.default.clientsOnStateChange;
(utils.default as any).clientsOnStateChange = (_clients: any, cb: any) => {
storedCb = () => cb("fetching-ipns", "type", "url");
};

await act(async () => {
await communitiesStore.getState().addCommunityToStore(address, mockAccount);
});

communitiesStore.setState((state: any) => ({
communities: {
...state.communities,
[address]: {
...state.communities[address],
clients: { type: {} },
},
},
}));
storedCb!();
expect(communitiesStore.getState().communities[address]?.clients?.type?.url).toEqual({
state: "fetching-ipns",
});

(utils.default as any).clientsOnStateChange = origClientsOnStateChange;
});

test("clientsOnStateChange returns {} when community missing and chainTicker provided", async () => {
const address = "chain-missing-address";
let storedCb: ((...args: any[]) => void) | null = null;
Expand Down
Loading
Loading