aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 08:16:57 -0700
committerAdam Soltys <[email protected]>2026-10-18 08:17:11 -0700
commita9cdc8a6fd7abd38a185f9d8828531374307cf41 (patch)
tree04aa9267fcc9538f50ccf0319fa094e16a05934f /src/features/messages.js
parent88194134aebe4a516f32afd39ac95cc757024e9b (diff)
downloadcoinosv3-a9cdc8a6fd7abd38a185f9d8828531374307cf41.tar.gz
coinosv3-a9cdc8a6fd7abd38a185f9d8828531374307cf41.zip
Live rings and going live. A face whose person is streaming wears a red ring (LIVE under the bigger ones) and opens the stream. Go live (the composer's red dot, or your profile) sends your camera or screen to coinos's live server (coinosv3-server live/: the recording over a WebSocket, HLS out), announces it as your kind 30311 once three segments exist (Chrome's own player won't start a live playlist with fewer), refreshes it each minute with how many watch, reconnects to the same stream if the link drops, and ends it with its recording. Needs a coinos name. Also: the service worker leaves /live/ and /vmedia/hls alone (it froze a live playlist at its first copy).
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js317
1 files changed, 301 insertions, 16 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 6179404..33ba903 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -3888,6 +3888,14 @@ export function messagesFeature(ctx) {
node.onclick = (e) => { e.stopPropagation(); openProfile(pk); };
(node._evs || (node._evs = new Set())).add('onpointerdown').add('onclick');
}
+ // streaming now: a red ring, and the face opens the stream (the name
+ // beside it still opens the profile, which says they're on too)
+ let on = null;
+ try { on = liveHostNow(pk); } catch {} // (an avatar drawn while the feature is still being built)
+ if (on) {
+ node.classList.add('live-ring');
+ if (clickable) node.onclick = (e) => { e.stopPropagation(); openLive(on); };
+ }
return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node;
};
@@ -5785,7 +5793,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.liveStream = null; ui.feedEdit = null; ui.profOverThread = false;
+ ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.profOverThread = false;
ui.chatOpen = true; ui.msgView = 'feed';
switchFeed(id);
}
@@ -5832,7 +5840,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.liveStream = null; ui.userSearch = null;
+ ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null;
ui.chatOpen = true; ui.msgView = 'feed';
openFeedEditor(null);
ui.feedEdit.authors = [pk];
@@ -6151,7 +6159,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.liveStream = null; ui.feedEdit = null;
+ ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = 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);
@@ -6352,7 +6360,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.liveStream = null; ui.feedEdit = null;
+ ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null;
ui.chatOpen = true; ui.msgView = 'feed';
switchFeed(id);
}
@@ -8471,8 +8479,26 @@ export function messagesFeature(ctx) {
const cur = liveSeen.get(a);
if (!cur || cur.created_at < ev.created_at) { liveSeen.set(a, ev); changed = true; }
}
+ if (changed) liveHostsAt = 0;
return changed;
}
+ // who is on now (host -> their stream), for the rings round faces: worked
+ // out again when a stream changes and every half minute (one goes stale)
+ let liveHostsAt = 0, liveHosts = new Map();
+ function liveHostNow(pk) {
+ if (!liveSeen.size) return null;
+ if (Date.now() - liveHostsAt > 30_000) {
+ liveHostsAt = Date.now();
+ const now = Date.now() / 1000;
+ liveHosts = new Map();
+ for (const ev of liveSeen.values()) {
+ if (!isLiveNow(ev, now) || !liveShowable(ev)) continue;
+ const h = liveHost(ev), cur = liveHosts.get(h);
+ if (!cur || cur.created_at < ev.created_at) liveHosts.set(h, ev);
+ }
+ }
+ return liveHosts.get(pk) || null;
+ }
const liveSince = () => Math.floor(Date.now() / 1000) - 6 * 3600;
function refreshLive() {
if (liveAsking || Date.now() - liveAt < 120_000) return;
@@ -8610,7 +8636,7 @@ export function messagesFeature(ctx) {
// 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');
+ const el = document.querySelector('video.live-video:not(.bc-preview)');
if (!w || !el || el === w.video) return;
stopLivePlayer(w);
const ev = liveSeen.get(w.a);
@@ -8642,6 +8668,10 @@ export function messagesFeature(ctx) {
// it (a <video> taken out of the page plays on, unheard and unseen)
let liveShown = false;
function liveAfterRender() {
+ // the camera shown before going live: let go of it once that screen is left
+ if (!ui.goLive && bc && bc.phase === 'setup') { bcStopMedia(); bc = null; }
+ const pv = document.querySelector('video.bc-preview');
+ if (pv && bc && bc.media && pv.srcObject !== bc.media) { pv._skipMorph = true; pv.srcObject = bc.media; pv.play().catch(() => {}); }
if (!ui.liveStream) { stopLiveWatch(); return; }
if (!liveShown) { stopLivePlayer(); return; }
mountLivePlayer();
@@ -8689,6 +8719,9 @@ export function messagesFeature(ctx) {
const s = ui.liveStream;
const w = watchLive(s);
liveShown = true;
+ // your own stream, going out: the camera itself, not the stream seconds behind it
+ const mine = !!bc && bc.phase === 'live' && bc.a === s.a;
+ if (mine) setTimeout(scheduleRepaint, 1000); // the clock
const ev = liveSeen.get(s.a) || s.seed;
const host = liveHost(ev), status = liveStatus(ev), n = liveViewers(ev);
const src = livePlayable(ev);
@@ -8707,10 +8740,20 @@ export function messagesFeature(ctx) {
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; }),
+ mine ? h('button', { class: 'iconbtn chat-back', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, '‹')
+ : 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 },
+ mine ? h('div', { class: 'row gap6 bc-bar', style: 'align-items:center' },
+ h('span', { class: 'bc-clock' }, clockOf(Date.now() / 1000 - bc.starts)),
+ h('span', { class: 'small muted' }, t('liveWatching', { n: bc.viewers || 0 })),
+ bc.reconnecting ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveReconnecting'))
+ : bc.behind ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveBehind')) : null,
+ h('button', { class: 'btn-sm bc-end', style: 'margin-left:auto', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, t('goLiveEnd'))) : null,
+ mine ? h('div', { class: 'live-player', 'data-key': 'bc-player:' + s.a },
+ h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + (bc.media ? bc.media.id : '') },
+ h('video', { class: 'live-video bc-preview' + (bc.source === 'camera' && bc.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' })))
+ : 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
@@ -8744,6 +8787,243 @@ export function messagesFeature(ctx) {
...noteOverlays());
}
+ // ---- going live -----------------------------------------------------------
+ // The camera (or the screen) recorded in half-second chunks and sent over a
+ // WebSocket to coinos's live server (coinosv3-server live/), which makes HLS
+ // of it; the stream is announced as your kind 30311, refreshed every minute
+ // with how many watch, and ended with its recording. A coinos name's owner
+ // only: the server asks the registrar. One at a time.
+ const liveIngest = () => {
+ const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname);
+ return (local ? location.origin : APP_BASE).replace(/^http/, 'ws') + '/live/ingest';
+ };
+ const recType = () => {
+ if (typeof MediaRecorder === 'undefined') return null;
+ return ['video/webm;codecs=h264,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'].find((m) => { try { return MediaRecorder.isTypeSupported(m); } catch { return false; } }) || null;
+ };
+ const canGoLive = () => !isVisitor() && typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia && !!recType();
+ const clockOf = (sec) => { sec = Math.max(0, Math.floor(sec)); const hh = Math.floor(sec / 3600), mm = Math.floor(sec / 60) % 60, ss = sec % 60; return (hh ? hh + ':' + String(mm).padStart(2, '0') : String(mm)) + ':' + String(ss).padStart(2, '0'); };
+ let bc = null; // the broadcast: { phase, title, source, facing, media, rec, ws, id, a, ev, err, ... }
+ function openGoLive() {
+ if (bc && bc.a && bc.phase === 'live') { openLive(bc.ev); return; }
+ bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null };
+ ui.goLive = true;
+ ui.liveStream = null;
+ render();
+ bcPreview().catch(() => {});
+ }
+ function bcStopMedia() {
+ if (bc && bc.media) for (const tr of bc.media.getTracks()) { try { tr.stop(); } catch {} }
+ if (bc) bc.media = null;
+ }
+ // the picture to send: the camera (with its microphone), or the screen
+ // (with the microphone beside it, and the tab's sound when shared)
+ async function bcPreview() {
+ const b = bc;
+ if (!b) return;
+ bcStopMedia();
+ b.err = null;
+ try {
+ let media;
+ if (b.source === 'screen') {
+ media = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true });
+ const mic = await navigator.mediaDevices.getUserMedia({ audio: true }).catch(() => null);
+ if (mic && !media.getAudioTracks().length) for (const tr of mic.getAudioTracks()) media.addTrack(tr);
+ // the browser's own "Stop sharing" ends the stream
+ media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live') endBroadcast(); });
+ } else {
+ media = await navigator.mediaDevices.getUserMedia({
+ video: { facingMode: b.facing, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
+ audio: { echoCancellation: true, noiseSuppression: true },
+ });
+ }
+ if (bc !== b) { for (const tr of media.getTracks()) tr.stop(); return; }
+ b.media = media;
+ // a switch mid-stream: the recorder carries on with the new picture
+ if (b.phase === 'live') bcRecord(b, true);
+ } catch (e) {
+ if (bc === b) b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera');
+ }
+ render();
+ }
+ async function liveAuth(b) {
+ const id = await requireIdentity();
+ const partial = { kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '',
+ tags: [['u', APP_BASE + '/live/ingest'], ['method', 'GET']] };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ b.pk = id.pubkey;
+ return btoa(JSON.stringify(evt));
+ }
+ // one connection to the server: its first word is where the stream is
+ function bcConnect(b, resume) {
+ return liveAuth(b).then((auth) => new Promise((resolve, reject) => {
+ const name = (hook('namesAddress') || '').split('@')[0];
+ const ws = new WebSocket(liveIngest() + '?auth=' + encodeURIComponent(auth) + '&name=' + encodeURIComponent(name) + (resume ? '&resume=' + resume : ''));
+ ws.binaryType = 'arraybuffer';
+ let hello = false;
+ ws.onmessage = (e) => {
+ if (hello || typeof e.data !== 'string') return;
+ hello = true;
+ try { resolve({ ws, info: JSON.parse(e.data) }); } catch (err) { reject(err); }
+ };
+ ws.onerror = () => { if (!hello) reject(new Error(t('goLiveRefused'))); };
+ ws.onclose = () => {
+ if (!hello) { reject(new Error(t('goLiveRefused'))); return; }
+ if (bc === b && b.ws === ws && b.phase === 'live') bcReconnect(b);
+ };
+ }));
+ }
+ // a recorder over the current picture, its chunks to the server; a new one
+ // (a new connection, a new camera) starts a fresh recording, header and all
+ function bcRecord(b, again = false) {
+ if (b.rec) { try { b.rec.ondataavailable = null; b.rec.stop(); } catch {} }
+ if (again && b.ws) { try { b.ws.close(1000, 'switch'); } catch {} return; } // the reconnect brings the new recording
+ const rec = new MediaRecorder(b.media, { mimeType: recType(), videoBitsPerSecond: 2_500_000, audioBitsPerSecond: 128_000 });
+ rec.ondataavailable = (e) => {
+ if (!e.data || !e.data.size || bc !== b || !b.ws || b.ws.readyState !== 1) return;
+ // more than a few seconds unsent: the link can't keep up
+ b.behind = b.ws.bufferedAmount > 4_000_000;
+ e.data.arrayBuffer().then((buf) => { if (b.ws && b.ws.readyState === 1) b.ws.send(buf); });
+ };
+ rec.start(500);
+ b.rec = rec;
+ }
+ async function bcReconnect(b) {
+ if (b.reconnecting) return;
+ b.reconnecting = true;
+ b.ws = null;
+ render();
+ for (let i = 0; i < 10 && bc === b && b.phase === 'live'; i++) {
+ try {
+ const { ws, info } = await bcConnect(b, b.id);
+ if (bc !== b || b.phase !== 'live') { ws.close(); break; }
+ b.ws = ws;
+ bcRecord(b);
+ b.reconnecting = false;
+ if (info.id !== b.id) toast(t('goLiveRestarted'));
+ render();
+ return;
+ } catch {}
+ await new Promise((r) => setTimeout(r, 3000));
+ }
+ b.reconnecting = false;
+ if (bc === b && b.phase === 'live') { toast(t('goLiveLost')); endBroadcast(); }
+ }
+ // the announcement: your 30311, and its copies as the stream goes on
+ async function bcAnnounce(b, status) {
+ const id = await requireIdentity();
+ const relays = [...new Set([...LIVE_RELAYS, ...wallet.nostrRelays()])];
+ const me = profileOf(id.pubkey) || {};
+ const tags = [['d', b.id], ['title', b.title || t('goLiveDefaultTitle', { name: displayName(id.pubkey) })],
+ ['status', status], ['streaming', b.liveUrl], ['starts', String(b.starts)],
+ ['p', id.pubkey, '', 'Host'], ['relays', ...relays.slice(0, 6)], CLIENT_TAG];
+ if (me.picture) tags.push(['image', me.picture]);
+ if (status === 'live' && b.viewers != null) tags.push(['current_participants', String(b.viewers)]);
+ if (status === 'ended') tags.push(['ends', String(Math.floor(Date.now() / 1000))], ['recording', b.recording]);
+ const partial = { kind: LIVE_KIND, content: '', created_at: Math.floor(Date.now() / 1000), tags };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ takeLive([evt]);
+ b.ev = evt;
+ scheduleRepaint();
+ return publishOn(relays, evt);
+ }
+ async function startBroadcast() {
+ const b = bc;
+ if (!b || b.phase !== 'setup' || !b.media) return;
+ b.phase = 'starting'; b.err = null;
+ render();
+ try {
+ const { ws, info } = await bcConnect(b, null);
+ if (bc !== b) { ws.close(); return; }
+ Object.assign(b, { ws, id: info.id, liveUrl: info.live, recording: info.recording, starts: Math.floor(Date.now() / 1000), viewers: 0 });
+ b.phase = 'live';
+ b.a = LIVE_KIND + ':' + b.pk + ':' + b.id;
+ bcRecord(b);
+ try { b.lock = await navigator.wakeLock?.request('screen'); } catch {}
+ // the stream is a few seconds behind the camera: announced once it has
+ // three segments — Chrome's own player won't start a live playlist with
+ // fewer (it fails to parse it), so a viewer who comes at once would get
+ // an error, not the stream
+ for (let i = 0; i < 40 && bc === b && b.phase === 'live'; i++) {
+ await new Promise((r) => setTimeout(r, 500));
+ const pl = await fetch(b.liveUrl, { cache: 'no-store' }).then((r) => r.text()).catch(() => '');
+ if ((pl.match(/\.ts\s*$/gm) || []).length >= 3) break;
+ }
+ if (bc !== b || b.phase !== 'live') return;
+ await bcAnnounce(b, 'live');
+ b.tick = setInterval(() => bcTick(b), 60_000);
+ ui.goLive = null;
+ openLive(b.ev);
+ } catch (e) {
+ if (bc !== b) return;
+ b.phase = 'setup';
+ b.err = (e && e.message) || t('goLiveRefused');
+ try { b.ws && b.ws.close(); } catch {}
+ render();
+ }
+ }
+ // every minute: how many watch, into a fresh copy of the announcement
+ async function bcTick(b) {
+ if (bc !== b || b.phase !== 'live') return;
+ try {
+ const st = await (await fetch(b.liveUrl.replace(/live\.m3u8$/, 'stats'))).json();
+ b.viewers = st.viewers;
+ } catch {}
+ bcAnnounce(b, 'live').catch(() => {});
+ }
+ async function endBroadcast() {
+ const b = bc;
+ if (!b || b.phase === 'ended') return;
+ const was = b.phase;
+ b.phase = 'ended';
+ clearInterval(b.tick);
+ try { b.rec && b.rec.stop(); } catch {}
+ try { if (b.ws && b.ws.readyState === 1) b.ws.send('end'); } catch {}
+ setTimeout(() => { try { b.ws && b.ws.close(1000, 'ended'); } catch {} }, 500);
+ bcStopMedia();
+ try { b.lock && b.lock.release(); } catch {}
+ bc = null;
+ ui.goLive = null;
+ render();
+ if (was === 'live' && b.id) {
+ await bcAnnounce(b, 'ended').catch(() => {});
+ toast(t('goLiveEnded'));
+ }
+ }
+ // the screen before going live: what to send, a title, the picture itself
+ function goLiveScreen() {
+ if (!bc) { bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null }; queueMicrotask(() => bcPreview().catch(() => {})); } // (a reload, back on this screen)
+ const b = bc;
+ const name = hook('namesAddress');
+ const screenOk = !!navigator.mediaDevices?.getDisplayMedia;
+ queueMicrotask(() => {
+ const v = document.querySelector('video.bc-preview');
+ if (v && b.media && v.srcObject !== b.media) { v._skipMorph = true; v.srcObject = b.media; v.play().catch(() => {}); }
+ });
+ const pick = (src) => { if (b.source === src) return; b.source = src; render(); bcPreview(); };
+ return h('div', { class: 'col live-page', style: 'gap:12px' },
+ ctx.brandHeader(true),
+ h('div', { class: 'card col', style: 'gap:12px' },
+ h('div', { class: 'row gap6', style: 'align-items:center' },
+ backBtn(() => { bcStopMedia(); bc = null; ui.goLive = null; }),
+ h('h3', { style: 'margin:0' }, t('goLive'))),
+ !name ? h('div', { class: 'notice small' }, t('goLiveNeedsName')) : null,
+ h('div', { class: 'live-player', 'data-key': 'bc-player:' + b.source },
+ b.media
+ ? h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + b.media.id }, h('video', { class: 'live-video bc-preview' + (b.source === 'camera' && b.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' }))
+ : h('div', { class: 'live-cover live-still', 'data-key': 'bc-still' }, h('span', { class: 'live-say' }, b.err || t('goLiveAsking')))),
+ h('div', { class: 'row gap6 wrap' },
+ h('button', { class: 'btn-sm' + (b.source === 'camera' ? ' on' : ''), onClick: () => pick('camera') }, t('goLiveCamera')),
+ screenOk ? h('button', { class: 'btn-sm' + (b.source === 'screen' ? ' on' : ''), onClick: () => pick('screen') }, t('goLiveScreen')) : null,
+ b.source === 'camera' ? h('button', { class: 'btn-sm', title: t('goLiveFlip'), onClick: () => { b.facing = b.facing === 'user' ? 'environment' : 'user'; bcPreview(); } }, '⇄ ' + t('goLiveFlip')) : null),
+ h('input', { type: 'text', class: 'go-live-title', placeholder: t('goLiveTitlePh'), value: b.title, maxlength: '120',
+ onInput: (e) => { b.title = e.target.value; } }),
+ b.err && b.media ? h('div', { class: 'notice err small' }, b.err) : null,
+ h('button', { class: 'btn-primary btn-block go-live-start', disabled: !name || !b.media || b.phase !== 'setup', onClick: startBroadcast },
+ b.phase === 'starting' ? h('span', { class: 'spinner sm' }) : null, ' ', t(b.phase === 'starting' ? 'goLiveStarting' : 'goLiveStart')),
+ h('div', { class: 'small faint' }, t('goLiveHint'))));
+ }
+
// 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) {
@@ -9209,7 +9489,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.liveStream = null;
+ ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null;
ui.userSearch = { q, rows: null };
warmSearch();
userSearcher.update(q);
@@ -9530,7 +9810,7 @@ export function messagesFeature(ctx) {
const next = (cur ? cur + '\n\n' : '') + ref;
ui.profCompose = next;
setDraft(POST_DRAFT, next);
- ui.noteThread = null; ui.liveStream = null;
+ ui.noteThread = null; ui.liveStream = null; ui.goLive = null;
ui.profilePk = null;
ui.chatOpen = true;
ui.msgView = 'feed';
@@ -11248,7 +11528,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; ui.liveStream = null; }) }, t('back'))
+ ? h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; ui.liveStream = null; ui.goLive = null; }) }, t('back'))
: null,
...noteOverlays());
}
@@ -11528,6 +11808,8 @@ export function messagesFeature(ctx) {
// the hat pitch lives beside the avatar it decorates
mine ? hook('hatShopEntry') : null)),
ui.profEdit ? null : liveBanner(pk),
+ mine && !ui.profEdit && canGoLive() && !liveNowOf(pk) ? h('button', { class: 'btn-sm go-live-btn', style: 'align-self:flex-start', onClick: openGoLive },
+ h('span', { class: 'go-live-dot' }), t('goLive')) : null,
// 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
@@ -11654,7 +11936,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.liveStream = null; ui.userSearch = null; ui.feedEdit = null;
+ ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null; ui.feedEdit = null;
ui.chatOpen = true;
ui.msgView = 'dm';
ui.msgPeer = peer;
@@ -11668,7 +11950,7 @@ export function messagesFeature(ctx) {
// and the Send tab would never appear
ui.profilePk = null;
ui.profOverThread = false;
- ui.noteThread = null; ui.liveStream = null;
+ ui.noteThread = null; ui.liveStream = null; ui.goLive = null;
ui.userSearch = null;
ui.chatOpen = false;
ctx.showSend({ fresh: true });
@@ -11895,7 +12177,9 @@ export function messagesFeature(ctx) {
type: 'button', class: 'compose-tool', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading,
onClick: () => document.getElementById('post-file')?.click(),
}, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null,
- gifs.button('post', 'compose-tool gif-btn'))),
+ gifs.button('post', 'compose-tool gif-btn'),
+ canGoLive() ? h('button', { type: 'button', class: 'compose-tool go-live-tool', title: t('goLive'), 'aria-label': t('goLive'), onClick: openGoLive },
+ h('span', { class: 'go-live-dot' })) : null)),
mentionList('.post-input'),
ui.postPreview ? draftPreview(text) : null,
mediaBar(),
@@ -12007,7 +12291,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.liveStream = null; ui.feedRelayEdit = null;
+ ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedRelayEdit = null;
render();
try { window.scrollTo({ top: 0 }); } catch {}
}
@@ -16449,6 +16733,7 @@ export function messagesFeature(ctx) {
return null;
}
if (ui.zapSetup) return zapSetupScreen();
+ if (ui.goLive) return goLiveScreen();
if (ui.liveStream) return liveScreen();
if (ui.profOverThread && ui.profilePk) return profileScreen();
if (ui.noteThread) return threadScreen();
@@ -16463,7 +16748,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.liveStream = 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.goLive = null; ui.userSearch = { q: '', rows: null }; warmSearch(); }
ui.userSearch.q = q; userSearcher.update(q); render();
return true;
},
@@ -16572,7 +16857,7 @@ export function messagesFeature(ctx) {
visitorSignIn() { toFrontDoor(); return true; },
visitorSearch() {
rememberReturn();
- ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.profOverThread = false;
+ ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.profOverThread = false;
ui.userSearch = { q: '', rows: null };
warmSearch();
render();