From 63eb414e7fe4bfd907bab5ff4a9a51d290b855b1 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Tue, 11 Aug 2026 23:04:55 -0500 Subject: [PATCH 1/2] fix: propagate name changes to peers with a rename event Renaming yourself in a room only wrote the new name to localStorage and then tried to restart the room; peers were never told, so the desk kept rendering the name from the join handshake. The restart itself was dead code: it called startRoom(root, ...) from renderWaiting, where root is not in scope, so it threw before doing anything. Add a 'rename' action to the protocol plus a setName() API that updates self (what later joins and state-syncs carry), rewrites our participant record and broadcasts the change. Incoming renames are accepted only from the connection that owns the identity and are name-sanitized like any other peer string. --- src/__tests__/protocol.test.js | 76 ++++++++++++++++++++++++++++++++++ src/pages/room.js | 7 +++- src/protocol.js | 28 +++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/__tests__/protocol.test.js b/src/__tests__/protocol.test.js index c108a2e..7444cd0 100644 --- a/src/__tests__/protocol.test.js +++ b/src/__tests__/protocol.test.js @@ -284,6 +284,82 @@ describe('protocol — round management', () => { }); }); +// ── Rename ──────────────────────────────────────────────────────────────────── +// Peers learn names from the join handshake, so a rename after joining needs +// its own event — otherwise everyone else keeps showing the name from the join. + +describe('protocol — rename', () => { + let proto, updates; + + beforeEach(async () => { + vi.useFakeTimers(); + vi.resetModules(); + ({ proto, updates } = await makeProtocol('u1', 'Alice')); + vi.advanceTimersByTime(1); + }); + + afterEach(() => { proto.destroy(); vi.useRealTimers(); }); + + it('setName updates our own participant record and broadcasts it', () => { + proto.setName('Alicia'); + expect(latest(updates).participants['u1'].name).toBe('Alicia'); + expect(mockRoom.getSend('rename')).toHaveBeenCalledWith({ participantId: 'u1', name: 'Alicia' }); + }); + + it('a rename mid-round survives into the voting phase', () => { + proto.startVoting('Story'); + proto.setName('Alicia'); + proto.vote('5'); + const s = latest(updates); + expect(s.phase).toBe('voting'); + expect(s.participants['u1'].name).toBe('Alicia'); + expect(s.votes['u1']).toBe('5'); + }); + + it('carries the new name in later joins and state-syncs', () => { + proto.setName('Alicia'); + mockRoom.triggerPeerJoin('peer-u2'); + expect(mockRoom.getSend('join')).toHaveBeenLastCalledWith( + { participant: expect.objectContaining({ id: 'u1', name: 'Alicia' }) }, + 'peer-u2', + ); + const [[{ state: synced }]] = mockRoom.getSend('state-sync').mock.calls; + expect(synced.participants['u1'].name).toBe('Alicia'); + }); + + it('applies an incoming rename from the peer that owns the identity', () => { + mockRoom.triggerAction('join', { participant: mkPeer('u2', 'Bob') }, 'peer-u2'); + mockRoom.triggerAction('rename', { participantId: 'u2', name: 'Bobby' }, 'peer-u2'); + expect(latest(updates).participants['u2'].name).toBe('Bobby'); + }); + + it('keeps a renamed peer vote-able — the record is keyed by id, not name', () => { + mockRoom.triggerAction('join', { participant: mkPeer('u2', 'Bob') }, 'peer-u2'); + proto.startVoting('Story'); + mockRoom.triggerAction('rename', { participantId: 'u2', name: 'Bobby' }, 'peer-u2'); + const roundId = latest(updates).roundId; + mockRoom.triggerAction('vote', { participantId: 'u2', card: '8', roundId }, 'peer-u2'); + const s = latest(updates); + expect(s.votes['u2']).toBe('8'); + expect(s.participants['u2'].name).toBe('Bobby'); + }); + + it('rejects a rename sent on behalf of another participant', () => { + mockRoom.triggerAction('join', { participant: mkPeer('u2', 'Bob') }, 'peer-u2'); + mockRoom.triggerAction('rename', { participantId: 'u2', name: 'Impostor' }, 'peer-evil'); + expect(latest(updates).participants['u2'].name).toBe('Bob'); + }); + + it('sanitizes an incoming rename payload', () => { + mockRoom.triggerAction('join', { participant: mkPeer('u2', 'Bob') }, 'peer-u2'); + mockRoom.triggerAction('rename', { participantId: 'u2', name: 'z'.repeat(10_000) }, 'peer-u2'); + expect(latest(updates).participants['u2'].name.length).toBeLessThanOrEqual(40); + + mockRoom.triggerAction('rename', { participantId: 'u2', name: ' ' }, 'peer-u2'); + expect(latest(updates).participants['u2'].name).toBe('Anon'); + }); +}); + // ── Zombie detection ────────────────────────────────────────────────────────── describe('protocol — zombie detection', () => { diff --git a/src/pages/room.js b/src/pages/room.js index a5e4f80..af0b442 100644 --- a/src/pages/room.js +++ b/src/pages/room.js @@ -530,12 +530,17 @@ function renderWaiting(el, s, userId, protocol, force, roomId) { const input = nameSpan.querySelector('input'); input.focus(); input.select(); + let done = false; const save = () => { + if (done) return; + done = true; const newName = input.value.trim(); if (newName && newName !== currentName) { setUserName(newName); nameSpan.innerHTML = `${escHtml(newName)} ✓`; - setTimeout(() => startRoom(root, roomId, userId, newName), 600); + // Tell the protocol (and through it every peer) about the new name; + // the resulting emit re-renders the peer list and the desk. + setTimeout(() => protocol.setName(newName), 600); } else { nameSpan.textContent = currentName; } diff --git a/src/protocol.js b/src/protocol.js index 9337f3e..78433fe 100644 --- a/src/protocol.js +++ b/src/protocol.js @@ -51,6 +51,7 @@ export function createProtocol(roomId, userId, userName, onUpdate, onCountdown) const [sendJoin, getJoin] = room.makeAction('join'); const [sendStateSync, getStateSync] = room.makeAction('state-sync'); const [sendVote, getVote] = room.makeAction('vote'); + const [sendRename, getRename] = room.makeAction('rename'); const [sendReveal, getReveal] = room.makeAction('reveal'); const [sendNextRound, getNextRound] = room.makeAction('next-round'); const [sendNewStory, getNewStory] = room.makeAction('new-story'); @@ -204,6 +205,19 @@ export function createProtocol(roomId, userId, userName, onUpdate, onCountdown) checkAutoReveal(); }); + getRename((data, peerId) => { + if (!isObj(data)) return; + const { participantId, name } = data; + // Only the owner of an identity may rename it — same rule as vote/leave. + if (peerMap.get(peerId) !== participantId) return; + if (!state.participants[participantId]) return; + state.participants[participantId] = { + ...state.participants[participantId], + name: cleanName(name), + }; + emit(); + }); + getReveal((data) => { if (!isObj(data)) return; if (data.roundId !== state.roundId || state.phase !== 'voting') return; @@ -319,6 +333,20 @@ export function createProtocol(roomId, userId, userName, onUpdate, onCountdown) emit(); }, + /** + * Rename ourselves in place. Peers learn names from the join handshake + * only, so a rename has to travel on its own event — and `self` must be + * updated too, since it is what later joins and state-syncs carry. + */ + setName(name) { + const clean = cleanName(name); + if (clean === self.name) return; + self.name = clean; + state.participants[userId] = { ...state.participants[userId], name: clean }; + sendRename({ participantId: userId, name: clean }); + emit(); + }, + vote(card) { if (state.phase !== 'voting') return; state.votes[userId] = card; From 75a84347187c511d29360d762be4a37479f07789 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Tue, 11 Aug 2026 23:41:43 -0500 Subject: [PATCH 2/2] chore: bump version to 0.1.6 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 44b1be1..c5becd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@univerlab/quorum", - "version": "0.1.3", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@univerlab/quorum", - "version": "0.1.3", + "version": "0.1.6", "license": "MIT", "dependencies": { "trystero": "^0.21.0" diff --git a/package.json b/package.json index f2ede1c..6aeb9f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@univerlab/quorum", - "version": "0.1.5", + "version": "0.1.6", "description": "Browser-native planning poker — P2P, no accounts, no server", "license": "MIT", "type": "module",