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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
76 changes: 76 additions & 0 deletions src/__tests__/protocol.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
7 changes: 6 additions & 1 deletion src/pages/room.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<span class="name-saved">${escHtml(newName)} ✓</span>`;
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;
}
Expand Down
28 changes: 28 additions & 0 deletions src/protocol.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading