Keep avatar preview visible during upload (#2237)

Signed-off-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
cynfria
2026-07-21 12:59:57 -07:00
committed by GitHub
co-authored by npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 Logan Johnson
parent fd55ab6624
commit f609fcee02
8 changed files with 563 additions and 41 deletions
@@ -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 {
@@ -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
@@ -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>
)}
@@ -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 couldnt 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);
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,
);
}
@@ -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;
@@ -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.
@@ -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);
@@ -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>
);
}
@@ -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 };
}
@@ -7,12 +7,20 @@ import { flushSync } from "react-dom";
import { AnimatedAvatarCapture } from "@/features/profile/ui/AnimatedAvatarCapture";
import { AvatarCustomColorPanel } from "@/features/profile/ui/AvatarCustomColorPanel";
import { ProfileAvatarUploadPreview } from "@/features/profile/ui/ProfileAvatarUploadPreview";
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 {
DONE_BUTTON_CONTENT_TRANSITION,
DONE_BUTTON_SHELL_TRANSITION,
useLocalAvatarPreview,
useUploadPreviewLifecycle,
waitForPendingButtonPaint,
} from "./ProfileAvatarEditor.helpers";
import {
AVATAR_COLORS,
AVATAR_COLOR_SWATCHES,
@@ -40,35 +48,6 @@ import type {
ProfileAvatarEditorProps,
} from "./ProfileAvatarEditor.types";
const DONE_BUTTON_CONTENT_TRANSITION = {
duration: 0.14,
ease: [0.23, 1, 0.32, 1],
} as const;
const DONE_BUTTON_SHELL_TRANSITION = {
duration: 0.18,
ease: [0.23, 1, 0.32, 1],
} as const;
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));
});
});
}
type EmojiMartEmoji = {
native?: string;
};
const INITIAL_EMOJI_AVATAR_COLORS = AVATAR_COLORS.filter(
(color) => color !== DEFAULT_EMOJI_AVATAR_COLOR,
);
@@ -97,6 +76,7 @@ export function ProfileAvatarEditor({
onAnimatedAvatarApply,
onDone,
onUploadingChange,
previewName,
showEmojiColorControlsWhenEmpty = false,
disabled,
testIdPrefix = "profile-avatar",
@@ -115,6 +95,7 @@ export function ProfileAvatarEditor({
const [mode, setMode] = React.useState<AvatarMode>("image");
const [isDragging, setIsDragging] = React.useState(false);
const [urlDraft, setUrlDraft] = React.useState("");
const localPreview = useLocalAvatarPreview();
const [selectedEmoji, setSelectedEmoji] = React.useState<string | null>(
() => initialEmojiAvatar?.emoji ?? null,
);
@@ -191,6 +172,11 @@ export function ProfileAvatarEditor({
);
const [isAnimatedApplyPending, setIsAnimatedApplyPending] =
React.useState(false);
const uploadPreviewLifecycle = useUploadPreviewLifecycle({
clearFallback: localPreview.clearPreview,
onSuccess: handleUploadSuccess,
showFallback: localPreview.showFilePreview,
});
const {
clearError: clearUploadError,
errorMessage: uploadErrorMessage,
@@ -199,7 +185,7 @@ export function ProfileAvatarEditor({
isUploading,
openPicker,
uploadFile,
} = useAvatarUpload({ onUploadSuccess: handleUploadSuccess });
} = useAvatarUpload(uploadPreviewLifecycle);
const isInputDisabled = disabled || isUploading || isAnimatedApplyPending;
const handleAnimatedApply = React.useCallback(
(animatedUrl: string) => {
@@ -624,6 +610,14 @@ export function ProfileAvatarEditor({
onClick={openPicker}
type="button"
>
{isOnboardingModal &&
(localPreview.previewUrl || avatarUrl) ? (
<ProfileAvatarUploadPreview
avatarUrl={localPreview.previewUrl || avatarUrl || ""}
label={previewName}
testId={`${testIdPrefix}-upload-preview`}
/>
) : null}
<span
aria-hidden="true"
className={cn(
@@ -771,7 +765,7 @@ export function ProfileAvatarEditor({
icons="outline"
navPosition="bottom"
onEmojiSelect={(
emoji: EmojiMartEmoji,
emoji: { native?: string },
event?: MouseEvent,
) => {
if (isInputDisabled) {
@@ -934,7 +928,7 @@ export function ProfileAvatarEditor({
>
<span className="grid place-items-center">
<AnimatePresence initial={false}>
{isDoneButtonPending ? (
{isDoneButtonPending && !isOnboardingModal ? (
<motion.span
animate={{ opacity: 1, y: 0 }}
className="col-start-1 row-start-1 inline-flex items-center justify-center gap-2"
@@ -0,0 +1,23 @@
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
type ProfileAvatarUploadPreviewProps = {
avatarUrl: string;
label: string;
testId: string;
};
export function ProfileAvatarUploadPreview({
avatarUrl,
label,
testId,
}: ProfileAvatarUploadPreviewProps) {
return (
<ProfileAvatar
avatarUrl={avatarUrl}
className="h-20 w-20 text-xl"
imageClassName="object-cover"
label={label}
testId={testId}
/>
);
}
@@ -11,6 +11,8 @@ const AVATAR_IMAGE_TYPES = [
];
type UseAvatarUploadOptions = {
onUploadStart?: (file: File) => void;
onUploadSettled?: () => void;
onUploadSuccess: (url: string) => void;
};
@@ -25,6 +27,8 @@ type UseAvatarUploadReturn = {
};
export function useAvatarUpload({
onUploadStart,
onUploadSettled,
onUploadSuccess,
}: UseAvatarUploadOptions): UseAvatarUploadReturn {
const inputRef = React.useRef<HTMLInputElement | null>(null);
@@ -49,6 +53,7 @@ export function useAvatarUpload({
flushSync(() => {
setIsUploading(true);
setErrorMessage(null);
onUploadStart?.(file);
});
try {
@@ -71,9 +76,10 @@ export function useAvatarUpload({
);
} finally {
setIsUploading(false);
onUploadSettled?.();
}
},
[onUploadSuccess],
[onUploadSettled, onUploadStart, onUploadSuccess],
);
const handleFileChange = React.useCallback(
+222 -3
View File
@@ -1313,10 +1313,43 @@ test("connected first-community profile step offers equal-width Next and Back co
},
);
await installFakeCamera(page, { failRequests: 1 });
await installMockBridge(page, undefined, {
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
const uploadedAvatarUrl = "https://mock.relay/media/community-avatar.png";
let avatarRequestCount = 0;
await page.route(`${uploadedAvatarUrl}*`, async (route) => {
avatarRequestCount += 1;
if (avatarRequestCount === 1) {
await route.fulfill({ status: 404 });
return;
}
await new Promise((resolve) => setTimeout(resolve, 1_000));
await route.fulfill({
body: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
contentType: "image/png",
});
});
await installMockBridge(
page,
{
uploadDelayMs: 1_000,
uploadDescriptors: [
{
filename: "community-avatar.png",
sha256: "c".repeat(64),
size: 128,
type: "image/png",
uploaded: 1_779_900_000,
url: "https://mock.relay/media/community-avatar.png",
},
],
},
{
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
},
);
await page.goto("/");
await expect(page.getByTestId("community-onboarding-flow")).toBeVisible();
@@ -1432,6 +1465,43 @@ test("connected first-community profile step offers equal-width Next and Back co
dialogBox.y + dialogBox.height,
);
const saveButton = page.getByTestId("community-avatar-done");
await page.getByTestId("community-avatar-input").setInputFiles({
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
mimeType: "image/png",
name: "community-avatar.png",
});
const previewImage = page.getByTestId(
"community-avatar-upload-preview-image",
);
await expect(previewImage).toHaveAttribute("src", /^blob:/);
await expect(saveButton).toBeDisabled();
await expect(saveButton).toHaveText("Save");
const localPreviewUrl = await previewImage.getAttribute("src");
await expect(previewImage).toHaveAttribute("src", localPreviewUrl ?? "");
await saveButton.click();
await expect(avatarDialog).toHaveCount(0);
const avatarCircleImage = page.getByTestId("community-avatar-circle-image");
await expect(avatarCircleImage).toHaveAttribute("src", /^blob:/);
await expect(
page.getByTestId("community-avatar-circle-upload-pending"),
).toBeVisible();
await expect(page.getByTestId("community-profile-name-key")).toBeEnabled();
await expect
.poll(() => avatarCircleImage.getAttribute("src"))
.not.toMatch(/^blob:/);
await expect(
page.getByTestId("community-avatar-circle-upload-pending"),
).toHaveCount(0);
await avatarButton.click();
await expect(avatarDialog).toBeVisible();
await expect(previewImage).toHaveAttribute(
"src",
new RegExp(`^${uploadedAvatarUrl}`),
);
const modeContentShell = page.getByTestId(
"community-avatar-mode-content-shell",
);
@@ -1566,6 +1636,155 @@ test("connected first-community profile step offers equal-width Next and Back co
.toBeNull();
});
test("pending avatar stays navigable and exposes retry after propagation fails", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript(
({ pubkey, transactionStorageKey }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
transactionStorageKey,
JSON.stringify({
id: "txn-avatar-propagation",
source: "first-community",
stage: "profile",
relayUrl: "wss://default.example.com",
communityName: "Default",
communityId: "e2e-default-community",
addedCommunity: true,
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{
pubkey: BLANK_TYLER_IDENTITY.pubkey,
transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
},
);
const uploadedAvatarUrl =
"https://mock.relay/media/pending-community-avatar.png";
let avatarReady = false;
await page.route(`${uploadedAvatarUrl}*`, async (route) => {
if (!avatarReady) {
await route.fulfill({ status: 404 });
return;
}
await new Promise((resolve) => setTimeout(resolve, 500));
await route.fulfill({
body: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
contentType: "image/png",
});
});
await installMockBridge(
page,
{
uploadDelayMs: 250,
uploadDescriptors: [
{
filename: "pending-community-avatar.png",
sha256: "d".repeat(64),
size: 128,
type: "image/png",
uploaded: 1_779_900_000,
url: uploadedAvatarUrl,
},
],
},
{
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-profile-name-key").fill("Tyler");
await page.getByTestId("community-avatar-open").click();
await page.getByTestId("community-avatar-input").setInputFiles({
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
mimeType: "image/png",
name: "pending-community-avatar.png",
});
await page.getByTestId("community-avatar-done").click();
const avatarImage = page.getByTestId("community-avatar-circle-image");
await expect(avatarImage).toHaveAttribute("src", /^blob:/);
await expect(
page.getByTestId("community-avatar-circle-upload-pending"),
).toBeVisible();
await expect(avatarImage).toHaveClass(/brightness-75/);
await expect(page.getByTestId("community-profile-next")).toBeEnabled();
await page.waitForTimeout(500);
await expect(
page.getByTestId("community-avatar-circle-upload-pending"),
).toBeVisible();
const avatar = page.getByTestId("community-avatar-circle");
const pendingSpinner = page
.getByTestId("community-avatar-circle-upload-pending")
.locator(".sprout-arc-spinner");
const [avatarBox, spinnerBox] = await Promise.all([
avatar.boundingBox(),
pendingSpinner.boundingBox(),
]);
if (!avatarBox || !spinnerBox) {
throw new Error("Could not measure pending avatar spinner");
}
expect(
Math.abs(
avatarBox.x + avatarBox.width / 2 - (spinnerBox.x + spinnerBox.width / 2),
),
).toBeLessThanOrEqual(1);
expect(
Math.abs(
avatarBox.y +
avatarBox.height / 2 -
(spinnerBox.y + spinnerBox.height / 2),
),
).toBeLessThanOrEqual(1);
expect(spinnerBox.width / avatarBox.width).toBeLessThanOrEqual(0.2);
await expect(page.getByTestId("community-avatar-empty")).toBeVisible({
timeout: 10_000,
});
await expect(
page.getByRole("button", { name: "Add an avatar" }),
).toBeVisible();
await expect(
page.getByTestId("community-avatar-circle-fallback"),
).toHaveCount(0);
await expect(avatarImage).toHaveCount(0);
await expect(
page.getByText("Avatar couldnt finish uploading"),
).toBeVisible();
await expect(
page.getByText("Your default avatar is showing instead."),
).toBeVisible();
avatarReady = true;
await page.getByRole("button", { name: "Retry" }).click();
await expect(
page.getByTestId("community-avatar-circle-upload-pending"),
).toBeVisible();
await expect(avatarImage).toHaveAttribute("src", /^blob:/);
await expect(avatarImage).toHaveClass(/brightness-75/);
await expect
.poll(() => avatarImage.getAttribute("src"))
.not.toMatch(/^blob:/);
});
test("membership denial on community profile save offers recovery", async ({
page,
}) => {