Skip to content
Open
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 @@ -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",
Expand Down
62 changes: 62 additions & 0 deletions desktop/src/features/onboarding/featuredCommunities.ts
Original file line number Diff line number Diff line change
@@ -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,
},
];
123 changes: 123 additions & 0 deletions desktop/src/features/onboarding/ui/DiscoveryLanding.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="flex w-full max-w-[860px] flex-col items-center text-center"
data-testid="discovery-landing"
>
<img
alt="Buzz"
className="w-full max-w-[420px]"
src="/landing/buzz-wordmark.png"
/>
<h1 className="mt-4 text-2xl font-normal leading-tight text-foreground">
Find your people
</h1>
<p className="mt-2 max-w-[520px] text-sm leading-6 text-foreground/80">
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.
</p>
{error ? <p className="mt-4 text-sm text-destructive">{error}</p> : null}
<div className="mt-10 grid w-full grid-cols-1 gap-x-10 gap-y-12 sm:grid-cols-2">
{FEATURED_COMMUNITIES.map((community) => (
<Card
className="items-stretch px-7 py-5 text-left [--buzz-card-textured-min-height:132px]"
key={community.id}
variant="textured"
>
<div className="flex items-start gap-4">
<span
aria-hidden
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-foreground/8 text-2xl"
>
{community.emoji}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
<span className="truncate text-base font-medium text-foreground">
{community.name}
</span>
<span className="shrink-0 text-xs text-foreground/60">
{community.members.toLocaleString()} members
</span>
</div>
<p className="mt-1 line-clamp-2 text-sm leading-5 text-foreground/75">
{community.tagline}
</p>
</div>
</div>
<div className="mt-4 flex justify-end">
<Button
className="h-8 rounded-full px-5"
data-testid={`discovery-join-${community.id}`}
disabled={isPending}
onClick={() => onJoin(community)}
size="sm"
type="button"
>
Join
</Button>
</div>
</Card>
))}
</div>
<div className="mt-12 flex flex-col items-center gap-3 pb-10">
<p className="text-sm text-foreground/70">
Have an invite link, or want to run your own?
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<Button
className={ONBOARDING_SECONDARY_CTA_CLASS}
data-testid="discovery-advanced-setup"
disabled={isPending}
onClick={onAdvancedSetup}
type="button"
variant="ghost"
>
Set up identity &amp; agents first
</Button>
<Button
className={ONBOARDING_SECONDARY_CTA_CLASS}
data-testid="discovery-import-key"
disabled={isPending}
onClick={onImportKey}
type="button"
variant="ghost"
>
I already have a key
</Button>
</div>
</div>
</div>
);
}
132 changes: 102 additions & 30 deletions desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -46,6 +49,7 @@ import { SetupStep } from "./SetupStep";
import type { DefaultConfigDraft } from "./types";

export type MachineOnboardingPage =
| "discover"
| "identity"
| "key-import"
| "backup"
Expand Down Expand Up @@ -84,7 +88,7 @@ export function MachineOnboardingFlow({
navigateAfterComplete?: (nav: PostOnboardingNavigation) => void;
}) {
const [page, setPage] = React.useState<MachineOnboardingPage>(
identityLost ? "key-import" : (initialPage ?? "identity"),
identityLost ? "key-import" : (initialPage ?? "discover"),
);
const [transitionDirection, setTransitionDirection] =
React.useState<OnboardingTransitionDirection>("forward");
Expand Down Expand Up @@ -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[]) => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -253,59 +291,93 @@ 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 (
<div
className={`buzz-onboarding-neutral-theme buzz-startup-shell flex max-h-dvh items-start justify-center overflow-x-hidden overflow-y-auto px-4 text-foreground ${
isSecuritySubview ? "buzz-onboarding-security-theme" : ""
} ${
page === "identity"
page === "discover" || page === "identity"
? "buzz-onboarding-welcome py-8"
: "pb-28 pt-[106px]"
}`}
data-testid="machine-onboarding-gate"
>
<StartupWindowDragRegion />
{page === "identity" ? <LandingBees /> : null}
{page !== "identity" && !isSecuritySubview ? (
{page !== "discover" && page !== "identity" && !isSecuritySubview ? (
<OnboardingChrome
current={page === "config" ? 4 : page === "setup" ? 3 : 2}
/>
) : null}
<OnboardingFooterProvider backAction={chromeBackAction}>
<div
className={`relative flex w-full max-w-[1040px] flex-col items-center text-center ${
page === "identity" ? "my-auto" : "buzz-onboarding-step-frame"
page === "discover" || page === "identity"
? "my-auto"
: "buzz-onboarding-step-frame"
}`}
>
{page === "identity" ? (
{page === "discover" ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction={transitionDirection}
transitionKey={`machine-discover-${transitionDirection}`}
>
<DiscoveryLanding
error={error}
isPending={isPending}
onAdvancedSetup={() => {
setError(null);
setTransitionDirection("forward");
setPage("identity");
}}
onImportKey={() => {
setError(null);
setKeyImportDialog(null);
setKeyImportStage("key-entry");
setTransitionDirection("forward");
setPage("key-import");
}}
onJoin={(community) => void quickJoinCommunity(community)}
/>
</OnboardingSlideTransition>
) : page === "identity" ? (
<OnboardingSlideTransition
className="flex w-full max-w-[720px] flex-col items-center text-center"
direction={transitionDirection}
Expand Down
Loading
Loading