Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
60 changes: 51 additions & 9 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -351,7 +388,7 @@ function CommunityApp({
addedCommunity: !relayAlreadyExists,
error: undefined,
});
switchCommunity(id);
await transitionCommunity(id);
reconnectCommunity();
}, [
activeCommunity?.id,
Expand All @@ -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;
}
Expand All @@ -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();
Expand Down Expand Up @@ -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}>
Expand Down
89 changes: 67 additions & 22 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand Down
109 changes: 109 additions & 0 deletions desktop/src/app/communityViewTransition.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading