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:
@@ -6225,6 +6225,19 @@ const Party = {
|
|||||||
let resume = false;
|
let resume = false;
|
||||||
try { const host = JSON.parse(localStorage.getItem('ytpPartyHost') || 'null'); resume = !!(host?.code && host?.secret); } catch {}
|
try { const host = JSON.parse(localStorage.getItem('ytpPartyHost') || 'null'); resume = !!(host?.code && host?.secret); } catch {}
|
||||||
if (resume || new URLSearchParams(location.search).has('party')) Lazy.load('feature:watch-party').catch(error => toast(error.message));
|
if (resume || new URLSearchParams(location.search).has('party')) Lazy.load('feature:watch-party').catch(error => toast(error.message));
|
||||||
|
// Invites for this device's online profile: a cheap check that only loads
|
||||||
|
// the party chunk (which shows the invite list) when there is one.
|
||||||
|
const peek = async () => {
|
||||||
|
const name = data.profile && data.profile.name;
|
||||||
|
if (!name || Lazy.loaded('feature:watch-party') || document.visibilityState === 'hidden') return;
|
||||||
|
try {
|
||||||
|
const j = await (await fetch(`/api/party/invites?name=${encodeURIComponent(name)}`, { headers: profileHeaders(name) })).json();
|
||||||
|
if (j && j.ok && j.invites && j.invites.length) { await Lazy.load('feature:watch-party'); window.YT.Party.checkInvites(); }
|
||||||
|
} catch { /* offline */ }
|
||||||
|
};
|
||||||
|
setTimeout(peek, 3000);
|
||||||
|
setInterval(peek, 60_000);
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') peek(); });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
Lazy.onLoad('feature:watch-party', () => window.YT.Party.boot());
|
Lazy.onLoad('feature:watch-party', () => window.YT.Party.boot());
|
||||||
|
|||||||
@@ -4066,6 +4066,14 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t
|
|||||||
.party-panel.min .pt-body { display: none; }
|
.party-panel.min .pt-body { display: none; }
|
||||||
.pt-present { display: flex; flex-wrap: wrap; gap: 6px; }
|
.pt-present { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
.pt-present:empty { display: none; }
|
.pt-present:empty { display: none; }
|
||||||
|
.pt-invite { display: flex; gap: 6px; }
|
||||||
|
.pt-invite input { flex: 1; min-width: 0; }
|
||||||
|
.pt-uninvite { margin-left: 4px; min-width: 28px; min-height: 28px; border: 0; background: none; color: var(--text-2); font-size: 16px; cursor: pointer; }
|
||||||
|
.pt-invites { display: grid; gap: 8px; margin-top: 4px; }
|
||||||
|
.pt-invite-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--bg-2); }
|
||||||
|
.pt-invite-row > div { flex: 1; min-width: 0; display: grid; gap: 2px; }
|
||||||
|
.pt-invite-row small { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pt-invite-row .rv-primary { flex: none; min-height: 44px; }
|
||||||
/* Shared presentation: fills the screen beside the party panel (below it on phones). */
|
/* Shared presentation: fills the screen beside the party panel (below it on phones). */
|
||||||
.party-present {
|
.party-present {
|
||||||
position: fixed; inset: 12px 372px 12px 12px; z-index: 155;
|
position: fixed; inset: 12px 372px 12px 12px; z-index: 155;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0,
|
ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0,
|
||||||
dj: { enabled: false, entries: [] }, djPending: false,
|
dj: { enabled: false, entries: [] }, djPending: false,
|
||||||
allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '',
|
allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '',
|
||||||
loadingId: '', restoring: false, inviteCode: '', hiddenAt: 0, present: null, presentHidden: false,
|
loadingId: '', restoring: false, inviteCode: '', hiddenAt: 0, present: null, presentHidden: false, invites: [], inviteError: '',
|
||||||
voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
|
voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
|
||||||
minimized: false,
|
minimized: false,
|
||||||
};
|
};
|
||||||
@@ -70,6 +70,7 @@
|
|||||||
st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false;
|
st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false;
|
||||||
st.allowControl = !!m.allowControl;
|
st.allowControl = !!m.allowControl;
|
||||||
st.present = m.present || null; renderPresent();
|
st.present = m.present || null; renderPresent();
|
||||||
|
st.invites = m.invites || [];
|
||||||
setStatus(isHost() ? 'You’re hosting' : 'Synced with the host');
|
setStatus(isHost() ? 'You’re hosting' : 'Synced with the host');
|
||||||
if (m.lost && isHost()) toast(`🎉 The old party was lost (server restart). New code ${m.code} — share the link again`, { duration: 8000 });
|
if (m.lost && isHost()) toast(`🎉 The old party was lost (server restart). New code ${m.code} — share the link again`, { duration: 8000 });
|
||||||
render();
|
render();
|
||||||
@@ -109,6 +110,10 @@
|
|||||||
st.allowControl = !!m.allowControl;
|
st.allowControl = !!m.allowControl;
|
||||||
render();
|
render();
|
||||||
if (!isHost()) sysMsg(st.allowControl ? 'The host lets everyone control playback' : 'Only the host controls playback now');
|
if (!isHost()) sysMsg(st.allowControl ? 'The host lets everyone control playback' : 'Only the host controls playback now');
|
||||||
|
} else if (m.type === 'invites') {
|
||||||
|
st.invites = m.invites || []; st.inviteError = m.error || '';
|
||||||
|
if (m.error) toast('⚠ ' + m.error);
|
||||||
|
render();
|
||||||
} else if (m.type === 'present') {
|
} else if (m.type === 'present') {
|
||||||
const was = st.present && st.present.url;
|
const was = st.present && st.present.url;
|
||||||
st.present = m.present || null;
|
st.present = m.present || null;
|
||||||
@@ -395,6 +400,8 @@
|
|||||||
<div class="pt-present">${st.present
|
<div class="pt-present">${st.present
|
||||||
? `${st.presentHidden ? '<button class="np-btn" type="button" data-act="present-show">🖥 Show the presentation</button>' : ''}${canPresent() ? '<button class="np-btn" type="button" data-act="present">Change link</button><button class="np-btn" type="button" data-act="present-stop">Stop presenting</button>' : ''}`
|
? `${st.presentHidden ? '<button class="np-btn" type="button" data-act="present-show">🖥 Show the presentation</button>' : ''}${canPresent() ? '<button class="np-btn" type="button" data-act="present">Change link</button><button class="np-btn" type="button" data-act="present-stop">Stop presenting</button>' : ''}`
|
||||||
: canPresent() ? '<button class="np-btn" type="button" data-act="present">🖥 Present a document or website</button>' : ''}</div>
|
: canPresent() ? '<button class="np-btn" type="button" data-act="present">🖥 Present a document or website</button>' : ''}</div>
|
||||||
|
${isHost() ? `<form class="pt-invite"><input class="pt-invite-name" maxlength="40" placeholder="Invite a profile by name" autocapitalize="none" autocomplete="off" spellcheck="false" /><button class="np-btn" type="submit">Invite</button></form>
|
||||||
|
${st.invites.length ? `<div class="pt-members pt-invited">${st.invites.map((n) => `<span class="pt-member">✉ ${escapeHtml(n)} <button type="button" class="pt-uninvite" data-act="uninvite" data-profile="${escapeHtml(n)}" aria-label="Cancel the invite for ${escapeHtml(n)}">×</button></span>`).join('')}</div>` : ''}` : ''}
|
||||||
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
|
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
|
||||||
<div class="pt-present"><button class="np-btn pt-lyrics${lyricsOn() ? ' on' : ''}" type="button" data-act="lyrics">${lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'}</button>
|
<div class="pt-present"><button class="np-btn pt-lyrics${lyricsOn() ? ' on' : ''}" type="button" data-act="lyrics">${lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'}</button>
|
||||||
<button class="np-btn pt-land" type="button" data-act="land"${lyricsOn() ? '' : ' hidden'}>${Notes.stageLyrics.landscapeOn?.() ? '↺ Exit landscape' : '↻ Landscape lyrics'}</button></div>
|
<button class="np-btn pt-land" type="button" data-act="land"${lyricsOn() ? '' : ' hidden'}>${Notes.stageLyrics.landscapeOn?.() ? '↺ Exit landscape' : '↻ Landscape lyrics'}</button></div>
|
||||||
@@ -463,6 +470,7 @@
|
|||||||
else if (act === 'land') { Notes.stageLyrics.landscape?.(!Notes.stageLyrics.landscapeOn?.()); renderLyricsToggle(); }
|
else if (act === 'land') { Notes.stageLyrics.landscape?.(!Notes.stageLyrics.landscapeOn?.()); renderLyricsToggle(); }
|
||||||
else if (act === 'leave') leave(true);
|
else if (act === 'leave') leave(true);
|
||||||
else if (act === 'present') presentPrompt();
|
else if (act === 'present') presentPrompt();
|
||||||
|
else if (act === 'uninvite') send({ type: 'uninvite', profile: a.dataset.profile });
|
||||||
else if (act === 'present-stop') send({ type: 'present', present: null });
|
else if (act === 'present-stop') send({ type: 'present', present: null });
|
||||||
else if (act === 'present-show') { st.presentHidden = false; renderPresent(); render(); }
|
else if (act === 'present-show') { st.presentHidden = false; renderPresent(); render(); }
|
||||||
});
|
});
|
||||||
@@ -471,6 +479,11 @@
|
|||||||
});
|
});
|
||||||
panel.addEventListener('submit', (e) => {
|
panel.addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (e.target.closest('.pt-invite')) {
|
||||||
|
const inp = panel.querySelector('.pt-invite-name'), name = inp.value.trim().toLowerCase();
|
||||||
|
if (name) { send({ type: 'invite', profile: name }); inp.value = ''; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
const input = panel.querySelector('.pt-msg');
|
const input = panel.querySelector('.pt-msg');
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
if (text) { send({ type: 'chat', text }); input.value = ''; }
|
if (text) { send({ type: 'chat', text }); input.value = ''; }
|
||||||
@@ -485,6 +498,34 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- invites: parties that invited this device's online profile ----------
|
||||||
|
const SEEN_KEY = 'ytpPartyInvitesSeen';
|
||||||
|
let invitesOpen = false;
|
||||||
|
async function checkInvites({ force = false } = {}) {
|
||||||
|
const name = data.profile && data.profile.name;
|
||||||
|
if (!name || inParty() || invitesOpen || document.visibilityState === 'hidden') return;
|
||||||
|
let list = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/party/invites?name=${encodeURIComponent(name)}`, { headers: typeof profileHeaders === 'function' ? profileHeaders(name) : {} });
|
||||||
|
const j = await res.json(); list = (j && j.ok && j.invites) || [];
|
||||||
|
} catch { return; }
|
||||||
|
let seen = []; try { seen = JSON.parse(ls.get(SEEN_KEY) || '[]'); } catch { /* none */ }
|
||||||
|
const fresh = list.filter((i) => force || !seen.includes(`${i.code}:${i.at}`));
|
||||||
|
if (!fresh.length || inParty()) return;
|
||||||
|
invitesOpen = true;
|
||||||
|
const markSeen = () => { ls.set(SEEN_KEY, JSON.stringify([...new Set([...seen, ...list.map((i) => `${i.code}:${i.at}`)])].slice(-50))); };
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.innerHTML = `<p class="modal-text">${list.length === 1 ? 'You’ve been invited to a watch party.' : `You’ve been invited to ${list.length} watch parties.`}</p>
|
||||||
|
<div class="pt-invites">${list.map((i) => `<div class="pt-invite-row">
|
||||||
|
<div><b>${escapeHtml(i.host)}’s party</b><small>${i.song ? `Playing ${escapeHtml(i.song)} · ` : ''}${i.members} ${i.members === 1 ? 'person' : 'people'}${i.hostOnline ? '' : ' · host away'}</small></div>
|
||||||
|
<button class="rv-primary" type="button" data-join="${escapeHtml(i.code)}">Join</button></div>`).join('')}</div>`;
|
||||||
|
body.addEventListener('click', (e) => {
|
||||||
|
const b = e.target.closest('[data-join]'); if (!b) return;
|
||||||
|
markSeen(); invitesOpen = false; closeModal(); join(b.dataset.join, myName() || name);
|
||||||
|
});
|
||||||
|
showModal('🎉 Watch party invites', body, [{ label: 'Dismiss', onClick: () => { markSeen(); invitesOpen = false; closeModal(); } }]);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- presenting: a shared document / website everyone sees ----------
|
// ---------- presenting: a shared document / website everyone sees ----------
|
||||||
const canPresent = () => inParty() && (isHost() || st.allowControl);
|
const canPresent = () => inParty() && (isHost() || st.allowControl);
|
||||||
// Google Slides/Docs/Drive and PDFs get their embeddable form; Slides and PDFs
|
// Google Slides/Docs/Drive and PDFs get their embeddable form; Slides and PDFs
|
||||||
@@ -561,8 +602,11 @@
|
|||||||
render();
|
render();
|
||||||
// iOS keeps a dead socket "open" after the PWA was in the background:
|
// iOS keeps a dead socket "open" after the PWA was in the background:
|
||||||
// after a long absence reconnect straight away instead of waiting for onclose.
|
// after a long absence reconnect straight away instead of waiting for onclose.
|
||||||
|
setTimeout(() => checkInvites(), 2500);
|
||||||
|
setInterval(() => checkInvites(), 60_000);
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'hidden') { st.hiddenAt = Date.now(); return; }
|
if (document.visibilityState === 'hidden') { st.hiddenAt = Date.now(); return; }
|
||||||
|
checkInvites();
|
||||||
if (!inParty() || st.detached) return;
|
if (!inParty() || st.detached) return;
|
||||||
const ws = st.ws;
|
const ws = st.ws;
|
||||||
if (!ws || ws.readyState !== 1 || Date.now() - st.hiddenAt > 20000) {
|
if (!ws || ws.readyState !== 1 || Date.now() - st.hiddenAt > 20000) {
|
||||||
@@ -586,7 +630,7 @@
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
nextDJ: () => { if (!isHost() || !st.dj.enabled || !st.dj.entries.some(e => e.approved)) return false; if (!st.djPending) { st.djPending = true; send({ type: 'dj', action: 'take' }); } return true; },
|
nextDJ: () => { if (!isHost() || !st.dj.enabled || !st.dj.entries.some(e => e.approved)) return false; if (!st.djPending) { st.djPending = true; send({ type: 'dj', action: 'take' }); } return true; },
|
||||||
open, boot, guestActive, intercept, renderLyricsToggle, afterLoad: () => pushState(true),
|
open, boot, guestActive, intercept, renderLyricsToggle, checkInvites, afterLoad: () => pushState(true),
|
||||||
state: () => ({
|
state: () => ({
|
||||||
role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on,
|
role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on,
|
||||||
peers: st.voice.pcs.size, peerStates: [...st.voice.pcs.values()].map((pc) => pc.connectionState),
|
peers: st.voice.pcs.size, peerStates: [...st.voice.pcs.values()].map((pc) => pc.connectionState),
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
* any → all {type:'voice', on} (voice-chat presence)
|
* any → all {type:'voice', on} (voice-chat presence)
|
||||||
* any → one {type:'rtc', to, data} (WebRTC offer/answer/ICE, relayed)
|
* any → one {type:'rtc', to, data} (WebRTC offer/answer/ICE, relayed)
|
||||||
* guest → host {type:'cmd', cmd, …} (only when the host allows control)
|
* 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
|
* host → all {type:'present', present: {url, page} | null} shared document/website
|
||||||
* (guests too while the host allows control; kept for late joiners)
|
* (guests too while the host allows control; kept for late joiners)
|
||||||
* server → you {type:'hello', code, secret?, you, now, members, state, chat, allowControl}
|
* 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;
|
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); };
|
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 parties = new Map(); // code → party
|
||||||
const joinFails = new Map();
|
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 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 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() {
|
function newCode() {
|
||||||
let c;
|
let c;
|
||||||
do { c = Array.from({ length: 6 }, () => ALPHABET[randomInt(ALPHABET.length)]).join(''); } while (parties.has(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) {
|
if (!p) {
|
||||||
lost = !!d.code; // resuming a room the server no longer has (restart / expiry)
|
lost = !!d.code; // resuming a room the server no longer has (restart / expiry)
|
||||||
const code = newCode();
|
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);
|
parties.set(code, p);
|
||||||
}
|
}
|
||||||
p.hostPid = d.pid;
|
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() });
|
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, {
|
send(ws, {
|
||||||
type: 'hello', code: p.code, secret: d.role === 'host' ? p.secret : undefined, you: d.pid, lost,
|
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);
|
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() };
|
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 });
|
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) {
|
} else if (m.type === 'settings' && isHost) {
|
||||||
p.allowControl = !!m.allowControl;
|
p.allowControl = !!m.allowControl;
|
||||||
broadcast(p, { type: 'settings', allowControl: p.allowControl });
|
broadcast(p, { type: 'settings', allowControl: p.allowControl });
|
||||||
@@ -195,6 +225,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
upgrade,
|
upgrade,
|
||||||
|
invitesFor,
|
||||||
websocket: { maxPayloadLength: MAX_MSG, idleTimeout: 120, open, message: (ws, msg) => message(ws, typeof msg === 'string' ? msg : null), close },
|
websocket: { maxPayloadLength: MAX_MSG, idleTimeout: 120, open, message: (ws, msg) => message(ws, typeof msg === 'string' ? msg : null), close },
|
||||||
_parties: parties,
|
_parties: parties,
|
||||||
stop() { clearInterval(sweeper); },
|
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', () => {
|
describe('watch party seat rules', () => {
|
||||||
test('unknown code closes with 4004 (no reconnect loop)', async () => {
|
test('unknown code closes with 4004 (no reconnect loop)', async () => {
|
||||||
const bad = client({ role: 'guest', pid: 'guestpid7777', name: 'X', code: 'YYYYYY' });
|
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
|
// Phone remote for a desktop instance — see remote.js
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
const remote = createRemoteHub({ requireSameNetwork: process.env.REMOTE_SAME_NETWORK === '1' });
|
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
|
// Bun allows ONE websocket handler per server: party sockets are tagged
|
||||||
// (ws.data.hub === 'party'), everything else belongs to the remote relay.
|
// (ws.data.hub === 'party'), everything else belongs to the remote relay.
|
||||||
|
|||||||
Reference in New Issue
Block a user