Watch party: Prepared presentations queue for the host (add, name, reorder, remove, present when it is time)
This commit is contained in:
@@ -4066,6 +4066,15 @@ 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-pq { display: grid; gap: 6px; padding: 8px; border: 1px solid var(--line); border-radius: 12px; }
|
||||||
|
.pt-pq-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.pt-pq-row { display: flex; align-items: center; gap: 6px; padding: 6px; border-radius: 10px; background: var(--bg-1); }
|
||||||
|
.pt-pq-row.live { box-shadow: inset 0 0 0 1px var(--accent, #f06b53); }
|
||||||
|
.pt-pq-name { flex: 1; min-width: 0; display: grid; overflow: hidden; }
|
||||||
|
.pt-pq-name, .pt-pq-name small { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.pt-pq-name small { color: var(--text-dim); font-size: 11px; }
|
||||||
|
.pt-pq-row .np-btn { flex: none; min-height: 36px; padding-inline: 10px; }
|
||||||
|
.pt-pq-empty { margin: 0; font-size: 12px; color: var(--text-2); }
|
||||||
.pt-invite { display: flex; gap: 6px; }
|
.pt-invite { display: flex; gap: 6px; }
|
||||||
.pt-invite input { flex: 1; min-width: 0; }
|
.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-uninvite { margin-left: 4px; min-width: 28px; min-height: 28px; border: 0; background: none; color: var(--text-2); font-size: 16px; cursor: pointer; }
|
||||||
@@ -4120,7 +4129,7 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t
|
|||||||
.pt-status { font-family: var(--mono); font-size: 10px; color: var(--text-dim); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.pt-status { font-family: var(--mono); font-size: 10px; color: var(--text-dim); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.pt-body { display: flex; flex-direction: column; gap: 10px; min-height: 0; flex: 1; }
|
.pt-body { display: flex; flex-direction: column; gap: 10px; min-height: 0; flex: 1; }
|
||||||
.pt-row { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--text-dim); }
|
.pt-row { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--text-dim); }
|
||||||
.pt-row input, .pt-msg, .pt-name {
|
.pt-row input, .pt-msg, .pt-name, .pt-invite-name {
|
||||||
flex: 1; min-width: 0; background: var(--bg-1); color: var(--text); border: 1px solid var(--line);
|
flex: 1; min-width: 0; background: var(--bg-1); color: var(--text); border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm); padding: 8px 10px; font-size: 14px;
|
border-radius: var(--radius-sm); padding: 8px 10px; font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, invites: [], inviteError: '', viewMode: {}, reader: { url: '', html: '', error: '', loading: false },
|
loadingId: '', restoring: false, inviteCode: '', hiddenAt: 0, present: null, presentHidden: false, invites: [], inviteError: '', pq: [], viewMode: {}, reader: { url: '', html: '', error: '', loading: false },
|
||||||
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,7 +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 || [];
|
st.invites = m.invites || []; st.pq = m.pq || [];
|
||||||
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();
|
||||||
@@ -110,6 +110,8 @@
|
|||||||
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 === 'pq') {
|
||||||
|
st.pq = m.items || []; render();
|
||||||
} else if (m.type === 'invites') {
|
} else if (m.type === 'invites') {
|
||||||
st.invites = m.invites || []; st.inviteError = m.error || '';
|
st.invites = m.invites || []; st.inviteError = m.error || '';
|
||||||
if (m.error) toast('⚠ ' + m.error);
|
if (m.error) toast('⚠ ' + m.error);
|
||||||
@@ -400,6 +402,14 @@
|
|||||||
<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() ? `<div class="pt-pq">
|
||||||
|
<div class="pt-pq-head"><b>Prepared</b><button class="np-btn" type="button" data-act="pq-new">+ Prepare</button></div>
|
||||||
|
${st.pq.length ? st.pq.map((q, i) => `<div class="pt-pq-row${st.present && st.present.url === q.url ? ' live' : ''}">
|
||||||
|
<span class="pt-pq-name">${escapeHtml(q.title || embedOf(q.url, 1).label || q.url)}<small>${q.mode === 'reader' ? 'Reader' : escapeHtml(embedOf(q.url, 1).label || '')}</small></span>
|
||||||
|
<button class="np-btn" type="button" data-act="pq-go" data-id="${escapeHtml(q.id)}">${st.present && st.present.url === q.url ? 'Showing' : 'Present'}</button>
|
||||||
|
<button class="np-btn" type="button" data-act="pq-up" data-id="${escapeHtml(q.id)}" aria-label="Move up"${i ? '' : ' disabled'}>↑</button>
|
||||||
|
<button class="np-btn" type="button" data-act="pq-del" data-id="${escapeHtml(q.id)}" aria-label="Remove">×</button></div>`).join('')
|
||||||
|
: '<p class="pt-pq-empty">Line up slides, documents and pages now; present each one when its time comes.</p>'}</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>
|
${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>` : ''}` : ''}
|
${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>` : ''}
|
||||||
@@ -470,6 +480,10 @@
|
|||||||
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 === 'pq-new') presentPrompt({ queue: true });
|
||||||
|
else if (act === 'pq-go') { const q = st.pq.find((x) => x.id === a.dataset.id); if (q) send({ type: 'present', present: { url: q.url, mode: q.mode, page: 1 } }); }
|
||||||
|
else if (act === 'pq-up') send({ type: 'pq', action: 'move', id: a.dataset.id, by: -1 });
|
||||||
|
else if (act === 'pq-del') send({ type: 'pq', action: 'remove', id: a.dataset.id });
|
||||||
else if (act === 'uninvite') send({ type: 'uninvite', profile: a.dataset.profile });
|
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(); }
|
||||||
@@ -633,22 +647,29 @@
|
|||||||
const frame = el.querySelector('.pp-frame');
|
const frame = el.querySelector('.pp-frame');
|
||||||
if (!reading && frame.getAttribute('src') !== e.src) frame.setAttribute('src', e.src);
|
if (!reading && frame.getAttribute('src') !== e.src) frame.setAttribute('src', e.src);
|
||||||
}
|
}
|
||||||
function presentPrompt() {
|
function presentPrompt({ queue = false } = {}) {
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.innerHTML = `<p class="modal-text">Everyone in the party sees it full screen. Google Slides and PDFs follow your page — use ‹ › or the arrow keys.</p>
|
body.innerHTML = `<p class="modal-text">Everyone in the party sees it full screen. Google Slides and PDFs follow your page — use ‹ › or the arrow keys.</p>
|
||||||
<input id="ppUrl" type="url" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://… a website, Google Slides/Docs or a PDF" value="${escapeHtml(st.present?.url || '')}" />
|
<input id="ppUrl" type="url" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://… a website, Google Slides/Docs or a PDF" value="${queue ? '' : escapeHtml(st.present?.url || '')}" />
|
||||||
|
${isHost() ? '<input id="ppTitle" maxlength="120" autocomplete="off" placeholder="Name it (optional), e.g. Sermon slides" style="margin-top:8px" />' : ''}
|
||||||
<label class="pt-toggle" style="margin-top:8px"><input type="checkbox" id="ppReader" ${st.present && st.present.mode === 'embed' ? '' : 'checked'} /> Reader view for websites — clean text everyone can enlarge</label>
|
<label class="pt-toggle" style="margin-top:8px"><input type="checkbox" id="ppReader" ${st.present && st.present.mode === 'embed' ? '' : 'checked'} /> Reader view for websites — clean text everyone can enlarge</label>
|
||||||
<p class="modal-text" style="font-size:12px">Google files must be shared “Anyone with the link”. Sites like Facebook or banks refuse embedding; guests can still tap Open.</p>`;
|
<p class="modal-text" style="font-size:12px">Google files must be shared “Anyone with the link”. Sites like Facebook or banks refuse embedding; guests can still tap Open.</p>`;
|
||||||
const go = () => {
|
const go = (toQueue = false) => {
|
||||||
let url = ($('ppUrl').value || '').trim();
|
let url = ($('ppUrl').value || '').trim();
|
||||||
if (url && !/^https?:\/\//i.test(url)) url = 'https://' + url;
|
if (url && !/^https?:\/\//i.test(url)) url = 'https://' + url;
|
||||||
try { new URL(url); } catch { toast('Paste a full link, e.g. https://…'); return; }
|
try { new URL(url); } catch { toast('Paste a full link, e.g. https://…'); return; }
|
||||||
const special = embedOf(url, 1); // Google files and PDFs always embed
|
const special = embedOf(url, 1); // Google files and PDFs always embed
|
||||||
const mode = $('ppReader').checked && !special.paged && !/Google/.test(special.label || '') ? 'reader' : 'embed';
|
const mode = $('ppReader').checked && !special.paged && !/Google/.test(special.label || '') ? 'reader' : 'embed';
|
||||||
closeModal(); send({ type: 'present', present: { url, page: 1, mode } });
|
closeModal();
|
||||||
|
if (toQueue) { send({ type: 'pq', action: 'add', url, mode, title: ($('ppTitle')?.value || '').trim() }); toast('Added to Prepared ✓'); }
|
||||||
|
else send({ type: 'present', present: { url, page: 1, mode } });
|
||||||
};
|
};
|
||||||
showModal('🖥 Present to the party', body, [{ label: 'Cancel', onClick: closeModal }, { label: 'Present', primary: true, onClick: go }]);
|
showModal(queue ? '🖥 Prepare a presentation' : '🖥 Present to the party', body, [
|
||||||
setTimeout(() => { const i = $('ppUrl'); if (i) { i.focus(); i.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } }); } }, 50);
|
{ label: 'Cancel', onClick: closeModal },
|
||||||
|
...(isHost() ? [{ label: 'Add to Prepared', primary: queue, onClick: () => go(true) }] : []),
|
||||||
|
...(queue ? [] : [{ label: 'Present now', primary: true, onClick: () => go(false) }]),
|
||||||
|
]);
|
||||||
|
setTimeout(() => { const i = $('ppUrl'); if (i) { i.focus(); i.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(queue); } }); } }, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
* 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
|
* 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}
|
* 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
|
* 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}
|
||||||
@@ -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 PROFILE_RE = /^[a-z0-9_-]{3,40}$/;
|
||||||
const MAX_INVITES = 30;
|
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 } = {}) {
|
export function createPartyHub({ now = () => Date.now(), profileExists = async () => true } = {}) {
|
||||||
const parties = new Map(); // code → party
|
const parties = new Map(); // code → party
|
||||||
@@ -114,7 +118,7 @@ export function createPartyHub({ now = () => Date.now(), profileExists = async (
|
|||||||
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 = { 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);
|
parties.set(code, p);
|
||||||
}
|
}
|
||||||
p.hostPid = d.pid;
|
p.hostPid = d.pid;
|
||||||
@@ -134,7 +138,7 @@ export function createPartyHub({ now = () => Date.now(), profileExists = async (
|
|||||||
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,
|
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);
|
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() };
|
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 });
|
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) {
|
} else if ((m.type === 'invite' || m.type === 'uninvite') && isHost) {
|
||||||
const name = String(m.profile || '').trim().toLowerCase();
|
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 (!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', () => {
|
describe('watch party invites', () => {
|
||||||
test('host invites a profile; it is listed for that profile until uninvited or the party ends', async () => {
|
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 host = client({ role: 'host', pid: 'hostpid0020', name: 'Josh' });
|
||||||
|
|||||||
Reference in New Issue
Block a user