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/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 98f9d90ff9..15f4b08693 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -41,6 +41,8 @@ export interface UserBasic { 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/UserAvatar.tsx b/packages/ui/src/features/avatars/UserAvatar.tsx new file mode 100644 index 0000000000..ad25cba8c8 --- /dev/null +++ b/packages/ui/src/features/avatars/UserAvatar.tsx @@ -0,0 +1,32 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@posthog/quill"; +import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { + type AvatarUser, + 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: AvatarUser | 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..3d08e5ae26 --- /dev/null +++ b/packages/ui/src/features/avatars/useUserAvatar.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { buildMemberAvatarIndex, 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$/, + ); + }); +}); + +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 new file mode 100644 index 0000000000..a03b062cbe --- /dev/null +++ b/packages/ui/src/features/avatars/useUserAvatar.ts @@ -0,0 +1,88 @@ +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; +} + +// 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`; +} + +/** 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: 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: gravatar } = useQuery({ + queryKey: ["gravatar-url", email], + queryFn: () => (email ? gravatarUrl(email) : null), + staleTime: Number.POSITIVE_INFINITY, + retry: false, + refetchOnWindowFocus: false, + }); + 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/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 17fe071e67..d4e35d82f4 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,7 +13,7 @@ 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 { UserAvatar } from "@posthog/ui/features/avatars/UserAvatar"; 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)} - + ))} @@ -583,15 +581,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..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 { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { UserAvatar } from "@posthog/ui/features/avatars/UserAvatar"; 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/ThreadPanel.test.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx index 89aca9895a..d368a2315e 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx @@ -1,6 +1,14 @@ 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 { describe, expect, it } from "vitest"; +import type { ReactElement } from "react"; +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, @@ -8,6 +16,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 +70,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/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