Skip to content
Draft
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
10 changes: 10 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<string, unknown>,
});
}

// 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.
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/domain-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions packages/ui/src/features/avatars/UserAvatar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Avatar size={size} className={className}>
{avatarUrl && (
<AvatarImage src={avatarUrl} alt={name || user?.email || ""} />
)}
<AvatarFallback>{getUserInitials(user)}</AvatarFallback>
</Avatar>
);
}
33 changes: 33 additions & 0 deletions packages/ui/src/features/avatars/useUserAvatar.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
88 changes: 88 additions & 0 deletions packages/ui/src/features/avatars/useUserAvatar.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
// 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<string, string> {
const index = new Map<string, string>();
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<string, string> | 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;
}
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 @@ -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";
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
22 changes: 10 additions & 12 deletions packages/ui/src/features/canvas/components/ChannelFeedView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
} from "@posthog/quill";
import { formatRelativeTimeShort, getLocalDayDiff } from "@posthog/shared";
import type { Task, TaskRunStatus } from "@posthog/shared/domain-types";
import { getUserInitials } from "@posthog/ui/features/auth/userInitials";
import { UserAvatar } from "@posthog/ui/features/avatars/UserAvatar";
import { TaskTabIcon } from "@posthog/ui/features/browser-tabs/TaskTabIcon";
import { mentionChipClass } from "@posthog/ui/features/canvas/components/MentionText";
import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages";
Expand Down Expand Up @@ -396,9 +396,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 @@ -583,15 +581,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
22 changes: 13 additions & 9 deletions packages/ui/src/features/canvas/components/MentionComposer.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -264,15 +264,19 @@ export function MentionComposer({
index === highlightedIndex ? "bg-[var(--accent-a4)]" : ""
}`}
>
<Avatar size="xs" className="shrink-0">
<AvatarFallback>
{candidate.kind === "agent" ? (
{candidate.kind === "agent" ? (
<Avatar size="xs" className="shrink-0">
<AvatarFallback>
<RobotIcon size={12} />
) : (
getUserInitials(candidate.member)
)}
</AvatarFallback>
</Avatar>
</AvatarFallback>
</Avatar>
) : (
<UserAvatar
user={candidate.member}
size="xs"
className="shrink-0"
/>
)}
<span className="truncate font-medium text-xs">
{candidate.kind === "agent"
? "Agent"
Expand Down
31 changes: 25 additions & 6 deletions packages/ui/src/features/canvas/components/ThreadPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,32 @@
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,
UserPromptRow,
} 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(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
);
}

describe("agentTurns", () => {
it("accumulates every text chunk in one agent turn", () => {
const items = [
Expand Down Expand Up @@ -51,7 +70,7 @@ describe("AgentStatusLine", () => {

describe("ThreadMessageRow", () => {
it("renders backend-authored agent announcements as Agent", () => {
render(
renderRow(
<ThreadMessageRow
message={{
id: "announcement",
Expand All @@ -76,7 +95,7 @@ describe("ThreadMessageRow", () => {
});

it("renders system announcements as System without human actions", () => {
render(
renderRow(
<ThreadMessageRow
message={{
id: "system-announcement",
Expand All @@ -103,7 +122,7 @@ describe("ThreadMessageRow", () => {
});

it("keeps legacy authorless rows as human messages", () => {
render(
renderRow(
<ThreadMessageRow
message={{
id: "legacy-message",
Expand All @@ -129,7 +148,7 @@ describe("ThreadMessageRow", () => {

describe("UserPromptRow", () => {
it("prefixes direct task prompts with @agent", () => {
render(
renderRow(
<UserPromptRow
message={{ id: "prompt", text: "Investigate this", timestamp: 1 }}
author={{ id: 1, uuid: "user", email: "user@example.com" }}
Expand All @@ -141,7 +160,7 @@ describe("UserPromptRow", () => {
});

it("hides forwarded thread attribution and duplicate agent mentions", () => {
render(
renderRow(
<UserPromptRow
message={{
id: "prompt",
Expand Down
Loading
Loading