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
117 changes: 66 additions & 51 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup";
import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen";
import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay";
import { createBuzzQueryClient } from "@/shared/api/queryClient";
import { getMyRelayMembershipLookup } from "@/shared/api/relayMembers";
import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri";
import {
type AddCommunityDeepLinkPayload,
Expand All @@ -62,16 +61,6 @@ const BOOT_SPLASH_MIN_VISIBLE_MS = 1_200;
const BOOT_SPLASH_FADE_MS = 200;
const INITIAL_RENDER_READY_EVENT = "initial-render-ready";

function isRelayMembershipDeniedError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
return [
"You must be a relay member",
"relay_membership_required",
"restricted: not a relay member",
"invalid: you are not a relay member",
].some((message) => error.message.includes(message));
}

type BootSplashPhase = "holding" | "fading" | "done";

function useInitialRenderReady() {
Expand Down Expand Up @@ -275,13 +264,19 @@ function CommunityApp({
}) {
const {
activeCommunity,
communities,
reinitKey,
addCommunity,
clearCommunities,
removeCommunity,
switchCommunity,
reconnectCommunity,
} = useCommunities();
const communityOnboarding = useCommunityOnboarding();
const connectingTransactionRef = useRef<string | null>(null);
const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false);
const [resumeFirstCommunityJoin, setResumeFirstCommunityJoin] =
useState(false);

// Surface nest-related backend events (repos-dir errors, legacy migration)
// as toasts. Mounted before useCommunityInit so the listeners are registered
Expand Down Expand Up @@ -311,10 +306,16 @@ function CommunityApp({
const handleCommunityOnboardingConnect = useCallback(() => {
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);
return;
}
const previousCommunityId = activeCommunity?.id;
const relayAlreadyExists = communities.some(
(community) => community.relayUrl === transaction.relayUrl,
);
const id = addCommunity({
id: crypto.randomUUID(),
name: transaction.communityName,
Expand All @@ -323,58 +324,70 @@ function CommunityApp({
reposDir: transaction.reposDir,
addedAt: new Date().toISOString(),
});
communityOnboarding.update({ communityId: id, error: undefined });
communityOnboarding.update({
communityId: id,
previousCommunityId,
addedCommunity: !relayAlreadyExists,
error: undefined,
});
switchCommunity(id);
reconnectCommunity();
}, [addCommunity, communityOnboarding, reconnectCommunity, switchCommunity]);
}, [
activeCommunity?.id,
addCommunity,
communities,
communityOnboarding,
reconnectCommunity,
switchCommunity,
]);

const handleCommunityOnboardingCancel = useCallback(() => {
const transaction = communityOnboarding.transaction;
communityOnboarding.clear();

if (!transaction?.communityId) return;
if (!transaction.addedCommunity) {
if (transaction.previousCommunityId) {
switchCommunity(transaction.previousCommunityId);
}
return;
}
if (communities.length === 1) {
if (transaction.source === "first-community") {
setResumeFirstCommunityJoin(true);
}
clearCommunities();
return;
}
removeCommunity(transaction.communityId);
if (transaction.previousCommunityId) {
switchCommunity(transaction.previousCommunityId);
}
}, [
clearCommunities,
communities.length,
communityOnboarding,
removeCommunity,
switchCommunity,
]);

const bootSplashPhase = useBootSplashHold();

const transaction = communityOnboarding.transaction;
useEffect(() => {
if (transaction?.stage !== "connecting") {
connectingTransactionRef.current = null;
}
}, [transaction?.stage]);
const targetIsReady =
transaction?.communityId === activeCommunity?.id &&
community.isReady &&
community.appliedKey === communityKey;
useEffect(() => {
if (
transaction?.stage !== "connecting" ||
transaction.error ||
!targetIsReady
) {
return;
if (transaction?.stage === "connecting" && targetIsReady) {
communityOnboarding.update({ stage: "profile", error: undefined });
}

let cancelled = false;
void getMyRelayMembershipLookup()
.then(({ membership, snapshotFound }) => {
if (cancelled) return;
if (snapshotFound && membership === null) {
communityOnboarding.update({
error:
"You have not been added to this community yet. Ask the host to add your public key, then try again.",
});
return;
}
communityOnboarding.update({ stage: "profile", error: undefined });
})
.catch((error: unknown) => {
if (cancelled) return;
communityOnboarding.update({
error: isRelayMembershipDeniedError(error)
? "You have not been added to this community yet. Ask the host to add your public key, then try again."
: "Could not check community access. Check the URL and try again.",
});
});

return () => {
cancelled = true;
};
}, [
communityOnboarding.update,
targetIsReady,
transaction?.error,
transaction?.stage,
]);
}, [communityOnboarding.update, targetIsReady, transaction?.stage]);
// During "entering" the transaction stays alive as a curtain: the app mounts
// underneath (already pointed at the Welcome channel route) while the
// onboarding screen covers it, then fades once Welcome reports ready.
Expand All @@ -398,6 +411,7 @@ function CommunityApp({
appContent = (
<WelcomeSetup
defaultRelayUrl={community.defaultRelayUrl}
initialPage={resumeFirstCommunityJoin ? "join" : undefined}
onBack={onBackToMachineConfig}
/>
);
Expand Down Expand Up @@ -465,6 +479,7 @@ function CommunityApp({
}
>
<CommunityOnboardingFlow
onCancel={handleCommunityOnboardingCancel}
onConnect={handleCommunityOnboardingConnect}
/>
</div>
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/features/communities/ui/WelcomeSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection;

type WelcomeSetupProps = {
defaultRelayUrl: string;
initialPage?: WelcomeSetupPage;
initialTransitionMode?: WelcomeTransitionMode;
onBack: () => void;
};
Expand All @@ -50,10 +51,11 @@ function isLocalDevRelayUrl(relayUrl: string) {

export function WelcomeSetup({
defaultRelayUrl,
initialPage = "welcome",
initialTransitionMode = "initial",
onBack,
}: WelcomeSetupProps) {
const [page, setPage] = React.useState<WelcomeSetupPage>("welcome");
const [page, setPage] = React.useState<WelcomeSetupPage>(initialPage);
const [transitionMode, setTransitionMode] =
React.useState<WelcomeTransitionMode>(initialTransitionMode);
const [npub, setNpub] = React.useState("");
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/features/onboarding/communityOnboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export type CommunityOnboardingTransaction = {
*/
policyReceipt?: string;
communityId?: string;
previousCommunityId?: string;
addedCommunity?: boolean;
createdAt: string;
updatedAt: string;
error?: string;
Expand All @@ -56,6 +58,8 @@ export type CommunityOnboardingTransactionPatch = Partial<
| "stage"
| "relayUrl"
| "communityId"
| "previousCommunityId"
| "addedCommunity"
| "communityName"
| "error"
| "acknowledged"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,10 @@ function AvatarCircle({
}

export function CommunityOnboardingFlow({
onCancel,
onConnect,
}: {
onCancel: () => void;
onConnect: () => void;
}) {
const { transaction, update, clear } = useCommunityOnboarding();
Expand Down Expand Up @@ -372,7 +374,7 @@ export function CommunityOnboardingFlow({
) : null}
<Button
className="rounded-full bg-foreground/10 px-5 hover:bg-foreground/15"
onClick={clear}
onClick={onCancel}
variant="ghost"
>
Cancel
Expand Down
3 changes: 1 addition & 2 deletions desktop/src/features/onboarding/ui/OnboardingFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
persistCurrentIdentity,
} from "@/shared/api/tauriIdentity";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { forceFreshOnboarding } from "@/features/onboarding/devFreshOnboarding";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { AvatarStep } from "./AvatarStep";
Expand Down Expand Up @@ -513,7 +512,7 @@ export function OnboardingFlow({
}}
direction={transitionDirection}
state={profileStepState}
usesExistingIdentity={forceFreshOnboarding}
usesExistingIdentity
/>
) : currentPage === "key-import" ? (
<OnboardingSlideTransition
Expand Down
27 changes: 4 additions & 23 deletions desktop/tests/e2e/onboarding-avatar-skip.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { hexToBytes } from "@noble/hashes/utils.js";
import { expect, test } from "@playwright/test";
import { nsecEncode } from "nostr-tools/nip19";

import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
Expand Down Expand Up @@ -82,36 +80,19 @@ test("avatar Next button still requires an avatar to be chosen", async ({
// B4: Routing tests
// ---------------------------------------------------------------------------

test("import-key path skips backup and goes directly to avatar", async ({
page,
}) => {
// Import tyler's OWN key (same pubkey = no component remount) so the
// identityWasImported flag persists in the same component instance.
test("normal profile setup keeps the existing identity", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");

// Profile page — click "Use existing key" to open the key import form.
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await page.getByTestId("onboarding-import-key").click();
await expect(
page.getByRole("heading", { name: "Use your existing key" }),
).toBeVisible();

// Enter tyler's own nsec (same pubkey → no remount, identityWasImported stays true).
const tylerNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.tyler.privateKey));
await page.getByTestId("nostr-import-nsec-input").fill(tylerNsec);
await expect(page.getByTestId("nostr-import-npub-preview")).toBeVisible();
await page.getByTestId("nostr-import-submit").click();

// After import, the flow returns to profile with identityWasImported=true.
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await expect(page.getByTestId("onboarding-import-key")).toHaveCount(0);
await expect(page.getByText("Create an identity key")).toHaveCount(0);

await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();

// Backup page must NOT appear — avatar comes next on the import path.
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await expect(page.getByTestId("onboarding-page-backup")).not.toBeVisible();
});

test("Back from the community avatar step returns to profile", async ({
Expand Down
Loading
Loading