diff --git a/packages/core/src/auth/avatarColor.test.ts b/packages/core/src/auth/avatarColor.test.ts new file mode 100644 index 0000000000..235d51fbc9 --- /dev/null +++ b/packages/core/src/auth/avatarColor.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { avatarColor } from "./avatarColor"; + +describe("avatarColor", () => { + it("is deterministic for a given seed", () => { + expect(avatarColor("user-uuid-123")).toEqual(avatarColor("user-uuid-123")); + }); + + it("returns a paired bg/text hex color", () => { + for (const seed of ["a", "raquel@posthog.com", "uuid", "", "James Doe"]) { + const color = avatarColor(seed); + expect(color.bg).toMatch(/^#[0-9a-f]{6}$/); + expect(color.text).toMatch(/^#[0-9a-f]{6}$/); + } + }); + + it("spreads distinct seeds across more than one color", () => { + const seeds = Array.from({ length: 40 }, (_, i) => `person-${i}`); + const distinct = new Set(seeds.map((s) => avatarColor(s).bg)); + expect(distinct.size).toBeGreaterThan(1); + }); +}); diff --git a/packages/core/src/auth/avatarColor.ts b/packages/core/src/auth/avatarColor.ts new file mode 100644 index 0000000000..09a0c7590c --- /dev/null +++ b/packages/core/src/auth/avatarColor.ts @@ -0,0 +1,41 @@ +export interface AvatarColor { + bg: string; + text: string; +} + +// Profile-avatar palette ported verbatim from PostHog's Lettermark (the 16 +// --lettermark-N-bg / --lettermark-N-text pairs). Each background is paired with +// a text color picked for legible contrast, and the values are theme-independent, +// so they hold up in both light and dark mode. +const AVATAR_PALETTE: readonly AvatarColor[] = [ + { bg: "#dcb1e3", text: "#572e5e" }, + { bg: "#ffc4b2", text: "#3e5891" }, + { bg: "#b1985d", text: "#3e5891" }, + { bg: "#3e5891", text: "#ffc4b2" }, + { bg: "#8da9e7", text: "#572e5e" }, + { bg: "#572e5e", text: "#dcb1e3" }, + { bg: "#ffc035", text: "#35416b" }, + { bg: "#ff906e", text: "#2a3d65" }, + { bg: "#5dd4db", text: "#1a4a4d" }, + { bg: "#c8e65c", text: "#3d4a1a" }, + { bg: "#e85da4", text: "#4a1a35" }, + { bg: "#e85c5c", text: "#4a1a1a" }, + { bg: "#3d7a5a", text: "#c8f0dc" }, + { bg: "#6b5bd4", text: "#e0ddf5" }, + { bg: "#98e8c8", text: "#1a4a3d" }, + { bg: "#8c3d5a", text: "#f0d0dc" }, +] as const; + +export function avatarColorSeedHash(seed: string): number { + let hash = 0; + for (let i = 0; i < seed.length; i += 1) { + hash = (hash + seed.charCodeAt(i) * (i + 1)) % 9973; + } + return hash; +} + +// Picks a stable palette entry for a seed, mirroring Lettermark's `index % 16` +// variant selection. +export function avatarColor(seed: string): AvatarColor { + return AVATAR_PALETTE[avatarColorSeedHash(seed) % AVATAR_PALETTE.length]; +} diff --git a/packages/ui/src/features/auth/UserAvatar.tsx b/packages/ui/src/features/auth/UserAvatar.tsx new file mode 100644 index 0000000000..2bb24d6ef6 --- /dev/null +++ b/packages/ui/src/features/auth/UserAvatar.tsx @@ -0,0 +1,38 @@ +import { avatarColor } from "@posthog/core/auth/avatarColor"; +import { Avatar, AvatarFallback, AvatarImage } from "@posthog/quill"; +import type { UserBasic } from "@posthog/shared/domain-types"; +import { useGravatarUrl } from "@posthog/ui/features/auth/useGravatarUrl"; +import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; + +type AvatarSize = "lg" | "default" | "sm" | "xs"; + +interface UserAvatarProps { + user?: UserBasic | null; + size?: AvatarSize; + className?: string; +} + +// A person's avatar: Gravatar (by email) when one exists, otherwise a colored +// initials bubble. The color is seeded off a stable identifier so each person keeps +// one hue everywhere. When the Gravatar image loads it covers the colored fallback. +export function UserAvatar({ + user, + size = "default", + className, +}: UserAvatarProps) { + const gravatarUrl = useGravatarUrl(user?.email); + const seed = user?.uuid ?? user?.email ?? userDisplayName(user); + const color = avatarColor(seed); + + return ( + + {gravatarUrl ? ( + + ) : null} + + {getUserInitials(user)} + + + ); +} diff --git a/packages/ui/src/features/auth/useGravatarUrl.test.ts b/packages/ui/src/features/auth/useGravatarUrl.test.ts new file mode 100644 index 0000000000..652936afa9 --- /dev/null +++ b/packages/ui/src/features/auth/useGravatarUrl.test.ts @@ -0,0 +1,50 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useGravatarUrl } from "./useGravatarUrl"; + +describe("useGravatarUrl", () => { + it("returns undefined when there is no email", () => { + const { result } = renderHook(() => useGravatarUrl(undefined)); + expect(result.current).toBeUndefined(); + }); + + it("builds a SHA-256 Gravatar URL with the d=404 fallback", async () => { + const { result } = renderHook(() => useGravatarUrl("user@example.com")); + await waitFor(() => + expect(result.current).toBe( + "https://www.gravatar.com/avatar/b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514?s=96&d=404", + ), + ); + }); + + it("lowercases and trims the email before hashing", async () => { + const { result } = renderHook(() => useGravatarUrl(" TEST@Example.com ")); + await waitFor(() => + expect(result.current).toBe( + "https://www.gravatar.com/avatar/973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b?s=96&d=404", + ), + ); + }); + + it("clears the previous URL while a changed email is hashing", async () => { + const { result, rerender } = renderHook( + ({ email }) => useGravatarUrl(email), + { initialProps: { email: "user@example.com" } }, + ); + await waitFor(() => + expect(result.current).toContain( + "b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514", + ), + ); + + rerender({ email: "test@example.com" }); + // The prior person's URL must not linger during the new hash. + expect(result.current).toBeUndefined(); + + await waitFor(() => + expect(result.current).toContain( + "973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b", + ), + ); + }); +}); diff --git a/packages/ui/src/features/auth/useGravatarUrl.ts b/packages/ui/src/features/auth/useGravatarUrl.ts new file mode 100644 index 0000000000..c0bc24c33e --- /dev/null +++ b/packages/ui/src/features/auth/useGravatarUrl.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; + +// Gravatar accepts a SHA-256 hex hash of the lowercased, trimmed email, so we hash +// with the built-in Web Crypto API rather than pulling in an md5 dependency. `d=404` +// makes Gravatar return 404 (instead of a default silhouette) when the address has no +// avatar, so the errors and the initials fallback stays visible. +async function gravatarUrlForEmail(email: string): Promise { + if (!globalThis.crypto?.subtle) return undefined; + const normalized = email.trim().toLowerCase(); + const bytes = new TextEncoder().encode(normalized); + const digest = await globalThis.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=96&d=404`; +} + +export function useGravatarUrl(email?: string | null): string | undefined { + const [url, setUrl] = useState(undefined); + + useEffect(() => { + if (!email) { + setUrl(undefined); + return; + } + let cancelled = false; + // Clear any prior URL so a reused avatar whose email just changed shows + // initials during the async hash rather than the previous person's photo. + setUrl(undefined); + gravatarUrlForEmail(email) + .then((next) => { + if (!cancelled) setUrl(next); + }) + .catch(() => { + if (!cancelled) setUrl(undefined); + }); + return () => { + cancelled = true; + }; + }, [email]); + + return url; +} diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 17fe071e67..0103d4d327 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -1,8 +1,6 @@ import { AtIcon, LinkIcon } from "@phosphor-icons/react"; import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity"; import { - Avatar, - AvatarFallback, Button, Empty, EmptyDescription, @@ -14,8 +12,8 @@ import { import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; -import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; import { MentionText } from "@posthog/ui/features/canvas/components/MentionText"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; @@ -68,9 +66,7 @@ function ActivityRow({ className="flex w-full gap-2 rounded-md px-2 py-2 text-left hover:bg-fill-secondary" > - - {getUserInitials(item.author)} - + {isNew && ( {authors.map((author, index) => ( - - {getUserInitials(author)} - + ))} @@ -440,11 +438,15 @@ export function TaskFeedRow({ return ( - - - {starter ? getUserInitials(starter) : } - - + {starter ? ( + + ) : ( + + + + + + )} @@ -611,15 +613,15 @@ function SystemFeedRow({ message }: { message: ChannelFeedSystemMessage }) { - - - {message.author ? ( - getUserInitials(message.author) - ) : ( + {message.author ? ( + + ) : ( + + - )} - - + + + )} diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index fba801b8f1..170887d8dd 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -52,8 +52,8 @@ import type { } from "@posthog/shared/domain-types"; import { isTerminalStatus } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; -import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView"; import { MentionComposer } from "@posthog/ui/features/canvas/components/MentionComposer"; import { @@ -114,17 +114,19 @@ export function ThreadMessageRow({ return ( - - - {isAgent ? ( - - ) : isSystem ? ( - "S" - ) : ( - getUserInitials(message.author) - )} - - + {isAgent || isSystem ? ( + + + {isAgent ? : "S"} + + + ) : ( + + )} @@ -266,9 +268,7 @@ export function UserPromptRow({ return ( - - {getUserInitials(author)} - +