fix: show user avatar on the home page settings button

The bottom-left settings button and the settings page both read
profilePictureUrl, but only the settings page had a fallback (initials
avatar) - the button silently fell back to a gear. The URL itself was
usually null because fetchProfilePicture raced the background OAuth
avatar sync with a fixed 500ms delay and never retried, and a missing
bucket object simply resolved to null.

- useConfigButtonIcon: fall back to the same initials avatar as the
  settings page instead of the gear when no picture URL is available.
- UseSession: fetch the profile picture when syncOAuthAvatar settles
  (init and SIGNED_IN) instead of after an arbitrary 500ms.
- fetchProfilePicture: when the bucket copy is missing, fall back to
  the OAuth provider's own photo URL so the picture shows immediately
  on first login - unless the user explicitly uploaded/removed a
  picture (metadata source 'upload'), preserving the remove flow.
This commit is contained in:
Anthony Stirling
2026-06-10 14:25:19 +01:00
parent d29059e6fb
commit bf18af4708
2 changed files with 56 additions and 25 deletions
+45 -22
View File
@@ -27,6 +27,7 @@ import { synchronizeUserUpgrade } from "@app/services/userService";
import {
syncOAuthAvatar,
getProfilePictureMetadata,
getProviderAvatarUrl,
type ProfilePictureMetadata,
} from "@app/services/avatarSyncService";
@@ -321,6 +322,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await fetchTrialStatus();
}, [fetchTrialStatus]);
// Provider photo as interim fallback when the bucket copy is missing —
// skipped when the user explicitly chose upload/removal (source "upload").
const providerAvatarFallback = useCallback(
async (user: SupabaseUser): Promise<string | null> => {
try {
const metadata = await getProfilePictureMetadata(user.id);
if (metadata?.source === "upload") return null;
return getProviderAvatarUrl(user);
} catch {
return getProviderAvatarUrl(user);
}
},
[],
);
const fetchProfilePicture = useCallback(
async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session;
@@ -351,7 +367,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
"[Auth Debug] Profile picture not available:",
error.message,
);
setProfilePictureUrl(null);
setProfilePictureUrl(
await providerAvatarFallback(currentSession.user),
);
} else {
setProfilePictureUrl(data.signedUrl);
console.debug(
@@ -360,10 +378,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
} catch (error: unknown) {
console.debug("[Auth Debug] Failed to fetch profile picture:", error);
setProfilePictureUrl(null);
setProfilePictureUrl(await providerAvatarFallback(currentSession.user));
}
},
[session],
[session, providerAvatarFallback],
);
const refreshProfilePicture = useCallback(async () => {
@@ -522,23 +540,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Fetch credits, pro status, trial status, profile picture metadata, and profile picture using the session from the response
if (data.session?.user) {
// Sync OAuth avatar in background
syncOAuthAvatar(data.session.user).catch((err) => {
console.debug(
"[Auth Debug] Failed to sync OAuth avatar on init:",
err,
);
});
// Sync OAuth avatar in background; fetch the picture once the
// sync settles instead of guessing with a fixed delay.
syncOAuthAvatar(data.session.user)
.catch((err) => {
console.debug(
"[Auth Debug] Failed to sync OAuth avatar on init:",
err,
);
return false;
})
.then(() => fetchProfilePicture(data.session));
await fetchCredits(data.session);
await fetchProStatus(data.session);
await fetchTrialStatus(data.session);
await fetchProfilePictureMetadata(data.session);
// Small delay to allow avatar sync to complete if quick
setTimeout(() => {
fetchProfilePicture(data.session);
}, 500);
}
}
} catch (err) {
@@ -602,9 +619,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// null/loading states.
// Sync OAuth avatar in background (don't block other fetches)
syncOAuthAvatar(newSession.user).catch((err) => {
console.debug("[Auth Debug] Failed to sync OAuth avatar:", err);
});
const avatarSync = syncOAuthAvatar(newSession.user).catch(
(err) => {
console.debug(
"[Auth Debug] Failed to sync OAuth avatar:",
err,
);
return false;
},
);
// Fetch user data in parallel
Promise.all([
@@ -613,15 +636,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
fetchTrialStatus(newSession),
fetchProfilePictureMetadata(newSession),
]).then(() => {
// Fetch profile picture AFTER sync has had time to complete
// Use a small delay to allow avatar sync to finish if it's quick
setTimeout(() => {
// Fetch the picture once the avatar sync settles rather than
// after a fixed 500ms the first-ever sync usually loses.
avatarSync.then(() => {
fetchProfilePicture(newSession).finally(() => {
console.debug(
"[Auth Debug] User data fully loaded after sign in",
);
});
}, 500);
});
});
}
} else if (event === "TOKEN_REFRESHED") {
@@ -2,8 +2,16 @@ import { Avatar } from "@mantine/core";
import { useAuth } from "@app/auth/UseSession";
export function useConfigButtonIcon(): React.ReactNode {
const { profilePictureUrl } = useAuth();
return profilePictureUrl ? (
<Avatar src={profilePictureUrl} radius="xl" size={24} />
const { profilePictureUrl, user } = useAuth();
if (profilePictureUrl) {
return <Avatar src={profilePictureUrl} radius="xl" size={24} />;
}
// Mirror the settings page fallback (initials avatar) so both spots show
// the same identity badge while the picture URL is unavailable.
const initial = user?.email?.trim()?.charAt(0)?.toUpperCase();
return initial ? (
<Avatar radius="xl" size={24} color="blue">
{initial}
</Avatar>
) : null;
}