Signal: keep messages that arrive glued together, one chat per person, receipts by time, faster reconnect; edit contacts, clear and remove chats; debug list
build-container / build (push) Successful in 7m31s
build-container / build (push) Successful in 7m31s
- read every JSON value in a frame (lines, arrays), binary frames too - a person seen by number and by private id is one conversation (merged) - delivery/read receipts match on message time, not on who sent them - reconnect every 1-5 s; the chat list says when the link was down - /api/signal/debug: kinds of messages seen lately (no text) - edit a contact (kept in the app and sent to Signal), clear messages, remove a chat; right-click menu in the list; name for a new number - tests: 54 checks Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
522fdba4cd
commit
bfd37967c3
4 files changed
+188
-27
No files matched your search
+33
-7
@@ -2136,6 +2136,7 @@ function sgEvent(ev) {
|
||||
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);
|
||||
}
|
||||
@@ -2151,7 +2152,7 @@ async function sgMount(root, dock) {
|
||||
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>
|
||||
<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);
|
||||
@@ -2173,6 +2174,7 @@ function sgDrawList() {
|
||||
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>`);
|
||||
@@ -2200,11 +2202,11 @@ async function sgDrawThread(first) {
|
||||
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>
|
||||
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);
|
||||
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');
|
||||
@@ -2251,13 +2253,37 @@ function sgWireComposer() {
|
||||
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, `<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">
|
||||
<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 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) {
|
||||
|
||||
Reference in new issue
Block a user