From fb805ace4313afa87999f4d258713ae4a4adb5a9 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 21 Jul 2026 13:16:20 -0700 Subject: [PATCH 01/14] Fix avatar upload lifecycle edge cases Signed-off-by: Logan Johnson --- .../onboarding/ui/CommunityOnboardingFlow.tsx | 35 ++- .../profile/avatarPresentationStore.ts | 29 ++- .../profile/ui/ProfileAvatarEditor.tsx | 24 +- desktop/tests/e2e/onboarding.spec.ts | 237 ++++++++++++++---- 4 files changed, 265 insertions(+), 60 deletions(-) diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 050aad96d0..bfe6bf8c07 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -152,6 +152,12 @@ export function CommunityOnboardingFlow({ const systemColorScheme = useSystemColorScheme(); const [displayName, setDisplayName] = React.useState(""); const [avatarUrl, setAvatarUrl] = React.useState(""); + const avatarPresentation = useAvatarPresentation(avatarUrl); + const [hasSavedProfile, setHasSavedProfile] = React.useState(false); + const persistedAvatarUrlRef = React.useRef(null); + const profileAvatarSyncQueueRef = React.useRef>( + Promise.resolve(), + ); const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false); const [isAvatarEditorOpen, setIsAvatarEditorOpen] = React.useState(false); const [starterPersonas, setStarterPersonas] = React.useState( @@ -292,6 +298,29 @@ export function CommunityOnboardingFlow({ } }, [isAvatarEditorOpen, isProfileStage]); + React.useEffect(() => { + if (!hasSavedProfile) return; + + // Next remains available during propagation. Serialize later failure and + // Retry transitions so an older profile write cannot win the race. + const nextAvatarUrl = + avatarPresentation?.state === "failed" ? "" : avatarUrl.trim(); + if (persistedAvatarUrlRef.current === nextAvatarUrl) return; + + persistedAvatarUrlRef.current = nextAvatarUrl; + profileAvatarSyncQueueRef.current = profileAvatarSyncQueueRef.current + .catch(() => undefined) + .then(async () => { + try { + await updateProfile({ avatarUrl: nextAvatarUrl }); + } catch { + if (persistedAvatarUrlRef.current === nextAvatarUrl) { + persistedAvatarUrlRef.current = null; + } + } + }); + }, [avatarPresentation?.state, avatarUrl, hasSavedProfile]); + React.useLayoutEffect(() => { if (!isAvatarEditorOpen) { setAvatarEditorDialogHeight(null); @@ -356,10 +385,14 @@ export function CommunityOnboardingFlow({ if (!displayName.trim()) return; setIsPending(true); try { + const nextAvatarUrl = + avatarPresentation?.state === "failed" ? "" : avatarUrl.trim(); await updateProfile({ displayName: displayName.trim(), - avatarUrl: avatarUrl.trim() || undefined, + avatarUrl: nextAvatarUrl, }); + persistedAvatarUrlRef.current = nextAvatarUrl; + setHasSavedProfile(true); update({ stage: "team-intro", error: undefined }); } catch (error) { if (isRelayMembershipDeniedError(error)) { diff --git a/desktop/src/features/profile/avatarPresentationStore.ts b/desktop/src/features/profile/avatarPresentationStore.ts index 769e7b27b7..c5cb2cfcac 100644 --- a/desktop/src/features/profile/avatarPresentationStore.ts +++ b/desktop/src/features/profile/avatarPresentationStore.ts @@ -125,7 +125,10 @@ async function verifyPresentation( export function beginAvatarPresentation(remoteUrl: string, image: Blob): void { const existing = presentations.get(remoteUrl); - if (existing) releaseLocalPreview(existing); + if (existing) { + toast.dismiss(toastId(remoteUrl)); + releaseLocalPreview(existing); + } const localPreviewUrl = URL.createObjectURL(image); const entry: AvatarPresentationEntry = { @@ -139,6 +142,17 @@ export function beginAvatarPresentation(remoteUrl: string, image: Blob): void { void verifyPresentation(entry); } +export function disposeAvatarPresentation(remoteUrl: string): void { + const entry = presentations.get(remoteUrl); + if (!entry) return; + + entry.generation = nextGeneration++; + toast.dismiss(toastId(remoteUrl)); + releaseLocalPreview(entry); + presentations.delete(remoteUrl); + emitChange(); +} + export function retryAvatarPresentation(remoteUrl: string): void { const entry = presentations.get(remoteUrl); if (entry?.snapshot.state !== "failed") return; @@ -172,3 +186,16 @@ export function useAvatarPresentation( () => null, ); } + +export function useAvatarSelection( + avatarUrl: string, + onUrlChange: (avatarUrl: string) => void, +): (avatarUrl: string) => void { + return React.useCallback( + (nextAvatarUrl: string) => { + if (avatarUrl !== nextAvatarUrl) disposeAvatarPresentation(avatarUrl); + onUrlChange(nextAvatarUrl); + }, + [avatarUrl, onUrlChange], + ); +} diff --git a/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx b/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx index 9e7901fffc..d663850482 100644 --- a/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx +++ b/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx @@ -9,6 +9,7 @@ import { AnimatedAvatarCapture } from "@/features/profile/ui/AnimatedAvatarCaptu import { AvatarCustomColorPanel } from "@/features/profile/ui/AvatarCustomColorPanel"; import { ProfileAvatarUploadPreview } from "@/features/profile/ui/ProfileAvatarUploadPreview"; import { ProfileAvatarModeTabs } from "@/features/profile/ui/ProfileAvatarModeTabs"; +import { useAvatarSelection } from "@/features/profile/avatarPresentationStore"; import { useAvatarUpload } from "@/features/profile/useAvatarUpload"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; @@ -161,14 +162,15 @@ export function ProfileAvatarEditor({ }, [mode, onModeChange], ); + const setAvatar = useAvatarSelection(avatarUrl, onUrlChange); const handleUploadSuccess = React.useCallback( (uploadedUrl: string) => { setUrlDraft(""); onUploadedAvatarChange?.(uploadedUrl); - onUrlChange(uploadedUrl); + setAvatar(uploadedUrl); updateMode("image"); }, - [onUploadedAvatarChange, onUrlChange, updateMode], + [onUploadedAvatarChange, setAvatar, updateMode], ); const [isAnimatedApplyPending, setIsAnimatedApplyPending] = React.useState(false); @@ -192,14 +194,14 @@ export function ProfileAvatarEditor({ clearUploadError(); setUrlDraft(""); onUploadedAvatarChange?.(animatedUrl); - onUrlChange(animatedUrl); + setAvatar(animatedUrl); onAnimatedAvatarApply?.(animatedUrl); }, [ clearUploadError, onAnimatedAvatarApply, onUploadedAvatarChange, - onUrlChange, + setAvatar, ], ); // Done on the animated tab uploads the pending recording first, then @@ -347,14 +349,14 @@ export function ProfileAvatarEditor({ } onUploadedAvatarChange?.(null); - onUrlChange(nextAvatarUrl); + setAvatar(nextAvatarUrl); }, [ avatarUrl, customColorDraft, isCustomColorPickerOpen, onUploadedAvatarChange, - onUrlChange, selectedEmoji, + setAvatar, ]); const handleFiles = React.useCallback( @@ -379,14 +381,14 @@ export function ProfileAvatarEditor({ clearUploadError(); onUploadedAvatarChange?.(null); - onUrlChange(nextUrl); + setAvatar(nextUrl); hasUserEditedUrlDraftRef.current = false; updateMode("image"); }, [ clearUploadError, isInputDisabled, onUploadedAvatarChange, - onUrlChange, + setAvatar, updateMode, urlDraft, ]); @@ -396,10 +398,10 @@ export function ProfileAvatarEditor({ setUrlDraft(""); hasUserEditedUrlDraftRef.current = false; onUploadedAvatarChange?.(null); - onUrlChange(emojiAvatarDataUrl(emoji, color)); + setAvatar(emojiAvatarDataUrl(emoji, color)); onEmojiAvatarChange?.(); }, - [onEmojiAvatarChange, onUploadedAvatarChange, onUrlChange, selectedColor], + [onEmojiAvatarChange, onUploadedAvatarChange, selectedColor, setAvatar], ); const openCustomColorPicker = React.useCallback(() => { @@ -699,7 +701,7 @@ export function ProfileAvatarEditor({ hasUserEditedUrlDraftRef.current = true; setUrlDraft(event.target.value); onUploadedAvatarChange?.(null); - onUrlChange(event.target.value); + setAvatar(event.target.value); }} onFocus={() => { isUrlInputFocusedRef.current = true; diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index c3c716bec3..714a50cdd8 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -58,6 +58,8 @@ async function setRelayConnectionState( const HOME_SEEN_STORAGE_KEY_PREFIX = "buzz-home-feed-seen.v1:"; const COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY = "buzz-community-onboarding-transaction.v1"; +const ONE_PIXEL_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8); const BLANK_TYLER_IDENTITY = { ...TEST_IDENTITIES.tyler, @@ -89,6 +91,48 @@ async function seedOnboardingCompletion(page: Page, pubkey: string) { ); } +async function seedCommunityProfileStage(page: Page, id: string) { + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + await page.addInitScript( + ({ pubkey, transactionId, transactionStorageKey }) => { + window.localStorage.setItem( + `buzz-machine-onboarding-complete.v2:${pubkey}`, + "true", + ); + const timestamp = new Date().toISOString(); + window.localStorage.setItem( + transactionStorageKey, + JSON.stringify({ + id: transactionId, + 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, + transactionId: id, + transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY, + }, + ); +} + +async function uploadCommunityAvatar(page: Page, filename: string) { + await page.getByTestId("community-avatar-open").click(); + await page.getByTestId("community-avatar-input").setInputFiles({ + buffer: Buffer.from(ONE_PIXEL_PNG_BASE64, "base64"), + mimeType: "image/png", + name: filename, + }); + await page.getByTestId("community-avatar-done").click(); +} + async function readHomeSeenStorageKeys(page: Page) { return page.evaluate((prefix) => { return Object.keys(window.localStorage).filter((key) => @@ -1636,37 +1680,10 @@ 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 ({ +test("pending avatar stays navigable, clears failures, and retries", 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, - }, - ); + await seedCommunityProfileStage(page, "txn-avatar-propagation"); const uploadedAvatarUrl = "https://mock.relay/media/pending-community-avatar.png"; @@ -1678,10 +1695,7 @@ test("pending avatar stays navigable and exposes retry after propagation fails", } await new Promise((resolve) => setTimeout(resolve, 500)); await route.fulfill({ - body: Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", - "base64", - ), + body: Buffer.from(ONE_PIXEL_PNG_BASE64, "base64"), contentType: "image/png", }); }); @@ -1708,16 +1722,7 @@ test("pending avatar stays navigable and exposes retry after propagation fails", 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(); + await uploadCommunityAvatar(page, "pending-community-avatar.png"); const avatarImage = page.getByTestId("community-avatar-circle-image"); await expect(avatarImage).toHaveAttribute("src", /^blob:/); @@ -1773,16 +1778,154 @@ test("pending avatar stays navigable and exposes retry after propagation fails", page.getByText("Your default avatar is showing instead."), ).toBeVisible(); + await page.getByTestId("community-profile-next").click(); + await expect + .poll(() => + page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) + .filter(({ command }) => command === "update_profile") + .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), + ), + ) + .toEqual([""]); + avatarReady = true; await page.getByRole("button", { name: "Retry" }).click(); + await expect + .poll(() => + page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) + .filter(({ command }) => command === "update_profile") + .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), + ), + ) + .toEqual(["", uploadedAvatarUrl]); + await expect(page.getByText("Avatar couldn’t finish uploading")).toHaveCount( + 0, + ); +}); + +test("a pending avatar is cleared if propagation fails after profile save", async ({ + page, +}) => { + await seedCommunityProfileStage(page, "txn-avatar-saved-before-failure"); + const uploadedAvatarUrl = + "https://mock.relay/media/saved-pending-community-avatar.png"; + await page.route(`${uploadedAvatarUrl}*`, (route) => + route.fulfill({ status: 404 }), + ); + await installMockBridge( + page, + { + uploadDescriptors: [ + { + filename: "saved-pending-community-avatar.png", + sha256: "f".repeat(64), + size: 128, + type: "image/png", + uploaded: 1_779_900_002, + url: uploadedAvatarUrl, + }, + ], + }, + { + relayWsUrl: "wss://default.example.com", + skipOnboardingSeed: true, + }, + ); + await page.goto("/"); + + await page.getByTestId("community-profile-name-key").fill("Tyler"); + await uploadCommunityAvatar(page, "saved-pending-community-avatar.png"); await expect( page.getByTestId("community-avatar-circle-upload-pending"), ).toBeVisible(); - await expect(avatarImage).toHaveAttribute("src", /^blob:/); - await expect(avatarImage).toHaveClass(/brightness-75/); + await page.getByTestId("community-profile-next").click(); + await expect - .poll(() => avatarImage.getAttribute("src")) - .not.toMatch(/^blob:/); + .poll(() => + page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) + .filter(({ command }) => command === "update_profile") + .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), + ), + ) + .toEqual([uploadedAvatarUrl, ""]); + await expect( + page.getByText("Avatar couldn’t finish uploading"), + ).toBeVisible(); +}); + +test("replacing a pending upload disposes its verifier and local preview", async ({ + page, +}) => { + await seedCommunityProfileStage(page, "txn-avatar-replacement"); + await page.addInitScript(() => { + const testWindow = window as Window & { + __BUZZ_E2E_REVOKED_OBJECT_URLS__?: string[]; + }; + const revokedUrls: string[] = []; + const revokeObjectUrl = URL.revokeObjectURL.bind(URL); + testWindow.__BUZZ_E2E_REVOKED_OBJECT_URLS__ = revokedUrls; + URL.revokeObjectURL = (url) => { + revokedUrls.push(url); + revokeObjectUrl(url); + }; + }); + + const uploadedAvatarUrl = + "https://mock.relay/media/superseded-community-avatar.png"; + await page.route(`${uploadedAvatarUrl}*`, (route) => + route.fulfill({ status: 404 }), + ); + await installMockBridge( + page, + { + uploadDescriptors: [ + { + filename: "superseded-community-avatar.png", + sha256: "e".repeat(64), + size: 128, + type: "image/png", + uploaded: 1_779_900_001, + url: uploadedAvatarUrl, + }, + ], + }, + { + relayWsUrl: "wss://default.example.com", + skipOnboardingSeed: true, + }, + ); + await page.goto("/"); + + await page.getByTestId("community-profile-name-key").fill("Tyler"); + await uploadCommunityAvatar(page, "superseded-community-avatar.png"); + const supersededPreviewUrl = await page + .getByTestId("community-avatar-circle-image") + .getAttribute("src"); + expect(supersededPreviewUrl).toMatch(/^blob:/); + + await page.getByTestId("community-avatar-open").click(); + await page.getByRole("tab", { name: "Emoji" }).click(); + await selectFirstEmojiFromPicker(page); + await page.getByTestId("community-avatar-done").click(); + + await expect + .poll(() => + page.evaluate(() => { + const testWindow = window as Window & { + __BUZZ_E2E_REVOKED_OBJECT_URLS__?: string[]; + }; + return testWindow.__BUZZ_E2E_REVOKED_OBJECT_URLS__ ?? []; + }), + ) + .toContain(supersededPreviewUrl); + await page.waitForTimeout(6_000); + await expect(page.getByText("Avatar couldn’t finish uploading")).toHaveCount( + 0, + ); + await expect(page.getByRole("button", { name: "Retry" })).toHaveCount(0); }); test("membership denial on community profile save offers recovery", async ({ From 666cd173d959375de7c20123fa5d4dafe3bd6cce Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 21 Jul 2026 16:39:03 -0700 Subject: [PATCH 02/14] Preserve confirmed avatars during upload failures Signed-off-by: Logan Johnson --- .../onboarding/ui/CommunityOnboardingFlow.tsx | 54 +++++++-- desktop/tests/e2e/onboarding.spec.ts | 106 +++++++++++++++++- 2 files changed, 149 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index bfe6bf8c07..234f5da68f 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -20,7 +20,7 @@ import { parseEmojiAvatarDataUrl, ProfileAvatarEditor, } from "@/features/profile/ui/ProfileAvatarEditor"; -import { updateProfile } from "@/shared/api/tauriProfiles"; +import { getProfile, updateProfile } from "@/shared/api/tauriProfiles"; import { getIdentity, importIdentity } from "@/shared/api/tauriIdentity"; import { listPersonas } from "@/shared/api/tauriPersonas"; import { relayClient } from "@/shared/api/relayClient"; @@ -154,6 +154,9 @@ export function CommunityOnboardingFlow({ const [avatarUrl, setAvatarUrl] = React.useState(""); const avatarPresentation = useAvatarPresentation(avatarUrl); const [hasSavedProfile, setHasSavedProfile] = React.useState(false); + const confirmedAvatarUrlRef = React.useRef( + undefined, + ); const persistedAvatarUrlRef = React.useRef(null); const profileAvatarSyncQueueRef = React.useRef>( Promise.resolve(), @@ -303,16 +306,34 @@ export function CommunityOnboardingFlow({ // Next remains available during propagation. Serialize later failure and // Retry transitions so an older profile write cannot win the race. + const candidateAvatarUrl = avatarUrl.trim(); + if (!candidateAvatarUrl) return; + + const presentationState = avatarPresentation?.state; const nextAvatarUrl = - avatarPresentation?.state === "failed" ? "" : avatarUrl.trim(); - if (persistedAvatarUrlRef.current === nextAvatarUrl) return; + presentationState === "failed" + ? (confirmedAvatarUrlRef.current ?? "") + : candidateAvatarUrl; + if (persistedAvatarUrlRef.current === nextAvatarUrl) { + if (presentationState === "ready") { + confirmedAvatarUrlRef.current = candidateAvatarUrl; + } + return; + } persistedAvatarUrlRef.current = nextAvatarUrl; profileAvatarSyncQueueRef.current = profileAvatarSyncQueueRef.current .catch(() => undefined) .then(async () => { try { - await updateProfile({ avatarUrl: nextAvatarUrl }); + const profile = await updateProfile({ avatarUrl: nextAvatarUrl }); + if ( + presentationState !== "failed" && + presentationState !== "pending" && + persistedAvatarUrlRef.current === nextAvatarUrl + ) { + confirmedAvatarUrlRef.current = profile.avatarUrl?.trim() || null; + } } catch { if (persistedAvatarUrlRef.current === nextAvatarUrl) { persistedAvatarUrlRef.current = null; @@ -385,13 +406,28 @@ export function CommunityOnboardingFlow({ if (!displayName.trim()) return; setIsPending(true); try { - const nextAvatarUrl = - avatarPresentation?.state === "failed" ? "" : avatarUrl.trim(); - await updateProfile({ + const candidateAvatarUrl = avatarUrl.trim(); + const presentationState = avatarPresentation?.state; + const shouldSaveCandidate = + candidateAvatarUrl.length > 0 && presentationState !== "failed"; + + if ( + shouldSaveCandidate && + presentationState === "pending" && + confirmedAvatarUrlRef.current === undefined + ) { + const profile = await getProfile(); + confirmedAvatarUrlRef.current = profile.avatarUrl?.trim() || null; + } + + const profile = await updateProfile({ displayName: displayName.trim(), - avatarUrl: nextAvatarUrl, + avatarUrl: shouldSaveCandidate ? candidateAvatarUrl : undefined, }); - persistedAvatarUrlRef.current = nextAvatarUrl; + persistedAvatarUrlRef.current = profile.avatarUrl?.trim() || ""; + if (presentationState !== "pending") { + confirmedAvatarUrlRef.current = profile.avatarUrl?.trim() || null; + } setHasSavedProfile(true); update({ stage: "team-intro", error: undefined }); } catch (error) { diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 714a50cdd8..45f58f0be5 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -456,6 +456,23 @@ async function invokeMockCommand( ); } +async function seedCurrentAvatar(page: Page, avatarUrl: string) { + await page.waitForFunction(() => { + const bridgeWindow = window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown; + __TAURI_INTERNALS__?: { invoke?: unknown }; + }; + return ( + typeof bridgeWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" || + typeof bridgeWindow.__TAURI_INTERNALS__?.invoke === "function" + ); + }); + await invokeMockCommand(page, "update_profile", { avatarUrl }); + await page.evaluate(() => { + window.__BUZZ_E2E_COMMAND_PAYLOADS__ = []; + }); +} + async function getWelcomeChannelId(page: Page) { const channels = await getMockChannels(page); return ( @@ -1680,6 +1697,38 @@ test("connected first-community profile step offers equal-width Next and Back co .toBeNull(); }); +test("name-only community profile save preserves an existing avatar", async ({ + page, +}) => { + await seedCommunityProfileStage(page, "txn-avatar-preserve-existing"); + await installMockBridge(page, undefined, { + relayWsUrl: "wss://default.example.com", + skipOnboardingSeed: true, + }); + await page.goto("/"); + + const existingAvatarUrl = + "https://mock.relay/media/existing-community-avatar.png"; + await seedCurrentAvatar(page, existingAvatarUrl); + await page.getByTestId("community-profile-name-key").fill("Tyler"); + await page.getByTestId("community-profile-next").click(); + + await expect + .poll(() => + page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) + .filter(({ command }) => command === "update_profile") + .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), + ), + ) + .toEqual([undefined]); + const profile = await invokeMockCommand<{ avatar_url: string | null }>( + page, + "get_profile", + ); + expect(profile.avatar_url).toBe(existingAvatarUrl); +}); + test("pending avatar stays navigable, clears failures, and retries", async ({ page, }) => { @@ -1787,7 +1836,7 @@ test("pending avatar stays navigable, clears failures, and retries", async ({ .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) - .toEqual([""]); + .toEqual([undefined]); avatarReady = true; await page.getByRole("button", { name: "Retry" }).click(); @@ -1799,7 +1848,7 @@ test("pending avatar stays navigable, clears failures, and retries", async ({ .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) - .toEqual(["", uploadedAvatarUrl]); + .toEqual([undefined, uploadedAvatarUrl]); await expect(page.getByText("Avatar couldn’t finish uploading")).toHaveCount( 0, ); @@ -1856,6 +1905,59 @@ test("a pending avatar is cleared if propagation fails after profile save", asyn ).toBeVisible(); }); +test("a failed pending avatar restores the previously confirmed avatar", async ({ + page, +}) => { + await seedCommunityProfileStage(page, "txn-avatar-restore-existing"); + const existingAvatarUrl = + "https://mock.relay/media/existing-community-avatar.png"; + const uploadedAvatarUrl = + "https://mock.relay/media/replacement-community-avatar.png"; + await page.route(`${uploadedAvatarUrl}*`, (route) => + route.fulfill({ status: 404 }), + ); + await installMockBridge( + page, + { + uploadDescriptors: [ + { + filename: "replacement-community-avatar.png", + sha256: "a".repeat(64), + size: 128, + type: "image/png", + uploaded: 1_779_900_003, + url: uploadedAvatarUrl, + }, + ], + }, + { + relayWsUrl: "wss://default.example.com", + skipOnboardingSeed: true, + }, + ); + await page.goto("/"); + await seedCurrentAvatar(page, existingAvatarUrl); + + await page.getByTestId("community-profile-name-key").fill("Tyler"); + await uploadCommunityAvatar(page, "replacement-community-avatar.png"); + await page.getByTestId("community-profile-next").click(); + + await expect + .poll(() => + page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) + .filter(({ command }) => command === "update_profile") + .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), + ), + ) + .toEqual([uploadedAvatarUrl, existingAvatarUrl]); + const profile = await invokeMockCommand<{ avatar_url: string | null }>( + page, + "get_profile", + ); + expect(profile.avatar_url).toBe(existingAvatarUrl); +}); + test("replacing a pending upload disposes its verifier and local preview", async ({ page, }) => { From 19649856af9cae7a19cb1d3c3b59cdb65ef94583 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 22 Jul 2026 07:10:43 -0700 Subject: [PATCH 03/14] Defer avatar profile saves until ready Signed-off-by: Logan Johnson --- .../onboarding/ui/CommunityOnboardingFlow.tsx | 135 +++++++++--------- desktop/tests/e2e/onboarding.spec.ts | 99 ++++++++++++- 2 files changed, 163 insertions(+), 71 deletions(-) diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 234f5da68f..c01365d0e7 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -13,7 +13,11 @@ import { takePendingWelcomeChannelForDirectEntry, WELCOME_SURFACE_READY_EVENT, } from "@/features/onboarding/welcome"; -import { useAvatarPresentation } from "@/features/profile/avatarPresentationStore"; +import { + getAvatarPresentation, + subscribeAvatarPresentations, + useAvatarPresentation, +} from "@/features/profile/avatarPresentationStore"; import { profileQueryKey } from "@/features/profile/hooks"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { @@ -62,6 +66,59 @@ const ENTERING_CURTAIN_FADE_MS = 500; */ const ENTERING_CURTAIN_MAX_WAIT_MS = 8_000; +// Upload verification can finish after onboarding unmounts, so these +// subscriptions deliberately live for the lifetime of the application. +const pendingAvatarProfileSyncs = new Map void>(); + +function normalizedAvatarUrl(avatarUrl: string | null | undefined) { + return avatarUrl?.trim() || null; +} + +function saveAvatarWhenReady( + avatarUrl: string, + expectedPubkey: string, + expectedAvatarUrl: string | null, +): void { + const syncKey = `${expectedPubkey}:${avatarUrl}`; + if (pendingAvatarProfileSyncs.has(syncKey)) return; + + let isSaving = false; + const stop = () => { + pendingAvatarProfileSyncs.get(syncKey)?.(); + pendingAvatarProfileSyncs.delete(syncKey); + }; + const saveIfReady = () => { + const presentation = getAvatarPresentation(avatarUrl); + if (!presentation) { + stop(); + return; + } + if (presentation.state !== "ready" || isSaving) return; + + isSaving = true; + void getProfile() + .then((profile) => { + // Do not overwrite an identity or avatar the user changed meanwhile. + if ( + profile.pubkey !== expectedPubkey || + normalizedAvatarUrl(profile.avatarUrl) !== + normalizedAvatarUrl(expectedAvatarUrl) + ) { + return; + } + return updateProfile({ avatarUrl }); + }) + .catch(() => undefined) + .finally(stop); + }; + + pendingAvatarProfileSyncs.set( + syncKey, + subscribeAvatarPresentations(saveIfReady), + ); + saveIfReady(); +} + const NEUTRAL_EMOJI_PICKER_THEME_VARS = { "--buzz-emoji-picker-rgb-background": "var(--buzz-onboarding-emoji-picker-background)", @@ -153,14 +210,6 @@ export function CommunityOnboardingFlow({ const [displayName, setDisplayName] = React.useState(""); const [avatarUrl, setAvatarUrl] = React.useState(""); const avatarPresentation = useAvatarPresentation(avatarUrl); - const [hasSavedProfile, setHasSavedProfile] = React.useState(false); - const confirmedAvatarUrlRef = React.useRef( - undefined, - ); - const persistedAvatarUrlRef = React.useRef(null); - const profileAvatarSyncQueueRef = React.useRef>( - Promise.resolve(), - ); const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false); const [isAvatarEditorOpen, setIsAvatarEditorOpen] = React.useState(false); const [starterPersonas, setStarterPersonas] = React.useState( @@ -301,47 +350,6 @@ export function CommunityOnboardingFlow({ } }, [isAvatarEditorOpen, isProfileStage]); - React.useEffect(() => { - if (!hasSavedProfile) return; - - // Next remains available during propagation. Serialize later failure and - // Retry transitions so an older profile write cannot win the race. - const candidateAvatarUrl = avatarUrl.trim(); - if (!candidateAvatarUrl) return; - - const presentationState = avatarPresentation?.state; - const nextAvatarUrl = - presentationState === "failed" - ? (confirmedAvatarUrlRef.current ?? "") - : candidateAvatarUrl; - if (persistedAvatarUrlRef.current === nextAvatarUrl) { - if (presentationState === "ready") { - confirmedAvatarUrlRef.current = candidateAvatarUrl; - } - return; - } - - persistedAvatarUrlRef.current = nextAvatarUrl; - profileAvatarSyncQueueRef.current = profileAvatarSyncQueueRef.current - .catch(() => undefined) - .then(async () => { - try { - const profile = await updateProfile({ avatarUrl: nextAvatarUrl }); - if ( - presentationState !== "failed" && - presentationState !== "pending" && - persistedAvatarUrlRef.current === nextAvatarUrl - ) { - confirmedAvatarUrlRef.current = profile.avatarUrl?.trim() || null; - } - } catch { - if (persistedAvatarUrlRef.current === nextAvatarUrl) { - persistedAvatarUrlRef.current = null; - } - } - }); - }, [avatarPresentation?.state, avatarUrl, hasSavedProfile]); - React.useLayoutEffect(() => { if (!isAvatarEditorOpen) { setAvatarEditorDialogHeight(null); @@ -409,26 +417,25 @@ export function CommunityOnboardingFlow({ const candidateAvatarUrl = avatarUrl.trim(); const presentationState = avatarPresentation?.state; const shouldSaveCandidate = - candidateAvatarUrl.length > 0 && presentationState !== "failed"; - - if ( - shouldSaveCandidate && - presentationState === "pending" && - confirmedAvatarUrlRef.current === undefined - ) { - const profile = await getProfile(); - confirmedAvatarUrlRef.current = profile.avatarUrl?.trim() || null; - } + candidateAvatarUrl.length > 0 && + presentationState !== "failed" && + presentationState !== "pending"; const profile = await updateProfile({ displayName: displayName.trim(), avatarUrl: shouldSaveCandidate ? candidateAvatarUrl : undefined, }); - persistedAvatarUrlRef.current = profile.avatarUrl?.trim() || ""; - if (presentationState !== "pending") { - confirmedAvatarUrlRef.current = profile.avatarUrl?.trim() || null; + if ( + candidateAvatarUrl && + presentationState && + presentationState !== "ready" + ) { + saveAvatarWhenReady( + candidateAvatarUrl, + profile.pubkey, + profile.avatarUrl, + ); } - setHasSavedProfile(true); update({ stage: "team-intro", error: undefined }); } catch (error) { if (isRelayMembershipDeniedError(error)) { diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 45f58f0be5..349975fe05 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -1854,15 +1854,19 @@ test("pending avatar stays navigable, clears failures, and retries", async ({ ); }); -test("a pending avatar is cleared if propagation fails after profile save", async ({ +test("a pending avatar never becomes durable if propagation fails after onboarding unmounts", async ({ page, }) => { await seedCommunityProfileStage(page, "txn-avatar-saved-before-failure"); const uploadedAvatarUrl = "https://mock.relay/media/saved-pending-community-avatar.png"; - await page.route(`${uploadedAvatarUrl}*`, (route) => - route.fulfill({ status: 404 }), - ); + let allowAvatarFailure = false; + await page.route(`${uploadedAvatarUrl}*`, async (route) => { + while (!allowAvatarFailure) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + await route.fulfill({ status: 404 }); + }); await installMockBridge( page, { @@ -1890,6 +1894,11 @@ test("a pending avatar is cleared if propagation fails after profile save", asyn page.getByTestId("community-avatar-circle-upload-pending"), ).toBeVisible(); await page.getByTestId("community-profile-next").click(); + await page.getByTestId("community-team-intro-enter").click(); + await expect(page.getByTestId("community-onboarding-flow")).toHaveCount(0, { + timeout: 10_000, + }); + allowAvatarFailure = true; await expect .poll(() => @@ -1899,13 +1908,89 @@ test("a pending avatar is cleared if propagation fails after profile save", asyn .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) - .toEqual([uploadedAvatarUrl, ""]); + .toEqual([undefined]); await expect( page.getByText("Avatar couldn’t finish uploading"), ).toBeVisible(); + const profile = await invokeMockCommand<{ avatar_url: string | null }>( + page, + "get_profile", + ); + expect(profile.avatar_url).toBeNull(); +}); + +test("a pending avatar becomes durable after onboarding unmounts once ready", async ({ + page, +}) => { + await seedCommunityProfileStage(page, "txn-avatar-ready-after-unmount"); + const uploadedAvatarUrl = + "https://mock.relay/media/ready-after-unmount-community-avatar.png"; + let allowAvatarReady = false; + await page.route(`${uploadedAvatarUrl}*`, async (route) => { + while (!allowAvatarReady) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + await route.fulfill({ + body: Buffer.from(ONE_PIXEL_PNG_BASE64, "base64"), + contentType: "image/png", + }); + }); + await installMockBridge( + page, + { + uploadDescriptors: [ + { + filename: "ready-after-unmount-community-avatar.png", + sha256: "b".repeat(64), + size: 128, + type: "image/png", + uploaded: 1_779_900_004, + url: uploadedAvatarUrl, + }, + ], + }, + { + relayWsUrl: "wss://default.example.com", + skipOnboardingSeed: true, + }, + ); + await page.goto("/"); + + await page.getByTestId("community-profile-name-key").fill("Tyler"); + await uploadCommunityAvatar(page, "ready-after-unmount-community-avatar.png"); + await page.getByTestId("community-profile-next").click(); + await page.getByTestId("community-team-intro-enter").click(); + await expect(page.getByTestId("community-onboarding-flow")).toHaveCount(0, { + timeout: 10_000, + }); + await expect + .poll(() => + page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) + .filter(({ command }) => command === "update_profile") + .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), + ), + ) + .toEqual([undefined]); + + allowAvatarReady = true; + await expect + .poll(() => + page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) + .filter(({ command }) => command === "update_profile") + .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), + ), + ) + .toEqual([undefined, uploadedAvatarUrl]); + const profile = await invokeMockCommand<{ avatar_url: string | null }>( + page, + "get_profile", + ); + expect(profile.avatar_url).toBe(uploadedAvatarUrl); }); -test("a failed pending avatar restores the previously confirmed avatar", async ({ +test("a failed pending replacement leaves the confirmed avatar untouched", async ({ page, }) => { await seedCommunityProfileStage(page, "txn-avatar-restore-existing"); @@ -1950,7 +2035,7 @@ test("a failed pending avatar restores the previously confirmed avatar", async ( .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) - .toEqual([uploadedAvatarUrl, existingAvatarUrl]); + .toEqual([undefined]); const profile = await invokeMockCommand<{ avatar_url: string | null }>( page, "get_profile", From 31af746eaaa17abeb6d9d54f5884329d17d9b5cb Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 22 Jul 2026 07:54:08 -0700 Subject: [PATCH 04/14] Reset avatar lifecycle on community switch Signed-off-by: Logan Johnson --- AGENTS.md | 2 + .../features/communities/useCommunityInit.ts | 4 + .../onboarding/ui/CommunityOnboardingFlow.tsx | 62 +----------- .../profile/avatarPresentationStore.ts | 10 ++ .../profile/avatarProfileSync.test.mjs | 88 +++++++++++++++++ .../src/features/profile/avatarProfileSync.ts | 96 +++++++++++++++++++ 6 files changed, 203 insertions(+), 59 deletions(-) create mode 100644 desktop/src/features/profile/avatarProfileSync.test.mjs create mode 100644 desktop/src/features/profile/avatarProfileSync.ts diff --git a/AGENTS.md b/AGENTS.md index c94ba3881e..c71d398cd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -482,6 +482,8 @@ Current singletons that are reset on community switch: - `resetAgentObserverStore()` — agent observer relay store - `resetActiveAgentTurnsStore()` — active agent turn timers - `resetAgentWorkingSignal()` — agent working indicator signal +- `resetAvatarProfileSync()` — pending verified-avatar profile writes +- `resetAvatarPresentations()` — avatar probes, previews, and Retry toasts - `resetSidebarRelayConnectionCardState()` — sidebar relay card dismiss state - `resetMediaCaches()` — proxy port and relay origin caches - `resetVideoPlayerState()` — video player singleton diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index a50a8665c7..ff64059140 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -19,6 +19,8 @@ import { } from "@/features/agents/activeAgentTurnsStore"; import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; +import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore"; +import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { clearMarkdownNodeCache } from "@/shared/ui/markdown/nodeCache"; import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState"; @@ -40,6 +42,8 @@ function resetCommunityState(): void { resetAgentObserverStore(); resetActiveAgentTurnsStore(); resetAgentWorkingSignal(); + resetAvatarProfileSync(); + resetAvatarPresentations(); resetSidebarRelayConnectionCardState(); resetMediaCaches(); resetVideoPlayerState(); diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index c01365d0e7..6e9c591308 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -13,18 +13,15 @@ import { takePendingWelcomeChannelForDirectEntry, WELCOME_SURFACE_READY_EVENT, } from "@/features/onboarding/welcome"; -import { - getAvatarPresentation, - subscribeAvatarPresentations, - useAvatarPresentation, -} from "@/features/profile/avatarPresentationStore"; +import { useAvatarPresentation } from "@/features/profile/avatarPresentationStore"; +import { saveAvatarWhenReady } from "@/features/profile/avatarProfileSync"; import { profileQueryKey } from "@/features/profile/hooks"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { parseEmojiAvatarDataUrl, ProfileAvatarEditor, } from "@/features/profile/ui/ProfileAvatarEditor"; -import { getProfile, updateProfile } from "@/shared/api/tauriProfiles"; +import { updateProfile } from "@/shared/api/tauriProfiles"; import { getIdentity, importIdentity } from "@/shared/api/tauriIdentity"; import { listPersonas } from "@/shared/api/tauriPersonas"; import { relayClient } from "@/shared/api/relayClient"; @@ -66,59 +63,6 @@ const ENTERING_CURTAIN_FADE_MS = 500; */ const ENTERING_CURTAIN_MAX_WAIT_MS = 8_000; -// Upload verification can finish after onboarding unmounts, so these -// subscriptions deliberately live for the lifetime of the application. -const pendingAvatarProfileSyncs = new Map void>(); - -function normalizedAvatarUrl(avatarUrl: string | null | undefined) { - return avatarUrl?.trim() || null; -} - -function saveAvatarWhenReady( - avatarUrl: string, - expectedPubkey: string, - expectedAvatarUrl: string | null, -): void { - const syncKey = `${expectedPubkey}:${avatarUrl}`; - if (pendingAvatarProfileSyncs.has(syncKey)) return; - - let isSaving = false; - const stop = () => { - pendingAvatarProfileSyncs.get(syncKey)?.(); - pendingAvatarProfileSyncs.delete(syncKey); - }; - const saveIfReady = () => { - const presentation = getAvatarPresentation(avatarUrl); - if (!presentation) { - stop(); - return; - } - if (presentation.state !== "ready" || isSaving) return; - - isSaving = true; - void getProfile() - .then((profile) => { - // Do not overwrite an identity or avatar the user changed meanwhile. - if ( - profile.pubkey !== expectedPubkey || - normalizedAvatarUrl(profile.avatarUrl) !== - normalizedAvatarUrl(expectedAvatarUrl) - ) { - return; - } - return updateProfile({ avatarUrl }); - }) - .catch(() => undefined) - .finally(stop); - }; - - pendingAvatarProfileSyncs.set( - syncKey, - subscribeAvatarPresentations(saveIfReady), - ); - saveIfReady(); -} - const NEUTRAL_EMOJI_PICKER_THEME_VARS = { "--buzz-emoji-picker-rgb-background": "var(--buzz-onboarding-emoji-picker-background)", diff --git a/desktop/src/features/profile/avatarPresentationStore.ts b/desktop/src/features/profile/avatarPresentationStore.ts index c5cb2cfcac..cfdad97248 100644 --- a/desktop/src/features/profile/avatarPresentationStore.ts +++ b/desktop/src/features/profile/avatarPresentationStore.ts @@ -153,6 +153,16 @@ export function disposeAvatarPresentation(remoteUrl: string): void { emitChange(); } +export function resetAvatarPresentations(): void { + for (const entry of presentations.values()) { + entry.generation = nextGeneration++; + toast.dismiss(toastId(entry.remoteUrl)); + releaseLocalPreview(entry); + } + presentations.clear(); + emitChange(); +} + export function retryAvatarPresentation(remoteUrl: string): void { const entry = presentations.get(remoteUrl); if (entry?.snapshot.state !== "failed") return; diff --git a/desktop/src/features/profile/avatarProfileSync.test.mjs b/desktop/src/features/profile/avatarProfileSync.test.mjs new file mode 100644 index 0000000000..d0db82f682 --- /dev/null +++ b/desktop/src/features/profile/avatarProfileSync.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { createAvatarProfileSync } from "./avatarProfileSync.ts"; + +const AVATAR_URL = "https://old-relay.example/avatar.png"; +const PUBKEY = "pubkey"; + +function createHarness({ initialState = "pending", getProfile } = {}) { + let presentation = { displayUrl: AVATAR_URL, state: initialState }; + let listener = () => {}; + let unsubscribeCount = 0; + const updates = []; + const sync = createAvatarProfileSync({ + getPresentation: () => presentation, + getProfile: + getProfile ?? (async () => ({ avatarUrl: null, pubkey: PUBKEY })), + subscribe: (nextListener) => { + listener = nextListener; + return () => { + unsubscribeCount += 1; + }; + }, + updateProfile: async (input) => { + updates.push(input); + }, + }); + + return { + get unsubscribeCount() { + return unsubscribeCount; + }, + listener: () => listener(), + setState: (state) => { + presentation = { ...presentation, state }; + }, + sync, + updates, + }; +} + +async function flushPromises() { + await new Promise((resolve) => setImmediate(resolve)); +} + +test("saves an avatar after verification succeeds", async () => { + const harness = createHarness(); + harness.sync.saveWhenReady(AVATAR_URL, PUBKEY, null); + + harness.setState("ready"); + harness.listener(); + await flushPromises(); + + assert.deepEqual(harness.updates, [{ avatarUrl: AVATAR_URL }]); + assert.equal(harness.unsubscribeCount, 1); +}); + +test("community reset cancels a pending avatar save", async () => { + const harness = createHarness(); + harness.sync.saveWhenReady(AVATAR_URL, PUBKEY, null); + + harness.sync.reset(); + harness.setState("ready"); + harness.listener(); + await flushPromises(); + + assert.deepEqual(harness.updates, []); + assert.equal(harness.unsubscribeCount, 1); +}); + +test("community reset invalidates a profile read already in flight", async () => { + let resolveProfile; + const profilePromise = new Promise((resolve) => { + resolveProfile = resolve; + }); + const harness = createHarness({ + getProfile: () => profilePromise, + initialState: "ready", + }); + harness.sync.saveWhenReady(AVATAR_URL, PUBKEY, null); + + harness.sync.reset(); + resolveProfile({ avatarUrl: null, pubkey: PUBKEY }); + await flushPromises(); + + assert.deepEqual(harness.updates, []); + assert.equal(harness.unsubscribeCount, 1); +}); diff --git a/desktop/src/features/profile/avatarProfileSync.ts b/desktop/src/features/profile/avatarProfileSync.ts new file mode 100644 index 0000000000..e1a6e02d2c --- /dev/null +++ b/desktop/src/features/profile/avatarProfileSync.ts @@ -0,0 +1,96 @@ +import { + getAvatarPresentation, + subscribeAvatarPresentations, + type AvatarPresentation, +} from "@/features/profile/avatarPresentationStore"; +import { getProfile, updateProfile } from "@/shared/api/tauriProfiles"; +import type { Profile } from "@/shared/api/types"; + +type AvatarProfileSyncDependencies = { + getPresentation: (avatarUrl: string) => AvatarPresentation | null; + getProfile: () => Promise; + subscribe: (listener: () => void) => () => void; + updateProfile: (input: { avatarUrl: string }) => Promise; +}; + +function normalizedAvatarUrl(avatarUrl: string | null | undefined) { + return avatarUrl?.trim() || null; +} + +export function createAvatarProfileSync( + dependencies: AvatarProfileSyncDependencies, +) { + const pendingSyncs = new Map void>(); + let generation = 0; + + const reset = () => { + generation += 1; + for (const unsubscribe of pendingSyncs.values()) unsubscribe(); + pendingSyncs.clear(); + }; + + const saveWhenReady = ( + avatarUrl: string, + expectedPubkey: string, + expectedAvatarUrl: string | null, + ): void => { + const syncKey = `${expectedPubkey}:${avatarUrl}`; + if (pendingSyncs.has(syncKey)) return; + + const queuedGeneration = generation; + let isSaving = false; + const stop = () => { + pendingSyncs.get(syncKey)?.(); + pendingSyncs.delete(syncKey); + }; + const saveIfReady = () => { + const presentation = dependencies.getPresentation(avatarUrl); + if (!presentation) { + stop(); + return; + } + if (presentation.state !== "ready" || isSaving) return; + + isSaving = true; + void dependencies + .getProfile() + .then((profile) => { + if ( + generation !== queuedGeneration || + profile.pubkey !== expectedPubkey || + normalizedAvatarUrl(profile.avatarUrl) !== + normalizedAvatarUrl(expectedAvatarUrl) + ) { + return; + } + return dependencies.updateProfile({ avatarUrl }); + }) + .catch(() => undefined) + .finally(stop); + }; + + pendingSyncs.set(syncKey, dependencies.subscribe(saveIfReady)); + saveIfReady(); + }; + + return { reset, saveWhenReady }; +} + +const avatarProfileSync = createAvatarProfileSync({ + getPresentation: getAvatarPresentation, + getProfile, + subscribe: subscribeAvatarPresentations, + updateProfile, +}); + +export function saveAvatarWhenReady( + avatarUrl: string, + expectedPubkey: string, + expectedAvatarUrl: string | null, +): void { + avatarProfileSync.saveWhenReady(avatarUrl, expectedPubkey, expectedAvatarUrl); +} + +export function resetAvatarProfileSync(): void { + avatarProfileSync.reset(); +} From c179fe72c09f4421be97e2b66740694ac184a2af Mon Sep 17 00:00:00 2001 From: npub1dpf98sl35hm9k65t8h6cvh5n6knn2msugh5k5nmysxwnw5wlh7uqn2wgp4 <685253c3f1a5f65b6a8b3df5865e93d5a7356e1c45e96a4f64819d3751dfbfb8@sprout-oss.stage.blox.sqprod.co> Date: Wed, 22 Jul 2026 15:16:31 -0700 Subject: [PATCH 05/14] fix avatar profile relay and cache lifecycle Signed-off-by: npub1dpf98sl35hm9k65t8h6cvh5n6knn2msugh5k5nmysxwnw5wlh7uqn2wgp4 <685253c3f1a5f65b6a8b3df5865e93d5a7356e1c45e96a4f64819d3751dfbfb8@sprout-oss.stage.blox.sqprod.co> --- AGENTS.md | 3 +- desktop/src-tauri/src/commands/profile.rs | 55 ++++++++++- desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/relay.rs | 52 +---------- desktop/src-tauri/src/relay/submit.rs | 62 +++++++++++++ desktop/src/app/App.tsx | 6 ++ .../features/communities/useCommunityInit.ts | 22 ++++- .../onboarding/ui/CommunityOnboardingFlow.tsx | 11 ++- .../profile/avatarProfileSync.test.mjs | 75 ++++++++++----- .../src/features/profile/avatarProfileSync.ts | 69 +++++++------- .../profile/profileCacheSync.test.mjs | 91 +++++++++++++++++++ .../src/features/profile/profileCacheSync.ts | 91 +++++++++++++++++++ desktop/src/shared/api/tauriProfiles.ts | 13 +++ desktop/src/testing/e2eBridge.ts | 7 ++ desktop/tests/e2e/onboarding.spec.ts | 42 +++++++-- 15 files changed, 475 insertions(+), 125 deletions(-) create mode 100644 desktop/src-tauri/src/relay/submit.rs create mode 100644 desktop/src/features/profile/profileCacheSync.test.mjs create mode 100644 desktop/src/features/profile/profileCacheSync.ts diff --git a/AGENTS.md b/AGENTS.md index c71d398cd6..c9761d4ea2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -475,7 +475,8 @@ class instances, cached promises) survive across remounts. Every community-scope singleton needs a reset function wired into `resetCommunityState()` in `desktop/src/features/communities/useCommunityInit.ts`. -Current singletons that are reset on community switch: +Current singletons that are reset on relay boundary changes (same-relay +reconnects preserve pending avatar verification work): - `relayClient.disconnect()` — WebSocket teardown + promise rejection - `resetRateLimitGate()` — clears any active rate-limit window from the old relay - `clearAllDrafts()` — message draft cache diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index ab36b45c5c..1a3102c9bf 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -9,7 +9,7 @@ use crate::{ events, models::{ProfileInfo, SearchUsersResponse, UserNotesResponse, UsersBatchResponse}, nostr_convert, - relay::{query_relay, submit_event}, + relay::{query_relay, query_relay_at, relay_http_base_url, submit_event, submit_event_at}, }; #[tauri::command] @@ -94,6 +94,59 @@ pub async fn update_profile( .unwrap_or_else(|| empty_profile_info(¤t_pubkey_hex_unwrap(&state)))) } +#[tauri::command] +pub async fn update_profile_at_relay( + relay_url: String, + expected_pubkey: String, + expected_avatar_url: Option, + avatar_url: String, + state: State<'_, AppState>, +) -> Result { + let current_pubkey = current_pubkey_hex(&state)?; + if current_pubkey != expected_pubkey { + return Err("profile identity changed before avatar save".to_string()); + } + + let api_base_url = relay_http_base_url(&relay_url); + let filter = serde_json::json!({ + "kinds": [0], + "authors": [expected_pubkey], + "limit": 1 + }); + let prior_events = query_relay_at(&state, &api_base_url, &[filter.clone()]).await?; + let current: Value = prior_events + .first() + .and_then(|event| serde_json::from_str::(&event.content).ok()) + .unwrap_or(Value::Null); + let current_avatar_url = current + .get("picture") + .and_then(Value::as_str) + .map(str::to_string); + if normalized_avatar_url(current_avatar_url.as_deref()) + != normalized_avatar_url(expected_avatar_url.as_deref()) + { + return Err("profile avatar changed before deferred save".to_string()); + } + + let display_name = current.get("display_name").and_then(Value::as_str); + let name = current.get("name").and_then(Value::as_str); + let about = current.get("about").and_then(Value::as_str); + let nip05 = current.get("nip05").and_then(Value::as_str); + let builder = events::build_profile(display_name, name, Some(&avatar_url), about, nip05)?; + submit_event_at(builder, &state, &api_base_url).await?; + + let events = query_relay_at(&state, &api_base_url, &[filter]).await?; + Ok(events + .first() + .map(nostr_convert::profile_info_from_event) + .transpose()? + .unwrap_or_else(|| empty_profile_info(&expected_pubkey))) +} + +fn normalized_avatar_url(avatar_url: Option<&str>) -> Option<&str> { + avatar_url.map(str::trim).filter(|value| !value.is_empty()) +} + #[tauri::command] pub async fn get_user_profile( pubkey: Option, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c22de0205f..1c129b89b5 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -662,6 +662,7 @@ pub fn run() { persist_current_identity, get_profile, update_profile, + update_profile_at_relay, get_user_profile, get_users_batch, get_user_notes, diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index d7d600d66b..85c04c2e9c 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -502,56 +502,8 @@ pub struct AgentProfileInfo { // ── Signed-event submission ───────────────────────────────────────────────── -/// Response from `POST /events`. -#[derive(Debug, Deserialize, serde::Serialize)] -pub struct SubmitEventResponse { - pub event_id: String, - pub accepted: bool, - pub message: String, -} - -/// Build an `EventBuilder` from the events module, sign it with the user's keys, -/// and POST the signed event to `/events` with NIP-98 auth. -pub async fn submit_event( - builder: nostr::EventBuilder, - state: &AppState, -) -> Result { - crate::relay_admission::wait_for_rate_limit().await; - // All synchronous work (signing) must complete before any .await - // so the MutexGuard is dropped and the future remains Send. - let url = format!("{}/events", relay_api_base_url_with_override(state)); - let (auth_header, body_bytes) = { - let keys = state.signing_keys()?; - let event = builder - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign event: {e}"))?; - let body = event.as_json().into_bytes(); - let auth = build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body)?; - (auth, body) - }; // keys dropped here - - let response = state - .http_client - .post(&url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json") - .body(body_bytes) - .send() - .await - .map_err(|e| classify_request_error(&e))?; - - if !response.status().is_success() { - return Err(relay_error_message(response).await); - } - - let result: SubmitEventResponse = parse_json_response(response).await?; - - if !result.accepted { - return Err(format!("relay rejected event: {}", result.message)); - } - - Ok(result) -} +mod submit; +pub use submit::{submit_event, submit_event_at, SubmitEventResponse}; /// POST an already-signed event to `/events` with NIP-98 auth. /// diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs new file mode 100644 index 0000000000..c90ca1113d --- /dev/null +++ b/desktop/src-tauri/src/relay/submit.rs @@ -0,0 +1,62 @@ +use super::*; + +/// Response from `POST /events`. +#[derive(Debug, Deserialize, serde::Serialize)] +pub struct SubmitEventResponse { + pub event_id: String, + pub accepted: bool, + pub message: String, +} + +/// Build an `EventBuilder` from the events module, sign it with the user's keys, +/// and POST the signed event to `/events` with NIP-98 auth. +pub async fn submit_event_at( + builder: nostr::EventBuilder, + state: &AppState, + api_base_url: &str, +) -> Result { + crate::relay_admission::wait_for_rate_limit().await; + // All synchronous work (signing) must complete before any .await + // so the MutexGuard is dropped and the future remains Send. + let url = format!("{}/events", api_base_url.trim_end_matches('/')); + let (auth_header, body_bytes) = { + let keys = state.signing_keys()?; + let event = builder + .sign_with_keys(&keys) + .map_err(|e| format!("failed to sign event: {e}"))?; + let body = event.as_json().into_bytes(); + let auth = build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body)?; + (auth, body) + }; // keys dropped here + + let response = state + .http_client + .post(&url) + .header("Authorization", auth_header) + .header("Content-Type", "application/json") + .body(body_bytes) + .send() + .await + .map_err(|e| classify_request_error(&e))?; + + if !response.status().is_success() { + return Err(relay_error_message(response).await); + } + + let result: SubmitEventResponse = parse_json_response(response).await?; + + if !result.accepted { + return Err(format!("relay rejected event: {}", result.message)); + } + + Ok(result) +} + +/// Build, sign, and submit an event to the currently active workspace relay. +pub async fn submit_event( + builder: nostr::EventBuilder, + state: &AppState, +) -> Result { + let api_base_url = relay_api_base_url_with_override(state); + submit_event_at(builder, state, &api_base_url).await +} diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 56b84118ad..4252109d73 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -41,6 +41,7 @@ import { import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup"; import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen"; import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay"; +import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync"; import { createBuzzQueryClient } from "@/shared/api/queryClient"; import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; import { @@ -193,6 +194,11 @@ function CommunitySwitchGate() { function CommunityQueryProvider({ children }: { children: ReactNode }) { const [queryClient] = useState(createBuzzQueryClient); + useEffect(() => { + setAvatarProfileSyncQueryClient(queryClient); + return () => setAvatarProfileSyncQueryClient(null); + }, [queryClient]); + useEffect(() => { const e2eWindow = window as Window & { __BUZZ_E2E__?: unknown; diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index ff64059140..d46c8d62ac 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -35,15 +35,21 @@ import type { Community } from "./types"; * destroyed via effect cleanup and do not need entries here. * See AGENTS.md "Community Switching" for the full contract. */ -function resetCommunityState(): void { +function resetCommunityState({ + resetAvatarState, +}: { + resetAvatarState: boolean; +}): void { relayClient.disconnect(); resetRateLimitGate(); clearAllDrafts(); resetAgentObserverStore(); resetActiveAgentTurnsStore(); resetAgentWorkingSignal(); - resetAvatarProfileSync(); - resetAvatarPresentations(); + if (resetAvatarState) { + resetAvatarProfileSync(); + resetAvatarPresentations(); + } resetSidebarRelayConnectionCardState(); resetMediaCaches(); resetVideoPlayerState(); @@ -88,6 +94,10 @@ export function useCommunityInit( // Track the previously-applied community ID so we can save its turn state // before resetting when the user switches to a different community. const prevCommunityIdRef = useRef(null); + // Deferred avatar work owns the relay captured when it was queued. A + // same-relay reconnect during onboarding must not cancel that work, while an + // actual relay boundary must clear both the queue and its presentation probe. + const appliedRelayUrlRef = useRef(null); // biome-ignore lint/correctness/useExhaustiveDependencies: we intentionally depend on specific properties (id/relayUrl/token/reposDir) — depending on the whole object would trigger resets on name-only changes useEffect(() => { @@ -149,9 +159,13 @@ export function useCommunityInit( // store under the outgoing community ID and delete its snapshot. prevCommunityIdRef.current = null; } - resetCommunityState(); + resetCommunityState({ + resetAvatarState: + appliedRelayUrlRef.current !== activeCommunity.relayUrl, + }); } hasInitializedRef.current = true; + appliedRelayUrlRef.current = activeCommunity.relayUrl; // Apply community config to the Tauri backend. // diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 6e9c591308..0630f4a627 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -374,11 +374,12 @@ export function CommunityOnboardingFlow({ presentationState && presentationState !== "ready" ) { - saveAvatarWhenReady( - candidateAvatarUrl, - profile.pubkey, - profile.avatarUrl, - ); + saveAvatarWhenReady({ + avatarUrl: candidateAvatarUrl, + relayUrl: transaction.relayUrl, + expectedPubkey: profile.pubkey, + expectedAvatarUrl: profile.avatarUrl, + }); } update({ stage: "team-intro", error: undefined }); } catch (error) { diff --git a/desktop/src/features/profile/avatarProfileSync.test.mjs b/desktop/src/features/profile/avatarProfileSync.test.mjs index d0db82f682..ea31e61fde 100644 --- a/desktop/src/features/profile/avatarProfileSync.test.mjs +++ b/desktop/src/features/profile/avatarProfileSync.test.mjs @@ -3,26 +3,35 @@ import { test } from "node:test"; import { createAvatarProfileSync } from "./avatarProfileSync.ts"; -const AVATAR_URL = "https://old-relay.example/avatar.png"; -const PUBKEY = "pubkey"; +const INPUT = { + avatarUrl: "https://old-relay.example/avatar.png", + relayUrl: "wss://old-relay.example", + expectedPubkey: "pubkey", + expectedAvatarUrl: null, +}; -function createHarness({ initialState = "pending", getProfile } = {}) { - let presentation = { displayUrl: AVATAR_URL, state: initialState }; +function createHarness({ initialState = "pending", saveProfile } = {}) { + let presentation = { displayUrl: INPUT.avatarUrl, state: initialState }; let listener = () => {}; let unsubscribeCount = 0; - const updates = []; + const saves = []; + const refreshed = []; const sync = createAvatarProfileSync({ getPresentation: () => presentation, - getProfile: - getProfile ?? (async () => ({ avatarUrl: null, pubkey: PUBKEY })), subscribe: (nextListener) => { listener = nextListener; return () => { unsubscribeCount += 1; }; }, - updateProfile: async (input) => { - updates.push(input); + saveProfile: + saveProfile ?? + (async (input) => { + saves.push(input); + return { avatarUrl: input.avatarUrl, pubkey: input.expectedPubkey }; + }), + refreshCaches: async (profile, input) => { + refreshed.push({ profile, input }); }, }); @@ -31,11 +40,12 @@ function createHarness({ initialState = "pending", getProfile } = {}) { return unsubscribeCount; }, listener: () => listener(), + refreshed, + saves, setState: (state) => { presentation = { ...presentation, state }; }, sync, - updates, }; } @@ -43,46 +53,63 @@ async function flushPromises() { await new Promise((resolve) => setImmediate(resolve)); } -test("saves an avatar after verification succeeds", async () => { +test("saves at the captured relay and refreshes caches after verification", async () => { const harness = createHarness(); - harness.sync.saveWhenReady(AVATAR_URL, PUBKEY, null); + harness.sync.saveWhenReady(INPUT); harness.setState("ready"); harness.listener(); await flushPromises(); - assert.deepEqual(harness.updates, [{ avatarUrl: AVATAR_URL }]); + assert.deepEqual(harness.saves, [INPUT]); + assert.equal(harness.refreshed.length, 1); + assert.equal(harness.refreshed[0].input.relayUrl, INPUT.relayUrl); assert.equal(harness.unsubscribeCount, 1); }); test("community reset cancels a pending avatar save", async () => { const harness = createHarness(); - harness.sync.saveWhenReady(AVATAR_URL, PUBKEY, null); + harness.sync.saveWhenReady(INPUT); harness.sync.reset(); harness.setState("ready"); harness.listener(); await flushPromises(); - assert.deepEqual(harness.updates, []); + assert.deepEqual(harness.saves, []); assert.equal(harness.unsubscribeCount, 1); }); -test("community reset invalidates a profile read already in flight", async () => { - let resolveProfile; - const profilePromise = new Promise((resolve) => { - resolveProfile = resolve; +test("a reset sync accepts deferred work from the next community", async () => { + const harness = createHarness(); + harness.sync.reset(); + harness.setState("ready"); + const nextInput = { + ...INPUT, + relayUrl: "wss://next-relay.example", + }; + + harness.sync.saveWhenReady(nextInput); + await flushPromises(); + + assert.deepEqual(harness.saves, [nextInput]); + assert.equal(harness.refreshed.length, 1); +}); + +test("cache refresh follows only a successful save", async () => { + let rejectSave; + const savePromise = new Promise((_, reject) => { + rejectSave = reject; }); const harness = createHarness({ - getProfile: () => profilePromise, initialState: "ready", + saveProfile: () => savePromise, }); - harness.sync.saveWhenReady(AVATAR_URL, PUBKEY, null); + harness.sync.saveWhenReady(INPUT); - harness.sync.reset(); - resolveProfile({ avatarUrl: null, pubkey: PUBKEY }); + rejectSave(new Error("stale baseline")); await flushPromises(); - assert.deepEqual(harness.updates, []); + assert.deepEqual(harness.refreshed, []); assert.equal(harness.unsubscribeCount, 1); }); diff --git a/desktop/src/features/profile/avatarProfileSync.ts b/desktop/src/features/profile/avatarProfileSync.ts index e1a6e02d2c..334c043927 100644 --- a/desktop/src/features/profile/avatarProfileSync.ts +++ b/desktop/src/features/profile/avatarProfileSync.ts @@ -1,22 +1,28 @@ +import type { QueryClient } from "@tanstack/react-query"; + import { getAvatarPresentation, subscribeAvatarPresentations, type AvatarPresentation, } from "@/features/profile/avatarPresentationStore"; -import { getProfile, updateProfile } from "@/shared/api/tauriProfiles"; +import { refreshProfileCaches } from "@/features/profile/profileCacheSync"; +import { updateProfileAtRelay } from "@/shared/api/tauriProfiles"; import type { Profile } from "@/shared/api/types"; +type PendingAvatarSave = { + avatarUrl: string; + relayUrl: string; + expectedPubkey: string; + expectedAvatarUrl: string | null; +}; + type AvatarProfileSyncDependencies = { getPresentation: (avatarUrl: string) => AvatarPresentation | null; - getProfile: () => Promise; subscribe: (listener: () => void) => () => void; - updateProfile: (input: { avatarUrl: string }) => Promise; + saveProfile: (input: PendingAvatarSave) => Promise; + refreshCaches: (profile: Profile, input: PendingAvatarSave) => Promise; }; -function normalizedAvatarUrl(avatarUrl: string | null | undefined) { - return avatarUrl?.trim() || null; -} - export function createAvatarProfileSync( dependencies: AvatarProfileSyncDependencies, ) { @@ -29,22 +35,19 @@ export function createAvatarProfileSync( pendingSyncs.clear(); }; - const saveWhenReady = ( - avatarUrl: string, - expectedPubkey: string, - expectedAvatarUrl: string | null, - ): void => { - const syncKey = `${expectedPubkey}:${avatarUrl}`; + const saveWhenReady = (input: PendingAvatarSave): void => { + const syncKey = `${input.relayUrl}:${input.expectedPubkey}:${input.avatarUrl}`; if (pendingSyncs.has(syncKey)) return; - const queuedGeneration = generation; let isSaving = false; + const queuedGeneration = generation; const stop = () => { pendingSyncs.get(syncKey)?.(); pendingSyncs.delete(syncKey); }; const saveIfReady = () => { - const presentation = dependencies.getPresentation(avatarUrl); + if (generation !== queuedGeneration) return; + const presentation = dependencies.getPresentation(input.avatarUrl); if (!presentation) { stop(); return; @@ -53,17 +56,10 @@ export function createAvatarProfileSync( isSaving = true; void dependencies - .getProfile() + .saveProfile(input) .then((profile) => { - if ( - generation !== queuedGeneration || - profile.pubkey !== expectedPubkey || - normalizedAvatarUrl(profile.avatarUrl) !== - normalizedAvatarUrl(expectedAvatarUrl) - ) { - return; - } - return dependencies.updateProfile({ avatarUrl }); + if (generation !== queuedGeneration) return; + return dependencies.refreshCaches(profile, input); }) .catch(() => undefined) .finally(stop); @@ -76,19 +72,26 @@ export function createAvatarProfileSync( return { reset, saveWhenReady }; } +let queryClient: QueryClient | null = null; + +export function setAvatarProfileSyncQueryClient( + client: QueryClient | null, +): void { + queryClient = client; +} + const avatarProfileSync = createAvatarProfileSync({ getPresentation: getAvatarPresentation, - getProfile, subscribe: subscribeAvatarPresentations, - updateProfile, + saveProfile: updateProfileAtRelay, + refreshCaches: async (profile, input) => { + if (!queryClient) return; + await refreshProfileCaches(queryClient, profile, input.relayUrl); + }, }); -export function saveAvatarWhenReady( - avatarUrl: string, - expectedPubkey: string, - expectedAvatarUrl: string | null, -): void { - avatarProfileSync.saveWhenReady(avatarUrl, expectedPubkey, expectedAvatarUrl); +export function saveAvatarWhenReady(input: PendingAvatarSave): void { + avatarProfileSync.saveWhenReady(input); } export function resetAvatarProfileSync(): void { diff --git a/desktop/src/features/profile/profileCacheSync.test.mjs b/desktop/src/features/profile/profileCacheSync.test.mjs new file mode 100644 index 0000000000..6c423abad2 --- /dev/null +++ b/desktop/src/features/profile/profileCacheSync.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { QueryClient } from "@tanstack/react-query"; + +import { readSelfProfileCache } from "./lib/selfProfileStorage.ts"; +import { refreshProfileCaches } from "./profileCacheSync.ts"; + +function installBrowserStubs() { + const values = new Map(); + globalThis.window = { + dispatchEvent() {}, + localStorage: { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + removeItem: (key) => values.delete(key), + key: (index) => [...values.keys()][index] ?? null, + get length() { + return values.size; + }, + }, + }; + globalThis.CustomEvent = class CustomEvent {}; + globalThis.fetch = async () => ({ ok: false }); +} + +const PUBKEY = "abcdef"; +const RELAY_URL = "wss://relay.example"; +const PROFILE = { + pubkey: PUBKEY, + displayName: "Alice", + avatarUrl: "https://cdn.example/avatar.png", + about: "About Alice", + nip05Handle: null, + ownerPubkey: null, + hasProfileEvent: true, +}; + +test("successful deferred save synchronizes every profile cache", async () => { + installBrowserStubs(); + const queryClient = new QueryClient(); + queryClient.setQueryData(["profile"], { ...PROFILE, avatarUrl: null }); + queryClient.setQueryData(["user-profile", PUBKEY], { + ...PROFILE, + avatarUrl: null, + }); + queryClient.setQueryData(["users-batch-entry", PUBKEY], { + summary: { displayName: "Alice", avatarUrl: null }, + fetchedAt: Date.now(), + }); + queryClient.setQueryData(["users-batch", PUBKEY], { + profiles: { + [PUBKEY]: { + displayName: "Alice", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }, + missing: [], + }); + queryClient.setQueryData( + ["user-search", "alice", 8], + [{ pubkey: PUBKEY, displayName: "Alice", avatarUrl: null }], + ); + + await refreshProfileCaches(queryClient, PROFILE, RELAY_URL); + + assert.deepEqual(queryClient.getQueryData(["profile"]), PROFILE); + assert.deepEqual(queryClient.getQueryData(["user-profile", PUBKEY]), PROFILE); + assert.equal( + queryClient.getQueryData(["users-batch", PUBKEY]).profiles[PUBKEY] + .avatarUrl, + PROFILE.avatarUrl, + ); + assert.equal( + queryClient.getQueryData(["users-batch-entry", PUBKEY]), + undefined, + ); + assert.equal( + queryClient.getQueryState(["user-search", "alice", 8]).isInvalidated, + true, + ); + const persisted = readSelfProfileCache(RELAY_URL, PUBKEY); + assert.equal(persisted.displayName, PROFILE.displayName); + assert.equal(persisted.avatarUrl, PROFILE.avatarUrl); + assert.equal(persisted.about, PROFILE.about); + assert.equal(persisted.avatarDataUrl, null); + assert.equal(persisted.hasProfileEvent, true); + assert.ok(persisted.updatedAt > 0); +}); diff --git a/desktop/src/features/profile/profileCacheSync.ts b/desktop/src/features/profile/profileCacheSync.ts new file mode 100644 index 0000000000..656096c973 --- /dev/null +++ b/desktop/src/features/profile/profileCacheSync.ts @@ -0,0 +1,91 @@ +import type { Query, QueryClient } from "@tanstack/react-query"; + +import { + evictUsersBatchEntries, + profileQueryKey, +} from "@/features/profile/hooks"; +import { + fetchAvatarDataUrl, + readSelfProfileCache, + resolveAvatarDataUrl, + writeSelfProfileCache, +} from "@/features/profile/lib/selfProfileStorage"; +import type { + Profile, + UserProfileSummary, + UsersBatchResponse, +} from "@/shared/api/types"; +import { getAvatarSnapshotUrl } from "@/shared/lib/animatedAvatar"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; + +function queryContainsPubkey(query: Query, pubkey: string): boolean { + return query.queryKey.includes(pubkey); +} + +export async function refreshProfileCaches( + queryClient: QueryClient, + profile: Profile, + relayUrl: string, +): Promise { + const pubkey = profile.pubkey.toLowerCase(); + await queryClient.cancelQueries({ + predicate: (query) => + query.queryKey[0] === profileQueryKey[0] || + (query.queryKey[0] === "user-profile" && + queryContainsPubkey(query, pubkey)) || + (query.queryKey[0] === "users-batch" && + queryContainsPubkey(query, pubkey)), + }); + + queryClient.setQueryData(profileQueryKey, profile); + queryClient.setQueryData(["user-profile", pubkey], profile); + evictUsersBatchEntries(queryClient, [pubkey]); + queryClient.setQueriesData( + { + predicate: (query) => + query.queryKey[0] === "users-batch" && + queryContainsPubkey(query, pubkey), + }, + (current) => { + if (!current?.profiles[pubkey]) return current; + return { + ...current, + profiles: { + ...current.profiles, + [pubkey]: { + ...current.profiles[pubkey], + avatarUrl: profile.avatarUrl, + } satisfies UserProfileSummary, + }, + }; + }, + ); + // Search result pages also embed profile avatars, but their arbitrary query + // text/page shape makes a safe targeted rewrite brittle. Mark every search + // view stale; active searches refetch immediately and inactive ones refresh + // when next opened. + await queryClient.invalidateQueries({ queryKey: ["user-search"] }); + + const existing = readSelfProfileCache(relayUrl, profile.pubkey); + const baseCache = { + version: 1 as const, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + about: profile.about, + avatarDataUrl: resolveAvatarDataUrl(profile.avatarUrl, null, existing), + updatedAt: Date.now(), + ...(profile.hasProfileEvent && { hasProfileEvent: true as const }), + }; + // Persist the canonical profile before attempting the optional image snapshot, + // so quitting during that fetch cannot leave the durable fallback stale. + writeSelfProfileCache(relayUrl, profile.pubkey, baseCache); + + const snapshotUrl = getAvatarSnapshotUrl(profile.avatarUrl); + if (!snapshotUrl) return; + const fetched = await fetchAvatarDataUrl(rewriteRelayUrl(snapshotUrl)); + if (!fetched) return; + writeSelfProfileCache(relayUrl, profile.pubkey, { + ...baseCache, + avatarDataUrl: fetched, + }); +} diff --git a/desktop/src/shared/api/tauriProfiles.ts b/desktop/src/shared/api/tauriProfiles.ts index aab1fd7a1e..c8e52f5169 100644 --- a/desktop/src/shared/api/tauriProfiles.ts +++ b/desktop/src/shared/api/tauriProfiles.ts @@ -83,6 +83,19 @@ export async function updateProfile( return fromRawProfile(profile); } +export async function updateProfileAtRelay(input: { + relayUrl: string; + expectedPubkey: string; + expectedAvatarUrl: string | null; + avatarUrl: string; +}): Promise { + const profile = await invokeTauri( + "update_profile_at_relay", + input, + ); + return fromRawProfile(profile); +} + export async function getUserProfile(pubkey?: string): Promise { const profile = await invokeTauri("get_user_profile", { pubkey }); return fromRawProfile(profile); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1ea8998313..6b23e7961d 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9197,6 +9197,13 @@ export function maybeInstallE2eTauriMocks() { payload as Parameters[0], activeConfig, ); + case "update_profile_at_relay": + return handleUpdateProfile( + { + avatarUrl: (payload as { avatarUrl: string }).avatarUrl, + }, + activeConfig, + ); case "get_user_profile": return handleGetUserProfile( (payload as Parameters[0]) ?? {}, diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 349975fe05..33e441c0fc 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -1717,7 +1717,11 @@ test("name-only community profile save preserves an existing avatar", async ({ .poll(() => page.evaluate(() => (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) - .filter(({ command }) => command === "update_profile") + .filter( + ({ command }) => + command === "update_profile" || + command === "update_profile_at_relay", + ) .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) @@ -1832,7 +1836,11 @@ test("pending avatar stays navigable, clears failures, and retries", async ({ .poll(() => page.evaluate(() => (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) - .filter(({ command }) => command === "update_profile") + .filter( + ({ command }) => + command === "update_profile" || + command === "update_profile_at_relay", + ) .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) @@ -1844,7 +1852,11 @@ test("pending avatar stays navigable, clears failures, and retries", async ({ .poll(() => page.evaluate(() => (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) - .filter(({ command }) => command === "update_profile") + .filter( + ({ command }) => + command === "update_profile" || + command === "update_profile_at_relay", + ) .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) @@ -1904,7 +1916,11 @@ test("a pending avatar never becomes durable if propagation fails after onboardi .poll(() => page.evaluate(() => (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) - .filter(({ command }) => command === "update_profile") + .filter( + ({ command }) => + command === "update_profile" || + command === "update_profile_at_relay", + ) .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) @@ -1967,7 +1983,11 @@ test("a pending avatar becomes durable after onboarding unmounts once ready", as .poll(() => page.evaluate(() => (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) - .filter(({ command }) => command === "update_profile") + .filter( + ({ command }) => + command === "update_profile" || + command === "update_profile_at_relay", + ) .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) @@ -1978,7 +1998,11 @@ test("a pending avatar becomes durable after onboarding unmounts once ready", as .poll(() => page.evaluate(() => (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) - .filter(({ command }) => command === "update_profile") + .filter( + ({ command }) => + command === "update_profile" || + command === "update_profile_at_relay", + ) .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) @@ -2031,7 +2055,11 @@ test("a failed pending replacement leaves the confirmed avatar untouched", async .poll(() => page.evaluate(() => (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) - .filter(({ command }) => command === "update_profile") + .filter( + ({ command }) => + command === "update_profile" || + command === "update_profile_at_relay", + ) .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) From 5bd8d6596cac161bce21950229df1c237e6b0a34 Mon Sep 17 00:00:00 2001 From: npub1dpf98sl35hm9k65t8h6cvh5n6knn2msugh5k5nmysxwnw5wlh7uqn2wgp4 <685253c3f1a5f65b6a8b3df5865e93d5a7356e1c45e96a4f64819d3751dfbfb8@sprout-oss.stage.blox.sqprod.co> Date: Wed, 22 Jul 2026 15:39:23 -0700 Subject: [PATCH 06/14] capture signer for deferred avatar saves Signed-off-by: npub1dpf98sl35hm9k65t8h6cvh5n6knn2msugh5k5nmysxwnw5wlh7uqn2wgp4 <685253c3f1a5f65b6a8b3df5865e93d5a7356e1c45e96a4f64819d3751dfbfb8@sprout-oss.stage.blox.sqprod.co> --- desktop/src-tauri/src/commands/profile.rs | 40 +++++++++++++++++++---- desktop/src-tauri/src/relay.rs | 2 +- desktop/src-tauri/src/relay/submit.rs | 32 +++++++++--------- 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index 1a3102c9bf..1969d8a470 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -9,7 +9,9 @@ use crate::{ events, models::{ProfileInfo, SearchUsersResponse, UserNotesResponse, UsersBatchResponse}, nostr_convert, - relay::{query_relay, query_relay_at, relay_http_base_url, submit_event, submit_event_at}, + relay::{ + query_relay, query_relay_at, relay_http_base_url, submit_event, submit_event_at_with_keys, + }, }; #[tauri::command] @@ -102,10 +104,7 @@ pub async fn update_profile_at_relay( avatar_url: String, state: State<'_, AppState>, ) -> Result { - let current_pubkey = current_pubkey_hex(&state)?; - if current_pubkey != expected_pubkey { - return Err("profile identity changed before avatar save".to_string()); - } + let signer = capture_expected_signer(&state, &expected_pubkey)?; let api_base_url = relay_http_base_url(&relay_url); let filter = serde_json::json!({ @@ -133,7 +132,7 @@ pub async fn update_profile_at_relay( let about = current.get("about").and_then(Value::as_str); let nip05 = current.get("nip05").and_then(Value::as_str); let builder = events::build_profile(display_name, name, Some(&avatar_url), about, nip05)?; - submit_event_at(builder, &state, &api_base_url).await?; + submit_event_at_with_keys(builder, &state, &api_base_url, &signer).await?; let events = query_relay_at(&state, &api_base_url, &[filter]).await?; Ok(events @@ -143,6 +142,14 @@ pub async fn update_profile_at_relay( .unwrap_or_else(|| empty_profile_info(&expected_pubkey))) } +fn capture_expected_signer(state: &AppState, expected_pubkey: &str) -> Result { + let signer = state.signing_keys()?; + if signer.public_key().to_hex() != expected_pubkey { + return Err("profile identity changed before avatar save".to_string()); + } + Ok(signer) +} + fn normalized_avatar_url(avatar_url: Option<&str>) -> Option<&str> { avatar_url.map(str::trim).filter(|value| !value.is_empty()) } @@ -388,6 +395,27 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo { mod tests { use super::*; + #[test] + fn deferred_profile_signer_is_captured_and_rejects_wrong_identity() { + let state = crate::app_state::build_app_state(); + let original = state.signing_keys().expect("signable identity"); + let original_pubkey = original.public_key().to_hex(); + + let captured = capture_expected_signer(&state, &original_pubkey) + .expect("matching identity should be captured"); + *state.keys.lock().expect("lock keys") = nostr::Keys::generate(); + + assert_eq!(captured.public_key().to_hex(), original_pubkey); + assert_ne!( + state.keys.lock().expect("lock keys").public_key().to_hex(), + original_pubkey + ); + assert_eq!( + capture_expected_signer(&state, &original_pubkey).unwrap_err(), + "profile identity changed before avatar save" + ); + } + #[test] fn user_search_filter_requests_prefix_mode_for_typeahead() { // Every caller of `search_users` is a typeahead surface. Whole-word diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 85c04c2e9c..a6040bb074 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -503,7 +503,7 @@ pub struct AgentProfileInfo { // ── Signed-event submission ───────────────────────────────────────────────── mod submit; -pub use submit::{submit_event, submit_event_at, SubmitEventResponse}; +pub use submit::{submit_event, submit_event_at_with_keys, SubmitEventResponse}; /// POST an already-signed event to `/events` with NIP-98 auth. /// diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index c90ca1113d..7fb3f94041 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -8,26 +8,24 @@ pub struct SubmitEventResponse { pub message: String, } -/// Build an `EventBuilder` from the events module, sign it with the user's keys, -/// and POST the signed event to `/events` with NIP-98 auth. -pub async fn submit_event_at( +/// Sign with an explicit identity and POST the event to an explicit relay. +/// +/// The caller owns the signer lifetime. This is important for deferred work: +/// an in-process identity swap cannot retarget the event or its NIP-98 auth +/// after the caller has validated which identity the operation belongs to. +pub async fn submit_event_at_with_keys( builder: nostr::EventBuilder, state: &AppState, api_base_url: &str, + keys: &nostr::Keys, ) -> Result { crate::relay_admission::wait_for_rate_limit().await; - // All synchronous work (signing) must complete before any .await - // so the MutexGuard is dropped and the future remains Send. let url = format!("{}/events", api_base_url.trim_end_matches('/')); - let (auth_header, body_bytes) = { - let keys = state.signing_keys()?; - let event = builder - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign event: {e}"))?; - let body = event.as_json().into_bytes(); - let auth = build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body)?; - (auth, body) - }; // keys dropped here + let event = builder + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign event: {e}"))?; + let body_bytes = event.as_json().into_bytes(); + let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; let response = state .http_client @@ -44,7 +42,6 @@ pub async fn submit_event_at( } let result: SubmitEventResponse = parse_json_response(response).await?; - if !result.accepted { return Err(format!("relay rejected event: {}", result.message)); } @@ -52,11 +49,12 @@ pub async fn submit_event_at( Ok(result) } -/// Build, sign, and submit an event to the currently active workspace relay. +/// Build and submit an event to the currently active workspace relay. pub async fn submit_event( builder: nostr::EventBuilder, state: &AppState, ) -> Result { let api_base_url = relay_api_base_url_with_override(state); - submit_event_at(builder, state, &api_base_url).await + let keys = state.signing_keys()?; + submit_event_at_with_keys(builder, state, &api_base_url, &keys).await } From b60f7b5906c14efe70c98f4ca5ca479500660add Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Wed, 22 Jul 2026 16:00:05 -0700 Subject: [PATCH 07/14] Fix desktop profile clippy failure Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/commands/profile.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index 1969d8a470..57b447f4be 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -112,7 +112,7 @@ pub async fn update_profile_at_relay( "authors": [expected_pubkey], "limit": 1 }); - let prior_events = query_relay_at(&state, &api_base_url, &[filter.clone()]).await?; + let prior_events = query_relay_at(&state, &api_base_url, std::slice::from_ref(&filter)).await?; let current: Value = prior_events .first() .and_then(|event| serde_json::from_str::(&event.content).ok()) From a0538b0e0eed27d2d56d62636dd236b04c309c33 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Wed, 22 Jul 2026 16:39:33 -0700 Subject: [PATCH 08/14] Guard deferred avatar cache refresh by identity Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../profile/avatarProfileSync.test.mjs | 28 ++++++++++++++++++- .../src/features/profile/avatarProfileSync.ts | 18 +++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/profile/avatarProfileSync.test.mjs b/desktop/src/features/profile/avatarProfileSync.test.mjs index ea31e61fde..03347076aa 100644 --- a/desktop/src/features/profile/avatarProfileSync.test.mjs +++ b/desktop/src/features/profile/avatarProfileSync.test.mjs @@ -10,7 +10,11 @@ const INPUT = { expectedAvatarUrl: null, }; -function createHarness({ initialState = "pending", saveProfile } = {}) { +function createHarness({ + initialState = "pending", + saveProfile, + getActivePubkey = async () => INPUT.expectedPubkey, +} = {}) { let presentation = { displayUrl: INPUT.avatarUrl, state: initialState }; let listener = () => {}; let unsubscribeCount = 0; @@ -30,6 +34,7 @@ function createHarness({ initialState = "pending", saveProfile } = {}) { saves.push(input); return { avatarUrl: input.avatarUrl, pubkey: input.expectedPubkey }; }), + getActivePubkey, refreshCaches: async (profile, input) => { refreshed.push({ profile, input }); }, @@ -96,6 +101,27 @@ test("a reset sync accepts deferred work from the next community", async () => { assert.equal(harness.refreshed.length, 1); }); +test("skips cache refresh when the active identity changes during save", async () => { + let resolveSave; + const savePromise = new Promise((resolve) => { + resolveSave = resolve; + }); + let activePubkey = INPUT.expectedPubkey; + const harness = createHarness({ + initialState: "ready", + saveProfile: () => savePromise, + getActivePubkey: async () => activePubkey, + }); + harness.sync.saveWhenReady(INPUT); + + activePubkey = "replacement-pubkey"; + resolveSave({ avatarUrl: INPUT.avatarUrl, pubkey: INPUT.expectedPubkey }); + await flushPromises(); + + assert.deepEqual(harness.refreshed, []); + assert.equal(harness.unsubscribeCount, 1); +}); + test("cache refresh follows only a successful save", async () => { let rejectSave; const savePromise = new Promise((_, reject) => { diff --git a/desktop/src/features/profile/avatarProfileSync.ts b/desktop/src/features/profile/avatarProfileSync.ts index 334c043927..572c993187 100644 --- a/desktop/src/features/profile/avatarProfileSync.ts +++ b/desktop/src/features/profile/avatarProfileSync.ts @@ -6,6 +6,7 @@ import { type AvatarPresentation, } from "@/features/profile/avatarPresentationStore"; import { refreshProfileCaches } from "@/features/profile/profileCacheSync"; +import { getIdentity } from "@/shared/api/tauriIdentity"; import { updateProfileAtRelay } from "@/shared/api/tauriProfiles"; import type { Profile } from "@/shared/api/types"; @@ -20,6 +21,7 @@ type AvatarProfileSyncDependencies = { getPresentation: (avatarUrl: string) => AvatarPresentation | null; subscribe: (listener: () => void) => () => void; saveProfile: (input: PendingAvatarSave) => Promise; + getActivePubkey: () => Promise; refreshCaches: (profile: Profile, input: PendingAvatarSave) => Promise; }; @@ -57,8 +59,15 @@ export function createAvatarProfileSync( isSaving = true; void dependencies .saveProfile(input) - .then((profile) => { + .then(async (profile) => { if (generation !== queuedGeneration) return; + const activePubkey = await dependencies.getActivePubkey(); + if ( + generation !== queuedGeneration || + activePubkey?.toLowerCase() !== input.expectedPubkey.toLowerCase() + ) { + return; + } return dependencies.refreshCaches(profile, input); }) .catch(() => undefined) @@ -84,6 +93,13 @@ const avatarProfileSync = createAvatarProfileSync({ getPresentation: getAvatarPresentation, subscribe: subscribeAvatarPresentations, saveProfile: updateProfileAtRelay, + getActivePubkey: async () => { + try { + return (await getIdentity()).pubkey; + } catch { + return null; + } + }, refreshCaches: async (profile, input) => { if (!queryClient) return; await refreshProfileCaches(queryClient, profile, input.relayUrl); From 3beb56a8157b1ef00de4d40b42adedf1e6eb3705 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Wed, 22 Jul 2026 16:57:52 -0700 Subject: [PATCH 09/14] Ensure deferred avatar profile events are newer Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/commands/profile.rs | 58 ++++++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index 57b447f4be..63771e7a59 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -7,6 +7,7 @@ use tauri::State; use crate::{ app_state::AppState, events, + managed_agents::persona_events::monotonic_created_at, models::{ProfileInfo, SearchUsersResponse, UserNotesResponse, UsersBatchResponse}, nostr_convert, relay::{ @@ -113,8 +114,8 @@ pub async fn update_profile_at_relay( "limit": 1 }); let prior_events = query_relay_at(&state, &api_base_url, std::slice::from_ref(&filter)).await?; - let current: Value = prior_events - .first() + let prior_event = prior_events.first(); + let current: Value = prior_event .and_then(|event| serde_json::from_str::(&event.content).ok()) .unwrap_or(Value::Null); let current_avatar_url = current @@ -127,11 +128,7 @@ pub async fn update_profile_at_relay( return Err("profile avatar changed before deferred save".to_string()); } - let display_name = current.get("display_name").and_then(Value::as_str); - let name = current.get("name").and_then(Value::as_str); - let about = current.get("about").and_then(Value::as_str); - let nip05 = current.get("nip05").and_then(Value::as_str); - let builder = events::build_profile(display_name, name, Some(&avatar_url), about, nip05)?; + let builder = build_deferred_profile_event(¤t, &avatar_url, prior_event)?; submit_event_at_with_keys(builder, &state, &api_base_url, &signer).await?; let events = query_relay_at(&state, &api_base_url, &[filter]).await?; @@ -142,6 +139,24 @@ pub async fn update_profile_at_relay( .unwrap_or_else(|| empty_profile_info(&expected_pubkey))) } +fn build_deferred_profile_event( + current: &Value, + avatar_url: &str, + prior_event: Option<&nostr::Event>, +) -> Result { + let display_name = current.get("display_name").and_then(Value::as_str); + let name = current.get("name").and_then(Value::as_str); + let about = current.get("about").and_then(Value::as_str); + let nip05 = current.get("nip05").and_then(Value::as_str); + + Ok( + events::build_profile(display_name, name, Some(avatar_url), about, nip05)? + .custom_created_at(monotonic_created_at( + prior_event.map(|event| event.created_at.as_secs() as i64), + )), + ) +} + fn capture_expected_signer(state: &AppState, expected_pubkey: &str) -> Result { let signer = state.signing_keys()?; if signer.public_key().to_hex() != expected_pubkey { @@ -416,6 +431,35 @@ mod tests { ); } + #[test] + fn deferred_profile_event_is_strictly_newer_than_prior_head() { + let keys = nostr::Keys::generate(); + let prior_created_at = nostr::Timestamp::now().as_secs() + 60; + let prior_event = nostr::EventBuilder::new( + nostr::Kind::Metadata, + serde_json::json!({"display_name": "Larry"}).to_string(), + ) + .custom_created_at(nostr::Timestamp::from(prior_created_at)) + .sign_with_keys(&keys) + .expect("sign prior profile"); + + let builder = build_deferred_profile_event( + &serde_json::json!({"display_name": "Larry"}), + "https://example.com/avatar.png", + Some(&prior_event), + ) + .expect("build deferred profile"); + let event = builder + .sign_with_keys(&keys) + .expect("sign deferred profile"); + + assert_eq!(event.created_at.as_secs(), prior_created_at + 1); + assert_eq!( + serde_json::from_str::(&event.content).unwrap()["picture"], + "https://example.com/avatar.png" + ); + } + #[test] fn user_search_filter_requests_prefix_mode_for_typeahead() { // Every caller of `search_users` is a typeahead surface. Whole-word From e9512825a729722dc98d33c3cbfe687c1ea6132b Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Wed, 22 Jul 2026 17:17:28 -0700 Subject: [PATCH 10/14] Use captured signer for deferred profile reads Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/commands/profile.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index 63771e7a59..ef67fac570 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -11,7 +11,8 @@ use crate::{ models::{ProfileInfo, SearchUsersResponse, UserNotesResponse, UsersBatchResponse}, nostr_convert, relay::{ - query_relay, query_relay_at, relay_http_base_url, submit_event, submit_event_at_with_keys, + query_relay, query_relay_at_with_keys, relay_http_base_url, submit_event, + submit_event_at_with_keys, }, }; @@ -113,7 +114,14 @@ pub async fn update_profile_at_relay( "authors": [expected_pubkey], "limit": 1 }); - let prior_events = query_relay_at(&state, &api_base_url, std::slice::from_ref(&filter)).await?; + let prior_events = query_relay_at_with_keys( + &state, + &api_base_url, + std::slice::from_ref(&filter), + &signer, + None, + ) + .await?; let prior_event = prior_events.first(); let current: Value = prior_event .and_then(|event| serde_json::from_str::(&event.content).ok()) @@ -131,7 +139,7 @@ pub async fn update_profile_at_relay( let builder = build_deferred_profile_event(¤t, &avatar_url, prior_event)?; submit_event_at_with_keys(builder, &state, &api_base_url, &signer).await?; - let events = query_relay_at(&state, &api_base_url, &[filter]).await?; + let events = query_relay_at_with_keys(&state, &api_base_url, &[filter], &signer, None).await?; Ok(events .first() .map(nostr_convert::profile_info_from_event) From 141e7f1102dfd4c35e151512f984dae98163011c Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Wed, 22 Jul 2026 17:28:45 -0700 Subject: [PATCH 11/14] Gate keyed relay queries during backoff Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/relay.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 30fed1e762..1c9ba0095a 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -347,6 +347,7 @@ pub async fn query_relay_at_with_keys( keys: &Keys, auth_tag: Option<&str>, ) -> Result, String> { + crate::relay_admission::wait_for_rate_limit().await; let url = format!("{}/query", api_base_url); let body_bytes = serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; From e02e360daca822a50fa15ca814b2bb7fdcd6726c Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Wed, 22 Jul 2026 17:40:59 -0700 Subject: [PATCH 12/14] Retry transient deferred avatar saves Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../profile/avatarProfileSync.test.mjs | 64 +++++++++++++++++++ .../src/features/profile/avatarProfileSync.ts | 54 ++++++++++++++-- 2 files changed, 112 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/profile/avatarProfileSync.test.mjs b/desktop/src/features/profile/avatarProfileSync.test.mjs index 03347076aa..9a71673627 100644 --- a/desktop/src/features/profile/avatarProfileSync.test.mjs +++ b/desktop/src/features/profile/avatarProfileSync.test.mjs @@ -14,6 +14,7 @@ function createHarness({ initialState = "pending", saveProfile, getActivePubkey = async () => INPUT.expectedPubkey, + scheduleRetry, } = {}) { let presentation = { displayUrl: INPUT.avatarUrl, state: initialState }; let listener = () => {}; @@ -38,6 +39,7 @@ function createHarness({ refreshCaches: async (profile, input) => { refreshed.push({ profile, input }); }, + scheduleRetry, }); return { @@ -122,6 +124,68 @@ test("skips cache refresh when the active identity changes during save", async ( assert.equal(harness.unsubscribeCount, 1); }); +test("retries a transient save and keeps the sync pending until success", async () => { + const scheduled = []; + let attempt = 0; + const harness = createHarness({ + initialState: "ready", + saveProfile: async (input) => { + attempt += 1; + if (attempt === 1) throw new Error("relay unreachable: network error"); + return { avatarUrl: input.avatarUrl, pubkey: input.expectedPubkey }; + }, + scheduleRetry: (callback, delayMs) => { + const retry = { callback, delayMs, cancelled: false }; + scheduled.push(retry); + return () => { + retry.cancelled = true; + }; + }, + }); + + harness.sync.saveWhenReady(INPUT); + await flushPromises(); + + assert.equal(attempt, 1); + assert.equal(harness.unsubscribeCount, 0); + assert.equal(scheduled[0].delayMs, 5_000); + + scheduled[0].callback(); + await flushPromises(); + + assert.equal(attempt, 2); + assert.equal(harness.refreshed.length, 1); + assert.equal(harness.unsubscribeCount, 1); +}); + +test("reset cancels a scheduled transient retry", async () => { + let scheduled; + let attempts = 0; + const harness = createHarness({ + initialState: "ready", + saveProfile: async () => { + attempts += 1; + throw new Error("relay rate-limited: retry in 5s"); + }, + scheduleRetry: (callback, delayMs) => { + scheduled = { callback, delayMs, cancelled: false }; + return () => { + scheduled.cancelled = true; + }; + }, + }); + + harness.sync.saveWhenReady(INPUT); + await flushPromises(); + harness.sync.reset(); + + assert.equal(scheduled.delayMs, 5_000); + assert.equal(scheduled.cancelled, true); + scheduled.callback(); + await flushPromises(); + assert.equal(attempts, 1); +}); + test("cache refresh follows only a successful save", async () => { let rejectSave; const savePromise = new Promise((_, reject) => { diff --git a/desktop/src/features/profile/avatarProfileSync.ts b/desktop/src/features/profile/avatarProfileSync.ts index 572c993187..720335f4b5 100644 --- a/desktop/src/features/profile/avatarProfileSync.ts +++ b/desktop/src/features/profile/avatarProfileSync.ts @@ -9,6 +9,9 @@ import { refreshProfileCaches } from "@/features/profile/profileCacheSync"; import { getIdentity } from "@/shared/api/tauriIdentity"; import { updateProfileAtRelay } from "@/shared/api/tauriProfiles"; import type { Profile } from "@/shared/api/types"; +import { isRelayUnreachableError } from "@/shared/lib/relayError"; + +const AVATAR_SAVE_RETRY_DELAYS_MS = [5_000, 30_000, 120_000] as const; type PendingAvatarSave = { avatarUrl: string; @@ -23,8 +26,16 @@ type AvatarProfileSyncDependencies = { saveProfile: (input: PendingAvatarSave) => Promise; getActivePubkey: () => Promise; refreshCaches: (profile: Profile, input: PendingAvatarSave) => Promise; + scheduleRetry?: (callback: () => void, delayMs: number) => () => void; }; +function isRetryableAvatarSaveError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + isRelayUnreachableError(error) || message.startsWith("relay rate-limited:") + ); +} + export function createAvatarProfileSync( dependencies: AvatarProfileSyncDependencies, ) { @@ -33,7 +44,7 @@ export function createAvatarProfileSync( const reset = () => { generation += 1; - for (const unsubscribe of pendingSyncs.values()) unsubscribe(); + for (const stop of pendingSyncs.values()) stop(); pendingSyncs.clear(); }; @@ -42,9 +53,14 @@ export function createAvatarProfileSync( if (pendingSyncs.has(syncKey)) return; let isSaving = false; + let retryAttempt = 0; + let cancelRetry: (() => void) | null = null; + let unsubscribe = () => {}; const queuedGeneration = generation; const stop = () => { - pendingSyncs.get(syncKey)?.(); + cancelRetry?.(); + cancelRetry = null; + unsubscribe(); pendingSyncs.delete(syncKey); }; const saveIfReady = () => { @@ -68,13 +84,39 @@ export function createAvatarProfileSync( ) { return; } - return dependencies.refreshCaches(profile, input); + await dependencies.refreshCaches(profile, input); }) - .catch(() => undefined) - .finally(stop); + .then(stop) + .catch((error: unknown) => { + if ( + generation !== queuedGeneration || + !isRetryableAvatarSaveError(error) + ) { + stop(); + return; + } + const delayMs = AVATAR_SAVE_RETRY_DELAYS_MS[retryAttempt]; + if (delayMs === undefined) { + stop(); + return; + } + retryAttempt += 1; + isSaving = false; + const scheduleRetry = + dependencies.scheduleRetry ?? + ((callback, delay) => { + const timeout = window.setTimeout(callback, delay); + return () => window.clearTimeout(timeout); + }); + cancelRetry = scheduleRetry(() => { + cancelRetry = null; + saveIfReady(); + }, delayMs); + }); }; - pendingSyncs.set(syncKey, dependencies.subscribe(saveIfReady)); + unsubscribe = dependencies.subscribe(saveIfReady); + pendingSyncs.set(syncKey, stop); saveIfReady(); }; From 32f2a4e17d8cea94071f656adf22ecb5cc0155d3 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Wed, 22 Jul 2026 18:15:09 -0700 Subject: [PATCH 13/14] Preserve deferred avatar readiness Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../onboarding/ui/CommunityOnboardingFlow.tsx | 31 +++++---- .../profile/avatarProfileSync.test.mjs | 54 +++++++++++++++- .../src/features/profile/avatarProfileSync.ts | 64 +++++++++++++++++-- 3 files changed, 130 insertions(+), 19 deletions(-) diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 77ae3aa397..4b729ab33f 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -14,7 +14,7 @@ import { WELCOME_SURFACE_READY_EVENT, } from "@/features/onboarding/welcome"; import { useAvatarPresentation } from "@/features/profile/avatarPresentationStore"; -import { saveAvatarWhenReady } from "@/features/profile/avatarProfileSync"; +import { registerAvatarWhenReady } from "@/features/profile/avatarProfileSync"; import { profileQueryKey } from "@/features/profile/hooks"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { @@ -389,21 +389,26 @@ export function CommunityOnboardingFlow({ presentationState !== "failed" && presentationState !== "pending"; - const profile = await updateProfile({ - displayName: displayName.trim(), - avatarUrl: shouldSaveCandidate ? candidateAvatarUrl : undefined, - }); - if ( - candidateAvatarUrl && - presentationState && - presentationState !== "ready" - ) { - saveAvatarWhenReady({ - avatarUrl: candidateAvatarUrl, - relayUrl: transaction.relayUrl, + const deferredAvatar = + candidateAvatarUrl && presentationState && presentationState !== "ready" + ? registerAvatarWhenReady({ + avatarUrl: candidateAvatarUrl, + relayUrl: transaction.relayUrl, + }) + : null; + + try { + const profile = await updateProfile({ + displayName: displayName.trim(), + avatarUrl: shouldSaveCandidate ? candidateAvatarUrl : undefined, + }); + deferredAvatar?.release({ expectedPubkey: profile.pubkey, expectedAvatarUrl: profile.avatarUrl, }); + } catch (error) { + deferredAvatar?.cancel(); + throw error; } update({ stage: "team-intro", error: undefined }); } catch (error) { diff --git a/desktop/src/features/profile/avatarProfileSync.test.mjs b/desktop/src/features/profile/avatarProfileSync.test.mjs index 9a71673627..585dc99e44 100644 --- a/desktop/src/features/profile/avatarProfileSync.test.mjs +++ b/desktop/src/features/profile/avatarProfileSync.test.mjs @@ -49,8 +49,14 @@ function createHarness({ listener: () => listener(), refreshed, saves, + removePresentation: () => { + presentation = null; + }, setState: (state) => { - presentation = { ...presentation, state }; + presentation = { + ...(presentation ?? { displayUrl: INPUT.avatarUrl }), + state, + }; }, sync, }; @@ -150,6 +156,10 @@ test("retries a transient save and keeps the sync pending until success", async assert.equal(harness.unsubscribeCount, 0); assert.equal(scheduled[0].delayMs, 5_000); + harness.listener(); + await flushPromises(); + assert.equal(attempt, 1, "store updates must not bypass retry backoff"); + scheduled[0].callback(); await flushPromises(); @@ -186,6 +196,48 @@ test("reset cancels a scheduled transient retry", async () => { assert.equal(attempts, 1); }); +test("registration preserves readiness until the initial profile write completes", async () => { + const harness = createHarness(); + const registration = harness.sync.registerWhenReady({ + avatarUrl: INPUT.avatarUrl, + relayUrl: INPUT.relayUrl, + }); + + harness.setState("ready"); + harness.listener(); + harness.removePresentation(); + assert.deepEqual(harness.saves, []); + + registration.release({ + expectedPubkey: INPUT.expectedPubkey, + expectedAvatarUrl: null, + }); + await flushPromises(); + + assert.deepEqual(harness.saves, [INPUT]); + assert.equal(harness.refreshed.length, 1); +}); + +test("cancelled registration cannot save after the initial profile write fails", async () => { + const harness = createHarness(); + const registration = harness.sync.registerWhenReady({ + avatarUrl: INPUT.avatarUrl, + relayUrl: INPUT.relayUrl, + }); + + harness.setState("ready"); + harness.listener(); + registration.cancel(); + registration.release({ + expectedPubkey: INPUT.expectedPubkey, + expectedAvatarUrl: null, + }); + await flushPromises(); + + assert.deepEqual(harness.saves, []); + assert.equal(harness.unsubscribeCount, 1); +}); + test("cache refresh follows only a successful save", async () => { let rejectSave; const savePromise = new Promise((_, reject) => { diff --git a/desktop/src/features/profile/avatarProfileSync.ts b/desktop/src/features/profile/avatarProfileSync.ts index 720335f4b5..708027f30d 100644 --- a/desktop/src/features/profile/avatarProfileSync.ts +++ b/desktop/src/features/profile/avatarProfileSync.ts @@ -20,6 +20,15 @@ type PendingAvatarSave = { expectedAvatarUrl: string | null; }; +type DeferredAvatarSave = Pick; + +type AvatarSaveRegistration = { + cancel: () => void; + release: ( + input: Pick, + ) => void; +}; + type AvatarProfileSyncDependencies = { getPresentation: (avatarUrl: string) => AvatarPresentation | null; subscribe: (listener: () => void) => () => void; @@ -48,11 +57,12 @@ export function createAvatarProfileSync( pendingSyncs.clear(); }; - const saveWhenReady = (input: PendingAvatarSave): void => { + const queueSave = (input: PendingAvatarSave, assumeReady = false): void => { const syncKey = `${input.relayUrl}:${input.expectedPubkey}:${input.avatarUrl}`; if (pendingSyncs.has(syncKey)) return; let isSaving = false; + let isReady = assumeReady; let retryAttempt = 0; let cancelRetry: (() => void) | null = null; let unsubscribe = () => {}; @@ -64,13 +74,14 @@ export function createAvatarProfileSync( pendingSyncs.delete(syncKey); }; const saveIfReady = () => { - if (generation !== queuedGeneration) return; + if (generation !== queuedGeneration || cancelRetry !== null) return; const presentation = dependencies.getPresentation(input.avatarUrl); - if (!presentation) { + if (presentation?.state === "ready") isReady = true; + if (!presentation && !isReady) { stop(); return; } - if (presentation.state !== "ready" || isSaving) return; + if (!isReady || isSaving) return; isSaving = true; void dependencies @@ -120,7 +131,44 @@ export function createAvatarProfileSync( saveIfReady(); }; - return { reset, saveWhenReady }; + const registerWhenReady = ( + input: DeferredAvatarSave, + ): AvatarSaveRegistration => { + const registrationKey = `registration:${input.relayUrl}:${input.avatarUrl}`; + if (pendingSyncs.has(registrationKey)) { + return { cancel: () => {}, release: () => {} }; + } + + let observedReady = false; + let active = true; + const queuedGeneration = generation; + const observe = () => { + if (generation !== queuedGeneration) return; + if (dependencies.getPresentation(input.avatarUrl)?.state === "ready") { + observedReady = true; + } + }; + const unsubscribe = dependencies.subscribe(observe); + const cancel = () => { + if (!active) return; + active = false; + unsubscribe(); + pendingSyncs.delete(registrationKey); + }; + pendingSyncs.set(registrationKey, cancel); + observe(); + + return { + cancel, + release: (completion) => { + if (!active || generation !== queuedGeneration) return; + cancel(); + queueSave({ ...input, ...completion }, observedReady); + }, + }; + }; + + return { registerWhenReady, reset, saveWhenReady: queueSave }; } let queryClient: QueryClient | null = null; @@ -148,6 +196,12 @@ const avatarProfileSync = createAvatarProfileSync({ }, }); +export function registerAvatarWhenReady( + input: DeferredAvatarSave, +): AvatarSaveRegistration { + return avatarProfileSync.registerWhenReady(input); +} + export function saveAvatarWhenReady(input: PendingAvatarSave): void { avatarProfileSync.saveWhenReady(input); } From a4417306efb25fbbd4fc50373b48bb8a931a0121 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Wed, 22 Jul 2026 18:29:44 -0700 Subject: [PATCH 14/14] Defer avatar cache refresh until provider mounts Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- desktop/src/app/App.tsx | 5 +- .../profile/avatarProfileSync.test.mjs | 48 +++++++++++++- .../src/features/profile/avatarProfileSync.ts | 65 +++++++++++++++++-- 3 files changed, 106 insertions(+), 12 deletions(-) diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index b134c87059..ed9c465bcc 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -198,10 +198,7 @@ function CommunitySwitchGate() { function CommunityQueryProvider({ children }: { children: ReactNode }) { const [queryClient] = useState(createBuzzQueryClient); - useEffect(() => { - setAvatarProfileSyncQueryClient(queryClient); - return () => setAvatarProfileSyncQueryClient(null); - }, [queryClient]); + useEffect(() => setAvatarProfileSyncQueryClient(queryClient), [queryClient]); useEffect(() => { const e2eWindow = window as Window & { diff --git a/desktop/src/features/profile/avatarProfileSync.test.mjs b/desktop/src/features/profile/avatarProfileSync.test.mjs index 585dc99e44..b814208bc0 100644 --- a/desktop/src/features/profile/avatarProfileSync.test.mjs +++ b/desktop/src/features/profile/avatarProfileSync.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { createAvatarProfileSync } from "./avatarProfileSync.ts"; +import { + createAvatarProfileSync, + createProfileCacheRefreshQueue, +} from "./avatarProfileSync.ts"; const INPUT = { avatarUrl: "https://old-relay.example/avatar.png", @@ -238,6 +241,49 @@ test("cancelled registration cannot save after the initial profile write fails", assert.equal(harness.unsubscribeCount, 1); }); +test("cache refresh waits for a provider and flushes exactly once", async () => { + const refreshed = []; + const queue = createProfileCacheRefreshQueue( + async (client, profile, relayUrl) => { + refreshed.push({ client, profile, relayUrl }); + }, + ); + const profile = { avatarUrl: INPUT.avatarUrl, pubkey: INPUT.expectedPubkey }; + const client = {}; + + await queue.enqueue({ profile, input: INPUT }); + assert.deepEqual(refreshed, []); + + const detach = queue.setClient(client); + await flushPromises(); + assert.deepEqual(refreshed, [{ client, profile, relayUrl: INPUT.relayUrl }]); + + detach(); + const detachAgain = queue.setClient(client); + await flushPromises(); + assert.equal(refreshed.length, 1); + detachAgain(); +}); + +test("cache refresh reset discards work from the previous community", async () => { + const refreshed = []; + const queue = createProfileCacheRefreshQueue( + async (client, profile, relayUrl) => { + refreshed.push({ client, profile, relayUrl }); + }, + ); + + await queue.enqueue({ + profile: { avatarUrl: INPUT.avatarUrl, pubkey: INPUT.expectedPubkey }, + input: INPUT, + }); + queue.reset(); + queue.setClient({}); + await flushPromises(); + + assert.deepEqual(refreshed, []); +}); + test("cache refresh follows only a successful save", async () => { let rejectSave; const savePromise = new Promise((_, reject) => { diff --git a/desktop/src/features/profile/avatarProfileSync.ts b/desktop/src/features/profile/avatarProfileSync.ts index 708027f30d..5bca6d8da9 100644 --- a/desktop/src/features/profile/avatarProfileSync.ts +++ b/desktop/src/features/profile/avatarProfileSync.ts @@ -171,14 +171,59 @@ export function createAvatarProfileSync( return { registerWhenReady, reset, saveWhenReady: queueSave }; } -let queryClient: QueryClient | null = null; +type ProfileCacheRefresh = { + profile: Profile; + input: PendingAvatarSave; +}; -export function setAvatarProfileSyncQueryClient( - client: QueryClient | null, -): void { - queryClient = client; +type ProfileCacheRefreshQueue = { + enqueue: (refresh: ProfileCacheRefresh) => Promise; + reset: () => void; + setClient: (client: QueryClient) => () => void; +}; + +export function createProfileCacheRefreshQueue( + refresh: ( + client: QueryClient, + profile: Profile, + relayUrl: string, + ) => Promise, +): ProfileCacheRefreshQueue { + let client: QueryClient | null = null; + const pending = new Map(); + const refreshKey = ({ profile, input }: ProfileCacheRefresh) => + `${input.relayUrl}:${profile.pubkey.toLowerCase()}`; + + const flush = (nextClient: QueryClient) => { + const queued = [...pending.values()]; + pending.clear(); + for (const item of queued) { + void refresh(nextClient, item.profile, item.input.relayUrl); + } + }; + + return { + enqueue: async (item) => { + if (client) { + await refresh(client, item.profile, item.input.relayUrl); + return; + } + pending.set(refreshKey(item), item); + }, + reset: () => pending.clear(), + setClient: (nextClient) => { + client = nextClient; + flush(nextClient); + return () => { + if (client === nextClient) client = null; + }; + }, + }; } +const profileCacheRefreshQueue = + createProfileCacheRefreshQueue(refreshProfileCaches); + const avatarProfileSync = createAvatarProfileSync({ getPresentation: getAvatarPresentation, subscribe: subscribeAvatarPresentations, @@ -191,11 +236,16 @@ const avatarProfileSync = createAvatarProfileSync({ } }, refreshCaches: async (profile, input) => { - if (!queryClient) return; - await refreshProfileCaches(queryClient, profile, input.relayUrl); + await profileCacheRefreshQueue.enqueue({ profile, input }); }, }); +export function setAvatarProfileSyncQueryClient( + client: QueryClient, +): () => void { + return profileCacheRefreshQueue.setClient(client); +} + export function registerAvatarWhenReady( input: DeferredAvatarSave, ): AvatarSaveRegistration { @@ -207,5 +257,6 @@ export function saveAvatarWhenReady(input: PendingAvatarSave): void { } export function resetAvatarProfileSync(): void { + profileCacheRefreshQueue.reset(); avatarProfileSync.reset(); }