'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 ? '' : '
Signal ';
root.innerHTML = ``;
let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `${title}
${esc(e.message)}
`; return; }
if (sg.root !== root) return; sg.st = st;
if (!st.reachable) { root.innerHTML = `${title}
The Signal service is not answering. Check that its container (signal ) is running, then reload.
`; return; }
if (!st.linked) return sgLinkScreen(root, dock);
root.innerHTML = `${dock ? 'Chats ' : '
Signal '}${icon('edit')}New
${esc(st.number)}${st.connected ? '' : ' · reconnecting…'}
${st.gap ? `
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.
` : ''}
`;
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 = 'Choose a conversation, or start a new one.
';
} catch (e) { const l = sgq('#sglist'); if (l) l.innerHTML = `${esc(e.message)}
`; }
}
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 => `${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))}
${esc(c.name || c.id)} ${esc(c.preview || '')} ${c.ts ? sgTime(c.ts) : ''} ${c.unread ? `${c.unread} ` : ''} `).join('') || `${q ? 'Nothing matches' : 'No conversations yet. Tap New, or wait for a message.'}
`;
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 => `${u} `);
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 || '') ? ` `
: /^audio\//.test(a.type || '') ? ` `
: /^video\//.test(a.type || '') ? ` `
: `${icon('clip')}${esc(a.name || 'file')}${a.size ? ' · ' + sgSize(a.size) : ''} `; }).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() ? `${new Date(m.ts).toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })}
` : '';
const tick = m.out ? (m.status === 'read' ? '✓✓' : m.status === 'delivered' ? '✓✓' : '✓') : '';
return `${day}${c.type === 'group' && !m.out ? `
${esc(m.name || nm(m.from))} ` : ''}
${m.quote ? `
${esc(nm(m.quote.author))} ${esc(m.quote.text || '')}
` : ''}${att}
${m.deleted ? '
This message was deleted ' : m.text ? `
${sgLink(esc(m.text)).replace(/\n/g, ' ')}
` : ''}
${m.edited ? 'edited · ' : ''}${new Date(m.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}${tick ? ` ${tick} ` : ''}
${Object.keys(reacts).length ? `
${Object.entries(reacts).map(([e, n]) => e + (n > 1 ? ' ' + n : '')).join(' ')} ` : ''}
`;
}
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 = `${esc(e.message)}
`; 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 = `${sg.dock ? `
${icon('back')} ` : `
${icon('back')} `}
${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))} ${icon('more')}
`;
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 ? 'Older messages are not shown here
' : '') + list.map((m, i) => sgBubble(m, c, list[i - 1])).join('') || 'No messages yet. Say hello.
';
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('', '', `${['👍', '❤️', '😂', '😮', '😢', '🙏'].map(e => `${e} `).join('')}
${icon('back')}Reply ${m.text ? `${icon('copy')}Copy text ` : ''}
`);
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 = `${esc(sg.reply.out ? 'You' : (sg.reply.name || sg.reply.from))} ${esc(sg.reply.text || 'Attachment')} ${icon('x')} `;
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) => `${/^image\//.test(f.type) ? ` ` : icon('clip')}${esc(f.name)} ${icon('x')} `).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(); } });
sgq('#sgform').onsubmit = async e => {
e.preventDefault(); const text = ta.value, files = [...sg.tray]; if (!text.trim() && !files.length) return;
const btn = sgq('#sgsend'); btn.disabled = true;
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: sg.cur, text, attachments, quote: sg.reply ? sg.reply.ts : undefined });
ta.value = ''; grow(); sg.tray = []; sg.reply = null; sgDrawTray(); sgDrawReply(); await sgRefresh(); const el = sgq('#sgmsgs'); if (el) el.scrollTop = el.scrollHeight;
} catch (err) { toast(err.message); }
btn.disabled = false; ta.focus();
};
}
// ---- 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, `Name
Shown in this app, and sent to Signal as the contact's name. Leave it empty to use the name Signal has.
Cancel Save
`);
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', '', `Phone number (with country code) or a contact
Start chat
`);
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 => `${esc(sgInitial(c.name || c.number))} ${esc(c.name || c.number)} ${esc(c.number)} ${icon('edit')}
`).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 = `${dock ? '' : '
Signal '}
Link this app to your Signal account. It becomes one more linked device, like Signal Desktop.
On your phone open Signal → Settings → Linked devices → Link new device . Scan this code.
${icon('refresh')}New code
Waiting for you to scan…
Messages from before you link are not copied over, only new ones. Conversations are kept on your server.
`;
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 = `Signal ${icon('full')} ${icon('x')}
`;
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 ? `${sg.unread > 99 ? '99+' : sg.unread} ` : ''); }
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); }
});