diff --git a/chat.js b/chat.js index 33bcfb3..2c63986 100644 --- a/chat.js +++ b/chat.js @@ -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,9 +92,10 @@ 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) { @@ -89,10 +104,10 @@ function markRead(uid, c) { 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 }); @@ -182,7 +197,7 @@ 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); markRead(me.id, c); return json(res, 200, { ok: true, unread: totalUnread(me.id) }); } @@ -219,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' }); @@ -242,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 = { markRead, announcement: () => read(annFile(), null), handle, totalUnread, purge, parsePreview, askParents, searchFor }; +module.exports = { cleanShare, markRead, announcement: () => read(annFile(), null), handle, totalUnread, purge, parsePreview, askParents, searchFor }; diff --git a/public/audiobooks-view.js b/public/audiobooks-view.js index d8d1c96..1e50580 100644 --- a/public/audiobooks-view.js +++ b/public/audiobooks-view.js @@ -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(' Β· '), `
${resumable ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' Β· ' + abFmt(left) + ' left') : p && p.dur && p.pos >= p.dur - 30 ? 'Finished' : 'Not started'}
Loadingβ¦
'; diff --git a/public/chat-view.js b/public/chat-view.js index 92f7c4a..3f31a47 100644 --- a/public/chat-view.js +++ b/public/chat-view.js @@ -62,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 ? `${ch.showArchived || q ? arch.map(chRow).join('') : ''}` : '') - + (found.length ? `In messages
${found.map(f => `${esc(f.convName)}${esc(f.msg.name)}: ${esc(f.msg.text)}${sgTime(f.msg.ts)}`).join('')}` : '') + + (found.length ? `In messages
${found.map(f => `${esc(f.convName)}${esc(f.msg.name)}: ${esc(f.msg.text || (f.msg.share ? f.msg.share.title : ''))}${sgTime(f.msg.ts)}`).join('')}` : '') || ''; if (!box.innerHTML) box.innerHTML = `${q ? 'Nothing matches' : 'No chats yet. Tap New to start one.'}
`; const a = box.querySelector('#charch'); if (a) a.onclick = () => { ch.showArchived = !ch.showArchived; chDrawList(); }; @@ -78,6 +78,16 @@ function chPoll(m) { ${P.options.map(o => { const n = count(o.id); return ``; }).join('')} ${total} ${total === 1 ? 'person has' : 'people have'} voted`; } +// ---- share cards: a song, film, book... sent from its own screen. Tapping opens it, unless that part of the hub is not turned on for this person. +const CH_SHARE = { song: ['π΅', 'Song'], album: ['πΏ', 'Album'], artist: ['π€', 'Artist'], playlist: ['π', 'Playlist'], movie: ['π¬', 'Movie'], show: ['πΊ', 'TV show'], episode: ['πΊ', 'Episode'], book: ['π', 'Book'], audiobook: ['π§', 'Audiobook'], podcast: ['ποΈ', 'Podcast'], podcastep: ['ποΈ', 'Podcast episode'], radio: ['π»', 'Radio'] }; +/** 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 ? `Start a chat first.
'}Start a chat first (Chat, then New).
'}${esc([b.language, lvDur(total), b.chapters.length + ' chapters'].filter(Boolean).join(' Β· '))}
${p ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' Β· ' + abFmt(p.dur - p.pos) + ' left') : 'Not started'}
${esc(about).replace(/\n/g, '
')}
${esc(about).replace(/\n/g, '
')}
${esc(sub)}
${esc(info.summary).replace(/\n/g, '
')}
No playable tracks
'}${esc(pdF(show, 'author') || '')}
@@ -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'), '', `${vdEp(m)}
` : m.type === 'season' ? `${esc(m.parentTitle || '')}
` : ''}${esc(meta)}
${esc(m.summary || '')}
${m.Genre && m.Genre.length ? `Genres ${names(m.Genre)}
` : ''}${m.Director && m.Director.length ? `Director ${names(m.Director, 'tag', 3)}
` : ''}${m.Role && m.Role.length ? `Cast ${names(m.Role, 'tag', 8)}
` : ''} `; @@ -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}`; }; diff --git a/tools/chat-test.js b/tools/chat-test.js index a1097e9..4e87a4d 100644 --- a/tools/chat-test.js +++ b/tools/chat-test.js @@ -83,6 +83,19 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] const pp = chat.parsePreview('