diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 0469497d8d1..5046b296885 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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", diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index ed9c465bcc3..9561b2138cd 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -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 ? ( diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 1d6223e7139..d3f2708c3c8 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -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} diff --git a/desktop/src/app/communityViewTransition.test.mjs b/desktop/src/app/communityViewTransition.test.mjs new file mode 100644 index 00000000000..4e2c6db6d57 --- /dev/null +++ b/desktop/src/app/communityViewTransition.test.mjs @@ -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); +}); diff --git a/desktop/src/app/communityViewTransition.ts b/desktop/src/app/communityViewTransition.ts new file mode 100644 index 00000000000..1bbc395f9d0 --- /dev/null +++ b/desktop/src/app/communityViewTransition.ts @@ -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, + options: { timeoutMs?: number } = {}, +): Promise { + if (!document.startViewTransition) { + try { + await update(); + } catch (error) { + console.error("Community transition failed:", error); + } + return; + } + + let finish: (() => void) | undefined; + const targetReady = new Promise((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; + } + } +} diff --git a/desktop/src/app/useCommunityNavigationTransitions.ts b/desktop/src/app/useCommunityNavigationTransitions.ts new file mode 100644 index 00000000000..88acb30ad01 --- /dev/null +++ b/desktop/src/app/useCommunityNavigationTransitions.ts @@ -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; +type ShellRoute = ReturnType; +type GoHome = ReturnType["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 }; +} diff --git a/desktop/src/features/communities/communityNavigationStorage.test.mjs b/desktop/src/features/communities/communityNavigationStorage.test.mjs new file mode 100644 index 00000000000..465d70daaa3 --- /dev/null +++ b/desktop/src/features/communities/communityNavigationStorage.test.mjs @@ -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); +}); diff --git a/desktop/src/features/communities/communityNavigationStorage.ts b/desktop/src/features/communities/communityNavigationStorage.ts new file mode 100644 index 00000000000..e5a5c77d7e0 --- /dev/null +++ b/desktop/src/features/communities/communityNavigationStorage.ts @@ -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; + +function isCommunityDestination(value: unknown): value is CommunityDestination { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Record; + 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; +} diff --git a/desktop/src/features/communities/useCommunities.tsx b/desktop/src/features/communities/useCommunities.tsx index 5744d42ba71..df019519b69 100644 --- a/desktop/src/features/communities/useCommunities.tsx +++ b/desktop/src/features/communities/useCommunities.tsx @@ -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); } } diff --git a/desktop/src/shared/styles/globals/motion.css b/desktop/src/shared/styles/globals/motion.css index 302ae3fe35d..25288fb1a6f 100644 --- a/desktop/src/shared/styles/globals/motion.css +++ b/desktop/src/shared/styles/globals/motion.css @@ -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; +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index cf441829c57..721082aedb8 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -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); } diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 65fb3cd488c..2af7bb8783a 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -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, 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, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index d43e83db36f..66b5c897054 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -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. */