aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-30 18:03:33 +0000
committerAdam Soltys <[email protected]>2026-09-30 18:03:33 +0000
commit0570f48910ddc4ea10d27fb8ab7992fbd013a0b7 (patch)
treedbc1e5ece94bc71fcd8bb7e89f61043933c1992c /src/app.js
parent95eaf11d116260b101c81346ba41810aacf49a7d (diff)
parentcfeacb1a7a10766565ed3eaab13154096478c837 (diff)
downloadcoinosv3-0570f48910ddc4ea10d27fb8ab7992fbd013a0b7.tar.gz
coinosv3-0570f48910ddc4ea10d27fb8ab7992fbd013a0b7.zip
Merge remote-tracking branch 'cs/prod'
Diffstat (limited to 'src/app.js')
-rw-r--r--src/app.js223
1 files changed, 180 insertions, 43 deletions
diff --git a/src/app.js b/src/app.js
index cb6a030..b6721da 100644
--- a/src/app.js
+++ b/src/app.js
@@ -6,9 +6,11 @@
import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js';
import { qrSvg } from './qr.js';
+import './rich-text.js'; // <coinos-text>, the composers' field
import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './recipient-search.js';
import { npubOf, seedPubkey, neventOf } from './nostr.js';
import { nip98Header } from './nip98.js';
+import { uploadPublicMedia, 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';
@@ -232,7 +234,10 @@ function morph(a, b) {
// <details open> is the user's doing (the browser toggles the attribute on
// click), like a field's value: a render that doesn't set `open` leaves it
// alone instead of collapsing the section on every background repaint.
- const userOwned = (at) => a.nodeName === 'DETAILS' && at.name === 'open';
+ // <coinos-text> sets its own editing attributes when it joins the page;
+ // the freshly built copy doesn't have them yet, and must not strip them
+ const userOwned = (at) => (a.nodeName === 'DETAILS' && at.name === 'open')
+ || (a.nodeName === 'COINOS-TEXT' && /^(contenteditable|role|aria-multiline|aria-placeholder|data-empty)$/.test(at.name));
for (const at of [...a.attributes]) {
if (b.hasAttribute(at.name) || userOwned(at)) continue;
if (at.name === 'src' && b.hasAttribute('data-lazy-src')) continue; // the lazy source stands for it
@@ -260,6 +265,12 @@ function morph(a, b) {
}
if (a.disabled !== b.disabled) a.disabled = b.disabled;
}
+ // the contenteditable field: its text is its value, owned like a
+ // textarea's — synced when not being edited, children never diffed
+ if (tag === 'COINOS-TEXT') {
+ if (document.activeElement !== a && a.value !== b.value) a.value = b.value;
+ return;
+ }
// innerHTML-authored subtrees (svg icons, QRs): compare source, not nodes
if (b._html != null) {
if (a._html !== b._html) a.innerHTML = b._html;
@@ -561,12 +572,110 @@ setTimeout(() => { if (_bootDeciding) { _bootDeciding = false; render(); } }, 40
function imageViewer() {
if (!ui.lightbox) return null;
const close = () => goBack(() => { ui.lightbox = null; });
- return h('div', {
+ // a tap on the picture closes it too (the × is small and far) — unless
+ // it's zoomed, or the tap is half of a double-tap (see attachZoom)
+ const img = h('img', { src: ui.lightbox, alt: '', draggable: 'false' });
+ const box = h('div', {
class: 'lightbox', onClick: close,
role: 'dialog', 'aria-modal': 'true',
- }, h('img', { src: ui.lightbox, alt: '', onClick: (e) => { e.stopPropagation(); close(); } }), // a tap on the picture closes it too: the × is small and far
+ }, img,
// stop the bubble: the backdrop closes too, and two pops walk out of the room
h('button', { class: 'lightbox-x', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); close(); } }, '\u00d7'));
+ attachZoom(box, img, close);
+ return box;
+}
+// Pinch to zoom (up to 5×) around the fingers, drag to pan a zoomed picture,
+// double-tap (or double-click) to zoom in at a spot and again to reset, the
+// wheel to zoom on a desktop. The page's viewport meta turns browser zoom
+// off, so the picture does its own. Only the first-mounted nodes keep these
+// listeners: later renders morph into them, and the img is left out of the
+// morph so its transform survives a background render.
+function attachZoom(box, img, close) {
+ img._skipMorph = true;
+ const MAX = 5, pts = new Map();
+ let s = 1, x = 0, y = 0, pinch = null, pan = null, moved = false, lastTap = 0, tapTimer = 0;
+ const base = () => ({ bx: img.offsetLeft + img.offsetWidth / 2, by: img.offsetTop + img.offsetHeight / 2 });
+ const clamp = () => {
+ if (s <= 1) { s = 1; x = 0; y = 0; return; }
+ const mx = Math.max(0, (img.offsetWidth * s - window.innerWidth) / 2), my = Math.max(0, (img.offsetHeight * s - window.innerHeight) / 2);
+ x = Math.min(mx, Math.max(-mx, x)); y = Math.min(my, Math.max(-my, y));
+ };
+ const apply = (animate) => {
+ img.style.transition = animate ? 'transform .2s ease' : 'none';
+ img.style.transform = s === 1 && !x && !y ? '' : `translate(${x}px, ${y}px) scale(${s})`;
+ img.style.cursor = s > 1 ? 'grab' : '';
+ };
+ // scale to ns keeping the picture point under (cx, cy) where it is
+ const zoomAt = (ns, cx, cy) => {
+ const { bx, by } = base();
+ const px = (cx - bx - x) / s, py = (cy - by - y) / s;
+ s = Math.min(MAX, Math.max(1, ns));
+ x = cx - bx - px * s; y = cy - by - py * s;
+ };
+ box.addEventListener('pointerdown', (e) => {
+ if (e.target.closest('.lightbox-x')) return;
+ pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ if (pts.size === 1) moved = false;
+ begin();
+ });
+ const begin = () => {
+ const p = [...pts.values()];
+ if (p.length >= 2) {
+ const { bx, by } = base();
+ const mx = (p[0].x + p[1].x) / 2, my = (p[0].y + p[1].y) / 2;
+ pinch = { d: Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y) || 1, s, px: (mx - bx - x) / s, py: (my - by - y) / s };
+ pan = null;
+ } else if (p.length === 1) {
+ pinch = null;
+ pan = { sx: p[0].x, sy: p[0].y, x, y };
+ }
+ };
+ box.addEventListener('pointermove', (e) => {
+ if (!pts.has(e.pointerId)) return;
+ pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ const p = [...pts.values()];
+ if (pinch && p.length >= 2) {
+ const { bx, by } = base();
+ const d = Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
+ const mx = (p[0].x + p[1].x) / 2, my = (p[0].y + p[1].y) / 2;
+ s = Math.min(MAX, Math.max(0.8, pinch.s * d / pinch.d)); // a little give below 1, snapped back on release
+ x = mx - bx - pinch.px * s; y = my - by - pinch.py * s;
+ moved = true;
+ apply(false);
+ } else if (pan && p.length === 1) {
+ const dx = p[0].x - pan.sx, dy = p[0].y - pan.sy;
+ if (Math.abs(dx) + Math.abs(dy) > 8) moved = true;
+ if (s > 1) { x = pan.x + dx; y = pan.y + dy; clamp(); apply(false); }
+ }
+ });
+ const end = (e) => {
+ if (!pts.delete(e.pointerId)) return;
+ if (pts.size) { begin(); return; } // one finger lifted mid-pinch: carry on as a pan
+ pinch = pan = null;
+ clamp(); apply(true);
+ };
+ box.addEventListener('pointerup', end);
+ box.addEventListener('pointercancel', end);
+ // a drag or a pinch is not a tap: nothing closes after one
+ box.addEventListener('click', (e) => {
+ if (moved) { moved = false; e.stopImmediatePropagation(); e.preventDefault(); return; }
+ if (e.target !== img) return;
+ e.stopPropagation();
+ const now = Date.now();
+ if (now - lastTap < 300) {
+ clearTimeout(tapTimer); lastTap = 0;
+ if (s > 1) { s = 1; x = 0; y = 0; } else { zoomAt(2.5, e.clientX, e.clientY); clamp(); }
+ apply(true);
+ return;
+ }
+ lastTap = now;
+ if (s === 1) tapTimer = setTimeout(close, 300);
+ }, true);
+ box.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ zoomAt(s * Math.exp(-e.deltaY * 0.002), e.clientX, e.clientY);
+ clamp(); apply(false);
+ }, { passive: false });
}
if (typeof window !== 'undefined') {
window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && ui.lightbox) goBack(() => { ui.lightbox = null; }); });
@@ -625,7 +734,7 @@ function renderInner() {
// payment push, an SP scan) can't kick the user out of a field they're editing.
const a = document.activeElement;
let fpath = null, selStart = null, selEnd = null;
- if (a && root.contains(a) && /^(INPUT|SELECT|TEXTAREA)$/.test(a.tagName)) {
+ if (a && root.contains(a) && /^(INPUT|SELECT|TEXTAREA|COINOS-TEXT)$/.test(a.tagName)) {
fpath = focusPath(a);
try { selStart = a.selectionStart; selEnd = a.selectionEnd; } catch {}
}
@@ -681,12 +790,16 @@ function renderInner() {
ui.navAnimSkip = false;
}
applyAnim(screen, 'anim-page', (performance.now() - _navAt) < 340 ? performance.now() - _navAt : -1);
- morphChildren(root, [screen, footer(), ...(ui.lightbox ? [imageViewer()] : [])]);
+ // The bottom nav rides every page of a signed-in wallet and a visitor's
+ // public pages — never over sign-in, onboarding or the lock prompt.
+ const navPage = ui.pubProf || (activeAccount() && ['wallet', 'accounts', 'accountSettings'].includes(ui.screen) && !(ui.onb || onbInProgress()));
+ const bottomNav = navPage && !lockAsk && !_bootDeciding ? featureHook('bottomNav') : null;
+ morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(ui.lightbox ? [imageViewer()] : [])]);
promoteLazySrc(root); // sources on the nodes that actually made it into the page
try { document.documentElement.classList.toggle('no-scroll', !!ui.lightbox); } catch {}
if (fpath) {
const el = nodeAtPath(fpath);
- if (el && el !== a && /^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName)) {
+ if (el && el !== a && /^(INPUT|SELECT|TEXTAREA|COINOS-TEXT)$/.test(el.tagName)) {
try { el.focus({ preventScroll: true }); if (selStart != null && el.setSelectionRange) el.setSelectionRange(selStart, selEnd); } catch {}
}
}
@@ -737,7 +850,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', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop', 'lightbox'];
+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'];
function navSnapshot() {
const s = {};
for (const f of NAV_FIELDS) s[f] = ui[f] ?? null;
@@ -2488,23 +2601,6 @@ function lockBtn() {
}, h('span', { style: 'font-size:15px;line-height:1;margin-right:6px' }, locked ? '\u{1F512}' : '\u{1F513}'), locked ? t('unlock') : t('lockWallet'));
}
-// Messages sit one tap away, left of the wallet selector. The dot is presence,
-// not a count — it says "someone's waiting", and the chat list says who.
-function messagesBtn() {
- const me = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey());
- if (!me) return null;
- const unread = featureHook('unreadMessages') || 0;
- return h('button', {
- class: 'header-msgs' + (unread ? ' unread' : ''),
- title: t('msgDmsTitle'),
- 'aria-label': t('msgDmsTitle'),
- onClick: () => { clearFeatureNav(); ui.screen = 'wallet'; ui.chatOpen = true; ui.msgView = 'home'; render(); },
- }, h('span', {
- class: 'hm-ico',
- html: '<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>',
- }));
-}
-
// Find anyone on nostr from the header — results open their profile.
function searchBtn() {
if (!featureHook('userSearchAvailable')) return null;
@@ -2539,7 +2635,6 @@ function brandHeader(withLock) {
withLock
? h('div', { class: 'row gap6', style: 'align-items:center' },
searchBtn(),
- messagesBtn(),
settingsBtn(),
avatarMenu())
: ui.pubProf && !acc ? visitorButtons() : null
@@ -2885,6 +2980,14 @@ function goHome() {
ui.addrScan = false;
ui.arkMoveOpen = false;
ui.nameEditOpen = null;
+ // ...and every other page the history tracks (the vtxo list, a
+ // transaction's detail, the hat shop, a lightbox…): home is home,
+ // whatever was open. The chat's own position is kept for its next visit.
+ for (const f of NAV_FIELDS) {
+ if (['screen', 'tab', 'msgView', 'msgCommunity', 'msgPeer', 'feedId'].includes(f)) continue;
+ ui[f] = ['chatOpen', 'giftMode', 'profEditFilled', 'profOverThread'].includes(f) ? false : null;
+ }
+ ui.arkCoinsSel = null; ui.pos = null; ui.feedEdit = null; ui.feedMenu = null;
ui.tab = wallet.offline ? 'settings' : 'history';
ui.draft = null;
ui.sendResult = null;
@@ -3368,21 +3471,46 @@ async function onbUpload(file) {
const fd = new FormData();
fd.append('file', file);
const endpoint = 'https://nostr.build/api/v2/upload/files';
- // nostr.build no longer takes anonymous uploads — sign with the wallet's
- // nostr key, the one key that's always available without a signer prompt.
- const headers = {};
- try {
- if (wallet.nostrPubkey && wallet.nostrPubkey()) {
- headers.authorization = await nip98Header({ signEvent: (e) => wallet.nostrSign(e) }, endpoint, 'POST');
- }
- } catch {}
- const r = await fetch(endpoint, { method: 'POST', body: fd, headers });
- const j = await r.json();
- const url = j?.data?.[0]?.url;
+ const authorization = await nip98Header({ signEvent: (event) => wallet.nostrSign(event) }, endpoint, 'POST');
+ const response = await fetch(endpoint, {
+ method: 'POST', body: fd, headers: { authorization }, signal: AbortSignal.timeout(60_000),
+ });
+ let body = null;
+ try { body = await response.json(); } catch {}
+ const url = response.ok && body?.data?.[0]?.url;
if (!url) throw new Error(t('onbUploadFailed'));
return url;
}
+// ui.mediaProgress = { stage: 'shrink' | 'upload', pct } while a picture or
+// video is on its way; the composers draw it as a bar under the field.
+// A render per whole percent, no more.
+function mediaProgress(stage, fraction) {
+ const pct = Math.max(0, Math.min(100, Math.floor(fraction * 100)));
+ const cur = ui.mediaProgress;
+ if (cur && cur.stage === stage && cur.pct === pct) return;
+ ui.mediaProgress = { stage, pct };
+ render();
+}
+async function publicMediaUpload(file) {
+ try {
+ // a phone video is shrunk on the device first (see video-compress.js)
+ if (/^video\//.test(file?.type || '')) {
+ mediaProgress('shrink', 0);
+ const { compressVideo } = await import('./video-compress.js');
+ file = await compressVideo(file, (p) => mediaProgress('shrink', p));
+ }
+ mediaProgress('upload', 0);
+ return await uploadPublicMedia(file, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) });
+ } catch (error) {
+ if (error instanceof MediaUploadError && error.code === 'too-big') throw new Error(t('mediaUploadTooBig'));
+ if (error instanceof MediaUploadError && error.code === 'unsupported') throw new Error(t('mediaUploadUnsupported'));
+ throw new Error(t('msgUploadFailed'));
+ } finally {
+ ui.mediaProgress = null;
+ }
+}
+
// Whether the wizard can offer Spending at all: an Ark-capable build on a
// network with the names registrar, for a full (signing) account. Elsewhere
// the offer would be a spinner that never resolves.
@@ -4846,7 +4974,7 @@ function destReady(a) {
// Recipient search under the destination input: usernames and npubs resolve
// to candidates with avatars; picking one runs the same path as pasting.
-const sendSearch = { rows: null, sync: null };
+const sendSearch = { rows: null, sync: null, pick: null, el: null };
let sendRevealTimer = null;
// With the phone keyboard up, the space under the recipient field is scarce —
// park the field at the top of the view so the candidate list gets what's
@@ -4896,12 +5024,19 @@ function recipientRow(s, r, i) {
};
// The suggestions panel gets the same imperative treatment — see the
// sendSearcher note: a render between key repeats kills backspace-hold.
- const suggest = i === 0 ? h('div', { class: 'list send-suggest', style: 'display:none', 'data-fresh': '1' }) : null;
+ // One panel for the life of the page, rows rebuilt only when the results
+ // change: a background render must not swap the row under the pointer
+ // (a fresh node drops :hover, and the highlight blinked on every tick).
+ // data-fresh still installs this exact node wherever the morph lands it.
+ const suggest = i === 0 ? (sendSearch.el ||= h('div', { class: 'list send-suggest', style: 'display:none', 'data-fresh': '1' })) : null;
const syncSuggest = () => {
if (!suggest) return;
- const show = sendSearch.rows && sendSearch.rows.length && searchable(r.address);
- suggest.replaceChildren(...(show ? resultRows(h, sendSearch.rows, pickRecipient, (pk, node) => featureHook('wrapAvatar', pk, node)) : []));
- suggest.style.display = show ? '' : 'none';
+ const show = !!(sendSearch.rows && sendSearch.rows.length && searchable(r.address));
+ const rows = show ? sendSearch.rows : null;
+ if (suggest._rows === rows) return;
+ suggest._rows = rows;
+ suggest.replaceChildren(...(rows ? resultRows(h, rows, (cand) => sendSearch.pick(cand), (pk, node) => featureHook('wrapAvatar', pk, node)) : []));
+ suggest.style.display = rows ? '' : 'none';
};
if (i === 0) sendSearch.sync = syncSuggest;
@@ -4996,6 +5131,7 @@ function recipientRow(s, r, i) {
) : null
);
syncCheck();
+ if (i === 0) sendSearch.pick = pickRecipient; // the persistent rows call this render's
syncSuggest();
return row;
}
@@ -5628,9 +5764,10 @@ const ctx = {
setAccount: (a, dir) => setAccountSel(a, dir),
// Open (or create) a wallet from a mnemonic — used by nostr login.
openMnemonic: async (mnemonic, passphrase, opts) => enterWallet(mnemonic, passphrase, opts),
- // Upload an image (avatar) and get back its URL — the same signed
- // nostr.build path the onboarding picker uses.
+ // Profile pictures retain the metadata-scrubbing image uploader. Public
+ // post media uses resilient Blossom storage and media-specific failures.
uploadImage: (file) => onbUpload(file),
+ uploadMedia: (file) => publicMediaUpload(file),
// A nostr login that lands mid-wizard: a restored wallet has been through
// onboarding elsewhere, so the wizard ends without tour or prompts; a fresh
// one falls through to the wallet + tour on the next render.