mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: remember per-community navigation location (#2629)
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
This commit is contained in:
co-authored by
npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7
parent
5afa16157a
commit
95478919fc
@@ -71,6 +71,7 @@ export default defineConfig({
|
||||
"**/home-collapsed-top-chrome.spec.ts",
|
||||
"**/top-chrome-zoom-clearance.spec.ts",
|
||||
"**/thread-unread.spec.ts",
|
||||
"**/workspace-rail.spec.ts",
|
||||
"**/community-rail.spec.ts",
|
||||
"**/boot-splash.spec.ts",
|
||||
"**/thread-reply-anchor-roleplay.spec.ts",
|
||||
|
||||
+51
-9
@@ -12,6 +12,11 @@ import {
|
||||
} from "react";
|
||||
|
||||
import { router } from "@/app/router";
|
||||
import {
|
||||
completeCommunityViewTransition,
|
||||
replaceCommunityDestinationRoute,
|
||||
} from "@/app/communityViewTransition";
|
||||
import { deriveShellRoute } from "@/app/AppShell.helpers";
|
||||
import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground";
|
||||
import { useReloadShortcut } from "@/app/useReloadShortcut";
|
||||
import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys";
|
||||
@@ -37,6 +42,11 @@ import { ResetFailedScreen } from "@/features/onboarding/ui/ResetFailedScreen";
|
||||
import { useCommunityInit } from "@/features/communities/useCommunityInit";
|
||||
import { useNestNotifications } from "@/features/communities/useNestNotifications";
|
||||
import { useCommunities } from "@/features/communities/useCommunities";
|
||||
import {
|
||||
loadCommunityDestination,
|
||||
markPendingCommunityRestore,
|
||||
saveCommunityDestination,
|
||||
} from "@/features/communities/communityNavigationStorage";
|
||||
import {
|
||||
onAddCommunityPrefillAvailable,
|
||||
requestAddCommunityPrefill,
|
||||
@@ -323,13 +333,40 @@ function CommunityApp({
|
||||
sharedIdentity,
|
||||
);
|
||||
|
||||
const handleCommunityOnboardingConnect = useCallback(() => {
|
||||
const transitionCommunity = useCallback(
|
||||
async (targetCommunityId: string) => {
|
||||
const activeCommunityId = activeCommunity?.id;
|
||||
if (targetCommunityId === activeCommunityId) return;
|
||||
if (activeCommunityId) {
|
||||
const route = deriveShellRoute(router.state.location.pathname);
|
||||
saveCommunityDestination(
|
||||
activeCommunityId,
|
||||
route.selectedView === "channel" && route.selectedChannelId
|
||||
? { kind: "channel", channelId: route.selectedChannelId }
|
||||
: { kind: "home" },
|
||||
);
|
||||
await router.navigate({ to: "/", replace: true });
|
||||
markPendingCommunityRestore(targetCommunityId);
|
||||
const destination = loadCommunityDestination(targetCommunityId);
|
||||
if (destination?.kind === "channel") {
|
||||
replaceCommunityDestinationRoute(
|
||||
destination.channelId,
|
||||
router.history,
|
||||
);
|
||||
}
|
||||
}
|
||||
switchCommunity(targetCommunityId);
|
||||
},
|
||||
[activeCommunity?.id, switchCommunity],
|
||||
);
|
||||
|
||||
const handleCommunityOnboardingConnect = useCallback(async () => {
|
||||
const transaction = communityOnboarding.transaction;
|
||||
if (transaction?.stage !== "connecting") return;
|
||||
if (connectingTransactionRef.current === transaction.id) return;
|
||||
connectingTransactionRef.current = transaction.id;
|
||||
if (transaction.communityId) {
|
||||
switchCommunity(transaction.communityId);
|
||||
await transitionCommunity(transaction.communityId);
|
||||
return;
|
||||
}
|
||||
const previousCommunityId = activeCommunity?.id;
|
||||
@@ -351,7 +388,7 @@ function CommunityApp({
|
||||
addedCommunity: !relayAlreadyExists,
|
||||
error: undefined,
|
||||
});
|
||||
switchCommunity(id);
|
||||
await transitionCommunity(id);
|
||||
reconnectCommunity();
|
||||
}, [
|
||||
activeCommunity?.id,
|
||||
@@ -360,17 +397,17 @@ function CommunityApp({
|
||||
communityOnboarding,
|
||||
currentPubkey,
|
||||
reconnectCommunity,
|
||||
switchCommunity,
|
||||
transitionCommunity,
|
||||
]);
|
||||
|
||||
const handleCommunityOnboardingCancel = useCallback(() => {
|
||||
const handleCommunityOnboardingCancel = useCallback(async () => {
|
||||
const transaction = communityOnboarding.transaction;
|
||||
communityOnboarding.clear();
|
||||
|
||||
if (!transaction?.communityId) return;
|
||||
if (!transaction.addedCommunity) {
|
||||
if (transaction.previousCommunityId) {
|
||||
switchCommunity(transaction.previousCommunityId);
|
||||
await transitionCommunity(transaction.previousCommunityId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -381,16 +418,16 @@ function CommunityApp({
|
||||
clearCommunities();
|
||||
return;
|
||||
}
|
||||
removeCommunity(transaction.communityId);
|
||||
if (transaction.previousCommunityId) {
|
||||
switchCommunity(transaction.previousCommunityId);
|
||||
await transitionCommunity(transaction.previousCommunityId);
|
||||
}
|
||||
removeCommunity(transaction.communityId);
|
||||
}, [
|
||||
clearCommunities,
|
||||
communities.length,
|
||||
communityOnboarding,
|
||||
removeCommunity,
|
||||
switchCommunity,
|
||||
transitionCommunity,
|
||||
]);
|
||||
|
||||
const bootSplashPhase = useBootSplashHold();
|
||||
@@ -490,6 +527,11 @@ function CommunityApp({
|
||||
// Tauri backend is still configured for the previous one.
|
||||
const communityApplied =
|
||||
community.isReady && community.appliedKey === communityKey;
|
||||
useLayoutEffect(() => {
|
||||
if (communityApplied) {
|
||||
completeCommunityViewTransition();
|
||||
}
|
||||
}, [communityApplied]);
|
||||
if (appContent === null && (!transaction || isEnteringCurtain)) {
|
||||
appContent = communityApplied ? (
|
||||
<CommunityQueryProvider key={communityKey}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AppShellOverlays } from "@/app/AppShellOverlays";
|
||||
import { AppTopChrome } from "@/app/AppTopChrome";
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useBackForwardControls } from "@/app/navigation/useBackForwardControls";
|
||||
import { useCommunityNavigationTransitions } from "@/app/useCommunityNavigationTransitions";
|
||||
import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions";
|
||||
import { useChannelBrowserDialog } from "@/app/useChannelBrowserDialog";
|
||||
import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts";
|
||||
@@ -71,6 +72,11 @@ import { CommunityRail } from "@/features/sidebar/ui/CommunityRail";
|
||||
import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes";
|
||||
import { useChannelStars } from "@/features/sidebar/lib/useChannelStars";
|
||||
import { useCommunities } from "@/features/communities/useCommunities";
|
||||
import {
|
||||
consumePendingCommunityRestore,
|
||||
loadCommunityDestination,
|
||||
saveCommunityDestination,
|
||||
} from "@/features/communities/communityNavigationStorage";
|
||||
import { useAddCommunityDialogState } from "@/features/communities/addCommunityPrefill";
|
||||
import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
@@ -129,30 +135,19 @@ export function AppShell() {
|
||||
} = useAppNavigation();
|
||||
const { canGoBack, canGoForward, goBack, goForward } =
|
||||
useBackForwardControls();
|
||||
// Navigate home before switching communities so the outgoing channel URL is
|
||||
// cleared. Without this, ChannelScreen's read effect continues firing
|
||||
// markChannelRead({ topLevelOnly: true }) for the previous community's
|
||||
// channel, advancing its NIP-RS markers and causing the rail badge to vanish
|
||||
// on the next 30s poll (A→B→A→B disappearance bug).
|
||||
// Guard: skip goHome() when re-selecting the already-active community so
|
||||
// the current channel is not unexpectedly cleared.
|
||||
const handleSwitchCommunity = React.useCallback(
|
||||
(id: string) => {
|
||||
if (id !== communitiesHook.activeCommunity?.id) {
|
||||
void goHome();
|
||||
}
|
||||
communitiesHook.switchCommunity(id);
|
||||
},
|
||||
[
|
||||
goHome,
|
||||
communitiesHook.activeCommunity?.id,
|
||||
communitiesHook.switchCommunity,
|
||||
],
|
||||
);
|
||||
const { selectedChannelId, selectedView } = React.useMemo(
|
||||
() => deriveShellRoute(location.pathname),
|
||||
[location.pathname],
|
||||
);
|
||||
const {
|
||||
removeCommunity: handleRemoveCommunity,
|
||||
switchCommunity: handleSwitchCommunity,
|
||||
} = useCommunityNavigationTransitions({
|
||||
communities: communitiesHook,
|
||||
goHome,
|
||||
selectedChannelId,
|
||||
selectedView,
|
||||
});
|
||||
// Settings lives in history so back returns to the previous app entry.
|
||||
const settingsOpen = location.pathname === "/settings";
|
||||
const locationSearchSection = (location.search as { section?: unknown })
|
||||
@@ -241,6 +236,54 @@ export function AppShell() {
|
||||
() => memberChannels.filter((channel) => channel.archivedAt === null),
|
||||
[memberChannels],
|
||||
);
|
||||
const hasRestoredCommunityDestinationRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
const activeCommunityId = communitiesHook.activeCommunity?.id;
|
||||
if (
|
||||
hasRestoredCommunityDestinationRef.current ||
|
||||
!channelsQuery.isSuccess ||
|
||||
channelsQuery.dataUpdatedAt === 0 ||
|
||||
!activeCommunityId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
hasRestoredCommunityDestinationRef.current = true;
|
||||
|
||||
// Restoration belongs to an explicit community transition. Cold boot and
|
||||
// reconnect remounts must preserve the route the user explicitly opened.
|
||||
if (!consumePendingCommunityRestore(activeCommunityId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const destination = loadCommunityDestination(activeCommunityId);
|
||||
if (!destination || destination.kind === "home") {
|
||||
return;
|
||||
}
|
||||
|
||||
const channelIsAvailable = sidebarChannels.some(
|
||||
(channel) => channel.id === destination.channelId,
|
||||
);
|
||||
if (!channelIsAvailable) {
|
||||
saveCommunityDestination(activeCommunityId, { kind: "home" });
|
||||
void goHome({ replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// The normal switch path writes the remembered channel into the hash before
|
||||
// the target community mounts, so no intermediate Inbox frame is painted.
|
||||
// Older transition callers may still arrive at neutral Home; repair those.
|
||||
if (selectedView === "home") {
|
||||
void goChannel(destination.channelId, { replace: true });
|
||||
}
|
||||
}, [
|
||||
channelsQuery.dataUpdatedAt,
|
||||
channelsQuery.isSuccess,
|
||||
communitiesHook.activeCommunity?.id,
|
||||
goChannel,
|
||||
goHome,
|
||||
selectedView,
|
||||
sidebarChannels,
|
||||
]);
|
||||
const activeChannel = React.useMemo(
|
||||
() =>
|
||||
selectedChannelId
|
||||
@@ -713,7 +756,7 @@ export function AppShell() {
|
||||
communitiesHook.activeCommunity?.id ?? null
|
||||
}
|
||||
onAddCommunity={addCommunityDialog.openDialog}
|
||||
onRemoveCommunity={communitiesHook.removeCommunity}
|
||||
onRemoveCommunity={(id) => void handleRemoveCommunity(id)}
|
||||
onReorderCommunities={communitiesHook.reorderCommunities}
|
||||
onSwitchCommunity={handleSwitchCommunity}
|
||||
onUpdateCommunity={communitiesHook.updateCommunity}
|
||||
@@ -805,7 +848,9 @@ export function AppShell() {
|
||||
onOpenAddCommunity={addCommunityDialog.openDialog}
|
||||
onSendFeedback={() => setIsSendFeedbackOpen(true)}
|
||||
onUpdateCommunity={communitiesHook.updateCommunity}
|
||||
onRemoveCommunity={communitiesHook.removeCommunity}
|
||||
onRemoveCommunity={(id) =>
|
||||
void handleRemoveCommunity(id)
|
||||
}
|
||||
onSwitchCommunity={handleSwitchCommunity}
|
||||
onCreateAgent={() => requestOpenCreateAgent()}
|
||||
selfPresenceStatus={presenceSession.currentStatus}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { afterEach, mock } from "node:test";
|
||||
|
||||
import {
|
||||
completeCommunityViewTransition,
|
||||
replaceCommunityDestinationRoute,
|
||||
runCommunityViewTransition,
|
||||
} from "./communityViewTransition.ts";
|
||||
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.window = originalWindow;
|
||||
mock.restoreAll();
|
||||
});
|
||||
|
||||
function installBrowser(startViewTransition) {
|
||||
globalThis.window = { clearTimeout, setTimeout };
|
||||
globalThis.document = { startViewTransition };
|
||||
}
|
||||
|
||||
function transitionFor(callback) {
|
||||
return { updateCallbackDone: Promise.resolve().then(callback) };
|
||||
}
|
||||
|
||||
test("replaceCommunityDestinationRoute uses router history and encodes the channel id", () => {
|
||||
const replacements = [];
|
||||
replaceCommunityDestinationRoute("channel/with spaces", {
|
||||
replace: (href) => replacements.push(href),
|
||||
});
|
||||
assert.deepEqual(replacements, ["/channels/channel%2Fwith%20spaces"]);
|
||||
});
|
||||
|
||||
test("unsupported browsers execute the update and contain rejection", async () => {
|
||||
installBrowser(undefined);
|
||||
const expected = new Error("navigation failed");
|
||||
const error = mock.method(console, "error", () => {});
|
||||
|
||||
await assert.doesNotReject(() =>
|
||||
runCommunityViewTransition(async () => {
|
||||
throw expected;
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(error.mock.callCount(), 1);
|
||||
assert.equal(error.mock.calls[0].arguments[1], expected);
|
||||
});
|
||||
|
||||
test("supported transitions wait for target readiness", async () => {
|
||||
let updateFinished = false;
|
||||
let transitionFinished = false;
|
||||
installBrowser((callback) => transitionFor(callback));
|
||||
|
||||
const pending = runCommunityViewTransition(async () => {
|
||||
updateFinished = true;
|
||||
}).then(() => {
|
||||
transitionFinished = true;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(updateFinished, true);
|
||||
assert.equal(transitionFinished, false);
|
||||
|
||||
completeCommunityViewTransition();
|
||||
await pending;
|
||||
assert.equal(transitionFinished, true);
|
||||
});
|
||||
|
||||
test("a newer transition releases the previous transition", async () => {
|
||||
installBrowser((callback) => transitionFor(callback));
|
||||
|
||||
let firstFinished = false;
|
||||
const first = runCommunityViewTransition(() => {}).then(() => {
|
||||
firstFinished = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const second = runCommunityViewTransition(() => {});
|
||||
await first;
|
||||
assert.equal(firstFinished, true);
|
||||
|
||||
completeCommunityViewTransition();
|
||||
await second;
|
||||
});
|
||||
|
||||
test("timeout releases a transition whose target never reports ready", async () => {
|
||||
installBrowser((callback) => transitionFor(callback));
|
||||
|
||||
await assert.doesNotReject(() =>
|
||||
runCommunityViewTransition(() => {}, { timeoutMs: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
test("view-transition callback rejection is contained", async () => {
|
||||
installBrowser((callback) => transitionFor(callback));
|
||||
const expected = new Error("route rejected");
|
||||
const error = mock.method(console, "error", () => {});
|
||||
|
||||
await assert.doesNotReject(() =>
|
||||
runCommunityViewTransition(async () => {
|
||||
throw expected;
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(error.mock.callCount(), 1);
|
||||
assert.equal(error.mock.calls[0].arguments[1], expected);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
const COMMUNITY_TRANSITION_TIMEOUT_MS = 5_000;
|
||||
|
||||
let finishPendingTransition: (() => void) | null = null;
|
||||
|
||||
export function completeCommunityViewTransition(): void {
|
||||
finishPendingTransition?.();
|
||||
}
|
||||
|
||||
export function replaceCommunityDestinationRoute(
|
||||
channelId: string,
|
||||
history: { replace: (href: string) => void },
|
||||
): void {
|
||||
history.replace(`/channels/${encodeURIComponent(channelId)}`);
|
||||
}
|
||||
|
||||
export async function runCommunityViewTransition(
|
||||
update: () => Promise<void> | void,
|
||||
options: { timeoutMs?: number } = {},
|
||||
): Promise<void> {
|
||||
if (!document.startViewTransition) {
|
||||
try {
|
||||
await update();
|
||||
} catch (error) {
|
||||
console.error("Community transition failed:", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let finish: (() => void) | undefined;
|
||||
const targetReady = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
finishPendingTransition?.();
|
||||
finishPendingTransition = finish ?? null;
|
||||
|
||||
const timeout = window.setTimeout(
|
||||
() => completeCommunityViewTransition(),
|
||||
options.timeoutMs ?? COMMUNITY_TRANSITION_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
try {
|
||||
const transition = document.startViewTransition(async () => {
|
||||
await update();
|
||||
await targetReady;
|
||||
});
|
||||
await transition.updateCallbackDone;
|
||||
} catch (error) {
|
||||
// Event handlers intentionally fire-and-forget community switches. Contain
|
||||
// navigation/apply failures here so rejection cannot escape React; update()
|
||||
// either leaves the current route intact or at the deliberate Home barrier.
|
||||
console.error("Community transition failed:", error);
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
if (finishPendingTransition === finish) {
|
||||
finishPendingTransition = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import type { deriveShellRoute } from "@/app/AppShell.helpers";
|
||||
import type { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import {
|
||||
replaceCommunityDestinationRoute,
|
||||
runCommunityViewTransition,
|
||||
} from "@/app/communityViewTransition";
|
||||
import {
|
||||
loadCommunityDestination,
|
||||
markPendingCommunityRestore,
|
||||
saveCommunityDestination,
|
||||
} from "@/features/communities/communityNavigationStorage";
|
||||
import type { useCommunities } from "@/features/communities/useCommunities";
|
||||
|
||||
type Communities = ReturnType<typeof useCommunities>;
|
||||
type ShellRoute = ReturnType<typeof deriveShellRoute>;
|
||||
type GoHome = ReturnType<typeof useAppNavigation>["goHome"];
|
||||
|
||||
export function useCommunityNavigationTransitions({
|
||||
communities,
|
||||
goHome,
|
||||
selectedChannelId,
|
||||
selectedView,
|
||||
}: {
|
||||
communities: Communities;
|
||||
goHome: GoHome;
|
||||
selectedChannelId: ShellRoute["selectedChannelId"];
|
||||
selectedView: ShellRoute["selectedView"];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const saveActiveDestination = React.useCallback(() => {
|
||||
const activeCommunityId = communities.activeCommunity?.id;
|
||||
if (!activeCommunityId) return;
|
||||
saveCommunityDestination(
|
||||
activeCommunityId,
|
||||
selectedView === "channel" && selectedChannelId
|
||||
? { kind: "channel", channelId: selectedChannelId }
|
||||
: { kind: "home" },
|
||||
);
|
||||
}, [communities.activeCommunity?.id, selectedChannelId, selectedView]);
|
||||
|
||||
// Home is a teardown barrier: the outgoing channel must unmount before the
|
||||
// relay changes, or its read effect can advance markers on the wrong relay.
|
||||
const switchCommunity = React.useCallback(
|
||||
async (id: string) => {
|
||||
const activeCommunityId = communities.activeCommunity?.id;
|
||||
if (id === activeCommunityId) return;
|
||||
if (!activeCommunityId) {
|
||||
communities.switchCommunity(id);
|
||||
return;
|
||||
}
|
||||
|
||||
await runCommunityViewTransition(async () => {
|
||||
saveActiveDestination();
|
||||
await goHome({ replace: true });
|
||||
markPendingCommunityRestore(id);
|
||||
const destination = loadCommunityDestination(id);
|
||||
if (destination?.kind === "channel") {
|
||||
replaceCommunityDestinationRoute(
|
||||
destination.channelId,
|
||||
router.history,
|
||||
);
|
||||
}
|
||||
communities.switchCommunity(id);
|
||||
});
|
||||
},
|
||||
[communities, goHome, router.history, saveActiveDestination],
|
||||
);
|
||||
|
||||
const removeCommunity = React.useCallback(
|
||||
async (id: string) => {
|
||||
if (id !== communities.activeCommunity?.id) {
|
||||
communities.removeCommunity(id);
|
||||
return;
|
||||
}
|
||||
const fallback = communities.communities.find(
|
||||
(community) => community.id !== id,
|
||||
);
|
||||
if (!fallback) return;
|
||||
|
||||
await runCommunityViewTransition(async () => {
|
||||
saveActiveDestination();
|
||||
await goHome({ replace: true });
|
||||
markPendingCommunityRestore(fallback.id);
|
||||
const destination = loadCommunityDestination(fallback.id);
|
||||
if (destination?.kind === "channel") {
|
||||
replaceCommunityDestinationRoute(
|
||||
destination.channelId,
|
||||
router.history,
|
||||
);
|
||||
}
|
||||
communities.removeCommunity(id);
|
||||
});
|
||||
},
|
||||
[communities, goHome, router.history, saveActiveDestination],
|
||||
);
|
||||
|
||||
return { removeCommunity, switchCommunity };
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
clearCommunityDestinations,
|
||||
loadCommunityDestination,
|
||||
removeCommunityDestination,
|
||||
saveCommunityDestination,
|
||||
} from "./communityNavigationStorage.ts";
|
||||
|
||||
function createMemoryStorage(initial = {}) {
|
||||
const values = new Map(Object.entries(initial));
|
||||
return {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
removeItem: (key) => values.delete(key),
|
||||
clear: () => values.clear(),
|
||||
key: (index) => Array.from(values.keys())[index] ?? null,
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("saves independent Home and channel destinations by community", () => {
|
||||
const storage = createMemoryStorage();
|
||||
|
||||
saveCommunityDestination(
|
||||
"alpha",
|
||||
{ kind: "channel", channelId: "general" },
|
||||
storage,
|
||||
);
|
||||
saveCommunityDestination("bravo", { kind: "home" }, storage);
|
||||
|
||||
assert.deepEqual(loadCommunityDestination("alpha", storage), {
|
||||
kind: "channel",
|
||||
channelId: "general",
|
||||
});
|
||||
assert.deepEqual(loadCommunityDestination("bravo", storage), {
|
||||
kind: "home",
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores malformed stored destinations", () => {
|
||||
const storage = createMemoryStorage({
|
||||
"buzz-community-destinations": JSON.stringify({
|
||||
valid: { kind: "channel", channelId: "general" },
|
||||
emptyChannel: { kind: "channel", channelId: "" },
|
||||
unknown: { kind: "settings" },
|
||||
primitive: "home",
|
||||
}),
|
||||
});
|
||||
|
||||
assert.deepEqual(loadCommunityDestination("valid", storage), {
|
||||
kind: "channel",
|
||||
channelId: "general",
|
||||
});
|
||||
assert.equal(loadCommunityDestination("emptyChannel", storage), null);
|
||||
assert.equal(loadCommunityDestination("unknown", storage), null);
|
||||
assert.equal(loadCommunityDestination("primitive", storage), null);
|
||||
});
|
||||
|
||||
test("recovers from invalid JSON", () => {
|
||||
const storage = createMemoryStorage({
|
||||
"buzz-community-destinations": "not-json",
|
||||
});
|
||||
|
||||
assert.equal(loadCommunityDestination("alpha", storage), null);
|
||||
saveCommunityDestination("alpha", { kind: "home" }, storage);
|
||||
assert.deepEqual(loadCommunityDestination("alpha", storage), {
|
||||
kind: "home",
|
||||
});
|
||||
});
|
||||
|
||||
test("removes one destination without disturbing another", () => {
|
||||
const storage = createMemoryStorage();
|
||||
saveCommunityDestination("alpha", { kind: "home" }, storage);
|
||||
saveCommunityDestination(
|
||||
"bravo",
|
||||
{ kind: "channel", channelId: "random" },
|
||||
storage,
|
||||
);
|
||||
|
||||
removeCommunityDestination("alpha", storage);
|
||||
|
||||
assert.equal(loadCommunityDestination("alpha", storage), null);
|
||||
assert.deepEqual(loadCommunityDestination("bravo", storage), {
|
||||
kind: "channel",
|
||||
channelId: "random",
|
||||
});
|
||||
});
|
||||
|
||||
test("clears all destinations", () => {
|
||||
const storage = createMemoryStorage();
|
||||
saveCommunityDestination("alpha", { kind: "home" }, storage);
|
||||
|
||||
clearCommunityDestinations(storage);
|
||||
|
||||
assert.equal(storage.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
|
||||
|
||||
const COMMUNITY_DESTINATIONS_KEY = "buzz-community-destinations";
|
||||
let pendingCommunityRestoreId: string | null = null;
|
||||
|
||||
export type CommunityDestination =
|
||||
| { kind: "home" }
|
||||
| { kind: "channel"; channelId: string };
|
||||
|
||||
type CommunityDestinations = Record<string, CommunityDestination>;
|
||||
|
||||
function isCommunityDestination(value: unknown): value is CommunityDestination {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return (
|
||||
candidate.kind === "home" ||
|
||||
(candidate.kind === "channel" &&
|
||||
typeof candidate.channelId === "string" &&
|
||||
candidate.channelId.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function loadCommunityDestinations(storage: Storage): CommunityDestinations {
|
||||
try {
|
||||
const raw = storage.getItem(COMMUNITY_DESTINATIONS_KEY);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter(
|
||||
(entry): entry is [string, CommunityDestination] =>
|
||||
isCommunityDestination(entry[1]),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveCommunityDestinations(
|
||||
destinations: CommunityDestinations,
|
||||
storage: Storage,
|
||||
): void {
|
||||
const serialized = JSON.stringify(destinations);
|
||||
if (typeof window !== "undefined" && storage === window.localStorage) {
|
||||
setLocalStorageItemWithRecovery(COMMUNITY_DESTINATIONS_KEY, serialized);
|
||||
return;
|
||||
}
|
||||
storage.setItem(COMMUNITY_DESTINATIONS_KEY, serialized);
|
||||
}
|
||||
|
||||
export function loadCommunityDestination(
|
||||
communityId: string,
|
||||
storage: Storage = localStorage,
|
||||
): CommunityDestination | null {
|
||||
return loadCommunityDestinations(storage)[communityId] ?? null;
|
||||
}
|
||||
|
||||
export function saveCommunityDestination(
|
||||
communityId: string,
|
||||
destination: CommunityDestination,
|
||||
storage: Storage = localStorage,
|
||||
): void {
|
||||
saveCommunityDestinations(
|
||||
{ ...loadCommunityDestinations(storage), [communityId]: destination },
|
||||
storage,
|
||||
);
|
||||
}
|
||||
|
||||
export function removeCommunityDestination(
|
||||
communityId: string,
|
||||
storage: Storage = localStorage,
|
||||
): void {
|
||||
if (pendingCommunityRestoreId === communityId) {
|
||||
pendingCommunityRestoreId = null;
|
||||
}
|
||||
const destinations = loadCommunityDestinations(storage);
|
||||
if (!(communityId in destinations)) {
|
||||
return;
|
||||
}
|
||||
delete destinations[communityId];
|
||||
saveCommunityDestinations(destinations, storage);
|
||||
}
|
||||
|
||||
export function clearCommunityDestinations(
|
||||
storage: Storage = localStorage,
|
||||
): void {
|
||||
storage.removeItem(COMMUNITY_DESTINATIONS_KEY);
|
||||
pendingCommunityRestoreId = null;
|
||||
}
|
||||
|
||||
export function markPendingCommunityRestore(communityId: string): void {
|
||||
pendingCommunityRestoreId = communityId;
|
||||
}
|
||||
|
||||
export function consumePendingCommunityRestore(communityId: string): boolean {
|
||||
if (pendingCommunityRestoreId !== communityId) {
|
||||
return false;
|
||||
}
|
||||
pendingCommunityRestoreId = null;
|
||||
return true;
|
||||
}
|
||||
@@ -20,6 +20,10 @@ import { removeSelfProfileCachesForRelay } from "@/features/profile/lib/selfProf
|
||||
import { removeChannelSnapshotForRelay } from "@/features/channels/channelSnapshot";
|
||||
import { removeMessageSnapshotsForRelay } from "@/features/messages/lib/messageSnapshot";
|
||||
import { clearSavedCommunitySnapshot } from "@/features/agents/activeAgentTurnsStore";
|
||||
import {
|
||||
clearCommunityDestinations,
|
||||
removeCommunityDestination,
|
||||
} from "./communityNavigationStorage";
|
||||
|
||||
export type UpdateCommunityResult =
|
||||
| { kind: "updated"; requiresReinit: boolean }
|
||||
@@ -194,6 +198,7 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
|
||||
|
||||
const clearCommunities = useCallback(() => {
|
||||
clearCommunityStorage();
|
||||
clearCommunityDestinations();
|
||||
setCommunitiesState([]);
|
||||
setActiveId(null);
|
||||
}, []);
|
||||
@@ -211,6 +216,7 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
|
||||
removeChannelSnapshotForRelay(removed.relayUrl);
|
||||
removeMessageSnapshotsForRelay(removed.relayUrl);
|
||||
clearSavedCommunitySnapshot(id);
|
||||
removeCommunityDestination(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,3 +110,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Community switches retain the outgoing app snapshot until the target relay
|
||||
is ready, then swap atomically. Animating the snapshots creates a flash. */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -203,6 +203,8 @@ type E2eConfig = {
|
||||
channelMembersReadDelayMs?: number;
|
||||
createManagedAgentDelayMs?: number;
|
||||
channelsReadError?: string;
|
||||
/** Reject successive mock `get_channels` calls, then resume. */
|
||||
channelsReadErrors?: (string | null)[];
|
||||
/** Reject successive mock `create_channel` calls, then resume. */
|
||||
createChannelErrors?: string[];
|
||||
/** Reject successive mock `ensure_starter_channels` calls, then resume. */
|
||||
@@ -5134,7 +5136,9 @@ async function handleGetChannels(config: E2eConfig | undefined) {
|
||||
);
|
||||
}
|
||||
|
||||
const channelsReadError = config?.mock?.channelsReadError;
|
||||
const channelsReadError =
|
||||
config?.mock?.channelsReadErrors?.shift() ??
|
||||
config?.mock?.channelsReadError;
|
||||
if (channelsReadError) {
|
||||
throw new Error(channelsReadError);
|
||||
}
|
||||
|
||||
@@ -116,6 +116,278 @@ test.describe("community rail", () => {
|
||||
.toBe(COMMUNITY_B.id);
|
||||
});
|
||||
|
||||
test("restores the last Home or channel destination per community", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
||||
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page).toHaveURL(/#\/channels\//);
|
||||
const generalUrl = page.url();
|
||||
|
||||
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
||||
await expect(page).toHaveURL(/#\/$/);
|
||||
|
||||
await page.getByTestId("channel-random").click();
|
||||
await expect(page).toHaveURL(/#\/channels\//);
|
||||
const randomUrl = page.url();
|
||||
|
||||
await page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`).click();
|
||||
await expect(page).toHaveURL(generalUrl);
|
||||
|
||||
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
||||
await expect(page).toHaveURL(randomUrl);
|
||||
|
||||
await page.getByRole("button", { name: "Inbox" }).click();
|
||||
await expect(page).toHaveURL(/#\/$/);
|
||||
await page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`).click();
|
||||
await expect(page).toHaveURL(generalUrl);
|
||||
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
||||
await expect(page).toHaveURL(/#\/$/);
|
||||
});
|
||||
|
||||
test("enters a remembered channel before live validation completes", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
||||
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
||||
const rememberedChannelId = await page.evaluate((communityId) => {
|
||||
const source = window.localStorage.getItem(
|
||||
"buzz-channels.v1:ws://localhost:3000",
|
||||
);
|
||||
if (!source) throw new Error("missing source channel snapshot");
|
||||
const snapshot = JSON.parse(source) as {
|
||||
channels: Array<{ id: string; name: string }>;
|
||||
};
|
||||
const generalChannel = snapshot.channels.find(
|
||||
(channel) => channel.name === "general",
|
||||
);
|
||||
if (!generalChannel) throw new Error("missing general channel snapshot");
|
||||
window.localStorage.setItem(
|
||||
"buzz-channels.v1:ws://localhost:3001",
|
||||
source,
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
"buzz-community-destinations",
|
||||
JSON.stringify({
|
||||
[communityId]: {
|
||||
kind: "channel",
|
||||
channelId: generalChannel.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return generalChannel.id;
|
||||
}, COMMUNITY_B.id);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const testWindow = window as typeof window & {
|
||||
__BUZZ_E2E__?: { mock?: { channelsReadDelayMs?: number } };
|
||||
};
|
||||
if (!testWindow.__BUZZ_E2E__) {
|
||||
throw new Error("missing E2E config");
|
||||
}
|
||||
testWindow.__BUZZ_E2E__.mock = {
|
||||
...testWindow.__BUZZ_E2E__.mock,
|
||||
channelsReadDelayMs: 800,
|
||||
};
|
||||
});
|
||||
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
||||
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`#/channels/${rememberedChannelId}$`),
|
||||
{ timeout: 700 },
|
||||
);
|
||||
await expect(page.getByTestId("message-timeline")).toBeVisible({
|
||||
timeout: 700,
|
||||
});
|
||||
});
|
||||
|
||||
test("clears a remembered channel that is unavailable after switching", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
||||
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
||||
await page.addInitScript((communityId) => {
|
||||
window.localStorage.setItem(
|
||||
"buzz-community-destinations",
|
||||
JSON.stringify({
|
||||
[communityId]: { kind: "channel", channelId: "missing-channel" },
|
||||
}),
|
||||
);
|
||||
}, COMMUNITY_B.id);
|
||||
|
||||
await page.goto("/");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.localStorage.getItem("buzz-channels.v1:ws://localhost:3000"),
|
||||
),
|
||||
)
|
||||
.not.toBeNull();
|
||||
await page.evaluate(() => {
|
||||
const source = window.localStorage.getItem(
|
||||
"buzz-channels.v1:ws://localhost:3000",
|
||||
);
|
||||
if (!source) throw new Error("missing source channel snapshot");
|
||||
const snapshot = JSON.parse(source);
|
||||
snapshot.channels = snapshot.channels.map(
|
||||
(channel: Record<string, unknown>, index: number) =>
|
||||
index === 0 ? { ...channel, id: "missing-channel" } : channel,
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
"buzz-channels.v1:ws://localhost:3001",
|
||||
JSON.stringify(snapshot),
|
||||
);
|
||||
});
|
||||
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
||||
|
||||
await expect(page).not.toHaveURL(/#\/channels\//);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((communityId) => {
|
||||
const raw = window.localStorage.getItem(
|
||||
"buzz-community-destinations",
|
||||
);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw)[communityId];
|
||||
}, COMMUNITY_B.id),
|
||||
)
|
||||
.toEqual({ kind: "home" });
|
||||
});
|
||||
|
||||
test("does not repair a remembered channel until live validation succeeds", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(
|
||||
page,
|
||||
{
|
||||
channelsReadDelayMs: 300,
|
||||
channelsReadErrors: [null, "temporary channel read failure"],
|
||||
},
|
||||
{ skipCommunitySeed: true },
|
||||
);
|
||||
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
||||
await page.addInitScript((communityId) => {
|
||||
window.localStorage.setItem(
|
||||
"buzz-community-destinations",
|
||||
JSON.stringify({
|
||||
[communityId]: { kind: "channel", channelId: "general" },
|
||||
}),
|
||||
);
|
||||
}, COMMUNITY_B.id);
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.localStorage.getItem("buzz-channels.v1:ws://localhost:3000"),
|
||||
),
|
||||
)
|
||||
.not.toBeNull();
|
||||
await page.evaluate(() => {
|
||||
const source = window.localStorage.getItem(
|
||||
"buzz-channels.v1:ws://localhost:3000",
|
||||
);
|
||||
if (!source) throw new Error("missing source channel snapshot");
|
||||
const snapshot = JSON.parse(source);
|
||||
snapshot.channels = snapshot.channels.filter(
|
||||
(channel: { id: string }) => channel.id !== "general",
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
"buzz-channels.v1:ws://localhost:3001",
|
||||
JSON.stringify(snapshot),
|
||||
);
|
||||
});
|
||||
|
||||
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
||||
await expect(page).toHaveURL(/#\/channels\/general$/);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((communityId) => {
|
||||
const raw = window.localStorage.getItem(
|
||||
"buzz-community-destinations",
|
||||
);
|
||||
return raw ? JSON.parse(raw)[communityId] : null;
|
||||
}, COMMUNITY_B.id),
|
||||
)
|
||||
.toEqual({ kind: "channel", channelId: "general" });
|
||||
await page.waitForTimeout(400);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((communityId) => {
|
||||
const raw = window.localStorage.getItem(
|
||||
"buzz-community-destinations",
|
||||
);
|
||||
return raw ? JSON.parse(raw)[communityId] : null;
|
||||
}, COMMUNITY_B.id),
|
||||
)
|
||||
.toEqual({ kind: "channel", channelId: "general" });
|
||||
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((communityId) => {
|
||||
const raw = window.localStorage.getItem(
|
||||
"buzz-community-destinations",
|
||||
);
|
||||
return raw ? JSON.parse(raw)[communityId] : null;
|
||||
}, COMMUNITY_B.id),
|
||||
)
|
||||
.toEqual({ kind: "channel", channelId: "general" });
|
||||
});
|
||||
|
||||
test("does not restore a remembered destination on cold boot", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
||||
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
||||
await page.addInitScript((communityId) => {
|
||||
window.localStorage.setItem(
|
||||
"buzz-community-destinations",
|
||||
JSON.stringify({
|
||||
[communityId]: { kind: "channel", channelId: "general" },
|
||||
}),
|
||||
);
|
||||
}, COMMUNITY_A.id);
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page).not.toHaveURL(/#\/channels\//);
|
||||
});
|
||||
|
||||
test("removing the active community restores the fallback destination", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
||||
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
||||
await page.getByTestId("channel-random").click();
|
||||
const randomUrl = page.url();
|
||||
await page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`).click();
|
||||
await page.getByTestId("channel-general").click();
|
||||
|
||||
await page
|
||||
.getByTestId(`community-rail-button-${COMMUNITY_A.id}`)
|
||||
.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Community settings" }).click();
|
||||
await page.getByRole("button", { name: "Remove Community" }).click();
|
||||
|
||||
await expect(page).toHaveURL(randomUrl);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.localStorage.getItem("buzz-active-community-id"),
|
||||
),
|
||||
)
|
||||
.toBe(COMMUNITY_B.id);
|
||||
});
|
||||
|
||||
test("shows the quiet switch gate, not the boot splash, while switching", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -219,6 +219,8 @@ type MockBridgeOptions = {
|
||||
addChannelMembersErrors?: (string | null)[];
|
||||
channelMembersReadDelayMs?: number;
|
||||
channelsReadError?: string;
|
||||
/** Reject successive mock `get_channels` calls, then resume. */
|
||||
channelsReadErrors?: (string | null)[];
|
||||
/** Reject successive mock `create_channel` calls, then resume. */
|
||||
createChannelErrors?: string[];
|
||||
/** Reject successive mock `ensure_starter_channels` calls, then resume. */
|
||||
|
||||
Reference in New Issue
Block a user