Watch party: Prepared presentations queue for the host (add, name, reorder, remove, present when it is time)
This commit is contained in:
@@ -14,6 +14,8 @@
|
||||
* guest → host {type:'cmd', cmd, …} (only when the host allows control)
|
||||
* host → server {type:'invite'|'uninvite', profile} invite an online profile; it sees
|
||||
* the party at GET /api/party/invites (server.js) · server → host {type:'invites', invites}
|
||||
* host → server {type:'pq', action:'add'|'remove'|'move', …} prepared presentations;
|
||||
* server → host {type:'pq', items} (also in hello). Presenting one is a normal 'present'.
|
||||
* host → all {type:'present', present: {url, page} | null} shared document/website
|
||||
* (guests too while the host allows control; kept for late joiners)
|
||||
* server → you {type:'hello', code, secret?, you, now, members, state, chat, allowControl}
|
||||
@@ -39,6 +41,8 @@ const same = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(Strin
|
||||
|
||||
const PROFILE_RE = /^[a-z0-9_-]{3,40}$/;
|
||||
const MAX_INVITES = 30;
|
||||
const MAX_QUEUE = 50;
|
||||
const URL_RE = /^https?:\/\/[^\s<>"]{3,2000}$/i;
|
||||
|
||||
export function createPartyHub({ now = () => Date.now(), profileExists = async () => true } = {}) {
|
||||
const parties = new Map(); // code → party
|
||||
@@ -114,7 +118,7 @@ export function createPartyHub({ now = () => Date.now(), profileExists = async (
|
||||
if (!p) {
|
||||
lost = !!d.code; // resuming a room the server no longer has (restart / expiry)
|
||||
const code = newCode();
|
||||
p = { invites: new Map(), present: null, direct: createDirectRelay(), code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], dj: createDJQueue(), allowControl: false, hostOnline: true, hostLeftAt: 0 };
|
||||
p = { invites: new Map(), pq: [], present: null, direct: createDirectRelay(), code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], dj: createDJQueue(), allowControl: false, hostOnline: true, hostLeftAt: 0 };
|
||||
parties.set(code, p);
|
||||
}
|
||||
p.hostPid = d.pid;
|
||||
@@ -134,7 +138,7 @@ export function createPartyHub({ now = () => Date.now(), profileExists = async (
|
||||
send(ws, {
|
||||
type: 'hello', code: p.code, secret: d.role === 'host' ? p.secret : undefined, you: d.pid, lost,
|
||||
now: now(), members: members(p), state: p.state, chat: p.chat, present: p.present,
|
||||
invites: d.role === 'host' ? inviteList(p) : undefined, dj: queueSnapshot(p.dj), allowControl: p.allowControl,
|
||||
invites: d.role === 'host' ? inviteList(p) : undefined, pq: d.role === 'host' ? p.pq : undefined, dj: queueSnapshot(p.dj), allowControl: p.allowControl,
|
||||
});
|
||||
broadcast(p, { type: 'members', members: members(p), joined: d.name }, ws);
|
||||
}
|
||||
@@ -171,6 +175,18 @@ export function createPartyHub({ now = () => Date.now(), profileExists = async (
|
||||
p.present = { url, mode: v.mode === 'reader' ? 'reader' : 'embed', page: Math.max(1, Math.min(9999, Math.floor(Number(v.page) || 1))), by: me.name, at: now() };
|
||||
}
|
||||
broadcast(p, { type: 'present', present: p.present });
|
||||
} else if (m.type === 'pq' && isHost) {
|
||||
const i = p.pq.findIndex((x) => x.id === m.id);
|
||||
if (m.action === 'add') {
|
||||
const url = String(m.url || '').trim();
|
||||
if (!URL_RE.test(url) || p.pq.length >= MAX_QUEUE) return;
|
||||
p.pq.push({ id: randomBytes(6).toString('base64url'), url, mode: m.mode === 'reader' ? 'reader' : 'embed', title: clean(m.title, 120) });
|
||||
} else if (m.action === 'remove' && i >= 0) p.pq.splice(i, 1);
|
||||
else if (m.action === 'move' && i >= 0) {
|
||||
const j = Math.max(0, Math.min(p.pq.length - 1, i + (Number(m.by) < 0 ? -1 : 1)));
|
||||
p.pq.splice(j, 0, p.pq.splice(i, 1)[0]);
|
||||
} else return;
|
||||
send(ws, { type: 'pq', items: p.pq });
|
||||
} else if ((m.type === 'invite' || m.type === 'uninvite') && isHost) {
|
||||
const name = String(m.profile || '').trim().toLowerCase();
|
||||
if (!PROFILE_RE.test(name)) { send(ws, { type: 'invites', invites: inviteList(p), error: 'Profile names are 3–40 letters, digits, - or _' }); return; }
|
||||
|
||||
@@ -146,6 +146,29 @@ describe('watch party presenting', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('watch party presentation queue', () => {
|
||||
test('host prepares, reorders and removes queued presentations; guests cannot; it survives a host reload', async () => {
|
||||
const host = client({ role: 'host', pid: 'hostpid0030', name: 'H' });
|
||||
const { code, secret } = await host.next('hello');
|
||||
host.send({ type: 'pq', action: 'add', url: 'https://docs.google.com/presentation/d/a/edit', mode: 'embed', title: 'Sermon slides' });
|
||||
host.send({ type: 'pq', action: 'add', url: 'https://example.com/notes', mode: 'reader', title: '' });
|
||||
host.send({ type: 'pq', action: 'add', url: 'javascript:alert(1)' });
|
||||
let items = (await host.next('pq')).items; items = (await host.next('pq')).items;
|
||||
expect(items.map((x) => x.title || x.url)).toEqual(['Sermon slides', 'https://example.com/notes']);
|
||||
host.send({ type: 'pq', action: 'move', id: items[1].id, by: -1 });
|
||||
expect((await host.next('pq')).items[0].url).toBe('https://example.com/notes');
|
||||
const g = client({ role: 'guest', pid: 'guestpid0031', key: 'q'.repeat(36), name: 'G', code });
|
||||
expect((await g.next('hello')).pq).toBeUndefined();
|
||||
g.send({ type: 'pq', action: 'add', url: 'https://evil.example/' });
|
||||
host.ws.close(); await host.closed;
|
||||
const again = client({ role: 'host', pid: 'hostpid0030', name: 'H', code, secret });
|
||||
expect((await again.next('hello')).pq.length).toBe(2);
|
||||
again.send({ type: 'pq', action: 'remove', id: items[0].id });
|
||||
expect((await again.next('pq')).items.length).toBe(1);
|
||||
again.send({ type: 'end' }); await again.closed;
|
||||
});
|
||||
});
|
||||
|
||||
describe('watch party invites', () => {
|
||||
test('host invites a profile; it is listed for that profile until uninvited or the party ends', async () => {
|
||||
const host = client({ role: 'host', pid: 'hostpid0020', name: 'Josh' });
|
||||
|
||||
Reference in New Issue
Block a user