From 584a95753d85bd54c11bec8c3b27e95ccf2c4bdc Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:13:26 -0400 Subject: [PATCH] Chat: a GIF picker kept family-friendly, sharing a place, and Signal conversations in the Chat list Co-Authored-By: Claude Sonnet 5.5 --- .gitea/workflows/build.yml | 1 + README.md | 1 + chat.js | 26 +++++++++++++++++- docker-compose.yml | 2 ++ docs/v2-roadmap.md | 6 ++--- gifs.js | 54 ++++++++++++++++++++++++++++++++++++++ public/app.js | 1 + public/chat-extras.js | 51 +++++++++++++++++++++++++++++++++++ public/chat-view.js | 12 +++++---- public/index.html | 1 + public/signal-view.js | 2 +- public/style.css | 2 ++ public/sw.js | 2 +- server.js | 2 +- tools/chatextras-test.js | 48 +++++++++++++++++++++++++++++++++ tools/frontend-test.js | 2 +- 16 files changed, 200 insertions(+), 13 deletions(-) create mode 100644 gifs.js create mode 100644 public/chat-extras.js create mode 100644 tools/chatextras-test.js 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: '', bible: '', list: '', + pin: '', font: '', }; const icon = (n, cls = '') => `${ICONS[n]}`; diff --git a/public/chat-extras.js b/public/chat-extras.js new file mode 100644 index 0000000..ee46819 --- /dev/null +++ b/public/chat-extras.js @@ -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', `

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 = '
Searching…
'; + try { const r = await chApi('/places?q=' + encodeURIComponent(q)); box.innerHTML = r.items.map((x, i) => `${esc(x.title)}${esc(x.sub)}`).join('') || '
No places found.
'; + 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 => `${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(); }); } diff --git a/public/chat-view.js b/public/chat-view.js index 3f31a47..b6985d4 100644 --- a/public/chat-view.js +++ b/public/chat-view.js @@ -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 = '

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 ? `` : `${k[0]}`} ${k[0]} ${esc(k[1])}${esc(s.title)}${s.sub ? `${esc(s.sub)}` : ''}${ok ? '' : 'Not available for you'}`; + if (s.kind === 'place') return `${body}β€Ί`; return ok ? `${body}β€Ί` : `
${body}
`; } 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(); }; diff --git a/public/index.html b/public/index.html index eab398b..f02eaf3 100644 --- a/public/index.html +++ b/public/index.html @@ -96,6 +96,7 @@ + diff --git a/public/signal-view.js b/public/signal-view.js index c6362c8..c87bbc7 100644 --- a/public/signal-view.js +++ b/public/signal-view.js @@ -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. diff --git a/public/style.css b/public/style.css index 0d1f20c..d549ad3 100644 --- a/public/style.css +++ b/public/style.css @@ -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; } diff --git a/public/sw.js b/public/sw.js index fe4d715..719703c 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-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']; diff --git a/server.js b/server.js index ace7c62..bd15492 100644 --- a/server.js +++ b/server.js @@ -614,7 +614,7 @@ function securityHeaders(res) { res.setHeader('Referrer-Policy', 'same-origin'); res.setHeader('Content-Security-Policy', "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self'"); res.setHeader('Content-Security-Policy-Report-Only', screentime.policy()); // (reports only: nothing is blocked) - res.setHeader('Permissions-Policy', 'camera=(self), geolocation=(), payment=()'); + res.setHeader('Permissions-Policy', 'camera=(self), geolocation=(self), payment=()'); if (process.env.PUBLIC_URL && process.env.PUBLIC_URL.startsWith('https://')) res.setHeader('Strict-Transport-Security', 'max-age=15552000'); } /** Cross-site request check: a browser request that changes something must come from this same site. */ diff --git a/tools/chatextras-test.js b/tools/chatextras-test.js new file mode 100644 index 0000000..494938c --- /dev/null +++ b/tools/chatextras-test.js @@ -0,0 +1,48 @@ +// Family Chat extras: GIFs (kept family-friendly, fetched through the hub, kept in the chat) and sharing a place (a search, and coordinates that are checked). +const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'); +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-chatx-')); +const GIF = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64'); let asked = [], tenorQ = [], giphyQ = []; +const media = http.createServer((req, res) => { asked.push(req.url); if (req.url.startsWith('/big')) { res.writeHead(200, { 'content-type': 'image/gif' }); return res.end(Buffer.alloc(9 * 1024 * 1024)); } if (req.url.startsWith('/html')) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('x'); } res.writeHead(200, { 'content-type': 'image/gif' }); res.end(GIF); }).listen(0); +const svc = http.createServer((req, res) => { + const u = new URL(req.url, 'http://x'), M = 'http://127.0.0.1:' + media.address().port; res.setHeader('content-type', 'application/json'); + if (u.pathname.startsWith('/v2/')) { tenorQ.push(u.pathname + u.search); return res.end(JSON.stringify({ next: 'n2', results: [ + { content_description: 'happy dance', tags: ['dance'], media_formats: { tinygif: { url: M + '/t1.gif', dims: [100, 80] }, gif: { url: M + '/g1.gif', dims: [300, 240] } } }, + { content_description: 'sexy dance', tags: ['dance'], media_formats: { tinygif: { url: M + '/t2.gif', dims: [100, 80] }, gif: { url: M + '/g2.gif', dims: [300, 240] } } }, + { content_description: 'party', tags: ['topless', 'fun'], media_formats: { tinygif: { url: M + '/t3.gif', dims: [100, 80] }, gif: { url: M + '/g3.gif', dims: [300, 240] } } }, + { content_description: 'elsewhere', tags: [], media_formats: { tinygif: { url: 'https://evil.example/t4.gif', dims: [1, 1] }, gif: { url: 'https://evil.example/g4.gif', dims: [1, 1] } } }] })); } + if (u.pathname.startsWith('/v1/gifs/')) { giphyQ.push(u.pathname + u.search); return res.end(JSON.stringify({ data: [{ title: 'cat', rating: 'g', images: { fixed_width_small: { url: M + '/s1.gif', width: '100', height: '80' }, fixed_width: { url: M + '/f1.gif' } } }, { title: 'cat 2', rating: 'pg-13', images: { fixed_width_small: { url: M + '/s2.gif', width: '100', height: '80' }, fixed_width: { url: M + '/f2.gif' } } }] })); } + if (u.pathname === '/search') { return res.end(JSON.stringify([{ name: 'Central Park', display_name: 'Central Park, Manhattan, New York, United States', lat: '40.78', lon: '-73.96' }, { name: 'Bad', display_name: 'Bad', lat: 'x', lon: 'y' }])); } + res.statusCode = 404; res.end('{}'); }).listen(0, async () => { + process.env.GIF_MEDIA_HOSTS = '127.0.0.1:' + media.address().port; process.env.TENOR_URL = process.env.GIPHY_URL = process.env.NOMINATIM_URL = 'http://127.0.0.1:' + svc.address().port; + const users = require('../users'), chat = require('../chat'), gifs = require('../gifs'); + const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['chat'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['chat'], handle: 'Yw' }) }; + const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => chat.handle(req, res))).listen(0, async () => { + const B = 'http://127.0.0.1:' + hub.address().port + '/api/chat', j = r => r.json(), call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who); + try { + // the filter's words + for (const bad of ['sexy dance', 'Boobs', 'naked man', 'p0rn'.replace('0', 'o'), 'bikini beach', 'F-U-C-K'.replace(/-/g, '')]) assert.equal(gifs.unsafe(bad), true, bad + ' is refused'); for (const ok of ['happy dance', 'class', 'assistant', 'Scunthorpe', 'cat', 'thanks', 'high five', 'book club', 'pass the butter']) assert.equal(gifs.unsafe(ok), false, ok + ' is fine'); + // not set up + let r = await call('/gif/status'); assert.deepEqual(await j(r), { enabled: false, provider: '' }); assert.equal((await call('/gif?q=cat')).status, 503); + // Tenor: always the strictest setting, and what comes back is filtered by words and by where it is hosted + process.env.TENOR_API_KEY = 'k1'; assert.deepEqual(await j(await call('/gif/status')), { enabled: true, provider: 'tenor' }); + r = await j(await call('/gif?q=dance')); assert.match(tenorQ[0], /contentfilter=high/); assert.match(tenorQ[0], /q=dance/); assert.deepEqual(r.items.map(x => x.title), ['happy dance'], 'a title or a tag with a bad word, and a picture from another site, are dropped'); assert.equal(r.next, 'n2'); + assert.equal((await call('/gif?q=sexy')).status, 400, 'a bad search is refused before it is sent'); assert.equal(tenorQ.length, 1, 'and never reaches the service'); await call('/gif'); assert.match(tenorQ[1], /featured/, 'with no words: what is popular'); + // previews come through the hub (only ones it listed), never the phone's own request + const prev = r.items[0].preview; assert.equal((await call('/gifimg?u=' + encodeURIComponent(prev))).status, 200); assert.equal((await call('/gifimg?u=' + encodeURIComponent('http://127.0.0.1:' + media.address().port + '/other.gif'))).status, 400, 'an address the hub did not list is refused'); assert.equal((await call('/gifimg?u=' + encodeURIComponent('https://evil.example/x.gif'))).status, 400); + // sending: downloaded and kept in the chat like a photo + const dm = (await j(await post('/new', { type: 'direct', with: P.b.id }))).conv; r = await post('/gif/send', { conv: dm.id, url: r.items[0].url, title: 'happy dance' }); assert.equal(r.status, 200); const m = (await j(r)).msg; assert.equal(m.att[0].type, 'image/gif'); assert.equal(m.att[0].name, 'happy dance.gif'); + const f = await call('/file/' + m.att[0].id, {}, 'b'); assert.equal(f.status, 200); assert.deepEqual(Buffer.from(await f.arrayBuffer()), GIF, 'Bob sees the very same picture, from the hub'); assert.equal((await post('/gif/send', { conv: dm.id, url: 'http://127.0.0.1:' + media.address().port + '/g1.gif' }, 'c')).status, 404, 'a stranger cannot send into the chat'); + assert.equal((await post('/gif/send', { conv: dm.id, url: 'http://127.0.0.1:' + media.address().port + '/never-listed.gif' })).status, 400, 'only listed GIFs can be sent'); + // size and type limits + { const M = 'http://127.0.0.1:' + media.address().port; gifs.remember([M + '/big.gif', M + '/html.gif']); assert.equal((await post('/gif/send', { conv: dm.id, url: M + '/big.gif' })).status, 413, 'a huge GIF is refused'); assert.equal((await post('/gif/send', { conv: dm.id, url: M + '/html.gif' })).status, 502, 'something that is not a picture is refused'); } + // Giphy: G only, and nothing above it slips through + delete process.env.TENOR_API_KEY; process.env.GIPHY_API_KEY = 'k2'; r = await j(await call('/gif?q=cat')); assert.match(giphyQ[0], /rating=g/); assert.match(giphyQ[0], /api_key=k2/); assert.deepEqual(r.items.map(x => x.title), ['cat'], 'a result rated above G is dropped even if the service sent it'); + // a place: search through the hub, and a share with checked coordinates + assert.equal((await call('/places?q=ab')).status, 400); r = await j(await call('/places?q=central park')); assert.deepEqual(r.items, [{ title: 'Central Park', sub: 'Manhattan, New York, United States', lat: 40.78, lng: -73.96 }], 'bad rows are dropped'); + r = await post('/send', { conv: dm.id, share: { kind: 'place', title: 'Central Park', sub: 'New York', lat: 40.78, lng: -73.96, link: 'https://evil.example/' } }); assert.equal(r.status, 200); const sh = (await j(r)).msg.share; assert.equal(sh.link, 'https://www.openstreetmap.org/?mlat=40.78&mlon=-73.96#map=17/40.78/-73.96', 'the link is made by the hub, not taken from the sender'); assert.equal(sh.kind, 'place'); + for (const bad of [{ lat: 91, lng: 0 }, { lat: 0, lng: 181 }, { lat: 'x', lng: 1 }, { lat: '', lng: '' }, {}]) assert.equal((await post('/send', { conv: dm.id, share: { kind: 'place', title: 'x', ...bad } })).status, 400, JSON.stringify(bad) + ' is not a place'); + console.log('chat extras ok'); + } catch (e) { console.error(e); process.exitCode = 1; } + hub.close(); svc.close(); media.close(); setTimeout(() => process.exit(process.exitCode || 0), 80); + }); +}); diff --git a/tools/frontend-test.js b/tools/frontend-test.js index c304026..b422c6a 100644 --- a/tools/frontend-test.js +++ b/tools/frontend-test.js @@ -32,7 +32,7 @@ for (const f of listed) { } assert.deepEqual(failures, [], 'every script loads:\n ' + failures.join('\n ')); // the pieces the app calls by name across files are there -for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalendar', 'viewBible', 'viewMail', 'viewRecipes', 'viewVideo', 'themeApply', 'autoSettings', 'bbToday', 'homeListening', 'saveToFiles', 'downloadText', 'viewPantry', 'pantryScan']) assert.equal(typeof vm.runInContext(`typeof ${name}`, ctx), 'string') && assert.equal(vm.runInContext(`typeof ${name}`, ctx), 'function', name + ' is defined'); +for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalendar', 'viewBible', 'viewMail', 'viewRecipes', 'viewVideo', 'themeApply', 'autoSettings', 'bbToday', 'homeListening', 'saveToFiles', 'downloadText', 'viewPantry', 'pantryScan', 'chGifPicker', 'chPlacePicker', 'chSigRow']) assert.equal(typeof vm.runInContext(`typeof ${name}`, ctx), 'string') && assert.equal(vm.runInContext(`typeof ${name}`, ctx), 'function', name + ' is defined'); // the watch-stats counting (a pure function) { const now = Date.UTC(2026, 5, 15, 12), at = d => Math.floor((now - d * 864e5) / 1000), agg = vm.runInContext('vdAgg', ctx); const a = agg([{ lastViewedAt: at(1), duration: 6e6, Genre: [{ tag: 'Drama' }, { tag: 'Comedy' }] }, { lastViewedAt: at(3), duration: 3e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(20), duration: 6e6 }, { lastViewedAt: at(200), duration: 6e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(400), duration: 6e6 }, { duration: 6e6 }], now);