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:
Kalvin C
2026-07-23 22:52:25 +00:00
committed by GitHub
co-authored by npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7
parent 5afa16157a
commit 95478919fc
13 changed files with 890 additions and 32 deletions
+1
View File
@@ -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
View File
@@ -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}>
+67 -22
View File
@@ -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;
}
+5 -1
View File
@@ -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);
}
+272
View File
@@ -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,
}) => {
+2
View File
@@ -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. */