mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3
Logan Johnson
parent
fd55ab6624
commit
f609fcee02
@@ -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 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);
|
||||
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(
|
||||
|
||||
@@ -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 couldn’t 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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user