Signal chats in the app (desktop only) with a chat button on every screen
build-container / build (push) Successful in 6m44s
build-container / build (push) Successful in 6m44s
- signal.js: talks to signal-cli-rest-api (a linked device of your Signal account); keeps conversations in /data/signal; live updates; send/receive text, files, replies, reactions, groups, receipts - Signal tab and a floating chat panel on every screen, shown only on desktop-class windows; new Signal permission per person - compose: signal bridge container sharing the hub's network - tools/signal-test.js (fake bridge), README Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5157e42bc4
commit
d0070aa0bb
10 files changed
+746
-11
No files matched your search
+207
-7
@@ -35,7 +35,9 @@ async function loadMe() {
|
||||
try { const r = await fetch('/api/me'); if (r.ok) { ME = await r.json(); try { localStorage.setItem('me_cache', JSON.stringify(ME)); } catch {} } }
|
||||
catch { try { ME = JSON.parse(localStorage.getItem('me_cache')); } catch {} } // offline: the last known access (the server still decides)
|
||||
}
|
||||
const allowed = id => !ME || ME.tabs.includes(id);
|
||||
// Signal is for desktop-class screens only (a window at least 900 px wide with a mouse or trackpad), and needs the server to have it set up.
|
||||
const sgMQ = matchMedia('(min-width: 900px) and (pointer: fine)');
|
||||
const allowed = id => (!ME || ME.tabs.includes(id)) && (id !== 'signal' || (!!cfg.signal && sgMQ.matches)); // (Signal also needs the server to have it set up)
|
||||
/** Pull saved settings from the server into this browser; push up anything only this browser has (first run / migration). */
|
||||
async function loadSettings() {
|
||||
try {
|
||||
@@ -49,6 +51,9 @@ async function loadSettings() {
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
chat: '<path d="M4 5h16a1 1 0 011 1v10a1 1 0 01-1 1H10l-5 4v-4H4a1 1 0 01-1-1V6a1 1 0 011-1z"/>',
|
||||
clip: '<path d="M20 11l-8 8a5 5 0 01-7-7l8-8a3.5 3.5 0 015 5l-8 8a2 2 0 01-3-3l7-7"/>',
|
||||
send: '<path d="M4 12l16-8-6 16-3-7z"/>',
|
||||
menu: '<path d="M4 7h16M4 12h16M4 17h16"/>',
|
||||
home: '<path d="M3 11l9-8 9 8M5 10v10h14V10"/>',
|
||||
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/>',
|
||||
@@ -142,21 +147,24 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
|
||||
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
|
||||
// ---- Navigation
|
||||
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||
const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check' };
|
||||
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check' };
|
||||
const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state
|
||||
const PRIMARY = ['home', 'files', 'tasks', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
|
||||
const phone = matchMedia('(max-width: 767px)');
|
||||
let activeTab = 'home';
|
||||
|
||||
function renderNav(active = activeTab) {
|
||||
activeTab = active;
|
||||
const tab = ([id, label]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span></a>`;
|
||||
if (typeof sgDockBadge === 'function') sgDockBadge();
|
||||
const badge = n => n ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : '';
|
||||
const tab = ([id, label]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : ''}</a>`;
|
||||
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
|
||||
const mine = TABS.filter(t => allowed(t[0]));
|
||||
if (phone.matches) {
|
||||
const rest = mine.filter(t => !PRIMARY.includes(t[0])), more = !PRIMARY.includes(active);
|
||||
$('#nav').innerHTML = mine.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
|
||||
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span></a>` : '');
|
||||
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : ''}</a>` : '');
|
||||
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
|
||||
} else {
|
||||
$('#nav').innerHTML = brand + mine.map(tab).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
|
||||
@@ -167,11 +175,11 @@ phone.addEventListener('change', () => renderNav());
|
||||
|
||||
function openMore() {
|
||||
const el = openSheet('More', '', `<div class="list">${TABS.filter(t => !PRIMARY.includes(t[0]) && allowed(t[0])).map(([id, label]) => `
|
||||
<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${icon('chev', 'chev')}</a>`).join('')}</div>`);
|
||||
<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${id === 'signal' && sg.unread ? `<i class="nbadge" style="position:static">${sg.unread}</i>` : ''}${icon('chev', 'chev')}</a>`).join('')}</div>`);
|
||||
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
|
||||
}
|
||||
|
||||
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit };
|
||||
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, settings: viewSettings, edit: viewEdit };
|
||||
let editor;
|
||||
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
|
||||
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
|
||||
@@ -212,10 +220,12 @@ function route() {
|
||||
editor = null;
|
||||
document.body.classList.toggle('editing', name === 'edit');
|
||||
document.body.classList.remove('inedit', 'onaudio');
|
||||
sg.timers.forEach(clearInterval); sg.timers = []; // (Signal screens start their own)
|
||||
const aw = $('#audiowrap'); if (aw) aw.className = 'off'; // hide (not destroy) the audiobooks frame
|
||||
renderNav(name === 'edit' ? 'files' : name);
|
||||
drawNav();
|
||||
routes[name](rest.map(decodeURIComponent));
|
||||
sgDockSync(name);
|
||||
$('#view').scrollTop = 0;
|
||||
}
|
||||
|
||||
@@ -245,6 +255,7 @@ function viewHome() {
|
||||
${allowed('audio') ? tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen') : ''}
|
||||
${allowed('podcast') ? tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts') : ''}
|
||||
${allowed('verse') && cfg.verse ? tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true) : ''}
|
||||
${allowed('signal') ? tile('#/signal', '#3a76f0', 'chat', 'Signal', sg.unread ? sg.unread + ' unread' : 'Messages') : ''}
|
||||
${allowed('mail') ? tile('#/mail', '#4f7cff', 'mail', 'Mail', mailOn().length + (mailOn().length === 1 ? ' app' : ' apps')) : ''}
|
||||
</div>
|
||||
${allowed('mail') ? `<h2>Mail</h2><div class="grid">
|
||||
@@ -2032,6 +2043,194 @@ function dirSheet(repo, path, name, dir) {
|
||||
]);
|
||||
}
|
||||
|
||||
// ---- 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; } }
|
||||
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 && !(sgMounted() && sg.cur === ev.conv && document.visibilityState === 'visible')) toast('Signal · ' + (ev.name || '') + ': ' + (ev.preview || ''));
|
||||
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></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); } };
|
||||
}
|
||||
|
||||
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></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('#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(); } });
|
||||
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();
|
||||
};
|
||||
}
|
||||
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">
|
||||
<div class="err" id="sgne"></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 => `<button class="row" 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>`).join('');
|
||||
el.querySelectorAll('[data-n]').forEach(b => b.onclick = () => go(b.dataset.n)); };
|
||||
const go = async n => { try { const r = await sgPost('/new', { number: n }); 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); }
|
||||
});
|
||||
|
||||
// ---- Boot
|
||||
(async () => {
|
||||
try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
|
||||
@@ -2051,6 +2250,7 @@ function dirSheet(repo, path, name, dir) {
|
||||
setInterval(checkForUpdate, 10 * 60e3);
|
||||
await loadMe();
|
||||
await loadSettings();
|
||||
sgStart();
|
||||
addEventListener('hashchange', route);
|
||||
restorePlayer();
|
||||
route();
|
||||
|
||||
@@ -343,3 +343,83 @@ body:not(.editing) #view { padding-top: 8px; }
|
||||
body.nav-mini .navcol svg { transform: rotate(180deg); }
|
||||
body.nav-mini .navcol span { display: none; }
|
||||
}
|
||||
|
||||
/* Unread badge on the menu */
|
||||
#nav a { position: relative; }
|
||||
.nbadge { background: var(--danger); color: #fff; font-size: 11px; font-style: normal; font-weight: 700; line-height: 18px; min-width: 18px; padding: 0 5px; border-radius: 9px; text-align: center; position: absolute; top: 4px; left: calc(50% + 6px); }
|
||||
@media (min-width: 1024px) { body:not(.nav-mini) #nav .nbadge { position: static; margin-left: auto; } }
|
||||
|
||||
/* Signal */
|
||||
.sgwrap { max-width: 1200px; }
|
||||
.sg { display: block; }
|
||||
.sg .sgm { display: none; }
|
||||
.sg.open .sgl { display: none; }
|
||||
.sg.open .sgm { display: flex; }
|
||||
.sghead { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
|
||||
.sghead h1 { margin: 0; }
|
||||
.sglist { margin-top: 10px; }
|
||||
.sgrow { padding: 10px 14px; }
|
||||
.sgrow.on { background: var(--panel2); }
|
||||
.sgav { width: 42px; height: 42px; border-radius: 50%; color: #fff; display: grid; place-items: center; font-weight: 700; flex: none; }
|
||||
.sgav svg { width: 22px; height: 22px; }
|
||||
.sgside { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex: none; }
|
||||
.sgm { flex-direction: column; height: calc(100dvh - 210px); min-height: 360px; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; overflow: hidden; }
|
||||
.sgbar { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--line); }
|
||||
.sgbar .grow { min-width: 0; } .sgbar b, .sgbar small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgbar small { color: var(--muted); }
|
||||
.sgmsgs { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 4px; -webkit-overflow-scrolling: touch; }
|
||||
.sgday { align-self: center; color: var(--muted); font-size: 12px; margin: 10px 0 4px; }
|
||||
.sgb { max-width: min(78%, 520px); padding: 8px 12px 6px; border-radius: 16px; background: var(--panel2); align-self: flex-start; word-break: break-word; position: relative; cursor: pointer; }
|
||||
.sgb.out { align-self: flex-end; background: var(--accent); color: var(--accent-t); border-bottom-right-radius: 4px; }
|
||||
.sgb.in { border-bottom-left-radius: 4px; }
|
||||
.sgb .who { display: block; font-weight: 700; font-size: 12px; margin-bottom: 2px; }
|
||||
.sgt a { color: inherit; text-decoration: underline; }
|
||||
.sgmeta { display: block; text-align: right; font-size: 11px; opacity: .7; margin-top: 2px; }
|
||||
.sgmeta .rd { color: #8be28b; font-weight: 700; }
|
||||
.sgb.out .sgmeta .rd { color: #fff; }
|
||||
.sgimg { display: block; max-width: 100%; max-height: 320px; border-radius: 10px; margin-bottom: 4px; }
|
||||
.sgb audio, .sgb video { max-width: 100%; display: block; margin-bottom: 4px; }
|
||||
.sgfile { display: flex; align-items: center; gap: 8px; text-decoration: underline; margin-bottom: 4px; }
|
||||
.sgfile svg { width: 18px; height: 18px; }
|
||||
.sgq { border-left: 3px solid currentColor; opacity: .85; padding: 2px 8px; margin-bottom: 6px; font-size: 13px; }
|
||||
.sgq b, .sgq span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sgdel { opacity: .7; }
|
||||
.sgre { position: absolute; right: 8px; bottom: -12px; background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 12px; padding: 0 6px; font-size: 13px; }
|
||||
.sgb:has(.sgre) { margin-bottom: 12px; }
|
||||
.sgrep, .sgtray { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-top: 1px solid var(--line); background: var(--panel2); }
|
||||
.sgrep[hidden], .sgtray[hidden] { display: none; }
|
||||
.sgrep .grow { min-width: 0; border-left: 3px solid var(--accent); padding-left: 8px; } .sgrep b, .sgrep small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sgtray { overflow-x: auto; }
|
||||
.sgchip { display: flex; align-items: center; gap: 6px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 2px 4px 2px 8px; flex: none; max-width: 200px; }
|
||||
.sgchip img { width: 32px; height: 32px; object-fit: cover; border-radius: 6px; } .sgchip small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 110px; } .sgchip .ibtn { width: 32px; height: 32px; }
|
||||
.sgc { display: flex; align-items: flex-end; gap: 4px; padding: 8px; border-top: 1px solid var(--line); }
|
||||
.sgc textarea { flex: 1; min-height: 44px; max-height: 140px; resize: none; border-radius: 22px; border: 1px solid var(--line); background: var(--bg); padding: 11px 14px; font-size: 16px; }
|
||||
.sgsend { background: var(--accent); color: var(--accent-t); }
|
||||
.sgsend:disabled { opacity: .5; }
|
||||
.sgemo { display: flex; justify-content: space-around; } .sgemo .ibtn { font-size: 26px; width: 52px; height: 52px; }
|
||||
.sgsteps { margin: 0 0 6px; padding-left: 20px; }
|
||||
@media (min-width: 1024px) {
|
||||
.sgwrap .sg { display: grid; grid-template-columns: 340px 1fr; gap: 20px; align-items: start; }
|
||||
.sgwrap .sg .sgl, .sgwrap .sg.open .sgl, .sgwrap .sg .sgm, .sgwrap .sg.open .sgm { display: flex; }
|
||||
.sgwrap .sg .sgl { flex-direction: column; }
|
||||
.sgwrap .sgback { display: none; }
|
||||
.sgwrap .sgm { height: calc(100dvh - 150px); }
|
||||
}
|
||||
|
||||
/* The floating chat (desktop): a button on every screen that opens Signal over it */
|
||||
#sgfab { position: fixed; z-index: 14; right: 22px; bottom: calc(var(--ph, 0px) + 22px); width: 54px; height: 54px; border-radius: 50%; border: 0; background: var(--accent); color: var(--accent-t); display: grid; place-items: center; box-shadow: 0 6px 20px rgba(0,0,0,.35); cursor: pointer; }
|
||||
#sgfab svg { width: 26px; height: 26px; }
|
||||
#sgfab .nbadge { top: -2px; left: auto; right: -2px; position: absolute; }
|
||||
#sgfab[hidden], #sgdock[hidden], body.editing #sgfab, body.editing #sgdock { display: none; }
|
||||
#sgdock { position: fixed; z-index: 14; right: 22px; bottom: calc(var(--ph, 0px) + 88px); width: 400px; max-width: calc(100vw - 44px); height: min(640px, calc(100dvh - var(--ph, 0px) - 120px)); display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; box-shadow: 0 12px 40px rgba(0,0,0,.4); overflow: hidden; }
|
||||
.sgdtop { display: flex; align-items: center; gap: 2px; padding: 4px 6px 4px 14px; border-bottom: 1px solid var(--line); }
|
||||
.sgdtop .ibtn { width: 38px; height: 38px; } .sgdtop svg { width: 20px; height: 20px; }
|
||||
.sgdbody { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; }
|
||||
.sgdockin { padding: 10px; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.sgdockin .sg { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.sgdockin .sg .sgl { display: block; } .sgdockin .sg.open .sgl { display: none; }
|
||||
.sgdockin .sg .sgm { display: none; } .sgdockin .sg.open .sgm { display: flex; flex: 1; height: auto; min-height: 0; border: 0; border-radius: 0; background: none; }
|
||||
.sgdockin .sgbar { padding-left: 0; }
|
||||
.sgdt { font-size: 15px; }
|
||||
.sgdockin .sgm .sgmsgs { padding: 8px 4px; }
|
||||
.sgdockin .sgb { max-width: 86%; }
|
||||
@media (display-mode: standalone) { #sgfab { bottom: calc(var(--ph, 0px) + 22px + var(--sab, 0px)); } }
|
||||
Reference in new issue
Block a user