'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 = `
${title}

Loading…

`; 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

'}

${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 || '') ? `${esc(a.name || 'photo')}` : /^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')}`}${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))}
`; 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 => ``).join('')}
${m.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')}`; 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)}`).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'}
Sending…
`; 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, `

Shown in this app, and sent to Signal as the contact's name. Leave it empty to use the name Signal has.

`); 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', '', `
`); 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 => `
`).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.

  1. On your phone open Signal → Settings → Linked devices → Link new device.
  2. Scan this code.
Signal link 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
`; 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); } });