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
+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>