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
143 changes: 91 additions & 52 deletions desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,26 @@ function AvatarCircle({
);
}

function LoadingDots({ label }: { label: string }) {
return (
<span
aria-label={label}
className="inline-flex items-center justify-center gap-1"
data-testid="community-team-intro-loading-dots"
role="status"
>
{[0, 1, 2].map((index) => (
<span
aria-hidden="true"
className="h-1.5 w-1.5 animate-bounce rounded-full bg-current motion-reduce:animate-none"
key={index}
style={{ animationDelay: `${index * 120}ms` }}
/>
))}
</span>
);
}

export function CommunityOnboardingFlow({
onCancel,
onConnect,
Expand All @@ -126,6 +146,8 @@ export function CommunityOnboardingFlow({
[],
);
const [isPending, setIsPending] = React.useState(false);
const [starterChannelFailureCount, setStarterChannelFailureCount] =
React.useState(0);
const [deniedPubkey, setDeniedPubkey] = React.useState("");
const [isMembershipDenied, setIsMembershipDenied] = React.useState(false);
const [isCommunityChangeOpen, setIsCommunityChangeOpen] =
Expand Down Expand Up @@ -228,13 +250,20 @@ export function CommunityOnboardingFlow({
}
await finish();
} catch (error) {
setStarterChannelFailureCount((count) => count + 1);
update({
error: error instanceof Error ? error.message : String(error),
});
setIsPending(false);
}
}, [finish, isPending, queryClient, relayUrl, update]);

const backToProfile = React.useCallback(() => {
if (isPending) return;
setStarterChannelFailureCount(0);
update({ stage: "profile", error: undefined });
}, [isPending, update]);

const isProfileStage = transaction?.stage === "profile";
const isTeamStage =
transaction?.stage === "team-intro" ||
Expand Down Expand Up @@ -340,9 +369,9 @@ export function CommunityOnboardingFlow({
className={cn(
"relative w-full text-center",
isProfileStage
? "buzz-onboarding-step-frame flex max-w-[500px] flex-col justify-center"
? "buzz-onboarding-step-frame flex max-w-[500px] flex-col items-center"
: isTeamStage
? "buzz-onboarding-step-frame flex max-w-[760px] flex-col justify-center"
? "buzz-onboarding-step-frame flex max-w-[760px] flex-col items-center"
: "flex min-h-dvh max-w-[560px] flex-col justify-center py-8",
)}
data-testid="community-onboarding-body"
Expand Down Expand Up @@ -405,7 +434,7 @@ export function CommunityOnboardingFlow({
</div>
) : (
<>
<div data-testid="community-profile-main">
<div className="w-full" data-testid="community-profile-main">
<h1 className="text-title font-normal">Build your profile</h1>
<p className="mx-auto mt-3 max-w-[380px] text-sm leading-6 text-foreground/80">
Add a name and avatar. They’ll show up on your messages,
Expand Down Expand Up @@ -468,66 +497,76 @@ export function CommunityOnboardingFlow({
<h1 className="text-title font-normal">Meet your starter team</h1>
<p className="mx-auto mt-3 max-w-[400px] text-sm leading-6 text-foreground/80">
Buzz lets you bring multiple agents into the same workspace.
This team will help you get started using Buzz.
Your team will help you get started using Buzz.
</p>
{starterPersonas.length > 0 ? (
<div className="mt-10 flex flex-wrap justify-center gap-8">
{starterPersonas.map((persona) => {
const animationUrl =
STARTER_PERSONA_ANIMATIONS[persona.displayName];
return (
<div
className="flex w-40 flex-col items-center gap-3"
key={persona.id}
>
{animationUrl ? (
<img
alt={`${persona.displayName} animated character`}
className="h-40 w-40 object-contain"
data-testid={`starter-persona-${persona.displayName.toLowerCase()}`}
src={animationUrl}
/>
) : (
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-28 w-28 text-3xl"
label={persona.displayName}
/>
)}
<span className="font-mono text-xs font-medium uppercase tracking-[0.15em]">
{persona.displayName}
</span>
</div>
);
})}
</div>
) : null}
<div className="flex w-full flex-1 items-center justify-center py-10">
{starterPersonas.length > 0 ? (
<div className="flex flex-wrap justify-center gap-8">
{starterPersonas.map((persona) => {
const animationUrl =
STARTER_PERSONA_ANIMATIONS[persona.displayName];
return (
<div
className="flex w-40 flex-col items-center gap-3"
key={persona.id}
>
{animationUrl ? (
<img
alt={`${persona.displayName} animated character`}
className="h-40 w-40 object-contain"
data-testid={`starter-persona-${persona.displayName.toLowerCase()}`}
src={animationUrl}
/>
) : (
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-28 w-28 text-3xl"
label={persona.displayName}
/>
)}
<span className="font-mono text-xs font-medium uppercase tracking-[0.15em]">
{persona.displayName}
</span>
</div>
);
})}
</div>
) : null}
</div>
{transaction.error ? (
<p className="mt-4 text-sm text-destructive">
<p className="text-sm text-destructive">
{transaction.error}
{starterChannelFailureCount === 1 ? " Try again." : null}
</p>
) : null}
<OnboardingFooter>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="community-team-intro-enter"
disabled={isPending || transaction.stage === "entering"}
onClick={() => void finalize()}
onClick={() =>
void (starterChannelFailureCount >= 2
? finish()
: finalize())
}
>
{transaction.stage === "finalizing" ||
transaction.stage === "entering"
? "Preparing Welcome…"
: `Enter ${transaction.communityName}`}
{isPending || transaction.stage === "entering" ? (
<LoadingDots label="Preparing Welcome" />
) : starterChannelFailureCount >= 2 ? (
"Skip for now"
) : (
"Take me to Buzz"
)}
</Button>
<Button
className="h-9 rounded-full bg-foreground/10 px-5 hover:bg-foreground/15"
data-testid="community-team-intro-back"
disabled={isPending || transaction.stage === "entering"}
onClick={backToProfile}
variant="ghost"
>
Back
</Button>
{transaction.error ? (
<Button
className="h-9 rounded-full bg-foreground/10 px-5 hover:bg-foreground/15"
disabled={isPending}
onClick={() => void finish()}
variant="ghost"
>
Skip for now
</Button>
) : null}
</OnboardingFooter>
</>
)}
Expand Down
65 changes: 47 additions & 18 deletions desktop/tests/e2e/deep-link-invite.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ test("queued add-community links open and acknowledge one at a time", async ({
]);
});

test("Welcome failure can be skipped without abandoning community onboarding", async ({
test("Welcome failure retries once before allowing starter channel setup to be skipped", async ({
page,
}) => {
const welcomeError = "Channel creation is not permitted.";
Expand Down Expand Up @@ -277,7 +277,7 @@ test("Welcome failure can be skipped without abandoning community onboarding", a
);
await installMockBridge(
page,
{ ensureStarterChannelsErrors: [welcomeError, welcomeError] },
{ ensureStarterChannelsErrors: [welcomeError, welcomeError, welcomeError] },
{ relayWsUrl: COMMUNITY_RELAY_URL, skipOnboardingSeed: true },
);
await page.goto("/");
Expand All @@ -291,30 +291,59 @@ test("Welcome failure can be skipped without abandoning community onboarding", a
);
}

await page.getByRole("button", { name: "Enter hive" }).click();
const enterButton = page.getByRole("button", { name: "Take me to Buzz" });
await enterButton.click();

await expect(page.getByText(`${welcomeError} Try again.`)).toBeVisible();
await expect(enterButton).toBeEnabled();
const backButton = page.getByRole("button", { name: "Back" });
await expect(backButton).toBeVisible();
await backButton.click();

await expect(page.getByText(welcomeError)).toBeVisible();
await expect(
page.getByRole("button", { name: "Preparing Welcome…" }),
).toBeEnabled();
const skip = page.getByRole("button", { name: "Skip for now" });
await expect(skip).toBeVisible();
await skip.click();
page.getByRole("heading", { name: "Build your profile" }),
).toBeVisible();
await page.getByLabel("Community display name").fill("Tyler");
await page.getByTestId("community-profile-next").click();

await enterButton.click();
await expect(page.getByText(`${welcomeError} Try again.`)).toBeVisible();
await expect(enterButton).toBeEnabled();

await enterButton.click();

const skipButton = page.getByRole("button", { name: "Skip for now" });
await expect(page.getByText(welcomeError, { exact: true })).toBeVisible();
await expect(skipButton).toBeEnabled();
await expect(page.getByRole("button", { name: "Back" })).toBeVisible();

const starterChannelAttempts = await page.evaluate(
() =>
window.__BUZZ_E2E_COMMANDS__?.filter(
(command) => command === "ensure_starter_channels",
).length ?? 0,
);
expect(starterChannelAttempts).toBe(3);

await skipButton.click();

const completionKey = `buzz-community-onboarding-complete.v1:${encodeURIComponent(COMMUNITY_RELAY_URL)}:${WELCOME_FAILURE_PUBKEY}`;
await expect(page.getByTestId("community-onboarding-flow")).toHaveCount(0);
expect(
await page.evaluate(
() =>
window.__BUZZ_E2E_COMMANDS__?.filter(
(command) => command === "ensure_starter_channels",
).length ?? 0,
),
).toBe(3);
await expect
.poll(() =>
page.evaluate(
({ completion, transaction }) => ({
completion: window.localStorage.getItem(completion),
transaction: window.localStorage.getItem(transaction),
}),
{ completion: completionKey, transaction: TRANSACTION_STORAGE_KEY },
(transaction) => window.localStorage.getItem(transaction),
TRANSACTION_STORAGE_KEY,
),
)
.toEqual({ completion: "true", transaction: null });
await expect(page.getByTestId("community-onboarding-flow")).toHaveCount(0);
await expect(page.getByTestId("app-sidebar")).toBeVisible();
.toBeNull();
});

test("persisted deep-link invite hands off to Joining after machine onboarding", async ({
Expand Down
19 changes: 8 additions & 11 deletions desktop/tests/e2e/onboarding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -987,18 +987,15 @@ test("connected first-community profile step cannot discard resumable onboarding
await expect(
page.getByRole("heading", { name: "Build your profile" }),
).toBeVisible();
const profileMain = page.getByTestId("community-profile-main");
const profileMainBox = await profileMain.boundingBox();
const viewport = page.viewportSize();
if (!profileMainBox || !viewport) {
throw new Error("Could not measure community profile body position");
const profileHeading = page.getByRole("heading", {
name: "Build your profile",
});
await expect(profileHeading).toBeVisible();
const profileHeadingBox = await profileHeading.boundingBox();
if (!profileHeadingBox) {
throw new Error("Could not measure community profile heading position");
}
const chromeOffset = 106;
const footerOffset = 144;
const profileMainCenterY = profileMainBox.y + profileMainBox.height / 2;
const centeredInUsableLaneY =
chromeOffset + (viewport.height - chromeOffset - footerOffset) / 2;
expect(Math.abs(profileMainCenterY - centeredInUsableLaneY)).toBeLessThan(32);
expect(Math.abs(profileHeadingBox.y - 106)).toBeLessThan(8);
const nameKey = page.getByTestId("community-profile-name-key");
const avatarButton = page.getByTestId("community-avatar-open");
await expect(nameKey).toBeVisible();
Expand Down
Loading