diff --git a/CHANGES.md b/CHANGES.md index 1e1c853..1ff3eb7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -262,6 +262,8 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/sheet/RouterSheets.tsx`, `src/sheet/NameField.tsx`, `src/canvas/Canvas.tsx` (`highlightedLink`), `src/theme/tokens.css` (`--canvas-cable-active`) | Java `JVermittlungsrechnerKonfiguration`, `JGatewayConfiguration`, `JWeiterleitungsTabelle` (Wörter `jvermittlungsrechnerkonfiguration_msg6`–`26`, `jweiterleitungstabelle_msg3`–`6`), `JCablePanel.farbeBlinken`, `Kabel.setAktiv` | neu (F4.5b): Blatt des Vermittlungsrechners — Reiter Allgemein (Name, Gateway, Automatisches Routing, IP-Weiterleitung nur angezeigt), je Karte (Reiter mit ihrer Adresse, „Verbunden mit …“, Adresse, Maske, MAC) und Weiterleitungstabelle (alle Einträge oder nur die manuellen, Javas Zeilen grau, eine neue Zeile, die mit vier gültigen Feldern übernommen wird, Zelle bearbeiten, Eintrag löschen; bei RIP gesperrt); Blatt des Heimrouters mit Allgemein, LAN, WAN (DHCP sperrt die WAN-Adresse). Das Kabel der offenen Karte leuchtet grün wie bei Java. Javas „Eintrag Löschen“ klein geschrieben | | `src/remote/remoteSimHost.ts`, `src/remote/sharedDoc.ts`, `src/remote/SharedProject.tsx`, `src/main.tsx` (`?project=`) | Plan §5, §6.1, §8.5; Hocuspocus' Provider 3.4.4 (MIT) | neu (F5.3b): der Editor auf einem Projekt von F — das gemeinsame Dokument über `/sync` (bereit mit Dokument, Lesestufe und ob eingefroren; abgewiesen; getrennt) und der Raum über `/sim` (`RemoteSimHost`: Nachrichten vor dem Öffnen warten, nach einem Verlust kommt der Socket wieder, mit doppelter Wartezeit bis 10 s und allen abonnierten Themen; Absage und Trennung von F beenden ihn); wer liest und wieder schreiben darf (aufgetaut), bekommt das Dokument neu; statt des Editors ein Wort, warum nicht | | `src/Editor.tsx`, `src/canvas/Canvas.tsx` (`locked`), `src/sheet/SheetLock.tsx`, `src/sheet/DeviceSheet.tsx`, `src/sheet/RouterSheets.tsx`, `src/sheet/FirewallPanel.tsx`, `src/sheet/ServicePanels.tsx`, `src/design/DocumentationSidebar.tsx`, `src/simulation/ModeBar.tsx`, `src/simulation/useSimulation.ts`, `src/i18n/*.json` | Plan §6.2, §6.4, §9.3 | geändert (F5.3b): wer nur liest, sieht alles und ändert nichts — keine Palette, kein Rückgängig, nichts bewegt sich oder hat ein Menü, die Felder des Blatts sind gesperrt (die Reiter bleiben), keine neuen Doku-Elemente, die Simulation wird von ihm weder gestartet noch angehalten; Desktops und Datenaustausch öffnen zum Zuschauen; ein „nein“ des Raums wird gesagt; „Simulation gestartet von …“ und „Nur ansehen“ bzw. „Eingefroren – nur ansehen“ neben den Modusknöpfen | +| `src/remote/presence.ts`, `src/remote/usePresence.ts`, `src/remote/PresenceMarks.tsx`, `src/remote/sharedDoc.ts` (`you`, Awareness), `src/remote/SharedProject.tsx` (`onPeople`), `src/Editor.tsx`, `src/canvas/Canvas.tsx` (`chosenBy`), `src/desktop/DesktopWindow.tsx`, `src/simulation/ViewerWindow.tsx`, `src/simulation/CaptureWindow.tsx`, `src/bridge/dashboardBridge.ts` (`people`), `src/main.tsx`, `src/i18n/*.json` | Plan §7.4 | neu (F5.4): die anderen im Projekt über Yjs-Awareness — jeder Editor sagt, wer er ist (von F), welches Gerät er gewählt und welche Fenster er offen hat, und nichts sonst (kein Mauszeiger, nichts, das sich ohne Anlass ändert; gesendet nur bei einer Änderung); fremde Zustände werden geprüft gelesen; ein Rahmen in der Farbe der Person um ein Gerät, das sie gewählt hat, ein Punkt an einem Fenster, das sie auch offen hat, ein Kreis mit den Initialen je Person neben den Modusknöpfen (eine Person mit zwei Tabs einmal); eine Person ohne Farbe des Dashboards bekommt eine aus ihrer Kennung; die Brücke meldet dem Dashboard die Personen (`people`). Name und Farbe sagt der Editor selbst — wer seinen Editor verändert, könnte den anderen im Projekt einen anderen Namen zeigen | +| `test/presence.test.ts` | — | neu (F5.4): Lesen fremder Zustände, die anderen ohne sich selbst, eine Person einmal, Rahmen und Punkte, Farben, Initialen, Senden nur bei Änderung | | `test/remoteSimHost.test.ts`, `browser/shared.spec.ts`, `browser/serverF.ts` | — | neu (F5.3b): `RemoteSimHost` auf nachgebauten Sockets mit gestellter Uhr; drei Personen in drei Browsern an einem echten F (aus dem Bündel gestartet): verschieben, nur lesen, eine Simulation für alle mit Namen, ein Ping in einer Befehlszeile bei der anderen, Eintippen als Leser abgewiesen, eingefroren und aufgetaut, Zugang beendet; Absagen ohne Sitzung und ohne Projekt | ## packages/server @@ -275,7 +277,8 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/service.ts`, `src/store.ts` (`freeze`, `remove`), `src/persistence.ts` (`discard`), `src/main.ts` | Dashboard `backend/modules/scratch/src/service.py`, Scratch-Editor `revoke`, `active_sessions` | neu (F5.2): `/svc/*` mit Dienstschlüssel (in konstanter Zeit verglichen): Projekt anlegen (leeres FILIUS-Projekt), löschen (erst nicht mehr gespeichert, dann weg, dann alle getrennt — wer wiederkommt, findet kein Projekt), einfrieren und auftauen (wer drin ist, liest bzw. schreibt wieder und erfährt es), Berechtigung einer Person an einem Projekt oder alle ihre Sitzungen beenden (wer verbunden ist, wird getrennt und beim Wiederkommen abgewiesen), die Sitzungen auflisten; ohne Geheimnisse startet der Dienst nicht | | `src/simRooms.ts`, `src/simWorker.ts`, `src/simChannel.ts`, `src/server.ts` (`/sim`), `src/service.ts` (Räume) | Plan §6.1–§6.4, §8.2 | neu (F5.3a): die Simulationsräume auf F — einer je Projekt für alle darin, gehalten von wenigen `worker_threads` (der mit den wenigsten Räumen nimmt den nächsten), jeder Raum mit einer Kopie des Dokuments: Hocuspocus' direkte Verbindung hält das Dokument offen, solange der Raum lebt, jede Änderung geht in den Raum, was der Raum schreibt, kommt als seine Änderung zurück; `/sim?project=…` meldet an wie `/sync` (Keks, Berechtigung, Herkunft), Nachrichten vor der Anmeldung werden gehalten; ändern nur mit Schreibstufe; nach dem Letzten bleibt der Raum 60 s und hält dann, schreibt zurück und endet; Einfrieren hält eine laufende Simulation an (ihr Stand kommt noch ins Projekt), Löschen beendet den Raum ohne Zurückschreiben, Widerruf trennt auch im Raum; ein scheiternder Faden beendet seine Räume; beim Herunterfahren halten alle Räume und schreiben zurück; ein Socket ohne Antwort auf Pings wird getrennt; Nachrichten höchstens 1 MiB; `/healthz` zählt die Räume; der Build bündelt den Faden als `dist/simWorker.mjs` | | `src/sync.ts` (Zugang gleich zu Beginn, stille Absage) | Plan §8.5 | geändert (F5.3b): jede Verbindung erfährt ihren Zugang sofort nach der Anmeldung (Nachricht `access`), damit der Editor weiß, ob er liest und warum; eine Absage trägt keinen Meldungstext — Hocuspocus schreibt sonst die Meldung eines gescheiterten Hooks auf die Konsole, außerhalb des JSON-Protokolls | -| `test/editorConnection.test.ts` | — | neu (F5.3b): `connectSharedDoc` des Editors gegen einen echten F — bereit mit Schreib- und Lesestufe, eingefroren und aufgetaut (neues Dokument, die verirrte Änderung ist weg, Schreiben geht wieder), eingefroren von Beginn an, Absage, Widerruf, Löschen, Loslassen | +| `src/sync.ts` (`you`), `protocol/src/sync.ts` (`AccessMessage.you`) | Plan §7.4 | erweitert (F5.4): die Zugangsnachricht beim Verbinden nennt die Person (Kennung, Name, Farbe aus der Karte), damit der Editor sich den anderen zeigen kann — der Keks ist für die Seite nicht lesbar | +| `test/editorConnection.test.ts` | — | neu (F5.3b), erweitert (F5.4: Identität von F, die anderen über die Awareness, auch eine lesende Person, weg mit dem Schließen): `connectSharedDoc` des Editors gegen einen echten F — bereit mit Schreib- und Lesestufe, eingefroren und aufgetaut (neues Dokument, die verirrte Änderung ist weg, Schreiben geht wieder), eingefroren von Beginn an, Absage, Widerruf, Löschen, Loslassen | | `test/sim.test.ts`, `test/harness.ts` (Faden) | — | neu (F5.3a): echte Sockets, der echte Faden (esbuild bündelt ihn wie der Build), Beispiel internetwork_komplett: ein Start für alle mit Namen, Ping in einer Befehlszeile bei der anderen, Zurückschreiben ins Dokument und eine Datei aus dem Dokument im Raum, Lesestufe, Absagen, Nachlauf, Einfrieren, Löschen, Widerruf, Wahl des Fadens, scheiternder Faden, Herunterfahren, Ping | | `test/ticket.test.ts`, `test/tickets.ts`, `test/tickets.python.json` | `tools/ticket-oracle` | neu (F5.2): jede Karte des Python-Orakels liest Node wie Python (Nutzlast oder derselbe Grund der Ablehnung); in Node signierte Karten tragen dasselbe JSON zum selben Zeitpunkt (Nodes zlib packt anders, ungepackt byte-gleich); die Ansprüche | | `test/access.test.ts`, `test/harness.ts` | — | neu (F5.2): `/__auth` (Keks, `next`, schlechte Karten, unbekanntes Projekt, zweites Projekt, nächste Person, Verlängerung), Absagen an `/sync` mit Grund, Herkunft, Dienstweg (Schlüssel, Anlegen, Fehlerantworten, Löschen, Einfrieren, Widerruf einzeln und ganz, Sitzungsliste, 500) | diff --git a/packages/editor/browser/shared.spec.ts b/packages/editor/browser/shared.spec.ts index b8ee478..d700ef8 100644 --- a/packages/editor/browser/shared.spec.ts +++ b/packages/editor/browser/shared.spec.ts @@ -1,6 +1,8 @@ // The editor on a project of a real server F (F5.3b), three people in three // browsers: Mia and Ben write, Rita only reads. What one moves the others -// see; Rita gets everything shown and nothing to change; Mia starts the +// see, and each sees the others (a circle each, a frame round a device +// another has chosen, a dot on a window another has open too); Rita gets +// everything shown and nothing to change; Mia starts the // simulation and it runs for all, named after her; Ben's ping in a command // line is in Mia's too (one window per computer, as in Java); Rita's typing // there is refused, and she is told; the project frozen, Ben reads and is @@ -9,6 +11,7 @@ import { expect, test, type Browser, type Page } from '@playwright/test'; import { drag, onDevice, serveEditor } from './editorPage.js'; +import { colorOf } from '../src/remote/presence.js'; import { startServerF, type ServerF } from './serverF.js'; let editor: { readonly base: string; readonly close: () => Promise }; @@ -50,16 +53,40 @@ async function commandLine(page: Page, id: string) { test('three people in one project of F: moving, reading only, one simulation for all, a frozen project, an ended grant', async ({ browser }) => { test.setTimeout(120_000); const project = await f.project('java-examples/internetwork_komplett.fls'); - const benUser = crypto.randomUUID(); - const mia = await person(browser, project.id, 'Mia'); + const [miaUser, benUser, ritaUser] = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()]; + const mia = await person(browser, project.id, 'Mia', 'write', miaUser); const ben = await person(browser, project.id, 'Ben', 'write', benUser); - const rita = await person(browser, project.id, 'Rita', 'read'); + const rita = await person(browser, project.id, 'Rita', 'read', ritaUser); const devices = await mia.locator('g.device').count(); expect(devices).toBeGreaterThan(5); for (const page of [ben, rita]) await expect(page.locator('g.device')).toHaveCount(devices); - // Mia moves a device; Ben and Rita see it where she put it. + // Each sees the others, by their initials. + for (const [page, others] of [ + [mia, [benUser, ritaUser]], + [ben, [miaUser, ritaUser]], + [rita, [miaUser, benUser]], + ] as const) { + await expect(page.locator('[data-person]')).toHaveCount(2); + for (const other of others) await expect(page.locator(`[data-person="${other}"]`)).toBeVisible(); + } + await expect(mia.locator(`[data-person="${benUser}"]`)).toHaveText('B'); + + // Mia chooses a device: Ben sees a frame round it in her colour, until she lets it go. const notebook = project.node('Notebook 1.10'); + const router = project.node('Switch 1'); + const routerAt = await onDevice(mia, router); + await mia.mouse.click(routerAt.x, routerAt.y); + const frame = ben.locator(`g.device[data-node="${router}"] rect.chosen-by`); + await expect(frame).toHaveCount(1); + // The ticket gave Mia no colour: she has the one her id gives, the same in every editor. + await expect(frame).toHaveAttribute('data-color', colorOf({ id: miaUser, name: 'Mia', color: '' })); + await mia.getByRole('complementary', { name: 'Einstellungen' }).getByRole('button', { name: 'Schließen' }).click(); + await expect(frame).toHaveCount(0); + // Nobody sees their own frame. + await expect(mia.locator('rect.chosen-by')).toHaveCount(0); + + // Mia moves a device; Ben and Rita see it where she put it. const before = await box(mia, notebook); const from = await onDevice(mia, notebook); await drag(mia, from, { x: from.x + 120, y: from.y + 40 }); @@ -95,8 +122,13 @@ test('three people in one project of F: moving, reading only, one simulation for await bens.input.press('Enter'); const mias = await commandLine(mia, notebook); for (const each of [bens, mias]) await expect(each.text).toContainText('4 Paket(e) gesendet, 4 Paket(e) empfangen', { timeout: 30_000 }); - // Rita may look, not type. + // Both have the notebook's desktop open: each sees the other's dot on it. + await expect(desktop(mia, notebook).locator(`[data-watcher="${benUser}"]`)).toBeVisible(); + await expect(desktop(ben, notebook).locator(`[data-watcher="${miaUser}"]`)).toBeVisible(); + // Rita may look, not type; she sees both on the desktop, and they see her. const ritas = await commandLine(rita, notebook); + await expect(desktop(rita, notebook).locator('[data-watcher]')).toHaveCount(2); + await expect(desktop(mia, notebook).locator(`[data-watcher="${ritaUser}"]`)).toBeVisible(); await expect(ritas.text).toContainText('ping 141.99.5.10'); await ritas.input.fill('ipconfig'); await ritas.input.press('Enter'); @@ -126,6 +158,37 @@ test('three people in one project of F: moving, reading only, one simulation for await expect(canvas(mia)).toBeVisible(); }); +test('tells the dashboard over the bridge who else is in the project, each time it changes', async ({ browser }) => { + const project = await f.project('java-examples/zwei_rechnernetze_komplett.fls'); + const context = await browser.newContext(); + await f.signIn(context, project.id, { name: 'Mia' }); + const page = await context.newPage(); + // The page is its own parent here: what it tells the dashboard comes back to it. + const origin = new URL(editor.base).origin; + await page.addInitScript(() => { + (window as unknown as { told: unknown[] }).told = []; + window.addEventListener('message', (event) => (window as unknown as { told: unknown[] }).told.push(event.data)); + }); + await page.goto(`${editor.base}?project=${project.id}&server=${encodeURIComponent(f.base)}&parent=${encodeURIComponent(origin)}`); + await page.waitForSelector('svg.filius-canvas'); + const people = () => page.evaluate(() => (window as unknown as { told: { type?: string; people?: { name: string }[] }[] }).told.filter((message) => message.type === 'people').map((message) => message.people!.map((each) => each.name))); + await expect.poll(people).toEqual([[]]); + const benUser = crypto.randomUUID(); + const ben = await person(browser, project.id, 'Ben', 'write', benUser); + await expect.poll(people).toEqual([[], ['Ben']]); + // Mia in a second tab: she is not one of the others for herself, and Ben sees her once. + const second = await context.newPage(); + await second.goto(`${editor.base}?project=${project.id}&server=${encodeURIComponent(f.base)}`); + await second.waitForSelector('svg.filius-canvas'); + await expect(ben.locator('[data-person]')).toHaveCount(1); + await expect(second.locator('[data-person]')).toHaveCount(1); + await expect(second.locator(`[data-person="${benUser}"]`)).toBeVisible(); + await second.close(); + await ben.context().close(); + await expect.poll(people, { timeout: 40_000 }).toEqual([[], ['Ben'], []]); + await context.close(); +}); + test('says why when F ends only the room: the editor goes as when the document is let go', async ({ browser }) => { const project = await f.project('java-examples/zwei_rechnernetze_komplett.fls'); const context = await browser.newContext(); diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index 5e9c532..75a8db4 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -19,6 +19,11 @@ // sheet's fields are disabled, the simulation is neither started nor // stopped by them; desktops and the data exchange window open to watch. The // room's simulation names who started it. +// +// On F the others are shown (plan §7.4, presence.ts): a frame in their +// colour around a device they have chosen, a dot on a window they have open +// too, a circle each beside the mode buttons; this editor tells them its own +// choice and windows, and nothing more. import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import type * as Y from 'yjs'; @@ -63,6 +68,9 @@ import { ModeBar, type EditorMode } from './simulation/ModeBar.js'; import { DropControl } from './simulation/DropControl.js'; import { ViewerWindow, type ViewerKind } from './simulation/ViewerWindow.js'; import { useSimulation, type SimHostFactory } from './simulation/useSimulation.js'; +import { PeopleRow } from './remote/PresenceMarks.js'; +import { peopleOf, selectionsOf, watchersOf, windowKey } from './remote/presence.js'; +import type { Presence } from './remote/usePresence.js'; /** The document's drawing, read anew after every change. */ export function useTopologyView(doc: Y.Doc): TopologyView { @@ -153,9 +161,11 @@ export interface EditorProps { readonly readOnly?: boolean; /** …because the project is frozen (its deadline passed). */ readonly frozen?: boolean; + /** The others in the project and how to tell them this editor's state (on F). */ + readonly presence?: Presence; } -export function Editor({ doc, createSimHost, projectName, readOnly = false, frozen = false }: EditorProps) { +export function Editor({ doc, createSimHost, projectName, readOnly = false, frozen = false, presence }: EditorProps) { const t = useTranslate(); const language = useLanguage(); const view = useTopologyView(doc); @@ -364,6 +374,19 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz const selected = selection?.kind === 'device' ? view.nodes.find((node) => node.id === selection.id) : undefined; + // The others: told this editor's chosen device and open windows (a window is open only while the simulation runs). + const windows = simulating ? desktops.map((desktop) => (desktop.viewer === 'capture' ? windowKey('capture') : windowKey(desktop.viewer ?? 'desktop', desktop.id))) : []; + const told = JSON.stringify([selected?.id ?? null, windows]); + const publish = presence?.publish; + useEffect(() => { + const [device, open] = JSON.parse(told) as [string | null, string[]]; + publish?.(device, open); + }, [told, publish]); + const peers = presence?.peers; + const chosenBy = useMemo(() => selectionsOf(peers ?? []), [peers]); + const watchers = useMemo(() => watchersOf(peers ?? []), [peers]); + const people = useMemo(() => peopleOf(peers ?? [], presence?.you.id), [peers, presence?.you.id]); + // The keyboard: undo and redo, delete what is selected, Escape lets go. useEffect(() => { const key = (event: KeyboardEvent) => { @@ -418,6 +441,7 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz highlightedLink={highlightedLink} mode={editorMode} locked={readOnly} + chosenBy={chosenBy} onDocFrame={(id, frame) => setDocumentationFrame(doc, id, frame, origin)} renderDocText={(item, frame) => { const text = documentationText(doc, item.id); @@ -464,6 +488,7 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz {t('access.startedBy', { name: simulation.startedBy })} ) : null} + {readOnly ? ( {t(frozen ? 'access.frozen' : 'access.readOnly')} @@ -509,6 +534,7 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz onMove={(x, y) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, x, y } : each)))} onFront={() => setDesktops((open) => toFront(open, desktop.id))} onClose={() => setDesktops((open) => open.filter((each) => each.id !== desktop.id))} + watchers={watchers.get(windowKey('capture'))} /> ) ) : desktop.viewer !== undefined ? ( @@ -524,6 +550,7 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz onMove={(x, y) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, x, y } : each)))} onFront={() => setDesktops((open) => toFront(open, desktop.id))} onClose={() => setDesktops((open) => open.filter((each) => each.id !== desktop.id))} + watchers={watchers.get(windowKey(desktop.viewer, desktop.id))} /> ) : ( setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, x, y } : each)))} onFront={() => setDesktops((open) => toFront(open, desktop.id))} onClose={() => setDesktops((open) => open.filter((each) => each.id !== desktop.id))} + watchers={watchers.get(windowKey('desktop', desktop.id))} /> ), ) diff --git a/packages/editor/src/bridge/dashboardBridge.ts b/packages/editor/src/bridge/dashboardBridge.ts index 36520bd..a372355 100644 --- a/packages/editor/src/bridge/dashboardBridge.ts +++ b/packages/editor/src/bridge/dashboardBridge.ts @@ -19,6 +19,8 @@ export const COMMANDS = { export const EVENTS = { /** The editor listens. */ READY: 'ready', + /** `{ people: [{ id, name, color }] }`: the others in the project, each time they change (plan §7.4: the dashboard's header shows them). */ + PEOPLE: 'people', } as const; export interface BridgeHandlers { @@ -45,6 +47,11 @@ export function startBridge(window: Window, parentOrigin: string, handlers: Brid } }; window.addEventListener('message', listener); - window.parent.postMessage({ v: BRIDGE_VERSION, type: EVENTS.READY }, parentOrigin); + tellDashboard(window, parentOrigin, EVENTS.READY); return () => window.removeEventListener('message', listener); } + +/** Tells the dashboard at `parentOrigin` something the editor says (EVENTS). */ +export function tellDashboard(window: Pick, parentOrigin: string, type: (typeof EVENTS)[keyof typeof EVENTS], fields: Readonly> = {}): void { + window.parent.postMessage({ v: BRIDGE_VERSION, type, ...fields }, parentOrigin); +} diff --git a/packages/editor/src/canvas/Canvas.tsx b/packages/editor/src/canvas/Canvas.tsx index c652b4a..cdc71ff 100644 --- a/packages/editor/src/canvas/Canvas.tsx +++ b/packages/editor/src/canvas/Canvas.tsx @@ -87,6 +87,8 @@ export interface CanvasProps { * documentation item is chosen or changed, and nothing has a menu. */ readonly locked?: boolean; + /** The devices others in the project have chosen, with their colours (plan §7.4): a frame each. */ + readonly chosenBy?: ReadonlyMap; /** A documentation item moved or resized: its new frame. */ readonly onDocFrame?: (id: string, frame: Frame) => void; /** The field a focused text is typed into, over the surface at its frame. */ @@ -97,6 +99,8 @@ export interface CanvasProps { } const NO_LINKS: ReadonlySet = new Set(); +const NO_CHOICES: ReadonlyMap = new Map(); +const NO_COLORS: readonly string[] = []; /** The label's width in Java's font, measured once per text. */ function useTextWidth(): (text: string) => number { @@ -170,7 +174,22 @@ function Documentation({ item, documenting, focused }: { readonly item: Document ); } -function Device({ node, box, kindName, selected, pending }: { readonly node: NodeView; readonly box: DeviceBox; readonly kindName: string; readonly selected: boolean; readonly pending: boolean }) { +function Device({ + node, + box, + kindName, + selected, + pending, + chosenBy, +}: { + readonly node: NodeView; + readonly box: DeviceBox; + readonly kindName: string; + readonly selected: boolean; + readonly pending: boolean; + /** The colours of the others who have chosen it, each a frame further out. */ + readonly chosenBy: readonly string[]; +}) { return ( {/* The whole button takes the pointer, as Java's label does. */} + {chosenBy.map((color, index) => ( + + ))} {selected || pending ? ( (function Canvas( highlightedLink = null, mode = 'design', locked = false, + chosenBy = NO_CHOICES, activeLinks = NO_LINKS, onActivate, onDocFrame, @@ -631,6 +667,7 @@ export const Canvas = forwardRef(function Canvas( box={boxes.get(node.id)!} kindName={t(`device.${node.kind}`)} selected={selection?.kind === 'device' && selection.id === node.id} + chosenBy={chosenBy.get(node.id) ?? NO_COLORS} pending={cableFrom?.id === node.id} /> ))} diff --git a/packages/editor/src/desktop/DesktopWindow.tsx b/packages/editor/src/desktop/DesktopWindow.tsx index 2a31ab8..6f17661 100644 --- a/packages/editor/src/desktop/DesktopWindow.tsx +++ b/packages/editor/src/desktop/DesktopWindow.tsx @@ -46,8 +46,12 @@ import { GnutellaPanel } from './GnutellaPanel.js'; import { ImageViewerPanel, useImageViewer } from './ImageViewerPanel.js'; import { WebServerPanel } from './WebServerPanel.js'; import './desktop.css'; +import { WindowWatchers } from '../remote/PresenceMarks.js'; +import type { PresenceUser } from '../remote/presence.js'; export interface DesktopWindowProps { + /** Who else in the project has this window open (plan §7.4). */ + readonly watchers?: readonly PresenceUser[] | undefined; readonly doc: Y.Doc; readonly id: string; /** The room: the computer's network as it runs, its programs' windows. */ @@ -87,7 +91,7 @@ function useDesktopView(doc: Y.Doc, id: string) { return view; } -export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: open, onProgram: setOpen, origin, onMove, onFront, onClose }: DesktopWindowProps) { +export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: open, onProgram: setOpen, origin, onMove, onFront, onClose, watchers }: DesktopWindowProps) { const t = useTranslate(); const language = useLanguage(); const view = useDesktopView(doc, id); @@ -134,6 +138,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o

{title}

+ diff --git a/packages/editor/src/i18n/de.json b/packages/editor/src/i18n/de.json index 38b3ba0..1b9ee03 100644 --- a/packages/editor/src/i18n/de.json +++ b/packages/editor/src/i18n/de.json @@ -344,6 +344,8 @@ "personalFirewall.sameNetwork": "Absender im gleichen Rechnernetz", "personalFirewall.sender": "Absender", "personalFirewall.setup": "Konfiguration", + "presence.alsoHere": "Auch im Projekt: {names}", + "presence.watching": "Auch geöffnet bei: {names}", "program.commandPrompt": "Befehlszeile", "program.dnsServer": "DNS-Server", "program.echoServer": "Echo-Server", diff --git a/packages/editor/src/i18n/en.json b/packages/editor/src/i18n/en.json index 7b9ac28..ecf0a73 100644 --- a/packages/editor/src/i18n/en.json +++ b/packages/editor/src/i18n/en.json @@ -344,6 +344,8 @@ "personalFirewall.sameNetwork": "Local sender", "personalFirewall.sender": "Sender", "personalFirewall.setup": "Setup", + "presence.alsoHere": "Also in the project: {names}", + "presence.watching": "Also open for: {names}", "program.commandPrompt": "Command Line", "program.dnsServer": "DNS server", "program.echoServer": "Echo server", diff --git a/packages/editor/src/main.tsx b/packages/editor/src/main.tsx index 866d7bb..1f2a3f2 100644 --- a/packages/editor/src/main.tsx +++ b/packages/editor/src/main.tsx @@ -12,9 +12,10 @@ import { createRoot } from 'react-dom/client'; import * as Y from 'yjs'; import { changeFiles, desktopView, deviceSheet, emptyProject, fileText, topologyView, writeProject } from '@mdg-filius/doc'; import { openProjectArchive } from '@mdg-filius/fls'; -import { startBridge } from './bridge/dashboardBridge.js'; +import { EVENTS, startBridge, tellDashboard } from './bridge/dashboardBridge.js'; import { Editor } from './Editor.js'; import { SharedProject } from './remote/SharedProject.js'; +import type { PresenceUser } from './remote/presence.js'; import { LanguageProvider, translate, type Language } from './i18n/i18n.js'; import './theme/tokens.css'; @@ -37,10 +38,10 @@ function App() { const [fileName, setFileName] = useState(undefined); const [example, setExample] = useState(params.get('example') ?? ''); const project = params.get('project'); + const parent = params.get('parent'); useEffect(() => { if (params.get('theme') === 'dark') document.documentElement.dataset.theme = 'dark'; - const parent = params.get('parent'); if (parent === null) return undefined; return startBridge(window, parent, { onLocale: (locale) => setLanguage(locale.startsWith('en') ? 'en' : 'de'), @@ -90,7 +91,11 @@ function App() { return (
- + tellDashboard(window, parent, EVENTS.PEOPLE, { people }) })} + />
); diff --git a/packages/editor/src/remote/PresenceMarks.tsx b/packages/editor/src/remote/PresenceMarks.tsx new file mode 100644 index 0000000..ae7600e --- /dev/null +++ b/packages/editor/src/remote/PresenceMarks.tsx @@ -0,0 +1,47 @@ +// What the editor shows of the others in the project (plan §7.4): a dot in +// their colour on a window they have open too, and the people in the +// project beside the mode buttons — a circle each, with their initials. + +import { useTranslate } from '../i18n/i18n.js'; +import { colorOf, type PresenceUser } from './presence.js'; + +/** The initials of a name: of its first and last word. */ +export function initialsOf(name: string): string { + const words = name + .trim() + .split(/\s+/) + .filter((word) => word !== ''); + if (words.length === 0) return '?'; + const first = [...words[0]!][0]!; + return words.length === 1 ? first.toUpperCase() : `${first}${[...words.at(-1)!][0]!}`.toUpperCase(); +} + +/** Who else has this window open: a dot each. */ +export function WindowWatchers({ watchers }: { readonly watchers: readonly PresenceUser[] | undefined }) { + const t = useTranslate(); + if (watchers === undefined || watchers.length === 0) return null; + const names = watchers.map((user) => user.name).join(', '); + return ( + + {watchers.map((user) => ( + + ))} + + ); +} + +/** The others in the project. */ +export function PeopleRow({ people }: { readonly people: readonly PresenceUser[] }) { + const t = useTranslate(); + if (people.length === 0) return null; + const names = people.map((user) => user.name).join(', '); + return ( + + {people.map((user) => ( + + {initialsOf(user.name)} + + ))} + + ); +} diff --git a/packages/editor/src/remote/SharedProject.tsx b/packages/editor/src/remote/SharedProject.tsx index 4520946..88ad6dd 100644 --- a/packages/editor/src/remote/SharedProject.tsx +++ b/packages/editor/src/remote/SharedProject.tsx @@ -3,21 +3,37 @@ // /__auth. Until the document is there, a word that it is coming; when F // refuses or lets go — of the document or the room — a word why, instead of // the editor. Who only reads gets the editor locked (Editor's readOnly). +// The others in the project are shown in the editor and told to whoever +// embeds it (`onPeople`: the dashboard's header, over the bridge). import { useEffect, useMemo, useState } from 'react'; +import { peopleOf, type PresenceUser } from './presence.js'; +import { usePresence } from './usePresence.js'; import { Editor } from '../Editor.js'; import { CATALOGS, useLanguage, useTranslate } from '../i18n/i18n.js'; import type { SimHostFactory } from '../simulation/useSimulation.js'; import { RemoteSimHost, simUrl } from './remoteSimHost.js'; import { connectSharedDoc, type SharedDocState } from './sharedDoc.js'; +type Ready = Extract; + +/** The editor on the ready document, with the others in the project. */ +function ReadyEditor({ state, createSimHost, onPeople }: { readonly state: Ready; readonly createSimHost: SimHostFactory; readonly onPeople: ((people: readonly PresenceUser[]) => void) | undefined }) { + const presence = usePresence(state.awareness, state.you); + const key = JSON.stringify(peopleOf(presence.peers, state.you.id)); + useEffect(() => onPeople?.(JSON.parse(key) as PresenceUser[]), [key, onPeople]); + return ; +} + export interface SharedProjectProps { /** F's address. */ readonly server: string; readonly project: string; + /** The others in the project, each time they change. */ + readonly onPeople?: (people: readonly PresenceUser[]) => void; } -export function SharedProject({ server, project }: SharedProjectProps) { +export function SharedProject({ server, project, onPeople }: SharedProjectProps) { const t = useTranslate(); const language = useLanguage(); const [state, setState] = useState({ kind: 'connecting' }); @@ -47,5 +63,5 @@ export function SharedProject({ server, project }: SharedProjectProps) { {t('remote.connecting')}

); - return ; + return ; } diff --git a/packages/editor/src/remote/presence.ts b/packages/editor/src/remote/presence.ts new file mode 100644 index 0000000..56f7e0b --- /dev/null +++ b/packages/editor/src/remote/presence.ts @@ -0,0 +1,107 @@ +// Who else is in the project (plan §7.4), over Yjs awareness: each editor +// says who it is (F told it, `access.you`), the device it has chosen and the +// windows it has open — nothing else: no pointer (a tablet has none, and on +// a desktop it distracts in class) and nothing that changes without a +// cause, such as a time (Scratch's lesson 1: else a few events a minute +// become a stream). What another editor says is read with care: a state of +// another shape is left out. +// +// A person's name and colour are what their editor says. A pupil who +// changes their own editor could show another name to the others in the +// project; F does not check the awareness (it passes it on as Hocuspocus +// does). They see no more than before by it. + +export const PRESENCE_VERSION = 1; + +/** Who a person is, as F told their editor. */ +export interface PresenceUser { + readonly id: string; + readonly name: string; + /** The dashboard's colour of the person; empty when it has none (colorOf gives one). */ + readonly color: string; +} + +/** What an editor tells the others. */ +export interface PresenceState { + readonly v: 1; + readonly user: PresenceUser; + /** The device it has chosen. */ + readonly selection: string | null; + /** The windows it has open (windowKey). */ + readonly windows: readonly string[]; +} + +export interface Peer { + readonly clientId: number; + readonly state: PresenceState; +} + +/** A window's key, the same in every editor: a computer's desktop, a switch's or home router's table, the data exchange window. */ +export const windowKey = (kind: 'desktop' | 'sat' | 'nat' | 'capture', id = ''): string => (kind === 'capture' ? 'capture' : `${kind}:${id}`); + +/** The colours a person without one of the dashboard's gets, by their id (the same in every editor). */ +export const PRESENCE_COLORS = ['#d9480f', '#2b8a3e', '#1971c2', '#9c36b5', '#e67700', '#0c8599', '#c2255c', '#5f3dc4'] as const; + +export function colorOf(user: PresenceUser): string { + if (/^#[0-9a-f]{6}$/i.test(user.color)) return user.color; + let hash = 0; + for (const character of user.id) hash = (hash * 31 + character.charCodeAt(0)) >>> 0; + return PRESENCE_COLORS[hash % PRESENCE_COLORS.length]!; +} + +const isText = (value: unknown, most = 200): value is string => typeof value === 'string' && value.length <= most; + +/** Another editor's state, if it has the shape this one writes. */ +export function readPresence(value: unknown): PresenceState | undefined { + if (typeof value !== 'object' || value === null) return undefined; + const { v, user, selection, windows } = value as Record; + if (v !== PRESENCE_VERSION || typeof user !== 'object' || user === null) return undefined; + const { id, name, color } = user as Record; + if (!isText(id) || id === '' || !isText(name) || !isText(color, 32)) return undefined; + if (selection !== null && !isText(selection)) return undefined; + if (!Array.isArray(windows) || windows.length > 100 || !windows.every((each) => isText(each))) return undefined; + return { v: 1, user: { id, name, color }, selection, windows: windows as string[] }; +} + +/** The other editors in the project (this one left out), by name. */ +export function peersOf(states: ReadonlyMap, self: number): Peer[] { + const peers: Peer[] = []; + for (const [clientId, value] of states) { + if (clientId === self) continue; + const state = readPresence(value); + if (state !== undefined) peers.push({ clientId, state }); + } + return peers.sort((left, right) => left.state.user.name.localeCompare(right.state.user.name) || left.clientId - right.clientId); +} + +/** The people behind the editors, each once (a person with two tabs is one), leaving out `self` (this person in another tab). */ +export function peopleOf(peers: readonly Peer[], self?: string): PresenceUser[] { + const seen = new Map(); + for (const { state } of peers) if (state.user.id !== self && !seen.has(state.user.id)) seen.set(state.user.id, state.user); + return [...seen.values()]; +} + +/** Each device chosen by others: their colours, each person once. */ +export function selectionsOf(peers: readonly Peer[]): Map { + const chosen = new Map>(); + for (const { state } of peers) { + if (state.selection === null) continue; + const people = chosen.get(state.selection) ?? new Map(); + people.set(state.user.id, colorOf(state.user)); + chosen.set(state.selection, people); + } + return new Map([...chosen].map(([device, people]) => [device, [...people.values()]])); +} + +/** Each window open in other editors: who has it open, each person once. */ +export function watchersOf(peers: readonly Peer[]): Map { + const open = new Map>(); + for (const { state } of peers) { + for (const key of state.windows) { + const people = open.get(key) ?? new Map(); + people.set(state.user.id, state.user); + open.set(key, people); + } + } + return new Map([...open].map(([key, people]) => [key, [...people.values()]])); +} diff --git a/packages/editor/src/remote/sharedDoc.ts b/packages/editor/src/remote/sharedDoc.ts index 1d4049c..3f8a2a8 100644 --- a/packages/editor/src/remote/sharedDoc.ts +++ b/packages/editor/src/remote/sharedDoc.ts @@ -1,7 +1,8 @@ // The project's shared document on server F (plan §5): Hocuspocus' client // on /sync, signed in by the cookie F set at /__auth. What it says is the // editor's state: still connecting; ready — with the document, whether this -// person only reads, and whether because the project is frozen; refused +// person only reads, and whether because the project is frozen, who it is +// and the awareness the editors tell each other through (plan §7.4); refused // (no session, no grant, no such project); or let go (revoked, deleted). // // A person who read and may write again (a frozen project thawed) gets the @@ -13,10 +14,14 @@ import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider'; import * as Y from 'yjs'; import { SYNC_CLOSINGS, type AccessMessage } from '@mdg-filius/protocol'; +import type { PresenceUser } from './presence.js'; + +/** Hocuspocus' awareness of a document (y-protocols'). */ +export type Awareness = NonNullable; export type SharedDocState = | { readonly kind: 'connecting' } - | { readonly kind: 'ready'; readonly doc: Y.Doc; readonly readOnly: boolean; readonly frozen: boolean } + | { readonly kind: 'ready'; readonly doc: Y.Doc; readonly readOnly: boolean; readonly frozen: boolean; readonly awareness: Awareness; readonly you: PresenceUser } /** F refused the connection; the reason is one of SYNC_REFUSALS (or Hocuspocus' own word). */ | { readonly kind: 'refused'; readonly reason: string } /** F let the connection go; the reason is one of SYNC_CLOSINGS. */ @@ -56,10 +61,10 @@ export function connectSharedDoc({ server, project, onState, WebSocketPolyfill } let readOnly = false; let frozen = false; let synced = false; - /** F tells the access at once (`access`): ready only with it, so that a reader never sees a writable editor. */ - let told = false; + /** F tells the access at once (`access`, with who this is): ready only with it, so that a reader never sees a writable editor. */ + let you: PresenceUser | undefined; const tell = () => { - if (synced && told) onState({ kind: 'ready', doc, readOnly, frozen }); + if (synced && you !== undefined) onState({ kind: 'ready', doc, readOnly, frozen, awareness: provider.awareness!, you }); }; const provider = new HocuspocusProvider({ websocketProvider: socket, @@ -89,7 +94,7 @@ export function connectSharedDoc({ server, project, onState, WebSocketPolyfill } } readOnly = message.readOnly; frozen = message.frozen === true; - told = true; + if (message.you !== undefined) you = { id: message.you.user, name: message.you.name, color: message.you.color }; tell(); }, onClose: ({ event }) => { diff --git a/packages/editor/src/remote/usePresence.ts b/packages/editor/src/remote/usePresence.ts new file mode 100644 index 0000000..7c0cec7 --- /dev/null +++ b/packages/editor/src/remote/usePresence.ts @@ -0,0 +1,40 @@ +// The editor's side of the awareness (presence.ts): the others as they come, +// change and go, and this editor's own state told — only when it changed, +// for setting the same state again would send it again. + +import { useEffect, useMemo, useState } from 'react'; +import { peersOf, type Peer, type PresenceUser } from './presence.js'; +import type { Awareness } from './sharedDoc.js'; + +export interface Presence { + /** This person. */ + readonly you: PresenceUser; + /** The other editors in the project. */ + readonly peers: readonly Peer[]; + /** Tells the others the device chosen and the windows open (sent only when it changed). */ + publish(selection: string | null, windows: readonly string[]): void; +} + +/** Tells the others this editor's state, only when it changed (setting the same state again would send it again). */ +export function presencePublisher(awareness: Pick, you: PresenceUser): Presence['publish'] { + let told: string | undefined; + return (selection, windows) => { + const state = { v: 1, user: you, selection, windows }; + const key = JSON.stringify(state); + if (key === told) return; + told = key; + awareness.setLocalState(state); + }; +} + +export function usePresence(awareness: Awareness, you: PresenceUser): Presence { + const [peers, setPeers] = useState([]); + useEffect(() => { + const changed = () => setPeers(peersOf(awareness.getStates(), awareness.clientID)); + changed(); + awareness.on('change', changed); + return () => awareness.off('change', changed); + }, [awareness]); + const publish = useMemo(() => presencePublisher(awareness, you), [awareness, you]); + return { you, peers, publish }; +} diff --git a/packages/editor/src/sheet/sheet.css b/packages/editor/src/sheet/sheet.css index d390553..8e2af9d 100644 --- a/packages/editor/src/sheet/sheet.css +++ b/packages/editor/src/sheet/sheet.css @@ -363,3 +363,36 @@ padding: 24px; color: var(--text-primary); } + +/* The others in the project (plan §7.4): a circle each beside the mode buttons, a dot each on a window they have open too. */ +.people-row { + display: flex; + align-items: center; + gap: 4px; + min-height: 44px; +} + +.person-circle { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 16px; + border: 2px solid var(--surface-raised); + color: #fff; + font-size: 13px; + font-weight: 600; +} + +.window-watchers { + display: flex; + gap: 3px; + margin-left: 6px; +} + +.watcher-dot { + width: 10px; + height: 10px; + border-radius: 5px; +} diff --git a/packages/editor/src/simulation/CaptureWindow.tsx b/packages/editor/src/simulation/CaptureWindow.tsx index 8cf9ae6..d86c0cc 100644 --- a/packages/editor/src/simulation/CaptureWindow.tsx +++ b/packages/editor/src/simulation/CaptureWindow.tsx @@ -18,6 +18,8 @@ import { saveFile } from '../download.js'; import { useLanguage, useTranslate } from '../i18n/i18n.js'; import { captureWords, exportText, frameCells, frameDetails, type CaptureWords } from './captureCells.js'; import type { Simulation } from './useSimulation.js'; +import { WindowWatchers } from '../remote/PresenceMarks.js'; +import type { PresenceUser } from '../remote/presence.js'; /** A card's tab: its title, the frame chosen in it and whether it scrolls along — each table's own, as in Java, kept while the window is closed. */ export interface CaptureTab { @@ -28,6 +30,8 @@ export interface CaptureTab { } export interface CaptureWindowProps { + /** Who else in the project has this window open (plan §7.4). */ + readonly watchers?: readonly PresenceUser[] | undefined; readonly tabs: readonly CaptureTab[]; readonly selected: string; readonly simulation: Simulation; @@ -76,7 +80,7 @@ const FrameRow = memo(function FrameRow({ ); }); -export function CaptureWindow({ tabs, selected, simulation, x, y, z, onSelect, onCloseTab, onChoose, onAutoscroll, onClear, onMove, onFront, onClose }: CaptureWindowProps) { +export function CaptureWindow({ tabs, selected, simulation, x, y, z, onSelect, onCloseTab, onChoose, onAutoscroll, onClear, onMove, onFront, onClose, watchers }: CaptureWindowProps) { const t = useTranslate(); const language = useLanguage(); const words = useMemo(() => captureWords(t, language), [t, language]); @@ -118,6 +122,7 @@ export function CaptureWindow({ tabs, selected, simulation, x, y, z, onSelect, o

{title}

+ diff --git a/packages/editor/src/simulation/ViewerWindow.tsx b/packages/editor/src/simulation/ViewerWindow.tsx index 9d9c806..d895d32 100644 --- a/packages/editor/src/simulation/ViewerWindow.tsx +++ b/packages/editor/src/simulation/ViewerWindow.tsx @@ -15,10 +15,14 @@ import { ICON_URLS } from '../canvas/icons.js'; import { useTranslate } from '../i18n/i18n.js'; import { timeOfDay } from './timeOfDay.js'; import type { Simulation } from './useSimulation.js'; +import { WindowWatchers } from '../remote/PresenceMarks.js'; +import type { PresenceUser } from '../remote/presence.js'; export type ViewerKind = 'sat' | 'nat'; export interface ViewerWindowProps { + /** Who else in the project has this window open (plan §7.4). */ + readonly watchers?: readonly PresenceUser[] | undefined; readonly kind: ViewerKind; readonly id: string; readonly label: string; @@ -31,7 +35,7 @@ export interface ViewerWindowProps { readonly onClose: () => void; } -export function ViewerWindow({ kind, id, label, simulation, x, y, z, onMove, onFront, onClose }: ViewerWindowProps) { +export function ViewerWindow({ kind, id, label, simulation, x, y, z, onMove, onFront, onClose, watchers }: ViewerWindowProps) { const t = useTranslate(); const topic = `app:${id}/${kind}` as const; const { watch, act, clock } = simulation; @@ -69,6 +73,7 @@ export function ViewerWindow({ kind, id, label, simulation, x, y, z, onMove, onF

{title}

+ diff --git a/packages/editor/test/presence.test.ts b/packages/editor/test/presence.test.ts new file mode 100644 index 0000000..51dc8d9 --- /dev/null +++ b/packages/editor/test/presence.test.ts @@ -0,0 +1,126 @@ +// The others in the project (plan §7.4): another editor's state read with +// care, the others by name without this editor, each person once however +// many tabs they have, their choices as frames and their windows as dots, +// a colour for whoever the dashboard gave none, initials; and this editor's +// state told only when it changed — nothing that changes without a cause. + +import { describe, expect, it } from 'vitest'; +import { initialsOf } from '../src/remote/PresenceMarks.js'; +import { colorOf, peersOf, peopleOf, PRESENCE_COLORS, readPresence, selectionsOf, watchersOf, windowKey, type PresenceState } from '../src/remote/presence.js'; +import { presencePublisher } from '../src/remote/usePresence.js'; + +const state = (id: string, name: string, selection: string | null = null, windows: string[] = [], color = '#112233'): PresenceState => ({ v: 1, user: { id, name, color }, selection, windows }); + +describe('the others in the project', () => { + it('reads a state of the shape it writes, and leaves out any other', () => { + expect(readPresence(state('u', 'Mia', 'd', ['desktop:d']))).toEqual(state('u', 'Mia', 'd', ['desktop:d'])); + for (const bad of [ + null, + 'Mia', + { ...state('u', 'Mia'), v: 2 }, + { ...state('u', 'Mia'), user: null }, + { ...state('', 'Mia') }, + { ...state('u', 7 as unknown as string) }, + { ...state('u', 'x'.repeat(201)) }, + { ...state('u', 'Mia'), user: { id: 'u', name: 'Mia', color: 'c'.repeat(33) } }, + { ...state('u', 'Mia'), selection: 5 }, + { ...state('u', 'Mia'), windows: 'desktop:d' }, + { ...state('u', 'Mia'), windows: [7] }, + { ...state('u', 'Mia'), windows: Array.from({ length: 101 }, () => 'w') }, + ]) + expect(readPresence(bad)).toBeUndefined(); + }); + + it('lists the other editors by name, without this one and without what it cannot read', () => { + const states = new Map([ + [1, state('me', 'Zoe')], + [2, state('b', 'Ben')], + [3, { cursor: { x: 1 } }], + [4, state('a', 'Ada')], + [5, state('b', 'Ben')], + ]); + expect(peersOf(states, 1).map((peer) => [peer.clientId, peer.state.user.name])).toEqual([ + [4, 'Ada'], + [2, 'Ben'], + [5, 'Ben'], + ]); + }); + + it('counts a person with two tabs once, and leaves out this person in another tab', () => { + const peers = peersOf( + new Map([ + [2, state('b', 'Ben')], + [3, state('b', 'Ben')], + [4, state('me', 'Zoe')], + ]), + 1, + ); + expect(peopleOf(peers, 'me')).toEqual([{ id: 'b', name: 'Ben', color: '#112233' }]); + expect(peopleOf(peers).map((user) => user.id)).toEqual(['b', 'me']); + }); + + it('gives each chosen device the colours of who chose it, each person once', () => { + const peers = peersOf( + new Map([ + [2, state('b', 'Ben', 'router', [], '#aa0000')], + [3, state('b', 'Ben', 'router', [], '#aa0000')], + [4, state('a', 'Ada', 'router', [], '#00aa00')], + [5, state('c', 'Cem', 'pc', [], '#0000aa')], + [6, state('d', 'Dan', null)], + ]), + 1, + ); + expect(selectionsOf(peers)).toEqual( + new Map([ + ['router', ['#00aa00', '#aa0000']], + ['pc', ['#0000aa']], + ]), + ); + }); + + it('gives each open window who has it open, each person once', () => { + const desktop = windowKey('desktop', 'pc'); + const peers = peersOf( + new Map([ + [2, state('b', 'Ben', null, [desktop, 'capture'])], + [3, state('b', 'Ben', null, [desktop])], + [4, state('a', 'Ada', null, [windowKey('sat', 's')])], + ]), + 1, + ); + const watchers = watchersOf(peers); + expect([...watchers.keys()].sort()).toEqual(['capture', 'desktop:pc', 'sat:s']); + expect(watchers.get(desktop)!.map((user) => user.id)).toEqual(['b']); + expect([windowKey('nat', 'g'), windowKey('capture', 'ignored')]).toEqual(['nat:g', 'capture']); + }); + + it("takes the dashboard's colour, and gives one by the id to whoever has none", () => { + expect(colorOf({ id: 'x', name: 'X', color: '#ABCDEF' })).toBe('#ABCDEF'); + const given = colorOf({ id: 'person-1', name: 'X', color: '' }); + expect(PRESENCE_COLORS).toContain(given); + expect(colorOf({ id: 'person-1', name: 'Y', color: 'blue' })).toBe(given); + // Every editor gives the same colour, whatever its version: pinned. + expect([colorOf({ id: '0b8f5f2e-1a3c-4d5e-8f90-123456789abc', name: '', color: '' }), colorOf({ id: 'person-2', name: '', color: '' })]).toEqual(['#e67700', '#1971c2']); + const all = new Set(Array.from({ length: 40 }, (_, index) => colorOf({ id: `p${index}`, name: '', color: '' }))); + expect(all.size).toBeGreaterThan(4); + }); + + it('writes the initials of the first and the last word', () => { + expect([initialsOf('Ada Beispiel'), initialsOf('mia'), initialsOf('Jörg Ömer Weiß'), initialsOf(' '), initialsOf('🐧 Pinguin')]).toEqual(['AB', 'M', 'JW', '?', '🐧P']); + }); + + it("tells this editor's state only when it changed", () => { + const set: unknown[] = []; + const publish = presencePublisher({ setLocalState: (value) => void set.push(value) }, { id: 'me', name: 'Zoe', color: '' }); + publish(null, []); + publish(null, []); + publish('pc', []); + publish('pc', []); + publish('pc', ['desktop:pc']); + expect(set).toEqual([ + { v: 1, user: { id: 'me', name: 'Zoe', color: '' }, selection: null, windows: [] }, + { v: 1, user: { id: 'me', name: 'Zoe', color: '' }, selection: 'pc', windows: [] }, + { v: 1, user: { id: 'me', name: 'Zoe', color: '' }, selection: 'pc', windows: ['desktop:pc'] }, + ]); + }); +}); diff --git a/packages/protocol/src/sync.ts b/packages/protocol/src/sync.ts index 9ec63e5..42d7367 100644 --- a/packages/protocol/src/sync.ts +++ b/packages/protocol/src/sync.ts @@ -43,4 +43,6 @@ export interface AccessMessage { readonly readOnly: boolean; /** The project is frozen (its deadline passed): no one writes, whatever their level. */ readonly frozen: boolean; + /** Who this is (from the dashboard's ticket), told when the connection comes: the editor shows it to the others (plan §7.4). */ + readonly you?: { readonly user: string; readonly name: string; readonly color: string }; } diff --git a/packages/server/src/sync.ts b/packages/server/src/sync.ts index b5c7537..63e34bf 100644 --- a/packages/server/src/sync.ts +++ b/packages/server/src/sync.ts @@ -73,9 +73,9 @@ export function cookieValue(header: string | undefined, name: string): string | return undefined; } -/** What a connection is told when its access changes. */ -export function accessMessage(readOnly: boolean, frozen: boolean): string { - const message: AccessMessage = { v: 1, kind: 'access', readOnly, frozen }; +/** What a connection is told when it comes (with who it is) and when its access changes. */ +export function accessMessage(readOnly: boolean, frozen: boolean, person?: SessionPerson): string { + const message: AccessMessage = { v: 1, kind: 'access', readOnly, frozen, ...(person === undefined ? {} : { you: { user: person.user, name: person.name, color: person.color } }) }; return JSON.stringify(message); } @@ -121,7 +121,8 @@ export function createSync({ store, sessions, log, compactAfterIdleMilliseconds }, // Told at once, so that the editor knows from the start whether it reads, and why. async connected({ connection, context }) { - connection.sendStateless(accessMessage(connection.readOnly, (context as ConnectionContext).frozen)); + const { frozen, person } = context as ConnectionContext; + connection.sendStateless(accessMessage(connection.readOnly, frozen, person)); }, async onLoadDocument({ documentName, document }) { const persistence = new DocumentPersistence(store, documentName, log); diff --git a/packages/server/test/access.test.ts b/packages/server/test/access.test.ts index f7d13ac..b24a53c 100644 --- a/packages/server/test/access.test.ts +++ b/packages/server/test/access.test.ts @@ -319,11 +319,15 @@ describe("the dashboard's service", () => { it('freezes a project: whoever writes in it reads from then on and is told, and writes again when it is thawed', async () => { const server = await start(); const project = await newProject(); - const writer = await enter(server, project, { level: 'write' }); - const reader = await enter(server, project, { level: 'read' }); - // Each is told its access as it comes in. + const [writerId, readerId] = [randomUUID(), randomUUID()]; + const writer = await enter(server, project, { level: 'write', user: writerId, name: 'Mia' }); + const reader = await enter(server, project, { level: 'read', user: readerId, name: 'Rita' }); + // Each is told its access as it comes in, and who it is (with the colour of the ticket). await until(() => writer.messages.length === 1 && reader.messages.length === 1); - expect([writer.messages, reader.messages]).toEqual([[{ v: 1, kind: 'access', readOnly: false, frozen: false }], [{ v: 1, kind: 'access', readOnly: true, frozen: false }]]); + expect([writer.messages, reader.messages]).toEqual([ + [{ v: 1, kind: 'access', readOnly: false, frozen: false, you: { user: writerId, name: 'Mia', color: '#3366cc' } }], + [{ v: 1, kind: 'access', readOnly: true, frozen: false, you: { user: readerId, name: 'Rita', color: '#3366cc' } }], + ]); expect(await answer(await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: true }))).toEqual([200, { frozen: true }]); await until(() => writer.messages.length === 2); expect(writer.messages[1]).toEqual({ v: 1, kind: 'access', readOnly: true, frozen: true }); @@ -331,7 +335,7 @@ describe("the dashboard's service", () => { const late = await enter(server, project, { level: 'write' }); expect(late.provider.authorizedScope).toBe('readonly'); await until(() => late.messages.length === 1); - expect(late.messages).toEqual([{ v: 1, kind: 'access', readOnly: true, frozen: true }]); + expect(late.messages).toEqual([{ v: 1, kind: 'access', readOnly: true, frozen: true, you: expect.objectContaining({ name: 'Ada Beispiel' }) }]); place(late.doc, 'a'); await sleep(300); expect(await store.pending(project)).toBe(0); diff --git a/packages/server/test/editorConnection.test.ts b/packages/server/test/editorConnection.test.ts index 5c733ff..66734a5 100644 --- a/packages/server/test/editorConnection.test.ts +++ b/packages/server/test/editorConnection.test.ts @@ -10,6 +10,7 @@ import { randomUUID } from 'node:crypto'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import WebSocket from 'ws'; import { addDevice } from '@mdg-filius/doc'; +import { peersOf } from '../../editor/src/remote/presence.js'; import { connectSharedDoc, type SharedDocState } from '../../editor/src/remote/sharedDoc.js'; import { PostgresSessions, PostgresStore } from '../src/index.js'; import { testDatabase, type TestDatabase } from './database.js'; @@ -68,6 +69,29 @@ describe("the editor's shared document", () => { await server.close(); }); + it('knows who it is, from F, and sees the others through the awareness', async () => { + const server = await startTestServer(store, sessions); + const project = await newProject(store); + const [miaId, benId] = [randomUUID(), randomUUID()]; + const mia = connectEditor(server, project, await signIn(server, project, { user: miaId, name: 'Mia' })); + const ben = connectEditor(server, project, await signIn(server, project, { user: benId, name: 'Ben', level: 'read' })); + await mia.ready(); + await ben.ready(); + const miaReady = mia.last() as Extract; + const benReady = ben.last() as Extract; + expect(miaReady.you).toEqual({ id: miaId, name: 'Mia', color: '#3366cc' }); + expect(benReady.you).toMatchObject({ id: benId, name: 'Ben' }); + // A reader is seen too: the awareness is not the document. + benReady.awareness.setLocalState({ v: 1, user: benReady.you, selection: 'router', windows: [] }); + await until(() => peersOf(miaReady.awareness.getStates(), miaReady.awareness.clientID).length === 1); + expect(peersOf(miaReady.awareness.getStates(), miaReady.awareness.clientID)[0]!.state).toEqual({ v: 1, user: benReady.you, selection: 'router', windows: [] }); + // Gone, he is gone for her too. + ben.close(); + await until(() => peersOf(miaReady.awareness.getStates(), miaReady.awareness.clientID).length === 0); + mia.close(); + await server.close(); + }); + it('reads while the project is frozen and gets the document anew when it is thawed', async () => { const server = await startTestServer(store, sessions); const project = await newProject(store);