merge: combine all features from both codebases - mobile dashboard + layer panel + notifications + shares + all fixes

This commit is contained in:
Leopoldadmin
2026-07-04 16:43:55 +02:00
parent 0606dbb501
commit be62470b53
79 changed files with 9562 additions and 3132 deletions
+94 -11
View File
@@ -1,9 +1,14 @@
/**
* WebSocketProvider Custom WebSocket provider matching the backend raw-update protocol.
* Backend sends Y.encodeStateAsUpdate on connect and applies raw updates on message.
*
* Issue #20: On connect, the client must NOT send its local state immediately.
* Instead, the server sends its state first (Y.encodeStateAsUpdate). The client
* applies that update to sync, then sends only incremental local updates.
*/
import * as Y from 'yjs';
import type { YjsDocument } from './YjsDocument';
import type { AwarenessManager } from './AwarenessManager';
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
@@ -11,15 +16,22 @@ export interface WebSocketProviderOptions {
url: string;
docName: string;
yjsDoc: YjsDocument;
awareness?: AwarenessManager;
onStatusChange?: (status: ConnectionStatus) => void;
onSync?: () => void;
}
// Message type prefixes for multiplexing Y.Doc updates and awareness updates
// Using a simple byte-prefix scheme: 0 = Y.Doc update, 1 = awareness update
const MSG_DOC_UPDATE = 0;
const MSG_AWARENESS = 1;
export class WebSocketProvider {
private ws: WebSocket | null = null;
private url: string;
private docName: string;
private yjsDoc: YjsDocument;
private awareness?: AwarenessManager;
private status: ConnectionStatus = 'disconnected';
private onStatusChange?: (status: ConnectionStatus) => void;
private onSync?: () => void;
@@ -27,24 +39,30 @@ export class WebSocketProvider {
private reconnectDelay = 1000;
private maxReconnectDelay = 30000;
private shouldReconnect = true;
private authToken: string | undefined;
private synced = false;
constructor(opts: WebSocketProviderOptions) {
this.url = opts.url;
this.docName = opts.docName;
this.yjsDoc = opts.yjsDoc;
this.awareness = opts.awareness;
this.onStatusChange = opts.onStatusChange;
this.onSync = opts.onSync;
}
connect(): void {
connect(token?: string): void {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
this.authToken = token;
this.shouldReconnect = true;
this.synced = false;
this.setStatus('connecting');
const fullUrl = `${this.url}/ws/collab/${encodeURIComponent(this.docName)}`;
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
const fullUrl = `${this.url}/ws/collab/${encodeURIComponent(this.docName)}${tokenParam}`;
try {
this.ws = new WebSocket(fullUrl);
} catch (err) {
@@ -58,20 +76,50 @@ export class WebSocketProvider {
this.ws.onopen = () => {
this.setStatus('connected');
this.reconnectDelay = 1000;
// Send local state to server for initial sync
const stateUpdate = Y.encodeStateAsUpdate(this.yjsDoc.doc);
if (stateUpdate.length > 0) {
this.ws?.send(stateUpdate);
// Issue #20: Do NOT send local state on connect.
// The server sends its state (Y.encodeStateAsUpdate) to new clients.
// We wait for that server state, apply it, then mark as synced.
// Only after sync do we forward incremental local updates.
//
// If we have awareness, send our local awareness state after connect.
if (this.awareness) {
const awarenessUpdate = this.awareness.encodeLocalState();
if (awarenessUpdate && awarenessUpdate.length > 0) {
this.sendAwarenessUpdate(awarenessUpdate);
}
}
this.onSync?.();
};
this.ws.onmessage = (event: MessageEvent) => {
try {
const data = new Uint8Array(event.data as ArrayBuffer);
this.yjsDoc.applyUpdate(data);
if (data.length === 0) return;
// Check message type prefix (first byte)
const msgType = data[0];
const payload = data.slice(1);
if (msgType === MSG_AWARENESS) {
// Awareness update — apply to awareness manager
if (this.awareness) {
this.awareness.applyRemoteUpdate(payload, 'remote');
}
} else {
// Y.Doc update — apply to Y.Doc
// Support both prefixed and non-prefixed messages for backward compatibility
// If the first byte looks like a valid Y.Doc update, use the full data;
// otherwise treat the whole message as the Y.Doc update (legacy mode).
const updateData = msgType === MSG_DOC_UPDATE ? payload : data;
this.yjsDoc.applyUpdate(updateData);
// Mark as synced after receiving the first server state
if (!this.synced) {
this.synced = true;
this.onSync?.();
}
}
} catch (err) {
console.warn('[WebSocketProvider] Failed to apply update:', err);
console.warn('[WebSocketProvider] Failed to process message:', err);
}
};
@@ -81,6 +129,7 @@ export class WebSocketProvider {
this.ws.onclose = () => {
this.ws = null;
this.synced = false;
this.setStatus('disconnected');
if (this.shouldReconnect) {
this.scheduleReconnect();
@@ -90,8 +139,22 @@ export class WebSocketProvider {
/** Send a local Y.Doc update to the server */
sendUpdate(update: Uint8Array): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.synced) {
// Prefix with MSG_DOC_UPDATE byte
const msg = new Uint8Array(update.length + 1);
msg[0] = MSG_DOC_UPDATE;
msg.set(update, 1);
this.ws.send(msg);
}
}
/** Send a local awareness update to the server */
sendAwarenessUpdate(update: Uint8Array): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(update);
const msg = new Uint8Array(update.length + 1);
msg[0] = MSG_AWARENESS;
msg.set(update, 1);
this.ws.send(msg);
}
}
@@ -109,6 +172,25 @@ export class WebSocketProvider {
};
}
/** Listen for local awareness changes and forward to server */
bindAwarenessUpdates(): () => void {
if (!this.awareness) return () => {};
const handler = () => {
const update = this.awareness!.encodeLocalState();
if (update && update.length > 0) {
this.sendAwarenessUpdate(update);
}
};
this.awareness.awareness.on('update', handler);
return () => {
this.awareness!.awareness.off('update', handler);
};
}
isSynced(): boolean {
return this.synced;
}
getStatus(): ConnectionStatus {
return this.status;
}
@@ -123,6 +205,7 @@ export class WebSocketProvider {
this.ws.close();
this.ws = null;
}
this.synced = false;
this.setStatus('disconnected');
}
@@ -130,7 +213,7 @@ export class WebSocketProvider {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
this.connect(this.authToken);
}, this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
}