diff --git a/public/image-viewer.js b/public/image-viewer.js
new file mode 100644
index 0000000..b634a5b
--- /dev/null
+++ b/public/image-viewer.js
@@ -0,0 +1,29 @@
+'use strict';
+// Photos in Signal and Chat open in a full-screen viewer inside the app instead of a new tab.
+// Close with the X, a tap outside the photo, Escape, or the phone's Back gesture; the chat is right where it was.
+(function () {
+ let box = null, pushed = false;
+ function close(fromPop) {
+ if (!box) return; box.remove(); box = null; document.removeEventListener('keydown', onKey, true);
+ if (pushed && !fromPop) { pushed = false; history.back(); } else pushed = false;
+ }
+ const onKey = e => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); } };
+ function open(src, name) {
+ close();
+ box = document.createElement('div'); box.id = 'imgview'; box.setAttribute('role', 'dialog'); box.setAttribute('aria-modal', 'true'); box.setAttribute('aria-label', name || 'Photo');
+ box.innerHTML = 'Save
';
+ const x = box.querySelector('.ivx'); x.innerHTML = typeof icon === 'function' ? icon('x') : '✕';
+ const im = box.querySelector('img'); im.alt = name || 'Photo'; im.src = src;
+ const sv = box.querySelector('.ivsave'); sv.href = src; if (name) sv.setAttribute('download', name);
+ x.onclick = () => close();
+ box.onclick = e => { if (e.target === box) close(); };
+ document.body.appendChild(box); document.addEventListener('keydown', onKey, true); x.focus({ preventScroll: true });
+ try { history.pushState({ imgview: 1 }, ''); pushed = true; } catch { /* no history: the X still closes it */ }
+ }
+ addEventListener('popstate', () => { if (box) { pushed = false; close(true); } });
+ document.addEventListener('click', e => {
+ const im = e.target.closest && e.target.closest('img.sgimg'); if (!im) return;
+ const a = im.closest('a[href]'); if (!a) return;
+ e.preventDefault(); open(a.getAttribute('href'), im.alt);
+ });
+})();
diff --git a/public/index.html b/public/index.html
index f02eaf3..b19cc16 100644
--- a/public/index.html
+++ b/public/index.html
@@ -97,6 +97,7 @@
+
diff --git a/public/style.css b/public/style.css
index 814c859..4d7c55c 100644
--- a/public/style.css
+++ b/public/style.css
@@ -1205,3 +1205,9 @@ body.qdock #view { padding-right: 340px; }
.empty.loading { background: linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--muted) 16%, transparent) 50%, transparent 100%) 0 0 / 200% 100% no-repeat; animation: loadsweep 1.4s ease-in-out infinite; border-radius: 12px; }
@keyframes loadsweep { from { background-position: 150% 0; } to { background-position: -50% 0; } }
@media (prefers-reduced-motion: reduce) { .empty.loading { animation: none; } }
+
+/* Photo viewer for Signal and Chat images (image-viewer.js) */
+#imgview { position: fixed; inset: 0; z-index: 400; background: rgba(0, 0, 0, .92); display: flex; align-items: center; justify-content: center; padding: calc(var(--sat, 0px) + 56px) 12px calc(var(--sab, 0px) + 12px); }
+#imgview img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; touch-action: pinch-zoom; -webkit-touch-callout: default; user-select: auto; }
+#imgview .ivx { position: absolute; top: calc(var(--sat, 0px) + 8px); right: 12px; background: rgba(255, 255, 255, .16); color: #fff; }
+#imgview .ivsave { position: absolute; top: calc(var(--sat, 0px) + 16px); left: 16px; color: #fff; font-size: 16px; padding: 6px 12px; border-radius: 16px; background: rgba(255, 255, 255, .16); text-decoration: none; cursor: pointer; }
diff --git a/public/sw.js b/public/sw.js
index 4e8cec1..76370c3 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
-const CACHE = 'hub-v140';
+const CACHE = 'hub-v141';
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];