import { useMemo, type CSSProperties } from "react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "../ui/tooltip.js"; import { type CollabUser, dedupeCollabUsersByEmail, emailToColor, emailToName, } from "./types.js"; export interface PresenceBarProps { /** Active collaborators on this document. */ activeUsers: CollabUser[]; /** Whether the agent has a durable presence entry. */ agentPresent?: boolean; /** Whether the agent is actively making edits right now. */ agentActive?: boolean; /** Current user's email (to exclude from the list). */ currentUserEmail?: string; /** Max visible avatars before "+N" overflow. Default: 5 */ maxVisible?: number; /** Additional CSS classes. */ className?: string; /** * Called when an avatar is clicked. Receives the user being clicked * (or null for the agent avatar). Use this to start/stop follow mode. */ onAvatarClick?: (user: CollabUser | null) => void; /** * The email of the user currently being followed. Highlighted with a * blue ring to indicate active follow mode. */ followingEmail?: string | null; } const AVATAR_SIZE = 28; const OVERLAP = -8; const BORDER_WIDTH = 2; const FONT_SIZE = 12; const AGENT_COLOR = "#00B5FF"; const baseAvatarStyle: CSSProperties = { width: AVATAR_SIZE, height: AVATAR_SIZE, borderRadius: "50%", display: "flex", alignItems: "center", justifyContent: "center", fontSize: FONT_SIZE, fontWeight: 700, color: "#fff", border: `${BORDER_WIDTH}px solid #fff`, flexShrink: 0, position: "relative", cursor: "default", boxSizing: "border-box", }; const containerStyle: React.CSSProperties = { display: "flex", alignItems: "center", flexDirection: "row", }; const pulseKeyframes = ` @keyframes _anPresencePulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } `; let styleInjected = false; function injectStyles() { if (styleInjected || typeof document === "undefined") return; const style = document.createElement("style"); style.textContent = pulseKeyframes; document.head.appendChild(style); styleInjected = true; } function UserAvatar({ user, isFirst, onClick, isFollowing, }: { user: CollabUser; isFirst: boolean; onClick?: () => void; isFollowing?: boolean; }) { const color = user.color || emailToColor(user.email); const name = user.name || emailToName(user.email); const initial = name.charAt(0).toUpperCase(); return (
{ if (e.key === "Enter" || e.key === " ") onClick?.(); }} > {user.avatarUrl ? ( ) : ( initial )}
{isFollowing ? `Following ${name} — click to stop` : onClick ? `${name} — click to follow` : user.email}
); } function AgentAvatar({ active, onClick, isFollowing, }: { active: boolean; onClick?: () => void; isFollowing?: boolean; }) { injectStyles(); return (
{ if (e.key === "Enter" || e.key === " ") onClick?.(); }} role={onClick ? "button" : undefined} > A
{active && !isFollowing && } {isFollowing && ( Following AI )}
); } function AgentEditingChip() { return ( AI editing ); } function OverflowBadge({ count, isFirst, }: { count: number; isFirst: boolean; }) { return (
+{count}
); } export function PresenceBar({ activeUsers, agentPresent, agentActive, currentUserEmail, maxVisible = 5, className, onAvatarClick, followingEmail, }: PresenceBarProps) { const { humanUsers, showAgent } = useMemo(() => { const currentEmail = currentUserEmail?.trim().toLowerCase(); const uniqueUsers = dedupeCollabUsersByEmail(activeUsers); const humans = uniqueUsers.filter((u) => { const email = u.email.trim().toLowerCase(); return email !== currentEmail && email !== "agent@system"; }); const hasAgentUser = uniqueUsers.some( (u) => u.email.trim().toLowerCase() === "agent@system", ); return { humanUsers: humans, showAgent: agentPresent || agentActive || hasAgentUser, }; }, [activeUsers, currentUserEmail, agentPresent, agentActive]); const visibleUsers = humanUsers.slice(0, maxVisible); const overflowCount = humanUsers.length - visibleUsers.length; if (!showAgent && humanUsers.length === 0) return null; const followingLower = followingEmail?.trim().toLowerCase() ?? null; const isFollowingAgent = followingLower === "agent@system"; return (
{showAgent && ( onAvatarClick(null) : undefined} isFollowing={isFollowingAgent} /> )} {visibleUsers.length > 0 && (
{visibleUsers.map((u, i) => ( onAvatarClick(u) : undefined} isFollowing={ followingLower != null && u.email.trim().toLowerCase() === followingLower } /> ))} {overflowCount > 0 && ( )}
)}
); }