aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js20
-rw-r--r--src/features/messages.js13
-rw-r--r--src/i18n.js1
-rw-r--r--src/style.css34
4 files changed, 63 insertions, 5 deletions
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 = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
+ const CLIP = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
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 */