From 462022ef220a45db966d86b038672a4045a0465a Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 17 Jul 2026 13:57:08 -0400 Subject: [PATCH 1/4] Show posthog.com/people profile photos on channel avatars Resolve staff profile photos from posthog.com's public team roster and render them in the Bluebird avatar slots (channel feed facepile, system rows, thread panel, activity page, mention typeahead), falling back to the existing initials treatment whenever no confident match exists. Generated-By: PostHog Code Task-Id: 66aecb98-c55b-4516-a7aa-42c53cfa8bd2 --- packages/core/src/canvas/teamProfiles.test.ts | 200 ++++++++++++++++++ packages/core/src/canvas/teamProfiles.ts | 197 +++++++++++++++++ packages/shared/src/analytics-events.ts | 5 +- .../canvas/components/ActivityView.tsx | 8 +- .../canvas/components/ChannelFeedView.tsx | 26 +-- .../canvas/components/MentionComposer.tsx | 22 +- .../canvas/components/TeamMemberAvatar.tsx | 33 +++ .../canvas/components/ThreadPanel.test.tsx | 23 +- .../canvas/components/ThreadPanel.tsx | 30 +-- .../features/canvas/hooks/useTeamAvatars.ts | 55 +++++ 10 files changed, 551 insertions(+), 48 deletions(-) create mode 100644 packages/core/src/canvas/teamProfiles.test.ts create mode 100644 packages/core/src/canvas/teamProfiles.ts create mode 100644 packages/ui/src/features/canvas/components/TeamMemberAvatar.tsx create mode 100644 packages/ui/src/features/canvas/hooks/useTeamAvatars.ts diff --git a/packages/core/src/canvas/teamProfiles.test.ts b/packages/core/src/canvas/teamProfiles.test.ts new file mode 100644 index 0000000000..ed38fda238 --- /dev/null +++ b/packages/core/src/canvas/teamProfiles.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildTeamAvatarIndex, + fetchTeamProfiles, + parseTeamProfiles, + teamAvatarThumbUrl, + teamAvatarUrl, +} from "./teamProfiles"; + +const CLOUDINARY = "https://res.cloudinary.com/dmukukwp6/image/upload"; + +function rosterPayload( + members: Array<{ firstName: string; lastName?: string; url?: string | null }>, +) { + return { + data: { + team: { + teamMembers: members.map((m) => ({ + firstName: m.firstName, + lastName: m.lastName, + avatar: + m.url === null + ? null + : { url: m.url ?? `${CLOUDINARY}/v1/${m.firstName}.png` }, + })), + }, + }, + }; +} + +describe("parseTeamProfiles", () => { + it("extracts flat sq/d payload profiles", () => { + const profiles = parseTeamProfiles( + rosterPayload([{ firstName: "James", lastName: "Hawkins" }]), + ); + expect(profiles).toEqual([ + { + firstName: "James", + lastName: "Hawkins", + avatarUrl: `${CLOUDINARY}/v1/James.png`, + }, + ]); + }); + + it("reads the nested Strapi avatar shape", () => { + const profiles = parseTeamProfiles({ + data: { + team: { + teamMembers: [ + { + firstName: "Raquel", + lastName: "Smith", + avatar: { data: { attributes: { url: "https://x/r.png" } } }, + }, + ], + }, + }, + }); + expect(profiles[0]?.avatarUrl).toBe("https://x/r.png"); + }); + + it.each([ + ["null", null], + ["not the roster query", { data: { posts: [] } }], + ["members not an array", { data: { team: { teamMembers: {} } } }], + ])("returns [] for %s", (_label, payload) => { + expect(parseTeamProfiles(payload)).toEqual([]); + }); + + it("skips malformed members and tolerates a missing avatar", () => { + const profiles = parseTeamProfiles({ + data: { + team: { + teamMembers: [ + null, + { lastName: "NoFirst" }, + { firstName: "Ava", avatar: null }, + ], + }, + }, + }); + expect(profiles).toEqual([ + { firstName: "Ava", lastName: "", avatarUrl: null }, + ]); + }); +}); + +describe("teamAvatarUrl", () => { + const index = buildTeamAvatarIndex([ + { firstName: "James", lastName: "Hawkins", avatarUrl: "url:james-h" }, + { firstName: "James", lastName: "Greenhill", avatarUrl: "url:james-g" }, + { firstName: "Raquel", lastName: "Smith", avatarUrl: "url:raquel" }, + { firstName: "Zoé", lastName: "Dupont", avatarUrl: "url:zoe" }, + { firstName: "NoPhoto", lastName: "Person", avatarUrl: null }, + ]); + + const posthog = (first?: string | null, last?: string | null) => ({ + first_name: first, + last_name: last, + email: "someone@posthog.com", + }); + + it.each([ + ["full name", posthog("James", "Hawkins"), "url:james-h"], + ["case/diacritic-insensitive", posthog("ZOE", "DUPONT"), "url:zoe"], + [ + "single-letter last name by initial", + posthog("James", "G"), + "url:james-g", + ], + ["unique first name only", posthog("Raquel", null), "url:raquel"], + ["ambiguous first name only", posthog("James", null), null], + [ + "full last name never falls back to initial", + posthog("James", "Hawk"), + null, + ], + ["profile without a photo", posthog("NoPhoto", "Person"), null], + ["missing user", null, null], + ])("%s", (_label, user, expected) => { + expect(teamAvatarUrl(index, user)).toBe(expected); + }); + + it("never matches non-posthog.com accounts", () => { + expect( + teamAvatarUrl(index, { + first_name: "James", + last_name: "Hawkins", + email: "james.hawkins@example.com", + }), + ).toBeNull(); + }); +}); + +describe("teamAvatarThumbUrl", () => { + it("inserts a fill transform into Cloudinary URLs at 2x", () => { + expect( + teamAvatarThumbUrl(`${CLOUDINARY}/v1738943658/James_H.png`, 32), + ).toBe( + `${CLOUDINARY}/c_fill,g_face,w_64,h_64,q_auto/v1738943658/James_H.png`, + ); + }); + + it("passes non-Cloudinary URLs through", () => { + expect(teamAvatarThumbUrl("https://example.com/a.png")).toBe( + "https://example.com/a.png", + ); + }); +}); + +describe("fetchTeamProfiles", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function stubFetch(routes: Record) { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (!(url in routes)) return { ok: false } as Response; + return { ok: true, json: async () => routes[url] } as Response; + }), + ); + } + + it("uses the known static-query hash when it still resolves", async () => { + stubFetch({ + "https://posthog.com/page-data/sq/d/2290419275.json": rosterPayload([ + { firstName: "James", lastName: "Hawkins" }, + ]), + }); + const profiles = await fetchTeamProfiles(); + expect(profiles).toHaveLength(1); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("falls back to scanning the /people manifest hashes", async () => { + stubFetch({ + "https://posthog.com/page-data/people/page-data.json": { + staticQueryHashes: ["111", "222"], + }, + "https://posthog.com/page-data/sq/d/111.json": { data: { other: true } }, + "https://posthog.com/page-data/sq/d/222.json": rosterPayload([ + { firstName: "Raquel", lastName: "Smith" }, + ]), + }); + const profiles = await fetchTeamProfiles(); + expect(profiles.map((p) => p.firstName)).toEqual(["Raquel"]); + }); + + it("resolves to [] when everything is unreachable", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("offline"); + }), + ); + await expect(fetchTeamProfiles()).resolves.toEqual([]); + }); +}); diff --git a/packages/core/src/canvas/teamProfiles.ts b/packages/core/src/canvas/teamProfiles.ts new file mode 100644 index 0000000000..07e1b70565 --- /dev/null +++ b/packages/core/src/canvas/teamProfiles.ts @@ -0,0 +1,197 @@ +// Profile photos for PostHog staff, sourced from posthog.com/people. +// +// posthog.com is a Gatsby site with no public team API: the roster ships as a +// build-time static-query artifact at page-data/sq/d/.json. The hash is +// a content hash of the site's GraphQL query — stable across deploys but not +// a contract — so we try the known hash first and fall back to scanning the +// hashes the /people page manifest declares. Every failure degrades to "no +// avatar" and callers keep their initials fallback. +// +// The roster carries no email or other machine join key, only names, so +// matching is by normalized name — and only attempted for @posthog.com +// accounts, so namesakes in other orgs never pick up a staff photo. + +const PAGE_DATA_BASE = "https://posthog.com/page-data"; +const KNOWN_TEAM_QUERY_HASH = "2290419275"; + +export interface TeamProfile { + firstName: string; + lastName: string; + avatarUrl: string | null; +} + +/** The `UserBasic` fields matching needs; structural so any user-ish shape fits. */ +export interface TeamAvatarUser { + first_name?: string | null; + last_name?: string | null; + email?: string | null; +} + +export interface TeamAvatarIndex { + byFullName: ReadonlyMap; + /** "first l" — matches app accounts whose last name is a bare initial. */ + byFirstAndInitial: ReadonlyMap; + byFirstName: ReadonlyMap; +} + +function normalizeNamePart(value: string | null | undefined): string { + return (value ?? "") + .normalize("NFKD") + .replace(/\p{M}/gu, "") + .toLowerCase() + .replace(/\s+/g, " ") + .trim(); +} + +function sqQueryUrl(hash: string): string { + return `${PAGE_DATA_BASE}/sq/d/${hash}.json`; +} + +async function fetchJson(url: string): Promise { + try { + const response = await fetch(url); + if (!response.ok) return null; + return (await response.json()) as unknown; + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null + ? (value as Record) + : null; +} + +/** + * Extract profiles from a static-query payload. Returns [] when the payload + * isn't the team roster query, which is how the hash scan tells them apart. + */ +export function parseTeamProfiles(payload: unknown): TeamProfile[] { + const team = asRecord(asRecord(asRecord(payload)?.data)?.team); + const members = team?.teamMembers; + if (!Array.isArray(members)) return []; + const profiles: TeamProfile[] = []; + for (const member of members) { + const record = asRecord(member); + if (!record || typeof record.firstName !== "string") continue; + const avatar = asRecord(record.avatar); + // The flat sq/d payload uses avatar.url; Strapi's nested GraphQL shape + // (avatar.data.attributes.url) is handled too in case the query changes. + const nested = asRecord(asRecord(avatar?.data)?.attributes); + const url = avatar?.url ?? nested?.url; + profiles.push({ + firstName: record.firstName, + lastName: typeof record.lastName === "string" ? record.lastName : "", + avatarUrl: typeof url === "string" ? url : null, + }); + } + return profiles; +} + +/** + * Fetch the current roster. Never throws — resolves to [] when the site + * layout has drifted or the network is down. + */ +export async function fetchTeamProfiles(): Promise { + const known = parseTeamProfiles( + await fetchJson(sqQueryUrl(KNOWN_TEAM_QUERY_HASH)), + ); + if (known.length > 0) return known; + + // The known hash broke (site's GraphQL query changed). The page manifest + // lists every static-query hash the /people page uses; the roster query is + // one of them, so probe until a payload parses. + const manifest = asRecord( + await fetchJson(`${PAGE_DATA_BASE}/people/page-data.json`), + ); + const hashes = Array.isArray(manifest?.staticQueryHashes) + ? manifest.staticQueryHashes.filter( + (h): h is string => typeof h === "string", + ) + : []; + for (const hash of hashes) { + if (hash === KNOWN_TEAM_QUERY_HASH) continue; + const profiles = parseTeamProfiles(await fetchJson(sqQueryUrl(hash))); + if (profiles.length > 0) return profiles; + } + return []; +} + +function addKey( + map: Map, + key: string, + url: string, +): void { + if (!key) return; + const existing = map.get(key); + // Two different people sharing a key makes it ambiguous — drop it rather + // than show the wrong face. + if (existing !== undefined && existing !== url) { + map.set(key, null); + return; + } + map.set(key, url); +} + +function settle(map: Map): ReadonlyMap { + const settled = new Map(); + for (const [key, url] of map) { + if (url !== null) settled.set(key, url); + } + return settled; +} + +export function buildTeamAvatarIndex(profiles: TeamProfile[]): TeamAvatarIndex { + const byFullName = new Map(); + const byFirstAndInitial = new Map(); + const byFirstName = new Map(); + for (const profile of profiles) { + if (!profile.avatarUrl) continue; + const first = normalizeNamePart(profile.firstName); + const last = normalizeNamePart(profile.lastName); + if (!first) continue; + if (last) addKey(byFullName, `${first} ${last}`, profile.avatarUrl); + if (last) + addKey(byFirstAndInitial, `${first} ${last[0]}`, profile.avatarUrl); + addKey(byFirstName, first, profile.avatarUrl); + } + return { + byFullName: settle(byFullName), + byFirstAndInitial: settle(byFirstAndInitial), + byFirstName: settle(byFirstName), + }; +} + +/** + * The posthog.com avatar for an app user, or null when there's no confident + * match. A full last name must match exactly; a single-letter last name + * matches by initial; no last name matches by unique first name. + */ +export function teamAvatarUrl( + index: TeamAvatarIndex, + user: TeamAvatarUser | null | undefined, +): string | null { + if (!user?.email?.toLowerCase().endsWith("@posthog.com")) return null; + const first = normalizeNamePart(user.first_name); + if (!first) return null; + const last = normalizeNamePart(user.last_name); + if (last.length > 1) return index.byFullName.get(`${first} ${last}`) ?? null; + if (last.length === 1) { + return index.byFirstAndInitial.get(`${first} ${last}`) ?? null; + } + return index.byFirstName.get(first) ?? null; +} + +/** + * A right-sized Cloudinary thumb of a roster avatar (the originals are + * full-resolution uploads). Non-Cloudinary URLs pass through untouched. + */ +export function teamAvatarThumbUrl(url: string, sizePx = 64): string { + const marker = "/image/upload/"; + const at = url.indexOf(marker); + if (!url.startsWith("https://res.cloudinary.com/") || at === -1) return url; + const px = sizePx * 2; + const insert = `c_fill,g_face,w_${px},h_${px},q_auto/`; + return `${url.slice(0, at + marker.length)}${insert}${url.slice(at + marker.length)}`; +} diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index efe590fb7f..c0ee20fc96 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -880,7 +880,8 @@ export type ChannelActionType = | "mention_member" | "view_activity" | "open_mention" - | "canvas_mode_toggle"; + | "canvas_mode_toggle" + | "load_team_avatars"; export interface ChannelActionProperties { action_type: ChannelActionType; @@ -899,6 +900,8 @@ export interface ChannelActionProperties { suggestion_label?: string; /** For canvas_mode_toggle: whether canvas mode is being armed. */ armed?: boolean; + /** For load_team_avatars: how many posthog.com profiles resolved. */ + profile_count?: number; /** Whether the underlying mutation resolved successfully. */ success?: boolean; } diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 17fe071e67..1ef250d80b 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, @@ -15,8 +13,8 @@ import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; 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 { TeamMemberAvatar } from "@posthog/ui/features/canvas/components/TeamMemberAvatar"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; import { normalizeChannelName } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; @@ -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)} - + ))} @@ -583,15 +585,15 @@ function SystemFeedRow({ message }: { message: ChannelFeedSystemMessage }) { - - - {message.author ? ( - getUserInitials(message.author) - ) : ( + {message.author ? ( + + ) : ( + + - )} - - + + + )} diff --git a/packages/ui/src/features/canvas/components/MentionComposer.tsx b/packages/ui/src/features/canvas/components/MentionComposer.tsx index df0826b994..ac79335695 100644 --- a/packages/ui/src/features/canvas/components/MentionComposer.tsx +++ b/packages/ui/src/features/canvas/components/MentionComposer.tsx @@ -1,7 +1,7 @@ import { RobotIcon } from "@phosphor-icons/react"; import { Avatar, AvatarFallback, InputGroup } from "@posthog/quill"; import type { UserBasic } from "@posthog/shared/domain-types"; -import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { TeamMemberAvatar } from "@posthog/ui/features/canvas/components/TeamMemberAvatar"; import { type ComposerMentionCandidate, contentToDoc, @@ -264,15 +264,19 @@ export function MentionComposer({ index === highlightedIndex ? "bg-[var(--accent-a4)]" : "" }`} > - - - {candidate.kind === "agent" ? ( + {candidate.kind === "agent" ? ( + + - ) : ( - getUserInitials(candidate.member) - )} - - + + + ) : ( + + )} {candidate.kind === "agent" ? "Agent" diff --git a/packages/ui/src/features/canvas/components/TeamMemberAvatar.tsx b/packages/ui/src/features/canvas/components/TeamMemberAvatar.tsx new file mode 100644 index 0000000000..83ebe41327 --- /dev/null +++ b/packages/ui/src/features/canvas/components/TeamMemberAvatar.tsx @@ -0,0 +1,33 @@ +import { teamAvatarThumbUrl } from "@posthog/core/canvas/teamProfiles"; +import { Avatar, AvatarFallback, AvatarImage } from "@posthog/quill"; +import type { UserBasic } from "@posthog/shared/domain-types"; +import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { useTeamAvatarUrl } from "@posthog/ui/features/canvas/hooks/useTeamAvatars"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; + +/** + * A teammate's avatar: their posthog.com/people photo when the account maps + * to a staff profile, initials otherwise (and while the photo loads). + */ +export function TeamMemberAvatar({ + user, + size, + className, +}: { + user: UserBasic | null | undefined; + size?: "lg" | "default" | "sm" | "xs"; + className?: string; +}) { + const avatarUrl = useTeamAvatarUrl(user); + return ( + + {avatarUrl && ( + + )} + {getUserInitials(user)} + + ); +} diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx index 89aca9895a..7f0c06d46d 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx @@ -1,5 +1,7 @@ import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; +import type { ReactElement } from "react"; import { describe, expect, it } from "vitest"; import { AgentStatusLine, @@ -8,6 +10,17 @@ import { } from "./ThreadPanel"; import { agentTurns } from "./threadAgentTurns"; +// Rows resolve team avatars through a query; disable fetching so tests stay +// offline and rows render their initials fallback. +function renderRow(ui: ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { enabled: false } }, + }); + return render( + {ui}, + ); +} + describe("agentTurns", () => { it("accumulates every text chunk in one agent turn", () => { const items = [ @@ -51,7 +64,7 @@ describe("AgentStatusLine", () => { describe("ThreadMessageRow", () => { it("renders backend-authored agent announcements as Agent", () => { - render( + renderRow( { }); it("renders system announcements as System without human actions", () => { - render( + renderRow( { }); it("keeps legacy authorless rows as human messages", () => { - render( + renderRow( { describe("UserPromptRow", () => { it("prefixes direct task prompts with @agent", () => { - render( + renderRow( { }); it("hides forwarded thread attribution and duplicate agent mentions", () => { - render( + renderRow( - - - {isAgent ? ( - - ) : isSystem ? ( - "S" - ) : ( - getUserInitials(message.author) - )} - - + {isAgent || isSystem ? ( + + + {isAgent ? : "S"} + + + ) : ( + + )} @@ -266,9 +268,7 @@ export function UserPromptRow({ return ( - - {getUserInitials(author)} - + diff --git a/packages/ui/src/features/canvas/hooks/useTeamAvatars.ts b/packages/ui/src/features/canvas/hooks/useTeamAvatars.ts new file mode 100644 index 0000000000..896832aa69 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useTeamAvatars.ts @@ -0,0 +1,55 @@ +import { + buildTeamAvatarIndex, + fetchTeamProfiles, + type TeamAvatarIndex, + type TeamAvatarUser, + teamAvatarUrl, +} from "@posthog/core/canvas/teamProfiles"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { track } from "@posthog/ui/shell/analytics"; +import { useQuery } from "@tanstack/react-query"; + +export const TEAM_AVATARS_QUERY_KEY = ["posthog-team-avatars"] as const; + +// The roster is a static build artifact of posthog.com — one fetch per app +// session is plenty, and a failed fetch (offline, site drift) shouldn't retry +// under every avatar on screen. +const TEAM_AVATARS_GC_MS = 24 * 60 * 60 * 1000; + +// The load event fires once per session, not once per mounted avatar. +let trackedLoad = false; + +function useTeamAvatarIndex(): TeamAvatarIndex | null { + const query = useQuery({ + queryKey: TEAM_AVATARS_QUERY_KEY, + queryFn: async () => { + const profiles = await fetchTeamProfiles(); + if (!trackedLoad) { + trackedLoad = true; + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "load_team_avatars", + surface: "channel_home", + success: profiles.length > 0, + profile_count: profiles.length, + }); + } + return buildTeamAvatarIndex(profiles); + }, + staleTime: Number.POSITIVE_INFINITY, + gcTime: TEAM_AVATARS_GC_MS, + retry: false, + refetchOnWindowFocus: false, + }); + return query.data ?? null; +} + +/** + * The posthog.com/people photo for a teammate, or null while loading / when + * there's no confident name match (callers keep their initials fallback). + */ +export function useTeamAvatarUrl( + user: TeamAvatarUser | null | undefined, +): string | null { + const index = useTeamAvatarIndex(); + return index ? teamAvatarUrl(index, user) : null; +} From 0fbb0e2ad9f07f3f116fe11af3b149fe5ed7804b Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 17 Jul 2026 16:12:44 -0400 Subject: [PATCH 2/4] Replace roster scraping with a generic user avatar system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop all posthog.com-specific avatar fetching from the app. Avatars now resolve generically: the account's own avatar_url when the backend serves one (field added to UserBasic, awaiting backend support), Gravatar by email otherwise — the same convention PostHog cloud's ProfilePicture uses — with initials as the fallback. Internal users' photos become a backend data backfill instead of client logic. Generated-By: PostHog Code Task-Id: 66aecb98-c55b-4516-a7aa-42c53cfa8bd2 --- packages/core/src/canvas/teamProfiles.test.ts | 200 ------------------ packages/core/src/canvas/teamProfiles.ts | 197 ----------------- packages/shared/src/analytics-events.ts | 5 +- packages/shared/src/domain-types.ts | 2 + .../ui/src/features/avatars/UserAvatar.tsx | 30 +++ .../features/avatars/useUserAvatar.test.ts | 12 ++ .../ui/src/features/avatars/useUserAvatar.ts | 38 ++++ .../canvas/components/ActivityView.tsx | 4 +- .../canvas/components/ChannelFeedView.tsx | 10 +- .../canvas/components/MentionComposer.tsx | 4 +- .../canvas/components/TeamMemberAvatar.tsx | 33 --- .../canvas/components/ThreadPanel.tsx | 6 +- .../features/canvas/hooks/useTeamAvatars.ts | 55 ----- 13 files changed, 93 insertions(+), 503 deletions(-) delete mode 100644 packages/core/src/canvas/teamProfiles.test.ts delete mode 100644 packages/core/src/canvas/teamProfiles.ts create mode 100644 packages/ui/src/features/avatars/UserAvatar.tsx create mode 100644 packages/ui/src/features/avatars/useUserAvatar.test.ts create mode 100644 packages/ui/src/features/avatars/useUserAvatar.ts delete mode 100644 packages/ui/src/features/canvas/components/TeamMemberAvatar.tsx delete mode 100644 packages/ui/src/features/canvas/hooks/useTeamAvatars.ts diff --git a/packages/core/src/canvas/teamProfiles.test.ts b/packages/core/src/canvas/teamProfiles.test.ts deleted file mode 100644 index ed38fda238..0000000000 --- a/packages/core/src/canvas/teamProfiles.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - buildTeamAvatarIndex, - fetchTeamProfiles, - parseTeamProfiles, - teamAvatarThumbUrl, - teamAvatarUrl, -} from "./teamProfiles"; - -const CLOUDINARY = "https://res.cloudinary.com/dmukukwp6/image/upload"; - -function rosterPayload( - members: Array<{ firstName: string; lastName?: string; url?: string | null }>, -) { - return { - data: { - team: { - teamMembers: members.map((m) => ({ - firstName: m.firstName, - lastName: m.lastName, - avatar: - m.url === null - ? null - : { url: m.url ?? `${CLOUDINARY}/v1/${m.firstName}.png` }, - })), - }, - }, - }; -} - -describe("parseTeamProfiles", () => { - it("extracts flat sq/d payload profiles", () => { - const profiles = parseTeamProfiles( - rosterPayload([{ firstName: "James", lastName: "Hawkins" }]), - ); - expect(profiles).toEqual([ - { - firstName: "James", - lastName: "Hawkins", - avatarUrl: `${CLOUDINARY}/v1/James.png`, - }, - ]); - }); - - it("reads the nested Strapi avatar shape", () => { - const profiles = parseTeamProfiles({ - data: { - team: { - teamMembers: [ - { - firstName: "Raquel", - lastName: "Smith", - avatar: { data: { attributes: { url: "https://x/r.png" } } }, - }, - ], - }, - }, - }); - expect(profiles[0]?.avatarUrl).toBe("https://x/r.png"); - }); - - it.each([ - ["null", null], - ["not the roster query", { data: { posts: [] } }], - ["members not an array", { data: { team: { teamMembers: {} } } }], - ])("returns [] for %s", (_label, payload) => { - expect(parseTeamProfiles(payload)).toEqual([]); - }); - - it("skips malformed members and tolerates a missing avatar", () => { - const profiles = parseTeamProfiles({ - data: { - team: { - teamMembers: [ - null, - { lastName: "NoFirst" }, - { firstName: "Ava", avatar: null }, - ], - }, - }, - }); - expect(profiles).toEqual([ - { firstName: "Ava", lastName: "", avatarUrl: null }, - ]); - }); -}); - -describe("teamAvatarUrl", () => { - const index = buildTeamAvatarIndex([ - { firstName: "James", lastName: "Hawkins", avatarUrl: "url:james-h" }, - { firstName: "James", lastName: "Greenhill", avatarUrl: "url:james-g" }, - { firstName: "Raquel", lastName: "Smith", avatarUrl: "url:raquel" }, - { firstName: "Zoé", lastName: "Dupont", avatarUrl: "url:zoe" }, - { firstName: "NoPhoto", lastName: "Person", avatarUrl: null }, - ]); - - const posthog = (first?: string | null, last?: string | null) => ({ - first_name: first, - last_name: last, - email: "someone@posthog.com", - }); - - it.each([ - ["full name", posthog("James", "Hawkins"), "url:james-h"], - ["case/diacritic-insensitive", posthog("ZOE", "DUPONT"), "url:zoe"], - [ - "single-letter last name by initial", - posthog("James", "G"), - "url:james-g", - ], - ["unique first name only", posthog("Raquel", null), "url:raquel"], - ["ambiguous first name only", posthog("James", null), null], - [ - "full last name never falls back to initial", - posthog("James", "Hawk"), - null, - ], - ["profile without a photo", posthog("NoPhoto", "Person"), null], - ["missing user", null, null], - ])("%s", (_label, user, expected) => { - expect(teamAvatarUrl(index, user)).toBe(expected); - }); - - it("never matches non-posthog.com accounts", () => { - expect( - teamAvatarUrl(index, { - first_name: "James", - last_name: "Hawkins", - email: "james.hawkins@example.com", - }), - ).toBeNull(); - }); -}); - -describe("teamAvatarThumbUrl", () => { - it("inserts a fill transform into Cloudinary URLs at 2x", () => { - expect( - teamAvatarThumbUrl(`${CLOUDINARY}/v1738943658/James_H.png`, 32), - ).toBe( - `${CLOUDINARY}/c_fill,g_face,w_64,h_64,q_auto/v1738943658/James_H.png`, - ); - }); - - it("passes non-Cloudinary URLs through", () => { - expect(teamAvatarThumbUrl("https://example.com/a.png")).toBe( - "https://example.com/a.png", - ); - }); -}); - -describe("fetchTeamProfiles", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - function stubFetch(routes: Record) { - vi.stubGlobal( - "fetch", - vi.fn(async (url: string) => { - if (!(url in routes)) return { ok: false } as Response; - return { ok: true, json: async () => routes[url] } as Response; - }), - ); - } - - it("uses the known static-query hash when it still resolves", async () => { - stubFetch({ - "https://posthog.com/page-data/sq/d/2290419275.json": rosterPayload([ - { firstName: "James", lastName: "Hawkins" }, - ]), - }); - const profiles = await fetchTeamProfiles(); - expect(profiles).toHaveLength(1); - expect(fetch).toHaveBeenCalledTimes(1); - }); - - it("falls back to scanning the /people manifest hashes", async () => { - stubFetch({ - "https://posthog.com/page-data/people/page-data.json": { - staticQueryHashes: ["111", "222"], - }, - "https://posthog.com/page-data/sq/d/111.json": { data: { other: true } }, - "https://posthog.com/page-data/sq/d/222.json": rosterPayload([ - { firstName: "Raquel", lastName: "Smith" }, - ]), - }); - const profiles = await fetchTeamProfiles(); - expect(profiles.map((p) => p.firstName)).toEqual(["Raquel"]); - }); - - it("resolves to [] when everything is unreachable", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => { - throw new Error("offline"); - }), - ); - await expect(fetchTeamProfiles()).resolves.toEqual([]); - }); -}); diff --git a/packages/core/src/canvas/teamProfiles.ts b/packages/core/src/canvas/teamProfiles.ts deleted file mode 100644 index 07e1b70565..0000000000 --- a/packages/core/src/canvas/teamProfiles.ts +++ /dev/null @@ -1,197 +0,0 @@ -// Profile photos for PostHog staff, sourced from posthog.com/people. -// -// posthog.com is a Gatsby site with no public team API: the roster ships as a -// build-time static-query artifact at page-data/sq/d/.json. The hash is -// a content hash of the site's GraphQL query — stable across deploys but not -// a contract — so we try the known hash first and fall back to scanning the -// hashes the /people page manifest declares. Every failure degrades to "no -// avatar" and callers keep their initials fallback. -// -// The roster carries no email or other machine join key, only names, so -// matching is by normalized name — and only attempted for @posthog.com -// accounts, so namesakes in other orgs never pick up a staff photo. - -const PAGE_DATA_BASE = "https://posthog.com/page-data"; -const KNOWN_TEAM_QUERY_HASH = "2290419275"; - -export interface TeamProfile { - firstName: string; - lastName: string; - avatarUrl: string | null; -} - -/** The `UserBasic` fields matching needs; structural so any user-ish shape fits. */ -export interface TeamAvatarUser { - first_name?: string | null; - last_name?: string | null; - email?: string | null; -} - -export interface TeamAvatarIndex { - byFullName: ReadonlyMap; - /** "first l" — matches app accounts whose last name is a bare initial. */ - byFirstAndInitial: ReadonlyMap; - byFirstName: ReadonlyMap; -} - -function normalizeNamePart(value: string | null | undefined): string { - return (value ?? "") - .normalize("NFKD") - .replace(/\p{M}/gu, "") - .toLowerCase() - .replace(/\s+/g, " ") - .trim(); -} - -function sqQueryUrl(hash: string): string { - return `${PAGE_DATA_BASE}/sq/d/${hash}.json`; -} - -async function fetchJson(url: string): Promise { - try { - const response = await fetch(url); - if (!response.ok) return null; - return (await response.json()) as unknown; - } catch { - return null; - } -} - -function asRecord(value: unknown): Record | null { - return typeof value === "object" && value !== null - ? (value as Record) - : null; -} - -/** - * Extract profiles from a static-query payload. Returns [] when the payload - * isn't the team roster query, which is how the hash scan tells them apart. - */ -export function parseTeamProfiles(payload: unknown): TeamProfile[] { - const team = asRecord(asRecord(asRecord(payload)?.data)?.team); - const members = team?.teamMembers; - if (!Array.isArray(members)) return []; - const profiles: TeamProfile[] = []; - for (const member of members) { - const record = asRecord(member); - if (!record || typeof record.firstName !== "string") continue; - const avatar = asRecord(record.avatar); - // The flat sq/d payload uses avatar.url; Strapi's nested GraphQL shape - // (avatar.data.attributes.url) is handled too in case the query changes. - const nested = asRecord(asRecord(avatar?.data)?.attributes); - const url = avatar?.url ?? nested?.url; - profiles.push({ - firstName: record.firstName, - lastName: typeof record.lastName === "string" ? record.lastName : "", - avatarUrl: typeof url === "string" ? url : null, - }); - } - return profiles; -} - -/** - * Fetch the current roster. Never throws — resolves to [] when the site - * layout has drifted or the network is down. - */ -export async function fetchTeamProfiles(): Promise { - const known = parseTeamProfiles( - await fetchJson(sqQueryUrl(KNOWN_TEAM_QUERY_HASH)), - ); - if (known.length > 0) return known; - - // The known hash broke (site's GraphQL query changed). The page manifest - // lists every static-query hash the /people page uses; the roster query is - // one of them, so probe until a payload parses. - const manifest = asRecord( - await fetchJson(`${PAGE_DATA_BASE}/people/page-data.json`), - ); - const hashes = Array.isArray(manifest?.staticQueryHashes) - ? manifest.staticQueryHashes.filter( - (h): h is string => typeof h === "string", - ) - : []; - for (const hash of hashes) { - if (hash === KNOWN_TEAM_QUERY_HASH) continue; - const profiles = parseTeamProfiles(await fetchJson(sqQueryUrl(hash))); - if (profiles.length > 0) return profiles; - } - return []; -} - -function addKey( - map: Map, - key: string, - url: string, -): void { - if (!key) return; - const existing = map.get(key); - // Two different people sharing a key makes it ambiguous — drop it rather - // than show the wrong face. - if (existing !== undefined && existing !== url) { - map.set(key, null); - return; - } - map.set(key, url); -} - -function settle(map: Map): ReadonlyMap { - const settled = new Map(); - for (const [key, url] of map) { - if (url !== null) settled.set(key, url); - } - return settled; -} - -export function buildTeamAvatarIndex(profiles: TeamProfile[]): TeamAvatarIndex { - const byFullName = new Map(); - const byFirstAndInitial = new Map(); - const byFirstName = new Map(); - for (const profile of profiles) { - if (!profile.avatarUrl) continue; - const first = normalizeNamePart(profile.firstName); - const last = normalizeNamePart(profile.lastName); - if (!first) continue; - if (last) addKey(byFullName, `${first} ${last}`, profile.avatarUrl); - if (last) - addKey(byFirstAndInitial, `${first} ${last[0]}`, profile.avatarUrl); - addKey(byFirstName, first, profile.avatarUrl); - } - return { - byFullName: settle(byFullName), - byFirstAndInitial: settle(byFirstAndInitial), - byFirstName: settle(byFirstName), - }; -} - -/** - * The posthog.com avatar for an app user, or null when there's no confident - * match. A full last name must match exactly; a single-letter last name - * matches by initial; no last name matches by unique first name. - */ -export function teamAvatarUrl( - index: TeamAvatarIndex, - user: TeamAvatarUser | null | undefined, -): string | null { - if (!user?.email?.toLowerCase().endsWith("@posthog.com")) return null; - const first = normalizeNamePart(user.first_name); - if (!first) return null; - const last = normalizeNamePart(user.last_name); - if (last.length > 1) return index.byFullName.get(`${first} ${last}`) ?? null; - if (last.length === 1) { - return index.byFirstAndInitial.get(`${first} ${last}`) ?? null; - } - return index.byFirstName.get(first) ?? null; -} - -/** - * A right-sized Cloudinary thumb of a roster avatar (the originals are - * full-resolution uploads). Non-Cloudinary URLs pass through untouched. - */ -export function teamAvatarThumbUrl(url: string, sizePx = 64): string { - const marker = "/image/upload/"; - const at = url.indexOf(marker); - if (!url.startsWith("https://res.cloudinary.com/") || at === -1) return url; - const px = sizePx * 2; - const insert = `c_fill,g_face,w_${px},h_${px},q_auto/`; - return `${url.slice(0, at + marker.length)}${insert}${url.slice(at + marker.length)}`; -} diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index c0ee20fc96..efe590fb7f 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -880,8 +880,7 @@ export type ChannelActionType = | "mention_member" | "view_activity" | "open_mention" - | "canvas_mode_toggle" - | "load_team_avatars"; + | "canvas_mode_toggle"; export interface ChannelActionProperties { action_type: ChannelActionType; @@ -900,8 +899,6 @@ export interface ChannelActionProperties { suggestion_label?: string; /** For canvas_mode_toggle: whether canvas mode is being armed. */ armed?: boolean; - /** For load_team_avatars: how many posthog.com profiles resolved. */ - profile_count?: number; /** Whether the underlying mutation resolved successfully. */ success?: boolean; } diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 98f9d90ff9..2f1c6c5b87 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -35,6 +35,8 @@ export interface UserBasic { last_name?: string; email: string; is_email_verified?: boolean | null; + /** Profile photo URL; not served by the backend yet, read when it arrives. */ + avatar_url?: string | null; } /** One row from the org members list; trimmed to what mention pickers need. */ diff --git a/packages/ui/src/features/avatars/UserAvatar.tsx b/packages/ui/src/features/avatars/UserAvatar.tsx new file mode 100644 index 0000000000..1afdd79e8a --- /dev/null +++ b/packages/ui/src/features/avatars/UserAvatar.tsx @@ -0,0 +1,30 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@posthog/quill"; +import type { UserBasic } from "@posthog/shared/domain-types"; +import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { useUserAvatar } from "@posthog/ui/features/avatars/useUserAvatar"; + +/** + * A user's avatar: their profile photo when one resolves, initials otherwise + * (and while the photo loads — Gravatar's d=404 means accounts without one + * simply never swap in an image). + */ +export function UserAvatar({ + user, + size, + className, +}: { + user: UserBasic | null | undefined; + size?: "lg" | "default" | "sm" | "xs"; + className?: string; +}) { + const avatarUrl = useUserAvatar(user); + const name = [user?.first_name, user?.last_name].filter(Boolean).join(" "); + return ( + + {avatarUrl && ( + + )} + {getUserInitials(user)} + + ); +} diff --git a/packages/ui/src/features/avatars/useUserAvatar.test.ts b/packages/ui/src/features/avatars/useUserAvatar.test.ts new file mode 100644 index 0000000000..81cd7a9723 --- /dev/null +++ b/packages/ui/src/features/avatars/useUserAvatar.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { gravatarUrl } from "./useUserAvatar"; + +describe("gravatarUrl", () => { + it("hashes the normalized email into a d=404 Gravatar URL", async () => { + const url = await gravatarUrl(" Someone@PostHog.com "); + expect(url).toBe(await gravatarUrl("someone@posthog.com")); + expect(url).toMatch( + /^https:\/\/www\.gravatar\.com\/avatar\/[0-9a-f]{64}\?s=96&d=404$/, + ); + }); +}); diff --git a/packages/ui/src/features/avatars/useUserAvatar.ts b/packages/ui/src/features/avatars/useUserAvatar.ts new file mode 100644 index 0000000000..836ed0e9d1 --- /dev/null +++ b/packages/ui/src/features/avatars/useUserAvatar.ts @@ -0,0 +1,38 @@ +import type { UserBasic } from "@posthog/shared/domain-types"; +import { useQuery } from "@tanstack/react-query"; + +// Same convention as PostHog cloud's ProfilePicture: Gravatar keyed by the +// account email, d=404 so accounts without one fall through to the initials +// fallback. Gravatar accepts SHA-256 hashes, which Web Crypto can compute — +// no md5 dependency needed. +export async function gravatarUrl(email: string): Promise { + // Web Crypto is present on desktop/web but not guaranteed on every mobile + // JS runtime — without it there's just no Gravatar source. + const subtle = globalThis.crypto?.subtle; + if (!subtle) return null; + const bytes = new TextEncoder().encode(email.trim().toLowerCase()); + const digest = await subtle.digest("SHA-256", bytes); + const hash = Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + return `https://www.gravatar.com/avatar/${hash}?s=96&d=404`; +} + +/** + * A user's profile photo URL: the account's own `avatar_url` when the + * backend serves one, Gravatar otherwise. Null while resolving or when the + * user has neither — callers keep their initials fallback. + */ +export function useUserAvatar( + user: UserBasic | null | undefined, +): string | null { + const email = user?.email ?? null; + const { data } = useQuery({ + queryKey: ["gravatar-url", email], + queryFn: () => (email ? gravatarUrl(email) : null), + staleTime: Number.POSITIVE_INFINITY, + retry: false, + refetchOnWindowFocus: false, + }); + return user?.avatar_url ?? data ?? null; +} diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 1ef250d80b..d4e35d82f4 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -13,8 +13,8 @@ import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; +import { UserAvatar } from "@posthog/ui/features/avatars/UserAvatar"; import { MentionText } from "@posthog/ui/features/canvas/components/MentionText"; -import { TeamMemberAvatar } from "@posthog/ui/features/canvas/components/TeamMemberAvatar"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; import { normalizeChannelName } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; @@ -66,7 +66,7 @@ function ActivityRow({ className="flex w-full gap-2 rounded-md px-2 py-2 text-left hover:bg-fill-secondary" > - + {isNew && ( {authors.map((author, index) => ( - + ))} @@ -586,7 +582,7 @@ function SystemFeedRow({ message }: { message: ChannelFeedSystemMessage }) { {message.author ? ( - + ) : ( diff --git a/packages/ui/src/features/canvas/components/MentionComposer.tsx b/packages/ui/src/features/canvas/components/MentionComposer.tsx index ac79335695..b7c2178c49 100644 --- a/packages/ui/src/features/canvas/components/MentionComposer.tsx +++ b/packages/ui/src/features/canvas/components/MentionComposer.tsx @@ -1,7 +1,7 @@ import { RobotIcon } from "@phosphor-icons/react"; import { Avatar, AvatarFallback, InputGroup } from "@posthog/quill"; import type { UserBasic } from "@posthog/shared/domain-types"; -import { TeamMemberAvatar } from "@posthog/ui/features/canvas/components/TeamMemberAvatar"; +import { UserAvatar } from "@posthog/ui/features/avatars/UserAvatar"; import { type ComposerMentionCandidate, contentToDoc, @@ -271,7 +271,7 @@ export function MentionComposer({ ) : ( - - {avatarUrl && ( - - )} - {getUserInitials(user)} - - ); -} diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 8f5a08c41a..1ca944437c 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -53,13 +53,13 @@ import type { import { isTerminalStatus } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; +import { UserAvatar } from "@posthog/ui/features/avatars/UserAvatar"; import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView"; import { MentionComposer } from "@posthog/ui/features/canvas/components/MentionComposer"; import { MentionText, mentionChipClass, } from "@posthog/ui/features/canvas/components/MentionText"; -import { TeamMemberAvatar } from "@posthog/ui/features/canvas/components/TeamMemberAvatar"; import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp"; import { agentTurns } from "@posthog/ui/features/canvas/components/threadAgentTurns"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; @@ -121,7 +121,7 @@ export function ThreadMessageRow({ ) : ( - - + diff --git a/packages/ui/src/features/canvas/hooks/useTeamAvatars.ts b/packages/ui/src/features/canvas/hooks/useTeamAvatars.ts deleted file mode 100644 index 896832aa69..0000000000 --- a/packages/ui/src/features/canvas/hooks/useTeamAvatars.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - buildTeamAvatarIndex, - fetchTeamProfiles, - type TeamAvatarIndex, - type TeamAvatarUser, - teamAvatarUrl, -} from "@posthog/core/canvas/teamProfiles"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { track } from "@posthog/ui/shell/analytics"; -import { useQuery } from "@tanstack/react-query"; - -export const TEAM_AVATARS_QUERY_KEY = ["posthog-team-avatars"] as const; - -// The roster is a static build artifact of posthog.com — one fetch per app -// session is plenty, and a failed fetch (offline, site drift) shouldn't retry -// under every avatar on screen. -const TEAM_AVATARS_GC_MS = 24 * 60 * 60 * 1000; - -// The load event fires once per session, not once per mounted avatar. -let trackedLoad = false; - -function useTeamAvatarIndex(): TeamAvatarIndex | null { - const query = useQuery({ - queryKey: TEAM_AVATARS_QUERY_KEY, - queryFn: async () => { - const profiles = await fetchTeamProfiles(); - if (!trackedLoad) { - trackedLoad = true; - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "load_team_avatars", - surface: "channel_home", - success: profiles.length > 0, - profile_count: profiles.length, - }); - } - return buildTeamAvatarIndex(profiles); - }, - staleTime: Number.POSITIVE_INFINITY, - gcTime: TEAM_AVATARS_GC_MS, - retry: false, - refetchOnWindowFocus: false, - }); - return query.data ?? null; -} - -/** - * The posthog.com/people photo for a teammate, or null while loading / when - * there's no confident name match (callers keep their initials fallback). - */ -export function useTeamAvatarUrl( - user: TeamAvatarUser | null | undefined, -): string | null { - const index = useTeamAvatarIndex(); - return index ? teamAvatarUrl(index, user) : null; -} From c2e40b123fa7f7229088cffdb8d1136f2c3b732c Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 17 Jul 2026 16:31:11 -0400 Subject: [PATCH 3/4] Add profile picture controls to settings New "Profile picture" row in General settings (flag-gated): preview via UserAvatar, set/change with an https image URL, and remove. Saves to the PostHog account through PATCH /api/users/@me/ {avatar_url} (new updateCurrentUserAvatar client method; backend field lands in PostHog/posthog#72086). UserAvatar/useUserAvatar props widen to a structural AvatarUser so the full user object works too. Generated-By: PostHog Code Task-Id: 66aecb98-c55b-4516-a7aa-42c53cfa8bd2 --- packages/api-client/src/posthog-client.ts | 10 ++ .../ui/src/features/avatars/UserAvatar.tsx | 8 +- .../ui/src/features/avatars/useUserAvatar.ts | 11 +- .../settings/components/ProfilePictureRow.tsx | 117 ++++++++++++++++++ .../settings/sections/GeneralSettings.tsx | 3 + 5 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/features/settings/components/ProfilePictureRow.tsx diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index a9c5e72318..730c7f8b5b 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -1399,6 +1399,16 @@ export class PostHogAPIClient { return data; } + /** Update the signed-in user's profile picture; null removes it. */ + async updateCurrentUserAvatar(avatarUrl: string | null): Promise { + await this.api.patch("/api/users/{uuid}/", { + path: { uuid: "@me" }, + // Not in the generated schema yet (the backend field is new); same + // interim shape switchOrganization uses. + body: { avatar_url: avatarUrl } as Record, + }); + } + // Desktop file system — the backend surface that backs canvas channels // (top-level folders) and dashboards. These routes aren't in the generated // OpenAPI client, so we use the raw fetcher. diff --git a/packages/ui/src/features/avatars/UserAvatar.tsx b/packages/ui/src/features/avatars/UserAvatar.tsx index 1afdd79e8a..ad25cba8c8 100644 --- a/packages/ui/src/features/avatars/UserAvatar.tsx +++ b/packages/ui/src/features/avatars/UserAvatar.tsx @@ -1,7 +1,9 @@ import { Avatar, AvatarFallback, AvatarImage } from "@posthog/quill"; -import type { UserBasic } from "@posthog/shared/domain-types"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; -import { useUserAvatar } from "@posthog/ui/features/avatars/useUserAvatar"; +import { + type AvatarUser, + useUserAvatar, +} from "@posthog/ui/features/avatars/useUserAvatar"; /** * A user's avatar: their profile photo when one resolves, initials otherwise @@ -13,7 +15,7 @@ export function UserAvatar({ size, className, }: { - user: UserBasic | null | undefined; + user: AvatarUser | null | undefined; size?: "lg" | "default" | "sm" | "xs"; className?: string; }) { diff --git a/packages/ui/src/features/avatars/useUserAvatar.ts b/packages/ui/src/features/avatars/useUserAvatar.ts index 836ed0e9d1..dc026903f8 100644 --- a/packages/ui/src/features/avatars/useUserAvatar.ts +++ b/packages/ui/src/features/avatars/useUserAvatar.ts @@ -1,6 +1,13 @@ -import type { UserBasic } from "@posthog/shared/domain-types"; import { useQuery } from "@tanstack/react-query"; +/** The user fields avatar resolution needs; `UserBasic` and the full user satisfy it. */ +export interface AvatarUser { + first_name?: string | null; + last_name?: string | null; + email?: string | null; + avatar_url?: string | null; +} + // Same convention as PostHog cloud's ProfilePicture: Gravatar keyed by the // account email, d=404 so accounts without one fall through to the initials // fallback. Gravatar accepts SHA-256 hashes, which Web Crypto can compute — @@ -24,7 +31,7 @@ export async function gravatarUrl(email: string): Promise { * user has neither — callers keep their initials fallback. */ export function useUserAvatar( - user: UserBasic | null | undefined, + user: AvatarUser | null | undefined, ): string | null { const email = user?.email ?? null; const { data } = useQuery({ diff --git a/packages/ui/src/features/settings/components/ProfilePictureRow.tsx b/packages/ui/src/features/settings/components/ProfilePictureRow.tsx new file mode 100644 index 0000000000..aa28be9923 --- /dev/null +++ b/packages/ui/src/features/settings/components/ProfilePictureRow.tsx @@ -0,0 +1,117 @@ +import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { + authKeys, + useCurrentUser, +} from "@posthog/ui/features/auth/useCurrentUser"; +import { UserAvatar } from "@posthog/ui/features/avatars/UserAvatar"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { SettingRow } from "@posthog/ui/features/settings/SettingRow"; +import { toast } from "@posthog/ui/primitives/toast"; +import { track } from "@posthog/ui/shell/analytics"; +import { Button, Flex, TextField } from "@radix-ui/themes"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +/** + * Settings row for the account profile picture: preview, an https image URL + * to set/change it, and removal. Saves to the PostHog account so every client + * (and teammate) sees it. + */ +export function ProfilePictureRow() { + const bluebirdEnabled = useFeatureFlag(PROJECT_BLUEBIRD_FLAG); + const isAuthenticated = useAuthStateValue( + (state) => state.status === "authenticated", + ); + const client = useOptionalAuthenticatedClient(); + const queryClient = useQueryClient(); + const { data: user } = useCurrentUser({ + client, + enabled: isAuthenticated, + }); + // The generated API types don't carry avatar_url yet (new backend field). + const savedUrl = + (user as { avatar_url?: string | null } | undefined)?.avatar_url ?? null; + // null = no unsaved edits; the input shows the saved value. + const [draft, setDraft] = useState(null); + + const mutation = useMutation({ + mutationFn: async (avatarUrl: string | null) => { + if (!client) throw new Error("Not authenticated"); + await client.updateCurrentUserAvatar(avatarUrl); + return avatarUrl; + }, + onSuccess: (avatarUrl) => { + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "avatar_url", + new_value: avatarUrl ? "set" : "removed", + }); + setDraft(null); + queryClient.invalidateQueries({ queryKey: authKeys.currentUsers() }); + toast.success( + avatarUrl ? "Profile picture updated" : "Profile picture removed", + ); + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : "Failed to update profile picture", + ); + }, + }); + + if (!bluebirdEnabled || !user) return null; + + const value = draft ?? savedUrl ?? ""; + const trimmed = value.trim(); + const canSave = + draft !== null && + trimmed !== (savedUrl ?? "") && + (trimmed === "" || trimmed.startsWith("https://")) && + !mutation.isPending; + + return ( + + + + setDraft(event.target.value)} + className="w-[220px]" + /> + + {savedUrl && ( + + )} + + + ); +} diff --git a/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx index bdf56b0ffe..a547941005 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -7,6 +7,7 @@ import { COLLAPSE_MODE_OPTIONS, type CollapseMode, } from "@posthog/ui/features/sessions/components/new-thread/conversationThreadConfig"; +import { ProfilePictureRow } from "@posthog/ui/features/settings/components/ProfilePictureRow"; import { SettingRow } from "@posthog/ui/features/settings/SettingRow"; import { type AutoConvertLongText, @@ -253,6 +254,8 @@ export function GeneralSettings() { )} + {isAuthenticated && } + {/* Appearance */} Appearance From c75d3c98af7f9a58c0a1442d80b9a36d2baeda30 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 17 Jul 2026 18:56:32 -0400 Subject: [PATCH 4/4] Resolve teammate avatars from the org members endpoint The backend now stores profile photos on a UserPersonalization side table exposed via the org members list (and the full /@me/ user), not on UserBasic. useUserAvatar gains a members-index source (shared query key with useOrgMembers, so no extra fetch) between the user's own record and the Gravatar fallback. Generated-By: PostHog Code Task-Id: 66aecb98-c55b-4516-a7aa-42c53cfa8bd2 --- packages/shared/src/domain-types.ts | 4 +- .../features/avatars/useUserAvatar.test.ts | 23 +++++++- .../ui/src/features/avatars/useUserAvatar.ts | 53 +++++++++++++++++-- .../canvas/components/ThreadPanel.test.tsx | 8 ++- 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 2f1c6c5b87..15f4b08693 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -35,14 +35,14 @@ export interface UserBasic { last_name?: string; email: string; is_email_verified?: boolean | null; - /** Profile photo URL; not served by the backend yet, read when it arrives. */ - avatar_url?: string | null; } /** One row from the org members list; trimmed to what mention pickers need. */ export interface OrganizationMemberBasic { id: string; user: UserBasic; + /** The member's profile picture URL (UserPersonalization side table). */ + avatar_url?: string | null; } export interface Task { diff --git a/packages/ui/src/features/avatars/useUserAvatar.test.ts b/packages/ui/src/features/avatars/useUserAvatar.test.ts index 81cd7a9723..3d08e5ae26 100644 --- a/packages/ui/src/features/avatars/useUserAvatar.test.ts +++ b/packages/ui/src/features/avatars/useUserAvatar.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { gravatarUrl } from "./useUserAvatar"; +import { buildMemberAvatarIndex, gravatarUrl } from "./useUserAvatar"; describe("gravatarUrl", () => { it("hashes the normalized email into a d=404 Gravatar URL", async () => { @@ -10,3 +10,24 @@ describe("gravatarUrl", () => { ); }); }); + +describe("buildMemberAvatarIndex", () => { + it("indexes members with avatars by uuid and lowercased email", () => { + const index = buildMemberAvatarIndex([ + { + id: "m1", + user: { id: 1, uuid: "u1", email: "Raquel@PostHog.com" }, + avatar_url: "https://cdn/raquel.png", + }, + { + id: "m2", + user: { id: 2, uuid: "u2", email: "no-avatar@posthog.com" }, + avatar_url: null, + }, + ]); + expect(index.get("u1")).toBe("https://cdn/raquel.png"); + expect(index.get("raquel@posthog.com")).toBe("https://cdn/raquel.png"); + expect(index.has("u2")).toBe(false); + expect(index.size).toBe(2); + }); +}); diff --git a/packages/ui/src/features/avatars/useUserAvatar.ts b/packages/ui/src/features/avatars/useUserAvatar.ts index dc026903f8..a03b062cbe 100644 --- a/packages/ui/src/features/avatars/useUserAvatar.ts +++ b/packages/ui/src/features/avatars/useUserAvatar.ts @@ -1,10 +1,15 @@ +import type { OrganizationMemberBasic } from "@posthog/shared/domain-types"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; /** The user fields avatar resolution needs; `UserBasic` and the full user satisfy it. */ export interface AvatarUser { + uuid?: string; first_name?: string | null; last_name?: string | null; email?: string | null; + /** Present on the full `/api/users/@me/` response, not on `UserBasic`. */ avatar_url?: string | null; } @@ -25,21 +30,59 @@ export async function gravatarUrl(email: string): Promise { return `https://www.gravatar.com/avatar/${hash}?s=96&d=404`; } +/** uuid and lowercased email → avatar URL, for members who have set one. */ +export function buildMemberAvatarIndex( + members: OrganizationMemberBasic[], +): ReadonlyMap { + const index = new Map(); + for (const member of members) { + if (!member.avatar_url) continue; + if (member.user?.uuid) index.set(member.user.uuid, member.avatar_url); + if (member.user?.email) { + index.set(member.user.email.toLowerCase(), member.avatar_url); + } + } + return index; +} + +// Shared with useOrgMembers (same key → one fetch); membership churn is slow. +const ORG_MEMBERS_QUERY_KEY = ["org-members"] as const; +const ORG_MEMBERS_STALE_MS = 5 * 60_000; + +function useMemberAvatarIndex(): ReadonlyMap | null { + const query = useAuthenticatedQuery( + ORG_MEMBERS_QUERY_KEY, + (client) => client.listOrganizationMembers(), + { staleTime: ORG_MEMBERS_STALE_MS }, + ); + const members = query.data; + return useMemo( + () => (members ? buildMemberAvatarIndex(members) : null), + [members], + ); +} + /** - * A user's profile photo URL: the account's own `avatar_url` when the - * backend serves one, Gravatar otherwise. Null while resolving or when the - * user has neither — callers keep their initials fallback. + * A user's profile photo URL: their own record's `avatar_url` when present, + * their org-member personalization otherwise, Gravatar as the last source. + * Null while resolving or when the user has none — callers keep their + * initials fallback. */ export function useUserAvatar( user: AvatarUser | null | undefined, ): string | null { + const memberIndex = useMemberAvatarIndex(); const email = user?.email ?? null; - const { data } = useQuery({ + const { data: gravatar } = useQuery({ queryKey: ["gravatar-url", email], queryFn: () => (email ? gravatarUrl(email) : null), staleTime: Number.POSITIVE_INFINITY, retry: false, refetchOnWindowFocus: false, }); - return user?.avatar_url ?? data ?? null; + if (!user) return null; + const fromMembers = + (user.uuid ? memberIndex?.get(user.uuid) : undefined) ?? + (email ? memberIndex?.get(email.toLowerCase()) : undefined); + return user.avatar_url ?? fromMembers ?? gravatar ?? null; } diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx index 7f0c06d46d..d368a2315e 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx @@ -2,7 +2,13 @@ import type { ConversationItem } from "@posthog/ui/features/sessions/components/ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; import type { ReactElement } from "react"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +// Avatar resolution reaches for tRPC/auth providers these row tests don't +// mount; rows under test only need the initials fallback. +vi.mock("@posthog/ui/features/avatars/useUserAvatar", () => ({ + useUserAvatar: () => null, +})); import { AgentStatusLine, ThreadMessageRow,