Watch party invites by profile: host invites online profiles, invited users see running parties on open and can join one or dismiss
This commit is contained in:
@@ -12,6 +12,8 @@
|
||||
* any → all {type:'voice', on} (voice-chat presence)
|
||||
* any → one {type:'rtc', to, data} (WebRTC offer/answer/ICE, relayed)
|
||||
* 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 → 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}
|
||||
@@ -35,7 +37,10 @@ const clean = (v, max, fallback = '') =>
|
||||
String(v || '').replace(/[\u0000-\u001f\u007f<>]+/g, ' ').trim().slice(0, max) || fallback;
|
||||
const same = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && timingSafeEqual(x, y); };
|
||||
|
||||
export function createPartyHub({ now = () => Date.now() } = {}) {
|
||||
const PROFILE_RE = /^[a-z0-9_-]{3,40}$/;
|
||||
const MAX_INVITES = 30;
|
||||
|
||||
export function createPartyHub({ now = () => Date.now(), profileExists = async () => true } = {}) {
|
||||
const parties = new Map(); // code → party
|
||||
const joinFails = new Map();
|
||||
|
||||
@@ -43,6 +48,20 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
||||
const members = (p) => [...p.members.values()].map((m) => ({ pid: m.pid, name: m.name, host: m.pid === p.hostPid, voice: !!m.voice }));
|
||||
const broadcast = (p, m, except) => { for (const x of p.members.values()) if (x.ws !== except) send(x.ws, m); };
|
||||
|
||||
const inviteList = (p) => [...p.invites.keys()];
|
||||
// Parties that invited this profile and are still running.
|
||||
function invitesFor(profile) {
|
||||
const name = String(profile || '').toLowerCase(), out = [];
|
||||
for (const p of parties.values()) {
|
||||
const inv = p.invites.get(name);
|
||||
if (!inv) continue;
|
||||
const host = p.members.get(p.hostPid);
|
||||
out.push({ code: p.code, host: host ? host.name : inv.by, hostOnline: p.hostOnline, members: p.members.size,
|
||||
song: p.state && p.state.v ? p.state.v.title || '' : '', at: inv.at });
|
||||
}
|
||||
return out.sort((a, b) => b.at - a.at);
|
||||
}
|
||||
|
||||
function newCode() {
|
||||
let c;
|
||||
do { c = Array.from({ length: 6 }, () => ALPHABET[randomInt(ALPHABET.length)]).join(''); } while (parties.has(c));
|
||||
@@ -95,7 +114,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
||||
if (!p) {
|
||||
lost = !!d.code; // resuming a room the server no longer has (restart / expiry)
|
||||
const code = newCode();
|
||||
p = { 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(), 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;
|
||||
@@ -114,7 +133,8 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
||||
p.members.set(d.pid, { ws, pid: d.pid, name: d.name, voice: false, lastChat: 0, key: d.key || '', host: d.role === 'host', tokens: MSG_BURST, refill: now() });
|
||||
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, dj: queueSnapshot(p.dj), allowControl: p.allowControl,
|
||||
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,
|
||||
});
|
||||
broadcast(p, { type: 'members', members: members(p), joined: d.name }, ws);
|
||||
}
|
||||
@@ -151,6 +171,16 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
||||
p.present = { url, 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 === '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; }
|
||||
if (m.type === 'uninvite') { p.invites.delete(name); send(ws, { type: 'invites', invites: inviteList(p) }); return; }
|
||||
if (p.invites.size >= MAX_INVITES) { send(ws, { type: 'invites', invites: inviteList(p), error: `At most ${MAX_INVITES} invites per party` }); return; }
|
||||
Promise.resolve(profileExists(name)).catch(() => false).then((ok) => {
|
||||
if (!parties.has(p.code)) return;
|
||||
if (ok) p.invites.set(name, { at: now(), by: me.name });
|
||||
send(ws, { type: 'invites', invites: inviteList(p), error: ok ? undefined : `No profile called “${name}”` });
|
||||
});
|
||||
} else if (m.type === 'settings' && isHost) {
|
||||
p.allowControl = !!m.allowControl;
|
||||
broadcast(p, { type: 'settings', allowControl: p.allowControl });
|
||||
@@ -195,6 +225,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
||||
|
||||
return {
|
||||
upgrade,
|
||||
invitesFor,
|
||||
websocket: { maxPayloadLength: MAX_MSG, idleTimeout: 120, open, message: (ws, msg) => message(ws, typeof msg === 'string' ? msg : null), close },
|
||||
_parties: parties,
|
||||
stop() { clearInterval(sweeper); },
|
||||
|
||||
@@ -146,6 +146,28 @@ describe('watch party presenting', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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' });
|
||||
const { code } = await host.next('hello');
|
||||
const p = hub._parties.get(code);
|
||||
host.send({ type: 'invite', profile: 'Psalm-Team' });
|
||||
expect((await host.next('invites')).invites).toEqual(['psalm-team']);
|
||||
expect(hub.invitesFor('PSALM-TEAM')).toMatchObject([{ code, host: 'Josh', members: 1 }]);
|
||||
host.send({ type: 'invite', profile: 'x' });
|
||||
expect((await host.next('invites')).error).toMatch(/3–40/);
|
||||
const g = client({ role: 'guest', pid: 'guestpid0021', key: 'g'.repeat(36), name: 'G', code });
|
||||
await g.next('hello');
|
||||
g.send({ type: 'invite', profile: 'someone' });
|
||||
await Bun.sleep(150);
|
||||
expect([...p.invites.keys()]).toEqual(['psalm-team']);
|
||||
host.send({ type: 'uninvite', profile: 'psalm-team' });
|
||||
expect((await host.next('invites')).invites).toEqual([]);
|
||||
expect(hub.invitesFor('psalm-team')).toEqual([]);
|
||||
host.send({ type: 'end' }); await host.closed;
|
||||
});
|
||||
});
|
||||
|
||||
describe('watch party seat rules', () => {
|
||||
test('unknown code closes with 4004 (no reconnect loop)', async () => {
|
||||
const bad = client({ role: 'guest', pid: 'guestpid7777', name: 'X', code: 'YYYYYY' });
|
||||
|
||||
@@ -2304,7 +2304,16 @@ app.get('/api/user/data', async (c) => {
|
||||
// Phone remote for a desktop instance — see remote.js
|
||||
// ============================================================================
|
||||
const remote = createRemoteHub({ requireSameNetwork: process.env.REMOTE_SAME_NETWORK === '1' });
|
||||
const party = createPartyHub();
|
||||
const party = createPartyHub({ profileExists: async (name) => !!(await getProfile(name)) });
|
||||
// Running parties that invited this online profile (protected profiles need their secret).
|
||||
app.get('/api/party/invites', async (c) => {
|
||||
const name = (c.req.query('name') || '').trim().toLowerCase();
|
||||
const row = name ? await getProfile(name) : null;
|
||||
if (!row) return c.json({ ok: true, invites: [] }, 200, { 'Cache-Control': 'no-store' });
|
||||
const denied = await profileGate(c, name, row, c.req.header('x-profile-secret'));
|
||||
if (denied) return denied;
|
||||
return c.json({ ok: true, invites: party.invitesFor(name) }, 200, { 'Cache-Control': 'no-store' });
|
||||
});
|
||||
|
||||
// Bun allows ONE websocket handler per server: party sockets are tagged
|
||||
// (ws.data.hub === 'party'), everything else belongs to the remote relay.
|
||||
|
||||
Reference in New Issue
Block a user