Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@mediapipe/tasks-vision": "^0.10.35",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
Expand Down Expand Up @@ -70,6 +71,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tiptap-markdown": "^0.9.0",
"upng-js": "^2.1.0",
"yaml": "^2.8.3",
"zod": "^4.4.3"
},
Expand Down
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default defineConfig({
"**/identity-archive.spec.ts",
"**/identity-archive-hide.spec.ts",
"**/relay-connectivity-screenshots.spec.ts",
"**/animated-avatar-screenshots.spec.ts",
],
use: {
...devices["Desktop Chrome"],
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/Entitlements.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>
2 changes: 2 additions & 0 deletions desktop/src-tauri/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@
<string>Buzz</string>
<key>NSMicrophoneUsageDescription</key>
<string>Buzz needs microphone access for voice huddles.</string>
<key>NSCameraUsageDescription</key>
<string>Buzz needs camera access to record animated avatars.</string>
</dict>
</plist>
96 changes: 84 additions & 12 deletions desktop/src/features/onboarding/ui/AvatarStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ function AvatarStepActions({
onSubmit: () => void;
saveRecovery: ProfileStepState["saveRecovery"];
}) {
const areNavigationActionsDisabled = isSaving || isUploadingAvatar;

return (
<AnimatePresence initial={false} mode="popLayout">
{hidden ? null : (
Expand Down Expand Up @@ -178,7 +180,7 @@ function AvatarStepActions({
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
data-testid="onboarding-skip"
disabled={isSaving}
disabled={areNavigationActionsDisabled}
onClick={onSkipForNow}
type="button"
variant="ghost"
Expand All @@ -191,7 +193,7 @@ function AvatarStepActions({
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
data-testid="onboarding-next-without-saving"
disabled={isSaving}
disabled={areNavigationActionsDisabled}
onClick={onContinueWithoutSaving}
type="button"
variant="ghost"
Expand All @@ -203,7 +205,7 @@ function AvatarStepActions({
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
data-testid="onboarding-back"
disabled={isSaving}
disabled={areNavigationActionsDisabled}
onClick={onBack}
type="button"
variant="ghost"
Expand Down Expand Up @@ -237,15 +239,58 @@ export function AvatarStep({ actions, direction, state }: AvatarStepProps) {
const [avatarSquishKey, setAvatarSquishKey] = React.useState(0);
const [avatarEditorMode, setAvatarEditorMode] =
React.useState<AvatarMode>("image");
const [animatedPreviewEl, setAnimatedPreviewEl] =
React.useState<HTMLDivElement | null>(null);
const [isAnimatedPreviewActive, setIsAnimatedPreviewActive] =
React.useState(false);
const [animatedPreviewCaption, setAnimatedPreviewCaption] = React.useState<
string | null
>(null);
const [pendingAnimatedAvatarUrl, setPendingAnimatedAvatarUrl] =
React.useState<string | null>(null);
const [isCustomColorPickerOpen, setIsCustomColorPickerOpen] =
React.useState(false);
const canSubmit =
avatar.draftUrl.trim().length > 0 && !isSaving && !isUploadingAvatar;
const hasAvatarDraft = avatar.draftUrl.trim().length > 0;
const canSubmit = hasAvatarDraft && !isSaving && !isUploadingAvatar;
Comment thread
klopez4212 marked this conversation as resolved.
const isAutoAdvancingAnimatedAvatar = pendingAnimatedAvatarUrl !== null;
const shouldHideActionsForAnimatedAvatar =
avatarEditorMode === "animated" &&
(!hasAvatarDraft || isAutoAdvancingAnimatedAvatar);
Comment thread
klopez4212 marked this conversation as resolved.
const areActionsHidden =
isCustomColorPickerOpen || shouldHideActionsForAnimatedAvatar;
const previewName =
name.draftValue.trim() || name.savedValue.trim() || "Your avatar";
const animateEmojiAvatarChange = React.useCallback(() => {
setAvatarSquishKey((key) => key + 1);
}, []);
const handleAnimatedAvatarApply = React.useCallback((url: string) => {
setPendingAnimatedAvatarUrl(url);
}, []);

React.useEffect(() => {
if (!pendingAnimatedAvatarUrl) {
return;
}
if (avatar.draftUrl !== pendingAnimatedAvatarUrl) {
return;
}
if (saveRecovery.errorMessage) {
setPendingAnimatedAvatarUrl(null);
return;
}
if (isSaving || isUploadingAvatar) {
return;
}

submit();
}, [
avatar.draftUrl,
isSaving,
isUploadingAvatar,
pendingAnimatedAvatarUrl,
saveRecovery.errorMessage,
submit,
]);

return (
<OnboardingSlideTransition
Expand All @@ -270,12 +315,35 @@ export function AvatarStep({ actions, direction, state }: AvatarStepProps) {
</p>
</div>

<div className="mt-12">
<AvatarPreview
avatarSquishKey={avatarSquishKey}
avatarUrl={avatar.draftUrl}
previewName={previewName}
/>
<div className="mt-12 grid justify-items-center gap-3 lg:justify-items-start">
<div className="relative h-48 w-48">
<div
className="pointer-events-none absolute inset-0 z-10"
data-testid="onboarding-avatar-animated-preview-slot"
ref={setAnimatedPreviewEl}
/>
{isAnimatedPreviewActive ? null : (
<AvatarPreview
avatarSquishKey={avatarSquishKey}
avatarUrl={avatar.draftUrl}
previewName={previewName}
/>
)}
</div>

<AnimatePresence initial={false}>
{animatedPreviewCaption ? (
<motion.p
animate={{ opacity: 1, y: 0 }}
className="w-48 text-center text-sm font-medium text-muted-foreground"
exit={{ opacity: 0, y: -4 }}
initial={{ opacity: 0, y: 4 }}
transition={AVATAR_ACTIONS_MOTION_TRANSITION}
>
{animatedPreviewCaption}
</motion.p>
) : null}
</AnimatePresence>
</div>
</div>

Expand All @@ -286,10 +354,14 @@ export function AvatarStep({ actions, direction, state }: AvatarStepProps) {
transition={AVATAR_POSITION_MOTION_TRANSITION}
>
<ProfileAvatarEditor
animatedPreviewContainer={animatedPreviewEl}
avatarUrl={avatar.draftUrl}
disabled={isSaving}
emojiPickerTheme="auto"
emojiPickerThemeVars={NEUTRAL_EMOJI_PICKER_THEME_VARS}
onAnimatedAvatarApply={handleAnimatedAvatarApply}
onAnimatedPreviewActiveChange={setIsAnimatedPreviewActive}
onAnimatedPreviewCaptionChange={setAnimatedPreviewCaption}
onCustomColorPickerOpenChange={setIsCustomColorPickerOpen}
onEmojiAvatarChange={animateEmojiAvatarChange}
onModeChange={setAvatarEditorMode}
Expand All @@ -305,7 +377,7 @@ export function AvatarStep({ actions, direction, state }: AvatarStepProps) {

<AvatarStepActions
canSubmit={canSubmit}
hidden={isCustomColorPickerOpen}
hidden={areActionsHidden}
isSaving={isSaving}
isUploadingAvatar={isUploadingAvatar}
onBack={back}
Expand Down
6 changes: 4 additions & 2 deletions desktop/src/features/profile/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
UsersBatchResponse,
} from "@/shared/api/types";
import { useIdentityQuery } from "@/shared/api/hooks";
import { getAvatarSnapshotUrl } from "@/shared/lib/animatedAvatar";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import {
SELF_PROFILE_CACHE_EVENT,
Expand Down Expand Up @@ -49,9 +50,10 @@ async function persistSelfProfile(
profile: Profile,
): Promise<void> {
const existing = readSelfProfileCache(relayUrl, pubkey);
const avatarSnapshotUrl = getAvatarSnapshotUrl(profile.avatarUrl);
const fetched =
shouldFetchAvatar(profile.avatarUrl, existing) && profile.avatarUrl !== null
? await fetchAvatarDataUrl(rewriteRelayUrl(profile.avatarUrl))
shouldFetchAvatar(profile.avatarUrl, existing) && avatarSnapshotUrl !== null
? await fetchAvatarDataUrl(rewriteRelayUrl(avatarSnapshotUrl))
: null;
const avatarDataUrl = resolveAvatarDataUrl(
profile.avatarUrl,
Expand Down
46 changes: 46 additions & 0 deletions desktop/src/features/profile/lib/animatedAvatarCapture.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";

import { listAvatarCameras } from "./animatedAvatarCapture.ts";

function mockMediaDevices(devices) {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
mediaDevices: {
enumerateDevices: async () => devices,
},
},
});
}

test("listAvatarCameras: preserves blank pre-permission camera labels", async () => {
mockMediaDevices([
{
deviceId: "default",
kind: "videoinput",
label: "",
},
{
deviceId: "continuity",
kind: "videoinput",
label: "",
},
{
deviceId: "microphone",
kind: "audioinput",
label: "Studio Mic",
},
]);

const cameras = await listAvatarCameras();

assert.deepEqual(cameras, [
{ deviceId: "default", label: "" },
{ deviceId: "continuity", label: "" },
]);
assert.equal(
cameras.some((device) => device.label.trim().length > 0),
false,
);
});
Loading