diff --git a/public/chat-extras.js b/public/chat-extras.js index 3ed77bc..31b3dc6 100644 --- a/public/chat-extras.js +++ b/public/chat-extras.js @@ -38,7 +38,15 @@ async function chLoadSignal() { ch.sig = []; if (typeof allowed !== 'function' || !allowed('signal') || typeof sgApi !== 'function') return; try { const r = await sgApi('/convs'); ch.sig = (r.convs || []).map(c => ({ ...c, source: 'signal', name: c.name || c.id })); } catch { ch.sig = []; } } -const chSigRow = c => `${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))} +const chSigRow = c => `${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))} ${esc(c.name)} Signal${esc(c.preview || '')}${c.ts ? sgTime(c.ts) : ''}${c.unread ? `${c.unread}` : ''}`; /** Signal changed (a message came in): redraw the Chat list if it is showing. */ function chSigChanged() { if (typeof ch !== 'undefined' && ch.root && document.contains(ch.root) && location.hash.startsWith('#/chat')) chLoadSignal().then(() => { if (chq('#chlist')) chDrawList(); }); } + +/** A Signal conversation shown in Chat's own conversation pane (so choosing one in the combined list never leaves Chat). */ +async function chShowSignal() { + const box = chq('#chm'); if (!box) return; + if (!box.querySelector('#sgm')) { box.innerHTML = '
'; sg.reply = null; sg.tray = []; } + sg.root = ch.root; sg.dock = false; sg.inChat = true; sg.cur = ch.curSig; + await sgDrawThread(true); +} diff --git a/public/chat-view.js b/public/chat-view.js index 1b8fa35..9f4d3ed 100644 --- a/public/chat-view.js +++ b/public/chat-view.js @@ -34,10 +34,10 @@ function chEvent(ev) { clearTimeout(chEvT); chEvT = setTimeout(chRefresh, 150); } -function viewChat(args = []) { ch.cur = args[0] ? decodeURIComponent(args[0]) : null; return chMount($('#view')); } +function viewChat(args = []) { const id = args[0] ? decodeURIComponent(args[0]) : null; ch.curSig = id && id.startsWith('sg:') ? id.slice(3) : null; ch.cur = ch.curSig ? null : id; if (!ch.curSig && typeof sg === 'object') sg.inChat = false; return chMount($('#view')); } async function chMount(root) { ch.root = root; root.innerHTML = '

Chat

Loading…

'; - root.innerHTML = `

Chat

+ root.innerHTML = `

Chat

`; chq('#chnew').onclick = chNewChat; chq('#chset').onclick = chSettings; let t; chq('#chq').oninput = () => { chDrawList(); clearTimeout(t); t = setTimeout(chSearchMessages, 350); }; await chRefresh(true); @@ -46,7 +46,7 @@ async function chRefresh(first) { try { const [r] = await Promise.all([chApi('/convs'), typeof chLoadSignal === 'function' ? chLoadSignal() : null]); ch.convs = r.convs; ch.unread = r.unread; renderNav(activeTab); if (!chq('#chlist')) return; chDrawList(); - if (ch.cur) await chDrawThread(first); else chq('#chm').innerHTML = '

Choose a chat, or start a new one.

'; + if (ch.curSig && typeof chShowSignal === 'function') await chShowSignal(); else if (ch.cur) await chDrawThread(first); else chq('#chm').innerHTML = '

Choose a chat, or start a new one.

'; } catch (e) { const l = chq('#chlist'); if (l) l.innerHTML = `

${esc(e.message)}

`; } } async function chSearchMessages() { @@ -189,9 +189,10 @@ async function chPeople() { if (!ch.people) ch.people = await chApi('/people'); async function chNewChat() { let people = []; try { people = await chPeople(); } catch (e) { return toast(e.message); } const el = openSheet('New chat', 'Pick someone in the family', `
${people.map(p => ``).join('') || '

There is nobody else with Chat yet. An admin can turn it on for people in Settings.

'}
- ${people.length > 1 ? '
' : ''}`); +
${people.length > 1 ? '' : ''}${typeof sgNewChat === 'function' && typeof sgInChat === 'function' && sgInChat() ? '' : ''}
`); el.querySelectorAll('[data-p]').forEach(b => b.onclick = async () => { try { const r = await chPost('/new', { type: 'direct', with: b.dataset.p }); closeSheet(); location.hash = '#/chat/' + encodeURIComponent(r.conv.id); } catch (e) { toast(e.message); } }); const g = el.querySelector('#chg'); if (g) g.onclick = () => { closeSheet(); chNewGroup(people); }; + const sn = el.querySelector('#chsgn'); if (sn) sn.onclick = () => { closeSheet(); sg.inChat = true; sg.root = ch.root; sgNewChat(); }; } function chNewGroup(people) { const el = openSheet('New group', '', `
diff --git a/public/signal-view.js b/public/signal-view.js index d84bab7..4f7b3ff 100644 --- a/public/signal-view.js +++ b/public/signal-view.js @@ -7,6 +7,8 @@ const sgTime = ts => { const d = new Date(ts), n = new Date(); return d.toDateSt 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'); +/** Where a Signal conversation opens: inside Chat when the person has both (the Chat list shows Signal chats too), otherwise the Signal screen. */ +const sgHref = id => typeof sgInChat === 'function' && sgInChat() ? '#/chat/' + encodeURIComponent('sg:' + id) : '#/signal/' + encodeURIComponent(id); 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; @@ -38,7 +40,7 @@ function sgAlert(ev) { // a message from someone else arrived and you are not lo 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 */ } + n.onclick = () => { window.focus(); n.close(); sg.cur = ev.conv; sgSaveDock(); if (sgOnView() || sg.inChat) location.hash = sgHref(ev.conv); else { sg.dockOpen = true; sgSaveDock(); sgDockSync(activeTab); } }; } catch { /* the browser refused */ } } } let sgEvT = null; @@ -49,6 +51,9 @@ function sgEvent(ev) { if (!looking) { toast('Signal · ' + (ev.name || '') + ': ' + (ev.preview || '')); sgAlert(ev); } } if (ev.t === 'gone') sgGone(ev.conv, true); + if (sg.inChat) { // a Signal conversation is open inside Chat: refresh it and the combined list, never turn the Chat screen into the Signal screen + if (!sgMounted()) return; clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (sg.cur && ev.t !== 'status') sgDrawThread(false); if (typeof chSigChanged === 'function') chSigChanged(); }, 150); return; + } if (!sgMounted()) return; clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (ev.t === 'status') return sgMount(sg.root, sg.dock); sgRefresh(); }, 150); } @@ -57,6 +62,7 @@ function sgEvent(ev) { 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.inChat = false; // (a full Signal screen or the floating panel; Chat sets this itself when it shows a Signal conversation) sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : `

Signal

${typeof sgInChat === 'function' && sgInChat() ? `

‹ Back to Chat · your Signal chats are also listed there

` : ''}`; root.innerHTML = `
${title}

Loading…

`; let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `
${title}

${esc(e.message)}

`; return; } @@ -114,7 +120,7 @@ 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 = `
${sg.dock ? `` : `${icon('back')}`}${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))}
+ box.innerHTML = `
${sg.dock ? `` : `${icon('back')}`}${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))}
`; @@ -186,7 +192,7 @@ function sgChatMenu(id) { } 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 (sg.cur === id) { sg.cur = null; sgSaveDock(); if (sg.dock && sgMounted()) sgOpen(null); else if (sg.inChat) { location.hash = '#/chat'; } else if (sgOnView()) location.hash = '#/signal'; } if (!fromServer || sgMounted()) sgRefresh(); } function sgEditContact(c, after) { @@ -205,7 +211,7 @@ async function sgNewChat() { 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; } }; + 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 = sgHref(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) { diff --git a/public/style.css b/public/style.css index e039e88..53a8c0e 100644 --- a/public/style.css +++ b/public/style.css @@ -1111,3 +1111,4 @@ html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text- .srcbadge { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 999px; background: var(--panel2); color: var(--muted); font-size: 11px; vertical-align: middle; } .otplbox { margin: 12px 0 0; } .otplbox summary { cursor: pointer; padding: 10px 2px; list-style: none; } .otplbox summary::-webkit-details-marker { display: none; } .otplbox summary::before { content: '▸ '; color: var(--accent); } .otplbox[open] summary::before { content: '▾ '; } .otplbox summary small { color: var(--muted); margin-left: 6px; } .otpl { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; } .octile.otc small { white-space: normal; } +.sgin { display: flex; flex-direction: column; flex: 1; min-height: 0; height: 100%; } diff --git a/public/sw.js b/public/sw.js index c4e8a51..38d05e2 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v133'; +const CACHE = 'hub-v134'; const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];