diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
index c62eacd..0486bb3 100644
--- a/.gitea/workflows/build.yml
+++ b/.gitea/workflows/build.yml
@@ -35,6 +35,7 @@ jobs:
- run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books
- run: node tools/moviefam-test.js # Movies & TV: favorites and the family movie night vote
- run: node tools/pantry-test.js # pantry: counts, low levels, barcodes (several per item), kids can only take
+ - run: node tools/chatextras-test.js # chat: GIFs (family-friendly filter, kept in the chat) and sharing a place
- run: node tools/units-test.js # units: reading recipe lines, converting, what a recipe takes out of the pantry
- run: node tools/automations-test.js # automations: validation, triggers, daily cap, privacy
diff --git a/README.md b/README.md
index 2168015..f92f0cc 100644
--- a/README.md
+++ b/README.md
@@ -148,6 +148,7 @@ playing an episode or pressing the button still works. Settings -> Health shows
- **Podcast discovery**: *Add podcast* searches Apple's free directory and, when `PODCASTINDEX_KEY` and `PODCASTINDEX_SECRET` are set (a free key from podcastindex.org), Podcast Index too, with topic buttons (News, History, Science...) that list trending shows. A feed in both appears once.
- **Search what's said** (Podcast screen): finds a word or phrase in every transcript on the server and plays that episode from that moment. Only podcasts on your own list (and audiobooks) are searched; older transcripts are matched to their episode the first time.
+- **Chat extras**: a **GIF** button (set `TENOR_API_KEY` or `GIPHY_API_KEY`; the hub does all the talking to the service, asks only for G-rated results, refuses searches and drops results containing words that could bring up adult material, keeps a sent GIF in the chat like a photo, and a sender or admin can delete one that slips through), **Place or location** (your phone's location after it asks, or a search through OpenStreetMap; shared as a map card that opens the phone's maps app), and the Chat list also shows **Signal** conversations, labelled, on screens where Signal is available.
- **Recipes** (needs `MEALIE_TOKEN`; `MEALIE_URL` defaults to `https://mealie.necloud.us`): browse and search your Mealie recipes, read one (ingredients, steps, times), import a recipe from a web link, and add its ingredients to the Mealie shopping list (Mealie adds up repeats). The shopping list is also kept in **Tasks** as a "Shopping" list: ticking, adding or deleting an item in either place is carried over when either screen opens or an item changes. In a recipe, tick ingredients (and tap steps) as you go; this is remembered on the device, with *Uncheck all* to start over. On wide screens the ingredients sit beside the steps. Turn the tab on per person in the admin screen.
- **Contacts**: an address book per person (name, company, emails, phones, address, birthday, notes). Mail suggests contacts while you type a recipient, a message you are reading can be saved as a contact (or opens the one you have), birthdays appear in the Calendar, and .vcf files from iCloud, Google or a phone can be imported and exported.
diff --git a/chat.js b/chat.js
index 2c63986..44dce21 100644
--- a/chat.js
+++ b/chat.js
@@ -1,6 +1,8 @@
// Family Chat: a messenger built into the hub, for the people who have an account here. Direct chats and groups, text, attachments, replies, reactions, edit and delete,
// unread counts, search, mute and archive, live delivery (server-sent events) and phone push when someone is not looking. Everything is kept on this server, in
// DATA_DIR/chat: conversations and messages as JSON, attachments as files. (Messages are stored readable on the server; end-to-end encryption is a later decision.)
+const gifs = require('./gifs');
+let placeAt = 0;
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const { pipeline } = require('stream/promises');
const atomicWrite = require('./atomic');
@@ -18,11 +20,16 @@ const fail = (code, message) => Object.assign(new Error(message), { code });
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, n);
const newId = () => crypto.randomBytes(8).toString('hex');
// ---- share cards: a song, film, book... from elsewhere in the hub, with the picture and a link back to it
-const SHARE = { song: ['π΅', 'a song'], album: ['πΏ', 'an album'], artist: ['π€', 'an artist'], playlist: ['π', 'a playlist'], movie: ['π¬', 'a movie'], show: ['πΊ', 'a show'], episode: ['πΊ', 'an episode'], book: ['π', 'a book'], audiobook: ['π§', 'an audiobook'], podcast: ['ποΈ', 'a podcast'], podcastep: ['ποΈ', 'a podcast episode'], radio: ['π»', 'a radio station'] };
+const SHARE = { song: ['π΅', 'a song'], album: ['πΏ', 'an album'], artist: ['π€', 'an artist'], playlist: ['π', 'a playlist'], movie: ['π¬', 'a movie'], show: ['πΊ', 'a show'], episode: ['πΊ', 'an episode'], book: ['π', 'a book'], audiobook: ['π§', 'an audiobook'], podcast: ['ποΈ', 'a podcast'], podcastep: ['ποΈ', 'a podcast episode'], radio: ['π»', 'a radio station'], place: ['π', 'a place'] };
const ART_OK = /^\/(plex|api)\/[^\s"'<>\\]*$/, LINK_OK = /^#\/[\w-]+(\/[\w.~%:@+,=&?-]*)*$/;
/** What a client sent as a share, made safe: whitelisted kind, short words, a picture from this hub only and a link that is a hash route (never another site). */
function cleanShare(s) {
if (!s || typeof s !== 'object' || !SHARE[s.kind]) throw fail(400, 'That cannot be shared');
+ if (s.kind === 'place') { // a place on the map: the coordinates are checked and the link is made here (OpenStreetMap), never taken from the sender
+ const lat = Math.round(+s.lat * 1e5) / 1e5, lng = Math.round(+s.lng * 1e5) / 1e5, title = str(s.title, 120).trim() || 'Shared location';
+ if (!(Math.abs(lat) <= 90) || !(Math.abs(lng) <= 180) || s.lat === '' || s.lng === '' || s.lat == null || s.lng == null) throw fail(400, 'That place has no location');
+ return { kind: 'place', title, sub: str(s.sub, 160).trim(), art: '', link: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=17/${lat}/${lng}`, lat, lng };
+ }
const title = str(s.title, 120).trim(), link = String(s.link || ''), art = String(s.art || '');
if (!title) throw fail(400, 'Nothing to share');
if (link.length > 300 || !LINK_OK.test(link)) throw fail(400, 'That link cannot be shared');
@@ -173,6 +180,23 @@ async function handle(req, res) {
return json(res, 200, { conv: view(c, me.id, stateOf(me.id)), pinned: (c.pins || []).map(id => all.find(x => x.id === id)).filter(x => x && !x.deleted).map(x => msgView(x, me.id, c, sts)), msgs: page.map(x => msgView(x, me.id, c, sts)), more: eligible.length > page.length, names: Object.fromEntries(c.members.map(x => [x, nameOf(x)])) });
}
if (p === '/send' && req.method === 'POST') return json(res, 200, { msg: send(me, JSON.parse(await readBody(req, 5e4) || '{}')) });
+ if (p === '/places' && req.method === 'GET') { // search for a place by name (OpenStreetMap's Nominatim, one request a second at most, asked from here)
+ const term = str(q.get('q'), 100).trim(); if (term.length < 3) throw fail(400, 'Type at least three letters');
+ const wait = Math.max(0, placeAt + 1100 - Date.now()); placeAt = Date.now() + wait; if (wait) await new Promise(r => setTimeout(r, wait));
+ let j; try { const r = await fetch(`${(process.env.NOMINATIM_URL || 'https://nominatim.openstreetmap.org').replace(/\/+$/, '')}/search?format=jsonv2&limit=6&addressdetails=0&q=${encodeURIComponent(term)}`, { headers: { 'user-agent': 'NE-Hub/1.0 (family chat)', accept: 'application/json' }, signal: AbortSignal.timeout(8000) }); if (!r.ok) throw new Error('status ' + r.status); j = await r.json(); } catch { throw fail(502, 'The map search did not answer. Try again in a moment.'); }
+ return json(res, 200, { items: (Array.isArray(j) ? j : []).filter(x => isFinite(+x.lat) && isFinite(+x.lon)).slice(0, 6).map(x => { const parts = String(x.display_name || '').split(', '); return { title: str(x.name || parts[0] || term, 120), sub: str(parts.slice(1, 4).join(', '), 160), lat: +x.lat, lng: +x.lon }; }) });
+ }
+ if (p === '/gif/status') return json(res, 200, { enabled: gifs.enabled(), provider: gifs.provider() });
+ if (p === '/gif' && req.method === 'GET') { try { return json(res, 200, await gifs.search(q.get('q'), q.get('pos'))); } catch (e) { throw fail(e.code && e.code < 600 ? e.code : 502, e.message); } }
+ if (p === '/gifimg' && req.method === 'GET') { // a preview, fetched here so the phone never contacts the GIF service
+ try { const g = await gifs.fetchBytes(String(q.get('u') || ''), 2 * 1024 * 1024); res.writeHead(200, { 'content-type': g.type, 'cache-control': 'private, max-age=3600', 'x-content-type-options': 'nosniff', 'content-length': g.buf.length }); return res.end(g.buf); } catch (e) { throw fail(e.code && e.code < 600 ? e.code : 502, e.message); }
+ }
+ if (p === '/gif/send' && req.method === 'POST') { // the chosen GIF is downloaded and kept in the chat like a photo
+ const b = JSON.parse(await readBody(req, 5e3) || '{}'), c = convOf(String(b.conv || ''), me); let g; try { g = await gifs.fetchBytes(String(b.url || '')); } catch (e) { throw fail(e.code && e.code < 600 ? e.code : 502, e.message); }
+ const id = newId(); fs.mkdirSync(path.dirname(blob(id)), { recursive: true, mode: 0o700 }); fs.writeFileSync(blob(id), g.buf, { mode: 0o600 });
+ files()[id] = { conv: c.id, by: me.id, name: (str(b.title, 60).replace(/[\\/:*?"<>|\u0000-\u001f]+/g, ' ').trim() || 'GIF') + (g.type === 'image/gif' ? '.gif' : '.' + g.type.slice(6)), type: g.type, size: g.buf.length, ts: Date.now() }; saveFiles();
+ return json(res, 200, { msg: send(me, { conv: c.id, files: [id] }) });
+ }
if (p === '/upload' && req.method === 'POST') { // the body is the file; ?conv= says which chat it is for
const c = convOf(String(q.get('conv') || ''), me); if ((+req.headers['content-length'] || 0) > MAX_FILE) throw fail(413, 'That file is too large (100 MB at most)');
const id = newId(), type = /^[\w.+-]+\/[\w.+-]+$/.test(String(req.headers['content-type'] || '').split(';')[0].trim()) ? String(req.headers['content-type']).split(';')[0].trim().toLowerCase() : 'application/octet-stream', name = path.basename(str(q.get('name'), 160)).replace(/[\\/:*?"<>|]+/g, '_') || 'file';
diff --git a/docker-compose.yml b/docker-compose.yml
index fe7e903..b3c8ad5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -32,6 +32,8 @@ services:
PODCASTINDEX_SECRET: ${PODCASTINDEX_SECRET:-}
MEALIE_URL: ${MEALIE_URL:-https://mealie.necloud.us} # Recipes tab: your Mealie server, and an API token (Mealie: your profile -> API tokens); the token stays on the server
MEALIE_TOKEN: ${MEALIE_TOKEN:-}
+ TENOR_API_KEY: ${TENOR_API_KEY:-} # GIFs in Family Chat: a Tenor key (Google Cloud, Tenor API) ...
+ GIPHY_API_KEY: ${GIPHY_API_KEY:-} # ... or a GIPHY key. Either one turns the GIF button on; leave both empty to keep it off. Only G-rated GIFs are ever offered.
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret
PUBLIC_URL: ${PUBLIC_URL:-} # e.g. https://hub.necloud.us (OnlyOffice posts saved edits here)
HUB_SETUP_CODE: ${HUB_SETUP_CODE:-} # first passkey; if empty, a code is printed in the log
diff --git a/docs/v2-roadmap.md b/docs/v2-roadmap.md
index 042201a..70e78ce 100644
--- a/docs/v2-roadmap.md
+++ b/docs/v2-roadmap.md
@@ -22,12 +22,12 @@ A messenger inside the hub for the family, so family chats do not need Signal. E
- [x] Disappearing messages (a timer per chat)
- [x] @mentions in groups, pinned messages, forward a message
-**Phase 3: richer messages (M): done except the GIF picker and shared places**
+**Phase 3: richer messages (M): done**
- [x] Voice notes (record and play, with a waveform)
- [x] Link previews (fetched safely by the server)
- [x] Polls
-- [ ] GIF or sticker picker (optional)
-- [ ] Share a place as a link
+- [x] GIF picker: **Done (gifs.js, chat-extras.js): needs `TENOR_API_KEY` or `GIPHY_API_KEY`; G-rated only, a blocklist on searches and results, previews and the sent GIF go through the hub.**
+- [x] Share a place as a link: **Done: your location (the phone asks) or a place search through OpenStreetMap, shown as a map card.**
**Family extras (M): mostly done**
- [x] Turn a message into a task, an event or a shopping item in one tap
diff --git a/gifs.js b/gifs.js
new file mode 100644
index 0000000..89c04bc
--- /dev/null
+++ b/gifs.js
@@ -0,0 +1,54 @@
+// GIFs for Family Chat, from Tenor or GIPHY (whichever key the admin gives: TENOR_API_KEY or GIPHY_API_KEY). The hub does all the talking to the service, so phones never contact it:
+// searches come back through here, previews are fetched here, and a GIF that is sent is downloaded and kept in the chat like any other picture.
+// Everything is kept family-friendly, for everybody: only the services' G-rated results are asked for, words that could bring up adult material are refused in a search and in what comes
+// back (title and tags), and only GIFs this server itself listed can be sent. A filter like this is a strong guard, not a promise: a sender or an admin can delete anything that slips through.
+const TENOR = () => (process.env.TENOR_URL || 'https://tenor.googleapis.com').replace(/\/+$/, ''), GIPHY = () => (process.env.GIPHY_URL || 'https://api.giphy.com').replace(/\/+$/, '');
+const provider = () => process.env.TENOR_API_KEY ? 'tenor' : process.env.GIPHY_API_KEY ? 'giphy' : '';
+const enabled = () => !!provider();
+
+// whole words (and stems) that are never searched for and never shown
+const BLOCK = ['sex', 'sexy', 'sexual', 'sexually', 'sexier', 'sexiest', 'porn', 'porno', 'pornography', 'pornographic', 'xxx', 'nsfw', 'nude', 'nudes', 'nudity', 'naked', 'topless', 'bottomless', 'boob', 'boobs', 'boobies', 'breast', 'breasts', 'tit', 'tits', 'titty', 'titties', 'nipple', 'nipples', 'cleavage', 'butt', 'butts', 'booty', 'twerk', 'twerking', 'thong', 'thongs', 'lingerie', 'bikini', 'bikinis', 'underwear', 'panties', 'bra', 'bras', 'strip', 'stripper', 'strippers', 'striptease', 'erotic', 'erotica', 'fetish', 'kink', 'kinky', 'bdsm', 'hentai', 'orgasm', 'orgasms', 'penis', 'vagina', 'dick', 'dicks', 'cock', 'cocks', 'pussy', 'pussies', 'cum', 'cumming', 'horny', 'slut', 'sluts', 'whore', 'hooker', 'hookers', 'masturbate', 'masturbation', 'blowjob', 'handjob', 'anal', 'milf', 'onlyfans', 'playboy', 'adult', 'adults', 'lust', 'seduce', 'seductive', 'foreplay', 'grope', 'groping', 'fuck', 'fucking', 'fucker', 'fucked', 'shit', 'bitch', 'bitches', 'asshole', 'ass', 'drugs', 'cocaine', 'marijuana', 'gore', 'suicide'];
+const BLOCKED = new Set(BLOCK);
+/** Does this text (a search, a title, tags) contain a word that is not allowed? Whole words only, so "class" and "assistant" are fine. */
+const unsafe = text => (String(text || '').toLowerCase().match(/[a-z0-9]+/g) || []).some(w => BLOCKED.has(w) || BLOCKED.has(w.replace(/s$/, '')));
+
+// only GIFs this server has just listed can be sent or previewed (so the download address cannot be chosen by the sender)
+const known = new Map(); // url -> expiry
+const remember = urls => { const t = Date.now() + 3600e3; for (const u of urls) known.set(u, t); if (known.size > 4000) for (const [k, v] of known) if (v < Date.now()) known.delete(k); };
+const isKnown = u => (known.get(u) || 0) > Date.now();
+const MEDIA = /^https:\/\/(media[0-9]*\.tenor\.com|media[0-9]*\.giphy\.com|i\.giphy\.com|c\.tenor\.com)\//i;
+const mediaOk = u => { try { const x = new URL(u); return MEDIA.test(u) || String(process.env.GIF_MEDIA_HOSTS || '').split(',').filter(Boolean).includes(x.host); } catch { return false; } };
+
+async function getJson(url) {
+ const r = await fetch(url, { headers: { accept: 'application/json', 'user-agent': 'NE-Hub/1.0 (family chat)' }, signal: AbortSignal.timeout(8000) });
+ if (r.status === 403 || r.status === 401) throw Object.assign(new Error('The GIF service refused the key. Check it in the stack settings.'), { code: 502 });
+ if (r.status === 429) throw Object.assign(new Error('The GIF service is busy. Try again in a minute.'), { code: 429 });
+ if (!r.ok) throw Object.assign(new Error('The GIF service did not answer (' + r.status + ')'), { code: 502 });
+ return r.json();
+}
+/** Search (or, with no words, what is popular). Returns { items: [{ title, w, h, preview, url }], next }. */
+async function search(q, pos) {
+ q = String(q || '').trim().slice(0, 60);
+ if (q && unsafe(q)) throw Object.assign(new Error('That search is not available. Try something else.'), { code: 400 });
+ const n = 24; let items = [], next = '';
+ if (provider() === 'tenor') {
+ const key = encodeURIComponent(process.env.TENOR_API_KEY), base = `${TENOR()}/v2/${q ? 'search?q=' + encodeURIComponent(q) + '&' : 'featured?'}key=${key}&client_key=nehub&limit=${n}&contentfilter=high&media_filter=tinygif,gif${pos ? '&pos=' + encodeURIComponent(pos) : ''}`;
+ const j = await getJson(base); next = j.next || '';
+ items = (j.results || []).map(x => { const t = (x.media_formats || {}).tinygif, g = (x.media_formats || {}).gif || t; return t && g ? { title: x.content_description || '', tags: (x.tags || []).join(' '), w: (t.dims || [])[0] || 0, h: (t.dims || [])[1] || 0, preview: t.url, url: g.url } : null; });
+ } else if (provider() === 'giphy') {
+ const off = Math.max(0, parseInt(pos, 10) || 0), key = encodeURIComponent(process.env.GIPHY_API_KEY), base = `${GIPHY()}/v1/gifs/${q ? 'search?q=' + encodeURIComponent(q) + '&' : 'trending?'}api_key=${key}&limit=${n}&offset=${off}&rating=g&bundle=messaging_non_clips`;
+ const j = await getJson(base); next = (j.data || []).length >= n ? String(off + n) : '';
+ items = (j.data || []).filter(x => !x.rating || x.rating === 'g').map(x => { const t = (x.images || {}).fixed_width_small || (x.images || {}).fixed_width, g = (x.images || {}).fixed_width || (x.images || {}).original; return t && g && t.url && g.url ? { title: x.title || '', tags: '', w: +t.width || 0, h: +t.height || 0, preview: t.url, url: g.url } : null; });
+ } else throw Object.assign(new Error('GIFs are not set up on this server'), { code: 503 });
+ items = items.filter(x => x && mediaOk(x.preview) && mediaOk(x.url) && !unsafe(x.title) && !unsafe(x.tags)).map(({ tags, ...x }) => x);
+ remember(items.flatMap(x => [x.preview, x.url])); return { items, next };
+}
+/** The bytes of a GIF this server listed (a preview or the full one), size-limited. */
+async function fetchBytes(url, max = 8 * 1024 * 1024) {
+ if (!isKnown(url) || !mediaOk(url)) throw Object.assign(new Error('That GIF is not available'), { code: 400 });
+ const r = await fetch(url, { headers: { 'user-agent': 'NE-Hub/1.0 (family chat)' }, signal: AbortSignal.timeout(15000), redirect: 'error' });
+ if (!r.ok) throw Object.assign(new Error('The GIF could not be downloaded'), { code: 502 });
+ const type = String(r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); if (!/^image\/(gif|webp|png|jpeg)$/.test(type)) throw Object.assign(new Error('That is not a picture'), { code: 502 });
+ const buf = Buffer.from(await r.arrayBuffer()); if (buf.length > max) throw Object.assign(new Error('That GIF is too large'), { code: 413 }); return { buf, type };
+}
+module.exports = { enabled, provider, search, fetchBytes, unsafe, isKnown, remember };
diff --git a/public/app.js b/public/app.js
index fb8c6f9..15d0b3a 100644
--- a/public/app.js
+++ b/public/app.js
@@ -114,6 +114,7 @@ const ICONS = {
heart: '
Powered by ${by}
`); + 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 => ``).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', '', `Your phone asks first. This sends where you are right now, once; it does not keep following you.
+ `); + 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 = 'Choose a chat, or start a new one.
'; } catch (e) { const l = chq('#chlist'); if (l) l.innerHTML = `${esc(e.message)}
`; } @@ -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 ? `${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 ? `${ch.showArchived || q ? arch.map(chRow).join('') : ''}` : '') + (found.length ? `In messages
${found.map(f => `${esc(f.convName)}${esc(f.msg.name)}: ${esc(f.msg.text || (f.msg.share ? f.msg.share.title : ''))}${sgTime(f.msg.ts)}`).join('')}` : '') || ''; if (!box.innerHTML) box.innerHTML = `${q ? 'Nothing matches' : 'No chats yet. Tap New to start one.'}
`; @@ -79,13 +80,14 @@ function chPoll(m) { ${total} ${total === 1 ? 'person has' : 'people have'} voted`; } // ---- 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 ? `