Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down
27 changes: 22 additions & 5 deletions apps/web/src/features/chat/components/message/MessageAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<div>` (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 `<div>` (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 = (
<div
title={name}
aria-label={name}
Expand All @@ -64,4 +75,10 @@ export function MessageAvatar({ kind, name, agentRole }: MessageAvatarProps) {
<Icon name={AVATAR_ICONS[role]} size="xs" />
</div>
);

if (role !== "human" || !email?.trim().length) return badge;

return (
<UserAvatar email={email ?? ""} name={name} size="sm" fallback={badge} />
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down
76 changes: 61 additions & 15 deletions apps/web/src/features/user/components/UserAvatar.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,72 @@
import type { UserIdentity } from "@tangent/shared/contracts";
import { useQuery } from "@tanstack/react-query";

Check failure on line 1 in apps/web/src/features/user/components/UserAvatar.tsx

View workflow job for this annotation

GitHub Actions / Linting

Run autofix to sort these imports!

Check failure on line 1 in apps/web/src/features/user/components/UserAvatar.tsx

View workflow job for this annotation

GitHub Actions / Linting

Run autofix to sort these imports!
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<AvatarSize, number> = {
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 `<div>` (the sanctioned escape hatch, like `StatusDot`), so it
* is exempt from `tangle-ui/no-classname-on-primitives`.
* Styles a raw `<img>` (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 <Spinner />;

if (!src) return <>{fallback}</>;

return (
<div
title={fullName}
aria-label={fullName}
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary text-xs font-medium text-secondary-foreground"
>
{userInitials(user)}
</div>
<img
src={src}
title={name}
alt={name}
className={avatarImageVariants({ size })}
/>
);
}
27 changes: 27 additions & 0 deletions apps/web/src/features/user/components/UserInitialsBadge.tsx
Original file line number Diff line number Diff line change
@@ -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 `<div>` (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 (
<div
title={fullName}
aria-label={fullName}
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary text-xs font-medium text-secondary-foreground"
>
{userInitials(user)}
</div>
);
}
36 changes: 36 additions & 0 deletions apps/web/src/features/user/model/gravatar.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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 `<img>` from cache.
*/
export async function resolveGravatarUrl(
email: string,
size: number,
): Promise<string | null> {
const url = await gravatarUrl(email, size);
if (!url) return null;

const res = await fetch(url);
return res.ok ? url : null;
}
2 changes: 2 additions & 0 deletions apps/web/src/features/user/model/userQueryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@
*/
export const UserQueryKeys = {
Me: () => ["me"] as const,
Gravatar: (email: string, size: number) =>
["gravatar", email, size] as const,
} as const;
8 changes: 7 additions & 1 deletion apps/web/src/routes/layout/AppTopNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -17,6 +18,7 @@ import { ThemeMenu } from "./ThemeMenu";
*/
export function AppTopNav() {
const user = useCurrentUser();
const fullName = `${user.first_name} ${user.last_name}`.trim();
return (
<TopNav
brand={
Expand All @@ -39,7 +41,11 @@ export function AppTopNav() {
actions={
<>
<ThemeMenu />
<UserAvatar user={user} />
<UserAvatar
email={user.email}
name={fullName}
fallback={<UserInitialsBadge user={user} />}
/>
</>
}
/>
Expand Down
Loading