diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 9 | ||||
| -rw-r--r-- | src/features/ark.js | 5 | ||||
| -rw-r--r-- | src/features/messages.js | 347 | ||||
| -rw-r--r-- | src/hls-player.js | 17 | ||||
| -rw-r--r-- | src/i18n.js | 18 | ||||
| -rw-r--r-- | src/live.js | 82 | ||||
| -rw-r--r-- | src/lnurl.js | 4 | ||||
| -rw-r--r-- | src/style.css | 34 |
8 files changed, 495 insertions, 21 deletions
@@ -909,7 +909,7 @@ wallet.subscribe(scheduleRender); // lightbox: a full-screen photo is a place too — the phone's Back must close // it, not pop the screen under it (which once walked a viewer straight back // to the start page, so the photo's × then looked like a logout). -const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'feedId', 'pubProf', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop', 'lightbox', 'mediaPager', 'feedEdit', 'settingsView']; +const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'feedId', 'pubProf', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'liveStream', 'userSearch', 'zapSetup', 'hatShop', 'lightbox', 'mediaPager', 'feedEdit', 'settingsView']; function navSnapshot() { const s = {}; for (const f of NAV_FIELDS) s[f] = ui[f] ?? null; @@ -917,6 +917,7 @@ function navSnapshot() { // results streaming in) snapshot only what IDENTIFIES the screen — a // keystroke must never mint a history entry. if (ui.noteThread) s.noteThread = { rootId: ui.noteThread.rootId, focusId: ui.noteThread.focusId, seed: ui.noteThread.seed }; + if (ui.liveStream) s.liveStream = { a: ui.liveStream.a, seed: ui.liveStream.seed }; if (ui.userSearch) s.userSearch = { q: '', rows: null }; if (ui.feedEdit) s.feedEdit = { ...ui.feedEdit, q: '', rows: null, pq: '', packRows: null, pt: null }; if (ui.zapSetup) s.zapSetup = { pk: ui.zapSetup.pk, npub: ui.zapSetup.npub, eventId: ui.zapSetup.eventId, amount: '21' }; @@ -939,7 +940,7 @@ let restoringHistory = false; // true while applying a popstate (suppresses push // copy it, send it, reload it — the same paths the deep links already open. // Every other screen keeps whatever path it had (the till at /pos, the // plain app at /); only a path we set ourselves is taken back to /. -const OWN_PATH = /^\/(nevent|note|npub)1[a-z0-9]+\/?$/i; +const OWN_PATH = /^\/(nevent|note|npub|naddr)1[a-z0-9]+\/?$/i; function navUrl(snap) { try { const fp = featureHook('navPath', snap); // a feed with a public address @@ -1920,7 +1921,7 @@ function identities() { function clearFeatureNav() { ui.arkCoinsPage = null; ui.arkExitPage = null; ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false; - ui.chatOpen = false; ui.userSearch = null; ui.noteThread = null; ui.zapSetup = null; + ui.chatOpen = false; ui.userSearch = null; ui.noteThread = null; ui.liveStream = null; ui.zapSetup = null; } // Sign out of the CURRENT identity only. "Log out" locks every account on @@ -3220,7 +3221,7 @@ function goHome() { ui.profilePk = null; ui.profEdit = null; ui.userSearch = null; - ui.noteThread = null; + ui.noteThread = null; ui.liveStream = null; ui.zapSetup = null; ui.arkExitPage = null; ui.settingsPage = null; diff --git a/src/features/ark.js b/src/features/ark.js index cbfd1b9..1619ce4 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -2,6 +2,7 @@ // protocol, spoken natively by ../ark). Receive address + boarding, instant // sends, history entries, refresh/consolidation, server settings. +import { zapAddrOf } from '../live.js'; import * as btc from '@scure/btc-signer'; import { HDKey } from '@scure/bip32'; import { hex, base32nopad, bech32 } from '@scure/base'; @@ -1914,7 +1915,7 @@ export function arkFeature(ctx) { await wallet.nostrPublish({ kind: ARK_ZAP_KIND, content: (z.comment || '').slice(0, 280), - tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), + tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []), ['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()], ...zapSenderTag()], }).catch(() => {}); } @@ -1957,7 +1958,7 @@ export function arkFeature(ctx) { wallet.nostrPublish({ kind: ARK_ZAP_KIND, content: (z.comment || '').slice(0, 280), - tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), + tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []), ['amount', String(sats)], ['network', getNetwork()], ['gift', locked.url], ...zapSenderTag()], }).catch(() => {}); const dmText = t('giftDmText', { amount: fmtAmount(sats) + ' ' + unitLabel(), link: locked.url, code: locked.claimCode }); diff --git a/src/features/messages.js b/src/features/messages.js index 079c22c..2e3736c 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -52,6 +52,7 @@ import { animateZap, warmZapSound } from '../zap-animation.js'; import { SIGNER_SILENT } from '../dm.js'; import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js'; import { dlog } from '../debug.js'; +import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus, isLiveNow, liveRelays, newestByAddr, zapLine, setZapAddr } from '../live.js'; import { qrSvg } from '../qr.js'; // The coinos community's join material lives in ../community.js — shared @@ -2276,6 +2277,22 @@ export function messagesFeature(ctx) { }, 0); } + // /naddr1… of a live stream (kind 30311) opens the stream, public like a + // note: a visitor watches over the front door. A reload the history + // restores by itself needs nothing. + const urlLive = (() => { + if (typeof location === 'undefined' || urlInvite) return null; + const m = location.pathname.match(/^\/(naddr1[a-z0-9]+)\/?$/i); + let ref = null; try { ref = m ? parseNostrRef(m[1].toLowerCase()) : null; } catch {} + return ref && ref.type === 'addr' && ref.kind === LIVE_KIND ? ref : null; + })(); + if (urlLive && (() => { try { return history.state?.nav?.liveStream?.a !== LIVE_KIND + ':' + urlLive.pk + ':' + urlLive.d; } catch { return true; } })()) { + try { history.replaceState(null, '', '/'); } catch {} + ui.pubProf = true; + ui.pubNotePending = 'live'; + setTimeout(() => { openLiveRef(urlLive).catch(() => {}).finally(() => { ui.pubNotePending = null; render(); }); }, 0); + } + // Resolve and open right away — not in init(), which only runs once a // wallet opens (and would replay on every wallet born in this tab). For a // visitor with no wallet the profile renders as a truly public page over @@ -4017,7 +4034,7 @@ export function messagesFeature(ctx) { // The same for a post opened into its thread: Back lands on the row the // reader tapped, not the top of the feed. const pageReturn = []; // [{ key, y }] per profile/thread opened, newest last - const pageKey = () => ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread' + const pageKey = () => ui.liveStream ? 'live' : ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread' : ui.profilePk ? 'p:' + ui.profilePk : ''; function rememberPlace(key) { if (pageKey() === key) return; @@ -4048,6 +4065,7 @@ export function messagesFeature(ctx) { } function openProfile(pk) { rememberPlace('p:' + pk); + ui.liveStream = null; ui.profilePk = pk; // Opened from inside a thread (an author's avatar/name), the profile // stacks ON TOP of it — back returns to the conversation. The screen @@ -5770,7 +5788,7 @@ export function messagesFeature(ctx) { const own = p.pk === mePk() ? st().feeds.find((f) => f.d === p.d) : null; const id = own ? own.id : 'list:' + packKey(p); if (!own) adhocFeeds.set(id, { id, name: p.title || p.d, packs: [{ pk: p.pk, d: p.d, kind: p.kind === PACK_KIND ? undefined : p.kind, relays: [], title: p.title || '' }] }); - ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null; ui.profOverThread = false; + ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.feedEdit = null; ui.profOverThread = false; ui.chatOpen = true; ui.msgView = 'feed'; switchFeed(id); } @@ -5817,7 +5835,7 @@ export function messagesFeature(ctx) { onClick: () => { ui.listPick = null; // the editor lives on the feed screen; the profile gives way to it - ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.userSearch = null; + ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.userSearch = null; ui.chatOpen = true; ui.msgView = 'feed'; openFeedEditor(null); ui.feedEdit.authors = [pk]; @@ -6136,7 +6154,7 @@ export function messagesFeature(ctx) { // Every kind of feed has a URL, and a visitor with no wallet can read it. const isVisitor = () => !wallet.mnemonic && !wallet.xpub && !wallet.xprv; const showFeed = (id) => { - ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedEdit = null; + ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.feedEdit = null; ui.chatOpen = true; ui.msgView = 'feed'; if (ui.screen === 'wallet') ui.pubProf = null; // the wallet's chrome owns it switchFeed(id); @@ -6337,7 +6355,7 @@ export function messagesFeature(ctx) { const saved = relays.length ? null : st().feeds.find((f) => !f.follows && !(f.authors || []).length && feedTopics(f).length === 1 && feedTopics(f)[0] === tag); const id = saved ? saved.id : 'topic:' + tag + (relays.length ? '@' + relays.join(',') : ''); if (!saved && !adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: '#' + tag, topics: [tag], relays }); - ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null; + ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.feedEdit = null; ui.chatOpen = true; ui.msgView = 'feed'; switchFeed(id); } @@ -8370,6 +8388,7 @@ export function messagesFeature(ctx) { if (feedPaint && c.ev) feedPaint.quotes.set(key, { ...c }); } const isList = ref.kind === LIST_KIND || ref.kind === PACK_KIND; + if (c.ev && ref.kind === LIVE_KIND && !hiddenPk(liveHost(c.ev))) return liveCard(c.ev); if (!c.ev && isList) { // a list needs nothing fetched to open: the feed resolves its members return h('div', { class: 'quote-card clickable', onClick: (e) => { e.stopPropagation(); openListFeed({ pk: ref.pk, d: ref.d, kind: ref.kind, title: '' }); } }, @@ -8439,6 +8458,295 @@ export function messagesFeature(ctx) { return out; } + // ---- live streams (NIP-53) ----------------------------------------------- + // What's on now, newest copy of each stream by its address: asked of the + // relays streams are announced on every couple of minutes while a feed or + // profile wants it. A feed shows the ones its people are in (Following: + // your follows; Everything and Popular: all of them) in a row above its + // posts; a profile says when its person is on; a post linking a stream + // shows it as a card. Any of them opens the stream's own screen. + const liveSeen = new Map(); // addr -> newest 30311 + let liveAt = 0, liveAsking = null; + const liveAskedOf = new Map(); // pk -> when their own streams were asked for + function takeLive(evs) { + let changed = false; + for (const [a, ev] of newestByAddr(evs)) { + const cur = liveSeen.get(a); + if (!cur || cur.created_at < ev.created_at) { liveSeen.set(a, ev); changed = true; } + } + return changed; + } + const liveSince = () => Math.floor(Date.now() / 1000) - 6 * 3600; + function refreshLive() { + if (liveAsking || Date.now() - liveAt < 120_000) return; + liveAt = Date.now(); + liveAsking = queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: liveSince(), limit: 300 }, 4000) + .then((evs) => { if (takeLive(evs)) scheduleRepaint(); }) + .catch(() => {}).finally(() => { liveAsking = null; }); + } + // the streams someone is in, asked of their own relays as well (a + // streamer's on a relay we don't otherwise read) + function askLiveOf(pk) { + if (Date.now() - (liveAskedOf.get(pk) || 0) < 120_000) return; + liveAskedOf.set(pk, Date.now()); + Promise.resolve(relaysOf(pk)).catch(() => []).then((theirs) => { + const relays = [...new Set([...LIVE_RELAYS, ...(theirs || []).slice(0, 4)])]; + return Promise.all([ + queryOn(relays, { kinds: [LIVE_KIND], '#p': [pk], since: liveSince(), limit: 20 }, 4000), + queryOn(relays, { kinds: [LIVE_KIND], authors: [pk], since: liveSince(), limit: 20 }, 4000), + ]); + }).then(([a, b]) => { if (takeLive([...a, ...b])) scheduleRepaint(); }).catch(() => {}); + } + const liveNsfw = (ev) => ev.tags.some((x) => x[0] === 'content-warning' || (x[0] === 't' && /^nsfw$/i.test(x[1] || ''))); + const liveShowable = (ev) => !hiddenPk(liveHost(ev)) && !hiddenPk(ev.pubkey); + function liveNowOf(pk) { + const now = Date.now() / 1000; + return [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isLiveNow(ev, now) && liveShowable(ev)) + .sort((a, b) => b.created_at - a.created_at)[0] || null; + } + function liveForFeed(def) { + const now = Date.now() / 1000; + const fol = followsNow().set; + const people = def.all ? null : new Set([...feedAuthors(def), ...(def.follows ? fol : [])]); + if (people && !people.size) return []; + return [...liveSeen.values()] + .filter((ev) => isLiveNow(ev, now) && liveShowable(ev) && !liveNsfw(ev) && (!people || people.has(liveHost(ev)) || people.has(ev.pubkey))) + .sort((a, b) => (fol.has(liveHost(b)) - fol.has(liveHost(a))) || ((liveViewers(b) || 0) - (liveViewers(a) || 0))); + } + const liveCoverStyle = (ev) => { const img = liveImage(ev); return img ? 'background-image:url(' + JSON.stringify(img) + ')' : undefined; }; + function liveBadge(ev) { + const s = liveStatus(ev); + return h('span', { class: 'live-badge ' + s }, t(s === 'live' ? 'liveBadge' : s === 'planned' ? 'livePlanned' : 'liveEnded')); + } + // the row of streams above a feed's posts + function liveStrip(def) { + if (mediaMode === 'articles') return null; + refreshLive(); + const evs = liveForFeed(def).slice(0, 12); + if (!evs.length) return null; + return h('div', { class: 'live-strip', 'data-key': 'live-strip' }, ...evs.map((ev) => { + const host = liveHost(ev), n = liveViewers(ev); + return h('button', { type: 'button', class: 'live-tile', 'data-key': 'live:' + liveAddr(ev), onClick: (e) => { e.stopPropagation(); openLive(ev); } }, + h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, + liveBadge(ev), n != null ? h('span', { class: 'live-viewers' }, t('liveWatching', { n })) : null), + h('div', { class: 'live-tile-title' }, liveTitle(ev) || t('liveUntitled')), + h('div', { class: 'row gap6 live-tile-host' }, avatar(host, 'chat-avatar mini', false), h('span', { class: 'small muted' }, displayName(host)))); + })); + } + // a profile's "on now" line + function liveBanner(pk) { + askLiveOf(pk); + const ev = liveNowOf(pk); + if (!ev) return null; + return h('button', { type: 'button', class: 'live-banner', 'data-key': 'live-banner:' + liveAddr(ev), onClick: () => openLive(ev) }, + h('span', { class: 'live-badge live' }, t('liveBadge')), + h('span', { class: 'live-banner-title' }, liveTitle(ev) || t('liveUntitled')), + h('span', { class: 'small muted' }, t('liveWatch'))); + } + // a post linking a stream (nostr:naddr of a 30311) + function liveCard(ev) { + const host = liveHost(ev); + return h('div', { class: 'quote-card clickable live-card', onClick: (e) => { e.stopPropagation(); openLive(ev); } }, + h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, liveBadge(ev)), + h('div', { class: 'col', style: 'gap:4px;min-width:0' }, + h('div', { class: 'addr-title' }, liveTitle(ev) || t('liveUntitled')), + h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, + avatar(host, 'chat-avatar mini', false), h('span', { class: 'quote-name' }, displayName(host))))); + } + + function openLive(ev) { + rememberPlace('live'); + const a = liveAddr(ev); + takeLive([ev]); + setZapAddr(ev.id, a); + ui.liveStream = { a, seed: ev }; + ui.noteThread = null; ui.profilePk = null; ui.profOverThread = false; ui.userSearch = null; + render(); + } + // a link to a stream (/naddr1…, nostr:naddr in a post we couldn't show) + async function openLiveRef(ref) { + const ev = liveSeen.get(LIVE_KIND + ':' + ref.pk + ':' + ref.d) || await findAddr(ref).catch(() => null); + if (ev) openLive(ev); + else toast(t('liveNotFound')); + } + + // The open stream: its chat (1311) and zaps (9735, and coinos's Ark + // receipts) as they come, and the stream event itself as it's refreshed + // (the viewer count, `ended`). + let liveWatch = null; + function watchLive(s) { + if (liveWatch && liveWatch.a === s.a) return liveWatch; + stopLiveWatch(); + const [, pk, ...rest] = s.a.split(':'); + const d = rest.join(':'); + takeLive([s.seed]); + const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, draft: '' }; + let mine = []; try { mine = wallet.nostrRelays(); } catch {} + const relays = [...new Set([...liveRelays(s.seed), ...mine])]; + const add = (e) => { + if (liveWatch !== w || w.lines.has(e.id)) return; + w.lines.set(e.id, e); + scheduleRepaint(); + }; + w.stops.push(subscribeOn(relays, { kinds: [LIVE_CHAT_KIND, 9735, 9737], '#a': [s.a], limit: 200 }, add)); + w.stops.push(subscribeOn(relays, { kinds: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); })); + return w; + } + function stopLivePlayer(w = liveWatch) { + if (!w) return; + if (w.hls) { try { w.hls.destroy(); } catch {} w.hls = null; } + const v = w.video; + w.video = null; + if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch {} } + } + function stopLiveWatch() { + const w = liveWatch; + if (!w) return; + liveWatch = null; + for (const stop of w.stops) { try { stop(); } catch {} } + stopLivePlayer(w); + } + // What to play: the stream while it's on, its recording once it's over. + const livePlayable = (ev) => (isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' ? liveRecording(ev) : ''); + // The <video> is the page's own once it plays: Chrome takes the playlist as + // its src; anywhere without HLS of its own, hls.js feeds it. The morph + // leaves it alone from then on (it would strip the src hls.js set). + function mountLivePlayer() { + const w = liveWatch; + const el = document.querySelector('video.live-video'); + if (!w || !el || el === w.video) return; + stopLivePlayer(w); + const ev = liveSeen.get(w.a); + const src = ev && livePlayable(ev); + if (!src) return; + w.video = el; + el._skipMorph = true; + w.err = null; + // a stream that drops (the streamer's link, a restart) is tried again + const fail = () => { + if (w.video !== el) return; + stopLivePlayer(w); + w.err = 'offline'; + scheduleRepaint(); + setTimeout(() => { if (liveWatch === w && w.err === 'offline') { w.err = null; scheduleRepaint(); } }, 10_000); + }; + el.addEventListener('error', fail); + el.addEventListener('hlsfatal', fail); + el.addEventListener('playing', () => { if (w.err) { w.err = null; scheduleRepaint(); } }); + const go = () => el.play().catch(() => { el.muted = true; el.play().catch(() => {}); }); + if (!isHls(src) || canHls()) { el.src = src; go(); return; } + import('../hls-player.js').then((m) => m.attachHls(el, src)).then((hls) => { + if (w.video !== el) { try { hls && hls.destroy(); } catch {} return; } + if (!hls) { w.err = 'noplayer'; scheduleRepaint(); return; } + w.hls = hls; go(); + }).catch(() => { w.err = 'noplayer'; scheduleRepaint(); }); + } + // after every render: a stream screen gone takes its player and watch with + // it (a <video> taken out of the page plays on, unheard and unseen) + let liveShown = false; + function liveAfterRender() { + if (!ui.liveStream) { stopLiveWatch(); return; } + if (!liveShown) { stopLivePlayer(); return; } + mountLivePlayer(); + const box = document.querySelector('.live-chat'); + if (box && box._stick !== false) box.scrollTop = box.scrollHeight; + } + + async function sendLiveChat(w, text) { + const id = await requireIdentity(); + const ev = liveSeen.get(w.a); + const relays = [...new Set([...liveRelays(ev), ...wallet.nostrRelays()])]; + const partial = { kind: LIVE_CHAT_KIND, content: text, created_at: Math.floor(Date.now() / 1000), + tags: [['a', w.a, relays[0] || '', 'root'], ...outboundEmojiTags(text), CLIENT_TAG] }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + w.lines.set(evt.id, evt); + render(); + if (!(await publishOn(relays, evt))) { + w.lines.delete(evt.id); + w.draft = text; + toast(t('msgSendFailed')); + render(); + } + } + + function liveLine(ev) { + if (ev.kind === LIVE_CHAT_KIND) { + if (hiddenPk(ev.pubkey)) return null; + return keyed(h('div', { class: 'live-line' }, + avatar(ev.pubkey, 'chat-avatar mini'), + h('div', { class: 'live-line-body' }, + h('span', { class: 'quote-name' }, displayName(ev.pubkey)), ' ', + ...noteBody(ev.content.slice(0, 500), 1, noteEmojiTags(ev)))), ev.id); + } + const z = zapLine(ev, decodeBolt11); + if (!z.sats || (z.pk && hiddenPk(z.pk))) return null; + return keyed(h('div', { class: 'live-line live-zap' }, + z.pk ? avatar(z.pk, 'chat-avatar mini') : h('span', {}, '⚡'), + h('div', { class: 'live-line-body' }, + h('span', { class: 'quote-name' }, z.pk ? displayName(z.pk) : t('liveSomeone')), ' ', + h('b', {}, t('liveZapped', { n: z.sats.toLocaleString() })), + z.text ? h('span', {}, ' ' + z.text.slice(0, 280)) : null)), ev.id); + } + + function liveScreen() { + const s = ui.liveStream; + const w = watchLive(s); + liveShown = true; + const ev = liveSeen.get(s.a) || s.seed; + const host = liveHost(ev), status = liveStatus(ev), n = liveViewers(ev); + const src = livePlayable(ev); + const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(liveLine).filter(Boolean); + const say = w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer') + : !src ? (status === 'planned' ? t('liveStartsAt', { when: timeLabel(liveStarts(ev) * 1000) }) : t('liveOver')) : null; + const send = () => { + const text = (w.draft || '').trim(); + if (!text) return; + w.draft = ''; + const inp = document.querySelector('.live-input'); + if (inp) inp.value = ''; + sendLiveChat(w, text).catch(() => { w.draft = text; render(); }); + }; + return h('div', { class: 'col live-page', style: 'gap:12px' }, + ctx.brandHeader(!ui.pubProf && wallet.loaded), + h('div', { class: 'card col', style: 'gap:10px' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, + backBtn(() => { ui.liveStream = null; }), + h('h3', { class: 'live-title' }, liveTitle(ev) || t('liveUntitled')), + liveBadge(ev)), + h('div', { class: 'live-player', 'data-key': 'live-player:' + s.a }, + // the frame is keyed by what plays in it: the <video> itself is + // never morphed, so a stream that ends or drops swaps the frame + src && !w.err + ? h('div', { class: 'live-frame', 'data-key': 'live-frame:' + src }, + h('video', { class: 'live-video', controls: '', playsinline: '', autoplay: '', poster: liveImage(ev) || undefined })) + : h('div', { class: 'live-cover live-still', 'data-key': 'live-still', style: liveCoverStyle(ev) }, h('span', { class: 'live-say' }, say))), + h('div', { class: 'row gap6', style: 'align-items:center;flex-wrap:wrap' }, + avatar(host, 'chat-avatar mini'), + h('a', { href: '#', class: 'quote-name', onClick: (e) => { e.preventDefault(); openProfile(host); } }, displayName(host)), + n != null && status === 'live' ? h('span', { class: 'small muted' }, t('liveWatching', { n })) : null, + h('span', { style: 'margin-left:auto' }), + canZapPk(host) ? h('button', { class: 'btn-sm', onClick: (e) => { setZapAddr(ev.id, s.a); zapNote(host, ev, e.currentTarget.getBoundingClientRect()); } }, '⚡ ' + t('liveZap')) : null, + h('button', { class: 'btn-sm', title: t('postShare'), onClick: async () => { + const url = location.origin + '/' + packNaddr(s.a); + try { if (navigator.share) await navigator.share({ url }); else { await navigator.clipboard.writeText(url); toast(t('copied')); } } catch {} + } }, '↗')), + liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null), + h('div', { class: 'card col', style: 'gap:8px' }, + h('div', { class: 'small muted' }, t('liveChat')), + h('div', { + class: 'live-chat', 'data-key': 'live-chat:' + s.a, + onScroll: (e) => { const b = e.currentTarget; b._stick = b.scrollHeight - b.scrollTop - b.clientHeight < 40; }, + }, ...(lines.length ? lines : [keyed(h('div', { class: 'small faint' }, t('liveChatEmpty')), 'empty')])), + isVisitor() ? null : h('div', { class: 'row gap6' }, + h('input', { + type: 'text', class: 'live-input grow', placeholder: t('liveChatHint'), value: w.draft, maxlength: '500', + onInput: (e) => { w.draft = e.target.value; }, + onKeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }, + }), + h('button', { class: 'btn-sm', onClick: send }, t('liveSend')))), + ...noteOverlays()); + } + // A quote inside a quote: one line of who and what, never a third level // of cards (a bare "view note" link said nothing about where it leads). function quoteMini(ref) { @@ -8880,7 +9188,7 @@ export function messagesFeature(ctx) { const ref = parseNostrRef(part.slice(6)); if (ref && ref.type === 'addr' && !depth) out.push(addrCard(ref)); else if (ref && ref.type === 'addr') out.push(h('a', { - href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); }, + href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); if (ref.kind === LIVE_KIND) { openLiveRef(ref); return; } const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); }, }, t('noteRefLink'))); else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); } else if (/^nostr:/i.test(part)) { @@ -8904,7 +9212,7 @@ export function messagesFeature(ctx) { // The header magnifier's search, opened with a name already typed. function openPeopleSearch(q) { - ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; + ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.userSearch = { q, rows: null }; warmSearch(); userSearcher.update(q); @@ -9225,7 +9533,7 @@ export function messagesFeature(ctx) { const next = (cur ? cur + '\n\n' : '') + ref; ui.profCompose = next; setDraft(POST_DRAFT, next); - ui.noteThread = null; + ui.noteThread = null; ui.liveStream = null; ui.profilePk = null; ui.chatOpen = true; ui.msgView = 'feed'; @@ -10601,6 +10909,7 @@ export function messagesFeature(ctx) { } function openNoteThread(ev) { rememberPlace('thread'); + ui.liveStream = null; ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev, scrollPending: true }; ui.profOverThread = false; // a freshly opened thread goes on top render(); @@ -10942,7 +11251,7 @@ export function messagesFeature(ctx) { // (or profile) and its scroll place. A thread opened straight from a // link has nothing of ours under it, so no button that invents one. !ctx.canGoBack || ctx.canGoBack() - ? h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; }) }, t('back')) + ? h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; ui.liveStream = null; }) }, t('back')) : null, ...noteOverlays()); } @@ -11221,6 +11530,7 @@ export function messagesFeature(ctx) { showLud ? h('div', { class: 'muted small break' }, '⚡ ' + lud16) : null, // the hat pitch lives beside the avatar it decorates mine ? hook('hatShopEntry') : null)), + ui.profEdit ? null : liveBanner(pk), // no spinner while the kind 0 loads — prefetch keeps this rare, and // an empty beat reads calmer than a spinner // The bio rides the same renderer as notes: npub mentions become @@ -11347,7 +11657,7 @@ export function messagesFeature(ctx) { // screen router shows an open thread before the chat, so // a reply half-typed under their post used to win here ui.profilePk = null; ui.profOverThread = false; - ui.noteThread = null; ui.userSearch = null; ui.feedEdit = null; + ui.noteThread = null; ui.liveStream = null; ui.userSearch = null; ui.feedEdit = null; ui.chatOpen = true; ui.msgView = 'dm'; ui.msgPeer = peer; @@ -11361,7 +11671,7 @@ export function messagesFeature(ctx) { // and the Send tab would never appear ui.profilePk = null; ui.profOverThread = false; - ui.noteThread = null; + ui.noteThread = null; ui.liveStream = null; ui.userSearch = null; ui.chatOpen = false; ctx.showSend({ fresh: true }); @@ -11700,7 +12010,7 @@ export function messagesFeature(ctx) { function toFrontDoor() { rememberReturn(); ui.signinAsk = null; ui.pubProf = null; ui.chatOpen = false; ui.msgView = null; - ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedRelayEdit = null; + ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.feedRelayEdit = null; render(); try { window.scrollTo({ top: 0 }); } catch {} } @@ -13943,6 +14253,7 @@ export function messagesFeature(ctx) { ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null, visitor ? null : feedChips(), mediaModes(), + liveStrip(def), // floating bottom right, wherever the reader is in the feed: the // composer opens at the top and takes the focus visitor || ui.profCompose != null ? null : h('button', { @@ -16107,12 +16418,14 @@ export function messagesFeature(ctx) { bottomNav: appBottomNav, notifySettingsCards() { return [notifyCard()]; }, screenView() { + liveShown = false; + queueMicrotask(liveAfterRender); // A profile deep link mid-resolution holds the frame over EVERY screen // — an auto-restored wallet would otherwise flash its home page for the // beat the registrar lookup takes. The shell shows the name straight // off the URL; no spinner, the empty beat reads calmer. // Same for a note deep link while its thread is being found. - if (ui.pubNotePending && !ui.noteThread) return h('div', { class: 'col', style: 'gap:16px' }, + if (ui.pubNotePending && !ui.noteThread && !ui.liveStream) return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(false), h('div', { class: 'card row gap6', style: 'justify-content:center;align-items:center;padding:16px' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small faint' }, t('noteRefLoading')))); @@ -16128,6 +16441,8 @@ export function messagesFeature(ctx) { // threads reachable from it, are public nostr content — shown // without an account. Back falls through to the app's own screens. if (ui.pubProf) { + if (ui.zapSetup && ui.liveStream) return zapSetupScreen(); + if (ui.liveStream) return liveScreen(); if (ui.profOverThread && ui.profilePk) return profileScreen(); if (ui.noteThread) return threadScreen(); if (ui.profilePk) return profileScreen(); @@ -16137,6 +16452,7 @@ export function messagesFeature(ctx) { return null; } if (ui.zapSetup) return zapSetupScreen(); + if (ui.liveStream) return liveScreen(); if (ui.profOverThread && ui.profilePk) return profileScreen(); if (ui.noteThread) return threadScreen(); if (ui.profilePk) return profileScreen(); @@ -16150,7 +16466,7 @@ export function messagesFeature(ctx) { userSearchAvailable() { return true; }, // the desktop header's search field: typing there IS the search page's query userSearchQuery(q) { - if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.userSearch = { q: '', rows: null }; warmSearch(); } + if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.userSearch = { q: '', rows: null }; warmSearch(); } ui.userSearch.q = q; userSearcher.update(q); render(); return true; }, @@ -16259,7 +16575,7 @@ export function messagesFeature(ctx) { visitorSignIn() { toFrontDoor(); return true; }, visitorSearch() { rememberReturn(); - ui.profilePk = null; ui.noteThread = null; ui.profOverThread = false; + ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.profOverThread = false; ui.userSearch = { q: '', rows: null }; warmSearch(); render(); @@ -16272,6 +16588,7 @@ export function messagesFeature(ctx) { return true; }, navPath(snap) { + if (snap && snap.liveStream && snap.liveStream.a) { const n = packNaddr(snap.liveStream.a); if (n) return '/' + n; } if (!snap || !snap.chatOpen || snap.msgView !== 'feed' || snap.profilePk || snap.noteThread || snap.userSearch) return null; const def = feedDef(snap.feedId || curFeedId); return def && adhocFeeds.has(def.id) ? feedPath(def) : null; diff --git a/src/hls-player.js b/src/hls-player.js new file mode 100644 index 0000000..d0878e2 --- /dev/null +++ b/src/hls-player.js @@ -0,0 +1,17 @@ +// HLS where the browser has none of its own (Firefox): hls.js feeds the +// <video> through Media Source. Chrome and Safari play the playlist as a +// plain src, so this file is fetched only when needed. The single-file +// build replaces it with a stub (attachHls → null): no stream there. +export async function attachHls(video, src) { + const { default: Hls } = await import('hls.js'); + if (!Hls.isSupported()) return null; + const hls = new Hls({ lowLatencyMode: true, backBufferLength: 30 }); + hls.loadSource(src); + hls.attachMedia(video); + hls.on(Hls.Events.ERROR, (_, d) => { + if (!d.fatal) return; + if (d.type === Hls.ErrorTypes.MEDIA_ERROR) { hls.recoverMediaError(); return; } + video.dispatchEvent(new Event('hlsfatal')); + }); + return hls; +} diff --git a/src/i18n.js b/src/i18n.js index ef56389..fd70b6c 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -453,6 +453,24 @@ const STR = { feedEmpty: 'Nothing posted yet.', feedLoadingMore: 'Loading more posts…', feedEnd: 'You’re all caught up.', + liveBadge: 'LIVE', + livePlanned: 'SOON', + liveEnded: 'ENDED', + liveUntitled: 'Live stream', + liveWatching: '{n} watching', + liveWatch: 'Watch', + liveZap: 'Zap', + liveChat: 'Chat', + liveChatEmpty: 'No messages yet.', + liveChatHint: 'Say something', + liveSend: 'Send', + liveZapped: 'zapped {n} sats', + liveSomeone: 'Someone', + liveOffline: 'The stream isn’t playing right now.', + liveNoPlayer: 'This browser can’t play the stream.', + liveOver: 'This stream has ended.', + liveStartsAt: 'Starts {when}', + liveNotFound: 'Couldn’t find that stream.', feedAttach: 'Attach a picture or video', postLike: 'Like', postBoost: 'Boost', diff --git a/src/live.js b/src/live.js new file mode 100644 index 0000000..da18f19 --- /dev/null +++ b/src/live.js @@ -0,0 +1,82 @@ +// Live streams (NIP-53). A stream is a kind 30311 event: its title, cover, +// where to watch (`streaming`, almost always an HLS playlist), whether it's +// on (`status`), how many watch, and who's in it (p tags with a role). Its +// chat is kind 1311, addressed to the stream with an `a` tag; zaps carry +// that `a` too, which is how zap.stream and the rest count them. +export const LIVE_KIND = 30311; +export const LIVE_CHAT_KIND = 1311; +// Where streams are announced: zap.stream, Shosho and the rest publish to +// the big public relays (a 2026-10-18 survey found every live one on damus, +// nos.lol and ditto, most on primal; relay.zap.stream held none). +export const LIVE_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.ditto.pub']; +// A stream still marked live that hasn't been refreshed in an hour is over: +// plenty of publishers never say `ended` (NIP-53 allows reading it so). +export const LIVE_STALE_S = 3600; + +// https, or a stream on this machine (a test's, a streamer's own preview) +const WATCHABLE = /^(https:\/\/\S+|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/\S*)$/; +const tag = (ev, k) => ((ev && ev.tags) || []).find((x) => x[0] === k)?.[1] || ''; +export const liveAddr = (ev) => LIVE_KIND + ':' + ev.pubkey + ':' + tag(ev, 'd'); +export const liveTitle = (ev) => tag(ev, 'title').trim(); +export const liveSummary = (ev) => tag(ev, 'summary').trim(); +export const liveImage = (ev) => { const u = tag(ev, 'image').trim(); return /^https:\/\/\S+$/.test(u) ? u : ''; }; +export const liveViewers = (ev) => { const n = parseInt(tag(ev, 'current_participants'), 10); return n >= 0 ? n : null; }; +export const liveStarts = (ev) => +tag(ev, 'starts') || ev.created_at; + +// The person streaming: the host named in its p tags (zap.stream signs the +// event with its own key and names the streamer as Host), else its author. +export function liveHost(ev) { + const p = (ev.tags || []).find((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '') && /^host$/i.test(x[3] || '')); + return p ? p[1] : ev.pubkey; +} +// Where to watch: an HLS playlist first (what browsers can play), else any https address. +export function liveStream(ev) { + const urls = (ev.tags || []).filter((x) => x[0] === 'streaming' && WATCHABLE.test(x[1] || '')).map((x) => x[1]); + return urls.find((u) => /\.m3u8(\?|#|$)/i.test(u)) || urls[0] || ''; +} +export function liveRecording(ev) { + const u = tag(ev, 'recording'); + return WATCHABLE.test(u) ? u : ''; +} +export function liveStatus(ev, now = Date.now() / 1000) { + const s = tag(ev, 'status').toLowerCase(); + if (s === 'live') return now - ev.created_at > LIVE_STALE_S ? 'ended' : 'live'; + return s === 'planned' ? 'planned' : 'ended'; +} +export const isLiveNow = (ev, now) => liveStatus(ev, now) === 'live' && !!liveStream(ev); +// The relays the stream names for its chat, then the ones it's announced on. +export function liveRelays(ev) { + const named = (ev.tags || []).filter((x) => x[0] === 'relays').flatMap((x) => x.slice(1)).filter((r) => /^wss:\/\/\S+$/.test(r)); + return [...new Set([...named.slice(0, 6), ...LIVE_RELAYS])]; +} +// Newest copy of each stream (it's replaceable: the address is the stream). +export function newestByAddr(evs) { + const out = new Map(); + for (const ev of evs) { + if (!ev || ev.kind !== LIVE_KIND) continue; + const a = liveAddr(ev), cur = out.get(a); + if (!cur || cur.created_at < ev.created_at) out.set(a, ev); + } + return out; +} + +// The zap on a chat line: a 9735 receipt's amount from its bolt11, its +// sender and words from the zap request inside it; coinos's own Ark receipt +// (9737) says them in tags. `decode` is the bolt11 decoder (millisats). +export function zapLine(ev, decode) { + if (ev.kind === 9737) { + return { pk: tag(ev, 'P') || ev.pubkey, sats: +tag(ev, 'amount') || 0, text: ev.content || '' }; + } + let req = null; + try { req = JSON.parse(tag(ev, 'description')); } catch {} + let sats = 0; + try { sats = Math.floor(Number(decode(tag(ev, 'bolt11'))?.amountMsat || 0) / 1000); } catch {} + if (!sats && req) sats = Math.floor((+((req.tags || []).find((x) => x[0] === 'amount') || [])[1] || 0) / 1000); + return { pk: (req && /^[0-9a-f]{64}$/.test(req.pubkey || '') && req.pubkey) || tag(ev, 'P') || '', sats, text: (req && req.content) || '' }; +} + +// Zaps aimed at a stream carry its address: the zap request (kind 9734) and +// coinos's Ark receipt look the event they're zapping up here. +const zapAddrs = new Map(); +export const setZapAddr = (eventId, addr) => { if (eventId && addr) zapAddrs.set(eventId, addr); }; +export const zapAddrOf = (eventId) => (eventId && zapAddrs.get(eventId)) || null; diff --git a/src/lnurl.js b/src/lnurl.js index bfebf0d..7b1a798 100644 --- a/src/lnurl.js +++ b/src/lnurl.js @@ -10,6 +10,7 @@ // (bolt11) → hand that to the swap engine. The recipient's server is the one // that broadcasts the receipt; we only sign the request. +import { zapAddrOf } from './live.js'; import { bech32 } from '@scure/base'; import { secp256k1 } from '@noble/curves/secp256k1'; import { hmac } from '@noble/hashes/hmac'; @@ -126,6 +127,9 @@ export function buildZapRequest({ amountMsat, relays, lnurlBech32, recipientPk, const tags = [['relays', ...relays], ['amount', String(amountMsat)], ['p', recipientPk]]; if (lnurlBech32) tags.push(['lnurl', lnurlBech32]); if (eventId) tags.push(['e', eventId]); + // a live stream's address too, so its chat and zap.stream count the zap + const addr = zapAddrOf(eventId); + if (addr) tags.push(['a', addr]); return signFn({ kind: 9734, content: comment || '', tags }); } diff --git a/src/style.css b/src/style.css index 9547ab0..fbd1858 100644 --- a/src/style.css +++ b/src/style.css @@ -886,6 +886,40 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .addr-cover { width: 100%; aspect-ratio: 2 / 1; object-fit: cover; display: block; background: var(--line); } .addr-title { font-weight: 650; font-size: 1.05em; line-height: 1.3; } .addr-summary { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; white-space: normal; } +/* Live streams (NIP-53): the row of what's on above a feed, a profile's + "on now", a post's card, and the stream's own page (player over chat). */ +.live-strip { display: flex; gap: 10px; overflow-x: auto; padding: 2px 0 4px; scrollbar-width: none; } +.live-strip::-webkit-scrollbar { display: none; } +.live-tile { flex: 0 0 168px; display: flex; flex-direction: column; gap: 5px; padding: 0; text-align: left; + background: none; border: 0; color: inherit; cursor: pointer; min-width: 0; } +.live-cover { position: relative; width: 100%; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden; + background: var(--line) center / cover no-repeat; } +.live-badge { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 11px; font-weight: 700; + letter-spacing: .04em; color: #fff; background: #555; line-height: 1.5; flex-shrink: 0; } +.live-badge.live { background: #e0245e; } +.live-cover .live-badge { position: absolute; top: 6px; left: 6px; } +.live-viewers { position: absolute; bottom: 6px; left: 6px; padding: 1px 6px; border-radius: 4px; font-size: 11px; + color: #fff; background: rgba(0, 0, 0, .6); } +.live-tile-title { font-weight: 600; font-size: 14px; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2; + -webkit-box-orient: vertical; overflow: hidden; } +.live-tile-host { align-items: center; min-width: 0; } +.live-tile-host .small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.live-banner { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid #e0245e; + border-radius: 10px; background: none; color: inherit; cursor: pointer; text-align: left; min-width: 0; } +.live-banner-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; } +.live-card { flex-direction: row; align-items: center; gap: 10px; } +.live-card .live-cover { flex: 0 0 120px; width: 120px; } +.live-title { margin: 0; min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.live-player { width: 100%; aspect-ratio: 16 / 9; background: #000; border-radius: 10px; overflow: hidden; } +.live-frame { width: 100%; height: 100%; } +.live-video { width: 100%; height: 100%; display: block; background: #000; } +.live-still { border-radius: 0; height: 100%; display: flex; align-items: center; justify-content: center; } +.live-say { padding: 6px 12px; border-radius: 8px; color: #fff; background: rgba(0, 0, 0, .65); font-size: 14px; text-align: center; } +.live-chat { display: flex; flex-direction: column; gap: 8px; max-height: 46vh; min-height: 120px; overflow-y: auto; overscroll-behavior: contain; } +.live-line { display: flex; gap: 8px; align-items: flex-start; min-width: 0; } +.live-line-body { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; line-height: 1.35; } +.live-zap .live-line-body b { color: var(--accent); } +.live-input { min-width: 0; } .article-title { font-size: 1.35em; font-weight: 700; line-height: 1.25; margin-bottom: 6px; white-space: normal; } .article-h1, .article-h2, .article-h3 { font-weight: 650; line-height: 1.3; margin: 10px 0 2px; white-space: normal; } .article-h1 { font-size: 1.2em; } .article-h2 { font-size: 1.1em; } .article-h3 { font-size: 1em; } |
