` (the sanctioned escape hatch, like `StatusDot`), so it
- * is exempt from `tangle-ui/no-classname-on-primitives`.
+ * Styles a raw `
![]()
` (the sanctioned escape hatch, like `StatusDot`), so it is
+ * exempt from `tangle-ui/no-classname-on-primitives`.
*/
-export function UserAvatar({ user }: UserAvatarProps) {
- const fullName = `${user.first_name} ${user.last_name}`.trim();
+export function UserAvatar({
+ email,
+ name,
+ fallback,
+ size = "md",
+}: UserAvatarProps) {
+ const { data: src, isLoading } = useQuery({
+ queryKey: UserQueryKeys.Gravatar(email, AVATAR_SIZE_PX[size]),
+ queryFn: () => resolveGravatarUrl(email, AVATAR_SIZE_PX[size]),
+ enabled: email.trim().length > 0,
+ staleTime: Infinity,
+ });
+
+ if (isLoading) return
;
+
+ if (!src) return <>{fallback}>;
+
return (
-
- {userInitials(user)}
-
+

);
}
diff --git a/apps/web/src/features/user/components/UserInitialsBadge.tsx b/apps/web/src/features/user/components/UserInitialsBadge.tsx
new file mode 100644
index 0000000..f699986
--- /dev/null
+++ b/apps/web/src/features/user/components/UserInitialsBadge.tsx
@@ -0,0 +1,27 @@
+import type { UserIdentity } from "@tangent/shared/contracts";
+
+import { userInitials } from "@/features/user/model/userDisplay";
+
+interface UserInitialsBadgeProps {
+ user: UserIdentity;
+}
+
+/**
+ * UserInitialsBadge — circular initials badge, used as the {@link UserAvatar}
+ * fallback for the current user when no Gravatar exists.
+ *
+ * Styles a raw `
` (the sanctioned escape hatch, like `StatusDot`), so it is
+ * exempt from `tangle-ui/no-classname-on-primitives`.
+ */
+export function UserInitialsBadge({ user }: UserInitialsBadgeProps) {
+ const fullName = `${user.first_name} ${user.last_name}`.trim();
+ return (
+
+ {userInitials(user)}
+
+ );
+}
diff --git a/apps/web/src/features/user/model/gravatar.ts b/apps/web/src/features/user/model/gravatar.ts
new file mode 100644
index 0000000..65814ec
--- /dev/null
+++ b/apps/web/src/features/user/model/gravatar.ts
@@ -0,0 +1,36 @@
+/**
+ * Builds the Gravatar image URL for an email using a SHA-256 hash (Gravatar
+ * accepts SHA-256, so we avoid an md5 dependency and Node `crypto`, neither of
+ * which work in the browser). `d=404` makes Gravatar 404 when no avatar exists.
+ * Returns `null` for an empty email.
+ */
+async function gravatarUrl(email: string, size: number): Promise
{
+ const normalized = email.trim().toLowerCase();
+ if (!normalized) return null;
+
+ const bytes = new TextEncoder().encode(normalized);
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
+ const hash = Array.from(new Uint8Array(digest))
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+
+ return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=404`;
+}
+
+/**
+ * Resolves the Gravatar URL for an email, returning it only when the avatar
+ * actually exists. Thanks to `d=404`, Gravatar 404s for emails with no avatar,
+ * so a failed fetch (or non-`ok` response) resolves to `null` and callers can
+ * show a fallback. Gravatar serves permissive CORS, so this cross-origin fetch
+ * is readable and its response is reused by the `
` from cache.
+ */
+export async function resolveGravatarUrl(
+ email: string,
+ size: number,
+): Promise {
+ const url = await gravatarUrl(email, size);
+ if (!url) return null;
+
+ const res = await fetch(url);
+ return res.ok ? url : null;
+}
diff --git a/apps/web/src/features/user/model/userQueryKeys.ts b/apps/web/src/features/user/model/userQueryKeys.ts
index e78846a..01b7389 100644
--- a/apps/web/src/features/user/model/userQueryKeys.ts
+++ b/apps/web/src/features/user/model/userQueryKeys.ts
@@ -3,4 +3,6 @@
*/
export const UserQueryKeys = {
Me: () => ["me"] as const,
+ Gravatar: (email: string, size: number) =>
+ ["gravatar", email, size] as const,
} as const;
diff --git a/apps/web/src/routes/layout/AppTopNav.tsx b/apps/web/src/routes/layout/AppTopNav.tsx
index a4ccb11..2cc8299 100644
--- a/apps/web/src/routes/layout/AppTopNav.tsx
+++ b/apps/web/src/routes/layout/AppTopNav.tsx
@@ -3,6 +3,7 @@ import { Text } from "@tangent/ui-primitives/typography";
import { Link } from "@tanstack/react-router";
import { UserAvatar } from "@/features/user/components/UserAvatar";
+import { UserInitialsBadge } from "@/features/user/components/UserInitialsBadge";
import { useCurrentUser } from "@/features/user/hooks/useCurrentUser";
import { TopNav, TopNavLink } from "@/shared/ui/patterns/top-nav";
@@ -17,6 +18,7 @@ import { ThemeMenu } from "./ThemeMenu";
*/
export function AppTopNav() {
const user = useCurrentUser();
+ const fullName = `${user.first_name} ${user.last_name}`.trim();
return (
-
+ }
+ />
>
}
/>