Files
hub/public/signal-view.js
2026-10-22 16:22:05 -04:00

254 lines
26 KiB
JavaScript

'use strict';
// ---- Signal (through signal-cli-rest-api on the server; this hub is one more "linked device" of your Signal account)
const sgq = q => (sg.root || document).querySelector(q);
const sgApi = (p, o) => jfetch('/api/signal' + p, o);
const sgPost = (p, body) => sgApi(p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const sgTime = ts => { const d = new Date(ts), n = new Date(); return d.toDateString() === n.toDateString() ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : (n - d < 6 * 864e5 ? d.toLocaleDateString([], { weekday: 'short' }) : d.toLocaleDateString([], { month: 'short', day: 'numeric' })); };
const sgInitial = n => (String(n).replace(/^\+/, '').trim()[0] || '?').toUpperCase();
const sgColor = id => `hsl(${[...String(id)].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 360, 7)} 45% 42%)`;
const sgOnView = () => location.hash.startsWith('#/signal');
const sgMounted = () => !!sg.root && document.contains(sg.root);
async function sgStart() { // keep the unread count and live updates running whatever screen is open
if (!allowed('signal') || sg.es) return;
try { const st = await sgApi('/status'); sg.st = st; sg.unread = st.unread || 0; renderNav(activeTab); } catch {}
sg.es = new EventSource('/api/signal/events');
sg.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } sgEvent(ev); };
}
function sgStop() { if (sg.es) { sg.es.close(); sg.es = null; } }
// ---- Getting your attention: a chime, a desktop notification when the app is in the background, the count in the tab title and on the app icon.
const sgAlerts = () => { try { return { sound: true, notify: false, ...JSON.parse(localStorage.getItem('sg_alerts')) }; } catch { return { sound: true, notify: false }; } };
const sgSetAlerts = p => { try { localStorage.setItem('sg_alerts', JSON.stringify({ ...sgAlerts(), ...p })); } catch {} };
let sgAC = null;
function sgUnlockAudio() { try { sgAC = sgAC || new (window.AudioContext || window.webkitAudioContext)(); if (sgAC.state === 'suspended') sgAC.resume(); } catch {} } // a browser only allows sound after you have used the page once
['pointerdown', 'keydown', 'touchstart'].forEach(e => addEventListener(e, sgUnlockAudio, { passive: true }));
function sgChime() {
sgUnlockAudio(); if (!sgAC || sgAC.state !== 'running') return;
const t = sgAC.currentTime;
[[880, 0], [1318, 0.14]].forEach(([f, d]) => { const o = sgAC.createOscillator(), g = sgAC.createGain(); o.type = 'sine'; o.frequency.value = f;
g.gain.setValueAtTime(0.0001, t + d); g.gain.exponentialRampToValueAtTime(0.22, t + d + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t + d + 0.4);
o.connect(g); g.connect(sgAC.destination); o.start(t + d); o.stop(t + d + 0.45); });
}
function sgBadgeEverywhere() { // Signal chats, unread mail and tasks due: one number on the tab title and the installed app's icon
const n = (sg.unread || 0) + (typeof mailUnread === 'number' ? mailUnread : 0) + (typeof taskDue === 'number' ? taskDue : 0);
document.title = (n ? '(' + (n > 99 ? '99+' : n) + ') ' : '') + 'NE Hub';
try { if (navigator.setAppBadge) { n ? navigator.setAppBadge(n) : navigator.clearAppBadge(); } } catch {}
}
function sgAlert(ev) { // a message from someone else arrived and you are not looking at that chat
const a = sgAlerts();
if (a.sound) sgChime();
if (a.notify && 'Notification' in window && Notification.permission === 'granted' && (document.hidden || !document.hasFocus())) {
try { const n = new Notification(ev.name || 'Signal', { body: ev.preview || 'New message', tag: 'sg:' + ev.conv, icon: '/icons/icon-192.png', silent: true });
n.onclick = () => { window.focus(); n.close(); sg.cur = ev.conv; sgSaveDock(); if (sgOnView()) location.hash = '#/signal/' + encodeURIComponent(ev.conv); else { sg.dockOpen = true; sgSaveDock(); sgDockSync(activeTab); } }; } catch { /* the browser refused */ }
}
}
let sgEvT = null;
function sgEvent(ev) {
if (typeof ev.unread === 'number' && ev.unread !== sg.unread) { sg.unread = ev.unread; renderNav(activeTab); }
if (ev.t === 'msg' && !ev.out) {
const looking = sgMounted() && sg.cur === ev.conv && document.visibilityState === 'visible' && document.hasFocus();
if (!looking) { toast('Signal · ' + (ev.name || '') + ': ' + (ev.preview || '')); sgAlert(ev); }
}
if (ev.t === 'gone') sgGone(ev.conv, true);
if (!sgMounted()) return;
clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (ev.t === 'status') return sgMount(sg.root, sg.dock); sgRefresh(); }, 150);
}
// The Signal tab (full page, desktop) ...
function viewSignal(args = []) { sg.cur = args[0] || null; sgDockHide(); return sgMount($('#view'), false); }
// ... and the same screens in a panel that floats over every other screen.
async function sgMount(root, dock) {
sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : '<h1>Signal</h1>';
root.innerHTML = `<div class="${cls}">${title}<p class="empty">Loading…</p></div>`;
let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `<div class="${cls}">${title}<p class="err">${esc(e.message)}</p></div>`; return; }
if (sg.root !== root) return; sg.st = st;
if (!st.reachable) { root.innerHTML = `<div class="${cls}">${title}<div class="card" style="max-width:none"><p style="margin-top:0">The Signal service is not answering. Check that its container (<b>signal</b>) is running, then reload.</p></div></div>`; return; }
if (!st.linked) return sgLinkScreen(root, dock);
root.innerHTML = `<div class="${cls}"><div class="sg ${sg.cur ? 'open' : ''}"><div class="sgl"><div class="sghead">${dock ? '<b class="sgdt">Chats</b>' : '<h1>Signal</h1>'}<button class="btn ghost" id="sgnew">${icon('edit')}New</button></div>
<input type="search" id="sgq" placeholder="Search chats" autocomplete="off"><div class="list sglist" id="sglist"></div><p class="sub" style="margin-top:8px">${esc(st.number)}${st.connected ? '' : ' · reconnecting…'}</p>${st.gap ? `<p class="sub">The link to Signal was down at ${new Date(st.gap.from).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} for ${Math.max(1, Math.round((st.gap.to - st.gap.from) / 1000))} s. A message that arrived then may be missing here.</p>` : ''}</div>
<div class="sgm" id="sgm"></div></div></div>`;
sgq('#sgnew').onclick = sgNewChat; sgq('#sgq').oninput = sgDrawList;
await sgRefresh(true);
}
async function sgRefresh(first) {
try {
const r = await sgApi('/convs'); sg.convs = r.convs; sg.unread = r.unread; renderNav(activeTab);
if (!sgq('#sglist')) return; sgDrawList();
if (sg.cur) await sgDrawThread(first); else sgq('#sgm').innerHTML = '<p class="empty">Choose a conversation, or start a new one.</p>';
} catch (e) { const l = sgq('#sglist'); if (l) l.innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; }
}
function sgOpen(id) { // in the floating panel a chat opens in place (the page behind does not change)
sg.cur = id; sgSaveDock(); const box = sgq('.sg'); if (box) box.classList.toggle('open', !!id);
sgq('#sgm').innerHTML = ''; sgDrawList(); if (id) sgDrawThread(true);
}
function sgDrawList() {
const q = (sgq('#sgq') || {}).value ? sgq('#sgq').value.toLowerCase() : '', box = sgq('#sglist'); if (!box) return;
const rows = sg.convs.filter(c => !q || (c.name || c.id).toLowerCase().includes(q) || (c.preview || '').toLowerCase().includes(q));
box.innerHTML = rows.map(c => `<a class="row sgrow ${c.id === sg.cur ? 'on' : ''}" data-id="${esc(c.id)}" href="${sg.dock ? '#' : '#/signal/' + encodeURIComponent(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))}</span>
<span class="grow"><b>${esc(c.name || c.id)}</b><small>${esc(c.preview || '')}</small></span><span class="sgside"><small>${c.ts ? sgTime(c.ts) : ''}</small>${c.unread ? `<i class="nbadge" style="position:static">${c.unread}</i>` : ''}</span></a>`).join('') || `<p class="empty">${q ? 'Nothing matches' : 'No conversations yet. Tap New, or wait for a message.'}</p>`;
if (sg.dock) box.onclick = e => { const a = e.target.closest('[data-id]'); if (a) { e.preventDefault(); sgOpen(a.dataset.id); } };
box.oncontextmenu = e => { const a = e.target.closest('[data-id]'); if (a) { e.preventDefault(); sgChatMenu(a.dataset.id); } };
}
const sgLink = t => t.replace(/(https?:\/\/[^\s<]+[^\s<.,;:!?)\]'"])/g, u => `<a href="${u}" target="_blank" rel="noopener">${u}</a>`);
const sgSize = n => n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(0) + ' KB' : (n / 1048576).toFixed(1) + ' MB';
function sgBubble(m, c, prev) {
const nm = a => (sg.names[a] || (a === sg.st.number ? 'You' : a));
const att = (m.att || []).map(a => { const u = '/api/signal/attachment/' + encodeURIComponent(a.id);
return /^image\//.test(a.type || '') ? `<a href="${u}" target="_blank" rel="noopener"><img class="sgimg" loading="lazy" alt="${esc(a.name || 'photo')}" src="${u}"></a>`
: /^audio\//.test(a.type || '') ? `<audio controls preload="none" src="${u}"></audio>`
: /^video\//.test(a.type || '') ? `<video controls preload="metadata" playsinline src="${u}"></video>`
: `<a class="sgfile" href="${u}" target="_blank" rel="noopener">${icon('clip')}<span>${esc(a.name || 'file')}${a.size ? ' · ' + sgSize(a.size) : ''}</span></a>`; }).join('');
const reacts = {}; Object.values(m.react || {}).forEach(e => { reacts[e] = (reacts[e] || 0) + 1; });
const day = !prev || new Date(prev.ts).toDateString() !== new Date(m.ts).toDateString() ? `<div class="sgday">${new Date(m.ts).toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })}</div>` : '';
const tick = m.out ? (m.status === 'read' ? '✓✓' : m.status === 'delivered' ? '✓✓' : '✓') : '';
return `${day}<div class="sgb ${m.out ? 'out' : 'in'}" data-ts="${m.ts}">${c.type === 'group' && !m.out ? `<small class="who" style="color:${sgColor(m.from)}">${esc(m.name || nm(m.from))}</small>` : ''}
${m.quote ? `<div class="sgq"><b>${esc(nm(m.quote.author))}</b><span>${esc(m.quote.text || '')}</span></div>` : ''}${att}
${m.deleted ? '<i class="sgdel">This message was deleted</i>' : m.text ? `<div class="sgt">${sgLink(esc(m.text)).replace(/\n/g, '<br>')}</div>` : ''}
<span class="sgmeta">${m.edited ? 'edited · ' : ''}${new Date(m.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}${tick ? ` <span class="${m.status === 'read' ? 'rd' : ''}">${tick}</span>` : ''}</span>
${Object.keys(reacts).length ? `<span class="sgre">${Object.entries(reacts).map(([e, n]) => e + (n > 1 ? ' ' + n : '')).join(' ')}</span>` : ''}</div>`;
}
async function sgDrawThread(first) {
const box = sgq('#sgm'); if (!box) return;
const id = sg.cur; let r;
try { r = await sgApi('/conv/' + encodeURIComponent(id)); } catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
if (sg.cur !== id || !sgq('#sgm')) return; sg.names = r.names || {};
const c = r.conv, old = box.querySelector('.sgmsgs'), stick = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 80;
if (!box.querySelector('.sgbar')) {
box.innerHTML = `<div class="sgbar">${sg.dock ? `<button type="button" class="ibtn sgback" id="sgbk" aria-label="Back to chats">${icon('back')}</button>` : `<a class="ibtn sgback" href="#/signal" aria-label="Back to chats">${icon('back')}</a>`}<span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))}</span><span class="grow"><b id="sgtitle"></b><small id="sgsub"></small></span><button type="button" class="ibtn" id="sgmore" aria-label="Chat options" title="Chat options">${icon('more')}</button></div>
<div class="sgmsgs" id="sgmsgs"></div><div id="sgrep" class="sgrep" hidden></div><div id="sgtray" class="sgtray" hidden></div>
<form class="sgc" id="sgform"><button type="button" class="ibtn" id="sgat" aria-label="Attach a file">${icon('clip')}</button><input type="file" id="sgfile" multiple hidden>
<textarea id="sgtext" rows="1" placeholder="Message" enterkeyhint="send"></textarea><button class="ibtn sgsend" id="sgsend" aria-label="Send">${icon('send')}</button></form>`;
sgWireComposer(); const bk = sgq('#sgbk'); if (bk) bk.onclick = () => sgOpen(null); sgq('#sgmore').onclick = () => sgChatMenu(sg.cur);
}
sgq('#sgtitle').textContent = c.name || c.id; sgq('#sgsub').textContent = c.type === 'group' ? 'Group' : (c.name && c.name !== c.id ? c.id : '');
const list = r.msgs, el = sgq('#sgmsgs');
el.innerHTML = (r.more ? '<p class="sub" style="text-align:center">Older messages are not shown here</p>' : '') + list.map((m, i) => sgBubble(m, c, list[i - 1])).join('') || '<p class="empty">No messages yet. Say hello.</p>';
if (first || stick) el.scrollTop = el.scrollHeight;
el.onclick = e => { const b = e.target.closest('.sgb'); if (!b || e.target.closest('a, audio, video')) return; sgMessageMenu(list.find(m => String(m.ts) === b.dataset.ts), c); };
if (c.unread || (sg.convs.find(x => x.id === id) || {}).unread) sgPost('/read', { id }).then(() => { const x = sg.convs.find(q => q.id === id); if (x) x.unread = 0; sgDrawList(); }).catch(() => {});
}
function sgMessageMenu(m, c) {
if (!m || m.deleted) return;
const el = openSheet('', '', `<div class="sgemo">${['👍', '❤️', '😂', '😮', '😢', '🙏'].map(e => `<button class="ibtn" data-e="${e}" aria-label="React ${e}">${e}</button>`).join('')}</div>
<div class="list" style="background:var(--panel2);border:0;margin-top:8px"><button class="row" id="sgr">${icon('back')}<span class="grow"><b>Reply</b></span></button>${m.text ? `<button class="row" id="sgcp">${icon('copy')}<span class="grow"><b>Copy text</b></span></button>` : ''}</div>`);
el.querySelectorAll('[data-e]').forEach(b => b.onclick = async () => { closeSheet(); try { await sgPost('/react', { to: c.id, ts: m.ts, emoji: (m.react && m.react[sg.st.number]) === b.dataset.e ? '' : b.dataset.e }); } catch (e) { toast(e.message); } });
el.querySelector('#sgr').onclick = () => { closeSheet(); sg.reply = m; sgDrawReply(); sgq('#sgtext').focus(); };
const cp = el.querySelector('#sgcp'); if (cp) cp.onclick = async () => { closeSheet(); try { await navigator.clipboard.writeText(m.text); toast('Copied'); } catch { toast('Could not copy'); } };
}
function sgDrawReply() {
const b = sgq('#sgrep'); if (!b) return;
if (!sg.reply) { b.hidden = true; return; }
b.hidden = false; b.innerHTML = `<span class="grow"><b>${esc(sg.reply.out ? 'You' : (sg.reply.name || sg.reply.from))}</b><small>${esc(sg.reply.text || 'Attachment')}</small></span><button class="ibtn" id="sgrx" aria-label="Cancel reply">${icon('x')}</button>`;
sgq('#sgrx').onclick = () => { sg.reply = null; sgDrawReply(); };
}
function sgDrawTray() {
const t = sgq('#sgtray'); if (!t) return; t.hidden = !sg.tray.length;
t.innerHTML = sg.tray.map((f, i) => `<span class="sgchip">${/^image\//.test(f.type) ? `<img alt="" src="${URL.createObjectURL(f)}">` : icon('clip')}<small>${esc(f.name)}</small><button type="button" class="ibtn" data-x="${i}" aria-label="Remove">${icon('x')}</button></span>`).join('');
t.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { sg.tray.splice(+b.dataset.x, 1); sgDrawTray(); });
}
const sgB64 = f => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(String(r.result).split(',')[1] || ''); r.onerror = () => no(new Error('Could not read ' + f.name)); r.readAsDataURL(f); });
function sgWireComposer() {
const ta = sgq('#sgtext'), grow = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px'; };
ta.oninput = grow;
ta.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey && matchMedia('(pointer: fine)').matches) { e.preventDefault(); sgq('#sgform').requestSubmit(); } };
sgq('#sgat').onclick = () => sgq('#sgfile').click();
sgq('#sgfile').onchange = e => { sg.tray.push(...e.target.files); e.target.value = ''; sgDrawTray(); };
ta.addEventListener('paste', e => { const fs = [...(e.clipboardData && e.clipboardData.files || [])]; if (fs.length) { sg.tray.push(...fs); sgDrawTray(); } });
// The message shows up in the chat at once, dimmed as "Sending…"; Signal takes a few seconds to take it, and that happens in the background.
// If it fails, the text (and files) go back into the box so nothing is lost.
sgq('#sgform').onsubmit = async e => {
e.preventDefault(); const text = ta.value, files = [...sg.tray], reply = sg.reply, to = sg.cur; if (!text.trim() && !files.length) return;
ta.value = ''; grow(); sg.tray = []; sg.reply = null; sgDrawTray(); sgDrawReply(); ta.focus();
const list = sgq('#sgmsgs'), tmp = document.createElement('div');
tmp.className = 'sgb out sgpending'; tmp.style.opacity = '.65';
tmp.innerHTML = `${esc(text) || 'Attachment'}<div class="sgmeta"><small>Sending…</small></div>`;
if (list) { list.appendChild(tmp); list.scrollTop = list.scrollHeight; }
sg.sendQ = (sg.sendQ || Promise.resolve()).then(async () => { // one at a time, so quick messages arrive in the order you wrote them
try {
const attachments = []; for (const f of files) attachments.push({ name: f.name, type: f.type || 'application/octet-stream', data: await sgB64(f) });
await sgPost('/send', { to, text, attachments, quote: reply ? reply.ts : undefined });
await sgRefresh(); tmp.remove(); const el = sgq('#sgmsgs'); if (el) el.scrollTop = el.scrollHeight;
} catch (err) {
tmp.remove(); toast(err.message || 'Could not send');
if (sg.cur === to && !ta.value) { ta.value = text; grow(); sg.tray = files; sg.reply = reply; sgDrawTray(); sgDrawReply(); }
}
});
};
}
// ---- editing contacts and removing chats
function sgChatMenu(id) {
const c = sg.convs.find(x => x.id === id); if (!c) return;
const acts = [];
if (c.type === 'direct') acts.push({ label: 'Edit contact', icon: 'edit', run: () => sgEditContact(c) });
acts.push({ label: 'Clear messages', icon: 'trash', run: () => confirmSheet('Clear all messages?', (c.name || c.id) + ': removed from this app only. Your phone keeps them.', 'Clear', async () => { try { await sgPost('/conv/clear', { id }); await sgRefresh(); } catch (e) { toast(e.message); } }) });
acts.push({ label: 'Remove chat', icon: 'trash', danger: true, run: () => confirmSheet('Remove this chat?', 'It is removed from this app only. Your phone keeps it, and a new message from them starts it again here.', 'Remove', async () => { try { await sgPost('/conv/delete', { id }); sgGone(id); } catch (e) { toast(e.message); } }) });
actionSheet(c.name || c.id, c.type === 'group' ? 'Group' : c.id, acts);
}
function sgGone(id, fromServer) {
sg.convs = sg.convs.filter(x => x.id !== id);
if (sg.cur === id) { sg.cur = null; sgSaveDock(); if (sg.dock && sgMounted()) sgOpen(null); else if (sgOnView()) location.hash = '#/signal'; }
if (!fromServer || sgMounted()) sgRefresh();
}
function sgEditContact(c, after) {
const el = openSheet('Edit contact', c.id || c.number, `<form id="sgcf"><label for="sgcn">Name</label><input type="text" id="sgcn" value="${esc(!c.name || c.name === (c.id || c.number) ? '' : c.name)}" placeholder="Name" autocomplete="off" maxlength="80">
<p class="sub" style="margin:8px 0 0">Shown in this app, and sent to Signal as the contact's name. Leave it empty to use the name Signal has.</p><div class="err" id="sgce"></div>
<div class="bar"><button class="btn ghost" type="button" id="sgcx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
el.querySelector('#sgcx').onclick = closeSheet; setTimeout(() => el.querySelector('#sgcn').focus(), 60);
el.querySelector('#sgcf').onsubmit = async e => { e.preventDefault();
try { await sgPost('/contact', { number: c.id || c.number, name: el.querySelector('#sgcn').value }); closeSheet(); toast('Saved'); if (after) after(); await sgRefresh(); }
catch (err) { el.querySelector('#sgce').textContent = err.message; } };
}
async function sgNewChat() {
let contacts = []; try { contacts = (await sgApi('/contacts')).contacts; } catch {}
const el = openSheet('New chat', '', `<form id="sgnf"><label for="sgnn">Phone number (with country code) or a contact</label><input type="text" id="sgnn" inputmode="tel" autocomplete="off" placeholder="+1 555 123 4567 or a name">
<input type="text" id="sgnm" autocomplete="off" placeholder="Name for a new number (optional)" style="margin-top:8px" maxlength="80"><div class="err" id="sgne"></div><div class="bar"><button class="btn" style="width:100%;justify-content:center">Start chat</button></div></form><div class="list" id="sgcl" style="background:var(--panel2);border:0;margin-top:10px;max-height:40dvh;overflow:auto"></div>`);
const input = el.querySelector('#sgnn'), draw = () => { const q = input.value.toLowerCase().trim(); el.querySelector('#sgcl').innerHTML = contacts.filter(c => !q || (c.name || '').toLowerCase().includes(q) || c.number.includes(q)).slice(0, 60).map(c => `<div class="row it"><button type="button" class="rmain" data-n="${esc(c.number)}"><span class="sgav" style="background:${sgColor(c.number)}">${esc(sgInitial(c.name || c.number))}</span><span class="grow"><b>${esc(c.name || c.number)}</b><small>${esc(c.number)}</small></span></button><button type="button" class="ibtn" data-e="${esc(c.number)}" aria-label="Edit ${esc(c.name || c.number)}" title="Edit contact">${icon('edit')}</button></div>`).join('');
el.querySelectorAll('[data-n]').forEach(b => b.onclick = () => go(b.dataset.n));
el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => { const k = contacts.find(x => x.number === b.dataset.e); sgEditContact(k, async () => { try { contacts = (await sgApi('/contacts')).contacts; } catch {} }); }); };
const go = async n => { try { const r = await sgPost('/new', { number: n, name: el.querySelector('#sgnm').value }); closeSheet(); if (sg.dock) { await sgRefresh(); sgOpen(r.conv.id); } else location.hash = '#/signal/' + encodeURIComponent(r.conv.id); } catch (e) { el.querySelector('#sgne').textContent = e.message; } };
input.oninput = draw; draw(); el.querySelector('#sgnf').onsubmit = e => { e.preventDefault(); go(input.value); }; setTimeout(() => input.focus(), 60);
}
function sgLinkScreen(root, dock) {
const v = root, cls = dock ? 'sgdockin' : 'wrap';
const draw = () => { const img = sgq('#sgqr'); if (img) img.src = '/api/signal/qr?t=' + Date.now(); };
v.innerHTML = `<div class="${cls}">${dock ? '' : '<h1>Signal</h1>'}<p class="sub">Link this app to your Signal account. It becomes one more linked device, like Signal Desktop.</p>
<div class="card" style="max-width:none"><ol class="sgsteps"><li>On your phone open <b>Signal</b> → <b>Settings</b> → <b>Linked devices</b> → <b>Link new device</b>.</li><li>Scan this code.</li></ol>
<div style="text-align:center;margin:14px 0"><img id="sgqr" alt="Signal link code" width="256" height="256" style="background:#fff;border-radius:12px;padding:8px;max-width:100%"></div>
<div class="bar" style="justify-content:center"><button class="btn ghost" id="sgrq">${icon('refresh')}New code</button></div><p class="sub" id="sgls" style="text-align:center">Waiting for you to scan…</p></div>
<p class="sub" style="margin-top:12px">Messages from before you link are not copied over, only new ones. Conversations are kept on your server.</p></div>`;
draw(); sgq('#sgrq').onclick = draw;
sg.timers.push(setInterval(draw, 55000));
sg.timers.push(setInterval(async () => { try { const st = await sgApi('/status'); if (st.linked && sgMounted()) { toast('Signal linked'); sgMount(sg.root, sg.dock); } } catch {} }, 3000));
}
// ---- The floating chat: a button on every screen (desktop) that opens Signal over whatever you are doing.
const sgDockState = () => { try { return JSON.parse(localStorage.getItem('sg_dock')) || {}; } catch { return {}; } };
function sgSaveDock() { try { localStorage.setItem('sg_dock', JSON.stringify({ open: !!sg.dockOpen, cur: sg.cur })); } catch {} }
function sgDockInit() {
if ($('#sgfab') || !allowed('signal')) return;
const st = sgDockState(); sg.dockOpen = !!st.open; if (st.cur) sg.cur = st.cur;
const fab = document.createElement('button'); fab.id = 'sgfab'; fab.setAttribute('aria-label', 'Signal chat'); fab.title = 'Signal chat'; fab.hidden = true;
const dock = document.createElement('div'); dock.id = 'sgdock'; dock.hidden = true; dock.setAttribute('role', 'dialog'); dock.setAttribute('aria-label', 'Signal chat');
dock.innerHTML = `<div class="sgdtop"><b>Signal</b><span class="grow"></span><button class="ibtn" id="sgdfull" aria-label="Open the full Signal page" title="Open the full page">${icon('full')}</button><button class="ibtn" id="sgdx" aria-label="Close chat" title="Close">${icon('x')}</button></div><div class="sgdbody" id="sgdbody"></div>`;
document.body.append(dock, fab);
fab.onclick = () => (sg.dockOpen ? sgDockClose() : sgDockOpen());
dock.querySelector('#sgdx').onclick = sgDockClose;
dock.querySelector('#sgdfull').onclick = () => { const id = sg.cur; sgDockClose(); location.hash = '#/signal' + (id ? '/' + encodeURIComponent(id) : ''); };
const ro = new ResizeObserver(() => document.documentElement.style.setProperty('--ph', ($('#player') && !$('#player').hidden ? $('#player').offsetHeight : 0) + 'px')); ro.observe($('#player'));
}
function sgDockBadge() { const f = $('#sgfab'); if (f) f.innerHTML = icon('chat') + (sg.unread ? `<i class="nbadge">${sg.unread > 99 ? '99+' : sg.unread}</i>` : ''); }
function sgDockOpen() { const d = $('#sgdock'); if (!d) return; sg.dockOpen = true; d.hidden = false; sgSaveDock(); const body = $('#sgdbody'); if (sg.root !== body) sgMount(body, true); }
function sgDockClose() { const d = $('#sgdock'); if (d) d.hidden = true; sg.dockOpen = false; if (sg.dock) sg.root = null; const b = $('#sgdbody'); if (b) b.innerHTML = ''; sgSaveDock(); }
function sgDockHide() { const d = $('#sgdock'), f = $('#sgfab'); if (d) d.hidden = true; if (f) f.hidden = true; if (sg.dock) sg.root = null; const b = $('#sgdbody'); if (b) b.innerHTML = ''; }
function sgDockSync(name) { // after every screen change
if (!allowed('signal')) { sgDockHide(); return; }
sgDockInit(); const f = $('#sgfab'), d = $('#sgdock'); if (!f) return;
if (name === 'signal' || name === 'edit') { sgDockHide(); return; }
f.hidden = false; sgDockBadge();
if (sg.dockOpen) { d.hidden = false; if (sg.root !== $('#sgdbody')) sgMount($('#sgdbody'), true); } else d.hidden = true;
}
sgMQ.addEventListener('change', () => { // a window made narrower than a desktop (or back)
renderNav(activeTab);
if (!sgMQ.matches) { sgStop(); sgDockHide(); if (sgOnView()) location.hash = '#/home'; } else { sgStart(); sgDockSync(activeTab); }
});