-
-
Notifications
You must be signed in to change notification settings - Fork 628
Expand file tree
/
Copy pathCollaborationStatusBadge.tsx
More file actions
482 lines (462 loc) · 21.9 KB
/
Copy pathCollaborationStatusBadge.tsx
File metadata and controls
482 lines (462 loc) · 21.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
import { useAppStore } from "@geolibre/core";
import { Button, Input } from "@geolibre/ui";
import type { MapController } from "@geolibre/map";
import { ChevronUp, MapPin, Send, Settings2, Users, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { RefObject } from "react";
import type { CollaborationApi } from "../../hooks/useCollaboration";
import { CollaborationParticipantRow } from "./CollaborationParticipantRow";
interface Announcement {
id: number;
text: string;
}
interface CollaborationStatusBadgeProps {
api: CollaborationApi;
mapControllerRef: RefObject<MapController | null>;
}
// How long a join/leave announcement stays on screen before auto-dismissing.
const ANNOUNCEMENT_TTL_MS = 5000;
// Client-side chat send floor, matching the relay's per-socket limit, so a
// rapid second send is refused locally (keeping its draft) rather than sent and
// silently dropped by the relay.
const MIN_CHAT_SEND_INTERVAL_MS = 250;
/**
* Persistent on-canvas badge that surfaces a live collaboration session outside
* the Collaborate dialog (#754). It shows only while a session is active and:
*
* - displays a pulsing "live" dot plus the connected-participant count, so the
* host knows the session is still running after dismissing the dialog;
* - expands into a roster of connected clients with a shortcut back to the full
* Collaborate dialog (reopened via the store so this badge can drive it from
* outside the toolbar tree);
* - briefly announces when someone joins or leaves.
*
* Anchored bottom-left (top-left is where map-control plugins cluster), lifted
* above the MapLibre scale control (and the bounds-restriction badge when it is
* showing); the roster and announcements grow upward from the collapsed pill.
*/
export function CollaborationStatusBadge({ api, mapControllerRef }: CollaborationStatusBadgeProps) {
const { t } = useTranslation();
// Narrow selectors rather than the whole `collaboration` slice: remote cursor
// presence updates that slice many times per second, and subscribing to the
// whole object would re-render this badge on every pointer move even though it
// never renders presence.
const isActive = useAppStore((s) => s.collaboration.isActive);
const connecting = useAppStore((s) => s.collaboration.connecting);
const participants = useAppStore((s) => s.collaboration.participants);
// `clientId` is typed `string | null`; fall back to an empty-string sentinel
// so the `id !== selfId` self-filtering below never treats a null id as "not
// me" and announces the local user joining. Server client ids are UUIDs, so
// "" can never collide with a real participant.
const selfId = useAppStore((s) => s.collaboration.clientId) ?? "";
// Narrow selectors (see above): role/mode gate the host-only permission
// toggles; chat drives the message drawer. None update on cursor moves.
const role = useAppStore((s) => s.collaboration.role);
const mode = useAppStore((s) => s.collaboration.mode);
// Default to [] defensively: a relay that predates the chat protocol can send
// a `welcome` without `chat`, and an undefined slice would crash this badge
// (and, since it renders inside the map's error boundary, the whole map).
const chat = useAppStore((s) => s.collaboration.chat) ?? [];
const isHost = role === "host";
const setCollaborateDialogOpen = useAppStore((s) => s.setCollaborateDialogOpen);
// Shares the bottom-left corner with the MapLibre scale control and the
// bounds-restriction badge, so lift the badge above whichever of those is
// showing (see the positioning note below). The KnowledgeCardPanel also opens
// in this corner, but as a large transient z-20 panel it overlays this z-10
// badge while open rather than participating in this stacking.
const restrictBounds = useAppStore((s) => s.preferences.map.restrictBounds);
const [expanded, setExpanded] = useState(false);
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
// Chat composer: the draft text and whether to attach the current map center
// (#754, Part 4). The pin captures the center at send time, not toggle time.
const [draft, setDraft] = useState("");
const [attachLocation, setAttachLocation] = useState(false);
// Epoch-ms of the last accepted local send, for the client-side floor above.
const lastSentAtRef = useRef(0);
// The scroll viewport for the message list, so new messages pin to the bottom.
const chatScrollRef = useRef<HTMLDivElement | null>(null);
// Unread chat count while the roster is collapsed, surfaced on the pill so a
// working host notices new messages without keeping the panel open. Tracked by
// the last-seen message id (not the array length): the store keeps a bounded
// tail, so once it is full new messages arrive without changing the length.
const [unread, setUnread] = useState(0);
// `undefined` = not yet initialised for this session; `null` = initialised
// against an empty chat. These must be distinct so the very first message in
// an empty session is still counted as unread (a single `null` sentinel would
// re-seed on that first message and swallow it).
const lastSeenChatIdRef = useRef<string | null | undefined>(undefined);
// The participant set from the previous update, so we can diff join/leave.
// Seeded on first activation so existing members (and self) aren't announced
// as fresh arrivals when the dialog first connects.
const knownRef = useRef<Map<string, string> | null>(null);
const announceIdRef = useRef(0);
const timersRef = useRef<number[]>([]);
// The previous render's selfId. A transparent reconnect can rotate clientId
// while isActive stays true; without remembering the old id, the diff below
// would see the old self-id drop out of the roster and announce "you left".
const prevSelfIdRef = useRef(selfId);
// Clear any pending auto-dismiss timers on unmount.
useEffect(
() => () => {
for (const id of timersRef.current) window.clearTimeout(id);
timersRef.current = [];
},
[],
);
useEffect(() => {
if (!isActive) {
// Session ended: cancel any pending auto-dismiss timers from the previous
// session and reset so the next one starts clean (and we don't announce
// its initial roster against a stale previous one).
for (const id of timersRef.current) window.clearTimeout(id);
timersRef.current = [];
knownRef.current = null;
setExpanded(false);
setAnnouncements([]);
// Reset the chat composer so a fresh session starts clean.
setDraft("");
setAttachLocation(false);
setUnread(0);
lastSeenChatIdRef.current = undefined;
return;
}
const current = new Map(participants.map((p) => [p.clientId, p.displayName]));
const known = knownRef.current;
knownRef.current = current;
const prevSelfId = prevSelfIdRef.current;
prevSelfIdRef.current = selfId;
// First roster for this session: seed silently. This also doubles as a
// buffer for the live region below, which only mounts while a session is
// active: this early return guarantees at least one render with the region
// present-but-empty before any announcement text is injected, so assistive
// tech (notably JAWS, which registers live regions at mount) has discovered
// it first.
if (known === null) return;
const fresh: Announcement[] = [];
for (const [id, name] of current) {
if (id !== selfId && !known.has(id)) {
fresh.push({
id: announceIdRef.current++,
text: t("collaborate.participantJoined", { name }),
});
}
}
for (const [id, name] of known) {
// Skip both the current and previous self-id so a clientId rotation
// during a transparent reconnect doesn't announce "you left".
if (id === selfId || id === prevSelfId) continue;
if (!current.has(id)) {
fresh.push({
id: announceIdRef.current++,
text: t("collaborate.participantLeft", { name }),
});
}
}
if (fresh.length === 0) return;
setAnnouncements((prev) => [...prev, ...fresh]);
for (const a of fresh) {
const timer = window.setTimeout(() => {
setAnnouncements((prev) => prev.filter((x) => x.id !== a.id));
// Drop the fired timer so the array stays bounded over a long session.
timersRef.current = timersRef.current.filter((id) => id !== timer);
}, ANNOUNCEMENT_TTL_MS);
timersRef.current.push(timer);
}
}, [isActive, participants, selfId, t]);
// Let Escape close the expanded roster, matching the dismissal convention of
// dialogs and popovers elsewhere. Click-outside is intentionally not wired up:
// the panel floats over the map, which users click constantly, so dismissing
// on every map interaction would be more annoying than helpful.
useEffect(() => {
if (!expanded) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setExpanded(false);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [expanded]);
// The latest message's id; effects key on this (not chat.length) so a full,
// bounded tail still notices new arrivals.
const lastMessageId = chat.length ? chat[chat.length - 1].id : null;
// Track unread chat while the panel is collapsed; clear it (and remember the
// latest message id) whenever the panel is open, so the badge counts only
// messages that arrived while the host wasn't looking. The first collapsed
// render of a session seeds the baseline so welcome history isn't unread.
useEffect(() => {
if (!isActive) return;
if (expanded || lastSeenChatIdRef.current === undefined) {
lastSeenChatIdRef.current = lastMessageId;
setUnread(0);
return;
}
const seenIdx = chat.findIndex((m) => m.id === lastSeenChatIdRef.current);
// If the last-seen message has aged out of the tail, every retained message
// is newer than it.
setUnread(seenIdx === -1 ? chat.length : chat.length - seenIdx - 1);
}, [isActive, expanded, lastMessageId, chat]);
// Keep the message list pinned to the latest message while the panel is open.
// On open, always jump to the bottom (show the latest). For a message that
// arrives while it is already open, only auto-scroll when the user is at (or
// near) the bottom — otherwise a new message would yank them away from history
// they scrolled up to read.
const prevExpandedRef = useRef(false);
useEffect(() => {
if (!expanded) {
prevExpandedRef.current = false;
return;
}
const el = chatScrollRef.current;
if (!el) return;
const justOpened = !prevExpandedRef.current;
prevExpandedRef.current = true;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
if (justOpened || nearBottom) el.scrollTop = el.scrollHeight;
}, [expanded, lastMessageId]);
const handleSendChat = () => {
const text = draft.trim();
// Drop sends while reconnecting: api.sendChat silently no-ops when the
// socket isn't open, so clearing the draft here would lose the typed text.
if (!text || connecting) return;
// Mirror the relay's per-socket chat floor client-side: if two sends land
// within the window the relay would silently drop the second, so refuse it
// here WITHOUT clearing the draft, rather than clearing optimistically and
// losing the text. (Human-paced typing never hits this; double-Enter does.)
const now = Date.now();
if (now - lastSentAtRef.current < MIN_CHAT_SEND_INTERVAL_MS) return;
// Capture the live map center only when the pin is active, at send time.
const center =
attachLocation && mapControllerRef.current
? mapControllerRef.current.getMap()?.getCenter()
: null;
const sent = api.sendChat(text, center ? { lng: center.lng, lat: center.lat } : null);
// Only clear the composer (and stamp the floor) when the message actually
// reached an open socket; otherwise keep the draft so a transient
// disconnect doesn't lose it.
if (!sent) return;
lastSentAtRef.current = now;
setDraft("");
setAttachLocation(false);
};
const flyToCoordinate = (coordinate: { lng: number; lat: number }) => {
mapControllerRef.current?.getMap()?.flyTo({ center: [coordinate.lng, coordinate.lat] });
};
if (!isActive) return null;
return (
<div
className={`pointer-events-none absolute left-2 z-10 flex w-60 max-w-[calc(100%-1rem)] flex-col gap-1.5 ${
// Clear the bottom-left scale control (~32px tall); when the
// bounds-restriction badge (bottom-12) is also showing, sit above it
// instead of overlapping. The roster/announcements grow upward.
restrictBounds ? "bottom-20" : "bottom-10"
}`}
>
{/* Transient join/leave announcements, stacked just above the pill. The
live region stays mounted at all times (even when empty) and only its
content changes, so screen readers reliably pick up each insertion.
role="log" (aria-atomic="false") announces only the newly added entry,
rather than re-reading the whole region the way role="status" would. */}
<div className="flex flex-col gap-1" role="log" aria-label={t("collaborate.announcements")}>
{announcements.map((a) => (
<div
key={a.id}
className="pointer-events-auto flex items-center gap-1.5 rounded-md border map-glass px-2 py-1 text-xs text-foreground shadow-sm"
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-green-500" />
<span className="truncate">{a.text}</span>
</div>
))}
</div>
{/* Expanded roster: who is connected, plus a way back to the dialog. */}
{expanded && (
<div
id="collab-roster-panel"
className="pointer-events-auto rounded-md border map-glass shadow-md"
>
<div className="flex items-center justify-between border-b px-2.5 py-1.5">
<span className="text-xs font-medium">
{t("collaborate.participants", {
count: participants.length,
})}
</span>
<button
type="button"
aria-label={t("common.close")}
onClick={() => setExpanded(false)}
className="rounded-sm p-0.5 text-muted-foreground transition hover:bg-accent hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<ul className="max-h-40 space-y-1 overflow-y-auto p-2">
{participants.map((p) => (
<CollaborationParticipantRow
key={p.clientId}
participant={p}
mode={mode}
isSelf={p.clientId === selfId}
canManage={isHost}
onSetParticipantMode={api.setParticipantMode}
compact
/>
))}
</ul>
{/* Chat drawer (#754, Part 4): a lightweight text channel with an
optional attached map coordinate that recenters on click. */}
<div className="flex flex-col border-t">
<div
ref={chatScrollRef}
className="flex max-h-40 min-h-[3rem] flex-col gap-1.5 overflow-y-auto p-2"
role="log"
aria-label={t("collaborate.chatLog")}
>
{chat.length === 0 ? (
<p className="px-1 py-2 text-center text-[11px] text-muted-foreground">
{t("collaborate.chatEmpty")}
</p>
) : (
chat.map((m) => {
// Bind to a const so the narrowing survives into the onClick
// closure (a non-null assertion would otherwise be needed).
const coord = m.coordinate;
return (
<div key={m.id} className="flex flex-col gap-0.5 text-xs">
<div className="flex items-baseline gap-1.5">
<span className="truncate font-medium" style={{ color: m.color }}>
{m.clientId === selfId ? t("collaborate.you") : m.displayName}
</span>
</div>
<p className="whitespace-pre-wrap break-words text-foreground">{m.text}</p>
{coord && (
<button
type="button"
onClick={() => flyToCoordinate(coord)}
className="flex w-fit items-center gap-1 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground transition hover:bg-accent hover:text-foreground"
title={t("collaborate.chatGoToLocation")}
>
<MapPin className="h-3 w-3" aria-hidden="true" />
{coord.lat.toFixed(4)}, {coord.lng.toFixed(4)}
</button>
)}
</div>
);
})
)}
</div>
<div className="flex items-center gap-1 border-t p-1.5">
<button
type="button"
onClick={() => setAttachLocation((v) => !v)}
aria-pressed={attachLocation}
title={
attachLocation
? t("collaborate.chatLocationAttached")
: t("collaborate.chatAttachLocation")
}
className={`flex shrink-0 items-center rounded p-1.5 transition ${
attachLocation
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
<MapPin className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendChat();
} else if (e.key === "Escape") {
// Don't let Escape bubble to the window listener that
// collapses the panel — while typing it should stay open.
e.stopPropagation();
}
}}
placeholder={t("collaborate.chatPlaceholder")}
maxLength={2000}
aria-label={t("collaborate.chatCompose")}
className="h-8 text-xs"
/>
<Button
type="button"
size="sm"
className="h-8 shrink-0 px-2"
disabled={connecting || !draft.trim()}
onClick={handleSendChat}
aria-label={t("collaborate.chatSend")}
>
<Send className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="border-t p-2">
<Button
type="button"
variant="secondary"
size="sm"
className="w-full"
onClick={() => {
// Collapse the roster as the dialog takes over, so dismissing
// the dialog returns to a clean map rather than a stray panel.
setCollaborateDialogOpen(true);
setExpanded(false);
}}
>
<Settings2 className="me-2 h-3.5 w-3.5" />
{t("collaborate.manageSession")}
</Button>
</div>
</div>
)}
{/* Collapsed pill, always visible while the session is live. */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
// Only reference the roster while it is mounted (it is conditionally
// rendered when collapsed), so the id target always exists.
aria-controls={expanded ? "collab-roster-panel" : undefined}
// Describe the action the click performs, which flips with state. When
// collapsed with unread chat, fold the count into the label so it is
// announced rather than conveyed by the badge color alone.
aria-label={
expanded
? t("collaborate.collapseRoster")
: unread > 0
? t("collaborate.sessionStatusUnread", { count: unread })
: t("collaborate.sessionStatusTooltip")
}
title={expanded ? t("collaborate.collapseRoster") : t("collaborate.sessionStatusTooltip")}
className="pointer-events-auto flex items-center gap-1.5 self-start rounded-full border map-glass px-2.5 py-1 text-xs font-medium text-foreground shadow-sm transition hover:bg-accent"
>
<span className="relative flex h-2 w-2" aria-hidden="true">
{!connecting && (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-60" />
)}
<span
className={`relative inline-flex h-2 w-2 rounded-full ${
connecting ? "bg-amber-500" : "bg-green-500"
}`}
/>
</span>
<Users className="h-3.5 w-3.5" aria-hidden="true" />
<span>{participants.length}</span>
{/* Unread-chat count while collapsed, so a working host sees new
messages without keeping the panel open. */}
{!expanded && unread > 0 && (
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground">
{unread > 99 ? "99+" : unread}
</span>
)}
{/* The roster grows upward above the pill: point up when collapsed
("reveal above"), down when expanded ("collapse"). */}
<ChevronUp
className={`h-3 w-3 transition-transform ${expanded ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
</div>
);
}