1"use client";
2
3
25import { useEffect, useRef, useState } from "react";
26import { initials } from "@factory/ui";
27import { cn } from "@/lib/utils";
28import { presenceColors } from "@/lib/presence-color";
29import type { Presence } from "@/lib/types";
30
31export interface PresenceBarProps {
32
33 peers: readonly Presence[];
34
35 selfUserId?: string | null;
36
38 blockTypeFor?: (blockId: string) => string | null;
39
40 maxVisible?: number;
41 className?: string;
42}
43
44
45function describe(peer: Presence, blockTypeFor?: (blockId: string) => string | null): string {
46 if (peer.viewing === false) return "viewing";
47 if (peer.cursor) {
48 const type = blockTypeFor?.(peer.cursor.blockId);
49 return type ? `editing ${type}` : "editing";
50 }
51 return "viewing";
52}
53
54function AvatarDot({ peer }: { peer: Presence }) {
55 const colors = presenceColors(peer.color);
56 return (
57 <span
58 role="img"
59 aria-label={`${peer.name} is viewing this page`}
60 title={`${peer.name} — ${describe(peer)}`}
61 className="relative inline-flex h-6 w-6 shrink-0 select-none items-center justify-center rounded-full text-[10px] font-semibold ring-1"
62 style={{
63 backgroundColor: colors.softBg,
64 color: colors.solid,
65 boxShadow: `0 0 0 1px ${colors.solid}55`,
66 }}
67 >
68 {initials(peer.name) || "?"}
69 </span>
70 );
71}
72
73export function PresenceBar({
74 peers,
75 selfUserId,
76 blockTypeFor,
77 maxVisible = 5,
78 className,
79}: PresenceBarProps) {
80
81 const others = selfUserId ? peers.filter((p) => p.userId !== selfUserId) : peers;
82 const [overflowOpen, setOverflowOpen] = useState(false);
83 const overflowRef = useRef<HTMLDivElement>(null);
84
85
86 useEffect(() => {
87 if (!overflowOpen) return;
88 const onDown = (e: MouseEvent): void => {
89 if (overflowRef.current && !overflowRef.current.contains(e.target as Node))
90 setOverflowOpen(false);
91 };
92 const onKey = (e: KeyboardEvent): void => {
93 if (e.key === "Escape") setOverflowOpen(false);
94 };
95 document.addEventListener("mousedown", onDown);
96 document.addEventListener("keydown", onKey);
97 return () => {
98 document.removeEventListener("mousedown", onDown);
99 document.removeEventListener("keydown", onKey);
100 };
101 }, [overflowOpen]);
102
103
104 if (others.length === 0) return null;
105
106 const visible = others.slice(0, maxVisible);
107 const overflow = others.slice(maxVisible);
108 const count = others.length;
109 const label = `${count} ${count === 1 ? "person" : "people"} ${count === 1 ? "is" : "are"} viewing this page`;
110
111 return (
112 <div className={cn("relative inline-flex items-center", className)} aria-label={label}>
113 {}
117 <ul className="flex items-center -space-x-1.5">
118 {visible.map((p) => (
119 <li key={p.userId}>
120 <AvatarDot peer={p} />
121 </li>
122 ))}
123 </ul>
124
125 {overflow.length > 0 ? (
126 <div className="relative" ref={overflowRef}>
127 <button
128 type="button"
129 onClick={() => setOverflowOpen((v) => !v)}
130 aria-haspopup="true"
131 aria-expanded={overflowOpen}
132 aria-label={`${overflow.length} more people are viewing this page`}
133 className="-ml-1.5 inline-flex h-6 min-w-6 items-center justify-center rounded-full border border-border bg-muted px-1 text-[10px] font-semibold text-muted-foreground hover:bg-accent"
134 >
135 +{overflow.length}
136 </button>
137
138 {overflowOpen ? (
139 <div
140 role="menu"
141 aria-label="People viewing this page"
142 className="absolute right-0 top-7 z-50 w-56 rounded-lg border border-border bg-popover p-1 text-sm shadow-md"
143 >
144 <p className="px-2 py-1 text-xs font-medium text-muted-foreground">{count} viewing</p>
145 <ul>
146 {others.map((p) => (
147 <li
148 key={p.userId}
149 className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-accent/60"
150 >
151 <AvatarDot peer={p} />
152 <span className="min-w-0 flex-1">
153 <span className="block truncate font-medium">{p.name}</span>
154 <span className="block text-xs text-muted-foreground">
155 {describe(p, blockTypeFor)}
156 </span>
157 </span>
158 </li>
159 ))}
160 </ul>
161 </div>
162 ) : null}
163 </div>
164 ) : null}
165 </div>
166 );
167}
168