diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7d06c4da9..5e9dbc9f9 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ "**/sidebar-offcanvas-rail.spec.ts", "**/search-scope-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", + "**/discovery-landing-shot.spec.ts", "**/identity-key-help.spec.ts", "**/key-import-reveal.spec.ts", "**/navigation.spec.ts", diff --git a/desktop/src/features/onboarding/featuredCommunities.ts b/desktop/src/features/onboarding/featuredCommunities.ts new file mode 100644 index 000000000..bb23c3f83 --- /dev/null +++ b/desktop/src/features/onboarding/featuredCommunities.ts @@ -0,0 +1,62 @@ +/** + * EXPLORATION β€” Discord-style first-open discovery. + * + * A compiled directory of communities a brand-new user can join right away, + * shown on the first-open landing before any identity/agent setup. In a real + * implementation this list would come from a directory service (or a + * curated kind:30xxx event on a bootstrap relay); for the exploration it is + * a compiled constant, exactly like the compiled default relays that + * `initFirstCommunity` already admits token-less. + */ +export type FeaturedCommunity = { + /** Stable id for test hooks and keys. */ + id: string; + name: string; + tagline: string; + relayUrl: string; + /** Approximate member count shown as social proof. */ + members: number; + /** Accent emoji standing in for a community icon. */ + emoji: string; + /** True while a relay is open to token-less first connections. */ + openJoin: boolean; +}; + +export const FEATURED_COMMUNITIES: FeaturedCommunity[] = [ + { + id: "buzz-hq", + name: "Buzz HQ", + tagline: "The team building Buzz β€” ask anything, meet the agents.", + relayUrl: "wss://buzz.block.builderlab.xyz", + members: 128, + emoji: "🐝", + openJoin: true, + }, + { + id: "agent-builders", + name: "Agent Builders", + tagline: "Share agents, adopt the best ones, learn by watching.", + relayUrl: "wss://agents.buzz.builderlab.xyz", + members: 342, + emoji: "πŸ€–", + openJoin: true, + }, + { + id: "nostr-devs", + name: "Nostr Devs", + tagline: "Protocol talk, NIPs, relays, and open social infrastructure.", + relayUrl: "wss://nostr.buzz.builderlab.xyz", + members: 214, + emoji: "🟣", + openJoin: true, + }, + { + id: "digital-nomads", + name: "Digital Nomads", + tagline: "500 travelers coordinating meetups, visas, and city guides.", + relayUrl: "wss://nomads.buzz.builderlab.xyz", + members: 507, + emoji: "🌍", + openJoin: true, + }, +]; diff --git a/desktop/src/features/onboarding/ui/DiscoveryLanding.tsx b/desktop/src/features/onboarding/ui/DiscoveryLanding.tsx new file mode 100644 index 000000000..39cbaadc3 --- /dev/null +++ b/desktop/src/features/onboarding/ui/DiscoveryLanding.tsx @@ -0,0 +1,123 @@ +import { + FEATURED_COMMUNITIES, + type FeaturedCommunity, +} from "@/features/onboarding/featuredCommunities"; +import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; + +import { ONBOARDING_SECONDARY_CTA_CLASS } from "./OnboardingChrome"; + +/** + * EXPLORATION β€” Discord-style first-open landing. + * + * The very first screen a fresh install shows: a directory of communities + * the user can join right away. No identity ceremony, no agent corridor β€” + * clicking Join creates the key silently and connects. Agent setup and key + * backup become one avenue off this screen (`onAdvancedSetup`) instead of + * the mandatory path. + */ +export function DiscoveryLanding({ + error, + isPending, + onAdvancedSetup, + onImportKey, + onJoin, +}: { + error: string | null; + isPending: boolean; + /** Classic corridor: identity β†’ backup β†’ harness β†’ config. */ + onAdvancedSetup: () => void; + onImportKey: () => void; + onJoin: (community: FeaturedCommunity) => void; +}) { + return ( +
+ Buzz +

+ Find your people +

+

+ Jump into a community β€” we’ll set up your identity as you go. Your + agents, backups, and settings are one click away once you’re in. +

+ {error ?

{error}

: null} +
+ {FEATURED_COMMUNITIES.map((community) => ( + +
+ + {community.emoji} + +
+
+ + {community.name} + + + {community.members.toLocaleString()} members + +
+

+ {community.tagline} +

+
+
+
+ +
+
+ ))} +
+
+

+ Have an invite link, or want to run your own? +

+
+ + +
+
+
+ ); +} diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index 27d6b8ce4..160311179 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -16,8 +16,11 @@ import { DialogTitle, } from "@/shared/ui/dialog"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; +import type { FeaturedCommunity } from "@/features/onboarding/featuredCommunities"; +import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; import { BackupStep } from "./BackupStep"; import { DefaultConfigStep } from "./DefaultConfigStep"; +import { DiscoveryLanding } from "./DiscoveryLanding"; import { DownloadKeyStep } from "./DownloadKeyStep"; import { backupSessionToPasswordEntry, @@ -46,6 +49,7 @@ import { SetupStep } from "./SetupStep"; import type { DefaultConfigDraft } from "./types"; export type MachineOnboardingPage = + | "discover" | "identity" | "key-import" | "backup" @@ -84,7 +88,7 @@ export function MachineOnboardingFlow({ navigateAfterComplete?: (nav: PostOnboardingNavigation) => void; }) { const [page, setPage] = React.useState( - identityLost ? "key-import" : (initialPage ?? "identity"), + identityLost ? "key-import" : (initialPage ?? "discover"), ); const [transitionDirection, setTransitionDirection] = React.useState("forward"); @@ -121,6 +125,7 @@ export function MachineOnboardingFlow({ // security subview keeps the created backup, password, and test progress. const backupSession = useEncryptedBackupSession(); const reduceMotion = useReducedMotion() ?? false; + const communityOnboarding = useCommunityOnboarding(); const isSecuritySubview = page === "backup" && backupSubview !== "created"; const handleReadyRuntimeIdsChange = React.useCallback( (runtimeIds: readonly string[]) => { @@ -151,6 +156,39 @@ export function MachineOnboardingFlow({ } }, [queryClient]); + /** + * EXPLORATION β€” one-click join from the discovery landing. + * + * Persists a fresh identity silently (no backup ceremony β€” that becomes a + * post-join nudge), completes machine onboarding, and starts a community + * onboarding transaction pointed at the chosen relay. The existing + * CommunityApp machinery then connects, checks/creates the profile, and + * lands the user in the community. + */ + const quickJoinCommunity = React.useCallback( + async (community: FeaturedCommunity) => { + setIsPending(true); + setError(null); + try { + const identity = await getIdentity(); + queryClient.setQueryData(["identity"], identity); + communityOnboarding.start({ + source: "first-community", + relayUrl: community.relayUrl, + communityName: community.name, + }); + complete(identity.pubkey); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "Failed to load identity", + ); + } finally { + setIsPending(false); + } + }, + [communityOnboarding, complete, queryClient], + ); + const loadRecoveredIdentity = React.useCallback(async () => { setIsPending(true); setError(null); @@ -253,40 +291,48 @@ export function MachineOnboardingFlow({ }, [backupSession, backupSubview, identityWasImported]); const chromeBackAction = - page === "key-import" && - (!identityLost || keyImportStage === "backup-password") - ? { disabled: isKeyImporting, onClick: backFromKeyImport } - : page === "backup" && backupSubview !== "created" - ? { - label: "Return to onboarding", - onClick: returnToCreatedKey, - testId: "backup-return-to-onboarding", - } - : page === "backup" + page === "identity" + ? { + onClick: () => { + setTransitionDirection("backward"); + setPage("discover"); + }, + testId: "identity-back-to-discover", + } + : page === "key-import" && + (!identityLost || keyImportStage === "backup-password") + ? { disabled: isKeyImporting, onClick: backFromKeyImport } + : page === "backup" && backupSubview !== "created" ? { - onClick: () => { - setTransitionDirection("backward"); - setPage("identity"); - }, + label: "Return to onboarding", + onClick: returnToCreatedKey, + testId: "backup-return-to-onboarding", } - : page === "setup" - ? { onClick: backFromSetup } - : page === "config" - ? { - disabled: isDefaultConfigSaving, - onClick: () => { - setTransitionDirection("backward"); - setPage("setup"); - }, - } - : undefined; + : page === "backup" + ? { + onClick: () => { + setTransitionDirection("backward"); + setPage("identity"); + }, + } + : page === "setup" + ? { onClick: backFromSetup } + : page === "config" + ? { + disabled: isDefaultConfigSaving, + onClick: () => { + setTransitionDirection("backward"); + setPage("setup"); + }, + } + : undefined; return (
{page === "identity" ? : null} - {page !== "identity" && !isSecuritySubview ? ( + {page !== "discover" && page !== "identity" && !isSecuritySubview ? ( @@ -302,10 +348,36 @@ export function MachineOnboardingFlow({
- {page === "identity" ? ( + {page === "discover" ? ( + + { + setError(null); + setTransitionDirection("forward"); + setPage("identity"); + }} + onImportKey={() => { + setError(null); + setKeyImportDialog(null); + setKeyImportStage("key-entry"); + setTransitionDirection("forward"); + setPage("key-import"); + }} + onJoin={(community) => void quickJoinCommunity(community)} + /> + + ) : page === "identity" ? ( +
{backAction ? ( + // Rendered after the footer slot so the Back button stacks above the + // slot's full-width CTA groups (which re-enable pointer events) and + // stays clickable when a step docks a wide group (e.g. the identity + // help trigger).
) : null} -
); } diff --git a/desktop/tests/e2e/discovery-landing-shot.spec.ts b/desktop/tests/e2e/discovery-landing-shot.spec.ts new file mode 100644 index 000000000..c28972bbd --- /dev/null +++ b/desktop/tests/e2e/discovery-landing-shot.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; + +const SHOT_DIR = "test-results/discovery-landing"; + +test.use({ viewport: { width: 1280, height: 960 } }); + +test("discord-style discovery landing on first open", async ({ page }) => { + await installMockBridge(page, undefined, { + skipCommunitySeed: true, + skipOnboardingSeed: true, + }); + await page.goto("/"); + + // Fresh install β†’ straight to the discovery landing. No identity ceremony. + await expect(page.getByTestId("discovery-landing")).toBeVisible(); + await expect(page.getByTestId("discovery-join-buzz-hq")).toBeVisible(); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOT_DIR}/01-discovery-landing.png` }); + + // Advanced setup remains one click away (the classic corridor). + await page.getByTestId("discovery-advanced-setup").click(); + await expect( + page.getByRole("button", { name: "Create a new identity key" }), + ).toBeVisible(); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOT_DIR}/02-advanced-setup-corridor.png` }); + + // Back returns to discovery. + await page.getByTestId("identity-back-to-discover").click(); + await expect(page.getByTestId("discovery-landing")).toBeVisible(); + + // One-click join: identity is created silently, the community is added, and + // (with the mock relay resolving instantly) the user lands straight in the + // app β€” no agent corridor, no backup ceremony. + await page.getByTestId("discovery-join-buzz-hq").click(); + await expect + .poll(() => + page.evaluate(() => window.localStorage.getItem("buzz-communities")), + ) + .toContain("Buzz HQ"); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOT_DIR}/03-after-join-click.png` }); +});