Compare commits
10
Commits
video-restart
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b708a711c | ||
|
|
36395eb7f0 | ||
|
|
714680a94e | ||
|
|
c97b62004e | ||
|
|
98b428e8af | ||
|
|
a09d6b17f4 | ||
|
|
41c48b10e8 | ||
|
|
b0d60d0a5b | ||
|
|
71e759e477 | ||
|
|
24dd05db5d |
No files matched your search
@@ -48,7 +48,7 @@ function eventFields(title, b) {
|
||||
async function board(day) {
|
||||
const t1 = chores.addDays(day, 1), from = new Date(day + 'T00:00:00').getTime() - 864e5, to = new Date(t1 + 'T23:59:59').getTime() + 864e5;
|
||||
let events = []; try { events = cal.familyEvents({ from, to, fd: day, td: t1 }); } catch {}
|
||||
return { day, people: people(), announcement: chat.announcement(), events, chores: chores.board(day).filter(i => !i.user || onBoard(users.get(i.user) || {})).map(i => ({ chore: i.chore, title: i.title, points: i.points, user: i.user, userName: i.userName, done: !!i.done, doneBy: i.done ? i.done.byName : '', claimable: !!i.claimable, claimed: !!i.claimed })), week: (w => ({ from: w.from, tiers: w.tiers, people: w.people.filter(p => onBoard(users.get(p.id) || {})).map(p => ({ id: p.id, name: p.name, points: p.points, count: p.count, tiers: p.tiers, earned: p.earned, given: p.given })) }))(chores.week(day)), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() };
|
||||
return { day, people: people(), announcement: chat.announcement(), events, chores: chores.board(day).filter(i => !i.user || onBoard(users.get(i.user) || {})).map(i => ({ chore: i.chore, title: i.title, points: i.points, user: i.user, userName: i.userName, done: !!i.done, doneBy: i.done ? i.done.byName : '', claimable: !!i.claimable, claimed: !!i.claimed })), week: (w => ({ from: w.from, tiers: w.tiers, people: w.people.filter(p => onBoard(users.get(p.id) || {})).map(p => ({ id: p.id, name: p.name, points: p.points, count: p.count, tiers: p.tiers, earned: p.earned, given: p.given, goal: p.goal })) }))(chores.week(day)), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() };
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
|
||||
@@ -17,6 +17,19 @@ const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'applicat
|
||||
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 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');
|
||||
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');
|
||||
if (art && (art.length > 600 || !ART_OK.test(art) || art.includes('..'))) throw fail(400, 'That picture cannot be shared');
|
||||
return { kind: s.kind, title, sub: str(s.sub, 120).trim(), art, link };
|
||||
}
|
||||
const shareLine = s => SHARE[s.kind][0] + ' Shared ' + SHARE[s.kind][1] + ': ' + s.title;
|
||||
const idOk = s => /^[0-9a-f]{16}$/.test(s || '') || /^d:[0-9a-z]+:[0-9a-z]+$/.test(s || '');
|
||||
|
||||
// ---- storage (small enough to hold in memory and write whole)
|
||||
@@ -44,9 +57,10 @@ const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone';
|
||||
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chat'); };
|
||||
const people = () => users.all().filter(u => !u.disabled && users.can(u, 'chat')).map(u => ({ id: u.id, name: u.name }));
|
||||
const convOf = (id, me) => { const c = convs().find(x => x.id === id); if (!c || !c.members.includes(me.id)) throw fail(404, 'Conversation not found'); return c; };
|
||||
const textOf = m => [m.text, m.share && m.share.title, m.share && m.share.sub].filter(Boolean).join(' · ');
|
||||
const unreadIn = (c, uid, st) => { const r = st.read[c.id] || 0; return msgs(c.id).filter(m => m.ts > r && m.from !== uid && !m.deleted && !m.sys).length; };
|
||||
const totalUnread = uid => { const st = stateOf(uid); return convs().filter(c => c.members.includes(uid) && !st.muted.includes(c.id) && !st.archived.includes(c.id)).reduce((n, c) => n + unreadIn(c, uid, st), 0); };
|
||||
const preview = m => m ? (m.deleted ? 'This message was deleted' : m.sys ? str(m.text, 120) : m.poll ? '📊 ' + m.poll.q : m.att && m.att[0] && m.att[0].voice && !m.text ? '🎤 Voice message' : m.text ? str(m.text, 120) : m.att && m.att.length ? '📎 ' + (m.att[0].name || 'Attachment') : '') : '';
|
||||
const preview = m => m ? (m.deleted ? 'This message was deleted' : m.sys ? str(m.text, 120) : m.poll ? '📊 ' + m.poll.q : m.share ? SHARE[m.share.kind][0] + ' ' + m.share.title : m.att && m.att[0] && m.att[0].voice && !m.text ? '🎤 Voice message' : m.text ? str(m.text, 120) : m.att && m.att.length ? '📎 ' + (m.att[0].name || 'Attachment') : '') : '';
|
||||
|
||||
// ---- live updates (server-sent events), one stream per open app
|
||||
const annFile = () => path.join(ROOT(), 'announcement.json');
|
||||
@@ -60,7 +74,7 @@ const view = (c, uid, st) => { const last = msgs(c.id).slice(-1)[0], others = c.
|
||||
/** sent, delivered or read: for what you sent, from what the others have read and received (everyone, in a group). */
|
||||
const statusOf = (m, c, uid, sts) => { if (m.from !== uid || m.sys) return undefined; const o = c.members.filter(x => x !== uid); if (!o.length) return 'sent'; if (o.every(x => (sts[x].read[c.id] || 0) >= m.ts)) return 'read'; if (o.every(x => (sts[x].delivered[c.id] || 0) >= m.ts)) return 'delivered'; return 'sent'; };
|
||||
const stsOf = c => Object.fromEntries(c.members.map(x => [x, stateOf(x)]));
|
||||
const msgView = (m, uid, c, sts) => ({ id: m.id, ts: m.ts, from: m.from, name: m.sys ? '' : nameOf(m.from), out: m.from === uid, text: m.text, att: m.att, quote: m.quote, react: m.react, edited: m.edited, deleted: m.deleted, sys: m.sys, fwd: m.fwd, mentions: m.mentions, exp: m.exp, link: m.link, poll: m.poll ? { ...m.poll, votes: m.votes || {} } : undefined, ...(c && sts ? { status: statusOf(m, c, uid, sts) } : {}) });
|
||||
const msgView = (m, uid, c, sts) => ({ id: m.id, ts: m.ts, from: m.from, name: m.sys ? '' : nameOf(m.from), out: m.from === uid, text: m.text, att: m.att, quote: m.quote, react: m.react, edited: m.edited, deleted: m.deleted, sys: m.sys, fwd: m.fwd, mentions: m.mentions, exp: m.exp, link: m.link, poll: m.poll ? { ...m.poll, votes: m.votes || {} } : undefined, share: m.deleted ? undefined : m.share, ...(c && sts ? { status: statusOf(m, c, uid, sts) } : {}) });
|
||||
/** Someone has received or read the chat up to now: the senders' ticks change. */
|
||||
function markDelivered(uid, c, also) { const st = stateOf(uid), last = msgs(c.id).slice(-1)[0]; if (!last) return; let ch = false; if ((st.delivered[c.id] || 0) < last.ts) { st.delivered[c.id] = last.ts; ch = true; } if (also && (st.read[c.id] || 0) < last.ts) { st.read[c.id] = last.ts; ch = true; } if (ch) { saveState(uid, st); emit(c.members.filter(x => x !== uid), { t: 'receipt', conv: c.id }); } }
|
||||
const TTLS = [0, 3600, 86400, 604800], ttlWords = s => s === 3600 ? '1 hour' : s === 86400 ? '1 day' : s === 604800 ? '1 week' : 'off';
|
||||
@@ -78,15 +92,22 @@ const mentionsIn = (text, c) => c.type !== 'group' ? [] : c.members.filter(uid =
|
||||
function send(me, b) {
|
||||
const c = convOf(String(b.conv || ''), me), text = str(b.text, MAX_TEXT).trim(), ids = (Array.isArray(b.files) ? b.files : []).map(String).slice(0, MAX_FILES), att = [];
|
||||
for (const id of ids) { const f = files()[id]; if (!f || f.conv !== c.id || f.by !== me.id || f.attached) throw fail(400, 'An attachment is not available (upload it again)'); att.push({ id, name: f.name, type: f.type, size: f.size, ...(f.voice ? { voice: true, wave: f.wave, dur: f.dur } : {}) }); }
|
||||
if (!text && !att.length) throw fail(400, 'Write something or attach a file');
|
||||
let quote; if (b.quote) { const q = msgs(c.id).find(x => x.id === String(b.quote)); if (q) quote = { id: q.id, from: q.from, name: nameOf(q.from), text: q.deleted ? '' : str(q.text || (q.att && q.att[0] ? q.att[0].name : ''), 200) }; }
|
||||
return addMsg(me, c, { text, att, quote });
|
||||
const share = b.share ? cleanShare(b.share) : undefined;
|
||||
if (!text && !att.length && !share) throw fail(400, 'Write something or attach a file');
|
||||
let quote; if (b.quote) { const q = msgs(c.id).find(x => x.id === String(b.quote)); if (q) quote = { id: q.id, from: q.from, name: nameOf(q.from), text: q.deleted ? '' : str(q.text || (q.share ? q.share.title : q.att && q.att[0] ? q.att[0].name : ''), 200) }; }
|
||||
return addMsg(me, c, { text, att, quote, share });
|
||||
}
|
||||
/** This person has read a chat up to now (they opened it, or cleared its notification): their unread count, the senders' ticks and the Notifications list all follow. */
|
||||
function markRead(uid, c) {
|
||||
if (typeof c === 'string') c = convs().find(x => x.id === c && x.members.includes(uid)); if (!c) return;
|
||||
const st = stateOf(uid), last = msgs(c.id).slice(-1)[0]; st.read[c.id] = last ? last.ts : Date.now(); st.delivered[c.id] = Math.max(st.delivered[c.id] || 0, st.read[c.id]); saveState(uid, st);
|
||||
inbox.markTagRead(uid, 'chat:' + c.id); emit([uid], { t: 'read', conv: c.id, unread: totalUnread(uid) }); emit(c.members.filter(x => x !== uid), { t: 'receipt', conv: c.id });
|
||||
}
|
||||
/** Put a message in a chat and tell everyone: the live stream, and a phone notification for those who are away. */
|
||||
function addMsg(me, c, { text, att = [], quote, fwd, sys, poll }) {
|
||||
const list = msgs(c.id), last = list[list.length - 1], ts = Math.max(Date.now(), last ? last.ts + 1 : 0), m = { id: newId(), ts, from: sys ? null : me.id, text, att: att.length ? att : undefined, quote, fwd, sys: sys || undefined, poll, react: {}, mentions: sys ? undefined : (mentionsIn(text, c).length ? mentionsIn(text, c) : undefined), exp: c.ttl && !sys ? ts + c.ttl * 1000 : undefined };
|
||||
function addMsg(me, c, { text, att = [], quote, fwd, sys, poll, share }) {
|
||||
const list = msgs(c.id), last = list[list.length - 1], ts = Math.max(Date.now(), last ? last.ts + 1 : 0), m = { id: newId(), ts, from: sys ? null : me.id, text, att: att.length ? att : undefined, quote, fwd, sys: sys || undefined, poll, share, react: {}, mentions: sys ? undefined : (mentionsIn(text, c).length ? mentionsIn(text, c) : undefined), exp: c.ttl && !sys ? ts + c.ttl * 1000 : undefined };
|
||||
list.push(m); if (list.length > MAX_MSGS) list.splice(0, list.length - MAX_MSGS); att.forEach(a => { if (files()[a.id]) files()[a.id].attached = true; }); saveMsgs(c.id); if (att.length) saveFiles();
|
||||
const body = sys ? text : (c.type === 'group' ? me.name + ': ' : '') + (poll ? '📊 ' + poll.q : text ? str(text, 140) : att[0] && att[0].voice ? '🎤 Voice message' : '📎 ' + att[0].name), title = c.type === 'group' ? c.name : me.name;
|
||||
const body = sys ? text : (c.type === 'group' ? me.name + ': ' : '') + (poll ? '📊 ' + poll.q : share ? shareLine(share) : text ? str(text, 140) : att[0] && att[0].voice ? '🎤 Voice message' : '📎 ' + att[0].name), title = c.type === 'group' ? c.name : me.name;
|
||||
emit(c.members, { t: 'msg', conv: c.id, id: m.id }, uid => ({ t: 'msg', conv: c.id, id: m.id, out: uid === me.id, sys: !!sys, mention: !!(m.mentions && m.mentions.includes(uid)), name: title, preview: body, unread: totalUnread(uid) }));
|
||||
for (const uid of c.members) if (online(uid) && (sys || uid !== me.id)) markDelivered(uid, c); // (what an open app has received counts as delivered)
|
||||
if (!sys) for (const uid of c.members) if (uid !== me.id && live(uid) && (!stateOf(uid).muted.includes(c.id) || (m.mentions && m.mentions.includes(uid)))) inbox.add(uid, { kind: 'chat', title, body: (m.mentions && m.mentions.includes(uid) ? '@ ' : '') + body, url: '#/chat/' + encodeURIComponent(c.id), tag: 'chat:' + c.id });
|
||||
@@ -176,16 +197,16 @@ async function handle(req, res) {
|
||||
if ((p === '/edit' || p === '/delete') && req.method === 'POST') {
|
||||
const b = JSON.parse(await readBody(req, 2e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x) throw fail(404, 'Message not found'); if (x.from !== me.id) throw fail(403, 'You can only change your own messages'); if (x.deleted) throw fail(400, 'That message was deleted');
|
||||
if (p === '/edit') { const t = str(b.text, MAX_TEXT).trim(); if (!t) throw fail(400, 'Write something'); if (Date.now() - x.ts > 48 * 3600e3) throw fail(400, 'Messages can be edited for two days'); x.text = t; x.edited = true; }
|
||||
else { x.deleted = true; x.text = ''; for (const a of x.att || []) { fs.rm(blob(a.id), { force: true }, () => {}); delete files()[a.id]; } x.att = undefined; x.react = {}; saveFiles(); }
|
||||
else { x.deleted = true; x.text = ''; for (const a of x.att || []) { fs.rm(blob(a.id), { force: true }, () => {}); delete files()[a.id]; } x.att = undefined; x.share = undefined; x.react = {}; saveFiles(); }
|
||||
saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true });
|
||||
}
|
||||
if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), st = stateOf(me.id), last = msgs(c.id).slice(-1)[0]; st.read[c.id] = last ? last.ts : Date.now(); st.delivered[c.id] = Math.max(st.delivered[c.id] || 0, st.read[c.id]); saveState(me.id, st); emit([me.id], { t: 'read', conv: c.id, unread: totalUnread(me.id) }); emit(c.members.filter(x => x !== me.id), { t: 'receipt', conv: c.id }); return json(res, 200, { ok: true, unread: totalUnread(me.id) }); }
|
||||
if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me); markRead(me.id, c); return json(res, 200, { ok: true, unread: totalUnread(me.id) }); }
|
||||
if ((p === '/conv/mute' || p === '/conv/archive') && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), st = stateOf(me.id), k = p.endsWith('mute') ? 'muted' : 'archived'; st[k] = st[k].filter(x => x !== c.id); if (b.on) st[k].push(c.id); saveState(me.id, st); emit([me.id], { t: 'conv', conv: c.id, unread: totalUnread(me.id) }); return json(res, 200, { ok: true }); }
|
||||
if (p === '/announcement' && req.method === 'GET') return json(res, 200, read(annFile(), null));
|
||||
if (p === '/announcement' && req.method === 'POST') { // the family board on Home: an admin posts or clears it
|
||||
if (me.role !== 'admin') throw fail(403, 'Only an admin can change the announcement'); const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = str(b.text, 300).trim();
|
||||
if (!text) { fs.rmSync(annFile(), { force: true }); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, null); }
|
||||
const a = { id: newId(), text, by: me.name, at: Date.now() }; write(annFile(), a); for (const x of people()) if (x.id !== me.id) inbox.add(x.id, { kind: 'announce', title: 'Family announcement', body: text, url: '#/family' }); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, a);
|
||||
const a = { id: newId(), text, by: me.name, at: Date.now() }; write(annFile(), a); for (const x of people()) if (x.id !== me.id) inbox.add(x.id, { kind: 'announce', title: 'Family announcement', body: text, url: '#/family', ref: { announce: a.id } }); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, a);
|
||||
}
|
||||
if (p === '/prefs' && req.method === 'GET') return json(res, 200, { quiet: stateOf(me.id).quiet });
|
||||
if (p === '/prefs' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), st = stateOf(me.id), hm = /^([01]\d|2[0-3]):[0-5]\d$/; if (b.quiet) st.quiet = { on: !!b.quiet.on, from: hm.test(b.quiet.from) ? b.quiet.from : st.quiet.from, to: hm.test(b.quiet.to) ? b.quiet.to : st.quiet.to }; saveState(me.id, st); return json(res, 200, { quiet: st.quiet }); }
|
||||
@@ -213,11 +234,11 @@ async function handle(req, res) {
|
||||
if (p === '/forward' && req.method === 'POST') { // a copy of a message (and its files) into another chat
|
||||
const b = JSON.parse(await readBody(req, 1e4) || '{}'), from = convOf(String(b.from || ''), me), to = convOf(String(b.to || ''), me), x = msgs(from.id).find(y => y.id === String(b.msg)); if (!x || x.deleted || x.sys) throw fail(404, 'Message not found');
|
||||
const att = (x.att || []).map(a => { const id = newId(); try { fs.copyFileSync(blob(a.id), blob(id)); } catch { return null; } files()[id] = { conv: to.id, by: me.id, name: a.name, type: a.type, size: a.size, ts: Date.now(), attached: true }; return { id, name: a.name, type: a.type, size: a.size, ...(a.voice ? { voice: true, wave: a.wave, dur: a.dur } : {}) }; }).filter(Boolean);
|
||||
if (att.length) saveFiles(); return json(res, 200, { msg: addMsg(me, to, { text: x.text || '', att, fwd: { name: nameOf(x.from) } }) });
|
||||
if (att.length) saveFiles(); return json(res, 200, { msg: addMsg(me, to, { text: x.text || '', att, share: x.share, fwd: { name: nameOf(x.from) } }) });
|
||||
}
|
||||
if (p === '/search' && req.method === 'GET') {
|
||||
const needle = str(q.get('q'), 100).trim().toLowerCase(); if (needle.length < 2) return json(res, 200, { results: [] }); const out = [], st = stateOf(me.id);
|
||||
for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && x.text && x.text.toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); }
|
||||
for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && textOf(x).toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); }
|
||||
return json(res, 200, { results: out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 40) });
|
||||
}
|
||||
return json(res, 404, { error: 'Not found' });
|
||||
@@ -236,7 +257,7 @@ function askParents(me, text) {
|
||||
/** Messages in this person's chats that mention the words. */
|
||||
function searchFor(me, q) {
|
||||
const needle = q.toLowerCase(), out = [], st = stateOf(me.id);
|
||||
for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && x.text && x.text.toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); }
|
||||
for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && textOf(x).toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); }
|
||||
return out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 8);
|
||||
}
|
||||
module.exports = { announcement: () => read(annFile(), null), handle, totalUnread, purge, parsePreview, askParents, searchFor };
|
||||
module.exports = { cleanShare, markRead, announcement: () => read(annFile(), null), handle, totalUnread, purge, parsePreview, askParents, searchFor };
|
||||
@@ -18,7 +18,7 @@ const addDays = (s, n) => { const d = utc(s); d.setUTCDate(d.getUTCDate() + n);
|
||||
const weekday = s => utc(s).getUTCDay(); // 0 = Sunday
|
||||
const monday = s => addDays(s, -((weekday(s) + 6) % 7));
|
||||
let cache = null;
|
||||
const db = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return { chores: Array.isArray(j.chores) ? j.chores : [], log: Array.isArray(j.log) ? j.log : [], claims: Array.isArray(j.claims) ? j.claims : [], rewards: Array.isArray(j.rewards) ? j.rewards : [], given: Array.isArray(j.given) ? j.given : [] }; } catch { return { chores: [], log: [], claims: [], rewards: [], given: [] }; } })());
|
||||
const db = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return { chores: Array.isArray(j.chores) ? j.chores : [], log: Array.isArray(j.log) ? j.log : [], claims: Array.isArray(j.claims) ? j.claims : [], rewards: Array.isArray(j.rewards) ? j.rewards : [], given: Array.isArray(j.given) ? j.given : [], goals: j.goals && typeof j.goals === 'object' && !Array.isArray(j.goals) ? j.goals : {}, reminded: j.reminded && typeof j.reminded === 'object' && !Array.isArray(j.reminded) ? j.reminded : {} }; } catch { return { chores: [], log: [], claims: [], rewards: [], given: [], goals: {}, reminded: {} }; } })());
|
||||
const save = () => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify(db()), { mode: 0o600 }); };
|
||||
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chores'); };
|
||||
const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; };
|
||||
@@ -76,7 +76,7 @@ const forMe = (t, uid) => !Array.isArray(t.for) || t.for.includes(uid); // a rew
|
||||
function week(day) {
|
||||
const from = monday(day), to = addDays(from, 6), d = db(), tiers = d.rewards.slice().sort((a, b) => a.points - b.points), per = new Map(people().filter(p => p.kid).map(p => [p.id, { id: p.id, name: p.name, points: 0, count: 0, done: [] }]));
|
||||
for (const l of d.log) { if (l.date < from || l.date > to) continue; const e = per.get(l.by) || (liveKid(l.by) ? per.set(l.by, { id: l.by, name: nameOf(l.by), points: 0, count: 0, done: [] }).get(l.by) : null); if (!e) continue; const c = d.chores.find(x => x.id === l.chore); e.points += l.pts || 0; e.count++; e.done.push({ title: c ? c.title : 'A chore that was removed', date: l.date, pts: l.pts || 0 }); }
|
||||
const out = [...per.values()].map(e => ({ ...e, done: e.done.sort((a, b) => a.date.localeCompare(b.date)), tiers: tiers.filter(t => forMe(t, e.id)).map(t => t.id), earned: tiers.filter(t => forMe(t, e.id) && e.points >= t.points).map(t => t.id), given: d.given.filter(g => g.week === from && g.user === e.id).map(g => g.reward) })).sort((a, b) => b.points - a.points || b.count - a.count || a.name.localeCompare(b.name));
|
||||
const out = [...per.values()].map(e => ({ ...e, done: e.done.sort((a, b) => a.date.localeCompare(b.date)), tiers: tiers.filter(t => forMe(t, e.id)).map(t => t.id), earned: tiers.filter(t => forMe(t, e.id) && e.points >= t.points).map(t => t.id), given: d.given.filter(g => g.week === from && g.user === e.id).map(g => g.reward), goal: (g => tiers.some(t => t.id === g && forMe(t, e.id)) ? g : null)((d.goals[e.id] || {})[from]) })).sort((a, b) => b.points - a.points || b.count - a.count || a.name.localeCompare(b.name));
|
||||
return { from, to, tiers, people: out };
|
||||
}
|
||||
const cleanReward = (b, old) => { const title = str(b.title, 80); if (!title) throw fail(400, 'Say what the reward is'); const points = Math.round(+b.points); if (!(points >= 1 && points <= 1000)) throw fail(400, 'Choose the points needed (1 to 1000)'); const bonus = Math.max(0, Math.min(240, Math.round(+b.minutes) || 0)), r = { id: old ? old.id : newId(), points, title }; if (bonus) r.minutes = bonus; /* (optional: extra screen time that comes with the reward) */ const who = Array.isArray(b.for) ? [...new Set(b.for.map(String))].filter(liveKid).slice(0, 30) : null; if (who) { if (!who.length) throw fail(400, 'Choose who it is for (child accounts)'); r.for = who; } return r; };
|
||||
@@ -86,7 +86,7 @@ async function handle(req, res) {
|
||||
try {
|
||||
let m;
|
||||
const day = q.get('date') || ymd(new Date()); if (!ymdOk(day)) throw fail(400, 'Bad date');
|
||||
if (p === '/' && req.method === 'GET') return json(res, 200, { me: { id: me.id, admin }, day, rewards: db().rewards.slice().sort((a, b) => a.points - b.points), people: people(), chores: db().chores, board: board(day).map(i => ({ ...i, mine: i.user === me.id || i.user === null, canDo: admin || i.user === me.id || i.user === null })) });
|
||||
if (p === '/' && req.method === 'GET') return json(res, 200, { me: { id: me.id, admin, kid: isKid(me) }, day, rewards: db().rewards.slice().sort((a, b) => a.points - b.points), people: people(), chores: db().chores, board: board(day).map(i => ({ ...i, mine: i.user === me.id || i.user === null, canDo: admin || i.user === me.id || i.user === null })) });
|
||||
if (p === '/' && req.method === 'POST') {
|
||||
if (!admin) throw fail(403, 'Only an admin can add or change chores'); const b = JSON.parse(await readBody(req, 2e4) || '{}'), old = b.id ? db().chores.find(x => x.id === b.id) : null; if (b.id && !old) throw fail(404, 'Chore not found'); if (!old && db().chores.length >= MAX_CHORES) throw fail(400, 'That is plenty of chores');
|
||||
const c = clean(b, old); if (old) db().chores[db().chores.indexOf(old)] = c; else db().chores.push(c); save(); return json(res, 200, c);
|
||||
@@ -116,7 +116,7 @@ async function handle(req, res) {
|
||||
if (p === '/week' && req.method === 'GET') return json(res, 200, week(day));
|
||||
if (p === '/reward' && req.method === 'POST') { // make, change or remove a reward tier (admin)
|
||||
if (!admin) throw fail(403, 'Only an admin can change the rewards'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), d = db(), old = b.id ? d.rewards.find(x => x.id === b.id) : null;
|
||||
if (b.id && !old) throw fail(404, 'Reward not found'); if (b.remove) { d.rewards = d.rewards.filter(x => x !== old); d.given = d.given.filter(g => g.reward !== b.id); save(); return json(res, 200, { ok: true }); }
|
||||
if (b.id && !old) throw fail(404, 'Reward not found'); if (b.remove) { d.rewards = d.rewards.filter(x => x !== old); d.given = d.given.filter(g => g.reward !== b.id); for (const k of Object.keys(d.goals)) for (const w of Object.keys(d.goals[k])) if (d.goals[k][w] === b.id) delete d.goals[k][w]; save(); return json(res, 200, { ok: true }); }
|
||||
if (!old && d.rewards.length >= 12) throw fail(400, 'That is plenty of rewards'); const r = cleanReward(b, old); if (old) d.rewards[d.rewards.indexOf(old)] = r; else d.rewards.push(r); save(); return json(res, 200, r);
|
||||
}
|
||||
if (p === '/give' && req.method === 'POST') { // note that a reward was handed over (admin), or take it back
|
||||
@@ -124,11 +124,22 @@ async function handle(req, res) {
|
||||
if (b.undo) { if (i >= 0) d.given.splice(i, 1); save(); return json(res, 200, { ok: true }); }
|
||||
const w = week(wk), e = w.people.find(x => x.id === String(b.user)); if (!e || !e.earned.includes(String(b.reward))) throw fail(400, 'They have not earned that one'); if (i < 0) { d.given.push({ week: wk, user: e.id, reward: String(b.reward), at: Date.now(), by: me.id }); const rw = d.rewards.find(x => x.id === String(b.reward)); if (rw && rw.minutes) { try { require('./kidlimits').give(e.id, rw.minutes); } catch {} } } /* (a reward with bonus screen time adds it to today) */ if (d.given.length > 3000) d.given.splice(0, d.given.length - 3000); save(); return json(res, 200, { ok: true });
|
||||
}
|
||||
if (p === '/goal' && req.method === 'POST') { // a child chooses the reward they are aiming for (an admin can choose for a child); no reward clears it
|
||||
const b = JSON.parse(await readBody(req, 2e3) || '{}'), d = db(), uid = admin && b.user ? String(b.user) : me.id;
|
||||
if (!liveKid(uid)) throw fail(400, 'Goals are for child accounts');
|
||||
const near = ymdOk(b.date) && Math.abs(Date.parse(b.date + 'T12:00:00Z') - Date.now()) < 3 * 864e5, wk = monday(near ? b.date : ymd(new Date())); // (this week, by the child's own calendar; a goal is for one week and starts empty each Monday)
|
||||
if (!b.reward) { if (d.goals[uid]) delete d.goals[uid][wk]; save(); return json(res, 200, { ok: true, goal: null }); }
|
||||
const t = d.rewards.find(x => x.id === String(b.reward)); if (!t || !forMe(t, uid)) throw fail(400, 'That reward is not available'); const mine = d.goals[uid] = d.goals[uid] || {}; mine[wk] = t.id; for (const w of Object.keys(mine).sort().slice(0, -12)) delete mine[w]; save(); try { require('./inbox').markTagRead(uid, 'goal:' + wk); } catch {} return json(res, 200, { ok: true, goal: t.id });
|
||||
}
|
||||
if (p === '/summary' && req.method === 'GET') return json(res, 200, summary(q.get('range') === 'month' ? 'month' : 'week', day));
|
||||
return json(res, 404, { error: 'Not found' });
|
||||
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
|
||||
}
|
||||
|
||||
/** The Monday of this week, when a child with rewards to choose from still has no goal for it and has not been reminded yet (otherwise null). */
|
||||
function goalNeeded(uid, day) { const d = db(), wk = monday(day); if (!liveKid(uid) || d.reminded[uid] === wk || (d.goals[uid] || {})[wk]) return null; return d.rewards.some(t => forMe(t, uid)) ? wk : null; }
|
||||
function goalReminded(uid, wk) { db().reminded[uid] = wk; save(); }
|
||||
|
||||
/** Chores whose name or notes mention the words. */
|
||||
const find = q => db().chores.filter(c => (c.title + ' ' + c.notes).toLowerCase().includes(q)).map(c => ({ id: c.id, title: c.title, repeat: c.repeat, who: c.assign === 'anyone' ? 'Anyone' : assignees(c).map(nameOf).join(', '), points: c.points }));
|
||||
module.exports = { handle, dueKey, board, summary, week, ymd, addDays, find };
|
||||
module.exports = { handle, dueKey, board, summary, week, ymd, addDays, find, goalNeeded, goalReminded };
|
||||
@@ -23,6 +23,7 @@ services:
|
||||
MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-}
|
||||
LYRICS_API_URL: http://127.0.0.1:9999 # the bundled lyrics service (Lyrica, below, shares this container's network); remove this line and the service to skip it
|
||||
TRANSCRIBE_API_URL: http://127.0.0.1:8000 # the bundled speech-to-text service (whisper, below): makes a transcript for a podcast or audiobook on request. Remove this line and the service to skip it
|
||||
TRANSCRIBE_MODEL: ${TRANSCRIBE_MODEL:-Systran/faster-whisper-base} # smaller and about 3x faster than -small on a CPU-only host; set Systran/faster-whisper-small for better accuracy
|
||||
SIGNAL_API_URL: http://127.0.0.1:8085 # the bundled Signal bridge (below, shares this container's network); remove this line and the service to skip Signal
|
||||
OPENAUDIBLE_URL: ${OPENAUDIBLE_URL:-} # audiobooks straight from OpenAudible (e.g. http://172.16.16.106:8000); the username and password in Settings -> Audiobooks sign-in are OpenAudible's. Leave empty to go through the audiobook player instead
|
||||
PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/}
|
||||
|
||||
@@ -12,22 +12,34 @@ const load = uid => { try { const j = JSON.parse(fs.readFileSync(file(uid), 'utf
|
||||
const save = (uid, items) => { try { fs.mkdirSync(path.dirname(file(uid)), { recursive: true }); atomicWrite(file(uid), JSON.stringify({ items }), { mode: 0o600 }); } catch (e) { console.warn('[inbox]', e.message); } };
|
||||
|
||||
/** Add something for a person. With a `tag`, a still-unread entry with the same tag is updated (and counted) instead of added again. */
|
||||
function add(uid, { kind = 'info', title, body = '', url = '', tag = '' }) {
|
||||
const mergeRef = (a, b) => !a || !b ? b || a : { ...a, ...b, ...(a.mail && b.mail ? { mail: [...a.mail, ...b.mail].slice(-50) } : {}) };
|
||||
function add(uid, { kind = 'info', title, body = '', url = '', tag = '', ref }) {
|
||||
if (!users.get(uid)) return; const items = load(uid), now = Date.now();
|
||||
const old = tag ? items.find(i => i.tag === tag && !i.read) : null;
|
||||
if (old) { old.count = (old.count || 1) + 1; old.title = str(title, 100); old.body = str(body, 200); old.at = now; items.sort((a, b) => b.at - a.at); }
|
||||
else items.unshift({ id: crypto.randomBytes(6).toString('hex'), kind, title: str(title, 100), body: str(body, 200), url: str(url, 200), tag: str(tag, 80), at: now, read: false, count: 1 });
|
||||
if (old) { old.count = (old.count || 1) + 1; old.title = str(title, 100); old.body = str(body, 200); old.at = now; if (ref) old.ref = mergeRef(old.ref, ref); items.sort((a, b) => b.at - a.at); }
|
||||
else items.unshift({ id: crypto.randomBytes(6).toString('hex'), kind, title: str(title, 100), body: str(body, 200), url: str(url, 200), tag: str(tag, 80), ref: ref || undefined, at: now, read: false, count: 1 });
|
||||
save(uid, items.slice(0, MAX));
|
||||
}
|
||||
const unread = uid => load(uid).filter(i => !i.read).length;
|
||||
/** The thing itself was dealt with somewhere else (a chat opened, say): its notification is done too. */
|
||||
function markTagRead(uid, tag) { const items = load(uid); let ch = false; for (const i of items) if (i.tag === tag && !i.read) { i.read = true; ch = true; } if (ch) save(uid, items); }
|
||||
/** Reading or clearing a notification also settles what it was about: a chat is marked read, new mail is marked read. (Reminders, requests and the like stay as they are: they still need doing or answering.) */
|
||||
async function settle(uid, items) {
|
||||
const jobs = [];
|
||||
for (const i of items) {
|
||||
const m = /^chat:(.+)$/.exec(i.tag || ''); if (m) { try { require('./chat').markRead(uid, m[1]); } catch (e) { console.warn('[inbox]', e.message); } }
|
||||
if (i.ref && Array.isArray(i.ref.mail) && i.ref.mail.length) jobs.push(require('./mail').markRead(i.ref.mail).catch(() => {}));
|
||||
}
|
||||
await Promise.all(jobs);
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
const p = new URL(req.url, 'http://x').pathname.slice('/api/inbox'.length) || '/', me = users.current();
|
||||
try {
|
||||
if (p === '/' && req.method === 'GET') { const items = load(me.id); return json(res, 200, { items, unread: items.filter(i => !i.read).length }); }
|
||||
if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 5e3) || '{}'), items = load(me.id); for (const i of items) if (b.all || i.id === b.id) i.read = true; save(me.id, items); return json(res, 200, { unread: items.filter(i => !i.read).length }); }
|
||||
if (p === '/clear' && req.method === 'POST') { save(me.id, []); return json(res, 200, { unread: 0 }); }
|
||||
if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 5e3) || '{}'), items = load(me.id), hit = items.filter(i => !i.read && (b.all || i.id === b.id)); for (const i of items) if (b.all || i.id === b.id) i.read = true; save(me.id, items); await settle(me.id, hit); return json(res, 200, { unread: unread(me.id) }); }
|
||||
if (p === '/clear' && req.method === 'POST') { const hit = load(me.id).filter(i => !i.read); save(me.id, []); await settle(me.id, hit); return json(res, 200, { unread: 0 }); }
|
||||
return json(res, 404, { error: 'Not found' });
|
||||
} catch (e) { return json(res, e instanceof SyntaxError ? 400 : 500, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
|
||||
}
|
||||
module.exports = { handle, add, unread, load };
|
||||
module.exports = { handle, add, unread, load, markTagRead };
|
||||
@@ -332,11 +332,15 @@ async function handle(req, res) {
|
||||
/** The newest messages in each connected account's Inbox (for the push alerts). One account that fails does not stop the others. */
|
||||
async function newest() {
|
||||
const out = [];
|
||||
for (const a of accounts()) { try { const r = await withTimeout(providerOf(a).list(a, { folder: 'inbox', skip: 0, q: '' }), 15000); for (const m of r.items.slice(0, 10)) out.push({ id: a.id + ':' + m.id, subject: m.subject, from: (m.from && (m.from.name || m.from.address)) || '', read: !!m.read }); } catch { /* skipped this round */ } }
|
||||
for (const a of accounts()) { try { const r = await withTimeout(providerOf(a).list(a, { folder: 'inbox', skip: 0, q: '' }), 15000); for (const m of r.items.slice(0, 10)) out.push({ acct: a.id, mid: m.id, id: a.id + ':' + m.id, subject: m.subject, from: (m.from && (m.from.name || m.from.address)) || '', read: !!m.read }); } catch { /* skipped this round */ } }
|
||||
return out;
|
||||
}
|
||||
/** Mark messages read: what a cleared "new mail" notification stands for. refs: [{ a: account id, id: message id }]. */
|
||||
async function markRead(refs) {
|
||||
for (const r of (Array.isArray(refs) ? refs : []).slice(0, 50)) { try { const a = findAccount(r.a); await providerOf(a).update(a, r.id, { read: true }); dropFolderCache(a.id); } catch { /* gone or unreachable: nothing to mark */ } }
|
||||
}
|
||||
/** For the admin health page: can each connected account be reached right now? */
|
||||
async function health() {
|
||||
return Promise.all(accounts().map(async a => { const t = Date.now(); try { await withTimeout(providerOf(a).unread(a), 8000); return { address: a.address, type: a.type, ok: true, ms: Date.now() - t }; } catch (e) { return { address: a.address, type: a.type, ok: false, error: e.message }; } }));
|
||||
}
|
||||
module.exports = { health, newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
|
||||
module.exports = { markRead, health, newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
|
||||
@@ -92,15 +92,17 @@ async function viewBooks(args = []) {
|
||||
const byId = new Map(abBooks.map(b => [abId(b), b]));
|
||||
v.onclick = e => { const c = e.target.closest('button[data-id]'); if (c) abSheet(byId.get(c.dataset.id)); };
|
||||
lvBind(v, () => route());
|
||||
if (args[0] === 'book' && args[1]) { const bid = args[1], b0 = /^lv:/.test(bid) ? null : byId.get(bid); if (/^lv:/.test(bid)) lvSheet(bid.slice(3)); else if (b0) abSheet(b0); else toast('That book is not in your library'); } // (a link from a Chat share card)
|
||||
}
|
||||
|
||||
function abSheet(b) {
|
||||
const id = abId(b), p = abProg[id], left = p && p.dur ? p.dur - p.pos : 0, resumable = p && p.pos > 10 && p.dur && p.pos < p.dur - 30;
|
||||
const el = openSheet(abTitle(b), [abAuthor(b), abSeries(b)].filter(Boolean).join(' · '), `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1"><img alt="" src="${abCover(b)}" onerror="this.remove()"></span>
|
||||
<div style="min-width:0"><p class="sub" style="margin:0">${resumable ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(left) + ' left') : p && p.dur && p.pos >= p.dur - 30 ? 'Finished' : 'Not started'}</p></div></div>
|
||||
<div class="bar"><button class="btn" id="abp">${icon('play')}${resumable ? 'Resume' : 'Play'}</button>${resumable ? '<button class="btn ghost" id="abs">From the start</button>' : ''}<button class="btn ghost" id="abc">Chapters</button></div><div class="err" id="abe"></div><div id="abch" class="list" style="max-height:40vh;overflow:auto"></div>`);
|
||||
<div class="bar"><button class="btn" id="abp">${icon('play')}${resumable ? 'Resume' : 'Play'}</button>${resumable ? '<button class="btn ghost" id="abs">From the start</button>' : ''}<button class="btn ghost" id="abc">Chapters</button>${allowed('chat') ? `<button class="btn ghost" id="abh">${icon('send')}Share</button>` : ''}</div><div class="err" id="abe"></div><div id="abch" class="list" style="max-height:40vh;overflow:auto"></div>`);
|
||||
const go = async at => { try { await abPlay(b, at); closeSheet(); } catch (e) { el.querySelector('#abe').textContent = e.message; } };
|
||||
el.querySelector('#abp').onclick = () => go();
|
||||
if (el.querySelector('#abh')) el.querySelector('#abh').onclick = () => { closeSheet(); shareMedia({ kind: 'audiobook', title: abTitle(b), sub: abAuthor(b), art: abCover(b), link: '#/audio/book/' + encodeURIComponent(id) }); };
|
||||
if (el.querySelector('#abs')) el.querySelector('#abs').onclick = () => go(0);
|
||||
el.querySelector('#abc').onclick = async () => {
|
||||
const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty">Loading…</p>';
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ function clock() { const d = new Date(); $('#clock').textContent = d.toLocaleTim
|
||||
const card = (t, body, add, cls) => `<section class="card ${cls || ''}"><h2 style="display:flex;justify-content:space-between;align-items:center">${t}${add ? `<button class="pbtn" data-add="${add}" aria-label="Add">+</button>` : ''}</h2>${body}</section>`;
|
||||
function weekCard(w) {
|
||||
if (!w || !w.people.length) return none('No points yet');
|
||||
return w.people.map(p => { const mine = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...mine.map(t => t.points), p.points), next = mine.find(t => t.points > p.points), got = mine.filter(t => p.earned.includes(t.id)); return `<div style="margin-top:8px"><div style="display:flex;justify-content:space-between"><b>${esc(p.name)}</b><small class="sub">${p.points} pts · ${p.count} done</small></div><div class="bar2" style="position:relative">${mine.map(t => `<span style="position:absolute;top:0;left:${Math.round(t.points / top * 100)}%;width:2px;height:100%;background:var(--text);opacity:.5"></span>`).join('')}<i style="width:${Math.round(p.points / top * 100)}%"></i></div><small>${got.length ? '🎁 ' + esc(got.map(t => t.title).join(', ')) : ''}${next ? (got.length ? ' · ' : '') + (next.points - p.points) + ' more for ' + esc(next.title) : ''}</small></div>`; }).join('');
|
||||
return w.people.map(p => { const mine = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...mine.map(t => t.points), p.points), next = mine.find(t => t.points > p.points), goal = mine.find(t => t.id === p.goal), got = mine.filter(t => p.earned.includes(t.id)); return `<div style="margin-top:8px"><div style="display:flex;justify-content:space-between"><b>${esc(p.name)}</b><small class="sub">${p.points} pts · ${p.count} done</small></div><div class="bar2" style="position:relative">${mine.map(t => `<span style="position:absolute;top:0;left:${Math.round(t.points / top * 100)}%;width:2px;height:100%;background:var(--text);opacity:.5"></span>`).join('')}<i style="width:${Math.round(p.points / top * 100)}%"></i></div><small>${got.length ? '🎁 ' + esc(got.map(t => t.title).join(', ')) : ''}${goal ? (got.length ? ' · ' : '') + (p.points >= goal.points ? '🎯 ' + esc(goal.title) + ' reached!' : '🎯 ' + (goal.points - p.points) + ' more for ' + esc(goal.title)) : next ? (got.length ? ' · ' : '') + (next.points - p.points) + ' more for ' + esc(next.title) : ''}</small></div>`; }).join('');
|
||||
}
|
||||
const none = t => `<p class="none">${t}</p>`;
|
||||
|
||||
|
||||
+31
-7
@@ -17,6 +17,10 @@ async function chStart() {
|
||||
ch.es = new EventSource('/api/chat/events');
|
||||
ch.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } chEvent(ev); };
|
||||
}
|
||||
// (iOS keeps a suspended app's connection open for minutes, and the server skips the phone push for anyone it thinks is looking: close the stream whenever the app is hidden)
|
||||
const chHide = () => { if (ch.es) { ch.es.close(); ch.es = null; } };
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) chHide(); else chStart(); });
|
||||
window.addEventListener('pagehide', chHide);
|
||||
let chEvT = null;
|
||||
function chEvent(ev) {
|
||||
if (typeof ev.unread === 'number' && ev.unread !== ch.unread) { ch.unread = ev.unread; renderNav(activeTab); }
|
||||
@@ -58,7 +62,7 @@ function chDrawList() {
|
||||
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('') : ''}` : '')
|
||||
+ (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)}</small></span><span class="sgside"><small>${sgTime(f.msg.ts)}</small></span></a>`).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>`;
|
||||
const a = box.querySelector('#charch'); if (a) a.onclick = () => { ch.showArchived = !ch.showArchived; chDrawList(); };
|
||||
@@ -74,6 +78,16 @@ function chPoll(m) {
|
||||
${P.options.map(o => { const n = count(o.id); return `<button type="button" class="sgopt ${mine.includes(o.id) ? 'on' : ''}" data-o="${esc(o.id)}" ${P.closed ? 'disabled' : ''}><i style="width:${total ? Math.round(n / total * 100) : 0}%"></i><span>${mine.includes(o.id) ? '✓ ' : ''}${esc(o.text)}</span><small>${n}</small></button>`; }).join('')}
|
||||
<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'] };
|
||||
/** 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); };
|
||||
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>`;
|
||||
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) {
|
||||
const att = (m.att || []).map(a => { const u = '/api/chat/file/' + a.id;
|
||||
return /^image\//.test(a.type || '') ? `<a href="${u}" target="_blank" rel="noopener"><img class="sgimg" loading="lazy" alt="${esc(a.name || 'photo')}" src="${u}"></a>`
|
||||
@@ -85,7 +99,7 @@ function chBubble(m, c, prev) {
|
||||
if (m.sys) return `${day}<div class="sgday sgsys">${esc(m.text)}</div>`;
|
||||
const tick = m.out && m.status ? `<span class="${m.status === 'read' ? 'sgrd' : ''}">${m.status === 'sent' ? '✓' : '✓✓'}</span>` : '';
|
||||
return `${day}<div class="sgb ${m.out ? 'out' : 'in'}" data-id="${esc(m.id)}">${m.fwd ? `<small class="sgfwd">↪ Forwarded from ${esc(m.fwd.name)}</small>` : ''}${c.type === 'group' && !m.out ? `<small class="who" style="color:${sgColor(m.from)}">${esc(m.name)}</small>` : ''}
|
||||
${m.poll ? chPoll(m) : ''}${m.quote ? `<div class="sgq"><b>${esc(m.quote.name)}</b><span>${esc(m.quote.text || 'Message')}</span></div>` : ''}${att}
|
||||
${m.poll ? chPoll(m) : ''}${m.share && !m.deleted ? chShareCard(m) : ''}${m.quote ? `<div class="sgq"><b>${esc(m.quote.name)}</b><span>${esc(m.quote.text || 'Message')}</span></div>` : ''}${att}
|
||||
${m.deleted ? '<i class="sgdel">This message was deleted</i>' : m.text ? `<div class="sgt">${chMention(sgLink(esc(m.text)).replace(/\n/g, '<br>'))}</div>` : ''}
|
||||
${m.link && !m.deleted ? `<a class="sgprev" href="${esc(m.link.url)}" target="_blank" rel="noopener noreferrer">${m.link.image ? `<img alt="" loading="lazy" src="${esc(stOk(m.link.image))}" onerror="this.remove()">` : ''}<span><small>${esc(m.link.site || new URL(m.link.url).hostname)}</small><b>${esc(m.link.title)}</b>${m.link.desc ? `<em>${esc(m.link.desc)}</em>` : ''}</span></a>` : ''}
|
||||
<span class="sgmeta">${m.exp ? '⏱ ' : ''}${m.edited ? 'edited · ' : ''}${new Date(m.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}${tick ? ' ' + tick : ''}</span>
|
||||
@@ -135,7 +149,7 @@ function chMessageMenu(m, c) {
|
||||
}
|
||||
function chDrawReply() {
|
||||
const b = chq('#chrep'); if (!b) return; if (!ch.reply) { b.hidden = true; return; }
|
||||
b.hidden = false; b.innerHTML = `<span class="grow"><b>${esc(ch.reply.out ? 'You' : ch.reply.name)}</b><small>${esc(ch.reply.text || 'Attachment')}</small></span><button class="ibtn" id="chrx" aria-label="Cancel reply">${icon('x')}</button>`;
|
||||
b.hidden = false; b.innerHTML = `<span class="grow"><b>${esc(ch.reply.out ? 'You' : ch.reply.name)}</b><small>${esc(ch.reply.text || (ch.reply.share && ch.reply.share.title) || 'Attachment')}</small></span><button class="ibtn" id="chrx" aria-label="Cancel reply">${icon('x')}</button>`;
|
||||
chq('#chrx').onclick = () => { ch.reply = null; chDrawReply(); };
|
||||
}
|
||||
function chDrawTray() {
|
||||
@@ -216,8 +230,8 @@ function chPaintSub() {
|
||||
}
|
||||
function chDrawPin() {
|
||||
const b = chq('#chpin'); if (!b) return; if (!ch.pins.length) { b.hidden = true; return; }
|
||||
const last = ch.pins[ch.pins.length - 1]; b.hidden = false; b.innerHTML = `<span>📌</span><span class="grow"><small>${ch.pins.length > 1 ? ch.pins.length + ' pinned messages' : 'Pinned message'}</small><b>${esc(last.text || (last.att && last.att[0] ? last.att[0].name : 'Attachment'))}</b></span>`;
|
||||
b.onclick = () => { const el = openSheet('Pinned', '', `<div class="list">${ch.pins.slice().reverse().map(m => `<div class="row"><button class="rmain" data-go="${esc(m.id)}"><span class="grow"><b>${esc(m.name)}</b><small style="white-space:normal">${esc(m.text || 'Attachment')}</small></span></button><button class="ibtn" data-un="${esc(m.id)}" aria-label="Unpin">${icon('x')}</button></div>`).join('')}</div>`);
|
||||
const last = ch.pins[ch.pins.length - 1]; b.hidden = false; b.innerHTML = `<span>📌</span><span class="grow"><small>${ch.pins.length > 1 ? ch.pins.length + ' pinned messages' : 'Pinned message'}</small><b>${esc(last.text || (last.share && last.share.title) || (last.att && last.att[0] ? last.att[0].name : 'Attachment'))}</b></span>`;
|
||||
b.onclick = () => { const el = openSheet('Pinned', '', `<div class="list">${ch.pins.slice().reverse().map(m => `<div class="row"><button class="rmain" data-go="${esc(m.id)}"><span class="grow"><b>${esc(m.name)}</b><small style="white-space:normal">${esc(m.text || (m.share && m.share.title) || 'Attachment')}</small></span></button><button class="ibtn" data-un="${esc(m.id)}" aria-label="Unpin">${icon('x')}</button></div>`).join('')}</div>`);
|
||||
el.querySelectorAll('[data-go]').forEach(x => x.onclick = () => { closeSheet(); const t = chq(`.sgb[data-id="${CSS.escape(x.dataset.go)}"]`); if (t) { t.scrollIntoView({ block: 'center' }); t.classList.add('flash'); setTimeout(() => t.classList.remove('flash'), 1400); } });
|
||||
el.querySelectorAll('[data-un]').forEach(x => x.onclick = async () => { closeSheet(); try { await chPost('/pin', { conv: ch.cur, msg: x.dataset.un, on: false }); } catch (e) { toast(e.message); } }); };
|
||||
}
|
||||
@@ -321,6 +335,15 @@ async function shareToChat(text) {
|
||||
const el = openSheet('Share to Chat', text.length > 90 ? text.slice(0, 90) + '…' : text, `<div class="list" style="max-height:50dvh;overflow:auto">${convs.map(c => `<button class="row" data-t="${esc(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)}</b></span></button>`).join('') || '<p class="empty">Start a chat first.</p>'}</div>`);
|
||||
el.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { closeSheet(); try { await chPost('/send', { conv: b.dataset.t, text }); toast('Shared', { label: 'Open', run: () => { location.hash = '#/chat/' + encodeURIComponent(b.dataset.t); } }); } catch (e) { toast(e.message); } });
|
||||
}
|
||||
/** From a song, film, book...: pick a chat, add a note if you like, and a share card goes in. card = { kind, title, sub, art, link } (art and link are paths inside the hub). */
|
||||
async function shareMedia(card) {
|
||||
if (!allowed('chat')) return toast('Chat is not turned on for you');
|
||||
let convs = []; try { convs = (await chApi('/convs')).convs.filter(c => !c.archived); } catch (e) { return toast(e.message); }
|
||||
const k = CH_SHARE[card.kind] || ['🔗', 'Shared'];
|
||||
const el = openSheet('Share to Chat', k[0] + ' ' + card.title, `<label for="chsn">Add a note (optional)</label><input type="text" id="chsn" maxlength="300" autocomplete="off" placeholder="You will love this">
|
||||
<div class="list" style="max-height:44dvh;overflow:auto;margin-top:10px">${convs.map(c => `<button class="row" data-t="${esc(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)}</b></span>${icon('send')}</button>`).join('') || '<p class="empty">Start a chat first (Chat, then New).</p>'}</div>`);
|
||||
el.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { const text = el.querySelector('#chsn').value, to = b.dataset.t; closeSheet(); try { await chPost('/send', { conv: to, text, share: { kind: card.kind, title: card.title, sub: card.sub || '', art: /^\/(plex|api)\/[^\s"'<>\\]*$/.test(card.art || '') && !card.art.includes('..') ? card.art : '', link: card.link } }); toast('Shared', { label: 'Open', run: () => { location.hash = '#/chat/' + encodeURIComponent(to); } }); } catch (e) { toast(e.message); } });
|
||||
}
|
||||
// ---- settings: quiet hours, and the family announcement (admin)
|
||||
async function chSettings() {
|
||||
let pr, an = null; try { [pr, an] = await Promise.all([chApi('/prefs'), chApi('/announcement')]); } catch (e) { return toast(e.message); }
|
||||
@@ -345,10 +368,11 @@ async function chAnnounceCard(el) {
|
||||
function chFit() {
|
||||
const m = chMounted() && ch.root.querySelector('.sgm'); if (!m || m.offsetParent === null) return;
|
||||
if (window.innerWidth >= 1024) { m.style.height = ''; return; }
|
||||
let bottom = window.visualViewport ? window.visualViewport.height : window.innerHeight;
|
||||
let bottom = window.visualViewport ? window.visualViewport.offsetTop + window.visualViewport.height : window.innerHeight; // (page coordinates: the visible area can be panned down while the keyboard is up)
|
||||
const top0 = m.getBoundingClientRect().top; // (look up from the screen's edge for the first spot that is page content, not the bottom bar or mini player)
|
||||
for (let y = Math.floor(bottom) - 2; y > top0 + 130; y -= 4) { const hit = document.elementsFromPoint(innerWidth / 2, y).find(e => !e.closest('.sgm')); const covers = hit && !hit.closest('#view'); if (!covers) { bottom = y + 4; break; } }
|
||||
const top = m.getBoundingClientRect().top; m.style.height = Math.max(260, Math.floor(bottom - top - 10)) + 'px';
|
||||
const top = m.getBoundingClientRect().top; m.style.height = Math.max(document.body.classList.contains('kb') ? 150 : 260, Math.floor(bottom - top - 10)) + 'px';
|
||||
}
|
||||
window.addEventListener('kbfit', () => { chFit(); if (!chMounted() || !chq('.sgm')) return; const v = document.getElementById('view'); if (v) v.scrollTop = 0; const l = chq('#chmsgs'); if (l && document.activeElement && document.activeElement.id === 'chtext') l.scrollTop = l.scrollHeight; }); // (the keyboard came or went: refit, and keep the newest message in sight)
|
||||
window.addEventListener('resize', chFit); if (window.visualViewport) window.visualViewport.addEventListener('resize', chFit);
|
||||
setInterval(chFit, 700);
|
||||
+18
-3
@@ -13,12 +13,25 @@ const crMonday = s => crShift(s, -((new Date(s + 'T12:00:00').getDay() + 6) % 7)
|
||||
const crWeekLabel = w => { const f = new Date(w.from + 'T12:00:00'), t = new Date(w.to + 'T12:00:00'), o = { month: 'short', day: 'numeric' }; return (w.from === crMonday(crYmd(new Date())) ? 'This week · ' : '') + f.toLocaleDateString(undefined, o) + ' to ' + t.toLocaleDateString(undefined, o); };
|
||||
/** One person's week: points, how far to the next reward, the rewards reached (an admin marks each as handed over), and the chores they did. */
|
||||
function crWeekPerson(p) {
|
||||
const w = cr.week, admin = cr.data.me.admin, tiers = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...tiers.map(t => t.points), p.points), next = tiers.find(t => t.points > p.points);
|
||||
return `<div class="crwk"><div class="crrank" style="border:0;padding-bottom:4px"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b><div class="statbar" style="position:relative"><i style="width:${Math.round(p.points / top * 100)}%"></i>${tiers.map(t => `<span class="crtick" style="left:${Math.round(t.points / top * 100)}%" title="${esc(t.points + ' pts: ' + t.title)}"></span>`).join('')}</div></span><small>${p.points} pts · ${p.count} done</small></div>
|
||||
${next ? `<small class="sub" style="display:block;margin:0 0 4px 52px">${next.points - p.points} more for ${esc(next.title)}</small>` : tiers.length ? '<small class="sub" style="display:block;margin:0 0 4px 52px">Every reward reached 🎉</small>' : ''}
|
||||
const w = cr.week, admin = cr.data.me.admin, tiers = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...tiers.map(t => t.points), p.points), next = tiers.find(t => t.points > p.points), goal = tiers.find(t => t.id === p.goal), canPick = tiers.length && (p.id === cr.data.me.id || admin);
|
||||
return `<div class="crwk"><div class="crrank" style="border:0;padding-bottom:4px"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b><div class="statbar" style="position:relative"><i style="width:${Math.round(p.points / top * 100)}%"></i>${tiers.map(t => `<span class="crtick${t.id === p.goal ? ' goal' : ''}" style="left:${Math.round(t.points / top * 100)}%" title="${esc(t.points + ' pts: ' + t.title)}"></span>`).join('')}</div></span><small>${p.points} pts · ${p.count} done</small></div>
|
||||
${goal ? `<small class="sub" style="display:block;margin:0 0 4px 52px">🎯 ${p.points >= goal.points ? esc(goal.title) + ': reached! 🎉' : (goal.points - p.points) + ' more for ' + esc(goal.title)}</small>` : next ? `<small class="sub" style="display:block;margin:0 0 4px 52px">${next.points - p.points} more for ${esc(next.title)}</small>` : tiers.length ? '<small class="sub" style="display:block;margin:0 0 4px 52px">Every reward reached 🎉</small>' : ''}
|
||||
${canPick ? `<button type="button" class="btn ghost crgoalbtn" data-goal="${esc(p.id)}">${goal ? 'Change goal' : 'Pick a goal'}</button>` : ''}
|
||||
${p.earned.map(id => { const t = w.tiers.find(x => x.id === id), given = p.given.includes(id); return `<div class="crearn"><span>🎁</span><span class="grow"><b>${esc(t.title)}</b><small>${given ? 'Given' : 'Earned, not given yet'}</small></span>${admin ? `<button class="btn ${given ? 'ghost' : ''}" data-give="${esc(p.id)}|${esc(id)}" ${given ? 'data-undo="1"' : ''}>${given ? 'Undo' : 'Mark given'}</button>` : ''}</div>`; }).join('')}
|
||||
${p.done.length ? `<details class="crdet"><summary>Chores done (${p.done.length})</summary>${p.done.map(c => `<div class="crrank" style="border:0;padding:3px 0"><span class="grow">${esc(c.title)}<small style="display:block">${esc(new Date(c.date + 'T12:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }))}</small></span><small>${c.pts ? c.pts + ' pts' : ''}</small></div>`).join('')}</details>` : ''}</div>`;
|
||||
}
|
||||
/** A child's own "aiming for" card at the top of Chores: the reward they chose and how far they have to go this week. */
|
||||
function crGoalCard() {
|
||||
const me = cr.data.me.id, p = cr.week.people.find(x => x.id === me); if (!p || !p.tiers.length) return '';
|
||||
const t = cr.week.tiers.find(x => x.id === p.goal), now = cr.week.from === crMonday(crYmd(new Date()));
|
||||
return `<div class="crgoal"><span class="crgi">🎯</span><span class="grow"><small>Aiming for</small><b>${t ? esc(t.title) : 'Pick a reward to aim for'}</b>${t && now ? `<small>${p.points >= t.points ? 'You did it! 🎉' : (t.points - p.points) + ' more points this week'}</small>` : ''}</span><button type="button" class="btn ${t ? 'ghost' : ''}" id="crgoal">${t ? 'Change' : 'Pick'}</button></div>`;
|
||||
}
|
||||
function crPickGoal(uid) {
|
||||
const p = cr.week.people.find(x => x.id === uid); if (!p) return; const tiers = cr.week.tiers.filter(t => p.tiers.includes(t.id));
|
||||
const el = openSheet('What are you aiming for?', uid === cr.data.me.id ? 'Pick a reward for this week. Chores shows how far you have to go. You pick again each Monday.' : 'Choose a goal for ' + p.name, `<div class="list">${tiers.map(t => `<button class="row" data-g="${esc(t.id)}"><span class="ic" style="background:var(--accent)">🎁</span><span class="grow"><b>${esc(t.title)}</b><small>${t.points} points in a week${t.minutes ? ' · +' + t.minutes + ' min screen time' : ''}</small></span>${p.goal === t.id ? icon('check') : ''}</button>`).join('')}</div>${p.goal ? '<div class="bar"><button class="btn ghost" id="crgno" style="width:100%;justify-content:center">No goal for now</button></div>' : ''}`);
|
||||
const set = async r => { try { await crPost('/goal', { reward: r, user: uid, date: crYmd(new Date()) }); if (typeof inboxSettled === 'function') inboxSettled([{ tag: 'goal:' + crMonday(crYmd(new Date())) }]); closeSheet(); viewChores(); } catch (e) { toast(e.message); } };
|
||||
el.querySelectorAll('[data-g]').forEach(b => b.onclick = () => set(b.dataset.g)); const n = el.querySelector('#crgno'); if (n) n.onclick = () => set('');
|
||||
}
|
||||
/** After a change in the manage sheets: redraw the Chores page, or (when opened from Settings) reopen the manager. */
|
||||
const crRefresh = () => location.hash.startsWith('#/chores') ? viewChores() : (cr.afterEdit && cr.afterEdit());
|
||||
async function viewChores() {
|
||||
@@ -31,6 +44,7 @@ async function viewChores() {
|
||||
const person = p => { const l = d.board.filter(i => i.user === p.id), done = l.filter(i => i.done).length; return `<div class="crperson"><div class="crph"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><b>${esc(p.name)}</b><small>${l.length ? done + ' of ' + l.length : 'nothing today'}</small></div>${l.length ? `<div class="crbar"><i style="width:${Math.round(done / l.length * 100)}%"></i></div>` : ''}<div>${l.map(item).join('')}</div></div>`; };
|
||||
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Chores</h1><span class="pspace grow"></span>${d.me.admin ? `<button class="btn ghost" id="crman">${icon('cog')}Manage</button>` : ''}</div>
|
||||
<div class="bar" style="justify-content:flex-start;margin:0 0 8px"><button class="ibtn" id="crprev" aria-label="Previous day">${icon('back')}</button><b style="min-width:150px;text-align:center">${esc(crDayLabel(cr.day))}</b><button class="ibtn" id="crnext" aria-label="Next day" style="transform:scaleX(-1)">${icon('back')}</button>${cr.day !== crYmd(new Date()) ? '<button class="btn ghost" id="crtoday">Today</button>' : ''}</div>
|
||||
${crGoalCard()}
|
||||
<h2>Mine</h2><div class="crcard">${mine.map(item).join('') || '<p class="empty" style="margin:8px 0">Nothing for you ' + (cr.day === crYmd(new Date()) ? 'today' : 'on this day') + '. 🎉</p>'}</div>
|
||||
${anyone.length ? `<h2>Anyone can do</h2><div class="crcard">${anyone.map(item).join('')}</div>` : ''}
|
||||
${others.length ? `<h2>The family</h2><div class="crgrid">${others.map(person).join('')}</div>` : ''}
|
||||
@@ -43,6 +57,7 @@ async function viewChores() {
|
||||
$('#crprev').onclick = () => { cr.day = crShift(cr.day, -1); viewChores(); }; $('#crnext').onclick = () => { cr.day = crShift(cr.day, 1); viewChores(); }; if ($('#crtoday')) $('#crtoday').onclick = () => { cr.day = crYmd(new Date()); viewChores(); };
|
||||
$('#wkprev').onclick = () => { cr.wk = crShift(cr.week.from, -7); viewChores(); }; $('#wknext').onclick = () => { cr.wk = crShift(cr.week.from, 7); viewChores(); }; if ($('#wktoday')) $('#wktoday').onclick = () => { cr.wk = crYmd(new Date()); viewChores(); };
|
||||
v.querySelectorAll('[data-give]').forEach(b => b.onclick = async () => { const [u, r] = b.dataset.give.split('|'); try { await crPost('/give', { week: cr.week.from, user: u, reward: r, undo: !!b.dataset.undo }); viewChores(); } catch (e) { toast(e.message); } });
|
||||
if ($('#crgoal')) $('#crgoal').onclick = () => crPickGoal(cr.data.me.id); v.querySelectorAll('.crgoalbtn').forEach(b => b.onclick = () => crPickGoal(b.dataset.goal));
|
||||
if ($('#crman')) $('#crman').onclick = crManage;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,21 @@ function viewFamily() {
|
||||
const done = new Set(cards.map(c => c[0])), run = (k, f) => { if (done.has(k)) f().catch(e => fmErr({ cal: 'fmcal', chores: 'fmch', shop: 'fmsh', notes: 'fmnt', chat: 'fmct', tasks: 'fmtk' }[k], e)); };
|
||||
run('cal', fmEvents); run('chores', fmChores); run('shop', fmShopping); run('notes', fmNotes); run('chat', fmChat); run('tasks', fmTasks);
|
||||
}
|
||||
// ---- Keep the page fresh while it stays open (a tablet on the counter): every minute, and when the app comes back to the front. It waits while a sheet is open or someone is typing an item.
|
||||
const fmMounted = () => !!document.querySelector('#view .fmwrap');
|
||||
function fmRefresh() {
|
||||
if (!fmMounted() || document.hidden) return;
|
||||
const sheet = document.getElementById('sheet'); if (sheet && sheet.classList.contains('on')) return;
|
||||
const a = document.activeElement, typing = document.getElementById('fmshi'); if ((a && a.closest && a.closest('.fmwrap') && /^(INPUT|TEXTAREA)$/.test(a.tagName) && a.type !== 'checkbox') || (typing && typing.value)) return;
|
||||
const h = new Date().getHours(), head = document.querySelector('.fmwrap .homehead');
|
||||
if (head) { const t = head.querySelector('h1'), d = head.querySelector('.sub'), greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening'; if (t) t.textContent = greet + (ME ? ', ' + ME.name.split(' ')[0] : ''); if (d) d.textContent = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }); }
|
||||
if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc'));
|
||||
if (typeof inboxCount === 'function') inboxCount();
|
||||
for (const [id, load] of [['fmcal', fmEvents], ['fmch', fmChores], ['fmsh', fmShopping], ['fmnt', fmNotes], ['fmct', fmChat], ['fmtk', fmTasks]]) if (document.getElementById(id)) load().catch(() => {}); // (a failed refresh keeps what is already shown)
|
||||
}
|
||||
setInterval(fmRefresh, 60000);
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) fmRefresh(); });
|
||||
|
||||
async function fmEvents() {
|
||||
const d0 = new Date(), t0 = crYmd(d0), t1 = crShift(t0, 1), from = new Date(t0 + 'T00:00:00').getTime(), to = new Date(t1 + 'T23:59:59').getTime();
|
||||
const r = await jfetch('/api/cal/events?' + new URLSearchParams({ from, to, fd: t0, td: t1 })), b = fmBody('fmcal'); if (!b) return;
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
// ---- Notifications: a bell with the count of things you have not seen yet (chat, reminders, announcements); tapping it opens the list.
|
||||
let inboxN = 0;
|
||||
const inboxBell = () => `<button class="btn ghost inboxbell" id="inboxbtn" aria-label="Notifications" title="Notifications">${icon('bell')}<i class="nbadge" id="inboxn" ${inboxN ? '' : 'hidden'}>${inboxN > 99 ? '99+' : inboxN}</i></button>`;
|
||||
function inboxWire() { const b = document.getElementById('inboxbtn'); if (b) b.onclick = inboxOpen; inboxPaint(); inboxCount(); }
|
||||
function inboxWire() { const b = document.getElementById('inboxbtn'); if (b) b.onclick = inboxOpen; inboxPaint(); inboxCount(); if (typeof pushResync === 'function') pushResync(); }
|
||||
function inboxPaint() { const n = document.getElementById('inboxn'); if (n) { n.hidden = !inboxN; n.textContent = inboxN > 99 ? '99+' : inboxN; } }
|
||||
/** What a read or cleared notification stands for, on this device: its banner on the lock screen goes, and an announcement stops showing on Home. (The server marks the chats and mail read.) */
|
||||
async function inboxSettled(items, all) {
|
||||
for (const i of items) if (i.ref && i.ref.announce) { try { localStorage.setItem('ch_ann_gone:' + (typeof ME !== 'undefined' && ME ? ME.id : ''), i.ref.announce); } catch {} const a = document.getElementById('annc'); if (a) a.innerHTML = ''; }
|
||||
try { const reg = await navigator.serviceWorker.ready, tags = items.map(i => i.tag).filter(Boolean); for (const n of await reg.getNotifications()) if (all || tags.includes(n.tag)) n.close(); } catch {}
|
||||
}
|
||||
async function inboxCount() { try { const r = await jfetch('/api/inbox'); inboxN = r.unread; inboxPaint(); } catch {} }
|
||||
setInterval(() => { if (!document.hidden && typeof ME !== 'undefined' && ME && document.getElementById('inboxbtn')) inboxCount(); }, 45000);
|
||||
const inboxAgo = t => { const m = Math.round((Date.now() - t) / 6e4); return m < 1 ? 'now' : m < 60 ? m + ' min ago' : m < 1440 ? Math.round(m / 60) + ' h ago' : new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); };
|
||||
@@ -14,11 +19,11 @@ async function inboxOpen() {
|
||||
const draw = () => {
|
||||
const l = el.querySelector('#inboxl'); if (!l) return; el.querySelector('#inboxbar').hidden = !items.length;
|
||||
l.innerHTML = items.length ? `<div class="list">${items.map(i => { const [ic, col] = INBOX_ICON[i.kind] || ['bell', '#6a7280']; return `<a class="row" href="${esc(i.url || '#/home')}" data-id="${esc(i.id)}" style="${i.read ? 'opacity:.62' : ''}"><span class="ic" style="background:${col}">${icon(ic)}</span><span class="grow"><b>${esc(i.title)}${i.count > 1 ? ` <small>(${i.count})</small>` : ''}</b><small style="white-space:normal">${esc(i.body)}</small></span><small>${inboxAgo(i.at)}</small>${i.read ? '' : '<i class="nbadge" style="position:static;min-width:10px;height:10px;padding:0"></i>'}</a>`; }).join('')}</div>` : '<p class="empty">Nothing yet. Messages, reminders and announcements will show up here.</p>';
|
||||
l.querySelectorAll('a.row').forEach(a => a.addEventListener('click', () => { const id = a.dataset.id; jfetch('/api/inbox/read', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }).then(r => { inboxN = r.unread; inboxPaint(); }).catch(() => {}); closeSheet(); }));
|
||||
l.querySelectorAll('a.row').forEach(a => a.addEventListener('click', () => { const id = a.dataset.id; inboxSettled(items.filter(i => i.id === id)); jfetch('/api/inbox/read', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }).then(r => { inboxN = r.unread; inboxPaint(); }).catch(() => {}); closeSheet(); }));
|
||||
};
|
||||
try { const r = await jfetch('/api/inbox'); items = r.items; inboxN = r.unread; inboxPaint(); } catch (e) { toast(e.message); }
|
||||
draw();
|
||||
const post = (p, b) => jfetch('/api/inbox/' + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) });
|
||||
el.querySelector('#inboxall').onclick = async () => { try { const r = await post('read', { all: true }); items.forEach(i => i.read = true); inboxN = r.unread; inboxPaint(); draw(); } catch (e) { toast(e.message); } };
|
||||
el.querySelector('#inboxclr').onclick = async () => { try { await post('clear'); items = []; inboxN = 0; inboxPaint(); draw(); } catch (e) { toast(e.message); } };
|
||||
el.querySelector('#inboxall').onclick = async () => { try { inboxSettled(items.filter(i => !i.read), true); const r = await post('read', { all: true }); items.forEach(i => i.read = true); inboxN = r.unread; inboxPaint(); draw(); } catch (e) { toast(e.message); } };
|
||||
el.querySelector('#inboxclr').onclick = async () => { try { inboxSettled(items.filter(i => !i.read), true); await post('clear'); items = []; inboxN = 0; inboxPaint(); draw(); } catch (e) { toast(e.message); } };
|
||||
}
|
||||
@@ -85,9 +85,10 @@ async function lvSheet(id, after, known = new Map()) {
|
||||
el.querySelector('.empty').outerHTML = `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(b.title)} 55% 40%),hsl(${abHue(b.title) + 40} 55% 28%))"><img alt="" referrerpolicy="no-referrer" src="${esc(b.cover || '')}" onerror="this.remove()"></span>
|
||||
<div style="min-width:0"><p class="sub" style="margin:0">${esc([b.language, lvDur(total), b.chapters.length + ' chapters'].filter(Boolean).join(' · '))}</p><p class="sub" style="margin:4px 0 0">${p ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(p.dur - p.pos) + ' left') : 'Not started'}</p></div></div>
|
||||
${b.description ? `<p style="margin:10px 0 0;white-space:normal;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden">${esc(b.description)}</p>` : ''}
|
||||
<div class="bar"><button class="btn" id="lvp">${icon('play')}${p ? 'Resume' : 'Play'}</button>${p ? '<button class="btn ghost" id="lvs">From the start</button>' : ''}<button class="btn ghost" id="lvc">Chapters</button>${allowed('files') ? '<button class="btn ghost" id="lvf">Save to Files</button>' : ''}<button class="btn ghost" id="lvk">${onShelf() ? 'Remove from shelf' : 'Save to shelf'}</button></div><div class="list" id="lvch" style="max-height:40vh;overflow:auto"></div>`;
|
||||
<div class="bar"><button class="btn" id="lvp">${icon('play')}${p ? 'Resume' : 'Play'}</button>${p ? '<button class="btn ghost" id="lvs">From the start</button>' : ''}<button class="btn ghost" id="lvc">Chapters</button>${allowed('files') ? '<button class="btn ghost" id="lvf">Save to Files</button>' : ''}<button class="btn ghost" id="lvk">${onShelf() ? 'Remove from shelf' : 'Save to shelf'}</button>${allowed('chat') ? `<button class="btn ghost" id="lvh">${icon('send')}Share</button>` : ''}</div><div class="list" id="lvch" style="max-height:40vh;overflow:auto"></div>`;
|
||||
const go = at => { if (!b.chapters.length) return toast('This book has no audio'); lvPlay(b, at); closeSheet(); if (!onShelf()) lvShelf.push({ id, title: b.title, author: b.author, secs: b.secs, cover: b.cover, added: Date.now() }); if (after) after(); };
|
||||
el.querySelector('#lvp').onclick = () => go();
|
||||
if (el.querySelector('#lvh')) el.querySelector('#lvh').onclick = () => { closeSheet(); shareMedia({ kind: 'audiobook', title: b.title, sub: b.author || '', link: '#/audio/book/lv:' + id }); }; // (no picture: LibriVox covers live on another site)
|
||||
if (el.querySelector('#lvs')) el.querySelector('#lvs').onclick = () => go(0);
|
||||
if (el.querySelector('#lvf')) el.querySelector('#lvf').onclick = () => { closeSheet(); saveToFiles(b.title, { kind: 'librivox-book', id }); };
|
||||
el.querySelector('#lvc').onclick = () => { const offs = lvOffsets(b); el.querySelector('#lvch').innerHTML = b.chapters.map((c, i) => `<button class="row" data-i="${i}"><span class="grow"><b>${esc(c.title)}</b><small>${esc([abFmt(c.secs), c.reader].filter(Boolean).join(' · '))}</small></span></button>`).join(''); el.querySelectorAll('#lvch [data-i]').forEach(x => x.onclick = () => go(offs[x.dataset.i])); };
|
||||
|
||||
@@ -81,7 +81,7 @@ function lyrOpen() {
|
||||
const t = cur(); if (!t || lyr.open) return;
|
||||
lyr.open = true; document.body.classList.add('lyropen');
|
||||
const el = lyr.el = document.createElement('div'); el.id = 'lyr'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Lyrics');
|
||||
document.body.appendChild(el); lyrShell(); lyrReload();
|
||||
document.body.appendChild(el); lyrShell(); lyrReload(); swipeToDismiss(el, lyrClose, { handle: '.lyrtop', always: true });
|
||||
addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick);
|
||||
}
|
||||
function lyrClose() {
|
||||
|
||||
+5
-2
@@ -155,8 +155,9 @@ async function musicRoute([kind = 'featured', key, key2], st) {
|
||||
if (kind === 'artist') {
|
||||
const [mc, am] = await Promise.all([px.get(`/library/metadata/${key}/children`), px.get(`/library/metadata/${key}`).catch(() => ({}))]), about = ((am.Metadata || [])[0] || {}).summary || '';
|
||||
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/artists">${icon('back')}Artists</a></div><h1>${esc(mc.title2 || mc.parentTitle || 'Artist')}</h1>
|
||||
${about ? `<details class="mmabout"><summary>About</summary><p>${esc(about).replace(/\n/g, '<br>')}</p></details>` : ''}<div class="bar" style="justify-content:flex-start"><a class="btn ghost" href="#/music/mix/similar/${esc(key)}">${icon('shuffle')}More like this</a></div>
|
||||
${about ? `<details class="mmabout"><summary>About</summary><p>${esc(about).replace(/\n/g, '<br>')}</p></details>` : ''}<div class="bar" style="justify-content:flex-start"><a class="btn ghost" href="#/music/mix/similar/${esc(key)}">${icon('shuffle')}More like this</a>${allowed('chat') ? `<button class="btn ghost" id="ash">${icon('send')}Share</button>` : ''}</div>
|
||||
<h2>Albums</h2><div class="agrid">${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}</div></div>`;
|
||||
if ($('#ash')) $('#ash').onclick = () => shareMedia({ kind: 'artist', title: mc.title2 || mc.parentTitle || 'Artist', art: px.art((am.Metadata || [])[0] && am.Metadata[0].thumb, 200), link: '#/music/artist/' + key });
|
||||
return;
|
||||
}
|
||||
if (kind === 'album' || kind === 'playlist') {
|
||||
@@ -171,7 +172,7 @@ async function musicRoute([kind = 'featured', key, key2], st) {
|
||||
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/${album ? 'albums' : 'playlists'}">${icon('back')}${album ? 'Albums' : 'Playlists'}</a></div>
|
||||
<div class="mhead"><img class="art" alt="" src="${px.art(thumb, 400)}"><div><h1>${esc(title)}</h1><p class="sub">${esc(sub)}</p></div></div>
|
||||
${info.summary ? `<details class="mmabout"><summary>About</summary><p>${esc(info.summary).replace(/\n/g, '<br>')}</p></details>` : ''}
|
||||
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${album ? '<button class="btn ghost" id="pof">Save offline</button>' : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
|
||||
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${album ? '<button class="btn ghost" id="pof">Save offline</button>' : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
|
||||
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span>
|
||||
<span class="grow"><b>${esc(t.title)}</b>${album ? '' : `<small>${esc(t.artist)}</small>`}</span><small>${fmtDur(t.dur)}</small></button>
|
||||
<button class="ibtn" data-fv="${esc(t.key)}" data-fi="${i}" aria-label="Favorite">${icon('heart')}</button><button class="ibtn" data-m="${i}" aria-label="Track actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
|
||||
@@ -182,6 +183,8 @@ async function musicRoute([kind = 'featured', key, key2], st) {
|
||||
$('#prn').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await plx.rename(key, n); route(); } catch (e) { toast(e.message); } }, title);
|
||||
$('#pdel').onclick = () => confirmSheet('Delete playlist?', title, 'Delete', async () => { try { await plx.del(key); toast('Playlist deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } });
|
||||
}
|
||||
if ($('#psh')) $('#psh').onclick = () => shareMedia({ kind: 'album', title, sub, art: px.art(thumb, 200), link: '#/music/album/' + key });
|
||||
if ($('#psh')) $('#psh').onclick = () => shareMedia({ kind: 'album', title, sub, art: px.art(thumb, 200), link: '#/music/album/' + key });
|
||||
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
|
||||
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
|
||||
markPlaying();
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ function trackSheet(t, extra) {
|
||||
...(typeof mmPlex === 'function' && mmPlex(t.key) ? [
|
||||
{ label: mfIs(t) ? 'Remove from Favorites' : 'Add to Favorites', icon: 'heart', run: () => mfToggle(t) },
|
||||
{ label: 'Add to a Family playlist…', icon: 'plus', run: () => addToFamily([t]) },
|
||||
...(allowed('chat') && t.albumKey ? [{ label: 'Share to Chat', icon: 'send', run: () => shareToChat('♪ ' + t.title + ' — ' + t.artist + ' ' + location.origin + '/#/music/album/' + t.albumKey) }] : []),
|
||||
...(allowed('chat') && t.albumKey ? [{ label: 'Share to Chat', icon: 'send', run: () => shareMedia({ kind: 'song', title: t.title, sub: t.artist, art: px.art(t.thumb, 200), link: '#/music/album/' + t.albumKey }) }] : []),
|
||||
...(t.artistKey ? [{ label: 'More like this', icon: 'shuffle', run: () => { location.hash = '#/music/mix/similar/' + t.artistKey; } }] : []),
|
||||
] : []),
|
||||
...(extra || []),
|
||||
|
||||
@@ -114,7 +114,7 @@ async function pdShow(id) {
|
||||
const items = (Array.isArray(items0) ? items0 : []).sort((a, b) => pdTime(pdF(b, 'pubDate')) - pdTime(pdF(a, 'pubDate')));
|
||||
const paused = !!pdF(show, 'isPaused'), ep = e => `<div class="row pdep" data-id="${esc(pdId(e))}"><button class="ic" data-play style="background:var(--accent);color:#fff;border:0" aria-label="Play">${icon('play')}</button>
|
||||
<span class="grow"><b${pdF(e, 'isPlayed') ? ' style="color:var(--muted)"' : ''}>${esc(pdF(e, 'title'))}</b><small>${esc([pdDate(pdF(e, 'pubDate')), pdDur(pdF(e, 'duration')), pdF(e, 'isPlayed') ? 'played' : pdLeft(e) ? pdDur(pdLeft(e).dur - pdLeft(e).pos) + ' left' : ''].filter(Boolean).join(' · '))}</small></span>
|
||||
<button class="ibtn" data-seen aria-label="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" title="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" style="${pdF(e, 'isPlayed') ? 'color:var(--accent)' : ''}">${icon('check')}</button><button class="ibtn" data-off aria-label="Save for offline" title="Save for offline">${icon('dl')}</button></div>`;
|
||||
${allowed('chat') ? `<button class="ibtn" data-share aria-label="Share to Chat" title="Share to Chat">${icon('send')}</button>` : ''}<button class="ibtn" data-seen aria-label="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" title="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" style="${pdF(e, 'isPlayed') ? 'color:var(--accent)' : ''}">${icon('check')}</button><button class="ibtn" data-off aria-label="Save for offline" title="Save for offline">${icon('dl')}</button></div>`;
|
||||
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast">${icon('back')}Back</a></div>
|
||||
<div style="display:flex;gap:16px;align-items:flex-start"><span class="vimg" style="width:120px;flex:none;aspect-ratio:1/1"><img alt="" src="${pdImg('podcasts', show)}" onerror="this.remove()"></span>
|
||||
<div style="min-width:0"><h1 style="margin:0">${esc(pdF(show, 'title'))}</h1><p class="sub" style="margin:2px 0 0">${esc(pdF(show, 'author') || '')}</p>
|
||||
@@ -131,6 +131,7 @@ async function pdShow(id) {
|
||||
document.querySelectorAll('.pdep').forEach(r => {
|
||||
const e = byId.get(r.dataset.id);
|
||||
r.querySelector('[data-play]').onclick = () => playFrom(e);
|
||||
const shb = r.querySelector('[data-share]'); if (shb) shb.onclick = () => shareMedia({ kind: 'podcastep', title: pdF(e, 'title'), sub: pdF(show, 'title'), art: pdImg('podcasts', show), link: '#/podcast/show/' + encodeURIComponent(id) });
|
||||
const ob = r.querySelector('[data-off]'), paint = async () => { const on = await offHas(pdId(e)); ob.style.color = on ? 'var(--accent)' : ''; ob.title = ob.ariaLabel = on ? 'Saved on this device (tap to remove)' : 'Save for offline'; return on; };
|
||||
paint(); ob.onclick = async () => { const id = pdId(e); if (await offHas(id)) offRemove(id, paint); else offSave(id, paint); };
|
||||
r.querySelector('[data-seen]').onclick = async () => {
|
||||
@@ -139,9 +140,10 @@ async function pdShow(id) {
|
||||
});
|
||||
if ($('#pdlatest')) $('#pdlatest').onclick = () => playFrom(items[0]);
|
||||
$('#pdmore').onclick = () => {
|
||||
const el = openSheet(pdF(show, 'title'), '', `<div class="list"><button class="row" id="pda"><span class="grow"><b>Download all episodes</b><small>Podgrab keeps a copy of each one</small></span></button>
|
||||
const el = openSheet(pdF(show, 'title'), '', `<div class="list">${allowed('chat') ? `<button class="row" id="pdsh"><span class="grow"><b>Share to Chat</b></span></button>` : ''}<button class="row" id="pda"><span class="grow"><b>Download all episodes</b><small>Podgrab keeps a copy of each one</small></span></button>
|
||||
<button class="row" id="pdp"><span class="grow"><b>${paused ? 'Resume checking for new episodes' : 'Pause checking for new episodes'}</b></span></button>
|
||||
<button class="row" id="pdd"><span class="grow"><b style="color:var(--danger)">Remove from my podcasts</b><small>Only from your list; it is deleted from Podgrab when nobody else follows it</small></span></button></div>`);
|
||||
const sh = el.querySelector('#pdsh'); if (sh) sh.onclick = () => { closeSheet(); shareMedia({ kind: 'podcast', title: pdF(show, 'title'), sub: pdF(show, 'author') || '', art: pdImg('podcasts', show), link: '#/podcast/show/' + encodeURIComponent(id) }); };
|
||||
const act = (sel, fn, msg) => el.querySelector(sel).onclick = async () => { try { await fn(); closeSheet(); toast(msg); route(); } catch (e) { toast(e.message); } };
|
||||
act('#pda', () => pdApi('/podcasts/' + encodeURIComponent(id) + '/download'), 'Downloading in the background');
|
||||
act('#pdp', () => pdApi('/podcasts/' + encodeURIComponent(id) + (paused ? '/unpause' : '/pause')), paused ? 'Resumed' : 'Paused');
|
||||
|
||||
@@ -3,6 +3,17 @@ const remSupported = () => 'serviceWorker' in navigator && 'PushManager' in wind
|
||||
const b64ToBytes = s => { const p = '='.repeat((4 - s.length % 4) % 4), r = atob((s + p).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from([...r].map(c => c.charCodeAt(0))); };
|
||||
const myTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
|
||||
/** Each time the app opens: if this device has a push subscription, tell the server again (it forgets a device when the push service says it is gone, and the time zone can change). */
|
||||
let pushResynced = false;
|
||||
async function pushResync() {
|
||||
if (pushResynced) return; pushResynced = true;
|
||||
try {
|
||||
if (!remSupported() || Notification.permission !== 'granted') return;
|
||||
const reg = await Promise.race([navigator.serviceWorker.ready, new Promise(ok => setTimeout(ok, 4000))]), s = reg && await reg.pushManager.getSubscription(); if (!s) return;
|
||||
await jfetch('/api/push/subscribe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ subscription: s.toJSON(), tz: myTz() }) });
|
||||
} catch { /* try again next time the app opens */ }
|
||||
}
|
||||
|
||||
function remindersHtml() {
|
||||
return `<div id="sec-rem"><h2>Reminders</h2><p class="sub">Get a notification for tasks due today and events about to start, even when the app is closed. Turn it on for each device you want reminders on.</p>
|
||||
<div class="list" id="remlist"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></div>`;
|
||||
|
||||
+27
-2
@@ -5,13 +5,38 @@ function openSheet(title, sub, bodyHtml) {
|
||||
const el = document.createElement('div');
|
||||
el.id = 'sheet';
|
||||
el.innerHTML = `<div class="scrim"></div><div class="sheet" role="dialog" aria-modal="true" aria-label="${esc(title)}">
|
||||
<div class="grip"></div><h3>${esc(title)}</h3>${sub ? `<p class="sub">${esc(sub)}</p>` : ''}${bodyHtml}</div>`;
|
||||
<button type="button" class="ibtn sheetx" aria-label="Close">${icon('x')}</button><div class="grip"></div><h3>${esc(title)}</h3>${sub ? `<p class="sub">${esc(sub)}</p>` : ''}${bodyHtml}</div>`;
|
||||
document.body.appendChild(el);
|
||||
el.querySelector('.scrim').onclick = closeSheet;
|
||||
el.querySelector('.scrim').onclick = dismissSheet; el.querySelector('.sheetx').onclick = dismissSheet;
|
||||
swipeToDismiss(el.querySelector('.sheet'), () => closeSheet(), { handle: '.grip, h3, .sheetx', scrim: el.querySelector('.scrim') });
|
||||
requestAnimationFrame(() => el.classList.add('on'));
|
||||
return el;
|
||||
}
|
||||
function closeSheet() { document.getElementById('sheet')?.remove(); }
|
||||
/** Close with the slide-down (the close button, a tap outside); closeSheet() is the instant one other code calls before opening something else. */
|
||||
function dismissSheet() { const s = document.getElementById('sheet'); if (!s || s._closing) return; s._closing = true; s.classList.remove('on'); setTimeout(() => { if (s.isConnected) s.remove(); }, 230); }
|
||||
/** Swipe down to dismiss: `el` follows the finger and `close` runs when it is let go far enough or fast enough. Touches starting in `handle` always count; elsewhere only when nothing under the finger is scrolled down, and never on sliders or text fields. On a phone-sized screen only, unless `always` (full-screen views). */
|
||||
function swipeToDismiss(el, close, { handle = '', scrim = null, always = false } = {}) {
|
||||
let y0 = 0, x0 = 0, t0 = 0, on = false, dead = true;
|
||||
const reset = () => { el.style.transition = 'transform .2s ease'; el.style.transform = ''; if (scrim) { scrim.style.transition = 'opacity .2s'; scrim.style.opacity = ''; } setTimeout(() => { el.style.transition = ''; if (scrim) scrim.style.transition = ''; }, 220); };
|
||||
el.addEventListener('touchstart', e => {
|
||||
on = false; dead = true; if (e.touches.length !== 1 || (!always && !matchMedia('(max-width: 767px)').matches)) return;
|
||||
const t = e.target; if (t.closest && t.closest('input, textarea, select, [contenteditable], [data-nodrag]')) return;
|
||||
if (!(handle && t.closest && t.closest(handle))) for (let n = t; n; n = n.parentElement) { if (n.scrollTop > 0) return; if (n === el) break; }
|
||||
y0 = e.touches[0].clientY; x0 = e.touches[0].clientX; t0 = Date.now(); dead = false;
|
||||
}, { passive: true });
|
||||
el.addEventListener('touchmove', e => {
|
||||
if (dead) return; const dy = e.touches[0].clientY - y0, dx = e.touches[0].clientX - x0;
|
||||
if (!on) { if (dy > 10 && dy > Math.abs(dx) * 1.5) { on = true; el.style.transition = 'none'; if (scrim) scrim.style.transition = 'none'; } else if (Math.abs(dx) > 10 || dy < -6) { dead = true; return; } else return; }
|
||||
if (e.cancelable) e.preventDefault();
|
||||
const d = Math.max(0, dy); el.style.transform = 'translateY(' + d + 'px)'; if (scrim) scrim.style.opacity = String(Math.max(0, 1 - d / 420));
|
||||
}, { passive: false });
|
||||
const end = e => {
|
||||
if (!on) return; on = false; dead = true; const dy = (e.changedTouches && e.changedTouches[0] ? e.changedTouches[0].clientY : y0) - y0, fast = dy / Math.max(1, Date.now() - t0) > 0.6;
|
||||
if (e.type !== 'touchcancel' && (dy > 110 || (fast && dy > 30))) { el.style.transition = 'transform .18s ease'; el.style.transform = 'translateY(105%)'; if (scrim) { scrim.style.transition = 'opacity .18s'; scrim.style.opacity = '0'; } setTimeout(close, 170); } else reset();
|
||||
};
|
||||
el.addEventListener('touchend', end); el.addEventListener('touchcancel', end);
|
||||
}
|
||||
addEventListener('keydown', e => { if (e.key === 'Escape') closeSheet(); });
|
||||
|
||||
function actionSheet(title, sub, actions) {
|
||||
|
||||
@@ -68,3 +68,22 @@
|
||||
else if (!e.shiftKey && (document.activeElement === last || !s.contains(document.activeElement))) { e.preventDefault(); first.focus(); }
|
||||
});
|
||||
})();
|
||||
|
||||
// ---- iPhone keyboard: iOS shrinks only the visual viewport when the keyboard opens and pans the whole page to reveal the field, so the app used to slide up with it.
|
||||
// Pin the shell to what is really visible while typing and tuck the bottom bar away; chat and mail size themselves again on 'kbfit'.
|
||||
(function () {
|
||||
const vv = window.visualViewport; if (!vv || !matchMedia('(pointer: coarse)').matches) return;
|
||||
const root = document.documentElement; let raf = 0;
|
||||
const typing = () => { const a = document.activeElement; return !!a && (a.tagName === 'TEXTAREA' || a.isContentEditable || (a.tagName === 'INPUT' && !/^(checkbox|radio|button|submit|reset|range|file|color|image)$/.test(a.type))); };
|
||||
function apply() {
|
||||
raf = 0;
|
||||
const kb = typing() && window.innerHeight - vv.height > 120 && window.innerWidth < 768;
|
||||
document.body.classList.toggle('kb', kb);
|
||||
if (kb) { root.style.setProperty('--vvh', vv.height + 'px'); root.style.setProperty('--vvt', vv.offsetTop + 'px'); if (window.scrollY) window.scrollTo(0, 0); }
|
||||
else { root.style.removeProperty('--vvh'); root.style.removeProperty('--vvt'); }
|
||||
window.dispatchEvent(new Event('kbfit'));
|
||||
}
|
||||
const soon = () => { if (!raf) raf = requestAnimationFrame(apply); };
|
||||
vv.addEventListener('resize', soon); vv.addEventListener('scroll', soon);
|
||||
document.addEventListener('focusin', soon); document.addEventListener('focusout', () => setTimeout(soon, 80));
|
||||
})();
|
||||
@@ -123,6 +123,8 @@ label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 6px
|
||||
padding: 8px 16px calc(var(--sab) + 16px); transform: translateY(100%); transition: transform .22s ease; max-height: 85dvh; overflow-y: auto; }
|
||||
#sheet.on .scrim { opacity: 1; }
|
||||
#sheet.on .sheet { transform: none; }
|
||||
#sheet .sheetx { position: sticky; top: 0; float: right; z-index: 2; width: 36px; height: 36px; margin: 0 -6px 0 8px; background: var(--panel2); border-radius: 50%; } #sheet .sheetx svg { width: 18px; height: 18px; }
|
||||
#sheet .sheet { overscroll-behavior: contain; }
|
||||
#sheet .grip { width: 40px; height: 4px; border-radius: 2px; background: var(--line); margin: 4px auto 12px; }
|
||||
#sheet h3 { margin: 0 0 4px; font-size: 18px; word-break: break-all; }
|
||||
#sheet .list { background: var(--panel2); border: 0; margin-top: 8px; }
|
||||
@@ -264,6 +266,14 @@ body { -webkit-touch-callout: none; }
|
||||
button, a, .row { -webkit-user-select: none; user-select: none; }
|
||||
input, textarea, [contenteditable], .sheet p, .card p, #view .sub { -webkit-user-select: text; user-select: text; }
|
||||
|
||||
/* iPhone keyboard (shell.js adds body.kb while a field is focused and the keyboard is up): the shell is exactly the visible area, with no bottom bar in the way */
|
||||
@media (max-width: 767px) {
|
||||
body.kb { overflow: hidden; }
|
||||
body.kb #shell { height: var(--vvh, 100dvh); transform: translateY(var(--vvt, 0px)); }
|
||||
body.kb #nav, body.kb #player, body.kb #upd, body.kb #toast { display: none; }
|
||||
body.kb .sgm { min-height: 0; }
|
||||
}
|
||||
|
||||
/* "A new version is available" notice */
|
||||
#upd { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(var(--nav-h) + var(--sab) + 70px); z-index: 30; display: flex; align-items: center; gap: 12px;
|
||||
background: var(--text); color: var(--bg); padding: 8px 8px 8px 16px; border-radius: 14px; box-shadow: 0 6px 24px rgba(0,0,0,.35); max-width: 92vw; }
|
||||
@@ -1015,6 +1025,9 @@ body.vizopen { overflow: hidden; }
|
||||
.sgopt i { position: absolute; inset: 0 auto 0 0; background: rgba(127,127,127,.25); z-index: 0; } .sgopt span, .sgopt small { position: relative; z-index: 1; } .sgopt span { flex: 1; } .sgopt.on { border-color: currentColor; font-weight: 600; } .sgopt:disabled { cursor: default; opacity: .8; }
|
||||
.sgprev { display: flex; gap: 10px; margin: 6px 0 2px; padding: 8px; border-radius: 10px; background: rgba(127,127,127,.18); color: inherit; text-decoration: none; overflow: hidden; } .sgprev img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; flex: none; }
|
||||
.sgprev span { min-width: 0; } .sgprev small, .sgprev b, .sgprev em { display: block; overflow: hidden; text-overflow: ellipsis; } .sgprev small { opacity: .7; } .sgprev b { white-space: nowrap; } .sgprev em { font-style: normal; opacity: .8; font-size: 13px; max-height: 2.6em; }
|
||||
.sgshare { display: flex; align-items: center; gap: 10px; margin: 2px 0 4px; padding: 8px; min-width: 220px; border-radius: 12px; background: rgba(127,127,127,.18); color: inherit; text-decoration: none; overflow: hidden; } .sgshare.off { opacity: .75; }
|
||||
.sgshare-art { flex: none; width: 56px; height: 56px; border-radius: 8px; object-fit: cover; background: rgba(127,127,127,.25); display: grid; place-items: center; font-size: 26px; font-style: normal; }
|
||||
.sgshare-t { min-width: 0; flex: 1; } .sgshare-t small, .sgshare-t b, .sgshare-t em { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-style: normal; } .sgshare-t small { opacity: .7; } .sgshare-t em { opacity: .8; font-size: .9em; } .sgshare-t .sgshare-off { opacity: 1; color: var(--danger); font-weight: 600; margin-top: 2px; } .sgshare-go { font-size: 22px; opacity: .6; }
|
||||
.chemo { position: absolute; bottom: 100%; left: 0; right: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 2px; max-height: 190px; overflow: auto; padding: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px 12px 0 0; z-index: 25; }
|
||||
.chemo button { border: 0; background: none; font-size: 24px; padding: 4px; cursor: pointer; border-radius: 8px; } .chemo button:hover { background: var(--panel2); }
|
||||
.sgc { position: relative; } .sgc.rec > :not(.chrecbar) { visibility: hidden; } .chrecbar { position: absolute; inset: 0; display: flex; align-items: center; gap: 10px; padding: 0 10px; background: var(--panel); } .recdot { width: 10px; height: 10px; border-radius: 50%; background: var(--danger); animation: chblink 1s infinite; } @keyframes chblink { 50% { opacity: .2; } }
|
||||
@@ -1062,6 +1075,9 @@ body.vizopen { overflow: hidden; }
|
||||
#devchip .btn { min-height: 34px; padding: 0 14px; border-radius: 18px; } .devwho { display: flex; align-items: center; gap: 6px; } .devkid { font-style: normal; background: #2f9e6e; color: #fff; border-radius: 8px; padding: 1px 7px; font-size: 11px; font-weight: 700; }
|
||||
|
||||
.crtick { position: absolute; top: 0; width: 2px; height: 100%; background: var(--text); opacity: .55; } .crearn { display: flex; align-items: center; gap: 10px; margin: 6px 0 0 52px; padding: 8px 10px; border-radius: 12px; background: var(--panel2); } .crearn .btn { min-height: 34px; padding: 0 12px; }
|
||||
.crtick.goal { width: 4px; background: var(--accent); opacity: 1; }
|
||||
.crgoal { display: flex; align-items: center; gap: 12px; margin: 8px 0 4px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 16px; background: var(--panel); } .crgoal .crgi { font-size: 26px; } .crgoal .grow { min-width: 0; } .crgoal b, .crgoal small { display: block; } .crgoal small { color: var(--muted); }
|
||||
.crgoalbtn { margin: 4px 0 0 52px; min-height: 36px; font-size: 14px; }
|
||||
.crdet { margin: 6px 0 0 52px; } .crdet summary { cursor: pointer; color: var(--muted); font-size: 14px; } .crwk { padding: 8px 0; border-bottom: 1px solid var(--line); } .crwk:last-child { border-bottom: 0; }
|
||||
|
||||
#kidreq { position: fixed; left: 12px; bottom: calc(env(safe-area-inset-bottom, 0px) + 74px + var(--playerh, 0px)); z-index: 55; min-height: 40px; padding: 0 16px; border-radius: 22px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font: inherit; font-weight: 600; box-shadow: 0 4px 14px rgba(0,0,0,.3); cursor: pointer; }
|
||||
|
||||
+9
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v88';
|
||||
const CACHE = 'hub-v95';
|
||||
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'];
|
||||
|
||||
@@ -59,6 +59,14 @@ self.addEventListener('push', e => {
|
||||
let d = {}; try { d = e.data.json(); } catch {}
|
||||
e.waitUntil(self.registration.showNotification(d.title || 'NE Hub', { body: d.body || '', icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: d.tag, data: { url: d.url || '#/home' } }));
|
||||
});
|
||||
// The push service can replace a device's subscription: make a new one and give it to the server so reminders keep arriving.
|
||||
self.addEventListener('pushsubscriptionchange', e => e.waitUntil((async () => {
|
||||
try {
|
||||
const k = await (await fetch('/api/push/key', { credentials: 'same-origin' })).json();
|
||||
const s = await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: k.publicKey });
|
||||
await fetch('/api/push/subscribe', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ subscription: s.toJSON() }) });
|
||||
} catch { /* signed out or offline: the app re-registers when it is next opened */ }
|
||||
})()));
|
||||
self.addEventListener('notificationclick', e => {
|
||||
e.notification.close();
|
||||
const url = '/' + ((e.notification.data && e.notification.data.url) || '');
|
||||
|
||||
+3
-1
@@ -170,7 +170,7 @@ async function videoItem(id) {
|
||||
<div class="vinfo"><h1 style="margin:0">${esc(m.type === 'episode' ? m.title : m.title)}</h1>${m.type === 'episode' ? `<p class="sub" style="margin:2px 0">${vdEp(m)}</p>` : m.type === 'season' ? `<p class="sub" style="margin:2px 0">${esc(m.parentTitle || '')}</p>` : ''}<p class="sub" style="margin:4px 0">${esc(meta)}</p>
|
||||
<div class="bar" id="vact" style="margin:10px 0">${playable ? `<button class="btn" id="vplay">${icon('play')}${m.viewOffset ? 'Resume · ' + vdClock(m.viewOffset / 1000) : 'Play'}</button>${m.viewOffset ? '<button class="btn ghost" id="vstart">From the start</button>' : ''}`
|
||||
: m.type === 'show' && next ? `<button class="btn" id="vnext">${icon('play')}${next.viewOffset ? 'Resume' : 'Play'} ${vdEp(next).replace(/^.*· /, '')}</button>` : ''}
|
||||
${m.type !== 'season' && m.type !== 'collection' ? `<button class="btn ghost" id="vseen">${m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount) ? 'Mark unwatched' : 'Mark watched'}</button>` : ''}</div></div></div></div>
|
||||
${allowed('chat') && m.type !== 'collection' ? `<button class="btn ghost" id="vshare">${icon('send')}Share</button>` : ''}${m.type !== 'season' && m.type !== 'collection' ? `<button class="btn ghost" id="vseen">${m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount) ? 'Mark unwatched' : 'Mark watched'}</button>` : ''}</div></div></div></div>
|
||||
<p class="vsum">${esc(m.summary || '')}</p>
|
||||
${m.Genre && m.Genre.length ? `<p class="sub"><b>Genres</b> ${names(m.Genre)}</p>` : ''}${m.Director && m.Director.length ? `<p class="sub"><b>Director</b> ${names(m.Director, 'tag', 3)}</p>` : ''}${m.Role && m.Role.length ? `<p class="sub"><b>Cast</b> ${names(m.Role, 'tag', 8)}</p>` : ''}
|
||||
<div id="vkids"></div></div>`;
|
||||
@@ -196,6 +196,8 @@ async function videoItem(id) {
|
||||
}).catch(() => {});
|
||||
const pimg = document.querySelector('.vpost img'); // tint the backdrop with the poster's own colour
|
||||
if (pimg) { const tint = () => { try { const c = document.createElement('canvas'); c.width = c.height = 8; const x = c.getContext('2d'); x.drawImage(pimg, 0, 0, 8, 8); const d = x.getImageData(0, 0, 8, 8).data; let r = 0, g = 0, b = 0; for (let i = 0; i < d.length; i += 4) { r += d[i]; g += d[i + 1]; b += d[i + 2]; } const n = d.length / 4, h = document.querySelector('.vhero'); if (h) h.style.setProperty('--vtint', `rgb(${Math.round(r / n)}, ${Math.round(g / n)}, ${Math.round(b / n)})`); } catch {} }; pimg.complete && pimg.naturalWidth ? tint() : pimg.addEventListener('load', tint, { once: true }); }
|
||||
if ($('#vshare')) $('#vshare').onclick = () => shareMedia({ kind: m.type === 'movie' ? 'movie' : m.type === 'episode' ? 'episode' : 'show', title: m.type === 'episode' ? m.title : m.title, sub: m.type === 'episode' ? [m.grandparentTitle, 'S' + m.parentIndex + 'E' + m.index].filter(Boolean).join(' · ') : m.type === 'season' ? m.parentTitle || '' : m.year ? String(m.year) : '', art: vdArt(m.thumb, 200, 300), link: '#/video/item/' + id });
|
||||
if ($('#vshare')) $('#vshare').onclick = () => shareMedia({ kind: m.type === 'movie' ? 'movie' : m.type === 'episode' ? 'episode' : 'show', title: m.type === 'episode' ? m.title : m.title, sub: m.type === 'episode' ? [m.grandparentTitle, 'S' + m.parentIndex + 'E' + m.index].filter(Boolean).join(' · ') : m.type === 'season' ? m.parentTitle || '' : m.year ? String(m.year) : '', art: vdArt(m.thumb, 200, 300), link: '#/video/item/' + id });
|
||||
if ($('#vplay')) $('#vplay').onclick = () => { location.hash = `#/video/watch/${id}`; };
|
||||
if ($('#vstart')) $('#vstart').onclick = () => { store.set('video_from_start', id); location.hash = `#/video/watch/${id}`; };
|
||||
if ($('#vnext')) $('#vnext').onclick = () => { location.hash = `#/video/watch/${next.ratingKey}`; };
|
||||
|
||||
@@ -169,7 +169,7 @@ function vizOpen() {
|
||||
const el = viz.el = document.createElement('div'); el.id = 'viz'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Visualizer');
|
||||
el.innerHTML = `<canvas id="vizc"></canvas><div class="viztop"><div class="grow"><b id="vizti"></b><small id="vizar"></small><small id="vizst"></small></div><button class="btn ghost" id="vizm"></button><button class="ibtn" id="vizx" aria-label="Close" title="Close (Esc)">${icon('x')}</button></div>
|
||||
<div class="vizfoot"><button class="ibtn" id="vizpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="vizt" aria-label="Play or pause"></button><button class="ibtn" id="viznx" aria-label="Next">${icon('next')}</button></div>`;
|
||||
document.body.appendChild(el); viz.cv = el.querySelector('#vizc'); vizSize(); vizText(); vizColors(t);
|
||||
document.body.appendChild(el); swipeToDismiss(el, vizClose, { handle: '.viztop', always: true }); viz.cv = el.querySelector('#vizc'); vizSize(); vizText(); vizColors(t);
|
||||
const m = el.querySelector('#vizm'); m.textContent = VIZ_MODES.find(x => x[0] === viz.mode)[1];
|
||||
m.onclick = e => { e.stopPropagation(); const i = VIZ_MODES.findIndex(x => x[0] === viz.mode); viz.mode = VIZ_MODES[(i + 1) % VIZ_MODES.length][0]; vizSetPref(vizKind(cur()), viz.mode); m.textContent = VIZ_MODES.find(x => x[0] === viz.mode)[1]; viz.parts = []; viz.rings = []; };
|
||||
el.querySelector('#vizx').onclick = e => { e.stopPropagation(); vizClose(); }; el.querySelector('#vizt').onclick = e => { e.stopPropagation(); toggle(); }; el.querySelector('#vizpv').onclick = e => { e.stopPropagation(); prev(); }; el.querySelector('#viznx').onclick = e => { e.stopPropagation(); next(); };
|
||||
|
||||
@@ -10,6 +10,7 @@ const tasks = require('./tasks');
|
||||
const cal = require('./cal');
|
||||
const mail = require('./mail');
|
||||
const inbox = require('./inbox');
|
||||
const chores = require('./chores');
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||
const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > 20000) { no(new Error('Too large')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
|
||||
@@ -36,16 +37,24 @@ function localNow(tz, at = new Date()) {
|
||||
}
|
||||
|
||||
async function send(dir, st, uid, payload) {
|
||||
if (uid) inbox.add(uid, { kind: 'remind', title: payload.title, body: payload.body, url: payload.url, tag: payload.tag }); // (reminders also land in the Notifications list)
|
||||
keys();
|
||||
if (uid) inbox.add(uid, { kind: 'remind', title: payload.title, body: payload.body, url: payload.url, tag: payload.tag, ref: payload.ref }); // (reminders also land in the Notifications list)
|
||||
keys(); const { ref, ...wire } = payload;
|
||||
let changed = false;
|
||||
await Promise.all(st.subs.map(async s => {
|
||||
try { await webpush.sendNotification(s.sub, JSON.stringify(payload), { TTL: 3600 }); }
|
||||
try { await webpush.sendNotification(s.sub, JSON.stringify(wire), { TTL: 3600 }); }
|
||||
catch (e) { if (e.statusCode === 404 || e.statusCode === 410) { s.dead = true; changed = true; } else console.warn('[push] send failed:', e.statusCode || e.message); }
|
||||
}));
|
||||
if (changed) { st.subs = st.subs.filter(s => !s.dead); save(dir, st); }
|
||||
}
|
||||
|
||||
/** A child with no reward goal for this week gets one reminder (in the Notifications list, and on their devices if they have any), in the daytime by their own clock. */
|
||||
async function goalReminder(u, dir, st, now) {
|
||||
const lt = localNow(st.subs.length ? st.tz : Intl.DateTimeFormat().resolvedOptions().timeZone, now); if (lt.hm < '08:00' || lt.hm >= '20:00') return;
|
||||
const wk = chores.goalNeeded(u.id, lt.day); if (!wk) return;
|
||||
chores.goalReminded(u.id, wk);
|
||||
await send(dir, st, u.id, { title: 'Pick your reward for this week', body: 'What are you aiming for? Choose a reward on the Chores page.', url: '#/chores', tag: 'goal:' + wk });
|
||||
}
|
||||
|
||||
let lastEvents = 0;
|
||||
let lastDigest = 0;
|
||||
/** One pass over everyone: runs every minute. */
|
||||
@@ -56,6 +65,7 @@ async function tick(now = new Date(), force = false) {
|
||||
for (const u of users.all()) {
|
||||
if (u.disabled) continue;
|
||||
const dir = users.dirOf(u.id), st = load(dir);
|
||||
await goalReminder(u, dir, st, now).catch(e => console.warn('[push] goal reminder failed:', e.message));
|
||||
if (!st.subs.length) continue;
|
||||
await users.runAs(u, async () => {
|
||||
const lt = localNow(st.tz, now); let dirty = false;
|
||||
@@ -87,7 +97,7 @@ async function tick(now = new Date(), force = false) {
|
||||
const fresh = now2.filter(m => !seen.includes(m.id));
|
||||
if (fresh.length) {
|
||||
st.mailSeen = seen.concat(fresh.map(m => m.id)).slice(-200); dirty = true;
|
||||
await send(dir, st, u.id, { title: fresh.length === 1 ? (fresh[0].from || 'New mail') : fresh.length + ' new messages', body: fresh.length === 1 ? fresh[0].subject : fresh.slice(0, 3).map(m => m.from || m.subject).join(', '), url: '#/mail', tag: 'mail' });
|
||||
await send(dir, st, u.id, { title: fresh.length === 1 ? (fresh[0].from || 'New mail') : fresh.length + ' new messages', body: fresh.length === 1 ? fresh[0].subject : fresh.slice(0, 3).map(m => m.from || m.subject).join(', '), url: '#/mail', tag: 'mail', ref: { mail: fresh.map(m => ({ a: m.acct, id: m.mid })).filter(r => r.a && r.id) } });
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn('[push] mail check failed:', e.message); }
|
||||
|
||||
@@ -83,6 +83,19 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user']
|
||||
const pp = chat.parsePreview('<html><head><title>Fallback</title><meta property="og:title" content="Pot & Pan Recipes"><meta property="og:description" content="Best stew"><meta property="og:image" content="/img/a.jpg"><meta property="og:site_name" content="Cook Site"></head></html>', 'https://cook.example.com/stew'); assert.deepEqual(pp, { url: 'https://cook.example.com/stew', site: 'Cook Site', title: 'Pot & Pan Recipes', desc: 'Best stew', image: 'https://cook.example.com/img/a.jpg' }, 'a preview reads the page\'s own words');
|
||||
assert.equal(chat.parsePreview('<title>Plain</title>', 'https://x.example.com/').title, 'Plain'); assert.equal(chat.parsePreview('<meta property="og:title" content="T"><meta property="og:image" content="http://x.example.com/a.jpg">', 'https://x.example.com/').image, '', 'only https pictures'); assert.equal(chat.parsePreview('<html></html>', 'https://x.example.com/'), null);
|
||||
|
||||
// ---- share cards: a song, film or book from the hub, with a picture and a link back
|
||||
const card = { kind: 'song', title: 'Blue Moon', sub: 'Sinatra', art: '/plex/photo/:/transcode?width=300&url=%2Flibrary%2Fa', link: '#/music/album/123' };
|
||||
for (const bad of [{ ...card, kind: 'virus' }, { ...card, title: ' ' }, { ...card, link: 'https://evil.example/x' }, { ...card, link: 'javascript:alert(1)' }, { ...card, link: '#/music/<b>' }, { ...card, art: 'https://evil.example/a.jpg' }, { ...card, art: '//evil.example/a.jpg' }, { ...card, art: '/plex/../etc/passwd' }]) assert.equal((await post('/send', { conv: g2.id, share: bad }, 'a')).status, 400, 'a bad card is refused: ' + JSON.stringify(bad));
|
||||
assert.equal((await post('/send', { conv: g2.id, share: card }, 'c')).status, 200, 'sharing needs no words'); await post('/read', { id: g2.id }, 'b');
|
||||
const sh = (await j(await post('/send', { conv: g2.id, text: 'Listen to this', share: { ...card, title: 'T'.repeat(500), sub: 'S'.repeat(500) } }, 'a'))).msg; assert.equal(sh.share.title.length, 120, 'long words are cut'); assert.equal(sh.share.sub.length, 120); assert.equal(sh.text, 'Listen to this', 'with a note');
|
||||
assert.equal((await j(await call('/convs', {}, 'b'))).unread, 1, 'a share counts as unread (the earlier one was read)'); assert.match((await j(await call('/convs', {}, 'b'))).convs.find(x => x.id === g2.id).preview, /^Ann: 🎵 TTT/, 'listed by its title');
|
||||
const sh2 = (await j(await post('/send', { conv: g2.id, share: { kind: 'podcastep', title: 'Ep 4', link: '#/podcast/show/x' } }, 'a'))).msg; assert.equal(sh2.share.art, '', 'the picture is optional'); assert.equal((await st('b', g2.id)).msgs.find(x => x.id === sh2.id).share.link, '#/podcast/show/x');
|
||||
assert.equal(chat.cleanShare({ kind: 'movie', title: 'Up', link: '#/video/item/9' }).title, 'Up');
|
||||
assert.ok(require('../inbox').load(P.b.id).some(i => i.body === 'Ann: 🎙️ Shared a podcast episode: Ep 4' && i.url.startsWith('#/chat/')), 'the Notifications list says what was shared');
|
||||
assert.equal((await j(await call('/search?q=sinatra', {}, 'b'))).results.length, 1, 'search finds a card by its subtitle'); assert.equal((await j(await call('/search?q=ep%204', {}, 'b'))).results.length, 1);
|
||||
const fws = (await j(await post('/forward', { from: g2.id, to: dm.id, msg: sh.id }, 'a'))).msg; assert.equal(fws.share.link, '#/music/album/123', 'a forwarded card stays a card');
|
||||
assert.equal((await post('/delete', { conv: g2.id, msg: sh2.id }, 'a')).status, 200); assert.equal((await st('b', g2.id)).msgs.find(x => x.id === sh2.id).share, undefined, 'a deleted card shows nothing');
|
||||
|
||||
|
||||
// ---- family extras: announcement and quiet hours
|
||||
assert.equal(await j(await call('/announcement', {}, 'b')), null); assert.equal((await post('/announcement', { text: 'Dinner at 6' }, 'b')).status, 403, 'only an admin posts it'); const an = await j(await post('/announcement', { text: ' Dinner at 6 ' }, 'a')); assert.equal(an.text, 'Dinner at 6'); assert.equal((await j(await call('/announcement', {}, 'b'))).by, 'Ann'); await post('/announcement', { text: '' }, 'a'); assert.equal(await j(await call('/announcement', {}, 'b')), null, 'an empty one clears it');
|
||||
|
||||
+24
-1
@@ -1,7 +1,8 @@
|
||||
// Chores: when each kind of chore is due, who it is given to, ticking off and undoing, who may tick what, and the points for the week.
|
||||
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||
process.env.TZ = 'UTC'; // (the reminder goes out in the daytime by the server's clock)
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-chores-'));
|
||||
const users = require('../users'), chores = require('../chores');
|
||||
const users = require('../users'), chores = require('../chores'), push = require('../push'), inbox = require('../inbox');
|
||||
const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['chores'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['chores'], handle: 'Yw' }), d: users.create({ name: 'Di', role: 'user', tabs: ['music'], handle: 'ZA' }) };
|
||||
users.update(P.b.id, { kid: { on: true } }); users.update(P.c.id, { kid: { on: true } }); // (points and rewards are for child accounts)
|
||||
const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => chores.handle(req, res))).listen(0, async () => {
|
||||
@@ -47,6 +48,28 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user']
|
||||
assert.equal((await j(await call('/week?date=' + chores.addDays(today, -7), {}, 'a'))).people.every(x => x.points === 0), true, 'last week had no points: they start again each week');
|
||||
const rc = await j(await post('/reward', { title: 'Bake together', points: 3, for: [P.c.id] })); wk = await j(await call('/week?date=' + today, {}, 'a')); assert.ok(wk.people.find(x => x.name === 'Cy').earned.includes(rc.id), 'a reward just for Cy: Cy earns it'); assert.ok(!wk.people.find(x => x.name === 'Bob').tiers.includes(rc.id), 'and it is not on Bob\'s list'); assert.equal((await post('/reward', { title: 'x', points: 3, for: [] })).status, 400, 'someone has to be chosen'); await post('/reward', { id: rc.id, remove: true });
|
||||
assert.equal((await post('/give', { user: P.b.id, reward: r5.id, undo: true })).status, 200); assert.equal((await j(await call('/week?date=' + today, {}, 'a'))).people.find(x => x.name === 'Bob').given.length, 0);
|
||||
// a child chooses the reward they are aiming for
|
||||
const rcy = await j(await post('/reward', { title: 'Cy only', points: 4, for: [P.c.id] }));
|
||||
const goalOf = async who => (await j(await call('/week?date=' + today, {}, 'a'))).people.find(x => x.name === who).goal;
|
||||
assert.equal((await post('/goal', { reward: r50.id }, 'b')).status, 200); assert.equal(await goalOf('Bob'), r50.id, 'Bob is aiming for the trip');
|
||||
assert.equal((await post('/goal', { reward: rcy.id }, 'b')).status, 400, 'not one that is only for Cy'); assert.equal((await post('/goal', { reward: 'nope' }, 'b')).status, 400);
|
||||
assert.equal((await post('/goal', { reward: r5.id, user: P.c.id }, 'b')).status, 200); assert.equal(await goalOf('Bob'), r5.id, 'a child only ever sets their own'); assert.equal(await goalOf('Cy'), null);
|
||||
assert.equal((await post('/goal', { reward: rcy.id, user: P.c.id }, 'a')).status, 200); assert.equal(await goalOf('Cy'), rcy.id, 'an admin can choose for a child');
|
||||
assert.equal((await post('/goal', { reward: r5.id }, 'a')).status, 400, 'goals are for children');
|
||||
const tmp = await j(await post('/reward', { title: 'Temp', points: 9 })); await post('/goal', { reward: tmp.id }, 'b'); assert.equal(await goalOf('Bob'), tmp.id); await post('/reward', { id: tmp.id, remove: true }); assert.equal(await goalOf('Bob'), null, 'a removed reward is no longer anyone\'s goal');
|
||||
await post('/goal', { reward: r5.id }, 'b'); assert.equal((await post('/goal', { reward: '' }, 'b')).status, 200); assert.equal(await goalOf('Bob'), null, 'no reward clears it');
|
||||
assert.equal((await j(await call('/', {}, 'b'))).me.kid, true, 'the page knows who is a child');
|
||||
// a goal is for one week and starts empty each Monday; a child with none is reminded once
|
||||
const wkGoal = async (who, date) => (await j(await call('/week?date=' + date, {}, 'a'))).people.find(x => x.name === who).goal, thisMon = (await j(await call('/week?date=' + today, {}, 'a'))).from;
|
||||
await post('/goal', { reward: r5.id }, 'b'); assert.equal(await wkGoal('Bob', today), r5.id); assert.equal(await wkGoal('Bob', chores.addDays(today, 7)), null, 'next week starts empty'); assert.equal(await wkGoal('Bob', chores.addDays(today, -7)), null, 'and last week had none');
|
||||
assert.equal(chores.goalNeeded(P.b.id, today), null, 'a goal is chosen: no reminder'); await post('/goal', { reward: '' }, 'b');
|
||||
assert.equal(chores.goalNeeded(P.b.id, today), thisMon, 'no goal yet: a reminder is due'); assert.equal(chores.goalNeeded(P.a.id, today), null, 'not for adults');
|
||||
const later = chores.addDays(thisMon, 14), at = new Date(Date.parse(later + 'T10:00:00Z')), mine = () => inbox.load(P.b.id).filter(i => i.tag === 'goal:' + later);
|
||||
await push.tick(at, true); assert.equal(mine().length, 1, 'a child with no goal is reminded in the daytime'); assert.equal(inbox.load(P.a.id).some(i => /^goal:/.test(i.tag)), false, 'not an adult');
|
||||
await push.tick(new Date(at.getTime() + 60e3), true); assert.equal(mine()[0].count, 1, 'only once a week');
|
||||
await post('/goal', { reward: '', user: P.c.id }); await push.tick(new Date(Date.parse(thisMon + 'T10:00:00Z') + 2 * 864e5), true); const cyRem = () => inbox.load(P.c.id).filter(i => i.tag === 'goal:' + thisMon); assert.equal(cyRem().length, 1); assert.equal(cyRem()[0].read, false);
|
||||
await post('/goal', { reward: rcy.id }, 'c'); assert.equal(cyRem()[0].read, true, 'choosing a goal settles the reminder');
|
||||
const early = new Date(Date.parse(chores.addDays(later, 7) + 'T03:00:00Z')); await push.tick(early, true); assert.equal(inbox.load(P.b.id).some(i => i.tag === 'goal:' + chores.addDays(later, 7)), false, 'not in the middle of the night');
|
||||
// delete removes its history
|
||||
assert.equal((await call('/' + once.id, { method: 'DELETE' }, 'b')).status, 403); assert.equal((await call('/' + once.id, { method: 'DELETE' })).status, 200); assert.equal((await j(await call('/summary', {}, 'a'))).people.find(x => x.name === 'Bob').points, 3);
|
||||
assert.equal((await call('/?date=nope')).status, 400);
|
||||
|
||||
+12
-1
@@ -14,7 +14,18 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user']
|
||||
await post('/api/chat/send', { conv: dm.id, text: 'new one' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(r.items.filter(i => i.kind === 'chat').length, 1, 'still unread: updated in place'); assert.equal(r.items.find(i => i.kind === 'chat').count, 3);
|
||||
r = await j(await post('/api/inbox/read', { all: true }, 'b')); assert.equal(r.unread, 0);
|
||||
await post('/api/chat/send', { conv: dm.id, text: 'after reading' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(r.items.filter(i => i.kind === 'chat').length, 2, 'a read entry stays; a new one starts');
|
||||
await post('/api/inbox/clear', {}, 'b'); assert.equal((await j(await call('/api/inbox', {}, 'b'))).items.length, 0); console.log('inbox ok');
|
||||
await post('/api/inbox/clear', {}, 'b'); assert.equal((await j(await call('/api/inbox', {}, 'b'))).items.length, 0);
|
||||
// reading or clearing a notification settles the chat itself, and opening the chat settles the notification
|
||||
const unr = async () => (await j(await call('/api/chat/status', {}, 'b'))).unread;
|
||||
assert.equal(await unr(), 0, 'clearing the notifications read the chat');
|
||||
await post('/api/chat/send', { conv: dm.id, text: 'one more' }, 'a'); assert.equal(await unr(), 1); assert.equal((await j(await call('/api/inbox', {}, 'b'))).unread, 1);
|
||||
await post('/api/chat/read', { id: dm.id }, 'b'); assert.equal((await j(await call('/api/inbox', {}, 'b'))).unread, 0, 'opening the chat settled its notification');
|
||||
await post('/api/chat/send', { conv: dm.id, text: 'last' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(await unr(), 1);
|
||||
await post('/api/inbox/read', { id: r.items[0].id }, 'b'); assert.equal(await unr(), 0, 'marking one notification read read its chat');
|
||||
await post('/api/chat/send', { conv: dm.id, text: 'and again' }, 'a'); assert.equal(await unr(), 1);
|
||||
await post('/api/inbox/read', { all: true }, 'b'); assert.equal(await unr(), 0, 'mark all read read the chats too');
|
||||
await post('/api/chat/announcement', { text: 'Again' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.ok(r.items[0].ref && r.items[0].ref.announce, 'an announcement knows which one it was');
|
||||
console.log('inbox ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
|
||||
});
|
||||
@@ -110,9 +110,14 @@ async function fetchAudio(src, cookie, tmp) {
|
||||
let n = 0; const body = Readable.fromWeb(r.body); body.on('data', c => { n += c.length; if (n > MAX_AUDIO) body.destroy(new Error('That audio file is too large to transcribe')); });
|
||||
await pipeline(body, fs.createWriteStream(tmp, { mode: 0o600 }));
|
||||
}
|
||||
// TRANSCRIBE_HOURS=22-6 keeps the (slow, CPU-hungry) speech-to-text to the night: a job waits in the queue until that window opens (local time, so set TZ). A job that has started finishes even if the window closes. Empty = any time.
|
||||
const NIGHT = (process.env.TRANSCRIBE_HOURS || '').match(/^(\d{1,2})\s*-\s*(\d{1,2})$/);
|
||||
const inWindow = () => { if (!NIGHT) return true; const a = +NIGHT[1], b = +NIGHT[2], h = new Date().getHours(); return a === b || (a < b ? h >= a && h < b : h >= a || h < b); };
|
||||
async function untilWindow() { while (!inWindow()) await new Promise(r => setTimeout(r, 60e3)); }
|
||||
function startJob(key, src, cookie, getAudio, ep) {
|
||||
jobs.set(key, { state: 'running', since: Date.now() });
|
||||
chain = chain.then(async () => {
|
||||
await untilWindow();
|
||||
const tmp = path.join(os.tmpdir(), 'nehub-tr-' + key);
|
||||
try { if (getAudio) await saveAudio(await getAudio(), tmp); else await fetchAudio(src, cookie, tmp); const r = await whisper(tmp, 'audio'); if (!r) throw new Error('No speech was found in that audio'); atomicWrite(fileOf(key), JSON.stringify(ep ? { ...r, ep } : r)); jobs.delete(key); }
|
||||
catch (e) { jobs.set(key, { state: 'error', since: Date.now(), error: e.message }); setTimeout(() => jobs.delete(key), 10 * 60e3).unref(); }
|
||||
|
||||
Reference in new issue
Block a user