mirror of
https://github.com/block/buzz.git
synced 2026-11-11 11:51:13 +00:00
Expand onboarding V3 workshop preview
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -64,6 +64,9 @@ type HostedCommunityOnboardingProps = {
|
||||
* finished driving the flow.
|
||||
*/
|
||||
onReady?: () => void;
|
||||
/** Use the production stage with local-only workshop behavior. */
|
||||
previewMode?: boolean;
|
||||
onPreviewContinue?: (communityName: string) => void;
|
||||
/**
|
||||
* While true, render only the sign-in modal and keep the page scaffolding
|
||||
* hidden, so whatever screen launched the flow stays visible behind it.
|
||||
@@ -74,8 +77,136 @@ type HostedCommunityOnboardingProps = {
|
||||
export function HostedCommunityOnboarding({
|
||||
onBack,
|
||||
onReady,
|
||||
onPreviewContinue,
|
||||
previewMode = false,
|
||||
stageHidden = false,
|
||||
}: HostedCommunityOnboardingProps) {
|
||||
if (previewMode) {
|
||||
return (
|
||||
<HostedCommunityOnboardingPreview
|
||||
onBack={onBack}
|
||||
onContinue={onPreviewContinue ?? (() => undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HostedCommunityOnboardingLive
|
||||
onBack={onBack}
|
||||
onReady={onReady}
|
||||
stageHidden={stageHidden}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HostedCommunityOnboardingPreview({
|
||||
onBack,
|
||||
onContinue,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
onContinue: (communityName: string) => void;
|
||||
}) {
|
||||
const [name, setName] = React.useState("");
|
||||
const displayName = name || "your-community";
|
||||
const composedAddressLength =
|
||||
displayName.length + HOSTED_COMMUNITY_SUFFIX.length + 1;
|
||||
const addressFontSize = `min(2.25rem, calc(90cqw / ${(
|
||||
composedAddressLength * 0.62
|
||||
).toFixed(2)}))`;
|
||||
|
||||
const continueToCommunity = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
onContinue(name.trim() || "Your community");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-[calc(100dvh-15.625rem)] w-full max-w-[920px] flex-col items-center text-center"
|
||||
data-testid="hosted-community-preview"
|
||||
>
|
||||
<h1 className="max-w-[620px] text-title font-normal leading-[1.18] tracking-[-0.025em]">
|
||||
Create a community
|
||||
</h1>
|
||||
<p className="mx-auto mt-2 max-w-[560px] text-sm leading-6 text-foreground">
|
||||
Claim a Buzz address to get started.
|
||||
</p>
|
||||
|
||||
<div className="flex w-full flex-1 flex-col justify-center text-left">
|
||||
<Card
|
||||
asChild
|
||||
className={`${FUZZY_SURFACE_CLASS} !py-10 [--buzz-card-textured-min-height:176px]`}
|
||||
data-testid="hosted-community-create-surface"
|
||||
variant="textured"
|
||||
>
|
||||
<form
|
||||
id="hosted-community-preview-create-form"
|
||||
onSubmit={continueToCommunity}
|
||||
>
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-[900px] items-center justify-center whitespace-nowrap"
|
||||
data-testid="hosted-community-address-line"
|
||||
style={{ containerType: "inline-size" }}
|
||||
>
|
||||
<Input
|
||||
aria-label="Community name"
|
||||
autoComplete="off"
|
||||
className="h-auto min-w-0 flex-none rounded-none border-0 bg-transparent p-0 text-right font-mono !text-[rgb(var(--buzz-hosted-community-surface-fg))] shadow-none placeholder:!text-[rgb(var(--buzz-hosted-community-surface-fg))] placeholder:opacity-20 focus-visible:ring-0"
|
||||
data-testid="hosted-community-address-input"
|
||||
id="hosted-community-preview-address"
|
||||
maxLength={63}
|
||||
onChange={(event) => setName(event.target.value.toLowerCase())}
|
||||
placeholder="your-community"
|
||||
spellCheck={false}
|
||||
style={{
|
||||
width: `${displayName.length}ch`,
|
||||
fontSize: addressFontSize,
|
||||
}}
|
||||
value={name}
|
||||
/>
|
||||
<span
|
||||
className="shrink-0 font-mono !text-[rgb(var(--buzz-hosted-community-surface-fg))]"
|
||||
style={{ fontSize: addressFontSize }}
|
||||
>
|
||||
.{HOSTED_COMMUNITY_SUFFIX}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
<p
|
||||
aria-hidden
|
||||
className="relative z-10 mt-3 min-h-5 text-center text-sm invisible"
|
||||
>
|
||||
Community address status
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<OnboardingFooter>
|
||||
<Button
|
||||
className={PAGE_CTA_CLASS}
|
||||
data-testid="onboarding-preview-community-continue"
|
||||
form="hosted-community-preview-create-form"
|
||||
type="submit"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
<Button
|
||||
className={PAGE_BACK_CLASS}
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</OnboardingFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HostedCommunityOnboardingLive({
|
||||
onBack,
|
||||
onReady,
|
||||
stageHidden = false,
|
||||
}: Pick<HostedCommunityOnboardingProps, "onBack" | "onReady" | "stageHidden">) {
|
||||
const onboarding = useCommunityOnboarding();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const localPubkey = useIdentityQuery().data?.pubkey ?? null;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveOnboardingPreviewMode } from "./onboardingPreview.ts";
|
||||
import {
|
||||
ONBOARDING_PREVIEW_JOURNEYS,
|
||||
resolveOnboardingPreviewMode,
|
||||
} from "./onboardingPreview.ts";
|
||||
|
||||
test("preview requires an exact development opt-in", () => {
|
||||
assert.equal(
|
||||
@@ -43,3 +46,31 @@ test("preview is available to the explicit E2E build", () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("today preserves the complete workshop journey", () => {
|
||||
assert.deepEqual(ONBOARDING_PREVIEW_JOURNEYS.today, {
|
||||
afterAccount: "setup",
|
||||
afterCommunityEntry: "community-connecting",
|
||||
afterProfile: "starter-team",
|
||||
communityChoiceBack: "config",
|
||||
communityStep: 5,
|
||||
finalStep: 7,
|
||||
includeExistingCommunity: true,
|
||||
profileStep: 6,
|
||||
totalSteps: 7,
|
||||
});
|
||||
});
|
||||
|
||||
test("V3 skips technical setup and the transitional community screens", () => {
|
||||
assert.deepEqual(ONBOARDING_PREVIEW_JOURNEYS.v3, {
|
||||
afterAccount: "community-choice",
|
||||
afterCommunityEntry: "community-profile",
|
||||
afterProfile: "community-home",
|
||||
communityChoiceBack: null,
|
||||
communityStep: 3,
|
||||
finalStep: 4,
|
||||
includeExistingCommunity: false,
|
||||
profileStep: 4,
|
||||
totalSteps: 4,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,68 @@ type OnboardingPreviewEnvironment = {
|
||||
search: string;
|
||||
};
|
||||
|
||||
export type OnboardingPreviewPage =
|
||||
| "landing"
|
||||
| "email"
|
||||
| "identity-key"
|
||||
| "sign-in"
|
||||
| "sign-in-key"
|
||||
| "forgot-password"
|
||||
| "backup-options"
|
||||
| "backup-password"
|
||||
| "setup"
|
||||
| "config"
|
||||
| "community-choice"
|
||||
| "community-entry"
|
||||
| "community-connecting"
|
||||
| "community-profile"
|
||||
| "starter-team"
|
||||
| "welcome-channel"
|
||||
| "community-home";
|
||||
|
||||
export type OnboardingPreviewVariant = "today" | "v3";
|
||||
|
||||
type OnboardingPreviewJourney = {
|
||||
afterAccount: OnboardingPreviewPage;
|
||||
afterCommunityEntry: OnboardingPreviewPage;
|
||||
afterProfile: OnboardingPreviewPage;
|
||||
communityChoiceBack: OnboardingPreviewPage | null;
|
||||
communityStep: number;
|
||||
finalStep: number;
|
||||
includeExistingCommunity: boolean;
|
||||
profileStep: number;
|
||||
totalSteps: number;
|
||||
};
|
||||
|
||||
/** Workshop-only route maps that preserve today's full flow beside V3. */
|
||||
export const ONBOARDING_PREVIEW_JOURNEYS: Record<
|
||||
OnboardingPreviewVariant,
|
||||
OnboardingPreviewJourney
|
||||
> = {
|
||||
today: {
|
||||
afterAccount: "setup",
|
||||
afterCommunityEntry: "community-connecting",
|
||||
afterProfile: "starter-team",
|
||||
communityChoiceBack: "config",
|
||||
communityStep: 5,
|
||||
finalStep: 7,
|
||||
includeExistingCommunity: true,
|
||||
profileStep: 6,
|
||||
totalSteps: 7,
|
||||
},
|
||||
v3: {
|
||||
afterAccount: "community-choice",
|
||||
afterCommunityEntry: "community-profile",
|
||||
afterProfile: "community-home",
|
||||
communityChoiceBack: null,
|
||||
communityStep: 3,
|
||||
finalStep: 4,
|
||||
includeExistingCommunity: false,
|
||||
profileStep: 4,
|
||||
totalSteps: 4,
|
||||
},
|
||||
};
|
||||
|
||||
/** Resolve the explicit, non-production onboarding workshop route. */
|
||||
export function resolveOnboardingPreviewMode({
|
||||
dev,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Users } from "lucide-react";
|
||||
import { Users } from "lucide-react";
|
||||
|
||||
import {
|
||||
markCommunityOnboardingComplete,
|
||||
@@ -17,10 +17,6 @@ import { useAvatarPresentation } from "@/features/profile/avatarPresentationStor
|
||||
import { registerAvatarWhenReady } from "@/features/profile/avatarProfileSync";
|
||||
import { profileQueryKey } from "@/features/profile/hooks";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import {
|
||||
parseEmojiAvatarDataUrl,
|
||||
ProfileAvatarEditor,
|
||||
} from "@/features/profile/ui/ProfileAvatarEditor";
|
||||
import { getProfile, updateProfile } from "@/shared/api/tauriProfiles";
|
||||
import { getIdentity, importIdentity } from "@/shared/api/tauriIdentity";
|
||||
import { listPersonas } from "@/shared/api/tauriPersonas";
|
||||
@@ -29,8 +25,6 @@ import type { AgentPersona } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { MembershipDenied } from "./MembershipDenied";
|
||||
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
|
||||
import {
|
||||
@@ -42,6 +36,7 @@ import {
|
||||
type OnboardingTransitionDirection,
|
||||
OnboardingSlideTransition,
|
||||
} from "./OnboardingSlideTransition";
|
||||
import { CommunityProfileStage } from "./CommunityProfileStage";
|
||||
|
||||
function isRelayMembershipDeniedError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
@@ -67,64 +62,6 @@ const ENTERING_CURTAIN_FADE_MS = 500;
|
||||
*/
|
||||
const ENTERING_CURTAIN_MAX_WAIT_MS = 8_000;
|
||||
|
||||
const NEUTRAL_EMOJI_PICKER_THEME_VARS = {
|
||||
"--buzz-emoji-picker-rgb-background":
|
||||
"var(--buzz-onboarding-emoji-picker-background)",
|
||||
"--buzz-emoji-picker-rgb-color": "var(--buzz-onboarding-emoji-picker-color)",
|
||||
"--buzz-emoji-picker-rgb-input": "var(--buzz-onboarding-emoji-picker-input)",
|
||||
} as React.CSSProperties;
|
||||
|
||||
function AvatarCircle({
|
||||
avatarUrl,
|
||||
onClick,
|
||||
previewName,
|
||||
triggerRef,
|
||||
}: {
|
||||
avatarUrl: string;
|
||||
onClick: () => void;
|
||||
previewName: string;
|
||||
triggerRef?: React.Ref<HTMLButtonElement>;
|
||||
}) {
|
||||
const emojiAvatar = parseEmojiAvatarDataUrl(avatarUrl);
|
||||
const presentation = useAvatarPresentation(avatarUrl);
|
||||
const hasAvatar =
|
||||
avatarUrl.trim().length > 0 && presentation?.state !== "failed";
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={hasAvatar ? "Change your avatar" : "Add an avatar"}
|
||||
className="group block shrink-0 rounded-full"
|
||||
data-testid="community-avatar-open"
|
||||
onClick={onClick}
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
>
|
||||
{emojiAvatar ? (
|
||||
<span
|
||||
className="flex h-36 w-36 items-center justify-center overflow-hidden rounded-full text-5xl shadow-xs"
|
||||
style={{ backgroundColor: emojiAvatar.color }}
|
||||
>
|
||||
{emojiAvatar.emoji}
|
||||
</span>
|
||||
) : hasAvatar ? (
|
||||
<ProfileAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
className="h-36 w-36 rounded-full text-4xl"
|
||||
label={previewName}
|
||||
testId="community-avatar-circle"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="flex h-36 w-36 items-center justify-center rounded-full bg-white/30 text-[var(--buzz-onboarding-backup-ink)] transition-colors group-hover:bg-white/40"
|
||||
data-testid="community-avatar-empty"
|
||||
>
|
||||
<Plus className="h-7 w-7" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingDots({ label }: { label: string }) {
|
||||
return (
|
||||
<span
|
||||
@@ -157,22 +94,10 @@ export function CommunityOnboardingFlow({
|
||||
const systemColorScheme = useSystemColorScheme();
|
||||
const [displayName, setDisplayName] = React.useState("");
|
||||
const [avatarUrl, setAvatarUrl] = React.useState("");
|
||||
const [localAvatarPreviewUrl, setLocalAvatarPreviewUrl] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [avatarSquishKey, setAvatarSquishKey] = React.useState(0);
|
||||
const [transitionDirection, setTransitionDirection] =
|
||||
React.useState<OnboardingTransitionDirection>("forward");
|
||||
const avatarPresentation = useAvatarPresentation(avatarUrl);
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false);
|
||||
const [isAvatarEditorOpen, setIsAvatarEditorOpen] = React.useState(false);
|
||||
const [animatedPreviewEl, setAnimatedPreviewEl] =
|
||||
React.useState<HTMLDivElement | null>(null);
|
||||
const [isAnimatedPreviewActive, setIsAnimatedPreviewActive] =
|
||||
React.useState(false);
|
||||
const [animatedPreviewCaption, setAnimatedPreviewCaption] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [starterPersonas, setStarterPersonas] = React.useState<AgentPersona[]>(
|
||||
[],
|
||||
);
|
||||
@@ -185,14 +110,6 @@ export function CommunityOnboardingFlow({
|
||||
const [isCommunityChangeOpen, setIsCommunityChangeOpen] =
|
||||
React.useState(false);
|
||||
const [isCurtainFading, setIsCurtainFading] = React.useState(false);
|
||||
const nameInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const avatarTriggerRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
const avatarEditorContentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [avatarEditorDialogHeight, setAvatarEditorDialogHeight] =
|
||||
React.useState<number | null>(null);
|
||||
const animateEmojiAvatarChange = React.useCallback(() => {
|
||||
setAvatarSquishKey((key) => key + 1);
|
||||
}, []);
|
||||
|
||||
// Also fetch on "entering": the curtain is a fresh mount of this component,
|
||||
// so the team-intro fetch from the pre-curtain instance isn't in this state.
|
||||
@@ -351,31 +268,6 @@ export function CommunityOnboardingFlow({
|
||||
});
|
||||
}, [isProfileStage]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isProfileStage && !isAvatarEditorOpen) {
|
||||
nameInputRef.current?.focus();
|
||||
}
|
||||
}, [isAvatarEditorOpen, isProfileStage]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isAvatarEditorOpen) {
|
||||
setAvatarEditorDialogHeight(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = avatarEditorContentRef.current;
|
||||
if (!content) return;
|
||||
|
||||
const updateHeight = () => {
|
||||
setAvatarEditorDialogHeight(content.getBoundingClientRect().height + 64);
|
||||
};
|
||||
updateHeight();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateHeight);
|
||||
resizeObserver.observe(content);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [isAvatarEditorOpen]);
|
||||
|
||||
if (!transaction) return null;
|
||||
|
||||
if (isMembershipDenied) {
|
||||
@@ -551,200 +443,17 @@ export function CommunityOnboardingFlow({
|
||||
</div>
|
||||
</>
|
||||
) : isProfileStage ? (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 w-full flex-1 flex-col transition-[filter,opacity] duration-200 ease-out",
|
||||
isAvatarEditorOpen &&
|
||||
"pointer-events-none opacity-45 blur-[3px]",
|
||||
)}
|
||||
data-testid="community-profile-main"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<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,
|
||||
reactions, and agent handoffs.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col items-center justify-center pt-8">
|
||||
<AvatarCircle
|
||||
avatarUrl={avatarUrl}
|
||||
onClick={() => setIsAvatarEditorOpen(true)}
|
||||
previewName={displayName.trim() || "Your profile"}
|
||||
triggerRef={avatarTriggerRef}
|
||||
/>
|
||||
<label
|
||||
className="mt-7 block w-full max-w-[412px] text-left"
|
||||
htmlFor="community-display-name"
|
||||
>
|
||||
<span className="mb-2 block pl-4 text-sm text-foreground">
|
||||
Your username
|
||||
</span>
|
||||
<Input
|
||||
aria-label="Community username"
|
||||
autoCapitalize="none"
|
||||
autoComplete="username"
|
||||
autoCorrect="off"
|
||||
className="h-14 rounded-2xl border-[color:rgb(var(--buzz-onboarding-avatar-control-fg)_/_0.28)] bg-[rgb(var(--buzz-onboarding-avatar-dialog-bg)/0.95)] px-5 text-sm shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[color:rgb(var(--buzz-onboarding-avatar-control-fg)_/_0.5)] md:text-sm"
|
||||
data-testid="community-profile-name-key"
|
||||
disabled={isPending || isUploadingAvatar}
|
||||
id="community-display-name"
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder="Enter your username here"
|
||||
ref={nameInputRef}
|
||||
spellCheck={false}
|
||||
type="text"
|
||||
value={displayName}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{transaction.error ? (
|
||||
<p className="mt-4 text-sm text-destructive">
|
||||
{transaction.error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<OnboardingFooter
|
||||
className={cn(
|
||||
"transition-[filter,opacity] duration-200 ease-out",
|
||||
isAvatarEditorOpen &&
|
||||
"pointer-events-none opacity-45 blur-[3px]",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className={`${ONBOARDING_PRIMARY_CTA_CLASS} w-20`}
|
||||
data-testid="community-profile-next"
|
||||
disabled={
|
||||
!displayName.trim() || isPending || isUploadingAvatar
|
||||
}
|
||||
onClick={() => void saveProfile()}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</OnboardingFooter>
|
||||
<Dialog
|
||||
onOpenChange={(open) => setIsAvatarEditorOpen(open)}
|
||||
open={isAvatarEditorOpen}
|
||||
>
|
||||
<DialogContent
|
||||
className="buzz-onboarding-neutral-theme w-[min(calc(100vw-2rem),920px)] max-w-[920px] gap-0 overflow-hidden rounded-[18px] bg-[rgb(var(--buzz-onboarding-avatar-dialog-bg))] px-8 pb-6 pt-10 text-sm text-foreground shadow-[0_28px_90px_rgb(var(--buzz-onboarding-avatar-dialog-shadow)_/_0.28),0_8px_28px_rgb(var(--buzz-onboarding-avatar-dialog-shadow)_/_0.18)] transition-[height] duration-[250ms] ease-out"
|
||||
closeButtonClassName="right-6 top-6 h-10 w-10 rounded-full bg-[rgb(var(--buzz-onboarding-avatar-action-bg))] text-[rgb(var(--buzz-onboarding-avatar-action-fg))] hover:bg-[rgb(var(--buzz-onboarding-avatar-action-bg)/0.9)] hover:text-[rgb(var(--buzz-onboarding-avatar-action-fg))]"
|
||||
data-system-color-scheme="light"
|
||||
data-testid="community-avatar-editor-key-frame"
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
avatarTriggerRef.current?.focus();
|
||||
}}
|
||||
overlayVariant="transparent"
|
||||
style={
|
||||
avatarEditorDialogHeight === null
|
||||
? undefined
|
||||
: { height: avatarEditorDialogHeight }
|
||||
}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
Edit your avatar
|
||||
</DialogTitle>
|
||||
<div
|
||||
className="grid items-center gap-8 md:grid-cols-[240px_minmax(0,1fr)]"
|
||||
ref={avatarEditorContentRef}
|
||||
>
|
||||
<div
|
||||
className="flex min-h-[320px] flex-col items-center justify-center gap-3 px-6 py-8"
|
||||
data-testid="community-avatar-live-preview-panel"
|
||||
>
|
||||
<div className="relative h-48 w-48">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10"
|
||||
data-testid="community-avatar-animated-preview-slot"
|
||||
ref={setAnimatedPreviewEl}
|
||||
/>
|
||||
{isAnimatedPreviewActive
|
||||
? null
|
||||
: (() => {
|
||||
if (localAvatarPreviewUrl) {
|
||||
return (
|
||||
<ProfileAvatar
|
||||
avatarUrl={localAvatarPreviewUrl}
|
||||
className="h-full w-full rounded-full text-5xl"
|
||||
label={
|
||||
displayName.trim() || "Your profile"
|
||||
}
|
||||
testId="community-avatar-live-preview"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const emojiAvatar =
|
||||
parseEmojiAvatarDataUrl(avatarUrl);
|
||||
return emojiAvatar ? (
|
||||
<div
|
||||
aria-label={`${displayName.trim() || "Your profile"} avatar`}
|
||||
className="flex h-full w-full items-center justify-center overflow-hidden rounded-full text-6xl shadow-xs"
|
||||
data-testid="community-avatar-live-preview"
|
||||
role="img"
|
||||
style={{
|
||||
backgroundColor: emojiAvatar.color,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
avatarSquishKey > 0 &&
|
||||
"buzz-avatar-squish",
|
||||
)}
|
||||
data-testid="community-avatar-live-preview-emoji"
|
||||
key={avatarSquishKey}
|
||||
>
|
||||
{emojiAvatar.emoji}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<ProfileAvatar
|
||||
avatarUrl={
|
||||
localAvatarPreviewUrl || avatarUrl || null
|
||||
}
|
||||
className="h-full w-full rounded-full text-5xl"
|
||||
label={displayName.trim() || "Your profile"}
|
||||
testId="community-avatar-live-preview"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{animatedPreviewCaption ? (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{animatedPreviewCaption}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<ProfileAvatarEditor
|
||||
animatedPreviewContainer={animatedPreviewEl}
|
||||
avatarUrl={avatarUrl}
|
||||
disabled={isPending}
|
||||
donePending={isUploadingAvatar}
|
||||
emojiPickerTheme="auto"
|
||||
emojiPickerThemeVars={NEUTRAL_EMOJI_PICKER_THEME_VARS}
|
||||
onDone={() => setIsAvatarEditorOpen(false)}
|
||||
onAnimatedPreviewActiveChange={
|
||||
setIsAnimatedPreviewActive
|
||||
}
|
||||
onAnimatedPreviewCaptionChange={
|
||||
setAnimatedPreviewCaption
|
||||
}
|
||||
onEmojiAvatarChange={animateEmojiAvatarChange}
|
||||
onLocalPreviewChange={setLocalAvatarPreviewUrl}
|
||||
onUploadingChange={setIsUploadingAvatar}
|
||||
onUrlChange={setAvatarUrl}
|
||||
presentation="onboarding-modal"
|
||||
previewName={displayName.trim() || "Your profile"}
|
||||
testIdPrefix="community-avatar"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
<CommunityProfileStage
|
||||
avatarUrl={avatarUrl}
|
||||
displayName={displayName}
|
||||
errorMessage={transaction.error}
|
||||
isPending={isPending}
|
||||
isUploadingAvatar={isUploadingAvatar}
|
||||
onAvatarUrlChange={setAvatarUrl}
|
||||
onDisplayNameChange={setDisplayName}
|
||||
onNext={() => void saveProfile()}
|
||||
onUploadingChange={setIsUploadingAvatar}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-title font-normal">
|
||||
|
||||
352
desktop/src/features/onboarding/ui/CommunityProfileStage.tsx
Normal file
352
desktop/src/features/onboarding/ui/CommunityProfileStage.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import { Plus } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useAvatarPresentation } from "@/features/profile/avatarPresentationStore";
|
||||
import type { AnimatedAvatarRecordingProcessor } from "@/features/profile/ui/AnimatedAvatarCapture.types";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import {
|
||||
emojiAvatarDataUrl,
|
||||
parseEmojiAvatarDataUrl,
|
||||
} from "@/features/profile/ui/ProfileAvatarEditor.utils";
|
||||
import { ProfileAvatarEditor } from "@/features/profile/ui/ProfileAvatarEditor";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
|
||||
import { OnboardingFooter } from "./OnboardingFooter";
|
||||
|
||||
const NEUTRAL_EMOJI_PICKER_THEME_VARS = {
|
||||
"--buzz-emoji-picker-rgb-background":
|
||||
"var(--buzz-onboarding-emoji-picker-background)",
|
||||
"--buzz-emoji-picker-rgb-color": "var(--buzz-onboarding-emoji-picker-color)",
|
||||
"--buzz-emoji-picker-rgb-input": "var(--buzz-onboarding-emoji-picker-input)",
|
||||
} as React.CSSProperties;
|
||||
|
||||
const PREVIEW_URL_AVATAR = emojiAvatarDataUrl("🐝", "#D7D72E");
|
||||
|
||||
function blobAsDataUrl(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(reader.error ?? new Error("Read failed."));
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
const processPreviewImage = (file: File) => blobAsDataUrl(file);
|
||||
|
||||
const processPreviewAnimatedAvatar: AnimatedAvatarRecordingProcessor = ({
|
||||
posterBytes,
|
||||
}) =>
|
||||
blobAsDataUrl(
|
||||
new Blob([Uint8Array.from(posterBytes).buffer], { type: "image/png" }),
|
||||
);
|
||||
|
||||
function AvatarCircle({
|
||||
avatarUrl,
|
||||
onClick,
|
||||
previewName,
|
||||
triggerRef,
|
||||
}: {
|
||||
avatarUrl: string;
|
||||
onClick: () => void;
|
||||
previewName: string;
|
||||
triggerRef?: React.Ref<HTMLButtonElement>;
|
||||
}) {
|
||||
const emojiAvatar = parseEmojiAvatarDataUrl(avatarUrl);
|
||||
const presentation = useAvatarPresentation(avatarUrl);
|
||||
const hasAvatar =
|
||||
avatarUrl.trim().length > 0 && presentation?.state !== "failed";
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={hasAvatar ? "Change your avatar" : "Add an avatar"}
|
||||
className="group block shrink-0 rounded-full"
|
||||
data-testid="community-avatar-open"
|
||||
onClick={onClick}
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
>
|
||||
{emojiAvatar ? (
|
||||
<span
|
||||
className="flex h-36 w-36 items-center justify-center overflow-hidden rounded-full text-5xl shadow-xs"
|
||||
style={{ backgroundColor: emojiAvatar.color }}
|
||||
>
|
||||
{emojiAvatar.emoji}
|
||||
</span>
|
||||
) : hasAvatar ? (
|
||||
<ProfileAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
className="h-36 w-36 rounded-full text-4xl"
|
||||
label={previewName}
|
||||
testId="community-avatar-circle"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="flex h-36 w-36 items-center justify-center rounded-full bg-white/30 text-[var(--buzz-onboarding-backup-ink)] transition-colors group-hover:bg-white/40"
|
||||
data-testid="community-avatar-empty"
|
||||
>
|
||||
<Plus className="h-7 w-7" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommunityProfileStage({
|
||||
avatarUrl,
|
||||
displayName,
|
||||
errorMessage,
|
||||
isPending,
|
||||
isUploadingAvatar,
|
||||
onAvatarUrlChange,
|
||||
onDisplayNameChange,
|
||||
onNext,
|
||||
onUploadingChange,
|
||||
previewMode = false,
|
||||
}: {
|
||||
avatarUrl: string;
|
||||
displayName: string;
|
||||
errorMessage?: string;
|
||||
isPending: boolean;
|
||||
isUploadingAvatar: boolean;
|
||||
onAvatarUrlChange: (value: string) => void;
|
||||
onDisplayNameChange: (value: string) => void;
|
||||
onNext: () => void;
|
||||
onUploadingChange: (isUploading: boolean) => void;
|
||||
previewMode?: boolean;
|
||||
}) {
|
||||
const [localAvatarPreviewUrl, setLocalAvatarPreviewUrl] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [avatarSquishKey, setAvatarSquishKey] = React.useState(0);
|
||||
const [isAvatarEditorOpen, setIsAvatarEditorOpen] = React.useState(false);
|
||||
const [animatedPreviewEl, setAnimatedPreviewEl] =
|
||||
React.useState<HTMLDivElement | null>(null);
|
||||
const [isAnimatedPreviewActive, setIsAnimatedPreviewActive] =
|
||||
React.useState(false);
|
||||
const [animatedPreviewCaption, setAnimatedPreviewCaption] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const nameInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const avatarTriggerRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
const avatarEditorContentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [avatarEditorDialogHeight, setAvatarEditorDialogHeight] =
|
||||
React.useState<number | null>(null);
|
||||
const animateEmojiAvatarChange = React.useCallback(() => {
|
||||
setAvatarSquishKey((key) => key + 1);
|
||||
}, []);
|
||||
const updateAvatarUrl = React.useCallback(
|
||||
(nextAvatarUrl: string) => {
|
||||
if (
|
||||
!previewMode ||
|
||||
nextAvatarUrl.length === 0 ||
|
||||
nextAvatarUrl.startsWith("blob:") ||
|
||||
nextAvatarUrl.startsWith("data:image/")
|
||||
) {
|
||||
onAvatarUrlChange(nextAvatarUrl);
|
||||
return;
|
||||
}
|
||||
onAvatarUrlChange(PREVIEW_URL_AVATAR);
|
||||
},
|
||||
[onAvatarUrlChange, previewMode],
|
||||
);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isAvatarEditorOpen) nameInputRef.current?.focus();
|
||||
}, [isAvatarEditorOpen]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isAvatarEditorOpen) {
|
||||
setAvatarEditorDialogHeight(null);
|
||||
return;
|
||||
}
|
||||
const content = avatarEditorContentRef.current;
|
||||
if (!content) return;
|
||||
|
||||
const updateHeight = () => {
|
||||
setAvatarEditorDialogHeight(content.getBoundingClientRect().height + 64);
|
||||
};
|
||||
updateHeight();
|
||||
const resizeObserver = new ResizeObserver(updateHeight);
|
||||
resizeObserver.observe(content);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [isAvatarEditorOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 w-full flex-1 flex-col transition-[filter,opacity] duration-200 ease-out",
|
||||
isAvatarEditorOpen && "pointer-events-none opacity-45 blur-[3px]",
|
||||
)}
|
||||
data-testid="community-profile-main"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<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, reactions,
|
||||
and agent handoffs.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col items-center justify-center pt-8">
|
||||
<AvatarCircle
|
||||
avatarUrl={avatarUrl}
|
||||
onClick={() => setIsAvatarEditorOpen(true)}
|
||||
previewName={displayName.trim() || "Your profile"}
|
||||
triggerRef={avatarTriggerRef}
|
||||
/>
|
||||
<label
|
||||
className="mt-7 block w-full max-w-[412px] text-left"
|
||||
htmlFor="community-display-name"
|
||||
>
|
||||
<span className="mb-2 block pl-4 text-sm text-foreground">
|
||||
Your username
|
||||
</span>
|
||||
<Input
|
||||
aria-label="Community username"
|
||||
autoCapitalize="none"
|
||||
autoComplete="username"
|
||||
autoCorrect="off"
|
||||
className="h-14 rounded-2xl border-[color:rgb(var(--buzz-onboarding-avatar-control-fg)_/_0.28)] bg-[rgb(var(--buzz-onboarding-avatar-dialog-bg)/0.95)] px-5 text-sm shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[color:rgb(var(--buzz-onboarding-avatar-control-fg)_/_0.5)] md:text-sm"
|
||||
data-testid="community-profile-name-key"
|
||||
disabled={isPending || isUploadingAvatar}
|
||||
id="community-display-name"
|
||||
onChange={(event) => onDisplayNameChange(event.target.value)}
|
||||
placeholder="Enter your username here"
|
||||
ref={nameInputRef}
|
||||
spellCheck={false}
|
||||
type="text"
|
||||
value={displayName}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{errorMessage ? (
|
||||
<p className="mt-4 text-sm text-destructive">{errorMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<OnboardingFooter
|
||||
className={cn(
|
||||
"transition-[filter,opacity] duration-200 ease-out",
|
||||
isAvatarEditorOpen && "pointer-events-none opacity-45 blur-[3px]",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className={`${ONBOARDING_PRIMARY_CTA_CLASS} w-20`}
|
||||
data-testid="community-profile-next"
|
||||
disabled={!displayName.trim() || isPending || isUploadingAvatar}
|
||||
onClick={onNext}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</OnboardingFooter>
|
||||
<Dialog
|
||||
onOpenChange={(open) => setIsAvatarEditorOpen(open)}
|
||||
open={isAvatarEditorOpen}
|
||||
>
|
||||
<DialogContent
|
||||
className="buzz-onboarding-neutral-theme w-[min(calc(100vw-2rem),920px)] max-w-[920px] gap-0 overflow-hidden rounded-[18px] bg-[rgb(var(--buzz-onboarding-avatar-dialog-bg))] px-8 pb-6 pt-10 text-sm text-foreground shadow-[0_28px_90px_rgb(var(--buzz-onboarding-avatar-dialog-shadow)_/_0.28),0_8px_28px_rgb(var(--buzz-onboarding-avatar-dialog-shadow)_/_0.18)] transition-[height] duration-[250ms] ease-out"
|
||||
closeButtonClassName="right-6 top-6 h-10 w-10 rounded-full bg-[rgb(var(--buzz-onboarding-avatar-action-bg))] text-[rgb(var(--buzz-onboarding-avatar-action-fg))] hover:bg-[rgb(var(--buzz-onboarding-avatar-action-bg)/0.9)] hover:text-[rgb(var(--buzz-onboarding-avatar-action-fg))]"
|
||||
data-system-color-scheme="light"
|
||||
data-testid="community-avatar-editor-key-frame"
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
avatarTriggerRef.current?.focus();
|
||||
}}
|
||||
overlayVariant="transparent"
|
||||
style={
|
||||
avatarEditorDialogHeight === null
|
||||
? undefined
|
||||
: { height: avatarEditorDialogHeight }
|
||||
}
|
||||
>
|
||||
<DialogTitle className="sr-only">Edit your avatar</DialogTitle>
|
||||
<div
|
||||
className="grid items-center gap-8 md:grid-cols-[240px_minmax(0,1fr)]"
|
||||
ref={avatarEditorContentRef}
|
||||
>
|
||||
<div
|
||||
className="flex min-h-[320px] flex-col items-center justify-center gap-3 px-6 py-8"
|
||||
data-testid="community-avatar-live-preview-panel"
|
||||
>
|
||||
<div className="relative h-48 w-48">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10"
|
||||
data-testid="community-avatar-animated-preview-slot"
|
||||
ref={setAnimatedPreviewEl}
|
||||
/>
|
||||
{isAnimatedPreviewActive ? null : localAvatarPreviewUrl ? (
|
||||
<ProfileAvatar
|
||||
avatarUrl={localAvatarPreviewUrl}
|
||||
className="h-full w-full rounded-full text-5xl"
|
||||
label={displayName.trim() || "Your profile"}
|
||||
testId="community-avatar-live-preview"
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const emojiAvatar = parseEmojiAvatarDataUrl(avatarUrl);
|
||||
return emojiAvatar ? (
|
||||
<div
|
||||
aria-label={`${displayName.trim() || "Your profile"} avatar`}
|
||||
className="flex h-full w-full items-center justify-center overflow-hidden rounded-full text-6xl shadow-xs"
|
||||
data-testid="community-avatar-live-preview"
|
||||
role="img"
|
||||
style={{ backgroundColor: emojiAvatar.color }}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
avatarSquishKey > 0 && "buzz-avatar-squish",
|
||||
)}
|
||||
data-testid="community-avatar-live-preview-emoji"
|
||||
key={avatarSquishKey}
|
||||
>
|
||||
{emojiAvatar.emoji}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<ProfileAvatar
|
||||
avatarUrl={avatarUrl || null}
|
||||
className="h-full w-full rounded-full text-5xl"
|
||||
label={displayName.trim() || "Your profile"}
|
||||
testId="community-avatar-live-preview"
|
||||
/>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
{animatedPreviewCaption ? (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{animatedPreviewCaption}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<ProfileAvatarEditor
|
||||
animatedPreviewContainer={animatedPreviewEl}
|
||||
avatarUrl={avatarUrl}
|
||||
disabled={isPending}
|
||||
donePending={isUploadingAvatar}
|
||||
emojiPickerTheme="auto"
|
||||
emojiPickerThemeVars={NEUTRAL_EMOJI_PICKER_THEME_VARS}
|
||||
onAnimatedPreviewActiveChange={setIsAnimatedPreviewActive}
|
||||
onAnimatedPreviewCaptionChange={setAnimatedPreviewCaption}
|
||||
onDone={() => setIsAvatarEditorOpen(false)}
|
||||
onEmojiAvatarChange={animateEmojiAvatarChange}
|
||||
onLocalPreviewChange={setLocalAvatarPreviewUrl}
|
||||
onUploadingChange={onUploadingChange}
|
||||
onUrlChange={updateAvatarUrl}
|
||||
presentation="onboarding-modal"
|
||||
previewName={displayName.trim() || "Your profile"}
|
||||
processAnimatedAvatar={
|
||||
previewMode ? processPreviewAnimatedAvatar : undefined
|
||||
}
|
||||
processImage={previewMode ? processPreviewImage : undefined}
|
||||
testIdPrefix="community-avatar"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,8 @@ type InviteRedeemFormProps = {
|
||||
onConnect?: (relayWsUrl: string) => void;
|
||||
onRedeem: (relayWsUrl: string, code: string, policyReceipt?: string) => void;
|
||||
placeholder?: string;
|
||||
/** Render the production form without discovery or relay requests. */
|
||||
previewMode?: boolean;
|
||||
variant?: "add-community" | "default" | "onboarding-spotlight";
|
||||
};
|
||||
|
||||
@@ -69,6 +71,7 @@ export function InviteRedeemForm({
|
||||
onConnect,
|
||||
onRedeem,
|
||||
placeholder,
|
||||
previewMode = false,
|
||||
variant = "default",
|
||||
}: InviteRedeemFormProps) {
|
||||
const formId = React.useId();
|
||||
@@ -104,6 +107,7 @@ export function InviteRedeemForm({
|
||||
const needsRelayField = isBareCode && defaultRelayUrl !== undefined;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (previewMode) return;
|
||||
const relayWsUrl = normalizedRelayUrl
|
||||
? normalizedRelayUrl
|
||||
: parsedInvite && hasInviteRelay(parsedInvite)
|
||||
@@ -135,9 +139,10 @@ export function InviteRedeemForm({
|
||||
cancelled = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [bareCodeRelayUrl, normalizedRelayUrl, parsedInvite]);
|
||||
}, [bareCodeRelayUrl, normalizedRelayUrl, parsedInvite, previewMode]);
|
||||
|
||||
const canSubmit =
|
||||
previewMode ||
|
||||
(parsedInvite !== null &&
|
||||
("relayWsUrl" in parsedInvite ||
|
||||
(isBareCode && bareCodeRelayUrl.trim().length > 0))) ||
|
||||
@@ -150,6 +155,10 @@ export function InviteRedeemForm({
|
||||
const handleSubmit = React.useCallback(
|
||||
async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (previewMode) {
|
||||
onConnect?.(normalizedRelayUrl ?? "wss://preview.invalid");
|
||||
return;
|
||||
}
|
||||
if (normalizedRelayUrl) {
|
||||
setPolicyError(null);
|
||||
setIsLoadingPolicy(true);
|
||||
@@ -257,6 +266,7 @@ export function InviteRedeemForm({
|
||||
onRedeem,
|
||||
parsedInvite,
|
||||
policyTarget,
|
||||
previewMode,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Check, RotateCcw, ShieldCheck } from "lucide-react";
|
||||
import { Check } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { JoinPolicy } from "@/shared/api/invites";
|
||||
import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
|
||||
import {
|
||||
ONBOARDING_PREVIEW_JOURNEYS,
|
||||
type OnboardingPreviewPage,
|
||||
type OnboardingPreviewVariant,
|
||||
} from "../onboardingPreview";
|
||||
import { BackupStep } from "./BackupStep";
|
||||
import { JoinPolicyNotice } from "./JoinPolicyNotice";
|
||||
import { LandingBees } from "./LandingBees";
|
||||
@@ -21,6 +27,7 @@ import {
|
||||
BackupPasswordPreview,
|
||||
CommunityChoicePreview,
|
||||
CommunityConnectingPreview,
|
||||
CommunityCreatePreview,
|
||||
CommunityEntryPreview,
|
||||
CommunityHomePreview,
|
||||
CommunityProfilePreview,
|
||||
@@ -30,28 +37,10 @@ import {
|
||||
WelcomeChannelPreview,
|
||||
} from "./OnboardingPreviewJourney";
|
||||
import { OnboardingPreviewStep } from "./OnboardingPreviewShell";
|
||||
import { OnboardingPreviewControls } from "./OnboardingPreviewControls";
|
||||
import { OnboardingSlideTransition } from "./OnboardingSlideTransition";
|
||||
import { SetupStepPreview } from "./SetupStepPreview";
|
||||
|
||||
type PreviewPage =
|
||||
| "landing"
|
||||
| "email"
|
||||
| "identity-key"
|
||||
| "sign-in"
|
||||
| "sign-in-key"
|
||||
| "forgot-password"
|
||||
| "backup-options"
|
||||
| "backup-password"
|
||||
| "setup"
|
||||
| "config"
|
||||
| "community-choice"
|
||||
| "community-entry"
|
||||
| "community-connecting"
|
||||
| "community-profile"
|
||||
| "starter-team"
|
||||
| "welcome-channel"
|
||||
| "community-home";
|
||||
|
||||
const EMAIL_SIGNUP_POLICY: JoinPolicy = {
|
||||
ageAttestationRequired: true,
|
||||
privacyMarkdown: "available",
|
||||
@@ -59,35 +48,11 @@ const EMAIL_SIGNUP_POLICY: JoinPolicy = {
|
||||
version: "email-signup-workshop",
|
||||
};
|
||||
|
||||
function PreviewBanner({ onRestart }: { onRestart: () => void }) {
|
||||
return (
|
||||
<aside
|
||||
className="fixed right-4 top-4 z-[100] flex max-w-sm items-center gap-3 rounded-2xl border border-foreground/15 bg-background/95 px-4 py-3 text-left shadow-lg backdrop-blur"
|
||||
data-testid="onboarding-preview-banner"
|
||||
>
|
||||
<ShieldCheck className="h-5 w-5 shrink-0 text-primary" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">Workshop preview</p>
|
||||
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||||
Changes in this workshop aren’t saved.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Restart onboarding preview"
|
||||
className="h-8 w-8 shrink-0"
|
||||
data-testid="onboarding-preview-restart"
|
||||
onClick={onRestart}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Landing({ onNavigate }: { onNavigate: (page: PreviewPage) => void }) {
|
||||
function Landing({
|
||||
onNavigate,
|
||||
}: {
|
||||
onNavigate: (page: OnboardingPreviewPage) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="buzz-onboarding-neutral-theme buzz-startup-shell buzz-onboarding-welcome flex max-h-dvh items-start justify-center overflow-hidden px-4 py-8 text-foreground"
|
||||
@@ -431,11 +396,13 @@ function EmailSignup({
|
||||
onBack,
|
||||
onContinue,
|
||||
onPasswordChange,
|
||||
total,
|
||||
}: {
|
||||
password: string;
|
||||
onBack: () => void;
|
||||
onContinue: (email: string) => void;
|
||||
onPasswordChange: (password: string) => void;
|
||||
total: number;
|
||||
}) {
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [confirmPassword, setConfirmPassword] = React.useState("");
|
||||
@@ -444,15 +411,13 @@ function EmailSignup({
|
||||
const [openPolicyDocument, setOpenPolicyDocument] = React.useState<
|
||||
"terms" | "privacy" | null
|
||||
>(null);
|
||||
const canContinue =
|
||||
email.trim().includes("@") &&
|
||||
password.length > 0 &&
|
||||
confirmPassword === password &&
|
||||
ageConfirmed &&
|
||||
agreementConfirmed;
|
||||
|
||||
return (
|
||||
<OnboardingPreviewStep onBack={onBack} testId="onboarding-preview-email">
|
||||
<OnboardingPreviewStep
|
||||
onBack={onBack}
|
||||
testId="onboarding-preview-email"
|
||||
total={total}
|
||||
>
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-0 w-full max-w-[500px] flex-col items-center"
|
||||
transitionKey="preview-email"
|
||||
@@ -467,7 +432,7 @@ function EmailSignup({
|
||||
className="mt-8 w-full space-y-5 text-left"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (canContinue) onContinue(email);
|
||||
onContinue(email);
|
||||
}}
|
||||
>
|
||||
<PreviewCredentialsFields
|
||||
@@ -497,7 +462,6 @@ function EmailSignup({
|
||||
<Button
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="onboarding-preview-email-continue"
|
||||
disabled={!canContinue}
|
||||
onClick={() => onContinue(email)}
|
||||
type="button"
|
||||
>
|
||||
@@ -562,6 +526,7 @@ function EmailSignIn({
|
||||
onEmailChange,
|
||||
onForgotPassword,
|
||||
onSignInWithKey,
|
||||
total,
|
||||
}: {
|
||||
email: string;
|
||||
onBack: () => void;
|
||||
@@ -569,12 +534,17 @@ function EmailSignIn({
|
||||
onEmailChange: (email: string) => void;
|
||||
onForgotPassword: () => void;
|
||||
onSignInWithKey: () => void;
|
||||
total: number;
|
||||
}) {
|
||||
const [password, setPassword] = React.useState("");
|
||||
const canContinue = email.trim().length > 0 && password.length > 0;
|
||||
|
||||
return (
|
||||
<OnboardingPreviewStep onBack={onBack} testId="onboarding-preview-sign-in">
|
||||
<OnboardingPreviewStep
|
||||
onBack={onBack}
|
||||
testId="onboarding-preview-sign-in"
|
||||
total={total}
|
||||
>
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-0 w-full max-w-[500px] flex-col items-center"
|
||||
transitionKey="preview-sign-in"
|
||||
@@ -646,9 +616,11 @@ function EmailSignIn({
|
||||
function PasswordReset({
|
||||
initialEmail,
|
||||
onBack,
|
||||
total,
|
||||
}: {
|
||||
initialEmail: string;
|
||||
onBack: () => void;
|
||||
total: number;
|
||||
}) {
|
||||
const [email, setEmail] = React.useState(initialEmail);
|
||||
const [submitted, setSubmitted] = React.useState(false);
|
||||
@@ -658,6 +630,7 @@ function PasswordReset({
|
||||
<OnboardingPreviewStep
|
||||
onBack={onBack}
|
||||
testId="onboarding-preview-password-reset-sent"
|
||||
total={total}
|
||||
>
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-0 w-full max-w-[500px] flex-col items-center"
|
||||
@@ -678,6 +651,7 @@ function PasswordReset({
|
||||
<OnboardingPreviewStep
|
||||
onBack={onBack}
|
||||
testId="onboarding-preview-password-reset"
|
||||
total={total}
|
||||
>
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-0 w-full max-w-[500px] flex-col items-center"
|
||||
@@ -731,14 +705,17 @@ function PasswordReset({
|
||||
function KeySignIn({
|
||||
onBack,
|
||||
onContinue,
|
||||
total,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
onContinue: () => void;
|
||||
total: number;
|
||||
}) {
|
||||
return (
|
||||
<OnboardingPreviewStep
|
||||
onBack={onBack}
|
||||
testId="onboarding-preview-sign-in-key"
|
||||
total={total}
|
||||
>
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-[calc(100dvh-13.25rem)] w-full max-w-[560px] flex-col items-center justify-center"
|
||||
@@ -767,17 +744,19 @@ function KeySignIn({
|
||||
|
||||
export function OnboardingPreviewApp() {
|
||||
const [run, setRun] = React.useState(0);
|
||||
const [page, setPage] = React.useState<PreviewPage>("landing");
|
||||
const [variant, setVariant] = React.useState<OnboardingPreviewVariant>("v3");
|
||||
const [page, setPage] = React.useState<OnboardingPreviewPage>("landing");
|
||||
const [signInEmail, setSignInEmail] = React.useState("");
|
||||
const [signupPassword, setSignupPassword] = React.useState("");
|
||||
const [passwordResetEmail, setPasswordResetEmail] = React.useState("");
|
||||
const [setupBackPage, setSetupBackPage] =
|
||||
React.useState<PreviewPage>("landing");
|
||||
React.useState<OnboardingPreviewPage>("landing");
|
||||
const [communityRoute, setCommunityRoute] =
|
||||
React.useState<CommunityPreviewRoute>("join");
|
||||
const [communityName, setCommunityName] = React.useState("Block Community");
|
||||
const [displayName, setDisplayName] = React.useState("");
|
||||
const [hasAvatar, setHasAvatar] = React.useState(false);
|
||||
const [avatarUrl, setAvatarUrl] = React.useState("");
|
||||
const journey = ONBOARDING_PREVIEW_JOURNEYS[variant];
|
||||
|
||||
React.useEffect(() => {
|
||||
if (page === "landing") {
|
||||
@@ -788,6 +767,7 @@ export function OnboardingPreviewApp() {
|
||||
}, [page]);
|
||||
|
||||
const restart = React.useCallback(() => {
|
||||
resetAvatarPresentations();
|
||||
setPage("landing");
|
||||
setPasswordResetEmail("");
|
||||
setRun((current) => current + 1);
|
||||
@@ -796,9 +776,25 @@ export function OnboardingPreviewApp() {
|
||||
setCommunityRoute("join");
|
||||
setCommunityName("Block Community");
|
||||
setDisplayName("");
|
||||
setHasAvatar(false);
|
||||
setAvatarUrl("");
|
||||
}, []);
|
||||
|
||||
const changeVariant = React.useCallback(
|
||||
(nextVariant: OnboardingPreviewVariant) => {
|
||||
setVariant(nextVariant);
|
||||
restart();
|
||||
},
|
||||
[restart],
|
||||
);
|
||||
|
||||
const continueFromAccount = React.useCallback(
|
||||
(backPage: OnboardingPreviewPage) => {
|
||||
setSetupBackPage(backPage);
|
||||
setPage(journey.afterAccount);
|
||||
},
|
||||
[journey.afterAccount],
|
||||
);
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (page === "landing") {
|
||||
content = <Landing key={run} onNavigate={setPage} />;
|
||||
@@ -807,11 +803,9 @@ export function OnboardingPreviewApp() {
|
||||
<EmailSignup
|
||||
password={signupPassword}
|
||||
onBack={() => setPage("landing")}
|
||||
onContinue={() => {
|
||||
setSetupBackPage("email");
|
||||
setPage("setup");
|
||||
}}
|
||||
onContinue={() => continueFromAccount("email")}
|
||||
onPasswordChange={setSignupPassword}
|
||||
total={journey.totalSteps}
|
||||
/>
|
||||
);
|
||||
} else if (page === "identity-key") {
|
||||
@@ -819,15 +813,16 @@ export function OnboardingPreviewApp() {
|
||||
<OnboardingPreviewStep
|
||||
onBack={() => setPage("landing")}
|
||||
testId="onboarding-preview-identity-key"
|
||||
total={journey.totalSteps}
|
||||
>
|
||||
<BackupStep
|
||||
direction="forward"
|
||||
onNext={() => {
|
||||
setSetupBackPage("identity-key");
|
||||
setPage("setup");
|
||||
}}
|
||||
onNext={() => continueFromAccount("identity-key")}
|
||||
onOpenPasswordBackup={() => setPage("backup-password")}
|
||||
onShowOptions={() => setPage("backup-options")}
|
||||
onShowOptions={() => {
|
||||
setSetupBackPage("identity-key");
|
||||
setPage("backup-options");
|
||||
}}
|
||||
optionsExpanded={false}
|
||||
previewMode
|
||||
returningFromSecurity={false}
|
||||
@@ -839,10 +834,11 @@ export function OnboardingPreviewApp() {
|
||||
<OnboardingPreviewStep
|
||||
onBack={() => setPage("identity-key")}
|
||||
testId="onboarding-preview-backup-options"
|
||||
total={journey.totalSteps}
|
||||
>
|
||||
<BackupStep
|
||||
direction="forward"
|
||||
onNext={() => setPage("setup")}
|
||||
onNext={() => setPage(journey.afterAccount)}
|
||||
onOpenPasswordBackup={() => setPage("backup-password")}
|
||||
onShowOptions={() => setPage("backup-options")}
|
||||
optionsExpanded
|
||||
@@ -863,16 +859,14 @@ export function OnboardingPreviewApp() {
|
||||
<EmailSignIn
|
||||
email={signInEmail}
|
||||
onBack={() => setPage("landing")}
|
||||
onContinue={() => {
|
||||
setSetupBackPage("sign-in");
|
||||
setPage("setup");
|
||||
}}
|
||||
onContinue={() => continueFromAccount("sign-in")}
|
||||
onEmailChange={setSignInEmail}
|
||||
onForgotPassword={() => {
|
||||
setPasswordResetEmail(signInEmail);
|
||||
setPage("forgot-password");
|
||||
}}
|
||||
onSignInWithKey={() => setPage("sign-in-key")}
|
||||
total={journey.totalSteps}
|
||||
/>
|
||||
);
|
||||
} else if (page === "forgot-password") {
|
||||
@@ -880,16 +874,15 @@ export function OnboardingPreviewApp() {
|
||||
<PasswordReset
|
||||
initialEmail={passwordResetEmail}
|
||||
onBack={() => setPage("sign-in")}
|
||||
total={journey.totalSteps}
|
||||
/>
|
||||
);
|
||||
} else if (page === "sign-in-key") {
|
||||
content = (
|
||||
<KeySignIn
|
||||
onBack={() => setPage("sign-in")}
|
||||
onContinue={() => {
|
||||
setSetupBackPage("sign-in-key");
|
||||
setPage("setup");
|
||||
}}
|
||||
onContinue={() => continueFromAccount("sign-in-key")}
|
||||
total={journey.totalSteps}
|
||||
/>
|
||||
);
|
||||
} else if (page === "setup") {
|
||||
@@ -909,24 +902,40 @@ export function OnboardingPreviewApp() {
|
||||
} else if (page === "community-choice") {
|
||||
content = (
|
||||
<CommunityChoicePreview
|
||||
onBack={() => setPage("config")}
|
||||
current={journey.communityStep}
|
||||
includeExistingCommunity={journey.includeExistingCommunity}
|
||||
onBack={() => setPage(journey.communityChoiceBack ?? setupBackPage)}
|
||||
onChoose={(route) => {
|
||||
setCommunityRoute(route);
|
||||
setPage("community-entry");
|
||||
}}
|
||||
total={journey.totalSteps}
|
||||
/>
|
||||
);
|
||||
} else if (page === "community-entry") {
|
||||
content = (
|
||||
<CommunityEntryPreview
|
||||
onBack={() => setPage("community-choice")}
|
||||
onContinue={(name) => {
|
||||
setCommunityName(name);
|
||||
setPage("community-connecting");
|
||||
}}
|
||||
route={communityRoute}
|
||||
/>
|
||||
);
|
||||
content =
|
||||
communityRoute === "create" ? (
|
||||
<CommunityCreatePreview
|
||||
current={journey.communityStep}
|
||||
onBack={() => setPage("community-choice")}
|
||||
onContinue={(name) => {
|
||||
setCommunityName(name);
|
||||
setPage(journey.afterCommunityEntry);
|
||||
}}
|
||||
total={journey.totalSteps}
|
||||
/>
|
||||
) : (
|
||||
<CommunityEntryPreview
|
||||
current={journey.communityStep}
|
||||
onBack={() => setPage("community-choice")}
|
||||
onContinue={(name) => {
|
||||
setCommunityName(name);
|
||||
setPage(journey.afterCommunityEntry);
|
||||
}}
|
||||
route={communityRoute}
|
||||
total={journey.totalSteps}
|
||||
/>
|
||||
);
|
||||
} else if (page === "community-connecting") {
|
||||
content = (
|
||||
<CommunityConnectingPreview
|
||||
@@ -938,12 +947,14 @@ export function OnboardingPreviewApp() {
|
||||
} else if (page === "community-profile") {
|
||||
content = (
|
||||
<CommunityProfilePreview
|
||||
avatarUrl={avatarUrl}
|
||||
current={journey.profileStep}
|
||||
displayName={displayName}
|
||||
hasAvatar={hasAvatar}
|
||||
onAvatarChange={() => setHasAvatar((current) => !current)}
|
||||
onAvatarUrlChange={setAvatarUrl}
|
||||
onBack={() => setPage("community-entry")}
|
||||
onDisplayNameChange={setDisplayName}
|
||||
onNext={() => setPage("starter-team")}
|
||||
onNext={() => setPage(journey.afterProfile)}
|
||||
total={journey.totalSteps}
|
||||
/>
|
||||
);
|
||||
} else if (page === "starter-team") {
|
||||
@@ -964,15 +975,20 @@ export function OnboardingPreviewApp() {
|
||||
} else {
|
||||
content = (
|
||||
<CommunityHomePreview
|
||||
avatarUrl={avatarUrl}
|
||||
communityName={communityName}
|
||||
onBack={() => setPage("welcome-channel")}
|
||||
displayName={displayName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PreviewBanner onRestart={restart} />
|
||||
<OnboardingPreviewControls
|
||||
onRestart={restart}
|
||||
onVariantChange={changeVariant}
|
||||
variant={variant}
|
||||
/>
|
||||
{content}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { RotateCcw, ShieldCheck } from "lucide-react";
|
||||
|
||||
import type { OnboardingPreviewVariant } from "../onboardingPreview";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { SegmentedControl } from "@/shared/ui/segmented-control";
|
||||
|
||||
const PREVIEW_VARIANT_OPTIONS = [
|
||||
{ label: "Onboarding today", value: "today" },
|
||||
{ label: "V3", value: "v3" },
|
||||
] as const;
|
||||
|
||||
export function OnboardingPreviewControls({
|
||||
onRestart,
|
||||
onVariantChange,
|
||||
variant,
|
||||
}: {
|
||||
onRestart: () => void;
|
||||
onVariantChange: (variant: OnboardingPreviewVariant) => void;
|
||||
variant: OnboardingPreviewVariant;
|
||||
}) {
|
||||
return (
|
||||
<aside
|
||||
className="fixed right-4 top-4 z-[100] flex w-[320px] max-w-[calc(100vw-2rem)] flex-col gap-3 rounded-2xl border border-foreground/15 bg-background/95 px-4 py-3 text-left shadow-lg backdrop-blur"
|
||||
data-testid="onboarding-preview-banner"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="h-5 w-5 shrink-0 text-primary" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Workshop preview
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||||
Changes in this workshop aren’t saved.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Restart onboarding preview"
|
||||
className="h-8 w-8 shrink-0"
|
||||
data-testid="onboarding-preview-restart"
|
||||
onClick={onRestart}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
className="w-full"
|
||||
legend="Onboarding version"
|
||||
onValueChange={onVariantChange}
|
||||
optionTestIdPrefix="onboarding-preview-variant"
|
||||
options={PREVIEW_VARIANT_OPTIONS}
|
||||
size="wide"
|
||||
testId="onboarding-preview-variant"
|
||||
value={variant}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Check, Plus, Users } from "lucide-react";
|
||||
import { Check, Copy, Users } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { HostedCommunityOnboarding } from "@/features/communities/ui/HostedCommunityOnboarding";
|
||||
import { pubkeyToNpub } from "@/shared/lib/nostrUtils";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { InviteRedeemForm } from "./InviteRedeemForm";
|
||||
import { CommunityProfileStage } from "./CommunityProfileStage";
|
||||
import {
|
||||
ONBOARDING_PRIMARY_CTA_CLASS,
|
||||
ONBOARDING_SECONDARY_CTA_CLASS,
|
||||
@@ -11,11 +15,15 @@ import {
|
||||
import { OnboardingFooter } from "./OnboardingFooter";
|
||||
import { OnboardingPreviewStep } from "./OnboardingPreviewShell";
|
||||
import { OnboardingSlideTransition } from "./OnboardingSlideTransition";
|
||||
import { WelcomeChannelAppPreview } from "./WelcomeChannelAppPreview";
|
||||
|
||||
const FIELD_CLASS =
|
||||
"h-12 rounded-2xl border-foreground/15 bg-white px-4 text-sm shadow-none";
|
||||
const COMMUNITY_OPTION_CLASS =
|
||||
"w-full max-w-[320px] items-center px-6 py-4 text-center text-sm font-normal leading-6 text-foreground [--buzz-card-textured-min-height:88px] transition-[filter] duration-150 ease-out hover:brightness-[0.98] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/35";
|
||||
const PREVIEW_PUBLIC_ID = pubkeyToNpub(
|
||||
"e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34",
|
||||
);
|
||||
|
||||
function LabeledField({
|
||||
id,
|
||||
@@ -313,17 +321,32 @@ export function DefaultConfigPreview({
|
||||
export type CommunityPreviewRoute = "join" | "create" | "existing";
|
||||
|
||||
export function CommunityChoicePreview({
|
||||
current = 5,
|
||||
includeExistingCommunity = true,
|
||||
onBack,
|
||||
onChoose,
|
||||
total,
|
||||
}: {
|
||||
current?: number;
|
||||
includeExistingCommunity?: boolean;
|
||||
onBack: () => void;
|
||||
onChoose: (route: CommunityPreviewRoute) => void;
|
||||
total?: number;
|
||||
}) {
|
||||
const routes: ReadonlyArray<readonly [CommunityPreviewRoute, string]> = [
|
||||
["join", "Join a community"],
|
||||
["create", "Create a community"],
|
||||
...(includeExistingCommunity
|
||||
? ([["existing", "I already have a community"]] as const)
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<OnboardingPreviewStep
|
||||
current={5}
|
||||
current={current}
|
||||
onBack={onBack}
|
||||
testId="onboarding-preview-community-choice"
|
||||
total={total}
|
||||
>
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-full w-full flex-col items-center"
|
||||
@@ -332,18 +355,13 @@ export function CommunityChoicePreview({
|
||||
<div className="w-full max-w-[760px]">
|
||||
<h1 className="text-title font-normal">Join or create a community</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-foreground/80">
|
||||
Join with an invite, create your own community, or reconnect one you
|
||||
already have.
|
||||
{includeExistingCommunity
|
||||
? "Join with an invite, create your own community, or reconnect one you already have."
|
||||
: "Join with an invite or create your own community."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-center justify-center gap-8 py-10">
|
||||
{(
|
||||
[
|
||||
["join", "Join a community"],
|
||||
["create", "Create a community"],
|
||||
["existing", "I already have a community"],
|
||||
] as const
|
||||
).map(([route, label]) => (
|
||||
<div className="flex w-full flex-1 translate-y-16 flex-col items-center justify-center gap-20 py-8">
|
||||
{routes.map(([route, label]) => (
|
||||
<Card
|
||||
asChild
|
||||
className={COMMUNITY_OPTION_CLASS}
|
||||
@@ -366,69 +384,119 @@ export function CommunityChoicePreview({
|
||||
}
|
||||
|
||||
export function CommunityEntryPreview({
|
||||
current = 5,
|
||||
onBack,
|
||||
onContinue,
|
||||
route,
|
||||
total,
|
||||
}: {
|
||||
current?: number;
|
||||
onBack: () => void;
|
||||
onContinue: (communityName: string) => void;
|
||||
route: CommunityPreviewRoute;
|
||||
route: Exclude<CommunityPreviewRoute, "create">;
|
||||
total?: number;
|
||||
}) {
|
||||
const [value, setValue] = React.useState("");
|
||||
const isCreate = route === "create";
|
||||
const heading = isCreate
|
||||
? "Create a community"
|
||||
: route === "existing"
|
||||
? "Reconnect to your community"
|
||||
: "Join a community";
|
||||
const [copiedPublicId, setCopiedPublicId] = React.useState(false);
|
||||
const heading =
|
||||
route === "existing" ? "Reconnect to your community" : "Join a community";
|
||||
const continueToCommunity = () => onContinue("Block Community");
|
||||
|
||||
return (
|
||||
<OnboardingPreviewStep
|
||||
current={5}
|
||||
current={current}
|
||||
onBack={onBack}
|
||||
testId="onboarding-preview-community-entry"
|
||||
total={total}
|
||||
>
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-full w-full max-w-[620px] flex-col items-center"
|
||||
className="flex min-h-[calc(100dvh-15.625rem)] w-full flex-col items-center text-center"
|
||||
transitionKey={`preview-community-${route}`}
|
||||
>
|
||||
<h1 className="text-title font-normal">{heading}</h1>
|
||||
<p className="mt-3 max-w-[500px] text-sm leading-6 text-foreground/80">
|
||||
{isCreate
|
||||
? "Choose a name for the space where your people and agents will work together."
|
||||
: route === "existing"
|
||||
<div className="w-full max-w-[620px]">
|
||||
<h1 className="text-title font-normal">{heading}</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-foreground/80">
|
||||
{route === "existing"
|
||||
? "Enter the community URL or an invite link. Your role will be restored when you connect."
|
||||
: "Enter the invite link or community URL you received."}
|
||||
</p>
|
||||
<form
|
||||
className="flex w-full flex-1 flex-col items-center justify-center gap-6 py-10"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onContinue(
|
||||
isCreate ? value.trim() || "Your community" : "Block Community",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<LabeledField
|
||||
id="onboarding-preview-community-value"
|
||||
label={isCreate ? "Community name" : "Invite or community URL"}
|
||||
onChange={setValue}
|
||||
placeholder={
|
||||
isCreate
|
||||
? "Enter a community name"
|
||||
: "Paste an invite, code, or URL"
|
||||
}
|
||||
type={isCreate ? "text" : "url"}
|
||||
value={value}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-center justify-center gap-16">
|
||||
<InviteRedeemForm
|
||||
error={null}
|
||||
isRedeeming={false}
|
||||
onCancel={onBack}
|
||||
onConnect={continueToCommunity}
|
||||
onRedeem={continueToCommunity}
|
||||
placeholder="Invite link or community URL"
|
||||
previewMode
|
||||
variant="onboarding-spotlight"
|
||||
/>
|
||||
<Button
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="onboarding-preview-community-continue"
|
||||
type="submit"
|
||||
>
|
||||
{isCreate ? "Create community" : "Continue"}
|
||||
</Button>
|
||||
</form>
|
||||
{route === "join" ? (
|
||||
<div className="w-full max-w-[560px] text-left">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Joining a private community?
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-foreground/75">
|
||||
Some communities need the owner to add you before you can join.
|
||||
Copy your public ID and send it to the community owner.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-3 rounded-xl border border-foreground/10 bg-background/35 px-4 py-3">
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-xs text-foreground/80">
|
||||
{PREVIEW_PUBLIC_ID}
|
||||
</code>
|
||||
<Button
|
||||
aria-label="Copy public ID"
|
||||
className="h-9 shrink-0 rounded-full px-3"
|
||||
onClick={() => {
|
||||
setCopiedPublicId(true);
|
||||
window.setTimeout(() => setCopiedPublicId(false), 1500);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{copiedPublicId ? (
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
<span>{copiedPublicId ? "Copied" : "Copy"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</OnboardingSlideTransition>
|
||||
</OnboardingPreviewStep>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommunityCreatePreview({
|
||||
current = 5,
|
||||
onBack,
|
||||
onContinue,
|
||||
total,
|
||||
}: {
|
||||
current?: number;
|
||||
onBack: () => void;
|
||||
onContinue: (communityName: string) => void;
|
||||
total?: number;
|
||||
}) {
|
||||
return (
|
||||
<OnboardingPreviewStep
|
||||
current={current}
|
||||
testId="onboarding-preview-community-entry"
|
||||
total={total}
|
||||
>
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-full w-full flex-col items-center"
|
||||
transitionKey="preview-community-create"
|
||||
>
|
||||
<HostedCommunityOnboarding
|
||||
onBack={onBack}
|
||||
onPreviewContinue={onContinue}
|
||||
previewMode
|
||||
/>
|
||||
</OnboardingSlideTransition>
|
||||
</OnboardingPreviewStep>
|
||||
);
|
||||
@@ -471,80 +539,48 @@ export function CommunityConnectingPreview({
|
||||
}
|
||||
|
||||
export function CommunityProfilePreview({
|
||||
avatarUrl,
|
||||
current = 6,
|
||||
displayName,
|
||||
hasAvatar,
|
||||
onAvatarChange,
|
||||
onAvatarUrlChange,
|
||||
onBack,
|
||||
onDisplayNameChange,
|
||||
onNext,
|
||||
total,
|
||||
}: {
|
||||
avatarUrl: string;
|
||||
current?: number;
|
||||
displayName: string;
|
||||
hasAvatar: boolean;
|
||||
onAvatarChange: () => void;
|
||||
onAvatarUrlChange: (value: string) => void;
|
||||
onBack: () => void;
|
||||
onDisplayNameChange: (value: string) => void;
|
||||
onNext: () => void;
|
||||
total?: number;
|
||||
}) {
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false);
|
||||
|
||||
return (
|
||||
<OnboardingPreviewStep
|
||||
current={6}
|
||||
current={current}
|
||||
onBack={onBack}
|
||||
testId="onboarding-preview-community-profile"
|
||||
total={total}
|
||||
>
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-full w-full max-w-[500px] flex-col items-center"
|
||||
transitionKey="preview-community-profile"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<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, reactions,
|
||||
and agent handoffs.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-center justify-center pt-8">
|
||||
<button
|
||||
aria-label={hasAvatar ? "Change your avatar" : "Add an avatar"}
|
||||
className="flex h-36 w-36 items-center justify-center overflow-hidden rounded-full bg-white/30 text-[var(--buzz-onboarding-backup-ink)] shadow-xs transition-colors hover:bg-white/40"
|
||||
data-testid="onboarding-preview-profile-avatar"
|
||||
onClick={onAvatarChange}
|
||||
type="button"
|
||||
>
|
||||
{hasAvatar ? (
|
||||
<span className="text-5xl" role="img" aria-label="Bee avatar">
|
||||
🐝
|
||||
</span>
|
||||
) : (
|
||||
<Plus className="h-7 w-7" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
<label
|
||||
className="mt-7 block w-full max-w-[412px] text-left"
|
||||
htmlFor="onboarding-preview-community-name"
|
||||
>
|
||||
<span className="mb-2 block pl-4 text-sm text-foreground">
|
||||
Your username
|
||||
</span>
|
||||
<Input
|
||||
autoComplete="username"
|
||||
className="h-14 rounded-2xl border-foreground/25 bg-white/95 px-5 text-sm shadow-none"
|
||||
id="onboarding-preview-community-name"
|
||||
onChange={(event) => onDisplayNameChange(event.target.value)}
|
||||
placeholder="Enter your username here"
|
||||
value={displayName}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<OnboardingFooter>
|
||||
<Button
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="onboarding-preview-profile-next"
|
||||
disabled={!displayName.trim()}
|
||||
onClick={onNext}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</OnboardingFooter>
|
||||
<CommunityProfileStage
|
||||
avatarUrl={avatarUrl}
|
||||
displayName={displayName}
|
||||
isPending={false}
|
||||
isUploadingAvatar={isUploadingAvatar}
|
||||
onAvatarUrlChange={onAvatarUrlChange}
|
||||
onDisplayNameChange={onDisplayNameChange}
|
||||
onNext={onNext}
|
||||
onUploadingChange={setIsUploadingAvatar}
|
||||
previewMode
|
||||
/>
|
||||
</OnboardingSlideTransition>
|
||||
</OnboardingPreviewStep>
|
||||
);
|
||||
@@ -666,40 +702,19 @@ export function WelcomeChannelPreview({
|
||||
}
|
||||
|
||||
export function CommunityHomePreview({
|
||||
avatarUrl,
|
||||
communityName,
|
||||
onBack,
|
||||
displayName,
|
||||
}: {
|
||||
avatarUrl: string;
|
||||
communityName: string;
|
||||
onBack: () => void;
|
||||
displayName: string;
|
||||
}) {
|
||||
return (
|
||||
<OnboardingPreviewStep
|
||||
onBack={onBack}
|
||||
current={7}
|
||||
testId="onboarding-preview-community-home"
|
||||
>
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-full w-full max-w-[760px] flex-col items-center justify-center"
|
||||
transitionKey="preview-community-home"
|
||||
>
|
||||
<p className="text-sm font-medium uppercase tracking-[0.16em] text-foreground/60">
|
||||
{communityName}
|
||||
</p>
|
||||
<h1 className="mt-3 text-title font-normal">Welcome to Buzz</h1>
|
||||
<p className="mt-4 max-w-[480px] text-sm leading-6 text-foreground/80">
|
||||
You’re in the Welcome channel. Start with a message, meet the people
|
||||
and agents here, or explore the rest of the community.
|
||||
</p>
|
||||
<Card
|
||||
className="mt-10 w-full max-w-[620px] px-6 py-6 text-left"
|
||||
variant="textured"
|
||||
>
|
||||
<p className="text-sm font-medium">Fizz</p>
|
||||
<p className="mt-2 text-sm leading-6 text-foreground/75">
|
||||
Welcome! I can show you around and help you get started.
|
||||
</p>
|
||||
</Card>
|
||||
</OnboardingSlideTransition>
|
||||
</OnboardingPreviewStep>
|
||||
<WelcomeChannelAppPreview
|
||||
avatarUrl={avatarUrl}
|
||||
communityName={communityName}
|
||||
displayName={displayName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ export function OnboardingPreviewStep({
|
||||
onBack,
|
||||
security = false,
|
||||
testId,
|
||||
total,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
current?: number;
|
||||
onBack?: () => void;
|
||||
security?: boolean;
|
||||
testId: string;
|
||||
total?: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -25,7 +27,7 @@ export function OnboardingPreviewStep({
|
||||
data-testid={testId}
|
||||
>
|
||||
<StartupWindowDragRegion />
|
||||
{!security ? <OnboardingChrome current={current} /> : null}
|
||||
{!security ? <OnboardingChrome current={current} total={total} /> : null}
|
||||
<OnboardingFooterProvider
|
||||
backAction={onBack ? { onClick: onBack } : undefined}
|
||||
>
|
||||
|
||||
321
desktop/src/features/onboarding/ui/WelcomeChannelAppPreview.tsx
Normal file
321
desktop/src/features/onboarding/ui/WelcomeChannelAppPreview.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import {
|
||||
AtSign,
|
||||
Bot,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FolderKanban,
|
||||
Hash,
|
||||
Home,
|
||||
Mic,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
Smile,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
WelcomeComposerGuidanceLayer,
|
||||
type WelcomeComposerBannerState,
|
||||
} from "@/features/channels/ui/WelcomeComposerBanner";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import { ChannelIntroBlock } from "@/features/messages/ui/ChannelIntroBlock";
|
||||
import { ComposerDockBackdrop } from "@/features/messages/ui/ComposerDockBackdrop";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
const CHANNEL_ROW_CLASS =
|
||||
"flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition-colors";
|
||||
|
||||
export function WelcomeChannelAppPreview({
|
||||
avatarUrl,
|
||||
communityName,
|
||||
displayName,
|
||||
}: {
|
||||
avatarUrl: string;
|
||||
communityName: string;
|
||||
displayName: string;
|
||||
}) {
|
||||
const [bannerState, setBannerState] =
|
||||
React.useState<WelcomeComposerBannerState>("prompt");
|
||||
const [draft, setDraft] = React.useState("");
|
||||
const [messages, setMessages] = React.useState<string[]>([]);
|
||||
const [activePreviewAction, setActivePreviewAction] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const profileName = displayName.trim() || "Your profile";
|
||||
const submitDraft = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const message = draft.trim();
|
||||
if (!message) return;
|
||||
setMessages((current) => [...current, message]);
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
const intro = {
|
||||
actions: [
|
||||
{
|
||||
icon: <Hash aria-hidden className="h-4 w-4" />,
|
||||
label: "Browse channels",
|
||||
onClick: () => setActivePreviewAction("Browse channels"),
|
||||
testId: "welcome-intro-action-browse-channels",
|
||||
},
|
||||
{
|
||||
icon: <Plus aria-hidden className="h-4 w-4" />,
|
||||
label: "Create a channel",
|
||||
onClick: () => setActivePreviewAction("Create a channel"),
|
||||
testId: "welcome-intro-action-create-channel",
|
||||
},
|
||||
{
|
||||
icon: <Bot aria-hidden className="h-4 w-4" />,
|
||||
label: "Create an agent",
|
||||
onClick: () => setActivePreviewAction("Create an agent"),
|
||||
testId: "welcome-intro-action-create-agent",
|
||||
},
|
||||
],
|
||||
channelKindLabel: "private welcome channel",
|
||||
channelName: "Welcome",
|
||||
description: null,
|
||||
icon: <Sparkles aria-hidden className="h-7 w-7" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-dvh w-full flex-col overflow-hidden bg-background text-foreground"
|
||||
data-testid="onboarding-preview-community-home"
|
||||
>
|
||||
<StartupWindowDragRegion />
|
||||
<div
|
||||
className="flex h-12 shrink-0 items-center border-b border-sidebar-border bg-sidebar pl-20 pr-4 text-sidebar-foreground"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
aria-label="Go back"
|
||||
className="h-7 w-6 rounded text-sidebar-foreground/45"
|
||||
disabled
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Go forward"
|
||||
className="h-7 w-6 rounded text-sidebar-foreground/45"
|
||||
disabled
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
className="mx-auto flex h-7 w-full max-w-[420px] items-center justify-center gap-2 rounded-md bg-sidebar-accent/65 px-3 text-xs text-sidebar-foreground/65"
|
||||
onClick={() => setActivePreviewAction("Search")}
|
||||
type="button"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" aria-hidden />
|
||||
Search {communityName}
|
||||
</button>
|
||||
<div className="w-[72px]" aria-hidden />
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<aside className="flex w-[300px] shrink-0 flex-col border-r border-sidebar-border bg-sidebar p-2 text-sidebar-foreground">
|
||||
<button
|
||||
className="flex h-10 items-center gap-2 rounded-lg px-2 text-left hover:bg-sidebar-accent"
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-foreground text-xs font-semibold text-background">
|
||||
B
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
{communityName}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<nav className="mt-2 space-y-0.5" aria-label="Workspace">
|
||||
<button
|
||||
className={cn(CHANNEL_ROW_CLASS, "hover:bg-sidebar-accent")}
|
||||
type="button"
|
||||
>
|
||||
<Home
|
||||
className="h-4 w-4 text-sidebar-foreground/65"
|
||||
aria-hidden
|
||||
/>
|
||||
Home
|
||||
</button>
|
||||
<button
|
||||
className={cn(CHANNEL_ROW_CLASS, "hover:bg-sidebar-accent")}
|
||||
type="button"
|
||||
>
|
||||
<FolderKanban
|
||||
className="h-4 w-4 text-sidebar-foreground/65"
|
||||
aria-hidden
|
||||
/>
|
||||
Projects
|
||||
</button>
|
||||
<button
|
||||
className={cn(CHANNEL_ROW_CLASS, "hover:bg-sidebar-accent")}
|
||||
type="button"
|
||||
>
|
||||
<Bot className="h-4 w-4 text-sidebar-foreground/65" aria-hidden />
|
||||
Agents
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between px-2 text-xs font-medium text-sidebar-foreground/55">
|
||||
<span>Channels</span>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden />
|
||||
</div>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
<button
|
||||
className={cn(CHANNEL_ROW_CLASS, "hover:bg-sidebar-accent")}
|
||||
data-testid="channel-general"
|
||||
type="button"
|
||||
>
|
||||
<Hash
|
||||
className="h-4 w-4 text-sidebar-foreground/55"
|
||||
aria-hidden
|
||||
/>
|
||||
general
|
||||
</button>
|
||||
<button
|
||||
className={cn(CHANNEL_ROW_CLASS, "hover:bg-sidebar-accent")}
|
||||
data-testid="channel-welcome-everyone"
|
||||
type="button"
|
||||
>
|
||||
<Hash
|
||||
className="h-4 w-4 text-sidebar-foreground/55"
|
||||
aria-hidden
|
||||
/>
|
||||
welcome-everyone
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
CHANNEL_ROW_CLASS,
|
||||
"bg-sidebar-accent font-medium text-sidebar-accent-foreground",
|
||||
)}
|
||||
data-testid="channel-Welcome"
|
||||
type="button"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden />
|
||||
Welcome
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center gap-2 rounded-lg px-2 py-2 hover:bg-sidebar-accent">
|
||||
<ProfileAvatar
|
||||
avatarUrl={avatarUrl || null}
|
||||
className="h-8 w-8 rounded-lg text-xs"
|
||||
label={profileName}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{profileName}</p>
|
||||
<p className="truncate text-xs text-sidebar-foreground/55">
|
||||
Available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="relative flex min-w-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
<header className="relative z-20 flex h-14 shrink-0 items-center border-b border-border/55 bg-background/80 px-5 backdrop-blur-md">
|
||||
<Hash className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
<h1
|
||||
className="ml-1 text-base font-semibold"
|
||||
data-testid="chat-title"
|
||||
>
|
||||
Welcome
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-56 pt-4">
|
||||
<div className="mx-auto w-full max-w-[920px]">
|
||||
<ChannelIntroBlock intro={intro} />
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
className="mt-5 flex items-start gap-3 px-3 text-left"
|
||||
key={`${message}-${index.toString()}`}
|
||||
>
|
||||
<ProfileAvatar
|
||||
avatarUrl={avatarUrl || null}
|
||||
className="h-9 w-9 rounded-lg text-xs"
|
||||
label={profileName}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{profileName}</p>
|
||||
<p className="mt-1 text-sm leading-5">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-40 isolate before:absolute before:inset-x-0 before:bottom-0 before:-z-10 before:h-24 before:bg-gradient-to-b before:from-transparent before:to-background before:content-[''] after:absolute after:inset-x-0 after:bottom-0 after:-z-10 after:h-12 after:bg-background after:content-['']">
|
||||
<div className="composer-dock composer-overlay-corner-masks relative pointer-events-auto">
|
||||
{bannerState !== "hidden" ? (
|
||||
<WelcomeComposerGuidanceLayer
|
||||
onDismiss={() => setBannerState("hidden")}
|
||||
state={bannerState}
|
||||
>
|
||||
{null}
|
||||
</WelcomeComposerGuidanceLayer>
|
||||
) : null}
|
||||
<ComposerDockBackdrop gutterClassName="inset-x-5" />
|
||||
<form
|
||||
className="relative z-10 mx-5 rounded-2xl border border-border/50 bg-background/80 px-4 pb-2 pt-3 shadow-none backdrop-blur-md"
|
||||
data-testid="message-composer"
|
||||
onSubmit={submitDraft}
|
||||
>
|
||||
<textarea
|
||||
aria-label="Message Welcome"
|
||||
className="min-h-11 w-full resize-none bg-transparent text-sm leading-5 outline-none placeholder:text-muted-foreground/65"
|
||||
data-testid="welcome-preview-composer-input"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
placeholder="Message #Welcome"
|
||||
rows={2}
|
||||
value={draft}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{[Paperclip, Smile, AtSign, Mic].map((Icon) => (
|
||||
<Button
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
key={Icon.displayName ?? Icon.name}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
aria-label="Send message"
|
||||
className="ml-auto h-7 w-7 rounded-lg"
|
||||
disabled={!draft.trim()}
|
||||
size="icon"
|
||||
type="submit"
|
||||
>
|
||||
<Send className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activePreviewAction ? (
|
||||
<div
|
||||
className="absolute right-5 top-16 z-50 rounded-lg border border-border bg-background px-3 py-2 text-xs shadow-lg"
|
||||
role="status"
|
||||
>
|
||||
{activePreviewAction}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -228,12 +228,13 @@ export function preloadAvatarSegmenter(): void {
|
||||
export async function recordAnimatedAvatarFrames(
|
||||
video: HTMLVideoElement,
|
||||
options: {
|
||||
removeBackground?: boolean;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (fraction: number) => void;
|
||||
} = {},
|
||||
): Promise<AnimatedAvatarRecording> {
|
||||
const { onProgress, signal } = options;
|
||||
const handle = await loadSegmenter();
|
||||
const { onProgress, removeBackground = true, signal } = options;
|
||||
const handle = removeBackground ? await loadSegmenter() : null;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = ANIMATED_AVATAR_CAPTURE_SIZE;
|
||||
|
||||
@@ -4,10 +4,15 @@ import {
|
||||
type AvatarCameraDevice,
|
||||
type AvatarComposition,
|
||||
composeAvatarFrame,
|
||||
composeAvatarFrames,
|
||||
DEFAULT_PERSON_SCALE,
|
||||
encodeAvatarAnimation,
|
||||
renderAvatarPosterPng,
|
||||
} from "@/features/profile/lib/animatedAvatarCapture";
|
||||
import { beginAvatarPresentation } from "@/features/profile/avatarPresentationStore";
|
||||
import type { AnimatedAvatarRecordingProcessor } from "@/features/profile/ui/AnimatedAvatarCapture.types";
|
||||
import { AVATAR_COLORS } from "@/features/profile/ui/ProfileAvatarEditor.utils";
|
||||
import { uploadMediaBytes } from "@/shared/api/tauri";
|
||||
import { buildAnimatedAvatarUrl } from "@/shared/lib/animatedAvatar";
|
||||
|
||||
export type CapturePhase =
|
||||
@@ -49,6 +54,40 @@ export function presentAnimatedAvatar(
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
export async function saveAnimatedAvatar({
|
||||
bitmaps,
|
||||
composition,
|
||||
posterIndex,
|
||||
processRecording,
|
||||
}: {
|
||||
bitmaps: ImageBitmap[];
|
||||
composition: AvatarComposition;
|
||||
posterIndex: number;
|
||||
processRecording?: AnimatedAvatarRecordingProcessor;
|
||||
}): Promise<string> {
|
||||
const composed = composeAvatarFrames(bitmaps, composition);
|
||||
const posterFrame = composed[posterIndex] ?? composed[0];
|
||||
if (!posterFrame) throw new Error("No frames were recorded.");
|
||||
|
||||
const animationBytes = encodeAvatarAnimation(composed);
|
||||
const posterBytes = await renderAvatarPosterPng(posterFrame);
|
||||
if (processRecording) {
|
||||
return processRecording({ animationBytes, posterBytes });
|
||||
}
|
||||
|
||||
const [animationUpload, posterUpload] = await Promise.all([
|
||||
uploadMediaBytes([...animationBytes], "animated-avatar.png"),
|
||||
uploadMediaBytes([...posterBytes], "animated-avatar-poster.png"),
|
||||
]);
|
||||
if (
|
||||
!animationUpload.type.startsWith("image/") ||
|
||||
!posterUpload.type.startsWith("image/")
|
||||
) {
|
||||
throw new Error("The relay rejected the recording. Try again.");
|
||||
}
|
||||
return presentAnimatedAvatar(posterUpload, animationUpload, posterBytes);
|
||||
}
|
||||
|
||||
/** Keep things draggable but never fully lost off-frame. */
|
||||
const MAX_OFFSET = 192;
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type AvatarComposition,
|
||||
buildPingPongAvatarFrames,
|
||||
composeAvatarFrame,
|
||||
composeAvatarFrames,
|
||||
createAvatarFrameBitmaps,
|
||||
DEFAULT_PERSON_OFFSET_X,
|
||||
DEFAULT_PERSON_OFFSET_Y,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
DEFAULT_SHAPE_OFFSET_X,
|
||||
DEFAULT_SHAPE_OFFSET_Y,
|
||||
DEFAULT_SHAPE_SCALE,
|
||||
encodeAvatarAnimation,
|
||||
MAX_PERSON_SCALE,
|
||||
MAX_SHAPE_SCALE,
|
||||
MIN_PERSON_SCALE,
|
||||
@@ -28,7 +26,6 @@ import {
|
||||
openAvatarCamera,
|
||||
preloadAvatarSegmenter,
|
||||
recordAnimatedAvatarFrames,
|
||||
renderAvatarPosterPng,
|
||||
stopAvatarCamera,
|
||||
} from "@/features/profile/lib/animatedAvatarCapture";
|
||||
import { AnimatedAvatarBackdropPanel } from "@/features/profile/ui/AnimatedAvatarBackdropPanel";
|
||||
@@ -49,8 +46,8 @@ import {
|
||||
defaultPersonScaleForSource,
|
||||
PERSON_SIZE_TIP,
|
||||
preferredCameraDevice,
|
||||
presentAnimatedAvatar,
|
||||
randomBackdropColor,
|
||||
saveAnimatedAvatar,
|
||||
} from "@/features/profile/ui/AnimatedAvatarCapture.helpers";
|
||||
import {
|
||||
AnimatedAvatarReviewNav,
|
||||
@@ -63,7 +60,6 @@ import {
|
||||
hsvToHex,
|
||||
normalizeHue,
|
||||
} from "@/features/profile/ui/ProfileAvatarEditor.utils";
|
||||
import { uploadMediaBytes } from "@/shared/api/tauri";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
@@ -81,6 +77,7 @@ export function AnimatedAvatarCapture({
|
||||
showApplyButton = true,
|
||||
autoStartCamera = false,
|
||||
compactReview = false,
|
||||
processRecording,
|
||||
}: AnimatedAvatarCaptureProps) {
|
||||
const [phase, setPhase] = React.useState<CapturePhase>("idle");
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
||||
@@ -329,7 +326,7 @@ export function AnimatedAvatarCapture({
|
||||
setPhase("starting");
|
||||
releaseCamera();
|
||||
// Warm the segmentation model while the user lines up their shot.
|
||||
preloadAvatarSegmenter();
|
||||
if (!processRecording) preloadAvatarSegmenter();
|
||||
try {
|
||||
let stream = await openAvatarCamera(cameraId || null);
|
||||
let resolvedCameraId = cameraId ?? null;
|
||||
@@ -386,6 +383,7 @@ export function AnimatedAvatarCapture({
|
||||
[
|
||||
refreshCameraDevices,
|
||||
releaseCamera,
|
||||
processRecording,
|
||||
selectedCameraId,
|
||||
selectedCameraSource,
|
||||
],
|
||||
@@ -444,6 +442,7 @@ export function AnimatedAvatarCapture({
|
||||
|
||||
try {
|
||||
const captured = await recordAnimatedAvatarFrames(video, {
|
||||
removeBackground: !processRecording,
|
||||
onProgress: setRecordProgress,
|
||||
signal: abort.signal,
|
||||
});
|
||||
@@ -474,7 +473,13 @@ export function AnimatedAvatarCapture({
|
||||
} finally {
|
||||
recordAbortRef.current = null;
|
||||
}
|
||||
}, [phase, releaseBitmaps, releaseCamera, selectedCameraSource]);
|
||||
}, [
|
||||
phase,
|
||||
processRecording,
|
||||
releaseBitmaps,
|
||||
releaseCamera,
|
||||
selectedCameraSource,
|
||||
]);
|
||||
|
||||
const retake = React.useCallback(() => {
|
||||
setRecording(null);
|
||||
@@ -501,26 +506,13 @@ export function AnimatedAvatarCapture({
|
||||
setIsSaving(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const composed = composeAvatarFrames(bitmaps, composition);
|
||||
const posterFrame = composed[posterIndex] ?? composed[0];
|
||||
if (!posterFrame) {
|
||||
throw new Error("No frames were recorded.");
|
||||
}
|
||||
const animationBytes = encodeAvatarAnimation(composed);
|
||||
const posterBytes = await renderAvatarPosterPng(posterFrame);
|
||||
const [animationUpload, posterUpload] = await Promise.all([
|
||||
uploadMediaBytes([...animationBytes], "animated-avatar.png"),
|
||||
uploadMediaBytes([...posterBytes], "animated-avatar-poster.png"),
|
||||
]);
|
||||
if (
|
||||
!animationUpload.type.startsWith("image/") ||
|
||||
!posterUpload.type.startsWith("image/")
|
||||
) {
|
||||
setErrorMessage("The relay rejected the recording. Try again.");
|
||||
return false;
|
||||
}
|
||||
onApply(
|
||||
presentAnimatedAvatar(posterUpload, animationUpload, posterBytes),
|
||||
await saveAnimatedAvatar({
|
||||
bitmaps,
|
||||
composition,
|
||||
posterIndex,
|
||||
processRecording,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -533,7 +525,7 @@ export function AnimatedAvatarCapture({
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [bitmaps, composition, isSaving, onApply, posterIndex]);
|
||||
}, [bitmaps, composition, isSaving, onApply, posterIndex, processRecording]);
|
||||
|
||||
// Hand the host's Done button the current apply function whenever a
|
||||
// recording is ready to upload.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
export type AnimatedAvatarRecordingProcessor = (input: {
|
||||
animationBytes: Uint8Array;
|
||||
posterBytes: Uint8Array;
|
||||
}) => Promise<string>;
|
||||
|
||||
export type AnimatedAvatarCaptureProps = {
|
||||
disabled?: boolean;
|
||||
testIdPrefix: string;
|
||||
@@ -11,4 +16,5 @@ export type AnimatedAvatarCaptureProps = {
|
||||
showApplyButton?: boolean;
|
||||
autoStartCamera?: boolean;
|
||||
compactReview?: boolean;
|
||||
processRecording?: AnimatedAvatarRecordingProcessor;
|
||||
};
|
||||
|
||||
@@ -77,6 +77,8 @@ export function ProfileAvatarEditor({
|
||||
onAnimatedAvatarApply,
|
||||
onDone,
|
||||
onUploadingChange,
|
||||
processAnimatedAvatar,
|
||||
processImage,
|
||||
showEmojiColorControlsWhenEmpty = false,
|
||||
disabled,
|
||||
testIdPrefix = "profile-avatar",
|
||||
@@ -189,7 +191,7 @@ export function ProfileAvatarEditor({
|
||||
isUploading,
|
||||
openPicker,
|
||||
uploadFile,
|
||||
} = useAvatarUpload(uploadPreviewLifecycle);
|
||||
} = useAvatarUpload({ ...uploadPreviewLifecycle, processImage });
|
||||
const isInputDisabled = disabled || isUploading || isAnimatedApplyPending;
|
||||
const handleAnimatedApply = React.useCallback(
|
||||
(animatedUrl: string) => {
|
||||
@@ -740,6 +742,7 @@ export function ProfileAvatarEditor({
|
||||
onPreviewActiveChange={onAnimatedPreviewActiveChange}
|
||||
onPreviewCaptionChange={onAnimatedPreviewCaptionChange}
|
||||
previewContainer={animatedPreviewContainer}
|
||||
processRecording={processAnimatedAvatar}
|
||||
registerApply={registerAnimatedApply}
|
||||
compactReview={isOnboardingModal}
|
||||
showApplyButton={!onDone}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type * as React from "react";
|
||||
import type { AnimatedAvatarRecordingProcessor } from "./AnimatedAvatarCapture.types";
|
||||
|
||||
export type AvatarMode = "image" | "emoji" | "animated";
|
||||
export type AvatarEditorPresentation = "default" | "onboarding-modal";
|
||||
@@ -30,4 +31,6 @@ export type ProfileAvatarEditorProps = {
|
||||
onAnimatedPreviewActiveChange?: (active: boolean) => void;
|
||||
onAnimatedPreviewCaptionChange?: (caption: string | null) => void;
|
||||
presentation?: AvatarEditorPresentation;
|
||||
processImage?: (file: File) => Promise<string>;
|
||||
processAnimatedAvatar?: AnimatedAvatarRecordingProcessor;
|
||||
};
|
||||
|
||||
@@ -15,7 +15,10 @@ import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow";
|
||||
import { CommunityOnboardingProvider } from "@/features/onboarding/communityOnboarding";
|
||||
import { onboardingPreviewRequested } from "@/features/onboarding/onboardingPreview";
|
||||
import { OnboardingPreviewApp } from "@/features/onboarding/ui/OnboardingPreviewApp";
|
||||
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
|
||||
import {
|
||||
NonPersistentThemeProvider,
|
||||
ThemeProvider,
|
||||
} from "@/shared/theme/ThemeProvider";
|
||||
import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider";
|
||||
import { PoofBurstProvider } from "@/shared/ui/PoofBurstProvider";
|
||||
import { Toaster } from "@/shared/ui/sonner";
|
||||
@@ -114,7 +117,9 @@ function renderOnboardingPreview() {
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<RootErrorBoundary>
|
||||
<OnboardingPreviewApp />
|
||||
<NonPersistentThemeProvider>
|
||||
<OnboardingPreviewApp />
|
||||
</NonPersistentThemeProvider>
|
||||
</RootErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -89,6 +89,41 @@ type ThemeProviderProps = {
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
const NON_PERSISTENT_THEME_VALUE: ThemeContextValue = {
|
||||
accentColor: DEFAULT_ACCENT,
|
||||
applyAppearance: () => undefined,
|
||||
followSystem: false,
|
||||
glassBackground: false,
|
||||
glassBackgroundSupported: false,
|
||||
glassOpacity: 65,
|
||||
hasPair: false,
|
||||
isDark: false,
|
||||
isLoading: false,
|
||||
prominentActiveTab: false,
|
||||
selectedThemeName: "buzz",
|
||||
setAccentColor: () => undefined,
|
||||
setFollowSystem: () => undefined,
|
||||
setGlassBackground: () => undefined,
|
||||
setGlassOpacity: () => undefined,
|
||||
setProminentActiveTab: () => undefined,
|
||||
setTheme: () => undefined,
|
||||
terminalPalette: null,
|
||||
themeName: "buzz",
|
||||
};
|
||||
|
||||
/** Supplies theme context without reading, writing, or applying preferences. */
|
||||
export function NonPersistentThemeProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ThemeContext.Provider value={NON_PERSISTENT_THEME_VALUE}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function isValidThemeName(name: string): name is SyntaxThemeName {
|
||||
return (SYNTAX_THEMES as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user