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
@@ -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 };
}