diff options
Diffstat (limited to 'src/app.js')
| -rw-r--r-- | src/app.js | 180 |
1 files changed, 104 insertions, 76 deletions
@@ -1,3 +1,4 @@ +import { customMediaServers, saveMediaServers, resetMediaServers } from './media-servers.js'; // Bitcoin Wallet — UI controller (vanilla DOM, no framework). // // State lives in `ui` + the singleton `wallet`. Mutating handlers call render(), @@ -11,7 +12,7 @@ import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './rec import { assetUrl } from './asset-url.js'; import { npubOf, seedPubkey, neventOf, parseNostrPubkey } from './nostr.js'; import { nip98Header } from './nip98.js'; -import { uploadPublicMedia, MediaUploadError } from './media-upload.js'; +import { uploadPublicMedia, uploadStream, MediaUploadError } from './media-upload.js'; import { NOSTR_MARK } from './features/nostrlogin.js'; import { scanQr } from './scan.js'; import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js'; @@ -853,7 +854,9 @@ function renderInner() { if (bottomNav) bottomNav.prepend(h('div', { class: 'app-nav-wide app-nav-brand' }, brandLogo())); // the wallet beside the feed and chats on a wide screen (its own page needs no twin) const side = bottomNav && featScreen && WIDE_MQ && WIDE_MQ.matches ? walletSide() : null; - morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : [])]); + // a call, over whatever page is up (the messages feature's) + const callOv = activeAccount() ? featureHook('callOverlay') : null; + morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : []), ...(callOv ? [callOv] : [])]); promoteLazySrc(root); // sources on the nodes that actually made it into the page try { document.documentElement.classList.toggle('no-scroll', !!(ui.lightbox || ui.mediaPager)); } catch {} if (fpath) { @@ -909,7 +912,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', 'goLive', 'userSearch', 'zapSetup', 'hatShop', 'lightbox', 'mediaPager', 'feedEdit', 'settingsView']; function navSnapshot() { const s = {}; for (const f of NAV_FIELDS) s[f] = ui[f] ?? null; @@ -917,6 +920,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 +943,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 +1924,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.goLive = null; ui.zapSetup = null; } // Sign out of the CURRENT identity only. "Log out" locks every account on @@ -3027,11 +3031,33 @@ function registerLinkHandler(scheme) { toast(t('linkHandlerAsked')); } catch (e) { toast(t('linkHandlerRefused')); } } +function mediaServersCard() { + if (ui.mediaServersDraft == null) ui.mediaServersDraft = (customMediaServers() || []).join('\n'); + const save = (reset = false) => { + try { + if (reset) { resetMediaServers(); ui.mediaServersDraft = ''; } + else ui.mediaServersDraft = saveMediaServers(ui.mediaServersDraft).join('\n'); + toast(t('mediaServersSaved')); + render(); + } catch (e) { toast(t(e.message === 'invalid' ? 'mediaServersInvalid' : 'mediaServersSaveFailed')); } + }; + return h('div', { class: 'card col', 'data-key': 'media-servers' }, + h('h3', {}, t('mediaServersTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('mediaServersHow')), + h('textarea', { rows: 3, style: 'font-size:12px', 'aria-label': t('mediaServersTitle'), + placeholder: t('mediaServersDefault'), value: ui.mediaServersDraft, spellcheck: 'false', + onInput: (e) => { ui.mediaServersDraft = e.target.value; } }), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost small', onClick: () => save() }, t('syncStorageSave')), + h('button', { class: 'btn-ghost small', onClick: () => save(true) }, t('syncStorageReset')))); +} + function nostrSettingsView() { return h( 'div', { class: 'col', style: 'gap:16px' }, ...featureAll('nostrSettingsCards'), + mediaServersCard(), // The browser's own "open these links here" registry: njump's "Your // default web app" is a web+nostr: link, and it opens whichever site // asked for that scheme last. Needs a tap (the browser asks). @@ -3220,7 +3246,7 @@ function goHome() { ui.profilePk = null; ui.profEdit = null; ui.userSearch = null; - ui.noteThread = null; + ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.zapSetup = null; ui.arkExitPage = null; ui.settingsPage = null; @@ -3829,10 +3855,18 @@ function mediaProgress(stage, fraction) { } async function publicMediaUpload(file) { try { - // a phone video is shrunk on the device first (see video-compress.js) + // a phone video is shrunk on the device first (see video-compress.js); + // a long one becomes an HLS stream, its master playlist the address if (/^video\//.test(file?.type || '')) { mediaProgress('shrink', 0); - const { compressVideo } = await import('./video-compress.js'); + const { compressVideo, streamVideo } = await import('./video-compress.js'); + const stream = await streamVideo(file, (p) => mediaProgress('shrink', p)); + if (stream) { + mediaProgress('upload', 0); + const url = await uploadStream(stream, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) }); + return { url, m: 'application/vnd.apple.mpegurl', dim: stream.dim, duration: Math.round(stream.seconds) }; + } + mediaProgress('shrink', 0); file = await compressVideo(file, (p) => mediaProgress('shrink', p)); } mediaProgress('upload', 0); @@ -6612,18 +6646,29 @@ if (INTENT_URI) { // The feed scrolls like a phone on a desktop. A mouse wheel's notch gives // it a push that coasts and slows, so a few turns travel a few posts and // quick turns stack up; a trackpad (which already coasts) is left alone. -// And a mouse can grab the feed and flick it: drag up or down, let go while -// moving and it keeps going. A drag that starts sideways is still a text -// selection, and a drag that moved isn't a click on what it started on. +// (A mouse drag used to grab the feed and flick it; it selects text again — +// Adam, 2026-10-18: "i'd rather be able to select text normally".) (() => { if (typeof window === 'undefined' || matchMedia('(prefers-reduced-motion: reduce)').matches) return; - const SCOPE = '.feed-page, .notes-feed'; + // The wheel coasts whenever a feed or a thread is the page being scrolled + // — wherever the pointer happens to be: over the posts, the header, the + // rail, the wallet column beside the feed on a wide screen (Adam, + // 2026-10-16: "when my cursor is hovered over the right side bar or any + // part of the page that's not the feed, wheeling scrolls without + // coasting. can we make it coast no matter where my pointer is?") — + // except over something that takes the wheel itself: a text box, the + // chips strip, the lightbox, the pager, a box that scrolls on its own. + const SCOPE = '.feed-page, .notes-feed, .thread-page'; // the pages that coast (a thread scrolls the same as a feed) const TAU = 325; // ms: how quickly a coast dies down (the phone's feel) const VMAX = 16; // px/ms const inScope = (t) => { const el = t && t.closest ? t : t && t.parentElement; - if (!el || !el.closest(SCOPE)) return false; - if (el.closest('input, textarea, select, video, audio, [contenteditable], .feed-chips, .lightbox, .media-pager')) return false; + if (!el || !document.querySelector(SCOPE)) return false; + // (A video or an audio player is part of the feed: a notch over a clip + // coasts like one over the words — the wheel does nothing to a player + // itself, so there is nothing to leave it. Adam, 2026-10-16: "over a + // video it doesn't initiate a coast, just a single notch".) + if (el.closest('input, textarea, select, [contenteditable], .feed-chips, .lightbox, .media-pager')) return false; // something inside that scrolls on its own (a caption, a list) keeps its wheel for (let n = el; n && n !== document.body; n = n.parentElement) { if (n.scrollHeight > n.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(n).overflowY)) return false; @@ -6653,16 +6698,28 @@ if (INTENT_URI) { if (!raf && v) { last = performance.now(); raf = requestAnimationFrame(tick); } }; // ---- the wheel - // A turn back against the coast catches the page at once and is a plain - // notch from rest — no flight the other way, since the next couple of - // notches after a reversal (CALM) don't wind the speed up, so a couple of - // correcting notches travel a couple of notches; a spin that keeps going - // winds up again from the third. (A spell of quarter-size "hunting" - // notches ramping back up was tried here and felt like a stutter and a - // slow patch; a 700 ms spell without wind-up after a reversal was tried - // too and a spin begun inside it felt slow — Adam, 2026-10-16.) - const CALM = 2; // notches after a reversal without wind-up - let calmLeft = 0; // (lastDir/lastMoveAt: the tick's, above) + // A turn back against a page that is flying is a brake: that notch only + // eases the coast to a stop (a quick easing, not a dead halt), and goes + // nowhere the other way. The next notch the same way is a gentle start + // back — half a notch's travel at half the speed — and the ones after + // wind up as usual. So the reader flying past a post stops on the first + // turn back, creeps back on the second, and gets going on the third. + // Against a coast that is merely strolling, or one that only just died, + // there is nothing worth braking: that turn back is the gentle start + // itself. (A spell of quarter-size "hunting" notches ramping back up over + // five turns was tried here and felt like a stutter and a slow patch; a + // full notch then two without wind-up felt like an overshoot; a gentle + // push straight away from a flight still overshot — Adam, 2026-10-16: + // "the first click of the wheel should just stop the coasting/flying + // maybe with a quick easing rather than a sudden stop and then the next + // notch should start going slowly in reverse and the next ones should + // start speeding up".) + const REVERSE = 0.5; // of a notch: the gentle start back + const BRAKE_V = 1.2; // px/ms: flying, worth a brake (a notch from rest peaks near 0.9) + const BRAKE_TAU = 40; // ms: the brake's easing (a flight at 8 px/ms stops within ~300 px) + const BRAKE_FOR = 2500; // ms: how long after a brake the next notch that way is the gentle start + let brake = null; // { dir, until }: the turn back that braked, awaiting its gentle start + // (lastDir/lastMoveAt: the tick's, above) let smoothUntil = 0; // a trackpad's events were just seen: hands off window.addEventListener('wheel', (e) => { if (e.ctrlKey || e.shiftKey || e.defaultPrevented) return; @@ -6676,72 +6733,43 @@ if (INTENT_URI) { const now = e.timeStamp; // the way the page is going: its coast, or the coast that only just died const going = v ? Math.sign(v) : now - lastMoveAt < 400 ? lastDir : 0; - if (going && dir !== going) { - // A reversal catches the old coast immediately. Discard its fractional - // step and frame clock too: after a busy frame, applying the NEW velocity - // to the OLD elapsed time made the first reverse step jump. Give the - // notch a small response now, then coast the rest of its travel. + const gentle = () => { + // The gentle start back catches whatever coast is left immediately. + // Discard its fractional step and frame clock too: after a busy frame, + // applying the NEW velocity to the OLD elapsed time made the first + // reverse step jump. A small response now, then the rest of its + // (half) travel coasts. stop(); - calmLeft = CALM; - const first = dir * Math.min(16, Math.abs(px * 3 / TAU) * 16); + brake = null; + const travel = px * 3 * REVERSE; + const first = dir * Math.min(16, Math.abs(travel / TAU) * 16); window.scrollBy({ top: first, behavior: 'instant' }); lastDir = dir; lastMoveAt = now; - coast((px * 3 - first) / TAU, TAU); + coast((travel - first) / TAU, TAU); + }; + if (going && dir !== going && Math.abs(v) >= BRAKE_V) { + // flying: this notch only brakes — the coast eases out over a few + // frames (from now: the frames a busy thread missed don't travel first) + tau = BRAKE_TAU; acc = 0; last = performance.now(); + brake = { dir, until: now + BRAKE_FOR }; + } else if (going && dir !== going) { + gentle(); + } else if (brake && brake.dir === dir && now < brake.until) { + gentle(); // the notch after a brake: the gentle start back } else { + brake = null; // A notch into a page already moving pushes harder: the faster it // goes, the more each turn adds (up to 2.5x), so keeping the wheel // turning winds the speed up instead of settling at a stroll. From // rest a notch is the same push as ever. - const gain = calmLeft-- > 0 ? 1 : 1 + Math.min(1.5, Math.abs(v) / 4); + const gain = 1 + Math.min(1.5, Math.abs(v) / 4); coast(v + (px * 3 * gain) / TAU, TAU); } }, { passive: false }); // anything else that moves the page takes over from a coast window.addEventListener('keydown', (e) => { if (/^(Arrow|Page|Home|End| )/.test(e.key)) stop(); }); window.addEventListener('touchstart', stop, { passive: true }); - // ---- grab and flick - let drag = null; - document.addEventListener('pointerdown', (e) => { - if (raf) stop(); // a click catches a coasting page, like a finger does - if (e.pointerType !== 'mouse' || e.button !== 0 || !inScope(e.target)) return; - drag = { x: e.clientX, y: e.clientY, ly: e.clientY, on: false, pts: [] }; - }); - document.addEventListener('dragstart', (e) => { if (drag) e.preventDefault(); }, true); // images/links: scroll, don't drag them out - document.addEventListener('pointermove', (e) => { - if (!drag) return; - if (!(e.buttons & 1)) { drag = null; return; } - if (drag.sel) return; - if (!drag.on) { - const dx = Math.abs(e.clientX - drag.x), dy = Math.abs(e.clientY - drag.y); - if (dx < 6 && dy < 6) return; - // clearly sideways: selecting text (and still not a click on the post) - if (dx > 2 * dy) { drag.sel = true; return; } - drag.on = true; - document.documentElement.classList.add('grab-scrolling'); - try { getSelection().removeAllRanges(); } catch {} - } - window.scrollBy({ top: drag.ly - e.clientY, behavior: 'instant' }); - drag.ly = e.clientY; - drag.pts.push([e.timeStamp, e.clientY]); - while (drag.pts.length > 2 && e.timeStamp - drag.pts[0][0] > 100) drag.pts.shift(); - }); - const end = (e) => { - if (!drag) return; - const d = drag; drag = null; - if (!d.on && !d.sel) return; - if (d.on) { - document.documentElement.classList.remove('grab-scrolling'); - const p = d.pts, a = p[0], b = p[p.length - 1]; - // released while moving: throw it; held still first: just let go - if (a && b && b[0] > a[0] && e.timeStamp - b[0] < 60) coast((a[1] - b[1]) / (b[0] - a[0]) * 2.4); - } - // the press moved: what it started on isn't opened (Chrome still clicks it) - const eat = (ev) => { ev.stopPropagation(); ev.preventDefault(); }; - document.addEventListener('click', eat, { capture: true, once: true }); - setTimeout(() => document.removeEventListener('click', eat, { capture: true }), 300); - }; - document.addEventListener('pointerup', end); - document.addEventListener('pointercancel', () => { if (drag?.on) document.documentElement.classList.remove('grab-scrolling'); drag = null; }); + document.addEventListener('pointerdown', () => { if (raf) stop(); }); // a click catches a coasting page, like a finger does })(); window.addEventListener('load', () => { |
