Files
kokkoja-rel/client/js/session.js
T

974 lines
35 KiB
JavaScript
Raw Normal View History

2026-10-06 15:58:02 +02:00
// @ts-check
/**
* A session: the page, the participants, and the messages that keep every
* participant's copy of the page the same.
*
* The rule that makes this simple is that each layer has one writer. A
* participant is the authority on their own layer, so there are no
* conflicting edits to reconcile. See notes/protocol.md for the messages.
*/
import {
decrypt, encrypt, fromBase64, hashOf, randomId, signClaim, toBase64, verifyClaim,
} from './crypto.js';
import {
MAX_STROKE_POINTS, POINT_SIZE, Page, appendPoints, cleanName, cleanPageDescriptor,
cleanStroke, cleanText, placeImage, pointCount, validId, validPoints,
} from './page_model.js';
import { RelayConnection } from './relay_connection.js';
/** @typedef {import('./crypto.js').Identity} Identity */
/** @typedef {import('./page_model.js').Layer} Layer */
/** @typedef {import('./page_model.js').Stroke} Stroke */
/** @typedef {import('./page_model.js').PageImage} PageImage */
/** @typedef {import('./page_model.js').TextBlock} TextBlock */
/**
* @typedef {object} Participant
* @property {string} id
* @property {string} name
* @property {number | null} peer Relay peer number; null when not connected.
* @property {boolean} self
* @property {boolean} host
*/
/**
* @typedef {object} SessionEvents
* @property {(layerId: string) => void} onLayerReplaced
* A layer's contents changed as a whole and must be redrawn.
* @property {(layerId: string, stroke: Stroke) => void} onStrokeProgress
* A stroke gained points or was finished.
* @property {(layerId: string, block: TextBlock) => void} onTextChanged
* A text block was made or changed.
* @property {(oldPage: Page, newPage: Page) => void} onPageReplaced
* The session moved to another page.
* @property {() => void} onImagesChanged
* An image was added to the page, or the bytes of one have arrived.
* @property {(byImages: boolean) => void} onPageResized
* The page grew (or shrank) around its contents; `byImages` if an
* image was added or taken back, not if writing reached an edge.
* @property {() => void} onPermissionsChanged
* Who may start pages changed.
* @property {() => void} onParticipantsChanged
* @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus
*/
/**
* @typedef {object} SessionOptions
* @property {string} relay
* @property {string} roomId
* @property {CryptoKey} key
* @property {Identity} identity
* @property {string} hostId
* @property {string} name Our display name.
* @property {Page} [page] The page as last saved, when returning to
* the session.
*/
/** Approximate size at which a layer snapshot is split into messages. */
const SNAPSHOT_CHUNK_CHARACTERS = 100000;
/** An image is sent in pieces of this many bytes. */
const IMAGE_CHUNK_BYTES = 96 * 1024;
export class Session {
/**
* @param {SessionOptions} options
* @param {SessionEvents} events
*/
constructor(options, events) {
this.roomId = options.roomId;
this.key = options.key;
this.identity = options.identity;
this.hostId = options.hostId;
this.name = cleanName(options.name) || 'Unnamed';
/**
* The image we have just added, which we may take back until anything
* else changes on the page.
* @type {PageImage | null}
*/
this.removableImage = null;
// Every event that reports a change to the page ends that.
/** @type {SessionEvents} */
this.events = {
...events,
onLayerReplaced: (layerId) => {
this.removableImage = null;
events.onLayerReplaced(layerId);
},
onStrokeProgress: (layerId, stroke) => {
this.removableImage = null;
events.onStrokeProgress(layerId, stroke);
},
onTextChanged: (layerId, block) => {
this.removableImage = null;
events.onTextChanged(layerId, block);
},
onPageReplaced: (oldPage, newPage) => {
this.removableImage = null;
events.onPageReplaced(oldPage, newPage);
},
onImagesChanged: () => {
this.removableImage = null;
events.onImagesChanged();
},
};
this.isHost = this.identity.id === this.hostId;
/** @type {Set<string>} Guests the host allows to start pages. */
this.permitted = new Set();
// The host begins a session with page 1. A guest shows a placeholder
// until the host's page arrives.
this.page = options.page ??
(this.isHost ? new Page(undefined, undefined, randomId(), 1) : new Page());
this.ownLayer = this.page.layer(this.identity.id, this.name);
this.page.recomputeStrokeBounds();
this.page.updateExtent();
/** @type {Map<string, Participant>} */
this.participants = new Map();
this.participants.set(this.identity.id, {
id: this.identity.id, name: this.name, peer: null, self: true,
host: this.identity.id === this.hostId,
});
for (const layer of this.page.layers.values()) {
if (layer.id === this.identity.id) continue;
this.participants.set(layer.id, {
id: layer.id, name: layer.name || 'Unnamed', peer: null, self: false,
host: layer.id === this.hostId,
});
}
/** @type {Map<number, string>} Relay peer number to participant id. */
this.peerIds = new Map();
/** @type {Set<number>} Peers the relay says are present. */
this.presentPeers = new Set();
/** @type {Map<number, number>} Last sequence number seen from a peer. */
this.lastSeq = new Map();
/** @type {Map<string, {name: string, strokes: Stroke[], texts: TextBlock[]}>} Snapshots being received. */
this.incoming = new Map();
/**
* The bytes of the page's images received so far, by each image's hash.
* @type {Map<string, {buffer: Uint8Array<ArrayBuffer>, received: number}>}
*/
this.imageTransfers = new Map();
/** @type {Map<string, Set<number>>} Peers waiting for an image from us, by its hash. */
this.imageRequests = new Map();
/**
* The peer whose announcement moved us to the present page. Its
* snapshots of any layer of that page are accepted: when a page is
* loaded from a notebook, that peer sends the writing kept with it.
*/
this.pageSourcePeer = 0;
this.myPeer = 0;
/** The peer asked for the layers of absent participants. */
this.statePeer = 0;
this.stateComplete = true;
/**
* True while our own layer may be completed from another participant's
* copy: on the first connection after the browser page was loaded, when
* the others may hold strokes of ours that we had not yet saved.
*/
this.recoverOwnLayer = false;
this.firstConnection = true;
/** Counts connections, so that work queued for an old one is dropped. */
this.epoch = 0;
this.seq = 0;
this.sendChain = Promise.resolve();
this.receiveChain = Promise.resolve();
/** @type {Stroke | null} The stroke we are drawing. */
this.currentStroke = null;
this.connection = new RelayConnection(options.relay, options.roomId, {
onWelcome: (peer, peers) => this.welcomed(peer, peers),
onJoin: (peer) => { this.presentPeers.add(peer); },
onLeave: (peer) => this.peerLeft(peer),
onMessage: (peer, body) => this.received(peer, body),
onStatus: (status, detail) => {
if (status !== 'connected') this.disconnected();
this.events.onStatus(status, detail);
},
});
}
start() {
this.connection.connect();
}
/** @returns {Participant[]} Ourselves first, then the others by name. */
participantList() {
return [...this.participants.values()].sort((a, b) =>
Number(b.self) - Number(a.self) || a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
}
/** @returns {string[]} Layer ids from bottom to top. */
layerOrder() {
return this.page.layerOrder(this.hostId);
}
// Pages.
/** @returns {boolean} Whether we may start pages: the host, or a guest the host allows. */
mayMakePages() {
return this.isHost || this.permitted.has(this.identity.id);
}
/**
* Allow a guest to start pages, or stop allowing it. Only the host may.
* @param {string} id
* @param {boolean} allowed
*/
setPermitted(id, allowed) {
if (!this.isHost || id === this.identity.id) return;
if (allowed) this.permitted.add(id);
else this.permitted.delete(id);
this.send(0, { type: 'permissions', ids: [...this.permitted] });
this.events.onPermissionsChanged();
}
/**
* The page's rectangle may have changed with its contents.
* @param {boolean} [byImages] Whether its images were what changed.
*/
extentChanged(byImages = false) {
if (this.page.updateExtent()) this.events.onPageResized(byImages);
}
/**
* Start a new blank page for everyone. The host may; so may a guest the
* host allows.
*/
newPage() {
if (!this.mayMakePages()) return;
const page = new Page(undefined, undefined, randomId(), this.page.number + 1);
page.updateExtent();
this.replacePage(page);
this.send(0, { type: 'page', page: page.descriptor() });
}
/**
* Add an image to the page for everyone, below the images already on it.
* The host may; so may a guest the host allows.
* @param {object} prepared
* @param {PageImage['type']} prepared.type
* @param {Uint8Array<ArrayBuffer>} prepared.data
* @param {string} prepared.hash
* @param {number} prepared.naturalWidth In page units.
* @param {number} prepared.naturalHeight
* @param {boolean} [shrink] Whether an image wider than the sheet is
* scaled down to it; otherwise it keeps its size and widens the sheet.
* @returns {boolean} False if the page has no room for it.
*/
addImage(prepared, shrink = true) {
const page = this.page;
if (!this.mayMakePages() || page.number === 0) return false;
const place = placeImage(prepared.naturalWidth, prepared.naturalHeight, page.imagesBottom(), shrink);
if (!page.hasRoomForImage(prepared.data.length, place.y + place.height)) return false;
/** @type {PageImage} */
const image = {
type: prepared.type, size: prepared.data.length, hash: prepared.hash, data: prepared.data,
x: place.x, y: place.y, width: place.width, height: place.height,
};
page.images.push(image);
page.imagesRevision += 1;
page.baseWidth = Math.max(page.baseWidth, place.pageWidth);
page.baseHeight = Math.max(page.baseHeight, place.pageHeight);
this.send(0, { type: 'page', page: page.descriptor() });
this.sendImage(0, image);
this.events.onImagesChanged();
this.removableImage = image;
this.extentChanged(true);
return true;
}
/**
* Take back the image we have just added: the remedy for having chosen
* the wrong file. It works only until anything else changes on the
* page (Andy, 2026-10-05), so nothing can have been written on the image,
* and since it is the lowest image, no other image is affected.
* @returns {boolean} Whether an image was taken back.
*/
removeAddedImage() {
const page = this.page;
const image = this.removableImage;
if (!image || page.images.at(-1) !== image) return false;
page.images.pop();
page.imagesRevision += 1;
page.baseWidth = page.sheetWidth();
page.baseHeight = page.sheetHeight();
this.send(0, { type: 'page', page: page.descriptor() });
this.events.onImagesChanged();
this.extentChanged(true);
return true;
}
/**
* Start a new page for everyone from a page kept in a notebook, with
* everything that was on it. Its layers keep their owners: a layer of
* someone present is theirs to erase again.
* @param {Page} kept As read from the page file.
*/
loadPage(kept) {
if (!this.mayMakePages()) return;
const page = new Page(kept.baseWidth, kept.baseHeight, randomId(), this.page.number + 1);
page.images = kept.images.map((image) => ({ ...image }));
page.imagesRevision = kept.imagesRevision;
for (const [id, layer] of kept.layers) {
const copy = page.layer(id, layer.name);
for (const stroke of layer.strokes.values()) {
copy.strokes.set(stroke.id, { ...stroke, points: stroke.points.slice(), done: true });
}
for (const block of layer.texts.values()) copy.texts.set(block.id, { ...block, lines: block.lines.slice() });
if (!this.participants.has(id)) {
this.participants.set(id, {
id, name: layer.name || 'Unnamed', peer: null, self: false, host: id === this.hostId,
});
}
}
page.recomputeStrokeBounds();
page.updateExtent();
this.replacePage(page);
this.send(0, { type: 'page', page: page.descriptor() });
const sent = new Set();
for (const image of page.images) {
if (!sent.has(image.hash)) this.sendImage(0, image);
sent.add(image.hash);
}
for (const layer of page.layers.values()) {
if (!layer.isEmpty()) this.sendLayer(0, layer);
}
this.events.onParticipantsChanged();
}
// Text on our own layer.
/**
* Make or change a text block of ours. The whole block is sent each
* time, so that it appears on the other screens as it is typed.
* @param {TextBlock} block
*/
setText(block) {
if (this.page.number === 0) return;
this.ownLayer.texts.set(block.id, block);
this.send(0, { type: 'text_set', page: this.page.id, text: block });
this.page.recomputeStrokeBounds();
this.events.onTextChanged(this.identity.id, block);
this.extentChanged();
}
/** @param {string[]} ids Text blocks of our own layer. */
removeTexts(ids) {
const removed = ids.filter((id) => this.ownLayer.texts.delete(id));
if (removed.length === 0) return;
this.send(0, { type: 'text_remove', page: this.page.id, ids: removed });
this.page.recomputeStrokeBounds();
this.events.onLayerReplaced(this.identity.id);
this.extentChanged();
}
/**
* @param {number} destination
* @param {PageImage} image An image of the present page whose bytes we hold.
*/
sendImage(destination, image) {
const data = image.data;
if (!data) return;
for (let at = 0; at < data.length; at += IMAGE_CHUNK_BYTES) {
this.send(destination, {
type: 'image', page: this.page.id, hash: image.hash, at,
data: toBase64(data.subarray(at, at + IMAGE_CHUNK_BYTES)),
});
}
}
/**
* Ask a peer that is on our page for the images whose bytes we lack.
* @param {number} peer
*/
requestImages(peer) {
const asked = new Set();
for (const image of this.page.images) {
if (image.data || asked.has(image.hash)) continue;
asked.add(image.hash);
this.send(peer, { type: 'image_request', page: this.page.id, hash: image.hash });
}
}
/**
* A piece of one of the page's images arrived. Pieces are taken in
* sequence; the finished image must match its hash in the page
* descriptor, so it does not matter which participant sent it.
* @param {any} message
*/
async receivedImagePiece(message) {
const page = this.page;
if (message.page !== page.id || typeof message.data !== 'string') return;
const image = page.images.find((one) => one.hash === message.hash && !one.data);
if (!image) return;
let transfer = this.imageTransfers.get(image.hash);
if (!transfer) {
transfer = { buffer: new Uint8Array(image.size), received: 0 };
this.imageTransfers.set(image.hash, transfer);
}
if (message.at !== transfer.received) return;
const piece = fromBase64(message.data);
if (transfer.received + piece.length > image.size) return;
transfer.buffer.set(piece, transfer.received);
transfer.received += piece.length;
if (transfer.received < image.size) return;
this.imageTransfers.delete(image.hash);
const data = transfer.buffer;
const matches = await hashOf(data) === image.hash;
if (page !== this.page || !matches) return; // A damaged or false image: wait for another copy.
let filled = false;
for (const one of page.images) {
if (one.hash !== image.hash || one.data) continue;
one.data = data;
filled = true;
}
if (!filled) return;
this.events.onImagesChanged();
for (const peer of this.imageRequests.get(image.hash) ?? []) this.sendImage(peer, image);
this.imageRequests.delete(image.hash);
}
/**
* Move to a page that another participant is on, if it is newer than
* ours. Pages are numbered by the host, so the highest number is the
* session's current page; between two pages of the same number, the
* host's is taken. If it is the page we are on, it may have gained
* images.
* @param {unknown} received A page descriptor.
* @param {boolean} fromHost
* @param {number} peer Who told us.
*/
considerPage(received, fromHost, peer) {
const descriptor = cleanPageDescriptor(received);
if (!descriptor) return;
if (descriptor.id === this.page.id) {
this.considerImages(descriptor);
return;
}
const newer = descriptor.number > this.page.number ||
(descriptor.number === this.page.number && fromHost);
if (!newer) return;
this.replacePage(Page.from(descriptor));
this.pageSourcePeer = peer;
}
/**
* Another participant's list of the images on the page we are on. The
* list with the higher revision is the later one. Of two different
* lists of the same revision (two participants changed the images at the
* same moment) everyone takes the same one, and the change that lost
* must be made again.
* @param {import('./page_model.js').PageDescriptor} descriptor
*/
considerImages(descriptor) {
const page = this.page;
/** @param {Omit<PageImage, 'data'>[]} images */
const listed = (images) => images.map((image) =>
`${image.hash} ${image.x} ${image.y} ${image.width} ${image.height}`).join(',');
const ours = listed(page.images);
const theirs = listed(descriptor.images);
if (descriptor.revision < page.imagesRevision ||
(descriptor.revision === page.imagesRevision && theirs <= ours)) return;
const held = new Map();
for (const image of page.images) {
if (image.data) held.set(image.hash, image.data);
}
page.images = descriptor.images.map((image) => ({ ...image, data: held.get(image.hash) ?? null }));
page.imagesRevision = descriptor.revision;
page.baseWidth = descriptor.width;
page.baseHeight = descriptor.height;
// Bytes on their way for an image that is no longer wanted are dropped,
// so that the same image, added again, is received from its beginning.
for (const hash of this.imageTransfers.keys()) {
if (!page.images.some((image) => image.hash === hash && !image.data)) this.imageTransfers.delete(hash);
}
this.events.onImagesChanged();
this.extentChanged(true);
}
/** @param {Page} page */
replacePage(page) {
this.endStroke();
const old = this.page;
this.page = page;
this.ownLayer = page.layer(this.identity.id, this.name);
for (const participant of this.participants.values()) {
if (participant.peer !== null) page.layer(participant.id, participant.name);
}
this.incoming.clear();
this.imageTransfers.clear();
this.imageRequests.clear();
this.pageSourcePeer = 0;
this.events.onPageReplaced(old, page);
}
// Drawing on our own layer.
/**
* @param {string} color
* @param {number} width
* @returns {Stroke}
*/
beginStroke(color, width) {
if (this.currentStroke) this.endStroke();
const stroke = { id: randomId(), color, width, points: [], done: false };
this.ownLayer.strokes.set(stroke.id, stroke);
this.currentStroke = stroke;
this.send(0, {
type: 'stroke_begin', page: this.page.id, stroke: { id: stroke.id, color, width },
});
return stroke;
}
/** @param {number[]} points x, y, pressure for each point. */
addPoints(points) {
let stroke = this.currentStroke;
if (!stroke || points.length === 0) return;
if (pointCount(stroke) + points.length / POINT_SIZE > MAX_STROKE_POINTS) {
const last = stroke.points.slice(-POINT_SIZE);
this.endStroke();
stroke = this.beginStroke(stroke.color, stroke.width);
points = last.concat(points);
}
const at = pointCount(stroke);
appendPoints(stroke, at, points);
this.page.includeStroke(stroke, at);
this.send(0, { type: 'stroke_points', id: stroke.id, at, points });
this.events.onStrokeProgress(this.identity.id, stroke);
this.extentChanged();
}
endStroke() {
const stroke = this.currentStroke;
if (!stroke) return;
this.currentStroke = null;
stroke.done = true;
this.send(0, { type: 'stroke_end', id: stroke.id });
this.events.onStrokeProgress(this.identity.id, stroke);
}
/** @param {string[]} ids Strokes of our own layer. */
erase(ids) {
const erased = ids.filter((id) => this.ownLayer.strokes.delete(id));
if (erased.length === 0) return;
this.send(0, { type: 'erase', page: this.page.id, ids: erased });
this.page.recomputeStrokeBounds();
this.events.onLayerReplaced(this.identity.id);
this.extentChanged();
}
// Sending.
/**
* Queue a message. Messages are encrypted and sent in the sequence in
* which they were queued.
* @param {number} destination A peer number, or 0 for everyone else.
* @param {object | (() => Promise<object>)} message
*/
send(destination, message) {
if (!this.connection.connected) return;
const epoch = this.epoch;
this.sendChain = this.sendChain.then(async () => {
if (epoch !== this.epoch) return;
const body = typeof message === 'function' ? await message() : message;
const bytes = await encrypt(this.key, { ...body, seq: this.seq++ });
if (epoch === this.epoch) this.connection.send(destination, bytes);
}).catch((error) => console.error('Kokkoja: send failed', error));
}
/**
* Send a whole layer. The receiver replaces its copy of the layer.
* @param {number} destination
* @param {Layer} layer
*/
sendLayer(destination, layer) {
this.send(destination, {
type: 'layer_begin', page: this.page.id, layer: layer.id, name: layer.name,
});
/** @type {Stroke[]} */
let chunk = [];
let size = 0;
for (const stroke of layer.strokes.values()) {
chunk.push(stroke);
size += 100 + stroke.points.length * 8;
if (size >= SNAPSHOT_CHUNK_CHARACTERS) {
this.send(destination, { type: 'layer_strokes', layer: layer.id, strokes: chunk });
chunk = [];
size = 0;
}
}
if (chunk.length) this.send(destination, { type: 'layer_strokes', layer: layer.id, strokes: chunk });
if (layer.texts.size) {
this.send(destination, { type: 'layer_texts', layer: layer.id, texts: [...layer.texts.values()] });
}
this.send(destination, { type: 'layer_end', layer: layer.id });
}
/**
* Announce ourselves, with proof that this connection is ours.
* @param {number} destination
* @param {boolean} reply True when answering another participant's hello.
*/
sendHello(destination, reply) {
const peer = this.myPeer;
this.send(destination, async () => ({
type: 'hello', reply, name: this.name, publicKey: this.identity.publicKey,
signature: await signClaim(this.identity, this.roomId, peer),
page: this.page.number > 0 ? this.page.descriptor() : null,
permitted: this.isHost ? [...this.permitted] : undefined,
}));
}
// Connection events.
/**
* @param {number} peer
* @param {number[]} peers
*/
welcomed(peer, peers) {
this.epoch += 1;
this.myPeer = peer;
this.presentPeers = new Set(peers);
const self = this.participants.get(this.identity.id);
if (self) self.peer = peer;
this.recoverOwnLayer = this.firstConnection && peers.length > 0;
this.firstConnection = false;
this.sendHello(0, false);
// While recovering, our layer is sent when the recovery is complete.
if (!this.recoverOwnLayer && !this.ownLayer.isEmpty()) this.sendLayer(0, this.ownLayer);
this.requestState();
this.events.onParticipantsChanged();
}
/** Ask the longest-connected peer for the layers of absent participants. */
requestState() {
this.stateComplete = this.presentPeers.size === 0;
if (this.stateComplete) this.recoverOwnLayer = false;
this.statePeer = this.stateComplete ? 0 : Math.min(...this.presentPeers);
if (this.statePeer) this.send(this.statePeer, { type: 'state_request' });
}
disconnected() {
this.epoch += 1;
this.peerIds.clear();
this.presentPeers.clear();
this.lastSeq.clear();
this.incoming.clear();
for (const participant of this.participants.values()) participant.peer = null;
this.finishStrokesOfAbsent();
this.events.onParticipantsChanged();
}
/** @param {number} peer */
peerLeft(peer) {
this.presentPeers.delete(peer);
this.lastSeq.delete(peer);
const id = this.peerIds.get(peer);
this.peerIds.delete(peer);
const participant = id ? this.participants.get(id) : undefined;
if (participant && participant.peer === peer) participant.peer = null;
this.finishStrokesOfAbsent();
if (peer === this.statePeer && !this.stateComplete) this.requestState();
this.events.onParticipantsChanged();
}
/** A stroke whose writer has gone will get no more points. */
finishStrokesOfAbsent() {
for (const layer of this.page.layers.values()) {
const owner = this.participants.get(layer.id);
if (owner && (owner.self || owner.peer !== null)) continue;
for (const stroke of layer.strokes.values()) {
if (stroke.done) continue;
stroke.done = true;
this.events.onStrokeProgress(layer.id, stroke);
}
}
}
// Receiving.
/**
* @param {number} peer
* @param {Uint8Array<ArrayBuffer>} body
*/
received(peer, body) {
const epoch = this.epoch;
this.receiveChain = this.receiveChain.then(async () => {
if (epoch !== this.epoch) return;
let message;
try {
message = await decrypt(this.key, body);
} catch {
return; // Not made with the session key.
}
if (epoch !== this.epoch || !message || typeof message.seq !== 'number') return;
if (message.seq <= (this.lastSeq.get(peer) ?? -1)) return;
this.lastSeq.set(peer, message.seq);
if (message.type === 'hello') await this.receivedHello(peer, message, epoch);
else this.receivedFromParticipant(peer, message);
}).catch((error) => console.error('Kokkoja: receive failed', error));
}
/**
* @param {number} peer
* @param {any} message
* @param {number} epoch
*/
async receivedHello(peer, message, epoch) {
const id = await verifyClaim(message.publicKey, message.signature, this.roomId, peer);
if (epoch !== this.epoch || !id || id === this.identity.id) return;
const name = cleanName(message.name) || 'Unnamed';
const previous = this.participants.get(id);
if (previous && previous.peer !== null) this.peerIds.delete(previous.peer);
this.peerIds.set(peer, id);
this.participants.set(id, { id, name, peer, self: false, host: id === this.hostId });
if (id === this.hostId) this.receivedPermissions(message.permitted);
this.considerPage(message.page, id === this.hostId, peer);
// This peer is on our page: it can give us the images we lack.
if (message.page?.id === this.page.id) this.requestImages(peer);
this.page.layer(id, name);
this.events.onParticipantsChanged();
if (!message.reply) {
this.sendHello(peer, true);
if (!this.ownLayer.isEmpty()) this.sendLayer(peer, this.ownLayer);
}
}
/**
* A message other than hello. It is accepted only from a peer that has
* proved who it is, and changes only that participant's layer.
* @param {number} peer
* @param {any} message
*/
receivedFromParticipant(peer, message) {
const id = this.peerIds.get(peer);
if (!id) return;
if (message.type === 'page') {
if (id === this.hostId || this.permitted.has(id)) this.considerPage(message.page, true, peer);
return;
}
if (message.type === 'permissions') {
if (id === this.hostId) this.receivedPermissions(message.ids);
return;
}
if (message.type === 'image') {
this.receivedImagePiece(message);
return;
}
if (message.type === 'image_request') {
if (message.page !== this.page.id) return;
const image = this.page.images.find((one) => one.hash === message.hash);
if (!image) return;
if (image.data) {
this.sendImage(peer, image);
} else {
const waiting = this.imageRequests.get(image.hash) ?? new Set();
waiting.add(peer);
this.imageRequests.set(image.hash, waiting);
}
return;
}
const layer = this.page.layer(id);
switch (message.type) {
case 'stroke_begin': {
if (message.page !== this.page.id) break;
const stroke = cleanStroke({ ...message.stroke, points: [], done: false });
if (stroke) layer.strokes.set(stroke.id, stroke);
break;
}
case 'stroke_points': {
const stroke = layer.strokes.get(message.id);
if (!stroke || stroke.done || !validPoints(message.points) ||
!Number.isInteger(message.at) || message.at < 0) break;
const before = pointCount(stroke);
appendPoints(stroke, message.at, message.points);
if (pointCount(stroke) > MAX_STROKE_POINTS) stroke.points.length = MAX_STROKE_POINTS * POINT_SIZE;
this.page.includeStroke(stroke, before);
this.events.onStrokeProgress(id, stroke);
this.extentChanged();
break;
}
case 'stroke_end': {
const stroke = layer.strokes.get(message.id);
if (!stroke || stroke.done) break;
stroke.done = true;
this.events.onStrokeProgress(id, stroke);
break;
}
case 'erase': {
if (message.page !== this.page.id || !Array.isArray(message.ids)) break;
let erased = false;
for (const strokeId of message.ids) erased = layer.strokes.delete(strokeId) || erased;
if (!erased) break;
this.page.recomputeStrokeBounds();
this.events.onLayerReplaced(id);
this.extentChanged();
break;
}
case 'text_set': {
if (message.page !== this.page.id) break;
const block = cleanText(message.text);
if (!block) break;
layer.texts.set(block.id, block);
this.page.recomputeStrokeBounds();
this.events.onTextChanged(id, block);
this.extentChanged();
break;
}
case 'text_remove': {
if (message.page !== this.page.id || !Array.isArray(message.ids)) break;
let removed = false;
for (const textId of message.ids) removed = layer.texts.delete(textId) || removed;
if (!removed) break;
this.page.recomputeStrokeBounds();
this.events.onLayerReplaced(id);
this.extentChanged();
break;
}
case 'layer_begin':
if (message.page === this.page.id && validId(message.layer) &&
this.acceptsLayerFrom(peer, id, message.layer)) {
this.incoming.set(`${peer} ${message.layer}`,
{ name: cleanName(message.name), strokes: [], texts: [] });
}
break;
case 'layer_texts': {
const snapshot = this.incoming.get(`${peer} ${message.layer}`);
if (!snapshot || !Array.isArray(message.texts)) break;
for (const received of message.texts) {
const block = cleanText(received);
if (block) snapshot.texts.push(block);
}
break;
}
case 'layer_strokes': {
const snapshot = this.incoming.get(`${peer} ${message.layer}`);
if (!snapshot || !Array.isArray(message.strokes)) break;
for (const received of message.strokes) {
const stroke = cleanStroke(received);
if (stroke) snapshot.strokes.push(stroke);
}
break;
}
case 'layer_end': {
const key = `${peer} ${message.layer}`;
const snapshot = this.incoming.get(key);
this.incoming.delete(key);
if (snapshot && this.acceptsLayerFrom(peer, id, message.layer)) {
this.applySnapshot(message.layer, snapshot, message.layer === id);
}
break;
}
case 'state_request':
this.sendState(peer, id);
break;
case 'state_end':
if (peer !== this.statePeer) break;
this.stateComplete = true;
if (this.recoverOwnLayer) {
this.recoverOwnLayer = false;
if (!this.ownLayer.isEmpty()) this.sendLayer(0, this.ownLayer);
}
break;
}
}
/**
* The host said who may start pages.
* @param {unknown} ids
*/
receivedPermissions(ids) {
if (!Array.isArray(ids)) return;
const permitted = new Set(ids.filter(validId));
const same = permitted.size === this.permitted.size && [...permitted].every((id) => this.permitted.has(id));
if (same) return;
this.permitted = permitted;
this.events.onPermissionsChanged();
}
/**
* Whether to accept a snapshot of a layer from a peer. A participant may
* always send their own layer. The peer that moved us to this page may
* send any of its layers, our own included while we have nothing on it
* (a page loaded from a notebook comes with its writing). The peer we
* asked for the session state may send the layers of participants who
* are not connected, and our own layer while we are recovering it (the
* browser page was reloaded).
* @param {number} peer
* @param {string} senderId
* @param {string} layerId
*/
acceptsLayerFrom(peer, senderId, layerId) {
if (layerId === senderId) return true;
if (peer === this.pageSourcePeer) {
return layerId !== this.identity.id || this.ownLayer.isEmpty();
}
if (peer !== this.statePeer) return false;
if (layerId === this.identity.id) return this.recoverOwnLayer;
const owner = this.participants.get(layerId);
return !owner || owner.peer === null;
}
/**
* @param {string} layerId
* @param {{name: string, strokes: Stroke[], texts: TextBlock[]}} snapshot
* @param {boolean} fromOwner
*/
applySnapshot(layerId, snapshot, fromOwner) {
const layer = this.page.layer(layerId, snapshot.name);
if (layerId === this.identity.id) {
// Recovering our own layer: keep anything drawn since we joined.
for (const stroke of snapshot.strokes) {
if (!layer.strokes.has(stroke.id)) layer.strokes.set(stroke.id, { ...stroke, done: true });
}
for (const block of snapshot.texts) {
if (!layer.texts.has(block.id)) layer.texts.set(block.id, block);
}
} else {
layer.strokes.clear();
for (const stroke of snapshot.strokes) {
layer.strokes.set(stroke.id, fromOwner ? stroke : { ...stroke, done: true });
}
layer.texts.clear();
for (const block of snapshot.texts) layer.texts.set(block.id, block);
if (!this.participants.has(layerId)) {
this.participants.set(layerId, {
id: layerId, name: snapshot.name || 'Unnamed', peer: null, self: false,
host: layerId === this.hostId,
});
this.events.onParticipantsChanged();
}
}
this.page.recomputeStrokeBounds();
this.events.onLayerReplaced(layerId);
this.extentChanged();
}
/**
* Answer a state request: send the layers that their owners cannot send
* themselves.
* @param {number} peer
* @param {string} requesterId
*/
sendState(peer, requesterId) {
for (const layer of this.page.layers.values()) {
if (layer.id === this.identity.id || layer.isEmpty()) continue;
const owner = this.participants.get(layer.id);
const ownerConnected = owner !== undefined && owner.peer !== null;
if (layer.id === requesterId || !ownerConnected) this.sendLayer(peer, layer);
}
this.send(peer, { type: 'state_end' });
}
}