diff --git a/apps/web/src/features/chat/components/message/ChatMessage.tsx b/apps/web/src/features/chat/components/message/ChatMessage.tsx index e0d0185..4e8820b 100644 --- a/apps/web/src/features/chat/components/message/ChatMessage.tsx +++ b/apps/web/src/features/chat/components/message/ChatMessage.tsx @@ -91,6 +91,7 @@ function ChatMessageContent({ kind={message.author.kind} name={message.author.name} agentRole={message.author.agentRole} + email={message.author.kind === "human" ? message.author.id : undefined} /> } header={ diff --git a/apps/web/src/features/chat/components/message/MessageAvatar.tsx b/apps/web/src/features/chat/components/message/MessageAvatar.tsx index 4800e7c..5efac1e 100644 --- a/apps/web/src/features/chat/components/message/MessageAvatar.tsx +++ b/apps/web/src/features/chat/components/message/MessageAvatar.tsx @@ -2,6 +2,7 @@ import { Icon } from "@tangent/ui-primitives/icon"; import { cva } from "class-variance-authority"; import type { AgentRole } from "@/features/chat/model/types"; +import { UserAvatar } from "@/features/user/components/UserAvatar"; import { cn } from "@/shared/lib/utils"; // Role-keyed avatar styling. The `agent`/`subagent` roles reuse the same @@ -45,17 +46,27 @@ interface MessageAvatarProps { kind: "human" | "agent"; name: string; agentRole?: AgentRole; + /** The human author's email, used to resolve a Gravatar image. */ + email?: string; } /** * MessageAvatar — small circular badge conveying the message sender's kind - * (human, prime agent, sub-agent). Styles a raw `
` (the sanctioned escape - * hatch, like `StatusDot`/`UserAvatar`), so it is exempt from - * tangle-ui/no-classname-on-primitives. + * (human, prime agent, sub-agent). Delegates to {@link UserAvatar}: human + * authors show their Gravatar, and everyone else (or a human without one) falls + * back to the role icon badge. + * + * Styles a raw `
` (the sanctioned escape hatch, like `StatusDot`), so it is + * exempt from `tangle-ui/no-classname-on-primitives`. */ -export function MessageAvatar({ kind, name, agentRole }: MessageAvatarProps) { +export function MessageAvatar({ + kind, + name, + agentRole, + email, +}: MessageAvatarProps) { const role = avatarRole(kind, agentRole); - return ( + const badge = (
); + + if (role !== "human" || !email?.trim().length) return badge; + + return ( + + ); } diff --git a/apps/web/src/features/chat/components/message/ThinkingOnlyMessage.tsx b/apps/web/src/features/chat/components/message/ThinkingOnlyMessage.tsx index 5662f3e..dc43e75 100644 --- a/apps/web/src/features/chat/components/message/ThinkingOnlyMessage.tsx +++ b/apps/web/src/features/chat/components/message/ThinkingOnlyMessage.tsx @@ -38,6 +38,7 @@ export function ThinkingOnlyMessage({ kind={message.author.kind} name={message.author.name} agentRole={message.author.agentRole} + email={message.author.kind === "human" ? message.author.id : undefined} /> } header={ diff --git a/apps/web/src/features/user/components/UserAvatar.tsx b/apps/web/src/features/user/components/UserAvatar.tsx index ebb3812..e3be1df 100644 --- a/apps/web/src/features/user/components/UserAvatar.tsx +++ b/apps/web/src/features/user/components/UserAvatar.tsx @@ -1,26 +1,72 @@ -import type { UserIdentity } from "@tangent/shared/contracts"; +import { useQuery } from "@tanstack/react-query"; +import { cva } from "class-variance-authority"; +import type { ReactNode } from "react"; -import { userInitials } from "@/features/user/model/userDisplay"; +import { resolveGravatarUrl } from "@/features/user/model/gravatar"; +import { UserQueryKeys } from "@/features/user/model/userQueryKeys"; +import { Spinner } from "@tangent/ui-primitives/spinner"; + +type AvatarSize = "sm" | "md"; + +const avatarImageVariants = cva("shrink-0 rounded-full object-cover", { + variants: { + size: { + sm: "size-6", + md: "size-8", + }, + }, + defaultVariants: { + size: "md", + }, +}); + +const AVATAR_SIZE_PX: Record = { + sm: 48, + md: 64, +}; interface UserAvatarProps { - user: UserIdentity; + /** Email to resolve a Gravatar for; empty skips the lookup and shows `fallback`. */ + email: string; + /** Accessible label and tooltip for the image. */ + name: string; + /** Badge shown when the email has no Gravatar or the lookup hasn't resolved. */ + fallback: ReactNode; + size?: AvatarSize; } /** - * UserAvatar — circular initials badge for the current user. + * UserAvatar — circular Gravatar image with a fallback badge. The existence + * check lives in the query (Gravatar 404s for emails without an avatar), so a + * missing avatar simply resolves to no URL and `fallback` renders — no separate + * error state or image `onError`. * - * Styles a raw `
` (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)} -
+ {name} ); } 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 ( - + } + /> } />