From d4576229565d9b4411d0d9fc44780da03ef0b47b Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 12 Sep 2026 07:17:58 -0700 Subject: A picture in a post opens full screen, and the paperclip is a button again. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attach control was a .btn-sm holding a 16px icon, wedged between two full-height buttons — a sliver to aim at on a phone. It's a 48px square the height of the row now, with a 20px clip in it. And tapping an image opens it full screen: the image centred on a dark ground, tap anywhere (or Escape) to close. Posts crop images to 320px so a feed stays a feed, which makes a photograph a thumbnail — this is where you actually look at one. Two details in the viewer. The page underneath stops scrolling while it's open, and the scrollbar gutter the page keeps reserved stands down with it: fixed positioning is sized to the viewport WITHOUT that gutter, so an "inset: 0" backdrop leaves a pale stripe down the side of an otherwise full-screen picture. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx --- src/app.js | 20 +++++++++++++++++++- src/features/messages.js | 13 +++++++++---- src/i18n.js | 1 + src/style.css | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 0e306bd..438e511 100644 --- a/src/app.js +++ b/src/app.js @@ -458,6 +458,22 @@ let _bootDeciding = true; // the welcome screen held back forever. Nothing legitimate is still deciding // four seconds in — the store wait alone caps at 800ms. setTimeout(() => { if (_bootDeciding) { _bootDeciding = false; render(); } }, 4000); +// A picture, full screen. Posts show images cropped to a few hundred pixels +// so a feed stays a feed; this is where you actually look at one. Tap +// anywhere (or Escape, or Back) to close — no chrome, nothing to aim at. +function imageViewer() { + if (!ui.lightbox) return null; + const close = () => { ui.lightbox = null; render(); }; + return h('div', { + class: 'lightbox', onClick: close, + role: 'dialog', 'aria-modal': 'true', + }, h('img', { src: ui.lightbox, alt: '', onClick: (e) => e.stopPropagation() }), + h('button', { class: 'lightbox-x', 'aria-label': t('close'), onClick: close }, '\u00d7')); +} +if (typeof window !== 'undefined') { + window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && ui.lightbox) { ui.lightbox = null; render(); } }); +} + function render() { if (_publicChatMode) return; // A background render mid carousel-drag rebuilds the strip; the morph then @@ -567,7 +583,8 @@ function renderInner() { ui.navAnimSkip = false; } applyAnim(screen, 'anim-page', (performance.now() - _navAt) < 340 ? performance.now() - _navAt : -1); - morphChildren(root, [screen, footer()]); + morphChildren(root, [screen, footer(), ...(ui.lightbox ? [imageViewer()] : [])]); + 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)) { @@ -5027,6 +5044,7 @@ const ctx = { // in fiat the typed figure is money, so it needs today's price to become sats parseAmount: (v, u) => parseAmount(v, u, rateNow()), worthLine, + openImage: (src) => { ui.lightbox = src; render(); }, // "The next balance isn't a change, it's the first real value" — state // arriving late (the ark store opening past boot's patience) must not read // as money landing: without this the balance counts up from 0 in green, diff --git a/src/features/messages.js b/src/features/messages.js index 24c9006..e95bfeb 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -2563,8 +2563,13 @@ export function messagesFeature(ctx) { if (!part) continue; if (/^https?:\/\//i.test(part)) { if (/\.(png|jpe?g|gif|webp|avif)(\?[^\s]*)?$/i.test(part)) { - out.push(h('img', { src: part, class: 'note-img', loading: 'lazy', - onError: (e) => { e.target.style.display = 'none'; } })); + // tap it to see it properly — a 320px-tall crop of someone's + // photograph is a thumbnail, not the picture they posted + out.push(h('img', { + src: part, class: 'note-img clickable', loading: 'lazy', + onClick: (e) => { e.stopPropagation(); ctx.openImage && ctx.openImage(part); }, + onError: (e) => { e.target.style.display = 'none'; }, + })); } else if (/\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i.test(part)) { // metadata-only preload: the poster frame paints, nothing streams // until the viewer presses play @@ -3324,7 +3329,7 @@ export function messagesFeature(ctx) { } catch (e) { toast(e.message || String(e)); } ui.postUploading = false; render(); } - const CLIP = ''; + const CLIP = ''; function postComposer() { if (ui.profCompose == null && !draftFor(POST_DRAFT)) return null; const text = composeText(); @@ -3355,7 +3360,7 @@ export function messagesFeature(ctx) { } } }, t('profPostBtn')), ctx.uploadImage ? h('button', { - class: 'btn-sm', title: t('feedAttach'), disabled: !!ui.postUploading, + class: 'attach-btn', 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, h('input', { diff --git a/src/i18n.js b/src/i18n.js index 9aa398e..a8f6dfd 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -193,6 +193,7 @@ const STR = { msgReact: 'React', msgZap: 'Zap', zapTallyTitle: '{n} sats zapped', + close: 'Close', worthThen: 'Worth then', worthNow: 'Worth now', worthNowInline: '{v} now', diff --git a/src/style.css b/src/style.css index 13198cc..4d71435 100644 --- a/src/style.css +++ b/src/style.css @@ -532,6 +532,40 @@ hr.divider { margin: 16px 0; } /* Profile note feed: inline images stay contained, links wrap. */ .note-img { display: block; max-width: 100%; max-height: 320px; border-radius: 10px; margin-top: 6px; object-fit: contain; } +.note-img.clickable { cursor: zoom-in; } +/* The attach button sits between Post and Cancel: a square the size of the + buttons beside it, not the sliver a .btn-sm made of a 20px icon. */ +.attach-btn { + flex: 0 0 auto; width: 48px; align-self: stretch; min-height: 44px; + display: flex; align-items: center; justify-content: center; padding: 0; +} +/* A picture, full screen: the image centred on a dark ground, everything + else out of the way. Fixed to the viewport (svh, so a phone's address bar + can't push it), and above every other layer including the toast. */ +.lightbox { + position: fixed; inset: 0; z-index: 200; + display: flex; align-items: center; justify-content: center; + background: rgba(8, 10, 14, .94); padding: 16px; + animation: lightbox-in .12s ease-out; +} +/* Nothing scrolls behind a picture that fills the screen — and the reserved + scrollbar gutter (see html, top of file) stands down with it, or it shows + as a pale stripe beside an otherwise full-screen image. Fixed positioning + is sized to the viewport WITHOUT that gutter, so this is the only way to + reach the edge. No layout shift worth the name: it happens under the + backdrop. */ +html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } +.lightbox img { + max-width: 100%; max-height: 100svh; max-height: 100vh; + object-fit: contain; border-radius: 6px; cursor: zoom-out; +} +.lightbox-x { + position: absolute; top: max(10px, env(safe-area-inset-top)); right: 10px; + width: 40px; height: 40px; padding: 0; font-size: 26px; line-height: 1; + border-radius: 999px; border: 0; color: #fff; background: rgba(255, 255, 255, .14); +} +.lightbox-x:hover { background: rgba(255, 255, 255, .24); } +@keyframes lightbox-in { from { opacity: 0; } to { opacity: 1; } } .note-video { display: block; max-width: 100%; max-height: 360px; border-radius: 10px; margin-top: 6px; background: #000; } /* the profile notes feed: page-scrolled (no inner scrollbox); on phones it runs edge to edge — the card walls step outside #app's padding */ -- cgit v1.2.3