mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish onboarding avatar capture (#2118)
Signed-off-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3
Wes
Pinky
parent
e25ed00248
commit
46a63a864b
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Users, X } from "lucide-react";
|
||||
import { Plus, Users } from "lucide-react";
|
||||
|
||||
import {
|
||||
markCommunityOnboardingComplete,
|
||||
@@ -25,7 +25,9 @@ import { listPersonas } from "@/shared/api/tauriPersonas";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
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";
|
||||
@@ -34,7 +36,6 @@ import {
|
||||
OnboardingChrome,
|
||||
} from "./OnboardingChrome";
|
||||
import { OnboardingFooter, OnboardingFooterProvider } from "./OnboardingFooter";
|
||||
import { ONBOARDING_KEY_FRAME_CLASS } from "./NsecMaskedDisplay";
|
||||
|
||||
function isRelayMembershipDeniedError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
@@ -71,10 +72,12 @@ function AvatarCircle({
|
||||
avatarUrl,
|
||||
onClick,
|
||||
previewName,
|
||||
triggerRef,
|
||||
}: {
|
||||
avatarUrl: string;
|
||||
onClick: () => void;
|
||||
previewName: string;
|
||||
triggerRef?: React.Ref<HTMLButtonElement>;
|
||||
}) {
|
||||
const emojiAvatar = parseEmojiAvatarDataUrl(avatarUrl);
|
||||
const hasAvatar = avatarUrl.trim().length > 0;
|
||||
@@ -85,6 +88,7 @@ function AvatarCircle({
|
||||
className="group block shrink-0 rounded-full"
|
||||
data-testid="community-avatar-open"
|
||||
onClick={onClick}
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
>
|
||||
{emojiAvatar ? (
|
||||
@@ -138,6 +142,7 @@ export function CommunityOnboardingFlow({
|
||||
}) {
|
||||
const { transaction, update, clear } = useCommunityOnboarding();
|
||||
const queryClient = useQueryClient();
|
||||
const systemColorScheme = useSystemColorScheme();
|
||||
const [displayName, setDisplayName] = React.useState("");
|
||||
const [avatarUrl, setAvatarUrl] = React.useState("");
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false);
|
||||
@@ -154,6 +159,10 @@ export function CommunityOnboardingFlow({
|
||||
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);
|
||||
|
||||
// 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.
|
||||
@@ -276,6 +285,25 @@ export function CommunityOnboardingFlow({
|
||||
}
|
||||
}, [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) {
|
||||
@@ -353,6 +381,7 @@ export function CommunityOnboardingFlow({
|
||||
isCurtainFading &&
|
||||
"pointer-events-none opacity-0 transition-opacity ease-out motion-reduce:transition-none",
|
||||
)}
|
||||
data-system-color-scheme={systemColorScheme}
|
||||
data-testid="community-onboarding-flow"
|
||||
style={
|
||||
isCurtainFading
|
||||
@@ -405,93 +434,120 @@ export function CommunityOnboardingFlow({
|
||||
</div>
|
||||
</>
|
||||
) : isProfileStage ? (
|
||||
isAvatarEditorOpen ? (
|
||||
<>
|
||||
<div
|
||||
className={cn("relative", ONBOARDING_KEY_FRAME_CLASS)}
|
||||
data-testid="community-avatar-editor-key-frame"
|
||||
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"
|
||||
>
|
||||
<Button
|
||||
aria-label="Close avatar editor"
|
||||
className="absolute -right-3 -top-3 h-9 w-9 rounded-full"
|
||||
data-testid="community-avatar-close"
|
||||
onClick={() => setIsAvatarEditorOpen(false)}
|
||||
size="icon"
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<ProfileAvatarEditor
|
||||
avatarUrl={avatarUrl}
|
||||
disabled={isPending}
|
||||
emojiPickerTheme="auto"
|
||||
emojiPickerThemeVars={NEUTRAL_EMOJI_PICKER_THEME_VARS}
|
||||
onDone={() => setIsAvatarEditorOpen(false)}
|
||||
onUploadingChange={setIsUploadingAvatar}
|
||||
onUrlChange={setAvatarUrl}
|
||||
previewName={displayName.trim() || "Your profile"}
|
||||
testIdPrefix="community-avatar"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-full" 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 className="mt-8 flex w-full flex-col items-center">
|
||||
<AvatarCircle
|
||||
avatarUrl={avatarUrl}
|
||||
onClick={() => setIsAvatarEditorOpen(true)}
|
||||
previewName={displayName.trim() || "Your profile"}
|
||||
/>
|
||||
<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 name
|
||||
</span>
|
||||
<Input
|
||||
aria-label="Community display name"
|
||||
autoCapitalize="words"
|
||||
autoComplete="name"
|
||||
autoCorrect="off"
|
||||
className="h-14 rounded-2xl border-[color:rgb(113_113_6_/_0.28)] bg-white/95 px-5 text-sm shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-1 focus-visible:ring-[var(--buzz-onboarding-backup-ink)] md:text-sm"
|
||||
data-testid="community-profile-name-key"
|
||||
disabled={isPending || isUploadingAvatar}
|
||||
id="community-display-name"
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder="First and last name"
|
||||
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>
|
||||
<Button
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="community-profile-next"
|
||||
disabled={
|
||||
!displayName.trim() || isPending || isUploadingAvatar
|
||||
}
|
||||
onClick={() => void saveProfile()}
|
||||
type="button"
|
||||
<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"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</OnboardingFooter>
|
||||
</>
|
||||
)
|
||||
<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}
|
||||
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),560px)] max-w-[560px] 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 ref={avatarEditorContentRef}>
|
||||
<ProfileAvatarEditor
|
||||
avatarUrl={avatarUrl}
|
||||
disabled={isPending}
|
||||
donePending={isUploadingAvatar}
|
||||
emojiPickerTheme="auto"
|
||||
emojiPickerThemeVars={NEUTRAL_EMOJI_PICKER_THEME_VARS}
|
||||
onDone={() => setIsAvatarEditorOpen(false)}
|
||||
onUploadingChange={setIsUploadingAvatar}
|
||||
onUrlChange={setAvatarUrl}
|
||||
presentation="onboarding-modal"
|
||||
previewName={displayName.trim() || "Your profile"}
|
||||
testIdPrefix="community-avatar"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-title font-normal">Meet your starter team</h1>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
import {
|
||||
AVATAR_COLOR_SWATCHES,
|
||||
CUSTOM_AVATAR_COLOR_SWATCH,
|
||||
@@ -6,6 +8,7 @@ import {
|
||||
|
||||
type AnimatedAvatarBackdropPanelProps = {
|
||||
backdropColor: string | null;
|
||||
compact?: boolean;
|
||||
disabled?: boolean;
|
||||
isCustomBackdropSelected: boolean;
|
||||
isSaving: boolean;
|
||||
@@ -16,6 +19,7 @@ type AnimatedAvatarBackdropPanelProps = {
|
||||
|
||||
export function AnimatedAvatarBackdropPanel({
|
||||
backdropColor,
|
||||
compact = false,
|
||||
disabled = false,
|
||||
isCustomBackdropSelected,
|
||||
isSaving,
|
||||
@@ -25,66 +29,70 @@ export function AnimatedAvatarBackdropPanel({
|
||||
}: AnimatedAvatarBackdropPanelProps) {
|
||||
return (
|
||||
<div
|
||||
className="grid gap-4 rounded-xl bg-muted p-4 transition-colors duration-[250ms] ease-out"
|
||||
data-testid={`${testIdPrefix}-animated-color-panel`}
|
||||
className={cn(
|
||||
"grid grid-cols-8 justify-items-center rounded-xl bg-muted transition-colors duration-[250ms] ease-out",
|
||||
compact ? "gap-2 p-3" : "gap-3 p-4",
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-animated-backdrop-grid`}
|
||||
>
|
||||
<div
|
||||
className="grid grid-cols-8 justify-items-center gap-3"
|
||||
data-testid={`${testIdPrefix}-animated-backdrop-grid`}
|
||||
>
|
||||
{AVATAR_COLOR_SWATCHES.map((swatch) => {
|
||||
const isCustomSwatch = swatch === CUSTOM_AVATAR_COLOR_SWATCH;
|
||||
const isSelected = isCustomSwatch
|
||||
? isCustomBackdropSelected
|
||||
: backdropColor !== null &&
|
||||
swatch.toUpperCase() === backdropColor.toUpperCase();
|
||||
{AVATAR_COLOR_SWATCHES.map((swatch) => {
|
||||
const isCustomSwatch = swatch === CUSTOM_AVATAR_COLOR_SWATCH;
|
||||
const isSelected = isCustomSwatch
|
||||
? isCustomBackdropSelected
|
||||
: backdropColor !== null &&
|
||||
swatch.toUpperCase() === backdropColor.toUpperCase();
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={
|
||||
isCustomSwatch
|
||||
? "Choose custom backdrop color"
|
||||
: `Use ${swatch} backdrop`
|
||||
return (
|
||||
<button
|
||||
aria-label={
|
||||
isCustomSwatch
|
||||
? "Choose custom backdrop color"
|
||||
: `Use ${swatch} backdrop`
|
||||
}
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
"relative rounded-full border border-border transition-transform duration-200 ease-out hover:scale-[1.15] focus-visible:scale-[1.15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
compact ? "h-7 w-7" : "h-10 w-10",
|
||||
)}
|
||||
data-testid={
|
||||
isCustomSwatch
|
||||
? `${testIdPrefix}-animated-backdrop-custom`
|
||||
: undefined
|
||||
}
|
||||
disabled={disabled || isSaving}
|
||||
key={swatch}
|
||||
onClick={() => {
|
||||
if (isCustomSwatch) {
|
||||
onOpenCustomPicker();
|
||||
return;
|
||||
}
|
||||
aria-pressed={isSelected}
|
||||
className="relative h-10 w-10 rounded-full border border-border transition-transform duration-200 ease-out hover:scale-[1.15] focus-visible:scale-[1.15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-testid={
|
||||
isCustomSwatch
|
||||
? `${testIdPrefix}-animated-backdrop-custom`
|
||||
: undefined
|
||||
}
|
||||
disabled={disabled || isSaving}
|
||||
key={swatch}
|
||||
onClick={() => {
|
||||
if (isCustomSwatch) {
|
||||
onOpenCustomPicker();
|
||||
return;
|
||||
}
|
||||
onSelectColor(swatch);
|
||||
}}
|
||||
style={{
|
||||
background: isCustomSwatch
|
||||
? isSelected && backdropColor
|
||||
? backdropColor
|
||||
: "conic-gradient(from 0deg, #ff4d4d, #ffe75c, #73ef75, #63c6f2, #b141ff, #ff4d4d)"
|
||||
: swatch,
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{isSelected ? (
|
||||
<span
|
||||
className="absolute inset-1 rounded-full border-[3px]"
|
||||
style={{
|
||||
borderColor: contrastColorForBackground(
|
||||
isCustomSwatch && backdropColor ? backdropColor : swatch,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
onSelectColor(swatch);
|
||||
}}
|
||||
style={{
|
||||
background: isCustomSwatch
|
||||
? isSelected && backdropColor
|
||||
? backdropColor
|
||||
: "conic-gradient(from 0deg, #ff4d4d, #ffe75c, #73ef75, #63c6f2, #b141ff, #ff4d4d)"
|
||||
: swatch,
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{isSelected ? (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute rounded-full border-[3px]",
|
||||
compact ? "inset-0.5" : "inset-1",
|
||||
)}
|
||||
style={{
|
||||
borderColor: contrastColorForBackground(
|
||||
isCustomSwatch && backdropColor ? backdropColor : swatch,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Video } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { AnimatedAvatarCameraPicker } from "@/features/profile/ui/AnimatedAvatarCameraPicker";
|
||||
import {
|
||||
type CameraSource,
|
||||
ENTRANCE_TRANSITION,
|
||||
RECORD_SECONDS,
|
||||
} from "@/features/profile/ui/AnimatedAvatarCapture.helpers";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
type AnimatedAvatarCameraControlsProps = {
|
||||
activeCameraSource: CameraSource | null;
|
||||
compact: boolean;
|
||||
computerDisabled: boolean;
|
||||
disabled: boolean;
|
||||
helpText: string | null;
|
||||
iphoneDisabled: boolean;
|
||||
isLive: boolean;
|
||||
isStarting: boolean;
|
||||
onRecord: () => void;
|
||||
onRetry?: () => void;
|
||||
onSelectSource: (source: CameraSource) => void;
|
||||
showCameraPicker: boolean;
|
||||
testIdPrefix: string;
|
||||
};
|
||||
|
||||
export function AnimatedAvatarCameraControls({
|
||||
activeCameraSource,
|
||||
compact,
|
||||
computerDisabled,
|
||||
disabled,
|
||||
helpText,
|
||||
iphoneDisabled,
|
||||
isLive,
|
||||
isStarting,
|
||||
onRecord,
|
||||
onRetry,
|
||||
onSelectSource,
|
||||
showCameraPicker,
|
||||
testIdPrefix,
|
||||
}: AnimatedAvatarCameraControlsProps) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{showCameraPicker ? (
|
||||
<AnimatedAvatarCameraPicker
|
||||
activeCameraSource={activeCameraSource}
|
||||
computerDisabled={computerDisabled}
|
||||
disabled={disabled || isStarting}
|
||||
iphoneDisabled={iphoneDisabled}
|
||||
onSelectSource={onSelectSource}
|
||||
testIdPrefix={testIdPrefix}
|
||||
/>
|
||||
) : null}
|
||||
{helpText ? (
|
||||
<p className="px-1 text-center text-sm text-muted-foreground">
|
||||
{helpText}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="h-14 pt-2">
|
||||
{onRetry ? (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-12 w-full rounded-xl",
|
||||
compact &&
|
||||
"bg-[rgb(var(--buzz-onboarding-avatar-accent-bg))] text-[rgb(var(--buzz-onboarding-avatar-accent-fg))] hover:bg-[rgb(var(--buzz-onboarding-avatar-accent-bg))]",
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-animated-retry`}
|
||||
disabled={disabled}
|
||||
onClick={onRetry}
|
||||
type="button"
|
||||
>
|
||||
Try camera again
|
||||
</Button>
|
||||
) : isLive ? (
|
||||
<Button
|
||||
asChild
|
||||
className={cn(
|
||||
"h-12 w-full rounded-xl",
|
||||
compact &&
|
||||
"bg-[rgb(var(--buzz-onboarding-avatar-accent-bg))] text-[rgb(var(--buzz-onboarding-avatar-accent-fg))] hover:bg-[rgb(var(--buzz-onboarding-avatar-accent-bg))]",
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-animated-record`}
|
||||
disabled={disabled}
|
||||
onClick={onRecord}
|
||||
type="button"
|
||||
>
|
||||
<motion.button
|
||||
animate={{ opacity: 1 }}
|
||||
initial={{ opacity: 0 }}
|
||||
transition={ENTRANCE_TRANSITION}
|
||||
>
|
||||
<Video aria-hidden="true" className="mr-2 h-4 w-4" />
|
||||
Capture {RECORD_SECONDS} sec video
|
||||
</motion.button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Camera, Video } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Camera } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
@@ -33,7 +32,8 @@ import {
|
||||
stopAvatarCamera,
|
||||
} from "@/features/profile/lib/animatedAvatarCapture";
|
||||
import { AnimatedAvatarBackdropPanel } from "@/features/profile/ui/AnimatedAvatarBackdropPanel";
|
||||
import { AnimatedAvatarCameraPicker } from "@/features/profile/ui/AnimatedAvatarCameraPicker";
|
||||
import type { AnimatedAvatarCaptureProps } from "@/features/profile/ui/AnimatedAvatarCapture.types";
|
||||
import { AnimatedAvatarCameraControls } from "@/features/profile/ui/AnimatedAvatarCameraControls";
|
||||
import {
|
||||
AvatarFilmstripPicker,
|
||||
AvatarFramingSlider,
|
||||
@@ -47,11 +47,9 @@ import {
|
||||
clampFrameIndex,
|
||||
clampOffset,
|
||||
defaultPersonScaleForSource,
|
||||
ENTRANCE_TRANSITION,
|
||||
PERSON_SIZE_TIP,
|
||||
preferredCameraDevice,
|
||||
randomBackdropColor,
|
||||
RECORD_SECONDS,
|
||||
} from "@/features/profile/ui/AnimatedAvatarCapture.helpers";
|
||||
import {
|
||||
AnimatedAvatarReviewNav,
|
||||
@@ -70,32 +68,6 @@ import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
|
||||
type AnimatedAvatarCaptureProps = {
|
||||
disabled?: boolean;
|
||||
testIdPrefix: string;
|
||||
/** Receives the composed animated avatar URL after upload. */
|
||||
onApply: (avatarUrl: string) => void;
|
||||
/**
|
||||
* Host element inside the page's main avatar preview. When provided, the
|
||||
* camera feed, recording ring, and composed preview render there (via a
|
||||
* portal) instead of inside the tab — so edits show exactly where the
|
||||
* avatar will live. Pair with `onPreviewActiveChange` so the host can
|
||||
* hide its regular preview while the capture content is showing.
|
||||
*/
|
||||
previewContainer?: HTMLElement | null;
|
||||
onPreviewActiveChange?: (active: boolean) => void;
|
||||
onPreviewCaptionChange?: (caption: string | null) => void;
|
||||
onApplyPendingChange?: (isPending: boolean) => void;
|
||||
onCustomColorPickerOpenChange?: (isOpen: boolean) => void;
|
||||
/**
|
||||
* Receives the current apply function (or null when there is nothing to
|
||||
* apply) so the host's Done button can upload-and-apply in one step.
|
||||
*/
|
||||
registerApply?: (apply: (() => Promise<boolean>) | null) => void;
|
||||
/** Show the in-tab "Use as avatar" button (hosts without a Done button). */
|
||||
showApplyButton?: boolean;
|
||||
};
|
||||
|
||||
export function AnimatedAvatarCapture({
|
||||
disabled = false,
|
||||
testIdPrefix,
|
||||
@@ -107,6 +79,8 @@ export function AnimatedAvatarCapture({
|
||||
onPreviewCaptionChange,
|
||||
registerApply,
|
||||
showApplyButton = true,
|
||||
autoStartCamera = false,
|
||||
compactReview = false,
|
||||
}: AnimatedAvatarCaptureProps) {
|
||||
const [phase, setPhase] = React.useState<CapturePhase>("idle");
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
||||
@@ -135,9 +109,10 @@ export function AnimatedAvatarCapture({
|
||||
const [personOutline, setPersonOutline] = React.useState(
|
||||
DEFAULT_PERSON_OUTLINE,
|
||||
);
|
||||
const initialShapeOffsetY = compactReview ? -24 : DEFAULT_SHAPE_OFFSET_Y;
|
||||
const [shapeOffset, setShapeOffset] = React.useState({
|
||||
x: DEFAULT_SHAPE_OFFSET_X,
|
||||
y: DEFAULT_SHAPE_OFFSET_Y,
|
||||
y: initialShapeOffsetY,
|
||||
});
|
||||
const [shapeScale, setShapeScale] = React.useState(DEFAULT_SHAPE_SCALE);
|
||||
const [activeSection, setActiveSection] =
|
||||
@@ -164,7 +139,7 @@ export function AnimatedAvatarCapture({
|
||||
|
||||
const resetActiveFraming = React.useCallback(() => {
|
||||
if (activeSection === "shape") {
|
||||
setShapeOffset({ x: DEFAULT_SHAPE_OFFSET_X, y: DEFAULT_SHAPE_OFFSET_Y });
|
||||
setShapeOffset({ x: DEFAULT_SHAPE_OFFSET_X, y: initialShapeOffsetY });
|
||||
setShapeScale(DEFAULT_SHAPE_SCALE);
|
||||
return;
|
||||
}
|
||||
@@ -173,7 +148,7 @@ export function AnimatedAvatarCapture({
|
||||
y: DEFAULT_PERSON_OFFSET_Y,
|
||||
});
|
||||
setPersonScale(DEFAULT_PERSON_SCALE);
|
||||
}, [activeSection]);
|
||||
}, [activeSection, initialShapeOffsetY]);
|
||||
|
||||
const resetAllFraming = React.useCallback(() => {
|
||||
setPersonOffset({
|
||||
@@ -181,9 +156,9 @@ export function AnimatedAvatarCapture({
|
||||
y: DEFAULT_PERSON_OFFSET_Y,
|
||||
});
|
||||
setPersonScale(DEFAULT_PERSON_SCALE);
|
||||
setShapeOffset({ x: DEFAULT_SHAPE_OFFSET_X, y: DEFAULT_SHAPE_OFFSET_Y });
|
||||
setShapeOffset({ x: DEFAULT_SHAPE_OFFSET_X, y: initialShapeOffsetY });
|
||||
setShapeScale(DEFAULT_SHAPE_SCALE);
|
||||
}, []);
|
||||
}, [initialShapeOffsetY]);
|
||||
|
||||
// Custom backdrop color picker (shared HSV panel).
|
||||
const [isCustomPickerOpen, setIsCustomPickerOpen] = React.useState(false);
|
||||
@@ -278,8 +253,6 @@ export function AnimatedAvatarCapture({
|
||||
releaseCamera();
|
||||
};
|
||||
}, [releaseCamera]);
|
||||
|
||||
// Close bitmaps on unmount only — phase changes manage them explicitly.
|
||||
React.useEffect(() => releaseBitmaps, [releaseBitmaps]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -435,6 +408,28 @@ export function AnimatedAvatarCapture({
|
||||
[cameraDevices, phase, startCamera],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!autoStartCamera || phase !== "idle" || selectedCameraSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const computerCamera = preferredCameraDevice(cameraDevices, "computer");
|
||||
const iphoneCamera = preferredCameraDevice(cameraDevices, "iphone");
|
||||
const source: CameraSource =
|
||||
computerCamera || !iphoneCamera ? "computer" : "iphone";
|
||||
const cameraId =
|
||||
(source === "computer" ? computerCamera : iphoneCamera)?.deviceId ?? null;
|
||||
setSelectedCameraSource(source);
|
||||
setSelectedCameraId(cameraId);
|
||||
void startCamera(cameraId, source);
|
||||
}, [
|
||||
autoStartCamera,
|
||||
cameraDevices,
|
||||
phase,
|
||||
selectedCameraSource,
|
||||
startCamera,
|
||||
]);
|
||||
|
||||
const record = React.useCallback(async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video || phase !== "live") {
|
||||
@@ -576,10 +571,6 @@ export function AnimatedAvatarCapture({
|
||||
);
|
||||
const isFramingSection =
|
||||
activeSection === "person" || activeSection === "shape";
|
||||
|
||||
// With a host preview container, the camera feed / recording ring /
|
||||
// composed preview render inside the page's main avatar preview (via a
|
||||
// portal) so edits show exactly where the avatar will live.
|
||||
const usePortal = previewContainer !== null;
|
||||
const reviewWarning =
|
||||
phase === "review" && recording && !recording.backgroundRemoved
|
||||
@@ -608,7 +599,9 @@ export function AnimatedAvatarCapture({
|
||||
? null
|
||||
: captureHelpText;
|
||||
const showCaptureCard = !usePortal && phase !== "review";
|
||||
const showCameraPicker = ["idle", "starting", "live"].includes(phase);
|
||||
const showInlineReviewStage = !usePortal && phase === "review";
|
||||
const showCameraControls = ["idle", "starting", "live"].includes(phase);
|
||||
const showCameraPicker = !autoStartCamera && showCameraControls;
|
||||
|
||||
React.useEffect(() => {
|
||||
onPreviewActiveChange?.(usePortal);
|
||||
@@ -635,7 +628,16 @@ export function AnimatedAvatarCapture({
|
||||
}, [isCustomPickerVisible, onCustomColorPickerOpenChange]);
|
||||
|
||||
const stageContent = (
|
||||
<div className={cn("relative", usePortal ? "h-full w-full" : "h-44 w-44")}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
usePortal
|
||||
? "h-full w-full"
|
||||
: compactReview && phase === "review"
|
||||
? "h-36 w-36"
|
||||
: "h-44 w-44",
|
||||
)}
|
||||
>
|
||||
{/* Live camera preview — kept mounted so the stream can attach. */}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -646,7 +648,7 @@ export function AnimatedAvatarCapture({
|
||||
<video
|
||||
autoPlay
|
||||
className={cn(
|
||||
"h-full w-full -scale-x-100 object-cover",
|
||||
"block h-full w-full -scale-x-100 object-cover object-center",
|
||||
!isCameraVisible && "opacity-0",
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-animated-preview`}
|
||||
@@ -794,8 +796,12 @@ export function AnimatedAvatarCapture({
|
||||
<div
|
||||
className={cn(
|
||||
"relative grid content-start",
|
||||
phase === "review" ? "gap-7 pb-9 pt-2" : "gap-4 pb-5",
|
||||
phase === "review" && !showApplyButton && "mb-5",
|
||||
phase === "review"
|
||||
? compactReview
|
||||
? "gap-4 pb-2 pt-0"
|
||||
: "gap-7 pb-9 pt-2"
|
||||
: "gap-4 pb-5",
|
||||
phase === "review" && !showApplyButton && !compactReview && "mb-5",
|
||||
isCustomPickerVisible && "min-h-[504px]",
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-animated`}
|
||||
@@ -804,6 +810,10 @@ export function AnimatedAvatarCapture({
|
||||
? createPortal(stageContent, previewContainer)
|
||||
: null}
|
||||
|
||||
{showInlineReviewStage ? (
|
||||
<div className="grid place-items-center">{stageContent}</div>
|
||||
) : null}
|
||||
|
||||
{showCaptureCard ? (
|
||||
<div className="relative grid place-items-center rounded-xl bg-muted px-4 py-6">
|
||||
{usePortal ? null : stageContent}
|
||||
@@ -845,7 +855,12 @@ export function AnimatedAvatarCapture({
|
||||
) : null}
|
||||
|
||||
{phase === "review" && isFramingSection ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-2",
|
||||
compactReview && "mx-auto w-full max-w-[360px]",
|
||||
)}
|
||||
>
|
||||
<AvatarFramingSlider
|
||||
disabled={disabled || isSaving}
|
||||
max={Math.round(activeScaleMax * 100)}
|
||||
@@ -877,6 +892,7 @@ export function AnimatedAvatarCapture({
|
||||
{phase === "review" && activeSection === "color" ? (
|
||||
<AnimatedAvatarBackdropPanel
|
||||
backdropColor={backdropColor}
|
||||
compact={compactReview}
|
||||
disabled={disabled}
|
||||
isCustomBackdropSelected={isCustomBackdropSelected}
|
||||
isSaving={isSaving}
|
||||
@@ -901,45 +917,28 @@ export function AnimatedAvatarCapture({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showCameraPicker ? (
|
||||
<div className="grid gap-4">
|
||||
<AnimatedAvatarCameraPicker
|
||||
activeCameraSource={activeCameraSource}
|
||||
computerDisabled={cameraDevices.length > 0 && !computerCamera}
|
||||
disabled={disabled || phase === "starting"}
|
||||
iphoneDisabled={
|
||||
cameraDevices.length > 0 && !iphoneCamera && hasCameraLabels
|
||||
}
|
||||
onSelectSource={selectCameraSource}
|
||||
testIdPrefix={testIdPrefix}
|
||||
/>
|
||||
{usePortal && inlineCaptureHelpText ? (
|
||||
<p className="px-1 text-center text-sm text-muted-foreground">
|
||||
{inlineCaptureHelpText}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="h-14 pt-2">
|
||||
{phase === "live" ? (
|
||||
<Button
|
||||
asChild
|
||||
className="h-12 w-full rounded-xl"
|
||||
data-testid={`${testIdPrefix}-animated-record`}
|
||||
disabled={disabled}
|
||||
onClick={() => void record()}
|
||||
type="button"
|
||||
>
|
||||
<motion.button
|
||||
animate={{ opacity: 1 }}
|
||||
initial={{ opacity: 0 }}
|
||||
transition={ENTRANCE_TRANSITION}
|
||||
>
|
||||
<Video aria-hidden="true" className="mr-2 h-4 w-4" />
|
||||
Record {RECORD_SECONDS} seconds
|
||||
</motion.button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{showCameraControls ? (
|
||||
<AnimatedAvatarCameraControls
|
||||
activeCameraSource={activeCameraSource}
|
||||
compact={compactReview}
|
||||
computerDisabled={cameraDevices.length > 0 && !computerCamera}
|
||||
disabled={disabled}
|
||||
helpText={usePortal ? inlineCaptureHelpText : null}
|
||||
iphoneDisabled={
|
||||
cameraDevices.length > 0 && !iphoneCamera && hasCameraLabels
|
||||
}
|
||||
isLive={phase === "live"}
|
||||
isStarting={phase === "starting"}
|
||||
onRecord={() => void record()}
|
||||
onRetry={
|
||||
errorMessage && autoStartCamera && phase === "idle"
|
||||
? () => void startCamera(selectedCameraId, selectedCameraSource)
|
||||
: undefined
|
||||
}
|
||||
onSelectSource={selectCameraSource}
|
||||
showCameraPicker={showCameraPicker}
|
||||
testIdPrefix={testIdPrefix}
|
||||
/>
|
||||
) : usePortal && inlineCaptureHelpText ? (
|
||||
<p className="px-1 text-center text-sm text-muted-foreground">
|
||||
{inlineCaptureHelpText}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export type AnimatedAvatarCaptureProps = {
|
||||
disabled?: boolean;
|
||||
testIdPrefix: string;
|
||||
onApply: (avatarUrl: string) => void;
|
||||
previewContainer?: HTMLElement | null;
|
||||
onPreviewActiveChange?: (active: boolean) => void;
|
||||
onPreviewCaptionChange?: (caption: string | null) => void;
|
||||
onApplyPendingChange?: (isPending: boolean) => void;
|
||||
onCustomColorPickerOpenChange?: (isOpen: boolean) => void;
|
||||
registerApply?: (apply: (() => Promise<boolean>) | null) => void;
|
||||
showApplyButton?: boolean;
|
||||
autoStartCamera?: boolean;
|
||||
compactReview?: boolean;
|
||||
};
|
||||
@@ -3,16 +3,16 @@ import Picker from "@emoji-mart/react";
|
||||
import { Link2, UploadCloud } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import * as React from "react";
|
||||
import { createPortal, flushSync } from "react-dom";
|
||||
import { flushSync } from "react-dom";
|
||||
|
||||
import { AnimatedAvatarCapture } from "@/features/profile/ui/AnimatedAvatarCapture";
|
||||
import { AvatarCustomColorPanel } from "@/features/profile/ui/AvatarCustomColorPanel";
|
||||
import { ProfileAvatarModeTabs } from "@/features/profile/ui/ProfileAvatarModeTabs";
|
||||
import { useAvatarUpload } from "@/features/profile/useAvatarUpload";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import {
|
||||
AVATAR_COLORS,
|
||||
AVATAR_COLOR_SWATCHES,
|
||||
@@ -33,12 +33,13 @@ import {
|
||||
useEmojiMartStyles,
|
||||
useEmojiMartThemeVars,
|
||||
} from "./ProfileAvatarEditor.utils";
|
||||
|
||||
export { parseEmojiAvatarDataUrl } from "./ProfileAvatarEditor.utils";
|
||||
export type { AvatarMode } from "./ProfileAvatarEditor.types";
|
||||
import type {
|
||||
AvatarMode,
|
||||
ProfileAvatarEditorProps,
|
||||
} from "./ProfileAvatarEditor.types";
|
||||
|
||||
export type AvatarMode = "image" | "emoji" | "animated";
|
||||
|
||||
const MODE_TAB_ORDER: AvatarMode[] = ["image", "emoji", "animated"];
|
||||
const DONE_BUTTON_CONTENT_TRANSITION = {
|
||||
duration: 0.14,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
@@ -59,40 +60,11 @@ function waitForPendingButtonPaint() {
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
window.requestAnimationFrame(() => setTimeout(resolve, 0));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
type ProfileAvatarEditorProps = {
|
||||
avatarUrl: string;
|
||||
previewName: string;
|
||||
onUrlChange: (url: string) => void;
|
||||
emojiPickerTheme?: "auto" | "dark" | "light";
|
||||
emojiPickerThemeVars?: React.CSSProperties;
|
||||
onEmojiAvatarChange?: () => void;
|
||||
onCustomColorPickerOpenChange?: (isOpen: boolean) => void;
|
||||
onModeChange?: (mode: AvatarMode) => void;
|
||||
onUploadedAvatarChange?: (url: string | null) => void;
|
||||
onUploadingChange?: (isUploading: boolean) => void;
|
||||
onAnimatedAvatarApply?: (url: string) => void;
|
||||
onDone?: () => void;
|
||||
donePending?: boolean;
|
||||
showEmojiColorControlsWhenEmpty?: boolean;
|
||||
disabled?: boolean;
|
||||
testIdPrefix?: string;
|
||||
/** Host element the animated tab renders its live preview into. */
|
||||
animatedPreviewContainer?: HTMLElement | null;
|
||||
/** Optional host element for the mode tabs; undefined keeps them inline. */
|
||||
modeTabsContainer?: HTMLElement | null;
|
||||
/** Fires when the animated tab starts/stops occupying the host preview. */
|
||||
onAnimatedPreviewActiveChange?: (active: boolean) => void;
|
||||
/** Caption shown under the host preview while animated capture is active. */
|
||||
onAnimatedPreviewCaptionChange?: (caption: string | null) => void;
|
||||
};
|
||||
|
||||
type EmojiMartEmoji = {
|
||||
native?: string;
|
||||
};
|
||||
@@ -132,6 +104,7 @@ export function ProfileAvatarEditor({
|
||||
modeTabsContainer,
|
||||
onAnimatedPreviewActiveChange,
|
||||
onAnimatedPreviewCaptionChange,
|
||||
presentation = "default",
|
||||
}: ProfileAvatarEditorProps) {
|
||||
const { burstEmoji } = useEmojiBurst();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
@@ -166,11 +139,29 @@ export function ProfileAvatarEditor({
|
||||
number | null
|
||||
>(null);
|
||||
const documentEmojiMartThemeVars = useEmojiMartThemeVars();
|
||||
const emojiMartThemeVars = emojiPickerThemeVars ?? documentEmojiMartThemeVars;
|
||||
const emojiMartThemeVars = React.useMemo(
|
||||
() =>
|
||||
({
|
||||
...(emojiPickerThemeVars ?? documentEmojiMartThemeVars),
|
||||
...(presentation === "onboarding-modal"
|
||||
? {
|
||||
"--buzz-emoji-picker-category-icon-size": "18px",
|
||||
"--buzz-emoji-picker-fade-height": "56px",
|
||||
"--buzz-emoji-picker-fade-opacity": "1",
|
||||
"--buzz-emoji-picker-nav-button-size": "32px",
|
||||
"--buzz-emoji-picker-nav-padding-x": "12px",
|
||||
"--buzz-emoji-picker-padding": "10px",
|
||||
"--buzz-emoji-picker-scroll-padding-top": "18px",
|
||||
}
|
||||
: null),
|
||||
}) as React.CSSProperties,
|
||||
[documentEmojiMartThemeVars, emojiPickerThemeVars, presentation],
|
||||
);
|
||||
const customColorDraft = React.useMemo(
|
||||
() => hsvToHex(customHue, customSaturation, customValue),
|
||||
[customHue, customSaturation, customValue],
|
||||
);
|
||||
const isOnboardingModal = presentation === "onboarding-modal";
|
||||
const shouldShowColorControls =
|
||||
mode === "emoji" &&
|
||||
(selectedEmoji !== null || showEmojiColorControlsWhenEmpty);
|
||||
@@ -268,20 +259,55 @@ export function ProfileAvatarEditor({
|
||||
}, [mode, onDone]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isAnimatedDoneQueued) {
|
||||
return;
|
||||
}
|
||||
if (!isAnimatedDoneQueued) return;
|
||||
setIsAnimatedDoneQueued(false);
|
||||
onDone?.();
|
||||
}, [isAnimatedDoneQueued, onDone]);
|
||||
|
||||
useEmojiMartStyles(emojiPickerContainerRef, mode === "emoji");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mode !== "emoji") return;
|
||||
|
||||
let animationFrame = 0;
|
||||
let observer: MutationObserver | null = null;
|
||||
const syncSelectedEmojiButton = () => {
|
||||
const shadowRoot =
|
||||
emojiPickerContainerRef.current?.querySelector(
|
||||
"em-emoji-picker",
|
||||
)?.shadowRoot;
|
||||
if (!shadowRoot) {
|
||||
animationFrame = window.requestAnimationFrame(syncSelectedEmojiButton);
|
||||
return;
|
||||
}
|
||||
|
||||
shadowRoot
|
||||
.querySelectorAll('button[data-buzz-selected="true"]')
|
||||
.forEach((button) => {
|
||||
button.removeAttribute("data-buzz-selected");
|
||||
});
|
||||
if (selectedEmoji) {
|
||||
shadowRoot.querySelectorAll(".category button").forEach((button) => {
|
||||
if (button.getAttribute("aria-label") === selectedEmoji) {
|
||||
button.setAttribute("data-buzz-selected", "true");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
observer ??= new MutationObserver(syncSelectedEmojiButton);
|
||||
observer.observe(shadowRoot, { childList: true, subtree: true });
|
||||
};
|
||||
|
||||
animationFrame = window.requestAnimationFrame(syncSelectedEmojiButton);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [mode, selectedEmoji]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const node = modeContentRef.current;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (!node) return;
|
||||
|
||||
const updateModeContentHeight = () => {
|
||||
setModeContentHeight(node.getBoundingClientRect().height);
|
||||
@@ -313,9 +339,7 @@ export function ProfileAvatarEditor({
|
||||
}, [avatarUrl]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shouldShowColorControls) {
|
||||
setIsCustomColorPickerOpen(false);
|
||||
}
|
||||
if (!shouldShowColorControls) setIsCustomColorPickerOpen(false);
|
||||
}, [shouldShowColorControls]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
@@ -472,63 +496,26 @@ export function ProfileAvatarEditor({
|
||||
onDone &&
|
||||
!isAnyCustomColorPickerVisible &&
|
||||
(mode !== "animated" || hasAnimatedApply || isDoneButtonPending);
|
||||
const modeTabs = (
|
||||
<Tabs
|
||||
className="w-full"
|
||||
onValueChange={(nextMode) => {
|
||||
if (isInputDisabled) {
|
||||
return;
|
||||
}
|
||||
updateMode(nextMode as AvatarMode);
|
||||
}}
|
||||
value={mode}
|
||||
>
|
||||
<TabsList
|
||||
aria-label="Avatar type"
|
||||
className="relative isolate grid h-14 w-full grid-cols-3 overflow-hidden rounded-full bg-muted p-1 text-muted-foreground"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-1 left-1 top-1 z-0 rounded-full bg-background shadow transition-transform duration-[250ms] ease-out"
|
||||
style={{
|
||||
transform: `translateX(${MODE_TAB_ORDER.indexOf(mode) * 100}%)`,
|
||||
width: "calc((100% - 8px) / 3)",
|
||||
}}
|
||||
/>
|
||||
<TabsTrigger
|
||||
className="relative z-10 h-full rounded-full bg-transparent text-sm font-medium shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
disabled={isInputDisabled}
|
||||
value="image"
|
||||
>
|
||||
Image
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
className="relative z-10 h-full rounded-full bg-transparent text-sm font-medium shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
disabled={isInputDisabled}
|
||||
value="emoji"
|
||||
>
|
||||
Emoji
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
className="relative z-10 h-full rounded-full bg-transparent text-sm font-medium shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
disabled={isInputDisabled}
|
||||
value="animated"
|
||||
>
|
||||
Animated
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
const isDoneButtonDisabled =
|
||||
disabled ||
|
||||
isDoneButtonPending ||
|
||||
(isOnboardingModal && mode === "animated" && !hasAnimatedApply);
|
||||
const modeTabsContent = (
|
||||
<ProfileAvatarModeTabs
|
||||
disabled={isInputDisabled}
|
||||
mode={mode}
|
||||
onModeChange={updateMode}
|
||||
portalContainer={modeTabsContainer}
|
||||
presentation={presentation}
|
||||
/>
|
||||
);
|
||||
const modeTabsContent =
|
||||
modeTabsContainer === undefined
|
||||
? modeTabs
|
||||
: modeTabsContainer
|
||||
? createPortal(modeTabs, modeTabsContainer)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
className="mx-auto w-full max-w-[576px] border-0 p-0 text-sm"
|
||||
className={cn(
|
||||
"mx-auto w-full border-0 p-0 text-sm",
|
||||
isOnboardingModal ? "max-w-[456px]" : "max-w-[576px]",
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-editor`}
|
||||
disabled={isInputDisabled}
|
||||
onDragEnter={(event) => {
|
||||
@@ -582,26 +569,54 @@ export function ProfileAvatarEditor({
|
||||
}}
|
||||
>
|
||||
<legend className="sr-only">Avatar image picker</legend>
|
||||
<div className="relative">
|
||||
<div className="relative grid w-full gap-4">
|
||||
<div
|
||||
className="relative"
|
||||
style={
|
||||
isOnboardingModal
|
||||
? { minHeight: isAnyCustomColorPickerVisible ? 704 : 454 }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isOnboardingModal ? "flex min-h-[inherit] flex-col" : "grid gap-4",
|
||||
)}
|
||||
>
|
||||
{modeTabsContent}
|
||||
|
||||
<div
|
||||
className="overflow-hidden transition-[height] duration-[250ms] ease-out"
|
||||
className={cn(
|
||||
"transition-[height] duration-[250ms] ease-out",
|
||||
isOnboardingModal
|
||||
? cn(
|
||||
"flex min-h-0 flex-1 items-center overflow-visible",
|
||||
shouldShowColorControls && "py-6",
|
||||
)
|
||||
: "overflow-hidden",
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-mode-content-shell`}
|
||||
style={
|
||||
modeContentHeight === null
|
||||
isOnboardingModal || modeContentHeight === null
|
||||
? undefined
|
||||
: { height: modeContentHeight }
|
||||
}
|
||||
>
|
||||
<div className="overflow-visible" ref={modeContentRef}>
|
||||
<div
|
||||
className={cn("overflow-visible", isOnboardingModal && "w-full")}
|
||||
ref={modeContentRef}
|
||||
>
|
||||
{mode === "image" ? (
|
||||
<div className="grid content-start gap-3">
|
||||
<button
|
||||
className={cn(
|
||||
"relative flex h-[120px] flex-col items-center justify-center gap-3 overflow-hidden rounded-xl border border-transparent bg-muted text-foreground transition-[background-color,border-color,box-shadow,color] duration-[250ms] ease-out hover:bg-muted/80 disabled:opacity-60",
|
||||
isOnboardingModal
|
||||
? "relative flex h-32 flex-col items-center justify-center overflow-hidden rounded-lg border border-dashed border-[color:rgb(var(--buzz-onboarding-avatar-control-fg)_/_0.7)] bg-transparent text-[rgb(var(--buzz-onboarding-avatar-control-fg))] transition-[background-color,border-color,box-shadow,color] duration-[250ms] ease-out hover:bg-[color:rgb(var(--buzz-onboarding-avatar-accent-bg)_/_0.18)] disabled:opacity-60"
|
||||
: "relative flex h-[120px] flex-col items-center justify-center gap-3 overflow-hidden rounded-xl border border-transparent bg-muted text-foreground transition-[background-color,border-color,box-shadow,color] duration-[250ms] ease-out hover:bg-muted/80 disabled:opacity-60",
|
||||
isImageDropActive &&
|
||||
"border-primary bg-primary/10 text-primary ring-1 ring-primary/35 hover:bg-primary/10",
|
||||
(isOnboardingModal
|
||||
? "border-[rgb(var(--buzz-onboarding-avatar-control-fg))] bg-[color:rgb(var(--buzz-onboarding-avatar-accent-bg)_/_0.24)]"
|
||||
: "border-primary bg-primary/10 text-primary ring-1 ring-primary/35 hover:bg-primary/10"),
|
||||
)}
|
||||
data-dragging={isImageDropActive ? "true" : undefined}
|
||||
data-testid={`${testIdPrefix}-upload`}
|
||||
@@ -617,7 +632,7 @@ export function ProfileAvatarEditor({
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-drop-mask`}
|
||||
/>
|
||||
{isUploading ? (
|
||||
{isOnboardingModal ? null : isUploading ? (
|
||||
<Spinner
|
||||
aria-hidden
|
||||
className="relative h-8 w-8 border-2 text-muted-foreground"
|
||||
@@ -632,14 +647,22 @@ export function ProfileAvatarEditor({
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"relative text-sm font-medium text-muted-foreground transition-colors duration-[250ms] ease-out",
|
||||
isImageDropActive && "text-primary",
|
||||
"relative transition-colors duration-[250ms] ease-out",
|
||||
isOnboardingModal
|
||||
? "text-sm font-normal text-[rgb(var(--buzz-onboarding-avatar-control-fg))]"
|
||||
: "text-sm font-medium text-muted-foreground",
|
||||
isImageDropActive &&
|
||||
(isOnboardingModal
|
||||
? "text-[rgb(var(--buzz-onboarding-avatar-control-fg))]"
|
||||
: "text-primary"),
|
||||
)}
|
||||
>
|
||||
{isUploading ? (
|
||||
"Uploading..."
|
||||
) : isImageDropActive ? (
|
||||
"Drop image here"
|
||||
) : isOnboardingModal ? (
|
||||
"Drag or browse"
|
||||
) : (
|
||||
<>
|
||||
Drop or{" "}
|
||||
@@ -651,12 +674,26 @@ export function ProfileAvatarEditor({
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex h-16 items-center gap-3 rounded-xl bg-muted px-5 transition-colors duration-[250ms] ease-out focus-within:bg-muted/80">
|
||||
<Link2 className="h-4 w-4 text-muted-foreground" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center transition-colors duration-[250ms] ease-out",
|
||||
isOnboardingModal
|
||||
? "h-[52px] rounded-lg border border-[color:rgb(var(--buzz-onboarding-avatar-control-fg)_/_0.45)] bg-transparent px-5 focus-within:border-[rgb(var(--buzz-onboarding-avatar-control-fg))]"
|
||||
: "h-16 gap-3 rounded-xl bg-muted px-5 focus-within:bg-muted/80",
|
||||
)}
|
||||
>
|
||||
{isOnboardingModal ? null : (
|
||||
<Link2 className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<input
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm font-medium text-foreground outline-none placeholder:text-muted-foreground"
|
||||
className={cn(
|
||||
"min-w-0 flex-1 bg-transparent outline-none",
|
||||
isOnboardingModal
|
||||
? "text-center text-sm font-normal text-foreground placeholder:text-[color:rgb(var(--buzz-onboarding-avatar-control-fg)_/_0.55)]"
|
||||
: "text-sm font-medium text-foreground placeholder:text-muted-foreground",
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-url`}
|
||||
disabled={isInputDisabled}
|
||||
onBlur={() => {
|
||||
@@ -679,7 +716,11 @@ export function ProfileAvatarEditor({
|
||||
applyUrl();
|
||||
}
|
||||
}}
|
||||
placeholder="Paste a URL (Slack profile, etc.)"
|
||||
placeholder={
|
||||
isOnboardingModal
|
||||
? "Paste a URL"
|
||||
: "Paste a URL (Slack profile, etc.)"
|
||||
}
|
||||
spellCheck={false}
|
||||
type="url"
|
||||
value={urlDraft}
|
||||
@@ -708,6 +749,8 @@ export function ProfileAvatarEditor({
|
||||
onPreviewCaptionChange={onAnimatedPreviewCaptionChange}
|
||||
previewContainer={animatedPreviewContainer}
|
||||
registerApply={registerAnimatedApply}
|
||||
autoStartCamera={isOnboardingModal}
|
||||
compactReview={isOnboardingModal}
|
||||
showApplyButton={!onDone}
|
||||
testIdPrefix={testIdPrefix}
|
||||
/>
|
||||
@@ -723,8 +766,8 @@ export function ProfileAvatarEditor({
|
||||
data={emojiData}
|
||||
dynamicWidth
|
||||
emojiButtonRadius="999px"
|
||||
emojiButtonSize={64}
|
||||
emojiSize={48}
|
||||
emojiButtonSize={isOnboardingModal ? 44 : 64}
|
||||
emojiSize={isOnboardingModal ? 28 : 48}
|
||||
icons="outline"
|
||||
navPosition="bottom"
|
||||
onEmojiSelect={(
|
||||
@@ -741,7 +784,9 @@ export function ProfileAvatarEditor({
|
||||
selectedEmoji === null
|
||||
? randomInitialEmojiAvatarColor()
|
||||
: selectedColor;
|
||||
burstEmoji(emoji.native, event);
|
||||
if (!isOnboardingModal) {
|
||||
burstEmoji(emoji.native, event);
|
||||
}
|
||||
setSelectedEmoji(emoji.native);
|
||||
setSelectedColor(nextColor);
|
||||
applyEmojiAvatar(emoji.native, nextColor);
|
||||
@@ -768,7 +813,10 @@ export function ProfileAvatarEditor({
|
||||
inert={shouldShowColorControls ? undefined : true}
|
||||
>
|
||||
<div
|
||||
className="grid grid-cols-8 justify-items-center gap-3 rounded-xl bg-muted p-4 transition-colors duration-[250ms] ease-out"
|
||||
className={cn(
|
||||
"grid grid-cols-8 justify-items-center rounded-xl bg-muted transition-colors duration-[250ms] ease-out",
|
||||
isOnboardingModal ? "gap-2 p-3" : "gap-3 p-4",
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-color-grid`}
|
||||
>
|
||||
{AVATAR_COLOR_SWATCHES.map((swatch) => {
|
||||
@@ -794,7 +842,8 @@ export function ProfileAvatarEditor({
|
||||
}
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
"relative h-10 w-10 scroll-mb-52 rounded-full border border-border transition-transform duration-200 ease-out hover:scale-[1.15] focus-visible:scale-[1.15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"relative scroll-mb-52 rounded-full border border-border transition-transform duration-200 ease-out hover:scale-[1.15] focus-visible:scale-[1.15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isOnboardingModal ? "h-7 w-7" : "h-10 w-10",
|
||||
isCustomSwatch &&
|
||||
!selectedEmoji &&
|
||||
"cursor-not-allowed opacity-45 hover:scale-100 focus-visible:scale-100",
|
||||
@@ -818,7 +867,10 @@ export function ProfileAvatarEditor({
|
||||
>
|
||||
{isSelected ? (
|
||||
<span
|
||||
className="absolute inset-1 rounded-full border-[3px]"
|
||||
className={cn(
|
||||
"absolute rounded-full border-[3px]",
|
||||
isOnboardingModal ? "inset-0.5" : "inset-1",
|
||||
)}
|
||||
style={{
|
||||
borderColor: contrastColorForBackground(
|
||||
isCustomSwatch ? selectedColor : swatch,
|
||||
@@ -853,11 +905,18 @@ export function ProfileAvatarEditor({
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{shouldShowDoneButton ? (
|
||||
<Button asChild className="mt-2 h-12 w-full rounded-xl">
|
||||
<Button
|
||||
asChild
|
||||
className={cn(
|
||||
isOnboardingModal
|
||||
? "mx-auto mt-0 h-[2.375rem] min-w-24 rounded-full bg-[rgb(var(--buzz-onboarding-avatar-action-bg))] px-6 text-sm font-medium text-[rgb(var(--buzz-onboarding-avatar-action-fg))] hover:bg-[color:rgb(var(--buzz-onboarding-avatar-action-bg)_/_0.9)]"
|
||||
: "mt-2 h-12 w-full rounded-xl",
|
||||
)}
|
||||
>
|
||||
<motion.button
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
data-testid={`${testIdPrefix}-done`}
|
||||
disabled={disabled || isDoneButtonPending}
|
||||
disabled={isDoneButtonDisabled}
|
||||
exit={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
@@ -915,7 +974,7 @@ export function ProfileAvatarEditor({
|
||||
key="ready"
|
||||
transition={DONE_BUTTON_CONTENT_TRANSITION}
|
||||
>
|
||||
Done
|
||||
{isOnboardingModal ? "Save" : "Done"}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type * as React from "react";
|
||||
|
||||
export type AvatarMode = "image" | "emoji" | "animated";
|
||||
export type AvatarEditorPresentation = "default" | "onboarding-modal";
|
||||
|
||||
export type ProfileAvatarEditorProps = {
|
||||
avatarUrl: string;
|
||||
previewName: string;
|
||||
onUrlChange: (url: string) => void;
|
||||
emojiPickerTheme?: "auto" | "dark" | "light";
|
||||
emojiPickerThemeVars?: React.CSSProperties;
|
||||
onEmojiAvatarChange?: () => void;
|
||||
onCustomColorPickerOpenChange?: (isOpen: boolean) => void;
|
||||
onModeChange?: (mode: AvatarMode) => void;
|
||||
onUploadedAvatarChange?: (url: string | null) => void;
|
||||
onUploadingChange?: (isUploading: boolean) => void;
|
||||
onAnimatedAvatarApply?: (url: string) => void;
|
||||
onDone?: () => void;
|
||||
donePending?: boolean;
|
||||
showEmojiColorControlsWhenEmpty?: boolean;
|
||||
disabled?: boolean;
|
||||
testIdPrefix?: string;
|
||||
animatedPreviewContainer?: HTMLElement | null;
|
||||
modeTabsContainer?: HTMLElement | null;
|
||||
onAnimatedPreviewActiveChange?: (active: boolean) => void;
|
||||
onAnimatedPreviewCaptionChange?: (caption: string | null) => void;
|
||||
presentation?: AvatarEditorPresentation;
|
||||
};
|
||||
@@ -71,7 +71,7 @@ const EMOJI_MART_SHADOW_CSS = `
|
||||
}
|
||||
|
||||
#root {
|
||||
--padding: 16px;
|
||||
--padding: var(--buzz-emoji-picker-padding, 16px);
|
||||
--sidebar-width: 0px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -79,16 +79,34 @@ const EMOJI_MART_SHADOW_CSS = `
|
||||
max-height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
#root::after {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--buzz-emoji-picker-rgb-background), 0),
|
||||
rgba(var(--buzz-emoji-picker-rgb-background), 0.98)
|
||||
);
|
||||
bottom: calc(var(--buzz-emoji-picker-nav-button-size, 40px) + 24px);
|
||||
content: "";
|
||||
height: var(--buzz-emoji-picker-fade-height, 0px);
|
||||
left: 0;
|
||||
opacity: var(--buzz-emoji-picker-fade-opacity, 0);
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-left: var(--padding);
|
||||
padding-right: var(--padding);
|
||||
padding-top: 28px;
|
||||
padding-top: var(--buzz-emoji-picker-scroll-padding-top, 28px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -110,7 +128,17 @@ const EMOJI_MART_SHADOW_CSS = `
|
||||
}
|
||||
|
||||
.category button .background {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
background-color: transparent;
|
||||
transition: background-color var(--duration) var(--easing);
|
||||
}
|
||||
|
||||
.category button:hover .background,
|
||||
.category button[data-buzz-selected="true"] .background {
|
||||
background-color: rgba(var(--em-rgb-color), 0.14);
|
||||
}
|
||||
|
||||
.category button[data-buzz-selected="true"] .background {
|
||||
background-color: rgba(var(--em-rgb-color), 0.2);
|
||||
}
|
||||
|
||||
.row {
|
||||
@@ -122,7 +150,7 @@ const EMOJI_MART_SHADOW_CSS = `
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
justify-content: space-between;
|
||||
padding: 8px 24px 16px;
|
||||
padding: 8px var(--buzz-emoji-picker-nav-padding-x, 24px) 16px;
|
||||
}
|
||||
|
||||
#nav .bar {
|
||||
@@ -139,14 +167,14 @@ const EMOJI_MART_SHADOW_CSS = `
|
||||
border-radius: 999px;
|
||||
color: rgba(var(--em-rgb-color), 0.58);
|
||||
display: flex;
|
||||
flex: 0 0 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 var(--buzz-emoji-picker-nav-button-size, 40px);
|
||||
height: var(--buzz-emoji-picker-nav-button-size, 40px);
|
||||
justify-content: center;
|
||||
transition:
|
||||
background-color var(--duration) var(--easing),
|
||||
color var(--duration) var(--easing),
|
||||
transform var(--duration) var(--easing);
|
||||
width: 40px;
|
||||
width: var(--buzz-emoji-picker-nav-button-size, 40px);
|
||||
}
|
||||
|
||||
#nav button:hover,
|
||||
@@ -164,8 +192,8 @@ const EMOJI_MART_SHADOW_CSS = `
|
||||
|
||||
#nav svg,
|
||||
#nav img {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
height: var(--buzz-emoji-picker-category-icon-size, 24px);
|
||||
width: var(--buzz-emoji-picker-category-icon-size, 24px);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type {
|
||||
AvatarEditorPresentation,
|
||||
AvatarMode,
|
||||
} from "@/features/profile/ui/ProfileAvatarEditor.types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
|
||||
const MODE_TAB_ORDER: AvatarMode[] = ["image", "emoji", "animated"];
|
||||
const MODE_TAB_LABELS: Record<AvatarMode, string> = {
|
||||
animated: "Animated",
|
||||
emoji: "Emoji",
|
||||
image: "Image",
|
||||
};
|
||||
|
||||
type ProfileAvatarModeTabsProps = {
|
||||
disabled: boolean;
|
||||
mode: AvatarMode;
|
||||
onModeChange: (mode: AvatarMode) => void;
|
||||
presentation: AvatarEditorPresentation;
|
||||
portalContainer?: HTMLElement | null;
|
||||
};
|
||||
|
||||
export function ProfileAvatarModeTabs({
|
||||
disabled,
|
||||
mode,
|
||||
onModeChange,
|
||||
presentation,
|
||||
portalContainer,
|
||||
}: ProfileAvatarModeTabsProps) {
|
||||
const isOnboardingModal = presentation === "onboarding-modal";
|
||||
const tabs = (
|
||||
<Tabs
|
||||
className={isOnboardingModal ? "flex w-full justify-center" : "w-full"}
|
||||
onValueChange={(nextMode) => {
|
||||
if (!disabled) onModeChange(nextMode as AvatarMode);
|
||||
}}
|
||||
value={mode}
|
||||
>
|
||||
<TabsList
|
||||
aria-label="Avatar type"
|
||||
className={cn(
|
||||
isOnboardingModal
|
||||
? "flex h-10 w-auto gap-2 rounded-none bg-transparent p-0 text-muted-foreground"
|
||||
: "relative isolate grid h-14 w-full grid-cols-3 overflow-hidden rounded-full bg-muted p-1 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isOnboardingModal ? null : (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-1 left-1 top-1 z-0 rounded-full bg-background shadow transition-transform duration-[250ms] ease-out"
|
||||
style={{
|
||||
transform: `translateX(${MODE_TAB_ORDER.indexOf(mode) * 100}%)`,
|
||||
width: "calc((100% - 8px) / 3)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{MODE_TAB_ORDER.map((tabMode) => (
|
||||
<TabsTrigger
|
||||
className={cn(
|
||||
isOnboardingModal
|
||||
? "relative z-10 h-10 rounded-[6px] px-4 text-sm font-normal shadow-none transition-colors data-[state=active]:bg-[rgb(var(--buzz-onboarding-avatar-action-bg))] data-[state=active]:text-[rgb(var(--buzz-onboarding-avatar-action-fg))] data-[state=active]:shadow-none"
|
||||
: "relative z-10 h-full rounded-full bg-transparent text-sm font-medium shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none",
|
||||
)}
|
||||
disabled={disabled}
|
||||
key={tabMode}
|
||||
value={tabMode}
|
||||
>
|
||||
{MODE_TAB_LABELS[tabMode]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
return portalContainer === undefined
|
||||
? tabs
|
||||
: portalContainer
|
||||
? createPortal(tabs, portalContainer)
|
||||
: null;
|
||||
}
|
||||
@@ -285,6 +285,13 @@
|
||||
--buzz-onboarding-emoji-picker-background: 245, 245, 245;
|
||||
--buzz-onboarding-emoji-picker-color: 23, 23, 23;
|
||||
--buzz-onboarding-emoji-picker-input: 255, 255, 255;
|
||||
--buzz-onboarding-avatar-dialog-bg: 255 255 255;
|
||||
--buzz-onboarding-avatar-accent-bg: 240 240 205;
|
||||
--buzz-onboarding-avatar-accent-fg: 113 113 6;
|
||||
--buzz-onboarding-avatar-action-bg: 23 23 23;
|
||||
--buzz-onboarding-avatar-action-fg: 240 240 205;
|
||||
--buzz-onboarding-avatar-control-fg: 113 113 6;
|
||||
--buzz-onboarding-avatar-dialog-shadow: 0 0 0;
|
||||
}
|
||||
|
||||
.buzz-onboarding-key-text {
|
||||
@@ -310,9 +317,20 @@
|
||||
--buzz-onboarding-emoji-picker-background: 38, 38, 38;
|
||||
--buzz-onboarding-emoji-picker-color: 250, 250, 250;
|
||||
--buzz-onboarding-emoji-picker-input: 10, 10, 10;
|
||||
--buzz-onboarding-avatar-dialog-bg: 24 24 20;
|
||||
--buzz-onboarding-avatar-accent-bg: 211 211 163;
|
||||
--buzz-onboarding-avatar-accent-fg: 24 24 20;
|
||||
--buzz-onboarding-avatar-action-bg: 113 113 6;
|
||||
--buzz-onboarding-avatar-action-fg: 240 240 205;
|
||||
--buzz-onboarding-avatar-control-fg: 224 224 176;
|
||||
--buzz-onboarding-avatar-dialog-shadow: 0 0 0;
|
||||
}
|
||||
|
||||
.buzz-onboarding-neutral-theme[data-system-color-scheme="light"] {
|
||||
.buzz-onboarding-neutral-theme[data-system-color-scheme="light"],
|
||||
.dark
|
||||
.buzz-onboarding-neutral-theme[data-system-color-scheme="light"]:not(
|
||||
.buzz-startup-shell
|
||||
) {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 9%;
|
||||
--primary: 0 0% 9%;
|
||||
@@ -329,6 +347,13 @@
|
||||
--buzz-onboarding-emoji-picker-background: 245, 245, 245;
|
||||
--buzz-onboarding-emoji-picker-color: 23, 23, 23;
|
||||
--buzz-onboarding-emoji-picker-input: 255, 255, 255;
|
||||
--buzz-onboarding-avatar-dialog-bg: 255 255 255;
|
||||
--buzz-onboarding-avatar-accent-bg: 240 240 205;
|
||||
--buzz-onboarding-avatar-accent-fg: 113 113 6;
|
||||
--buzz-onboarding-avatar-action-bg: 23 23 23;
|
||||
--buzz-onboarding-avatar-action-fg: 240 240 205;
|
||||
--buzz-onboarding-avatar-control-fg: 113 113 6;
|
||||
--buzz-onboarding-avatar-dialog-shadow: 0 0 0;
|
||||
}
|
||||
|
||||
.buzz-onboarding-neutral-theme[data-system-color-scheme="dark"]:not(
|
||||
@@ -350,6 +375,13 @@
|
||||
--buzz-onboarding-emoji-picker-background: 38, 38, 38;
|
||||
--buzz-onboarding-emoji-picker-color: 250, 250, 250;
|
||||
--buzz-onboarding-emoji-picker-input: 10, 10, 10;
|
||||
--buzz-onboarding-avatar-dialog-bg: 24 24 20;
|
||||
--buzz-onboarding-avatar-accent-bg: 211 211 163;
|
||||
--buzz-onboarding-avatar-accent-fg: 24 24 20;
|
||||
--buzz-onboarding-avatar-action-bg: 113 113 6;
|
||||
--buzz-onboarding-avatar-action-fg: 240 240 205;
|
||||
--buzz-onboarding-avatar-control-fg: 224 224 176;
|
||||
--buzz-onboarding-avatar-dialog-shadow: 0 0 0;
|
||||
}
|
||||
|
||||
/* The key-import card is viewport-centered on ordinary windows, independent
|
||||
|
||||
@@ -1,120 +1,9 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { installFakeCamera } from "../helpers/fakeCamera";
|
||||
import { openSettings } from "../helpers/settings";
|
||||
|
||||
/**
|
||||
* Stub getUserMedia with a canvas-generated stream (animated gradient with a
|
||||
* moving circle) so the camera phases work headless and deterministically —
|
||||
* Playwright's bundled headless shell has no media capture support.
|
||||
*/
|
||||
function installFakeCamera(
|
||||
page: import("@playwright/test").Page,
|
||||
options: { cameraDelayMs?: number; holdCamera?: boolean } = {},
|
||||
) {
|
||||
return page.addInitScript(
|
||||
(cameraOptions) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 640;
|
||||
canvas.height = 480;
|
||||
const context = canvas.getContext("2d");
|
||||
let hue = 0;
|
||||
setInterval(() => {
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
hue = (hue + 7) % 360;
|
||||
context.fillStyle = `hsl(${hue} 80% 60%)`;
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.beginPath();
|
||||
context.arc(
|
||||
canvas.width / 2 + Math.sin(hue / 30) * 60,
|
||||
canvas.height / 2,
|
||||
90,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
context.fill();
|
||||
}, 90);
|
||||
const stream = canvas.captureStream(15);
|
||||
const mediaDevices = navigator.mediaDevices ?? ({} as MediaDevices);
|
||||
if (!navigator.mediaDevices) {
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
configurable: true,
|
||||
value: mediaDevices,
|
||||
});
|
||||
}
|
||||
const devices: MediaDeviceInfo[] = [
|
||||
{
|
||||
deviceId: "builtin-camera",
|
||||
groupId: "mac",
|
||||
kind: "videoinput",
|
||||
label: "FaceTime HD Camera",
|
||||
toJSON() {
|
||||
return this;
|
||||
},
|
||||
} as MediaDeviceInfo,
|
||||
{
|
||||
deviceId: "iphone-continuity",
|
||||
groupId: "iphone",
|
||||
kind: "videoinput",
|
||||
label: "Kenny's iPhone Camera",
|
||||
toJSON() {
|
||||
return this;
|
||||
},
|
||||
} as MediaDeviceInfo,
|
||||
];
|
||||
const testWindow = window as Window & {
|
||||
__BUZZ_E2E_CAMERA_CONSTRAINTS__?: MediaStreamConstraints[];
|
||||
__BUZZ_E2E_CAMERA_REQUEST_COUNT__?: number;
|
||||
__BUZZ_E2E_RELEASE_CAMERA__?: () => void;
|
||||
};
|
||||
testWindow.__BUZZ_E2E_CAMERA_CONSTRAINTS__ = [];
|
||||
testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ = 0;
|
||||
let releaseCamera: (() => void) | null = null;
|
||||
testWindow.__BUZZ_E2E_RELEASE_CAMERA__ = () => {
|
||||
releaseCamera?.();
|
||||
releaseCamera = null;
|
||||
};
|
||||
Object.defineProperty(mediaDevices, "enumerateDevices", {
|
||||
configurable: true,
|
||||
value: () => Promise.resolve(devices),
|
||||
});
|
||||
Object.defineProperty(mediaDevices, "addEventListener", {
|
||||
configurable: true,
|
||||
value: () => {},
|
||||
});
|
||||
Object.defineProperty(mediaDevices, "removeEventListener", {
|
||||
configurable: true,
|
||||
value: () => {},
|
||||
});
|
||||
Object.defineProperty(mediaDevices, "getUserMedia", {
|
||||
configurable: true,
|
||||
value: async (constraints: MediaStreamConstraints) => {
|
||||
testWindow.__BUZZ_E2E_CAMERA_CONSTRAINTS__?.push(constraints);
|
||||
testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ =
|
||||
(testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ ?? 0) + 1;
|
||||
if (cameraOptions.holdCamera) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseCamera = resolve;
|
||||
});
|
||||
} else if (cameraOptions.cameraDelayMs > 0) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, cameraOptions.cameraDelayMs),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(stream);
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
cameraDelayMs: options.cameraDelayMs ?? 0,
|
||||
holdCamera: options.holdCamera ?? false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// The review editor (preview + framing + poster strip + backdrop panel) is
|
||||
// taller than the default 720px viewport — raise it so the whole editor remains visible.
|
||||
test.use({ viewport: { height: 1280, width: 1280 } });
|
||||
|
||||
@@ -303,7 +303,7 @@ test("Welcome failure retries once before allowing starter channel setup to be s
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Build your profile" }),
|
||||
).toBeVisible();
|
||||
await page.getByLabel("Community display name").fill("Tyler");
|
||||
await page.getByLabel("Community username").fill("Tyler");
|
||||
await page.getByTestId("community-profile-next").click();
|
||||
|
||||
await enterButton.click();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
import { npubEncode, nsecEncode } from "nostr-tools/nip19";
|
||||
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
import { installFakeCamera } from "../helpers/fakeCamera";
|
||||
import {
|
||||
E2E_IDENTITY_OVERRIDE_STORAGE_KEY,
|
||||
seedActiveIdentity,
|
||||
@@ -967,6 +968,7 @@ test("connected first-community profile step cannot discard resumable onboarding
|
||||
relayUrl: "wss://default.example.com",
|
||||
communityName: "Default",
|
||||
communityId: "e2e-default-community",
|
||||
addedCommunity: true,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
@@ -977,6 +979,7 @@ test("connected first-community profile step cannot discard resumable onboarding
|
||||
transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
|
||||
},
|
||||
);
|
||||
await installFakeCamera(page, { failRequests: 1 });
|
||||
await installMockBridge(page, undefined, {
|
||||
relayWsUrl: "wss://default.example.com",
|
||||
skipOnboardingSeed: true,
|
||||
@@ -987,6 +990,7 @@ test("connected first-community profile step cannot discard resumable onboarding
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Build your profile" }),
|
||||
).toBeVisible();
|
||||
const profileMain = page.getByTestId("community-profile-main");
|
||||
const profileHeading = page.getByRole("heading", {
|
||||
name: "Build your profile",
|
||||
});
|
||||
@@ -1008,18 +1012,197 @@ test("connected first-community profile step cannot discard resumable onboarding
|
||||
const styles = window.getComputedStyle(element);
|
||||
return {
|
||||
backgroundColor: styles.backgroundColor,
|
||||
borderColor: styles.borderColor,
|
||||
borderRadius: styles.borderRadius,
|
||||
boxShadow: styles.boxShadow,
|
||||
fontSize: styles.fontSize,
|
||||
};
|
||||
});
|
||||
expect(nameKeyStyles.backgroundColor).toMatch(
|
||||
/^(rgba\(255, 255, 255, 0\.95\)|oklab\(.+ \/ 0\.95\))$/,
|
||||
);
|
||||
expect(nameKeyStyles.borderColor).toBe("rgba(113, 113, 6, 0.28)");
|
||||
expect(nameKeyStyles.boxShadow).toContain(
|
||||
"rgba(113, 113, 6, 0.5) 0px 0px 0px 1px inset",
|
||||
);
|
||||
expect(nameKeyStyles).toMatchObject({
|
||||
borderRadius: "16px",
|
||||
fontSize: "14px",
|
||||
});
|
||||
await expect(page.getByText("Your name", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Your username", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("community-onboarding-flow")).toHaveAttribute(
|
||||
"data-system-color-scheme",
|
||||
/^(light|dark)$/,
|
||||
);
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await expect(page.getByTestId("community-onboarding-flow")).toHaveAttribute(
|
||||
"data-system-color-scheme",
|
||||
"dark",
|
||||
);
|
||||
await avatarButton.click();
|
||||
const avatarDialog = page.getByRole("dialog", { name: "Edit your avatar" });
|
||||
await expect(avatarDialog).toBeVisible();
|
||||
await expect(avatarDialog).toHaveAttribute(
|
||||
"data-system-color-scheme",
|
||||
"light",
|
||||
);
|
||||
const dialogStyles = await avatarDialog.evaluate((element) => {
|
||||
const styles = window.getComputedStyle(element);
|
||||
return {
|
||||
backgroundColor: styles.backgroundColor,
|
||||
boxShadow: styles.boxShadow,
|
||||
color: styles.color,
|
||||
};
|
||||
});
|
||||
expect(dialogStyles.backgroundColor).toBe("rgb(255, 255, 255)");
|
||||
expect(dialogStyles.color).toBe("rgb(23, 23, 23)");
|
||||
expect(dialogStyles.boxShadow).not.toBe("none");
|
||||
const dialogOverlay = page.getByTestId("dialog-overlay");
|
||||
const overlayStyles = await dialogOverlay.evaluate((element) => {
|
||||
const styles = window.getComputedStyle(element);
|
||||
return {
|
||||
backdropFilter: styles.backdropFilter,
|
||||
backgroundColor: styles.backgroundColor,
|
||||
};
|
||||
});
|
||||
expect(overlayStyles.backgroundColor).toBe("rgba(0, 0, 0, 0)");
|
||||
expect(overlayStyles.backdropFilter).toBe("none");
|
||||
const dialogLayout = await avatarDialog.evaluate((element) => ({
|
||||
clientHeight: element.clientHeight,
|
||||
clientWidth: element.clientWidth,
|
||||
scrollHeight: element.scrollHeight,
|
||||
}));
|
||||
const editorWidth = await page
|
||||
.getByTestId("community-avatar-editor")
|
||||
.evaluate((element) => element.clientWidth);
|
||||
const uploadHeight = await page
|
||||
.getByTestId("community-avatar-upload")
|
||||
.evaluate((element) => element.clientHeight);
|
||||
const urlBox = await page.getByTestId("community-avatar-url").boundingBox();
|
||||
const dialogBox = await avatarDialog.boundingBox();
|
||||
if (!dialogBox || !urlBox) {
|
||||
throw new Error("Could not measure avatar dialog layout");
|
||||
}
|
||||
expect(dialogLayout.clientWidth).toBeLessThanOrEqual(560);
|
||||
const imageDialogHeight = dialogLayout.clientHeight;
|
||||
const dialogTransition = await avatarDialog.evaluate(
|
||||
(element) => window.getComputedStyle(element).transitionProperty,
|
||||
);
|
||||
expect(dialogTransition).toContain("height");
|
||||
expect(editorWidth).toBe(456);
|
||||
expect(uploadHeight).toBe(126);
|
||||
expect(dialogLayout.scrollHeight).toBeLessThanOrEqual(
|
||||
dialogLayout.clientHeight,
|
||||
);
|
||||
expect(urlBox.y).toBeGreaterThanOrEqual(dialogBox.y);
|
||||
expect(urlBox.y + urlBox.height).toBeLessThanOrEqual(
|
||||
dialogBox.y + dialogBox.height,
|
||||
);
|
||||
const saveButton = page.getByTestId("community-avatar-done");
|
||||
const modeContentShell = page.getByTestId(
|
||||
"community-avatar-mode-content-shell",
|
||||
);
|
||||
await page.waitForTimeout(300);
|
||||
const measureAnchoredEditorLayout = async () => {
|
||||
const [tabsBox, contentShellBox, contentBox, saveBox] = await Promise.all([
|
||||
page.getByRole("tablist", { name: "Avatar type" }).boundingBox(),
|
||||
modeContentShell.boundingBox(),
|
||||
modeContentShell.locator(":scope > div").boundingBox(),
|
||||
saveButton.boundingBox(),
|
||||
]);
|
||||
if (!tabsBox || !contentShellBox || !contentBox || !saveBox) {
|
||||
throw new Error("Could not measure anchored avatar editor layout");
|
||||
}
|
||||
return { tabsBox, contentShellBox, contentBox, saveBox };
|
||||
};
|
||||
const imageEditorLayout = await measureAnchoredEditorLayout();
|
||||
expect(
|
||||
Math.abs(
|
||||
imageEditorLayout.contentBox.y +
|
||||
imageEditorLayout.contentBox.height / 2 -
|
||||
(imageEditorLayout.contentShellBox.y +
|
||||
imageEditorLayout.contentShellBox.height / 2),
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
const saveStyles = await saveButton.evaluate((element) => {
|
||||
const styles = window.getComputedStyle(element);
|
||||
return { backgroundColor: styles.backgroundColor, color: styles.color };
|
||||
});
|
||||
expect(saveStyles).toEqual({
|
||||
backgroundColor: "rgb(23, 23, 23)",
|
||||
color: "rgb(240, 240, 205)",
|
||||
});
|
||||
const defaultDialogHeight = imageDialogHeight;
|
||||
await page.getByRole("tab", { name: "Emoji" }).click();
|
||||
await expect
|
||||
.poll(() => avatarDialog.evaluate((element) => element.clientHeight))
|
||||
.toBe(defaultDialogHeight);
|
||||
await page.waitForTimeout(300);
|
||||
const emojiEditorLayout = await measureAnchoredEditorLayout();
|
||||
expect(emojiEditorLayout.saveBox.y).toBe(imageEditorLayout.saveBox.y);
|
||||
await page.getByRole("tab", { name: "Animated" }).click();
|
||||
await expect(saveButton).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("community-avatar-animated-error"),
|
||||
).toContainText("Could not access the camera");
|
||||
const retryCameraButton = page.getByTestId("community-avatar-animated-retry");
|
||||
await expect(retryCameraButton).toHaveText("Try camera again");
|
||||
await retryCameraButton.click();
|
||||
const captureButton = page.getByTestId("community-avatar-animated-record");
|
||||
await expect(captureButton).toHaveText("Capture 3 sec video");
|
||||
await captureButton.click();
|
||||
await expect(
|
||||
page.getByTestId("community-avatar-animated-sections"),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await expect(saveButton).toBeVisible();
|
||||
await page.getByRole("tab", { name: "Emoji" }).click();
|
||||
await selectFirstEmojiFromPicker(page);
|
||||
await expect
|
||||
.poll(() => avatarDialog.evaluate((element) => element.clientHeight))
|
||||
.toBeGreaterThan(defaultDialogHeight);
|
||||
await page.waitForTimeout(300);
|
||||
const selectedEmojiDialogHeight = await avatarDialog.evaluate(
|
||||
(element) => element.clientHeight,
|
||||
);
|
||||
const expandedEmojiLayout = await measureAnchoredEditorLayout();
|
||||
expect(
|
||||
expandedEmojiLayout.contentBox.y - expandedEmojiLayout.contentShellBox.y,
|
||||
).toBeGreaterThanOrEqual(24);
|
||||
expect(
|
||||
expandedEmojiLayout.contentShellBox.y +
|
||||
expandedEmojiLayout.contentShellBox.height -
|
||||
(expandedEmojiLayout.contentBox.y +
|
||||
expandedEmojiLayout.contentBox.height),
|
||||
).toBeGreaterThanOrEqual(24);
|
||||
expect(
|
||||
expandedEmojiLayout.contentBox.y -
|
||||
(expandedEmojiLayout.tabsBox.y + expandedEmojiLayout.tabsBox.height),
|
||||
).toBeGreaterThanOrEqual(24);
|
||||
expect(
|
||||
expandedEmojiLayout.saveBox.y -
|
||||
(expandedEmojiLayout.contentBox.y +
|
||||
expandedEmojiLayout.contentBox.height),
|
||||
).toBeGreaterThanOrEqual(24);
|
||||
await page.getByTestId("community-avatar-custom-color").click();
|
||||
await expect
|
||||
.poll(() => avatarDialog.evaluate((element) => element.clientHeight))
|
||||
.toBeGreaterThan(selectedEmojiDialogHeight);
|
||||
await page.getByTestId("community-avatar-custom-color-done").click();
|
||||
await expect
|
||||
.poll(() => avatarDialog.evaluate((element) => element.clientHeight))
|
||||
.toBe(selectedEmojiDialogHeight);
|
||||
await page.getByRole("tab", { name: "Image" }).click();
|
||||
await expect
|
||||
.poll(() => avatarDialog.evaluate((element) => element.clientHeight))
|
||||
.toBe(imageDialogHeight);
|
||||
await expect(profileMain).toHaveClass(/opacity-45/);
|
||||
await expect(profileMain).toHaveClass(/blur-\[3px\]/);
|
||||
await expect(
|
||||
page.getByTestId("community-profile-name-key"),
|
||||
).not.toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(avatarDialog).toHaveCount(0);
|
||||
await expect(avatarButton).toBeFocused();
|
||||
await expect(page.getByTestId("community-profile-next")).toHaveText("Next");
|
||||
await expect(page.getByTestId("community-profile-next")).toBeDisabled();
|
||||
await expect(page.getByTestId("community-profile-back")).toHaveCount(0);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Stub getUserMedia with a canvas-generated stream (animated gradient with a
|
||||
* moving circle) so the camera phases work headless and deterministically —
|
||||
* Playwright's bundled headless shell has no media capture support.
|
||||
*/
|
||||
export function installFakeCamera(
|
||||
page: Page,
|
||||
options: {
|
||||
cameraDelayMs?: number;
|
||||
failRequests?: number;
|
||||
holdCamera?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
return page.addInitScript(
|
||||
(cameraOptions) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 640;
|
||||
canvas.height = 480;
|
||||
const context = canvas.getContext("2d");
|
||||
let hue = 0;
|
||||
setInterval(() => {
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
hue = (hue + 7) % 360;
|
||||
context.fillStyle = `hsl(${hue} 80% 60%)`;
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.beginPath();
|
||||
context.arc(
|
||||
canvas.width / 2 + Math.sin(hue / 30) * 60,
|
||||
canvas.height / 2,
|
||||
90,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
context.fill();
|
||||
}, 90);
|
||||
const stream = canvas.captureStream(15);
|
||||
const mediaDevices = navigator.mediaDevices ?? ({} as MediaDevices);
|
||||
if (!navigator.mediaDevices) {
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
configurable: true,
|
||||
value: mediaDevices,
|
||||
});
|
||||
}
|
||||
const devices: MediaDeviceInfo[] = [
|
||||
{
|
||||
deviceId: "builtin-camera",
|
||||
groupId: "mac",
|
||||
kind: "videoinput",
|
||||
label: "FaceTime HD Camera",
|
||||
toJSON() {
|
||||
return this;
|
||||
},
|
||||
} as MediaDeviceInfo,
|
||||
{
|
||||
deviceId: "iphone-continuity",
|
||||
groupId: "iphone",
|
||||
kind: "videoinput",
|
||||
label: "Kenny's iPhone Camera",
|
||||
toJSON() {
|
||||
return this;
|
||||
},
|
||||
} as MediaDeviceInfo,
|
||||
];
|
||||
const testWindow = window as Window & {
|
||||
__BUZZ_E2E_CAMERA_CONSTRAINTS__?: MediaStreamConstraints[];
|
||||
__BUZZ_E2E_CAMERA_REQUEST_COUNT__?: number;
|
||||
__BUZZ_E2E_RELEASE_CAMERA__?: () => void;
|
||||
};
|
||||
testWindow.__BUZZ_E2E_CAMERA_CONSTRAINTS__ = [];
|
||||
testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ = 0;
|
||||
let releaseCamera: (() => void) | null = null;
|
||||
testWindow.__BUZZ_E2E_RELEASE_CAMERA__ = () => {
|
||||
releaseCamera?.();
|
||||
releaseCamera = null;
|
||||
};
|
||||
Object.defineProperty(mediaDevices, "enumerateDevices", {
|
||||
configurable: true,
|
||||
value: () => Promise.resolve(devices),
|
||||
});
|
||||
Object.defineProperty(mediaDevices, "addEventListener", {
|
||||
configurable: true,
|
||||
value: () => {},
|
||||
});
|
||||
Object.defineProperty(mediaDevices, "removeEventListener", {
|
||||
configurable: true,
|
||||
value: () => {},
|
||||
});
|
||||
Object.defineProperty(mediaDevices, "getUserMedia", {
|
||||
configurable: true,
|
||||
value: async (constraints: MediaStreamConstraints) => {
|
||||
testWindow.__BUZZ_E2E_CAMERA_CONSTRAINTS__?.push(constraints);
|
||||
testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ =
|
||||
(testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ ?? 0) + 1;
|
||||
if (
|
||||
testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ <=
|
||||
cameraOptions.failRequests
|
||||
) {
|
||||
throw new DOMException("Camera access denied", "NotAllowedError");
|
||||
}
|
||||
if (cameraOptions.holdCamera) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseCamera = resolve;
|
||||
});
|
||||
} else if (cameraOptions.cameraDelayMs > 0) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, cameraOptions.cameraDelayMs),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(stream);
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
cameraDelayMs: options.cameraDelayMs ?? 0,
|
||||
failRequests: options.failRequests ?? 0,
|
||||
holdCamera: options.holdCamera ?? false,
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user