Collaborative Features
This guide explains how to implement common collaborative features using Mesh, such as shared cursors, typing indicators, and real-time document editing.
Shared Cursors
Shared cursors allow users to see where other users are currently positioned in a document or interface. This is a common feature in collaborative editing tools.
Implementation
- Create a cursor record for each user:
// Server-side
server.exposeWritableRecord(/^cursor:user:\d+:document:\d+$/);
// Client-side
const userId = "123";
const documentId = "456";
const cursorRecordId = `cursor:user:${userId}:document:${documentId}`;
// Initialize cursor position
await client.publishRecordUpdate(cursorRecordId, {
x: 0,
y: 0,
selection: null,
timestamp: Date.now(),
});
- Update cursor position on mouse/selection events:
document.addEventListener(
"mousemove",
debounce((event) => {
const { clientX, clientY } = event;
const { x, y } = convertToDocumentCoordinates(clientX, clientY);
client.publishRecordUpdate(cursorRecordId, {
x,
y,
selection: getSelection(),
timestamp: Date.now(),
});
}, 50)
);
- Subscribe to other users’ cursors:
const { present } = await client.subscribePresence(
`document:${documentId}`,
async (update) => {
if (update.type === "join") {
const metadata = await client.getConnectionMetadata(update.connectionId);
subscribeToCursor(metadata.userId);
} else if (update.type === "leave") {
const metadata = await client.getConnectionMetadata(update.connectionId);
removeCursor(metadata.userId);
}
}
);
for (const connectionId of present) {
const metadata = await client.getConnectionMetadata(connectionId);
subscribeToCursor(metadata.userId);
}
function subscribeToCursor(userId) {
if (userId === currentUserId) return;
const cursorId = `cursor:user:${userId}:document:${documentId}`;
client.subscribeRecord(cursorId, (update) => {
updateCursorUI(userId, update.full);
});
}
- Render cursors in the UI:
function updateCursorUI(userId, cursorData) {
const { x, y, selection, timestamp } = cursorData;
let cursorElement = document.getElementById(`cursor-${userId}`);
if (!cursorElement) {
cursorElement = document.createElement("div");
cursorElement.id = `cursor-${userId}`;
cursorElement.className = "remote-cursor";
document.body.appendChild(cursorElement);
}
cursorElement.style.left = `${x}px`;
cursorElement.style.top = `${y}px`;
if (selection) {
updateSelectionUI(userId, selection);
}
const now = Date.now();
if (now - timestamp > 30000) {
removeCursor(userId);
}
}
Typing Indicators
Typing indicators show when a user is currently typing, commonly used in chat applications.
Implementation
- Use presence state to indicate typing:
const messageInput = document.getElementById("message-input");
messageInput.addEventListener("input", () => {
client.publishPresenceState("chat:room1", {
state: { typing: true },
expireAfter: 5000,
});
});
messageInput.addEventListener("blur", () => {
client.clearPresenceState("chat:room1");
});
- Subscribe to typing indicators:
const { success, present, states } = await client.subscribePresence(
"chat:room1",
async (update) => {
if (update.type === "state") {
const metadata = await client.getConnectionMetadata(update.connectionId);
if (update.state?.typing) {
showTypingIndicator(metadata.username);
} else {
hideTypingIndicator(metadata.username);
}
}
}
);
for (const [connectionId, state] of Object.entries(states)) {
if (state?.typing) {
const metadata = await client.getConnectionMetadata(connectionId);
showTypingIndicator(metadata.username);
}
}
Collaborative Document Editing
For collaborative document editing, you can use records to synchronize document state.
Simple Approach (Full Document Sync)
// Server-side
server.exposeWritableRecord(/^document:\d+$/);
// Client-side
const documentId = "123";
let documentContent = "";
await client.subscribeRecord(`document:${documentId}`, (update) => {
documentContent = update.full.content;
updateEditor(documentContent);
});
editor.addEventListener(
"change",
debounce(() => {
const content = editor.getValue();
client.publishRecordUpdate(`document:${documentId}`, {
content,
lastModified: Date.now(),
});
}, 500)
);
Advanced Approach (Operational Transforms)
// Server-side
server.exposeCommand("apply-document-operation", async (ctx) => {
const { documentId, operation, baseVersion } = ctx.payload;
const { value: document, version } = await server.recordManager.getRecord(
`document:${documentId}`
);
if (baseVersion !== version) {
return { success: false, reason: "version_mismatch" };
}
const newDocument = applyOperation(document, operation);
await server.publishRecordUpdate(`document:${documentId}`, newDocument);
return { success: true, version: version + 1 };
});
// Client-side
let localOperations = [];
let baseVersion = 1;
editor.addEventListener("change", (change) => {
const operation = convertChangeToOperation(change);
applyOperation(localDocument, operation);
client
.command("apply-document-operation", {
documentId,
operation,
baseVersion,
})
.then((result) => {
if (result.success) {
baseVersion = result.version;
} else {
handleConflict();
}
});
});
Presence Awareness
await client.joinRoom(`document:${documentId}`, async (update) => {
if (update.type === "join") {
const metadata = await client.getConnectionMetadata(update.connectionId);
addUserToPresenceUI(metadata);
} else if (update.type === "leave") {
const metadata = await client.getConnectionMetadata(update.connectionId);
removeUserFromPresenceUI(metadata.userId);
}
});
await client.publishPresenceState(`document:${documentId}`, {
state: {
section: "introduction",
viewingPage: 1,
},
});
Collaborative Drawing
// Server-side
server.exposeWritableRecord(/^drawing:\d+$/);
server.exposeChannel(/^drawing:\d+:strokes$/);
// Client-side
await client.subscribeRecord(`drawing:${drawingId}`, (update) => {
initializeCanvas(update.full);
});
await client.subscribeChannel(`drawing:${drawingId}:strokes`, (message) => {
const stroke = JSON.parse(message);
drawStroke(stroke);
});
canvas.addEventListener("mousemove", (event) => {
if (isDrawing) {
const point = {
x: event.offsetX,
y: event.offsetY,
pressure: event.pressure || 1,
};
currentStroke.points.push(point);
client.publishToChannel(
`drawing:${drawingId}:strokes`,
JSON.stringify({
userId,
color,
point,
})
);
}
});
canvas.addEventListener("mouseup", () => {
isDrawing = false;
const drawing = { ...currentDrawing };
drawing.strokes.push(currentStroke);
client.publishRecordUpdate(`drawing:${drawingId}`, drawing);
});
Best Practices
-
Debounce frequent updates
Limit updates for performance (e.g. mousemove, keystrokes) -
Consider bandwidth and latency
Use minimal payloads and patch mode for large documents -
Handle conflicts gracefully
Use version-aware operations or CRDTs -
Provide visual feedback
Show sync state and remote activity -
Clean up stale data
Use TTLs,expireAfter
, or presence disconnects to clear stale cursors or state