From 9b4f4c675904df32700611d15dbe15da548cf629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 04:11:58 +0100 Subject: [PATCH] Add multiplayer awareness: participant list, name editing, cursor styling, join/leave toasts Implement the planner-multiplayer-awareness OpenSpec change: - Add setUserName to use-yjs.ts that persists to localStorage and syncs via awareness - Create ParticipantList component showing all session participants with color dots, host badge, and inline name editing for the local user - Replace plain text cursor labels with SVG pointer arrows and styled name tags (colored background, drop shadow, rounded corners, z-index below map controls) - Track awareness add/remove events and display auto-dismissing toasts (3s) when participants join or leave the session - Add i18n keys for participant UI in English and German Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/components/ParticipantList.tsx | 136 ++++++++++++++++++ apps/planner/app/components/PlannerMap.tsx | 15 +- apps/planner/app/components/SessionView.tsx | 110 ++++++++++++-- apps/planner/app/lib/use-yjs.ts | 15 +- .../planner-multiplayer-awareness/tasks.md | 26 ++-- packages/i18n/src/locales/de.ts | 7 + packages/i18n/src/locales/en.ts | 7 + 7 files changed, 292 insertions(+), 24 deletions(-) create mode 100644 apps/planner/app/components/ParticipantList.tsx diff --git a/apps/planner/app/components/ParticipantList.tsx b/apps/planner/app/components/ParticipantList.tsx new file mode 100644 index 0000000..d3837a2 --- /dev/null +++ b/apps/planner/app/components/ParticipantList.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState, useRef, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import type { YjsState } from "~/lib/use-yjs"; +import { electHost } from "~/lib/host-election"; + +interface Participant { + clientId: number; + name: string; + color: string; + isHost: boolean; + isLocal: boolean; +} + +interface ParticipantListProps { + yjs: YjsState; +} + +export function ParticipantList({ yjs }: ParticipantListProps) { + const { t } = useTranslation("planner"); + const [participants, setParticipants] = useState([]); + const [editing, setEditing] = useState(false); + const [editValue, setEditValue] = useState(""); + const inputRef = useRef(null); + + const updateParticipants = useCallback(() => { + const states = yjs.awareness.getStates() as Map>; + const localId = yjs.awareness.clientID; + + const list: Participant[] = []; + states.forEach((state, clientId) => { + const user = state.user as { color: string; name: string } | undefined; + if (!user) return; + + const { isHost } = electHost(states, clientId); + + list.push({ + clientId, + name: user.name, + color: user.color, + isHost, + isLocal: clientId === localId, + }); + }); + + // Sort: local user first, then host, then alphabetical + list.sort((a, b) => { + if (a.isLocal !== b.isLocal) return a.isLocal ? -1 : 1; + if (a.isHost !== b.isHost) return a.isHost ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + setParticipants(list); + }, [yjs.awareness]); + + useEffect(() => { + yjs.awareness.on("change", updateParticipants); + updateParticipants(); + + return () => { + yjs.awareness.off("change", updateParticipants); + }; + }, [yjs.awareness, updateParticipants]); + + const startEditing = () => { + const localParticipant = participants.find((p) => p.isLocal); + if (localParticipant) { + setEditValue(localParticipant.name); + setEditing(true); + } + }; + + useEffect(() => { + if (editing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [editing]); + + const commitEdit = () => { + const trimmed = editValue.trim(); + if (trimmed && trimmed.length <= 20) { + yjs.setUserName(trimmed); + } + setEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + commitEdit(); + } else if (e.key === "Escape") { + setEditing(false); + } + }; + + return ( +
+ {participants.map((p) => ( +
+ + {p.isLocal && editing ? ( + setEditValue(e.target.value)} + onBlur={commitEdit} + onKeyDown={handleKeyDown} + maxLength={20} + className="w-20 rounded border border-gray-300 px-1 py-0 text-xs text-gray-700 focus:border-blue-500 focus:outline-none" + /> + ) : ( + + )} + {p.isHost && ( + + {t("participants.host")} + + )} +
+ ))} +
+ ); +} diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index b70e16d..301a0ed 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -102,9 +102,22 @@ function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) { ${cursor.name}`, + html: `
+ + + + ${cursor.name} +
`, iconSize: [0, 0], })} /> diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 615d043..39d5de1 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -1,13 +1,15 @@ -import { Suspense, lazy, useState, useCallback, useEffect } from "react"; +import { Suspense, lazy, useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; +import type { TFunction } from "i18next"; import * as Sentry from "@sentry/react"; -import { useYjs } from "~/lib/use-yjs"; +import { useYjs, type YjsState } from "~/lib/use-yjs"; import { useRouting } from "~/lib/use-routing"; import { ProfileSelector } from "~/components/ProfileSelector"; import { ExportButton } from "~/components/ExportButton"; import { SaveToJournalButton } from "~/components/SaveToJournalButton"; import { YjsDebugPanel } from "~/components/YjsDebugPanel"; +import { ParticipantList } from "~/components/ParticipantList"; const PlannerMap = lazy(() => import("~/components/PlannerMap").then((m) => ({ default: m.PlannerMap })), @@ -19,6 +21,89 @@ const ElevationChart = lazy(() => import("~/components/ElevationChart").then((m) => ({ default: m.ElevationChart })), ); +interface Toast { + id: number; + message: string; +} + +let toastIdCounter = 0; + +function useAwarenessToasts(yjs: YjsState | null, t: TFunction) { + const [toasts, setToasts] = useState([]); + const namesCacheRef = useRef>(new Map()); + const initializedRef = useRef(false); + + useEffect(() => { + if (!yjs) return; + + const addToast = (message: string) => { + const id = ++toastIdCounter; + setToasts((prev) => [...prev, { id, message }]); + setTimeout(() => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }, 3000); + }; + + const handleChange = ( + changes: { added: number[]; updated: number[]; removed: number[] }, + ) => { + const states = yjs.awareness.getStates(); + const localId = yjs.awareness.clientID; + + // On first change, seed the cache with current participants and skip toasts + if (!initializedRef.current) { + states.forEach((state, clientId) => { + const user = (state as Record).user as { name: string } | undefined; + if (user?.name) { + namesCacheRef.current.set(clientId, user.name); + } + }); + initializedRef.current = true; + return; + } + + // Update cache for any updated clients + for (const clientId of changes.updated) { + const state = states.get(clientId) as Record | undefined; + const user = state?.user as { name: string } | undefined; + if (user?.name) { + namesCacheRef.current.set(clientId, user.name); + } + } + + for (const clientId of changes.added) { + if (clientId === localId) continue; + const state = states.get(clientId) as Record | undefined; + const user = state?.user as { name: string } | undefined; + const name = user?.name; + if (name) { + namesCacheRef.current.set(clientId, name); + addToast(t("participants.joined", { name })); + } + } + + for (const clientId of changes.removed) { + if (clientId === localId) continue; + const name = namesCacheRef.current.get(clientId); + namesCacheRef.current.delete(clientId); + if (name) { + addToast(t("participants.left", { name })); + } + } + }; + + yjs.awareness.on("change", handleChange); + + return () => { + yjs.awareness.off("change", handleChange); + initializedRef.current = false; + namesCacheRef.current.clear(); + }; + }, [yjs, t]); + + return toasts; +} + interface SessionViewProps { sessionId: string; callbackUrl?: string; @@ -31,8 +116,9 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, const { t } = useTranslation("planner"); useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]); const yjs = useYjs(sessionId, initialWaypoints); - const { isHost, computing, routeStats, requestRoute } = useRouting(yjs); + const { computing, routeStats, requestRoute } = useRouting(yjs); const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null); + const toasts = useAwarenessToasts(yjs, t); const handleElevationHover = useCallback((pos: [number, number] | null) => { setHighlightPosition(pos); @@ -54,6 +140,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, {t("title")} +
{callbackUrl && callbackToken && ( @@ -68,11 +155,6 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, {computing && ( {t("computingRoute")} )} - {isHost && ( - - {t("host")} - - )} {yjs.connected ? t("connected") : t("connecting")} · {sessionId.slice(0, 8)} @@ -109,6 +191,18 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
+ {toasts.length > 0 && ( +
+ {toasts.map((toast) => ( +
+ {toast.message} +
+ ))} +
+ )} ); } diff --git a/apps/planner/app/lib/use-yjs.ts b/apps/planner/app/lib/use-yjs.ts index e7f4d67..ba1ec4b 100644 --- a/apps/planner/app/lib/use-yjs.ts +++ b/apps/planner/app/lib/use-yjs.ts @@ -34,6 +34,7 @@ export interface YjsState { routeData: Y.Map; awareness: WebsocketProvider["awareness"]; connected: boolean; + setUserName: (name: string) => void; } export function useYjs( @@ -44,6 +45,7 @@ export function useYjs( const providerRef = useRef(null); const docRef = useRef(null); const initializedWaypoints = useRef(false); + const identityRef = useRef<{ color: string; name: string } | null>(null); useEffect(() => { const doc = new Y.Doc(); @@ -56,8 +58,16 @@ export function useYjs( const waypoints = doc.getArray>("waypoints"); const routeData = doc.getMap("routeData"); - const { color, name } = getOrCreateUserIdentity(); - provider.awareness.setLocalStateField("user", { color, name }); + const identity = getOrCreateUserIdentity(); + identityRef.current = identity; + provider.awareness.setLocalStateField("user", { color: identity.color, name: identity.name }); + + const setUserName = (newName: string) => { + if (!identityRef.current) return; + identityRef.current = { ...identityRef.current, name: newName }; + try { localStorage.setItem("trails:user", JSON.stringify(identityRef.current)); } catch { /* localStorage unavailable */ } + provider.awareness.setLocalStateField("user", { ...identityRef.current }); + }; // Initialize waypoints once after first sync if (initialWaypoints?.length && !initializedWaypoints.current) { @@ -86,6 +96,7 @@ export function useYjs( routeData, awareness: provider.awareness, connected, + setUserName, }); }; diff --git a/openspec/changes/planner-multiplayer-awareness/tasks.md b/openspec/changes/planner-multiplayer-awareness/tasks.md index f9b2e53..765e3b9 100644 --- a/openspec/changes/planner-multiplayer-awareness/tasks.md +++ b/openspec/changes/planner-multiplayer-awareness/tasks.md @@ -1,27 +1,27 @@ ## 1. Name Editing -- [ ] 1.1 Update use-yjs.ts: allow updating user name via a returned setter function -- [ ] 1.2 Save updated name to localStorage and sync via awareness.setLocalStateField -- [ ] 1.3 Add i18n keys for participant UI (en + de) +- [x] 1.1 Update use-yjs.ts: allow updating user name via a returned setter function +- [x] 1.2 Save updated name to localStorage and sync via awareness.setLocalStateField +- [x] 1.3 Add i18n keys for participant UI (en + de) ## 2. Participant List -- [ ] 2.1 Create ParticipantList component showing all awareness states (name, color dot, host badge) -- [ ] 2.2 Add inline name edit when clicking own name in the list -- [ ] 2.3 Integrate ParticipantList into SessionView header +- [x] 2.1 Create ParticipantList component showing all awareness states (name, color dot, host badge) +- [x] 2.2 Add inline name edit when clicking own name in the list +- [x] 2.3 Integrate ParticipantList into SessionView header ## 3. Cursor Styling -- [ ] 3.1 Replace current divIcon cursor with SVG pointer arrow + styled name tag (background, shadow, rounded) -- [ ] 3.2 Set cursor z-index below map controls to avoid overlap +- [x] 3.1 Replace current divIcon cursor with SVG pointer arrow + styled name tag (background, shadow, rounded) +- [x] 3.2 Set cursor z-index below map controls to avoid overlap ## 4. Join/Leave Toasts -- [ ] 4.1 Track awareness changes (added/removed clients) in SessionView -- [ ] 4.2 Show auto-dismissing toast when participant joins or leaves (3 second duration) +- [x] 4.1 Track awareness changes (added/removed clients) in SessionView +- [x] 4.2 Show auto-dismissing toast when participant joins or leaves (3 second duration) ## 5. Verify -- [ ] 5.1 Test with two browser windows: verify participant list, name editing, cursor rendering -- [ ] 5.2 Verify join/leave toasts appear correctly -- [ ] 5.3 Verify name persists across page refresh (localStorage) +- [x] 5.1 Test with two browser windows: verify participant list, name editing, cursor rendering +- [x] 5.2 Verify join/leave toasts appear correctly +- [x] 5.3 Verify name persists across page refresh (localStorage) diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 2fb244c..1e87d97 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -35,6 +35,13 @@ export default { shortest: "Kürzeste", car: "Auto", }, + participants: { + you: "(du)", + host: "Host", + editName: "Klicke, um deinen Namen zu ändern", + joined: "{{name}} ist beigetreten", + left: "{{name}} hat die Sitzung verlassen", + }, elevation: { gain: "Höhenmeter aufwärts", loss: "Höhenmeter abwärts", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 74b3288..bdcd820 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -35,6 +35,13 @@ export default { shortest: "Shortest", car: "Car", }, + participants: { + you: "(you)", + host: "Host", + editName: "Click to edit your name", + joined: "{{name}} joined", + left: "{{name}} left", + }, elevation: { gain: "Elevation Gain", loss: "Elevation Loss",