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
+9 -5
View File
@@ -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);
@@ -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<SharedDocState, { kind: 'ready' }>;
const benReady = ben.last() as Extract<SharedDocState, { kind: 'ready' }>;
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);