mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
304 lines
8.8 KiB
TypeScript
304 lines
8.8 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import type { ReactNode } from "react";
|
|
|
|
import type { Community } from "./types";
|
|
import {
|
|
clearCommunityStorage,
|
|
loadActiveCommunityId,
|
|
loadCommunities,
|
|
saveActiveCommunityId,
|
|
saveCommunities,
|
|
} from "./communityStorage";
|
|
import { removeSelfProfileCachesForRelay } from "@/features/profile/lib/selfProfileStorage";
|
|
import { removeChannelSnapshotForRelay } from "@/features/channels/channelSnapshot";
|
|
import { removeMessageSnapshotsForRelay } from "@/features/messages/lib/messageSnapshot";
|
|
import { clearSavedCommunitySnapshot } from "@/features/agents/activeAgentTurnsStore";
|
|
|
|
export type UpdateCommunityResult =
|
|
| { kind: "updated"; requiresReinit: boolean }
|
|
| { kind: "unchanged" }
|
|
| { kind: "duplicate-relay" }
|
|
| { kind: "not-found" };
|
|
|
|
/**
|
|
* Pure decision logic for updateCommunity — determines the outcome from a
|
|
* synchronous snapshot of communities without side effects. Extracted so the
|
|
* 5-case result matrix is unit-testable outside React.
|
|
*/
|
|
export function resolveCommunityUpdateResult(
|
|
communities: Community[],
|
|
activeId: string | null,
|
|
id: string,
|
|
updates: Partial<
|
|
Pick<Community, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
|
|
>,
|
|
): UpdateCommunityResult {
|
|
const current = communities.find((w) => w.id === id);
|
|
if (!current) return { kind: "not-found" };
|
|
|
|
if (
|
|
updates.relayUrl !== undefined &&
|
|
updates.relayUrl !== current.relayUrl &&
|
|
communities.some((w) => w.id !== id && w.relayUrl === updates.relayUrl)
|
|
) {
|
|
return { kind: "duplicate-relay" };
|
|
}
|
|
|
|
const hasChange =
|
|
(updates.name !== undefined && updates.name !== current.name) ||
|
|
(updates.relayUrl !== undefined && updates.relayUrl !== current.relayUrl) ||
|
|
(updates.token !== undefined && updates.token !== current.token) ||
|
|
(updates.pubkey !== undefined && updates.pubkey !== current.pubkey) ||
|
|
(updates.reposDir !== undefined && updates.reposDir !== current.reposDir);
|
|
|
|
if (!hasChange) return { kind: "unchanged" };
|
|
|
|
const isActive = id === activeId;
|
|
const backendFieldsChanged =
|
|
isActive &&
|
|
((updates.relayUrl !== undefined &&
|
|
updates.relayUrl !== current.relayUrl) ||
|
|
(updates.token !== undefined && updates.token !== current.token) ||
|
|
(updates.reposDir !== undefined &&
|
|
updates.reposDir !== current.reposDir));
|
|
|
|
return { kind: "updated", requiresReinit: backendFieldsChanged };
|
|
}
|
|
|
|
/**
|
|
* Permute `communities` so that its order matches `orderedIds`.
|
|
*
|
|
* - Communities whose id appears in `orderedIds` are placed first, in the
|
|
* order given by `orderedIds`.
|
|
* - Communities not mentioned in `orderedIds` (e.g. added after the drag
|
|
* completed) are appended at the end in their original relative order.
|
|
*
|
|
* Pure and side-effect-free — extracted so it can be unit-tested without
|
|
* a DOM or React.
|
|
*/
|
|
export function applyCommunitiesOrder(
|
|
communities: Community[],
|
|
orderedIds: string[],
|
|
): Community[] {
|
|
const byId = new Map(communities.map((c) => [c.id, c]));
|
|
const seen = new Set<string>();
|
|
const reordered: Community[] = [];
|
|
|
|
for (const id of orderedIds) {
|
|
const c = byId.get(id);
|
|
if (c && !seen.has(id)) {
|
|
reordered.push(c);
|
|
seen.add(id);
|
|
}
|
|
}
|
|
|
|
for (const c of communities) {
|
|
if (!seen.has(c.id)) {
|
|
reordered.push(c);
|
|
}
|
|
}
|
|
|
|
return reordered;
|
|
}
|
|
|
|
export type UseCommunitiesReturn = {
|
|
communities: Community[];
|
|
activeCommunity: Community | null;
|
|
/** Counter bumped when the active community's config changes (relayUrl/token). */
|
|
reinitKey: number;
|
|
/** Add a community, deduplicating by relayUrl. Returns the final ID in the list. */
|
|
addCommunity: (community: Community) => string;
|
|
clearCommunities: () => void;
|
|
removeCommunity: (id: string) => void;
|
|
switchCommunity: (id: string) => void;
|
|
/** Force the active community to re-init (e.g. after a deep-link reconnect). */
|
|
reconnectCommunity: () => void;
|
|
updateCommunity: (
|
|
id: string,
|
|
updates: Partial<
|
|
Pick<Community, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
|
|
>,
|
|
) => UpdateCommunityResult;
|
|
/** Persist a new display order for the rail. IDs not in orderedIds keep their relative position at the end. */
|
|
reorderCommunities: (orderedIds: string[]) => void;
|
|
};
|
|
|
|
const CommunitiesContext = createContext<UseCommunitiesReturn | null>(null);
|
|
|
|
export function CommunitiesProvider({ children }: { children: ReactNode }) {
|
|
const value = useCommunitiesInternal();
|
|
return (
|
|
<CommunitiesContext.Provider value={value}>
|
|
{children}
|
|
</CommunitiesContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useCommunities(): UseCommunitiesReturn {
|
|
const ctx = useContext(CommunitiesContext);
|
|
if (!ctx) {
|
|
throw new Error("useCommunities must be used within a CommunitiesProvider");
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
function useCommunitiesInternal(): UseCommunitiesReturn {
|
|
const [communities, setCommunitiesState] =
|
|
useState<Community[]>(loadCommunities);
|
|
const [activeId, setActiveId] = useState<string | null>(
|
|
loadActiveCommunityId,
|
|
);
|
|
const [reinitKey, setReinitKey] = useState(0);
|
|
const communitiesRef = useRef(communities);
|
|
communitiesRef.current = communities;
|
|
|
|
const activeCommunity = useMemo(
|
|
() => communities.find((w) => w.id === activeId) ?? communities[0] ?? null,
|
|
[communities, activeId],
|
|
);
|
|
|
|
const addCommunity = useCallback((community: Community): string => {
|
|
const existing = communitiesRef.current.find(
|
|
(w) => w.relayUrl === community.relayUrl,
|
|
);
|
|
const resolvedId = existing?.id ?? community.id;
|
|
setCommunitiesState((prev) => {
|
|
const dup = prev.find((w) => w.relayUrl === community.relayUrl);
|
|
let next: Community[];
|
|
if (dup) {
|
|
next = prev.map((w) =>
|
|
w.id === dup.id
|
|
? {
|
|
...w,
|
|
name: community.name || w.name,
|
|
token: community.token ?? w.token,
|
|
pubkey: community.pubkey ?? w.pubkey,
|
|
}
|
|
: w,
|
|
);
|
|
} else {
|
|
next = [...prev, community];
|
|
}
|
|
saveCommunities(next);
|
|
return next;
|
|
});
|
|
return resolvedId;
|
|
}, []);
|
|
|
|
const clearCommunities = useCallback(() => {
|
|
clearCommunityStorage();
|
|
setCommunitiesState([]);
|
|
setActiveId(null);
|
|
}, []);
|
|
|
|
const removeCommunity = useCallback(
|
|
(id: string) => {
|
|
// GC self-profile caches for the removed community's relay. Mirror the
|
|
// updater guard (length > 1) so we only GC when removal will actually
|
|
// proceed. Runs outside the updater — updaters can execute twice under
|
|
// React StrictMode.
|
|
if (communities.length > 1) {
|
|
const removed = communities.find((w) => w.id === id);
|
|
if (removed) {
|
|
removeSelfProfileCachesForRelay(removed.relayUrl);
|
|
removeChannelSnapshotForRelay(removed.relayUrl);
|
|
removeMessageSnapshotsForRelay(removed.relayUrl);
|
|
clearSavedCommunitySnapshot(id);
|
|
}
|
|
}
|
|
|
|
setCommunitiesState((prev) => {
|
|
// Never allow removing the last community
|
|
if (prev.length <= 1) {
|
|
return prev;
|
|
}
|
|
const next = prev.filter((w) => w.id !== id);
|
|
saveCommunities(next);
|
|
|
|
// If removing the active community, switch to first remaining
|
|
if (activeId === id && next.length > 0) {
|
|
saveActiveCommunityId(next[0].id);
|
|
setActiveId(next[0].id);
|
|
}
|
|
|
|
return next;
|
|
});
|
|
},
|
|
[activeId, communities],
|
|
);
|
|
|
|
const switchCommunity = useCallback(
|
|
(id: string) => {
|
|
if (id === activeId) return;
|
|
saveActiveCommunityId(id);
|
|
setActiveId(id);
|
|
},
|
|
[activeId],
|
|
);
|
|
|
|
const reconnectCommunity = useCallback(() => {
|
|
setReinitKey((k) => k + 1);
|
|
}, []);
|
|
|
|
const updateCommunity = useCallback(
|
|
(
|
|
id: string,
|
|
updates: Partial<
|
|
Pick<Community, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
|
|
>,
|
|
): UpdateCommunityResult => {
|
|
const result = resolveCommunityUpdateResult(
|
|
communitiesRef.current,
|
|
activeId,
|
|
id,
|
|
updates,
|
|
);
|
|
|
|
if (result.kind === "updated") {
|
|
setCommunitiesState((prev) => {
|
|
const next = prev.map((w) =>
|
|
w.id === id ? { ...w, ...updates } : w,
|
|
);
|
|
saveCommunities(next);
|
|
return next;
|
|
});
|
|
|
|
if (result.requiresReinit) {
|
|
setReinitKey((k) => k + 1);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
},
|
|
[activeId],
|
|
);
|
|
|
|
const reorderCommunities = useCallback((orderedIds: string[]) => {
|
|
setCommunitiesState((prev) => {
|
|
const next = applyCommunitiesOrder(prev, orderedIds);
|
|
saveCommunities(next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
return {
|
|
communities,
|
|
activeCommunity,
|
|
reinitKey,
|
|
addCommunity,
|
|
clearCommunities,
|
|
removeCommunity,
|
|
switchCommunity,
|
|
reconnectCommunity,
|
|
updateCommunity,
|
|
reorderCommunities,
|
|
};
|
|
}
|