diff --git a/packages/client/src/tiptap/core/y-prosemirror/plugins/awareness.js b/packages/client/src/tiptap/core/y-prosemirror/plugins/awareness.js new file mode 100644 index 00000000..8b6a6f46 --- /dev/null +++ b/packages/client/src/tiptap/core/y-prosemirror/plugins/awareness.js @@ -0,0 +1,310 @@ +/** + * @module awareness-protocol + */ + +import * as decoding from 'lib0/decoding'; +import * as encoding from 'lib0/encoding'; +import * as f from 'lib0/function'; +import * as math from 'lib0/math'; +import { Observable } from 'lib0/observable'; +import * as time from 'lib0/time'; +import * as Y from 'yjs'; // eslint-disable-line + +export const outdatedTimeout = 30000; + +/** + * @typedef {Object} MetaClientState + * @property {number} MetaClientState.clock + * @property {number} MetaClientState.lastUpdated unix timestamp + */ + +/** + * The Awareness class implements a simple shared state protocol that can be used for non-persistent data like awareness information + * (cursor, username, status, ..). Each client can update its own local state and listen to state changes of + * remote clients. Every client may set a state of a remote peer to `null` to mark the client as offline. + * + * Each client is identified by a unique client id (something we borrow from `doc.clientID`). A client can override + * its own state by propagating a message with an increasing timestamp (`clock`). If such a message is received, it is + * applied if the known state of that client is older than the new state (`clock < newClock`). If a client thinks that + * a remote client is offline, it may propagate a message with + * `{ clock: currentClientClock, state: null, client: remoteClient }`. If such a + * message is received, and the known clock of that client equals the received clock, it will override the state with `null`. + * + * Before a client disconnects, it should propagate a `null` state with an updated clock. + * + * Awareness states must be updated every 30 seconds. Otherwise the Awareness instance will delete the client state. + * + * @extends {Observable} + */ +export class Awareness extends Observable { + /** + * @param {Y.Doc} doc + */ + constructor(doc) { + super(); + this.doc = doc; + /** + * @type {number} + */ + this.clientID = doc.clientID; + /** + * Maps from client id to client state + * @type {Map>} + */ + this.states = new Map(); + /** + * @type {Map} + */ + this.meta = new Map(); + this._checkInterval = /** @type {any} */ ( + setInterval(() => { + const now = time.getUnixTime(); + if ( + this.getLocalState() !== null && + outdatedTimeout / 2 <= now - /** @type {{lastUpdated:number}} */ (this.meta.get(this.clientID)).lastUpdated + ) { + // renew local clock + this.setLocalState(this.getLocalState()); + } + /** + * @type {Array} + */ + const remove = []; + this.meta.forEach((meta, clientid) => { + if (clientid !== this.clientID && outdatedTimeout <= now - meta.lastUpdated && this.states.has(clientid)) { + remove.push(clientid); + } + }); + if (remove.length > 0) { + removeAwarenessStates(this, remove, 'timeout'); + } + }, math.floor(outdatedTimeout / 10)) + ); + doc.on('destroy', () => { + this.destroy(); + }); + this.setLocalState({}); + } + + destroy() { + this.emit('destroy', [this]); + this.setLocalState(null); + super.destroy(); + clearInterval(this._checkInterval); + } + + /** + * @return {Object|null} + */ + getLocalState() { + return this.states.get(this.clientID) || null; + } + + /** + * @param {Object|null} state + */ + setLocalState(state) { + const clientID = this.clientID; + const currLocalMeta = this.meta.get(clientID); + const clock = currLocalMeta === undefined ? 0 : currLocalMeta.clock + 1; + const prevState = this.states.get(clientID); + if (state === null) { + this.states.delete(clientID); + } else { + this.states.set(clientID, state); + } + this.meta.set(clientID, { + clock, + lastUpdated: time.getUnixTime(), + }); + const added = []; + const updated = []; + const filteredUpdated = []; + const removed = []; + if (state === null) { + removed.push(clientID); + } else if (prevState == null) { + if (state != null) { + added.push(clientID); + } + } else { + updated.push(clientID); + if (!f.equalityDeep(prevState, state)) { + filteredUpdated.push(clientID); + } + } + if (added.length > 0 || filteredUpdated.length > 0 || removed.length > 0) { + this.emit('change', [{ added, updated: filteredUpdated, removed }, 'local']); + } + this.emit('update', [{ added, updated, removed }, 'local']); + } + + /** + * @param {string} field + * @param {any} value + */ + setLocalStateField(field, value) { + const state = this.getLocalState(); + if (state !== null) { + this.setLocalState({ + ...state, + [field]: value, + }); + } + } + + /** + * @return {Map>} + */ + getStates() { + return this.states; + } +} + +/** + * Mark (remote) clients as inactive and remove them from the list of active peers. + * This change will be propagated to remote clients. + * + * @param {Awareness} awareness + * @param {Array} clients + * @param {any} origin + */ +export const removeAwarenessStates = (awareness, clients, origin) => { + const removed = []; + for (let i = 0; i < clients.length; i++) { + const clientID = clients[i]; + if (awareness.states.has(clientID)) { + awareness.states.delete(clientID); + if (clientID === awareness.clientID) { + const curMeta = /** @type {MetaClientState} */ (awareness.meta.get(clientID)); + awareness.meta.set(clientID, { + clock: curMeta.clock + 1, + lastUpdated: time.getUnixTime(), + }); + } + removed.push(clientID); + } + } + if (removed.length > 0) { + awareness.emit('change', [{ added: [], updated: [], removed }, origin]); + awareness.emit('update', [{ added: [], updated: [], removed }, origin]); + } +}; + +/** + * @param {Awareness} awareness + * @param {Array} clients + * @return {Uint8Array} + */ +export const encodeAwarenessUpdate = (awareness, clients, states = awareness.states) => { + const len = clients.length; + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, len); + for (let i = 0; i < len; i++) { + const clientID = clients[i]; + const state = states.get(clientID) || null; + const clock = /** @type {MetaClientState} */ (awareness.meta.get(clientID)).clock; + encoding.writeVarUint(encoder, clientID); + encoding.writeVarUint(encoder, clock); + encoding.writeVarString(encoder, JSON.stringify(state)); + } + return encoding.toUint8Array(encoder); +}; + +/** + * Modify the content of an awareness update before re-encoding it to an awareness update. + * + * This might be useful when you have a central server that wants to ensure that clients + * cant hijack somebody elses identity. + * + * @param {Uint8Array} update + * @param {function(any):any} modify + * @return {Uint8Array} + */ +export const modifyAwarenessUpdate = (update, modify) => { + const decoder = decoding.createDecoder(update); + const encoder = encoding.createEncoder(); + const len = decoding.readVarUint(decoder); + encoding.writeVarUint(encoder, len); + for (let i = 0; i < len; i++) { + const clientID = decoding.readVarUint(decoder); + const clock = decoding.readVarUint(decoder); + const state = JSON.parse(decoding.readVarString(decoder)); + const modifiedState = modify(state); + encoding.writeVarUint(encoder, clientID); + encoding.writeVarUint(encoder, clock); + encoding.writeVarString(encoder, JSON.stringify(modifiedState)); + } + return encoding.toUint8Array(encoder); +}; + +/** + * @param {Awareness} awareness + * @param {Uint8Array} update + * @param {any} origin This will be added to the emitted change event + */ +export const applyAwarenessUpdate = (awareness, update, origin) => { + const decoder = decoding.createDecoder(update); + const timestamp = time.getUnixTime(); + const added = []; + const updated = []; + const filteredUpdated = []; + const removed = []; + const len = decoding.readVarUint(decoder); + for (let i = 0; i < len; i++) { + const clientID = decoding.readVarUint(decoder); + let clock = decoding.readVarUint(decoder); + const state = JSON.parse(decoding.readVarString(decoder)); + const clientMeta = awareness.meta.get(clientID); + const prevState = awareness.states.get(clientID); + const currClock = clientMeta === undefined ? 0 : clientMeta.clock; + if (currClock < clock || (currClock === clock && state === null && awareness.states.has(clientID))) { + if (state === null) { + // never let a remote client remove this local state + if (clientID === awareness.clientID && awareness.getLocalState() != null) { + // remote client removed the local state. Do not remote state. Broadcast a message indicating + // that this client still exists by increasing the clock + clock++; + } else { + awareness.states.delete(clientID); + } + } else { + awareness.states.set(clientID, state); + } + awareness.meta.set(clientID, { + clock, + lastUpdated: timestamp, + }); + if (clientMeta === undefined && state !== null) { + added.push(clientID); + } else if (clientMeta !== undefined && state === null) { + removed.push(clientID); + } else if (state !== null) { + if (!f.equalityDeep(state, prevState)) { + filteredUpdated.push(clientID); + } + updated.push(clientID); + } + } + } + if (added.length > 0 || filteredUpdated.length > 0 || removed.length > 0) { + awareness.emit('change', [ + { + added, + updated: filteredUpdated, + removed, + }, + origin, + ]); + } + if (added.length > 0 || updated.length > 0 || removed.length > 0) { + awareness.emit('update', [ + { + added, + updated, + removed, + }, + origin, + ]); + } +}; diff --git a/packages/client/src/tiptap/core/y-prosemirror/plugins/cursor-plugin.js b/packages/client/src/tiptap/core/y-prosemirror/plugins/cursor-plugin.js index b90e6e24..d70667b9 100644 --- a/packages/client/src/tiptap/core/y-prosemirror/plugins/cursor-plugin.js +++ b/packages/client/src/tiptap/core/y-prosemirror/plugins/cursor-plugin.js @@ -1,10 +1,10 @@ import * as math from 'lib0/math'; import { Plugin } from 'prosemirror-state'; // eslint-disable-line import { Decoration, DecorationSet } from 'prosemirror-view'; // eslint-disable-line -import { Awareness } from 'y-protocols/awareness'; // eslint-disable-line import * as Y from 'yjs'; import { absolutePositionToRelativePosition, relativePositionToAbsolutePosition, setMeta } from '../lib.js'; +import { Awareness } from './awareness'; // eslint-disable-line import { yCursorPluginKey, ySyncPluginKey } from './keys.js'; /**