feat: F5.4 presence: who else is in the project

Over Yjs awareness each editor tells who it is (F names the person in
the access message), the device it has chosen and the windows it has
open, and nothing else: no pointer, nothing that changes without a
cause, sent only when it changed. Others' states are read with care.
A frame in their colour round a device they chose, a dot on a window
they have open too, a circle with initials each beside the mode
buttons; the bridge tells the dashboard who is there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 16:09:34 +00:00
co-authored by Claude Opus 5.5
parent f52852493a
commit 47beda6b85
22 changed files with 600 additions and 33 deletions
+69 -6
View File
@@ -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<void> };
@@ -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();
+29 -1
View File
@@ -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 })}
</span>
) : null}
<PeopleRow people={people} />
{readOnly ? (
<span className="editor-note" data-note="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))}
/>
) : (
<DesktopWindow
@@ -541,6 +568,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', desktop.id))}
/>
),
)
@@ -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<Window, 'parent'>, parentOrigin: string, type: (typeof EVENTS)[keyof typeof EVENTS], fields: Readonly<Record<string, unknown>> = {}): void {
window.parent.postMessage({ v: BRIDGE_VERSION, type, ...fields }, parentOrigin);
}
+38 -1
View File
@@ -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<string, readonly string[]>;
/** 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<string> = new Set();
const NO_CHOICES: ReadonlyMap<string, readonly string[]> = 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 (
<g
className="device"
@@ -183,6 +202,22 @@ function Device({ node, box, kindName, selected, pending }: { readonly node: Nod
>
{/* The whole button takes the pointer, as Java's label does. */}
<rect x={box.x} y={box.y} width={box.width} height={box.height} fill="transparent" />
{chosenBy.map((color, index) => (
<rect
key={color}
className="chosen-by"
data-color={color}
x={box.x - 5 - 3 * index}
y={box.y - 5 - 3 * index}
width={box.width + 10 + 6 * index}
height={box.height + 10 + 6 * index}
rx={8}
fill="none"
stroke={color}
strokeWidth={2}
pointerEvents="none"
/>
))}
{selected || pending ? (
<rect
x={box.x - 2}
@@ -235,6 +270,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
highlightedLink = null,
mode = 'design',
locked = false,
chosenBy = NO_CHOICES,
activeLinks = NO_LINKS,
onActivate,
onDocFrame,
@@ -631,6 +667,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(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}
/>
))}
@@ -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
<header className="desktop-titlebar" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
<img src={ICON_URLS[view.kind === 'notebook' ? 'laptop' : 'server']} alt="" />
<h2>{title}</h2>
<WindowWatchers watchers={watchers} />
<button type="button" className="desktop-close" aria-label={t('sheet.close')} title={t('sheet.close')} onClick={onClose}>
×
</button>
+2
View File
@@ -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",
+2
View File
@@ -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",
+8 -3
View File
@@ -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<string | undefined>(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 (
<LanguageProvider language={language}>
<main style={{ height: '100%' }}>
<SharedProject server={params.get('server') ?? window.location.origin} project={project} />
<SharedProject
server={params.get('server') ?? window.location.origin}
project={project}
{...(parent === null ? {} : { onPeople: (people: readonly PresenceUser[]) => tellDashboard(window, parent, EVENTS.PEOPLE, { people }) })}
/>
</main>
</LanguageProvider>
);
@@ -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 (
<span className="window-watchers" role="img" aria-label={t('presence.watching', { names })} title={t('presence.watching', { names })}>
{watchers.map((user) => (
<span key={user.id} className="watcher-dot" data-watcher={user.id} style={{ background: colorOf(user) }} />
))}
</span>
);
}
/** 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 (
<span className="people-row" role="group" aria-label={t('presence.alsoHere', { names })} title={t('presence.alsoHere', { names })}>
{people.map((user) => (
<span key={user.id} className="person-circle" data-person={user.id} style={{ background: colorOf(user) }}>
{initialsOf(user.name)}
</span>
))}
</span>
);
}
+18 -2
View File
@@ -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<SharedDocState, { kind: 'ready' }>;
/** 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 <Editor doc={state.doc} createSimHost={createSimHost} readOnly={state.readOnly} frozen={state.frozen} presence={presence} />;
}
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<SharedDocState>({ kind: 'connecting' });
@@ -47,5 +63,5 @@ export function SharedProject({ server, project }: SharedProjectProps) {
{t('remote.connecting')}
</p>
);
return <Editor key={state.doc.guid} doc={state.doc} createSimHost={createSimHost} readOnly={state.readOnly} frozen={state.frozen} />;
return <ReadyEditor key={state.doc.guid} state={state} createSimHost={createSimHost} onPeople={onPeople} />;
}
+107
View File
@@ -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<string, unknown>;
if (v !== PRESENCE_VERSION || typeof user !== 'object' || user === null) return undefined;
const { id, name, color } = user as Record<string, unknown>;
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<number, unknown>, 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<string, PresenceUser>();
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<string, string[]> {
const chosen = new Map<string, Map<string, string>>();
for (const { state } of peers) {
if (state.selection === null) continue;
const people = chosen.get(state.selection) ?? new Map<string, string>();
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<string, PresenceUser[]> {
const open = new Map<string, Map<string, PresenceUser>>();
for (const { state } of peers) {
for (const key of state.windows) {
const people = open.get(key) ?? new Map<string, PresenceUser>();
people.set(state.user.id, state.user);
open.set(key, people);
}
}
return new Map([...open].map(([key, people]) => [key, [...people.values()]]));
}
+11 -6
View File
@@ -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<HocuspocusProvider['awareness']>;
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 }) => {
+40
View File
@@ -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<Awareness, 'setLocalState'>, 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<readonly Peer[]>([]);
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 };
}
+33
View File
@@ -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;
}
@@ -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
<section className="desktop-window capture-window" role="dialog" aria-label={title} data-capture="" style={{ left: x, top: y, zIndex: 5 + z }} onPointerDownCapture={onFront}>
<header className="desktop-titlebar" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
<h2>{title}</h2>
<WindowWatchers watchers={watchers} />
<button type="button" className="desktop-close" aria-label={t('sheet.close')} title={t('sheet.close')} onClick={onClose}>
×
</button>
@@ -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
<header className="desktop-titlebar" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
<img src={ICON_URLS[kind === 'sat' ? 'switch' : 'router']} alt="" />
<h2>{title}</h2>
<WindowWatchers watchers={watchers} />
<button type="button" className="desktop-close" aria-label={t('sheet.close')} title={t('sheet.close')} onClick={onClose}>
×
</button>
+126
View File
@@ -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<number, unknown>([
[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<number, unknown>([
[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<number, unknown>([
[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<number, unknown>([
[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'] },
]);
});
});