Skip to content
Merged
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
22 changes: 22 additions & 0 deletions packages/core/src/auth/avatarColor.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
41 changes: 41 additions & 0 deletions packages/core/src/auth/avatarColor.ts
Original file line number Diff line number Diff line change
@@ -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];
}
38 changes: 38 additions & 0 deletions packages/ui/src/features/auth/UserAvatar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Avatar size={size} className={className}>
{gravatarUrl ? (
<AvatarImage src={gravatarUrl} alt={userDisplayName(user)} />
) : null}
<AvatarFallback style={{ backgroundColor: color.bg, color: color.text }}>
{getUserInitials(user)}
</AvatarFallback>
</Avatar>
);
}
50 changes: 50 additions & 0 deletions packages/ui/src/features/auth/useGravatarUrl.test.ts
Original file line number Diff line number Diff line change
@@ -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",
),
);
});
});
43 changes: 43 additions & 0 deletions packages/ui/src/features/auth/useGravatarUrl.ts
Original file line number Diff line number Diff line change
@@ -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 <img> errors and the initials fallback stays visible.
async function gravatarUrlForEmail(email: string): Promise<string | undefined> {
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`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: User data disclosed to Gravatar

Opening a channel or thread now sends Gravatar an unsalted, deterministic hash for every displayed author's email, along with the viewer's IP and request timing. Gravatar can match likely organization addresses by hashing them, and d=404 still makes the request for users without an avatar; use an authenticated first-party image proxy/cache or require explicit opt-in before contacting Gravatar.

}

export function useGravatarUrl(email?: string | null): string | undefined {
const [url, setUrl] = useState<string | undefined>(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);
})
Comment thread
raquelmsmith marked this conversation as resolved.
.catch(() => {
if (!cancelled) setUrl(undefined);
});
return () => {
cancelled = true;
};
}, [email]);

return url;
}
8 changes: 2 additions & 6 deletions packages/ui/src/features/canvas/components/ActivityView.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -68,9 +66,7 @@ function ActivityRow({
className="flex w-full gap-2 rounded-md px-2 py-2 text-left hover:bg-fill-secondary"
>
<span className="relative mt-0.5 shrink-0">
<Avatar size="xs">
<AvatarFallback>{getUserInitials(item.author)}</AvatarFallback>
</Avatar>
<UserAvatar user={item.author} size="xs" />
{isNew && (
<span
className="-top-0.5 -right-0.5 absolute h-2 w-2 rounded-full bg-(--red-9)"
Expand Down
36 changes: 19 additions & 17 deletions packages/ui/src/features/canvas/components/ChannelFeedView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import type {
TaskRunStatus,
UserBasic,
} from "@posthog/shared/domain-types";
import { getUserInitials } from "@posthog/ui/features/auth/userInitials";
import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar";
import { TaskTabIcon } from "@posthog/ui/features/browser-tabs/TaskTabIcon";
import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages";
import { useChannelTaskData } from "@posthog/ui/features/canvas/hooks/useChannelTaskData";
Expand Down Expand Up @@ -401,9 +401,7 @@ function ReplyFooter({
<ThreadItemReplies onClick={onOpenThread} className="mt-1">
<AvatarGroup size="xs">
{authors.map((author, index) => (
<Avatar key={author?.uuid ?? index} size="xs">
<AvatarFallback>{getUserInitials(author)}</AvatarFallback>
</Avatar>
<UserAvatar key={author?.uuid ?? index} user={author} size="xs" />
))}
</AvatarGroup>
<ThreadItemRepliesLabel>
Expand Down Expand Up @@ -440,11 +438,15 @@ export function TaskFeedRow({
return (
<ThreadItem className="rounded-none py-1 pr-8 hover:bg-fill-hover/50">
<ThreadItemGutter>
<Avatar>
<AvatarFallback>
{starter ? getUserInitials(starter) : <RobotIcon size={16} />}
</AvatarFallback>
</Avatar>
{starter ? (
<UserAvatar user={starter} />
) : (
<Avatar>
<AvatarFallback>
<RobotIcon size={16} />
</AvatarFallback>
</Avatar>
)}
</ThreadItemGutter>

<ThreadItemContent className="min-w-0">
Expand Down Expand Up @@ -611,15 +613,15 @@ function SystemFeedRow({ message }: { message: ChannelFeedSystemMessage }) {
<ChatMessageScrollerItem messageId={message.id}>
<ThreadItem className="rounded-none py-1 pr-8">
<ThreadItemGutter>
<Avatar>
<AvatarFallback>
{message.author ? (
getUserInitials(message.author)
) : (
{message.author ? (
<UserAvatar user={message.author} />
) : (
<Avatar>
<AvatarFallback>
<RobotIcon size={16} />
)}
</AvatarFallback>
</Avatar>
</AvatarFallback>
</Avatar>
)}
</ThreadItemGutter>
<ThreadItemContent className="min-w-0">
<ThreadItemHeader>
Expand Down
30 changes: 15 additions & 15 deletions packages/ui/src/features/canvas/components/ThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -114,17 +114,19 @@ export function ThreadMessageRow({
return (
<ThreadItem>
<ThreadItemGutter>
<Avatar size="lg" className="sticky top-2">
<AvatarFallback>
{isAgent ? (
<RobotIcon size={14} />
) : isSystem ? (
"S"
) : (
getUserInitials(message.author)
)}
</AvatarFallback>
</Avatar>
{isAgent || isSystem ? (
<Avatar size="lg" className="sticky top-2">
<AvatarFallback>
{isAgent ? <RobotIcon size={14} /> : "S"}
</AvatarFallback>
</Avatar>
) : (
<UserAvatar
user={message.author}
size="lg"
className="sticky top-2"
/>
)}
</ThreadItemGutter>
<ThreadItemContent>
<ThreadItemHeader>
Expand Down Expand Up @@ -266,9 +268,7 @@ export function UserPromptRow({
return (
<ThreadItem>
<ThreadItemGutter>
<Avatar size="lg" className="sticky top-2">
<AvatarFallback>{getUserInitials(author)}</AvatarFallback>
</Avatar>
<UserAvatar user={author} size="lg" className="sticky top-2" />
</ThreadItemGutter>
<ThreadItemContent>
<ThreadItemHeader>
Expand Down
Loading