Files
multica/apps/mobile/components/ui/actor-avatar.tsx
Jiayuan Zhang c6fb2c5c50 feat(agents): assign emoji avatars by default (#5764)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-22 16:38:50 +08:00

204 lines
6.8 KiB
TypeScript

/**
* Mobile ActorAvatar. Mirrors the role of packages/views/common/actor-avatar.tsx
* (member/agent → avatar URL or initials chip), stripped down for phone use:
* no hover card, no nested focus management.
*
* Behavioral parity rules (apps/mobile/CLAUDE.md):
* - Same actor type → same name → same initials. Lookup is shared via
* useActorLookup which reads the same MemberWithUser / Agent lists.
* - Agents get distinct visual treatment (brand-tinted background) to
* match web's "agents render with distinct styling" rule from the
* repo-root CLAUDE.md "Agent Assignees" section.
*
* Presence dot: opt-in via `showPresence`. Mirrors web's `showStatusDot`
* (`packages/views/common/actor-avatar.tsx:51`). The prop is opt-in (default
* false) because the dot mounts `useAgentPresence` — three queries +
* 30s wall-clock tick — and we don't want every comment-author thumbnail
* subscribing to that.
*/
import { Image, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useColorScheme } from "nativewind";
import { Text } from "@/components/ui/text";
import { cn } from "@/lib/utils";
import { useActorLookup, getInitials } from "@/data/use-actor-name";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useAgentPresence } from "@/lib/use-agent-presence";
import { PresenceDot } from "@/components/ui/presence-dot";
import { THEME } from "@/lib/theme";
// `system` actors are server-side automation (state changes triggered by the
// platform itself, not a member or an agent). InboxItem.actor_type carries
// this third value (packages/core/types/inbox.ts:28). `squad` is a third
// assignee polymorph (packages/core/types/issue.ts IssueAssigneeType) — when
// a squad has an avatar_url we render it; otherwise fall back to a generic
// group glyph so squad-assigned issues from web never render blank.
interface Props {
type: "member" | "agent" | "system" | "squad" | null | undefined;
id: string | null | undefined;
size?: number;
/**
* Overlay a 3-state presence dot at the bottom-right corner. No-op for
* non-agent actors. Opt-in to keep useAgentPresence — and its three
* subscriptions — off thumbnails that don't need it.
*/
showPresence?: boolean;
}
export function ActorAvatar({ type, id, size = 32, showPresence }: Props) {
const avatar = <BareAvatar type={type} id={id} size={size} />;
if (!showPresence || type !== "agent" || !id) {
return avatar;
}
return <AgentAvatarWithPresence id={id} size={size}>{avatar}</AgentAvatarWithPresence>;
}
// Pure avatar render — no presence subscription, no workspace lookup. Kept
// separate so non-agent avatars and `showPresence=false` agent avatars do
// zero presence work.
function BareAvatar({
type,
id,
size,
}: {
type: Props["type"];
id: Props["id"];
size: number;
}) {
const { getName, getAvatarUrl } = useActorLookup();
const { colorScheme } = useColorScheme();
// Ionicons takes a hex string, not a className — go through THEME so the
// glyph follows light/dark instead of locking to a single hardcoded zinc.
const iconColor =
colorScheme === "dark"
? THEME.dark.mutedForeground
: THEME.light.mutedForeground;
// Squad gets a soft-square tile (matches web actor-avatar.tsx:42 which uses
// rounded-md) so a group never reads as a single person at a glance.
// Everyone else stays round.
const radius = type === "squad" ? Math.round(size * 0.22) : size / 2;
// URL lookup runs BEFORE the squad/system icon fallbacks so a squad with
// an avatar_url renders its image instead of the generic group glyph.
// Squad.avatar_url exists (packages/core/types/squad.ts) and useActorLookup
// already returns it — the previous early-return for type==="squad" meant
// that value was silently dropped.
// Only treat a URL as renderable if it actually looks like one — RN <Image>
// can crash native-side on malformed sources (empty string, plain "foo",
// etc.). Cheap regex; falsy / bad input falls through to the icon fallback.
const rawUrl = type && type !== "system" ? getAvatarUrl(type, id) : null;
const emoji = rawUrl?.startsWith("emoji:")
? rawUrl.slice("emoji:".length).trim() || null
: null;
const url =
!emoji && rawUrl && /^(https?:|data:|file:|asset:)/.test(rawUrl)
? rawUrl
: null;
if (emoji) {
return (
<View
style={{ width: size, height: size, borderRadius: radius }}
className="items-center justify-center bg-muted"
>
<Text
accessibilityLabel={type === "system" ? "" : getName(type, id)}
style={{ fontSize: Math.round(size * 0.58), lineHeight: size }}
>
{emoji}
</Text>
</View>
);
}
if (url) {
return (
<Image
source={{ uri: url }}
style={{ width: size, height: size, borderRadius: radius }}
className="bg-muted"
/>
);
}
if (type === "system") {
return (
<View
style={{ width: size, height: size, borderRadius: radius }}
className="items-center justify-center bg-muted"
>
<Ionicons name="cog" size={Math.round(size * 0.55)} color={iconColor} />
</View>
);
}
if (type === "squad") {
return (
<View
style={{ width: size, height: size, borderRadius: radius }}
className="items-center justify-center bg-muted"
>
<Ionicons name="people" size={Math.round(size * 0.55)} color={iconColor} />
</View>
);
}
const name = getName(type, id);
const isAgent = type === "agent";
return (
<View
style={{ width: size, height: size, borderRadius: radius }}
className={cn(
"items-center justify-center",
isAgent ? "bg-brand/15" : "bg-muted",
)}
>
<Text
className={cn(
"text-xs font-medium",
isAgent ? "text-brand" : "text-muted-foreground",
)}
>
{getInitials(name)}
</Text>
</View>
);
}
// Wraps an agent avatar in a `relative` container with a corner dot. The
// dot is suppressed while presence is still loading so the avatar never
// flashes a speculative "offline" gray before the queries resolve.
function AgentAvatarWithPresence({
id,
size,
children,
}: {
id: string;
size: number;
children: React.ReactNode;
}) {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const detail = useAgentPresence(wsId, id);
// Match web's size threshold (packages/views/common/actor-avatar.tsx:194).
const dotSize = size >= 24 ? 8 : 6;
return (
<View
style={{ width: size, height: size }}
className="relative"
>
{children}
{detail !== "loading" && (
<View
style={{ position: "absolute", bottom: -1, right: -1 }}
pointerEvents="none"
>
<PresenceDot availability={detail.availability} size={dotSize} />
</View>
)}
</View>
);
}