-
Notifications
You must be signed in to change notification settings - Fork 61
Gravatar avatars with colored initials in channel views #3657
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
bb20035
Add Gravatar avatars with colored initials to channel views
raquelmsmith 852a324
Render initials color via inline CSS var so it actually shows
raquelmsmith 05cb546
Use PostHog's Lettermark palette with paired text colors
raquelmsmith 98ffa54
Reset gravatar url when the avatar's email changes
raquelmsmith d6326d2
Update packages/ui/src/features/auth/useGravatarUrl.ts
raquelmsmith 1be46bd
Remove duplicate cancelled declaration in useGravatarUrl
raquelmsmith File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ), | ||
| ); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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`; | ||
| } | ||
|
|
||
| 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); | ||
| }) | ||
|
raquelmsmith marked this conversation as resolved.
|
||
| .catch(() => { | ||
| if (!cancelled) setUrl(undefined); | ||
| }); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [email]); | ||
|
|
||
| return url; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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=404still makes the request for users without an avatar; use an authenticated first-party image proxy/cache or require explicit opt-in before contacting Gravatar.