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
11 changes: 9 additions & 2 deletions desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
takePendingWelcomeChannelForDirectEntry,
WELCOME_SURFACE_READY_EVENT,
} from "@/features/onboarding/welcome";
import { useAvatarPresentation } from "@/features/profile/avatarPresentationStore";
import { profileQueryKey } from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
Expand Down Expand Up @@ -80,7 +81,9 @@ function AvatarCircle({
triggerRef?: React.Ref<HTMLButtonElement>;
}) {
const emojiAvatar = parseEmojiAvatarDataUrl(avatarUrl);
const hasAvatar = avatarUrl.trim().length > 0;
const presentation = useAvatarPresentation(avatarUrl);
const hasAvatar =
avatarUrl.trim().length > 0 && presentation?.state !== "failed";

return (
<button
Expand All @@ -103,9 +106,13 @@ function AvatarCircle({
avatarUrl={avatarUrl}
className="h-36 w-36 rounded-full text-4xl"
label={previewName}
testId="community-avatar-circle"
/>
) : (
<span className="flex h-36 w-36 items-center justify-center rounded-full bg-white/30 text-[var(--buzz-onboarding-backup-ink)] transition-colors group-hover:bg-white/40">
<span
className="flex h-36 w-36 items-center justify-center rounded-full bg-white/30 text-[var(--buzz-onboarding-backup-ink)] transition-colors group-hover:bg-white/40"
data-testid="community-avatar-empty"
>
<Plus className="h-7 w-7" aria-hidden="true" />
</span>
)}
Expand Down
174 changes: 174 additions & 0 deletions desktop/src/features/profile/avatarPresentationStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import * as React from "react";
import { toast } from "sonner";

import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";

export type AvatarPresentationState = "failed" | "pending" | "ready";

export type AvatarPresentation = {
displayUrl: string;
state: AvatarPresentationState;
};

type AvatarPresentationEntry = {
generation: number;
localPreviewUrl: string | null;
remoteUrl: string;
snapshot: AvatarPresentation;
};

const PROBE_DELAYS_MS = [0, 750, 1_500, 3_000] as const;
const PROBE_TIMEOUT_MS = 3_000;
const READY_PRESENTATION_TTL_MS = 30_000;
const presentations = new Map<string, AvatarPresentationEntry>();
const listeners = new Set<() => void>();
let nextGeneration = 1;

function emitChange(): void {
for (const listener of listeners) listener();
}

function toastId(remoteUrl: string): string {
return `avatar-presentation:${remoteUrl}`;
}

function releaseLocalPreview(entry: AvatarPresentationEntry): void {
if (!entry.localPreviewUrl) return;
URL.revokeObjectURL(entry.localPreviewUrl);
entry.localPreviewUrl = null;
}

function isCurrent(entry: AvatarPresentationEntry): boolean {
return presentations.get(entry.remoteUrl)?.generation === entry.generation;
}

function wait(delayMs: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, delayMs));
}

function buildProbeUrl(remoteUrl: string, attempt: number): string {
try {
const url = new URL(remoteUrl);
url.searchParams.set(
"buzz_avatar_probe",
`${Date.now()}-${attempt.toString()}`,
);
return url.toString();
} catch {
return remoteUrl;
}
}

function probeImage(
remoteUrl: string,
attempt: number,
): Promise<string | null> {
return new Promise((resolve) => {
const image = new Image();
let settled = false;
const verifiedUrl = buildProbeUrl(remoteUrl, attempt);
const finish = (result: string | null) => {
if (settled) return;
settled = true;
window.clearTimeout(timeoutId);
image.onload = null;
image.onerror = null;
resolve(result);
};
const timeoutId = window.setTimeout(() => finish(null), PROBE_TIMEOUT_MS);

image.onload = () => finish(verifiedUrl);
image.onerror = () => finish(null);
image.referrerPolicy = "no-referrer";
image.src = rewriteRelayUrl(verifiedUrl);
});
}

async function verifyPresentation(
entry: AvatarPresentationEntry,
): Promise<void> {
for (const [attempt, delayMs] of PROBE_DELAYS_MS.entries()) {
await wait(delayMs);
if (!isCurrent(entry) || entry.snapshot.state !== "pending") return;

const verifiedUrl = await probeImage(entry.remoteUrl, attempt);
if (!isCurrent(entry) || entry.snapshot.state !== "pending") return;
if (!verifiedUrl) continue;

releaseLocalPreview(entry);
entry.snapshot = { displayUrl: verifiedUrl, state: "ready" };
toast.dismiss(toastId(entry.remoteUrl));
emitChange();
window.setTimeout(() => {
if (!isCurrent(entry) || entry.snapshot.state !== "ready") return;
presentations.delete(entry.remoteUrl);
emitChange();
}, READY_PRESENTATION_TTL_MS);
return;
}

if (!isCurrent(entry) || entry.snapshot.state !== "pending") return;
entry.snapshot = {
displayUrl: entry.remoteUrl,
state: "failed",
};
emitChange();
toast.error("Avatar couldn’t finish uploading", {
action: {
label: "Retry",
onClick: () => retryAvatarPresentation(entry.remoteUrl),
},
description: "Your default avatar is showing instead.",
id: toastId(entry.remoteUrl),
});
}

export function beginAvatarPresentation(remoteUrl: string, image: Blob): void {
const existing = presentations.get(remoteUrl);
Comment thread
loganj marked this conversation as resolved.
if (existing) releaseLocalPreview(existing);

const localPreviewUrl = URL.createObjectURL(image);
const entry: AvatarPresentationEntry = {
generation: nextGeneration++,
localPreviewUrl,
remoteUrl,
snapshot: { displayUrl: localPreviewUrl, state: "pending" },
};
presentations.set(remoteUrl, entry);
emitChange();
void verifyPresentation(entry);
}

export function retryAvatarPresentation(remoteUrl: string): void {
const entry = presentations.get(remoteUrl);
if (entry?.snapshot.state !== "failed") return;
entry.generation = nextGeneration++;
entry.snapshot = {
displayUrl: entry.localPreviewUrl ?? entry.remoteUrl,
state: "pending",
};
emitChange();
void verifyPresentation(entry);
}

export function getAvatarPresentation(
remoteUrl: string | null,
): AvatarPresentation | null {
if (!remoteUrl) return null;
return presentations.get(remoteUrl)?.snapshot ?? null;
}

export function subscribeAvatarPresentations(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}

export function useAvatarPresentation(
remoteUrl: string | null,
): AvatarPresentation | null {
return React.useSyncExternalStore(
subscribeAvatarPresentations,
() => getAvatarPresentation(remoteUrl),
() => null,
);
}
26 changes: 23 additions & 3 deletions desktop/src/features/profile/ui/ProfileAvatar.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import * as React from "react";
import { UserRound } from "lucide-react";

import { useAvatarPresentation } from "@/features/profile/avatarPresentationStore";
import { parseAnimatedAvatarUrl } from "@/shared/lib/animatedAvatar";
import { cn } from "@/shared/lib/cn";
import { getInitials } from "@/shared/lib/initials";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/ui/avatar";
import { Spinner } from "@/shared/ui/spinner";

type ProfileAvatarProps = {
avatarUrl: string | null;
Expand All @@ -29,16 +31,18 @@ export function ProfileAvatar({
testId,
}: ProfileAvatarProps) {
const initials = getInitials(label);
const presentation = useAvatarPresentation(avatarUrl);
const presentedAvatarUrl = presentation?.displayUrl ?? avatarUrl;

// Animated avatars show their static poster frame until hovered, then play
// the animation.
const animated = parseAnimatedAvatarUrl(avatarUrl);
const animated = parseAnimatedAvatarUrl(presentedAvatarUrl);
const [isHovered, setIsHovered] = React.useState(false);
const baseUrl = animated
? isHovered
? animated.animationUrl
: animated.posterUrl
: avatarUrl;
: presentedAvatarUrl;

// Compute the live (proxied) source. Failures are tracked per resolved URL so
// the poster and hover animation can recover independently.
Expand Down Expand Up @@ -69,7 +73,11 @@ export function ProfileAvatar({
{src !== undefined ? (
<AvatarImage
alt={`${label} avatar`}
className={cn("object-cover", imageClassName)}
className={cn(
"object-cover",
presentation?.state === "pending" && "brightness-75",
imageClassName,
)}
data-testid={testId ? `${testId}-image` : undefined}
onLoadingStatusChange={(status) => {
if (status === "error") setFailedSrc(liveSrc);
Expand Down Expand Up @@ -97,6 +105,18 @@ export function ProfileAvatar({
)}
</AvatarFallback>
) : null}
{presentation?.state === "pending" ? (
<span
aria-label="Avatar upload pending"
className="pointer-events-none absolute inset-0 flex items-center justify-center text-white drop-shadow-sm"
data-testid={testId ? `${testId}-upload-pending` : undefined}
role="status"
>
<span className="flex size-7 items-center justify-center rounded-full bg-black/35">
<Spinner aria-hidden="true" className="border-2" size={16} />
</span>
</span>
) : null}
</Avatar>
);
}
79 changes: 79 additions & 0 deletions desktop/src/features/profile/ui/ProfileAvatarEditor.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import * as React from "react";

import { beginAvatarPresentation } from "@/features/profile/avatarPresentationStore";

export const DONE_BUTTON_CONTENT_TRANSITION = {
duration: 0.14,
ease: [0.23, 1, 0.32, 1],
} as const;

export const DONE_BUTTON_SHELL_TRANSITION = {
duration: 0.18,
ease: [0.23, 1, 0.32, 1],
} as const;

export function waitForPendingButtonPaint() {
return new Promise<void>((resolve) => {
if (
typeof window === "undefined" ||
typeof window.requestAnimationFrame !== "function"
) {
setTimeout(resolve, 0);
return;
}

window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => setTimeout(resolve, 0));
});
});
}

export function useUploadPreviewLifecycle({
clearFallback,
onSuccess,
showFallback,
}: {
clearFallback: () => void;
onSuccess: (uploadedUrl: string) => void;
showFallback: (file: File) => void;
}) {
const pendingFileRef = React.useRef<File | null>(null);

return {
onUploadSettled: () => {
pendingFileRef.current = null;
clearFallback();
},
onUploadStart: (file: File) => {
pendingFileRef.current = file;
showFallback(file);
},
onUploadSuccess: (uploadedUrl: string) => {
const pendingFile = pendingFileRef.current;
if (pendingFile) beginAvatarPresentation(uploadedUrl, pendingFile);
onSuccess(uploadedUrl);
},
};
}

export function useLocalAvatarPreview() {
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const previewUrlRef = React.useRef<string | null>(null);

const clearPreview = React.useCallback(() => {
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
setPreviewUrl(null);
}, []);

const showFilePreview = React.useCallback((file: File) => {
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
const nextUrl = URL.createObjectURL(file);
previewUrlRef.current = nextUrl;
setPreviewUrl(nextUrl);
}, []);

React.useEffect(() => clearPreview, [clearPreview]);

return { clearPreview, previewUrl, showFilePreview };
}
Loading
Loading