Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/components/RemoteCursors.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render } from "@testing-library/react";
import { RemoteCursors } from "./RemoteCursors";

function makeCursor(id, overrides = {}) {
return {
id,
displayName: `User-${id}`,
color: "#ff0000",
x: 100,
y: 200,
lastUpdate: Date.now(),
...overrides,
};
}

describe("RemoteCursors", () => {
it("renders nothing when cursors is empty", () => {
const { container } = render(<RemoteCursors cursors={[]} />);
expect(container.innerHTML).toBe("");
});

it("renders nothing when cursors is null", () => {
const { container } = render(<RemoteCursors cursors={null} />);
expect(container.innerHTML).toBe("");
});

it("renders one element per cursor", () => {
const cursors = [makeCursor("a"), makeCursor("b")];
const { container } = render(<RemoteCursors cursors={cursors} />);
const svgs = container.querySelectorAll("svg");
expect(svgs).toHaveLength(2);
});

it("displays the cursor display name", () => {
const cursors = [makeCursor("a", { displayName: "Alice" })];
const { container } = render(<RemoteCursors cursors={cursors} />);
expect(container.textContent).toContain("Alice");
});
});
3 changes: 2 additions & 1 deletion src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ export const DESCRIPTION_MAX_LENGTH = 500;

// ── Collaboration ───────────────────────────
export const COLLAB_DEBOUNCE_MS = 500;
export const CURSOR_THROTTLE_MS = 50;
export const CURSOR_THROTTLE_MS = 150;
export const CURSOR_STALE_MS = 4000;
export const COLLAB_ROOM_CODE_LENGTH = 6;
export const COLLAB_CURSOR_FADE_MS = 3000;

Expand Down
87 changes: 59 additions & 28 deletions src/hooks/useCollaboration.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { supabase } from "../collab/supabaseClient";
import { COLLAB_DEBOUNCE_MS, CURSOR_THROTTLE_MS, COLLAB_ROOM_CODE_LENGTH } from "../constants";
import { COLLAB_DEBOUNCE_MS, CURSOR_THROTTLE_MS, CURSOR_STALE_MS, COLLAB_ROOM_CODE_LENGTH } from "../constants";

function generateRoomCode() {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/O/0/1 for clarity
Expand Down Expand Up @@ -48,6 +48,7 @@ export function useCollaboration({
const applyRemoteStateRef = useRef(null);
const applyRemoteImageRef = useRef(null);
const initializedPeersRef = useRef(new Set());
const remoteCursorsRef = useRef([]);

// Keep refs in sync
useEffect(() => { screensRef.current = screens; }, [screens]);
Expand Down Expand Up @@ -102,11 +103,30 @@ export function useCollaboration({
}
});

// Broadcast: cursor-update
channel.on("broadcast", { event: "cursor-update" }, ({ payload }) => {
if (!payload?.peerId) return;
const entry = {
id: payload.peerId,
displayName: payload.displayName,
color: payload.color,
x: payload.x,
y: payload.y,
lastUpdate: payload.ts,
};
const prev = remoteCursorsRef.current;
const idx = prev.findIndex((c) => c.id === payload.peerId);
const next = idx >= 0
? prev.map((c, i) => (i === idx ? entry : c))
: [...prev, entry];
remoteCursorsRef.current = next;
setRemoteCursors(next);
});

// Presence: sync
channel.on("presence", { event: "sync" }, () => {
const state = channel.presenceState();
const allPeers = [];
const cursors = [];
for (const [, presences] of Object.entries(state)) {
for (const p of presences) {
if (p.peerId === peerIdRef.current) continue;
Expand All @@ -116,26 +136,22 @@ export function useCollaboration({
color: p.color,
role: p.role,
});
if (p.cursorX != null && p.cursorY != null) {
cursors.push({
id: p.peerId,
displayName: p.displayName,
color: p.color,
x: p.cursorX,
y: p.cursorY,
lastUpdate: p.cursorTs || Date.now(),
});
}
}
}
setPeers(allPeers);
setRemoteCursors(cursors);

// Remove cursors for peers that left via Presence
const activePeerIds = new Set(allPeers.map((p) => p.id));
const filtered = remoteCursorsRef.current.filter((c) => activePeerIds.has(c.id));
if (filtered.length !== remoteCursorsRef.current.length) {
remoteCursorsRef.current = filtered;
setRemoteCursors(filtered);
}

// Host departure detection
if (myRole !== "host") {
const hasHost = allPeers.some((p) => p.role === "host");
if (!hasHost && allPeers.length === 0 && channelRef.current) {
// Check if we ever had a host (we did since we joined)
setHostLeft(true);
}
}
Expand Down Expand Up @@ -193,9 +209,6 @@ export function useCollaboration({
displayName,
color,
role: "host",
cursorX: null,
cursorY: null,
cursorTs: null,
});
channelRef.current = channel;
setRoomCode(code);
Expand Down Expand Up @@ -226,9 +239,6 @@ export function useCollaboration({
displayName,
color,
role: "editor",
cursorX: null,
cursorY: null,
cursorTs: null,
});
channelRef.current = channel;
setRoomCode(normalizedCode);
Expand All @@ -253,6 +263,7 @@ export function useCollaboration({
debounceTimerRef.current = null;
}
initializedPeersRef.current.clear();
remoteCursorsRef.current = [];
setIsConnected(false);
setRoomCode(null);
setRole(null);
Expand Down Expand Up @@ -336,21 +347,41 @@ export function useCollaboration({
const worldX = (e.clientX - rect.left - panRef.current.x) / zoomRef.current;
const worldY = (e.clientY - rect.top - panRef.current.y) / zoomRef.current;

channel.track({
peerId: peerIdRef.current,
displayName: selfDisplayNameRef.current || "",
color: selfColorRef.current || "#61afef",
role: roleRef.current,
cursorX: worldX,
cursorY: worldY,
cursorTs: now,
channel.send({
type: "broadcast",
event: "cursor-update",
payload: {
peerId: peerIdRef.current,
displayName: selfDisplayNameRef.current || "",
color: selfColorRef.current || "#61afef",
x: worldX,
y: worldY,
ts: now,
},
});
};

canvas.addEventListener("mousemove", onMouseMove);
return () => canvas.removeEventListener("mousemove", onMouseMove);
}, [isConnected, canvasRef]);

// Stale cursor cleanup: remove cursors not updated within CURSOR_STALE_MS.
// Handles unclean disconnects (browser crash, network drop) where Presence
// leave event never fires.
useEffect(() => {
if (!isConnected) return;
const id = setInterval(() => {
const now = Date.now();
const prev = remoteCursorsRef.current;
const filtered = prev.filter((c) => now - c.lastUpdate < CURSOR_STALE_MS);
if (filtered.length !== prev.length) {
remoteCursorsRef.current = filtered;
setRemoteCursors(filtered);
}
}, 1000);
return () => clearInterval(id);
}, [isConnected]);

// Cleanup on unmount
useEffect(() => {
return () => {
Expand Down
Loading
Loading