Skip to content

Commit 1164865

Browse files
authored
Add animated profile avatars (#1031)
1 parent 89ae31d commit 1164865

30 files changed

Lines changed: 4711 additions & 660 deletions

desktop/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@dnd-kit/utilities": "^3.2.2",
2727
"@emoji-mart/data": "^1.2.1",
2828
"@emoji-mart/react": "^1.1.1",
29+
"@mediapipe/tasks-vision": "^0.10.35",
2930
"@radix-ui/react-alert-dialog": "^1.1.15",
3031
"@radix-ui/react-avatar": "^1.1.11",
3132
"@radix-ui/react-checkbox": "^1.3.3",
@@ -70,6 +71,7 @@
7071
"sonner": "^2.0.7",
7172
"tailwind-merge": "^3.5.0",
7273
"tiptap-markdown": "^0.9.0",
74+
"upng-js": "^2.1.0",
7375
"yaml": "^2.8.3",
7476
"zod": "^4.4.3"
7577
},

desktop/playwright.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export default defineConfig({
4141
"**/identity-archive.spec.ts",
4242
"**/identity-archive-hide.spec.ts",
4343
"**/relay-connectivity-screenshots.spec.ts",
44+
"**/animated-avatar-screenshots.spec.ts",
4445
],
4546
use: {
4647
...devices["Desktop Chrome"],

desktop/src-tauri/Entitlements.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,7 @@
44
<dict>
55
<key>com.apple.security.device.audio-input</key>
66
<true/>
7+
<key>com.apple.security.device.camera</key>
8+
<true/>
79
</dict>
810
</plist>

desktop/src-tauri/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,7 @@
88
<string>Buzz</string>
99
<key>NSMicrophoneUsageDescription</key>
1010
<string>Buzz needs microphone access for voice huddles.</string>
11+
<key>NSCameraUsageDescription</key>
12+
<string>Buzz needs camera access to record animated avatars.</string>
1113
</dict>
1214
</plist>

desktop/src/features/onboarding/ui/AvatarStep.tsx

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ function AvatarStepActions({
138138
onSubmit: () => void;
139139
saveRecovery: ProfileStepState["saveRecovery"];
140140
}) {
141+
const areNavigationActionsDisabled = isSaving || isUploadingAvatar;
142+
141143
return (
142144
<AnimatePresence initial={false} mode="popLayout">
143145
{hidden ? null : (
@@ -178,7 +180,7 @@ function AvatarStepActions({
178180
<Button
179181
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
180182
data-testid="onboarding-skip"
181-
disabled={isSaving}
183+
disabled={areNavigationActionsDisabled}
182184
onClick={onSkipForNow}
183185
type="button"
184186
variant="ghost"
@@ -191,7 +193,7 @@ function AvatarStepActions({
191193
<Button
192194
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
193195
data-testid="onboarding-next-without-saving"
194-
disabled={isSaving}
196+
disabled={areNavigationActionsDisabled}
195197
onClick={onContinueWithoutSaving}
196198
type="button"
197199
variant="ghost"
@@ -203,7 +205,7 @@ function AvatarStepActions({
203205
<Button
204206
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
205207
data-testid="onboarding-back"
206-
disabled={isSaving}
208+
disabled={areNavigationActionsDisabled}
207209
onClick={onBack}
208210
type="button"
209211
variant="ghost"
@@ -237,15 +239,58 @@ export function AvatarStep({ actions, direction, state }: AvatarStepProps) {
237239
const [avatarSquishKey, setAvatarSquishKey] = React.useState(0);
238240
const [avatarEditorMode, setAvatarEditorMode] =
239241
React.useState<AvatarMode>("image");
242+
const [animatedPreviewEl, setAnimatedPreviewEl] =
243+
React.useState<HTMLDivElement | null>(null);
244+
const [isAnimatedPreviewActive, setIsAnimatedPreviewActive] =
245+
React.useState(false);
246+
const [animatedPreviewCaption, setAnimatedPreviewCaption] = React.useState<
247+
string | null
248+
>(null);
249+
const [pendingAnimatedAvatarUrl, setPendingAnimatedAvatarUrl] =
250+
React.useState<string | null>(null);
240251
const [isCustomColorPickerOpen, setIsCustomColorPickerOpen] =
241252
React.useState(false);
242-
const canSubmit =
243-
avatar.draftUrl.trim().length > 0 && !isSaving && !isUploadingAvatar;
253+
const hasAvatarDraft = avatar.draftUrl.trim().length > 0;
254+
const canSubmit = hasAvatarDraft && !isSaving && !isUploadingAvatar;
255+
const isAutoAdvancingAnimatedAvatar = pendingAnimatedAvatarUrl !== null;
256+
const shouldHideActionsForAnimatedAvatar =
257+
avatarEditorMode === "animated" &&
258+
(!hasAvatarDraft || isAutoAdvancingAnimatedAvatar);
259+
const areActionsHidden =
260+
isCustomColorPickerOpen || shouldHideActionsForAnimatedAvatar;
244261
const previewName =
245262
name.draftValue.trim() || name.savedValue.trim() || "Your avatar";
246263
const animateEmojiAvatarChange = React.useCallback(() => {
247264
setAvatarSquishKey((key) => key + 1);
248265
}, []);
266+
const handleAnimatedAvatarApply = React.useCallback((url: string) => {
267+
setPendingAnimatedAvatarUrl(url);
268+
}, []);
269+
270+
React.useEffect(() => {
271+
if (!pendingAnimatedAvatarUrl) {
272+
return;
273+
}
274+
if (avatar.draftUrl !== pendingAnimatedAvatarUrl) {
275+
return;
276+
}
277+
if (saveRecovery.errorMessage) {
278+
setPendingAnimatedAvatarUrl(null);
279+
return;
280+
}
281+
if (isSaving || isUploadingAvatar) {
282+
return;
283+
}
284+
285+
submit();
286+
}, [
287+
avatar.draftUrl,
288+
isSaving,
289+
isUploadingAvatar,
290+
pendingAnimatedAvatarUrl,
291+
saveRecovery.errorMessage,
292+
submit,
293+
]);
249294

250295
return (
251296
<OnboardingSlideTransition
@@ -270,12 +315,35 @@ export function AvatarStep({ actions, direction, state }: AvatarStepProps) {
270315
</p>
271316
</div>
272317

273-
<div className="mt-12">
274-
<AvatarPreview
275-
avatarSquishKey={avatarSquishKey}
276-
avatarUrl={avatar.draftUrl}
277-
previewName={previewName}
278-
/>
318+
<div className="mt-12 grid justify-items-center gap-3 lg:justify-items-start">
319+
<div className="relative h-48 w-48">
320+
<div
321+
className="pointer-events-none absolute inset-0 z-10"
322+
data-testid="onboarding-avatar-animated-preview-slot"
323+
ref={setAnimatedPreviewEl}
324+
/>
325+
{isAnimatedPreviewActive ? null : (
326+
<AvatarPreview
327+
avatarSquishKey={avatarSquishKey}
328+
avatarUrl={avatar.draftUrl}
329+
previewName={previewName}
330+
/>
331+
)}
332+
</div>
333+
334+
<AnimatePresence initial={false}>
335+
{animatedPreviewCaption ? (
336+
<motion.p
337+
animate={{ opacity: 1, y: 0 }}
338+
className="w-48 text-center text-sm font-medium text-muted-foreground"
339+
exit={{ opacity: 0, y: -4 }}
340+
initial={{ opacity: 0, y: 4 }}
341+
transition={AVATAR_ACTIONS_MOTION_TRANSITION}
342+
>
343+
{animatedPreviewCaption}
344+
</motion.p>
345+
) : null}
346+
</AnimatePresence>
279347
</div>
280348
</div>
281349

@@ -286,10 +354,14 @@ export function AvatarStep({ actions, direction, state }: AvatarStepProps) {
286354
transition={AVATAR_POSITION_MOTION_TRANSITION}
287355
>
288356
<ProfileAvatarEditor
357+
animatedPreviewContainer={animatedPreviewEl}
289358
avatarUrl={avatar.draftUrl}
290359
disabled={isSaving}
291360
emojiPickerTheme="auto"
292361
emojiPickerThemeVars={NEUTRAL_EMOJI_PICKER_THEME_VARS}
362+
onAnimatedAvatarApply={handleAnimatedAvatarApply}
363+
onAnimatedPreviewActiveChange={setIsAnimatedPreviewActive}
364+
onAnimatedPreviewCaptionChange={setAnimatedPreviewCaption}
293365
onCustomColorPickerOpenChange={setIsCustomColorPickerOpen}
294366
onEmojiAvatarChange={animateEmojiAvatarChange}
295367
onModeChange={setAvatarEditorMode}
@@ -305,7 +377,7 @@ export function AvatarStep({ actions, direction, state }: AvatarStepProps) {
305377

306378
<AvatarStepActions
307379
canSubmit={canSubmit}
308-
hidden={isCustomColorPickerOpen}
380+
hidden={areActionsHidden}
309381
isSaving={isSaving}
310382
isUploadingAvatar={isUploadingAvatar}
311383
onBack={back}

desktop/src/features/profile/hooks.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
UsersBatchResponse,
1818
} from "@/shared/api/types";
1919
import { useIdentityQuery } from "@/shared/api/hooks";
20+
import { getAvatarSnapshotUrl } from "@/shared/lib/animatedAvatar";
2021
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
2122
import {
2223
SELF_PROFILE_CACHE_EVENT,
@@ -49,9 +50,10 @@ async function persistSelfProfile(
4950
profile: Profile,
5051
): Promise<void> {
5152
const existing = readSelfProfileCache(relayUrl, pubkey);
53+
const avatarSnapshotUrl = getAvatarSnapshotUrl(profile.avatarUrl);
5254
const fetched =
53-
shouldFetchAvatar(profile.avatarUrl, existing) && profile.avatarUrl !== null
54-
? await fetchAvatarDataUrl(rewriteRelayUrl(profile.avatarUrl))
55+
shouldFetchAvatar(profile.avatarUrl, existing) && avatarSnapshotUrl !== null
56+
? await fetchAvatarDataUrl(rewriteRelayUrl(avatarSnapshotUrl))
5557
: null;
5658
const avatarDataUrl = resolveAvatarDataUrl(
5759
profile.avatarUrl,
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { listAvatarCameras } from "./animatedAvatarCapture.ts";
5+
6+
function mockMediaDevices(devices) {
7+
Object.defineProperty(globalThis, "navigator", {
8+
configurable: true,
9+
value: {
10+
mediaDevices: {
11+
enumerateDevices: async () => devices,
12+
},
13+
},
14+
});
15+
}
16+
17+
test("listAvatarCameras: preserves blank pre-permission camera labels", async () => {
18+
mockMediaDevices([
19+
{
20+
deviceId: "default",
21+
kind: "videoinput",
22+
label: "",
23+
},
24+
{
25+
deviceId: "continuity",
26+
kind: "videoinput",
27+
label: "",
28+
},
29+
{
30+
deviceId: "microphone",
31+
kind: "audioinput",
32+
label: "Studio Mic",
33+
},
34+
]);
35+
36+
const cameras = await listAvatarCameras();
37+
38+
assert.deepEqual(cameras, [
39+
{ deviceId: "default", label: "" },
40+
{ deviceId: "continuity", label: "" },
41+
]);
42+
assert.equal(
43+
cameras.some((device) => device.label.trim().length > 0),
44+
false,
45+
);
46+
});

0 commit comments

Comments
 (0)