Chat: a GIF picker kept family-friendly, sharing a place, and Signal conversations in the Chat list

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 12:13:26 -04:00
1 parent 3ddf9942c2
commit 584a95753d
16 files changed
+200 -13

No files matched your search

+1
View File
@@ -114,6 +114,7 @@ const ICONS = {
heart: '<path d="M12 20s-7-4.4-7-10a4 4 0 017-2.5A4 4 0 0119 10c0 5.6-7 10-7 10z"/>',
bible: '<path d="M5 5a2 2 0 012-2h12v15H7a2 2 0 00-2 2z"/><path d="M5 20a2 2 0 002 2h12v-4M12 7v7M9.5 9.5h5"/>',
list: '<path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/>',
pin: '<path d="M12 21s-7-6.2-7-11a7 7 0 0114 0c0 4.8-7 11-7 11z"/><circle cx="12" cy="10" r="2.5"/>',
font: '<path d="M3 19l5-13 5 13M5 14h6M15 19l3.5-9 3.5 9M16.2 16h4.6"/>',
};
const icon = (n, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24">${ICONS[n]}</svg>`;
+51
View File
@@ -0,0 +1,51 @@
'use strict';
// ---- Family Chat extras: a GIF picker (kept family-friendly by the hub; phones never contact the GIF service) and sharing a place (your location, or a place you search for).
// The Chat list also shows Signal conversations, labelled, when Signal is available on this screen.
async function chGifOn() { if (ch.gifOn === undefined) { try { ch.gifOn = !!(await chApi('/gif/status')).enabled; ch.gifBy = (await chApi('/gif/status')).provider; } catch { ch.gifOn = false; } } return ch.gifOn; }
function chGifPicker() {
const to = ch.cur, by = ch.gifBy === 'giphy' ? 'GIPHY' : 'Tenor';
const el = openSheet('GIF', 'Family-friendly only', `<form id="gpf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="search" id="gpq" placeholder="Search GIFs" autocomplete="off" maxlength="60" style="flex:1;min-width:0"><button class="btn ghost">Search</button></form><div class="gifgrid" id="gpg" role="list"></div><p class="err" id="gpe"></p><div class="bar" style="justify-content:center"><button class="btn ghost" id="gpm" hidden>More</button></div><p class="sub" style="text-align:center;margin:4px 0 0;font-size:12px">Powered by ${by}</p>`);
el.querySelector('.sheet').classList.add('wide'); let next = '', q = '', busy = false;
const load = async more => {
if (busy) return; busy = true; el.querySelector('#gpe').textContent = '';
try { const r = await chApi('/gif?q=' + encodeURIComponent(q) + (more && next ? '&pos=' + encodeURIComponent(next) : '')); next = r.next || ''; const g = el.querySelector('#gpg'); if (!more) g.innerHTML = '';
g.insertAdjacentHTML('beforeend', r.items.map(x => `<button type="button" class="gifc" role="listitem" data-u="${esc(x.url)}" data-t="${esc(x.title)}" aria-label="Send GIF ${esc(x.title || '')}"><img alt="${esc(x.title || 'GIF')}" loading="lazy" ${x.w && x.h ? `width="${x.w}" height="${x.h}"` : ''} src="/api/chat/gifimg?u=${encodeURIComponent(x.preview)}"></button>`).join('')); if (!more && !r.items.length) el.querySelector('#gpe').textContent = 'Nothing found. Try another word.';
el.querySelector('#gpm').hidden = !next; } catch (e) { el.querySelector('#gpe').textContent = e.message; } busy = false;
};
el.querySelector('#gpf').onsubmit = e => { e.preventDefault(); q = el.querySelector('#gpq').value.trim(); next = ''; load(false); };
el.querySelector('#gpm').onclick = () => load(true);
el.querySelector('#gpg').onclick = async e => { const b = e.target.closest('.gifc'); if (!b || b.disabled) return; b.disabled = true; b.style.opacity = '.5';
try { await chPost('/gif/send', { conv: to, url: b.dataset.u, title: b.dataset.t }); closeSheet(); } catch (er) { b.disabled = false; b.style.opacity = ''; el.querySelector('#gpe').textContent = er.message; } };
load(false);
}
function chPlacePicker() {
const to = ch.cur, el = openSheet('Share a place', '', `<div class="bar" style="justify-content:flex-start"><button class="btn" id="plme">📍 Send my location</button></div><p class="sub" id="plmm" style="margin:0 0 8px">Your phone asks first. This sends where you are right now, once; it does not keep following you.</p>
<form id="plf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="search" id="plq" placeholder="Or search for a place" autocomplete="off" maxlength="100" style="flex:1;min-width:0"><button class="btn ghost">Search</button></form><div class="list" id="plr"></div><p class="err" id="ple"></p>`);
const send = async (title, sub, lat, lng) => { try { await chPost('/send', { conv: to, share: { kind: 'place', title, sub, lat, lng } }); closeSheet(); } catch (e) { el.querySelector('#ple').textContent = e.message; } };
el.querySelector('#plme').onclick = () => {
if (!navigator.geolocation) { el.querySelector('#ple').textContent = 'This browser cannot share a location here. Search for the place instead.'; return; }
el.querySelector('#plmm').textContent = 'Finding you…';
navigator.geolocation.getCurrentPosition(p => send('My location', `${p.coords.latitude.toFixed(4)}, ${p.coords.longitude.toFixed(4)}`, p.coords.latitude, p.coords.longitude), e => { el.querySelector('#plmm').textContent = e.code === 1 ? 'Location is blocked. Allow it for this site in your browser settings, or search for the place instead.' : 'Could not find your location. Search for the place instead.'; }, { enableHighAccuracy: true, timeout: 12000, maximumAge: 30000 });
};
el.querySelector('#plf').onsubmit = async e => { e.preventDefault(); const q = el.querySelector('#plq').value.trim(), box = el.querySelector('#plr'); el.querySelector('#ple').textContent = ''; if (q.length < 3) { el.querySelector('#ple').textContent = 'Type at least three letters.'; return; }
box.innerHTML = '<div class="row"><span class="grow"><small>Searching…</small></span></div>';
try { const r = await chApi('/places?q=' + encodeURIComponent(q)); box.innerHTML = r.items.map((x, i) => `<a href="#" class="row" data-i="${i}"><span class="grow" style="white-space:normal"><b>${esc(x.title)}</b><small>${esc(x.sub)}</small></span></a>`).join('') || '<div class="row"><span class="grow"><small>No places found.</small></span></div>';
box.querySelectorAll('[data-i]').forEach(a => a.onclick = ev => { ev.preventDefault(); const x = r.items[+a.dataset.i]; send(x.title, x.sub, x.lat, x.lng); }); } catch (er) { box.innerHTML = ''; el.querySelector('#ple').textContent = er.message; } };
}
/** Where a shared place opens: the phone's own maps app when there is one, otherwise OpenStreetMap. */
function chPlaceHref(s) {
const lat = +s.lat, lng = +s.lng; if (!isFinite(lat) || !isFinite(lng)) return s.link;
const ua = navigator.userAgent || '', t = encodeURIComponent(s.title || 'Place');
if (/iPhone|iPad|iPod|Macintosh/.test(ua) && 'ontouchend' in document) return `https://maps.apple.com/?ll=${lat},${lng}&q=${t}`;
if (/Android/.test(ua)) return `geo:${lat},${lng}?q=${lat},${lng}(${t})`;
return s.link;
}
// ---- one list for Chat and Signal
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 => `<a class="row sgrow" data-sig="1" href="#/signal/${encodeURIComponent(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span>
<span class="grow"><b>${esc(c.name)} <small class="srcbadge">Signal</small></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>`;
/** 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(); }); }
+7 -5
View File
@@ -44,7 +44,7 @@ async function chMount(root) {
}
async function chRefresh(first) {
try {
const r = await chApi('/convs'); ch.convs = r.convs; ch.unread = r.unread; renderNav(activeTab);
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 = '<p class="empty">Choose a chat, or start a new one.</p>';
} catch (e) { const l = chq('#chlist'); if (l) l.innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; }
@@ -61,7 +61,8 @@ function chDrawList() {
const q = (chq('#chq') || {}).value ? chq('#chq').value.toLowerCase().trim() : '', box = chq('#chlist'); if (!box) return;
const match = c => !q || c.name.toLowerCase().includes(q) || (c.preview || '').toLowerCase().includes(q), live = ch.convs.filter(c => !c.archived && match(c)), arch = ch.convs.filter(c => c.archived && match(c));
const found = q.length >= 2 && ch.found ? ch.found : [];
box.innerHTML = live.map(chRow).join('') + (arch.length ? `<button class="row" id="charch"><span class="grow"><small>${ch.showArchived || q ? '▾' : '▸'} Archived (${arch.length})</small></span></button>${ch.showArchived || q ? arch.map(chRow).join('') : ''}` : '')
const sig = (ch.sig || []).filter(match), rows = [...live.map(c => ({ ts: c.ts || 0, html: chRow(c) })), ...sig.map(c => ({ ts: c.ts || 0, html: chSigRow(c) }))].sort((a, b) => b.ts - a.ts);
box.innerHTML = rows.map(r => r.html).join('') + (arch.length ? `<button class="row" id="charch"><span class="grow"><small>${ch.showArchived || q ? '▾' : '▸'} Archived (${arch.length})</small></span></button>${ch.showArchived || q ? arch.map(chRow).join('') : ''}` : '')
+ (found.length ? `<p class="sub" style="margin:12px 12px 4px">In messages</p>${found.map(f => `<a class="row sgrow" href="#/chat/${encodeURIComponent(f.conv)}"><span class="grow"><b>${esc(f.convName)}</b><small>${esc(f.msg.name)}: ${esc(f.msg.text || (f.msg.share ? f.msg.share.title : ''))}</small></span><span class="sgside"><small>${sgTime(f.msg.ts)}</small></span></a>`).join('')}` : '')
|| '';
if (!box.innerHTML) box.innerHTML = `<p class="empty">${q ? 'Nothing matches' : 'No chats yet. Tap New to start one.'}</p>`;
@@ -79,13 +80,14 @@ function chPoll(m) {
<small>${total} ${total === 1 ? 'person has' : 'people have'} voted</small></div>`;
}
// ---- share cards: a song, film, book... sent from its own screen. Tapping opens it, unless that part of the hub is not turned on for this person.
const CH_SHARE = { song: ['🎵', 'Song'], album: ['💿', 'Album'], artist: ['🎤', 'Artist'], playlist: ['📃', 'Playlist'], movie: ['🎬', 'Movie'], show: ['📺', 'TV show'], episode: ['📺', 'Episode'], book: ['📖', 'Book'], audiobook: ['🎧', 'Audiobook'], podcast: ['🎙️', 'Podcast'], podcastep: ['🎙️', 'Podcast episode'], radio: ['📻', 'Radio'] };
const CH_SHARE = { song: ['🎵', 'Song'], album: ['💿', 'Album'], artist: ['🎤', 'Artist'], playlist: ['📃', 'Playlist'], movie: ['🎬', 'Movie'], show: ['📺', 'TV show'], episode: ['📺', 'Episode'], book: ['📖', 'Book'], audiobook: ['🎧', 'Audiobook'], podcast: ['🎙️', 'Podcast'], podcastep: ['🎙️', 'Podcast episode'], radio: ['📻', 'Radio'], place: ['📍', 'Place'] };
/** The tab a deep link lives in decides whether this person can open it (a child account only gets the tabs a parent allowed). */
const chShareOk = link => { const tab = String(link || '').split('/')[1]; return !!tab && !!routesOf()[tab] && allowed(tab); };
const chShareOk = link => { if (/^https:\/\/www\.openstreetmap\.org\/\?mlat=/.test(String(link || ''))) return true; const tab = String(link || '').split('/')[1]; return !!tab && !!routesOf()[tab] && allowed(tab); };
function chShareCard(m) {
const s = m.share, k = CH_SHARE[s.kind] || ['🔗', 'Shared'], ok = chShareOk(s.link), art = /^\/(plex|api)\//.test(s.art || '') ? s.art : '';
const body = `${art ? `<img class="sgshare-art" alt="" loading="lazy" src="${esc(art)}" onerror="this.replaceWith(Object.assign(document.createElement('i'),{className:'sgshare-art',textContent:'${k[0]}'}))">` : `<i class="sgshare-art">${k[0]}</i>`}
<span class="sgshare-t"><small>${k[0]} ${esc(k[1])}</small><b>${esc(s.title)}</b>${s.sub ? `<em>${esc(s.sub)}</em>` : ''}${ok ? '' : '<small class="sgshare-off">Not available for you</small>'}</span>`;
if (s.kind === 'place') return `<a class="sgshare" href="${esc(chPlaceHref(s))}" target="_blank" rel="noopener noreferrer">${body}<span class="sgshare-go">›</span></a>`;
return ok ? `<a class="sgshare" href="${esc(s.link)}">${body}<span class="sgshare-go">›</span></a>` : `<div class="sgshare off" role="note">${body}</div>`;
}
function chBubble(m, c, prev) {
@@ -162,7 +164,7 @@ function chWireComposer() {
ta.oninput = () => { grow(); chSetDraft(ch.cur, ta.value); if (ta.value.trim() && Date.now() - ch.typedAt > 3000) { ch.typedAt = Date.now(); chPost('/typing', { conv: ch.cur }).catch(() => {}); } };
chMentionBox(ta);
ta.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey && matchMedia('(pointer: fine)').matches) { e.preventDefault(); chq('#chform').requestSubmit(); } };
chq('#chat').onclick = () => actionSheet('Attach', '', [{ label: 'Photo or file', icon: 'clip', run: () => chq('#chfile').click() }, { label: 'Poll', icon: 'check', run: chNewPoll }]);
chGifOn(); chq('#chat').onclick = () => actionSheet('Attach', '', [{ label: 'Photo or file', icon: 'clip', run: () => chq('#chfile').click() }, ...(ch.gifOn ? [{ label: 'GIF', icon: 'play', run: chGifPicker }] : []), { label: 'Place or location', icon: 'pin', run: chPlacePicker }, { label: 'Poll', icon: 'check', run: chNewPoll }]);
chq('#chem').onclick = chEmojiPanel; chq('#chmic').onclick = chRecStart;
const sw = () => { const has = !!ta.value.trim() || ch.tray.length; chq('#chmic').hidden = has; chq('#chsend').hidden = !has; }; ta.addEventListener('input', sw); sw(); ch.swSend = sw;
chq('#chfile').onchange = e => { ch.tray.push(...e.target.files); e.target.value = ''; chDrawTray(); ch.swSend(); };
+1
View File
@@ -96,6 +96,7 @@
<script defer src="/file-actions.js"></script>
<script defer src="/signal-view.js"></script>
<script defer src="/chat-view.js"></script>
<script defer src="/chat-extras.js"></script>
<script defer src="/notes-view.js"></script>
<script defer src="/chores-view.js"></script>
<script defer src="/family-view.js"></script>
+1 -1
View File
@@ -12,7 +12,7 @@ async function sgStart() { // keep the unread count and live updates running wha
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); };
sg.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } sgEvent(ev); if (typeof chSigChanged === 'function') chSigChanged(); };
}
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.
+2
View File
@@ -1107,3 +1107,5 @@ html.rm *, html.rm *::before, html.rm *::after { animation-duration: .01ms !impo
html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text-decoration: underline; } html.hc .btn.ghost, html.hc select, html.hc input[type=text], html.hc input[type=search], html.hc textarea { border: 2px solid var(--line); } html.hc :focus-visible { outline-width: 3px; }
#offnote { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(var(--sab) + 70px); z-index: 90; max-width: min(92vw, 460px); padding: 10px 16px; border-radius: 12px; background: var(--panel); border: 1px solid var(--line); box-shadow: 0 6px 24px rgba(0, 0, 0, .3); font-size: 14px; text-align: center; } #offnote[hidden] { display: none; }
.paTiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; margin: 8px 0 16px; } .paTile { min-height: 84px; padding: 12px; border-radius: 16px; border: 1px solid var(--line); background: var(--panel); text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 17px; } .paTile small { color: var(--muted); font-size: 14px; } .paTile:active { transform: scale(.97); } .paTile.low { border-color: var(--danger); } .paTile.out { opacity: .45; }
.gifgrid { columns: 3 110px; column-gap: 6px; margin: 8px 0; } .gifc { display: block; width: 100%; margin: 0 0 6px; padding: 0; border: 0; border-radius: 10px; overflow: hidden; background: var(--panel2); break-inside: avoid; min-height: 0; cursor: pointer; } .gifc img { display: block; width: 100%; height: auto; } .gifc:focus-visible { outline: 3px solid var(--accent); }
.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; }
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v127';
const CACHE = 'hub-v129';
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'];