aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/app.js')
-rw-r--r--src/app.js99
1 files changed, 45 insertions, 54 deletions
diff --git a/src/app.js b/src/app.js
index f904cc3..416fd59 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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,9 +6646,8 @@ 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;
// The wheel coasts whenever a feed or a thread is the page being scrolled
@@ -6736,49 +6769,7 @@ if (INTENT_URI) {
// 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', () => {