tinyrelaycommit

Commit d855534581df

browse files at this commit | history

1diff --git a/docs/app.md b/docs/app.md
2index b2ed4a3..c25e256 100644
3--- a/docs/app.md
4+++ b/docs/app.md
5@@ -2,6 +2,10 @@
6
7 The relay installs as an app from the browser menu on Android, iOS and desktop. The installed app follows the site theme, opens in its own window and keeps the last pages available when the network fails.
8
9+## Mobile navigation
10+
11+The mobile header shows the current page or conversation. Chats show the room picture and name; replies show **Thread** above the room name. Use the back arrow to return to the parent page or conversation. Tap the title to open page details, including room members and settings. Tap **Menu** for navigation, your account and sign-out controls.
12+
13 ## Sharing to the relay
14
15 Share a photo, file or link from another app and choose the relay. Shared files wait in the Upload panel on the Files page until you press **Upload**, so nothing is stored without your signature. A shared link opens the **Import from URL** control with the address filled in.
16@@ -24,6 +28,12 @@ A request for a decision arrives with **Approve**, **Deny** and **Reply** button
17
18 An agent's access request offers **Review access** instead. It opens the proposed grant changes in Approvals. Only the current grant operator can approve them by signing the replacement grant; the notification itself cannot authorize more access.
19
20+## Reconnecting a signer
21+
22+Your sign-in session and signer connection are separate. After the app resumes, it checks the signer connection. If the signer is unavailable, the mobile header offers **Reconnect**. Your session stays signed in, and a draft stays in place. After reconnecting, retry the action that failed.
23+
24+If this browser has no saved signer connection, choose **Connect signer** to pair it again. Use the signer for the account already signed in. You do not need to sign out first.
25+
26 ## Names
27
28 People show by name wherever the relay knows one: room authors and members, mentions, approvals, agent owners, wiki authors and the signed-in line. Names come from the person's profile (kind 0) as held by this relay, so looking up a name does not contact a public indexer. Names are cached in the browser for six hours. Without JavaScript, or before a profile arrives, the short key shows instead.
29@@ -36,10 +46,10 @@ A profile picture is used when it is a safe, usable URL. Otherwise the relay sho
30
31 ## Account
32
33-The **Account** page is the home for settings that belong to the signed-in key. It links to your profile, shows your current relay lists and lets you choose whether this account shares presence and typing in Chat. That preference is stored for the account on this relay and applies across browsers; it is not a room setting. Select your name in the footer to return to Account.
34+The **Account** page is the home for settings that belong to the signed-in key. It links to your profile, shows your current relay lists and lets you choose whether this account shares presence and typing in Chat. That preference is stored for the account on this relay and applies across browsers; it is not a room setting. Open **Menu** on a phone and select **Account**, or select your name in the desktop footer.
35
36 When a protected page sends you to **Sign in**, the sign-in link carries that page's tenant path and query. After the signer connects, the browser returns to the page you opened, including repository, file, wiki and approval context. Fragments are kept by the browser when client-side navigation supplies them.
37
38 ## Copying a page address
39
40-The crumb that names the current page, next to the relay name at the top on phones and in the footer on larger screens, copies the page's address when tapped and says "copied" for a moment. The address is the relay's public URL with the page's canonical path, so a link copied while browsing over a tailnet or LAN address still opens for anyone: wiki names are normalized the way the relay resolves them, and repositories and files keep the query that names them.
41+On a phone, open **Menu** and choose **Copy page link**. On larger screens, tap the page name in the footer. The control says "copied" for a moment. The address is the relay's public URL with the page's canonical path, so a link copied while browsing over a tailnet or LAN address still opens for anyone: wiki names are normalized the way the relay resolves them, and repositories and files keep the query that names them.
42diff --git a/internal/webui/bridge.js b/internal/webui/bridge.js
43index 51d7ac4..a299fa4 100644
44--- a/internal/webui/bridge.js
45+++ b/internal/webui/bridge.js
46@@ -94,10 +94,17 @@
47 const menu = document.getElementById("nav-menu");
48 if (menu) menu.open = open;
49 };
50+ const setContext = open => {
51+ const menu = document.getElementById("context-menu");
52+ if (menu) menu.open = open;
53+ };
54 const shellBindings = new WeakSet();
55+ let shellDocumentBound = false;
56 const bindShell = () => {
57 const rail = document.getElementById("rail");
58 const themeButton = document.getElementById("theme");
59+ const navMenu = document.getElementById("nav-menu");
60+ const contextMenu = document.getElementById("context-menu");
61 if (rail && !shellBindings.has(rail)) {
62 shellBindings.add(rail);
63 rail.addEventListener("click", event => { if (event.target.closest("a")) setMenu(false); });
64@@ -125,12 +132,38 @@
65 paintChrome();
66 });
67 }
68+ if (navMenu && !shellBindings.has(navMenu)) {
69+ shellBindings.add(navMenu);
70+ navMenu.addEventListener("toggle", () => { if (navMenu.open) setContext(false); });
71+ }
72+ if (contextMenu && !shellBindings.has(contextMenu)) {
73+ shellBindings.add(contextMenu);
74+ contextMenu.addEventListener("toggle", () => { if (contextMenu.open) setMenu(false); });
75+ }
76+ if (!shellDocumentBound) {
77+ shellDocumentBound = true;
78+ document.addEventListener("click", event => {
79+ const nav = document.getElementById("nav-menu");
80+ const context = document.getElementById("context-menu");
81+ if ((!nav?.open && !context?.open) || event.target?.closest?.("#rail, #aside, #topbar, #footer")) return;
82+ setMenu(false);
83+ setContext(false);
84+ });
85+ }
86 };
87 bindShell();
88 document.addEventListener("keydown", event => {
89- if (event.key !== "Escape" || !document.getElementById("nav-menu")?.open) return;
90- setMenu(false);
91- document.getElementById("menu")?.focus();
92+ if (event.key !== "Escape") return;
93+ const nav = document.getElementById("nav-menu");
94+ const context = document.getElementById("context-menu");
95+ if (nav?.open) {
96+ setMenu(false);
97+ setContext(false);
98+ document.getElementById("menu")?.focus();
99+ } else if (context?.open) {
100+ setContext(false);
101+ document.getElementById("context-toggle")?.focus();
102+ }
103 });
104 if ("serviceWorker" in navigator) navigator.serviceWorker.register(localPath("/sw.js")).catch(() => {});
105 // An installed app asks to keep its cached shell and parked shares. Browsers
106@@ -182,7 +215,9 @@
107 const signIn = async () => {
108 const destination = signInDestination();
109 say("Signing in…");
110+ if (sessionActor() && window.tiny.signer()?.getPublicKey) verifySignerIdentity(await window.tiny.signer().getPublicKey());
111 await signedSession("/session");
112+ announceSigner("ready");
113 say("Signed in.");
114 location.replace(destination);
115 };
116@@ -204,14 +239,208 @@
117 store.setItem(storage.remote, JSON.stringify(signer.bp));
118 };
119
120+ let activeSigner, activeRemote, remoteFacade, nativeSigner = window.nostr;
121+ let signerStatus = "disconnected", signerMessage = "Signer unavailable.";
122+ let reconnecting, lastCheck = 0, signerGeneration = 0;
123+ const pendingCalls = new Map();
124+ const signerStatusText = {
125+ disconnected: "Signer unavailable.", checking: "Checking signer…",
126+ reconnecting: "Reconnecting signer…", lost: "Signer connection lost.", ready: "Signer connected."
127+ };
128+ const sessionActor = () => document.querySelector?.("signer-connection")?.getAttribute("actor") || "";
129+ const nativeProvider = () => {
130+ if (window.nostr && window.nostr !== remoteFacade) nativeSigner = window.nostr;
131+ return nativeSigner;
132+ };
133+ const withTimeout = (promise, ms, message = "Signer connection timed out.") => {
134+ let timer;
135+ return Promise.race([promise, new Promise((_, reject) => {
136+ timer = setTimeout(() => reject(Error(message)), ms);
137+ })]).finally(() => clearTimeout(timer));
138+ };
139+ const closeRemote = signer => {
140+ if (!signer) return;
141+ for (const reject of pendingCalls.get(signer) || []) reject(Error("Signer connection changed. Retry when connected."));
142+ pendingCalls.delete(signer);
143+ try { Promise.resolve(signer.close?.()).catch(() => {}); } catch {}
144+ try { signer.pool?.destroy?.(); } catch {}
145+ };
146+ const connectionURL = () => {
147+ const current = new URL(location.href);
148+ const url = new URL(localPath("/signin"), location.href);
149+ url.searchParams.set("connect", "1");
150+ url.searchParams.set("next", current.pathname + current.search);
151+ if (current.hash) url.hash = "return=" + encodeURIComponent(current.pathname + current.search + current.hash);
152+ return url.href;
153+ };
154+ const hasRememberedSigner = () => !!(sessionStorage.getItem(storage.key) || localStorage.getItem(storage.key));
155+ const updateSignerConnection = () => {
156+ const host = document.querySelector?.("signer-connection");
157+ if (!host || !document.createElement) return;
158+ host.hidden = signerStatus === "ready";
159+ host.setAttribute("data-state", signerStatus);
160+ host.setAttribute("role", "status");
161+ host.replaceChildren();
162+ if (host.hidden) return;
163+ const message = document.createElement("span");
164+ message.textContent = signerMessage;
165+ host.append(message);
166+ if (signerStatus === "checking" || signerStatus === "reconnecting") return;
167+ const button = document.createElement("button");
168+ button.type = "button";
169+ button.textContent = hasRememberedSigner() || nativeProvider() ? "Reconnect" : "Connect signer";
170+ button.addEventListener("click", () => {
171+ if (!hasRememberedSigner() && !nativeProvider()) { location.assign(connectionURL()); return; }
172+ reconnectSigner({force: true}).catch(() => {});
173+ });
174+ host.append(button);
175+ };
176+ const announceSigner = (status, message = signerStatusText[status]) => {
177+ signerStatus = status;
178+ signerMessage = message;
179+ updateSignerConnection();
180+ const statusNode = document.getElementById("session-status");
181+ if (statusNode) statusNode.textContent = status === "lost" ? message : "";
182+ if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:signer-status", {
183+ detail: {status, text: message, canReconnect: status === "lost" || status === "disconnected"}
184+ }));
185+ };
186+ const loseRemote = (remote, message) => {
187+ if (remote !== activeRemote) return;
188+ activeRemote = activeSigner = undefined;
189+ window.tinySigner = undefined;
190+ if (window.nostr === remoteFacade) window.nostr = nativeSigner;
191+ closeRemote(remote);
192+ announceSigner("lost", message || signerStatusText.lost);
193+ };
194+ const guardSigner = signer => {
195+ const methods = new Map();
196+ return new Proxy(signer, {get(target, property) {
197+ const value = Reflect.get(target, property, target);
198+ if (typeof value !== "function") return value;
199+ if (!/^(?:signEvent|getPublicKey|nip(?:04|44)(?:Encrypt|Decrypt))$/.test(String(property))) return value.bind(target);
200+ if (!methods.has(property)) methods.set(property, async (...args) => {
201+ if (target !== activeRemote) throw Error("Signer connection changed. Retry when connected.");
202+ let cancel;
203+ const pending = pendingCalls.get(target) || new Set();
204+ pendingCalls.set(target, pending);
205+ const canceled = new Promise((_, reject) => { cancel = reject; pending.add(reject); });
206+ try {
207+ const result = await withTimeout(Promise.race([Promise.resolve().then(() => value.apply(target, args)), canceled]), property === "signEvent" ? 120000 : 30000);
208+ if (target !== activeRemote) throw Error("Signer connection changed. Retry when connected.");
209+ return result;
210+ } catch (err) {
211+ if (!/reject|denied|cancel|declin|unsupported|invalid|permission/i.test(errorText(err))) loseRemote(target);
212+ throw err;
213+ } finally { pending.delete(cancel); }
214+ });
215+ return methods.get(property);
216+ }});
217+ };
218 const setSigner = signer => {
219- window.tinySigner = signer;
220- window.nostr = {signEvent: event => window.tinySigner.signEvent(event)};
221+ const previous = activeRemote;
222+ signerGeneration++;
223+ activeRemote = signer;
224+ activeSigner = guardSigner(signer);
225+ window.tinySigner = activeSigner;
226+ nativeProvider();
227+ remoteFacade = {signEvent: event => activeSigner ? activeSigner.signEvent(event) : Promise.reject(Error("Reconnect your signer first."))};
228+ window.nostr = remoteFacade;
229+ if (previous !== signer) closeRemote(previous);
230+ announceSigner("ready");
231 if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:signer"));
232- if (document.getElementById("signer-status")) {
233- say(document.getElementById("session-logout") ? "Signer connected." : "Signer connected. Choose Sign in with connected signer to continue.");
234- }
235 };
236+ const storedRemote = async () => {
237+ const store = sessionStorage.getItem(storage.key) ? sessionStorage : localStorage;
238+ const uri = store.getItem(storage.uri), keyHex = store.getItem(storage.key);
239+ if (!uri || !keyHex) return null;
240+ const remote = store.getItem(storage.remote);
241+ const pointer = remote ? JSON.parse(remote) : await withTimeout(window.NostrSigner.parseBunkerInput(uri), 5000);
242+ if (!validBunker(pointer)) throw Error("Saved signer connection is invalid. Connect your signer again.");
243+ return {key: window.NostrSigner.hexToBytes(keyHex), pointer, expected: store.getItem(storage.pubkey)};
244+ };
245+ const verifySignerIdentity = (pubkey, expected) => {
246+ if (!/^[0-9a-f]{64}$/i.test(pubkey || "") || (expected && pubkey.toLowerCase() !== expected.toLowerCase()) || (sessionActor() && pubkey.toLowerCase() !== sessionActor().toLowerCase()))
247+ throw Error("Choose the signer for your signed-in account.");
248+ };
249+ const reconnectSigner = ({force = false} = {}) => {
250+ if (reconnecting) return reconnecting;
251+ if (!force && Date.now() - lastCheck < 15000) return Promise.resolve(activeSigner);
252+ lastCheck = Date.now();
253+ const generation = signerGeneration;
254+ // Deferring keeps even an immediately failed attempt in the single-flight slot.
255+ const task = Promise.resolve().then(async () => {
256+ if (!hasRememberedSigner()) {
257+ const native = nativeProvider();
258+ if (!native?.getPublicKey) { announceSigner("disconnected"); return; }
259+ announceSigner("checking");
260+ try {
261+ verifySignerIdentity(await withTimeout(native.getPublicKey(), 5000));
262+ if (generation !== signerGeneration) return activeSigner;
263+ announceSigner("ready");
264+ if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:signer"));
265+ return native;
266+ } catch (err) {
267+ if (generation === signerGeneration) announceSigner("lost", /signed-in account/.test(errorText(err)) ? errorText(err) : "Unlock or reconnect your signer.");
268+ throw err;
269+ }
270+ }
271+ if (!force && activeRemote?.ping) {
272+ const previous = activeRemote;
273+ announceSigner("checking");
274+ try {
275+ await withTimeout(previous.ping(), 5000);
276+ if (generation !== signerGeneration) return activeSigner;
277+ announceSigner("ready");
278+ return activeSigner;
279+ } catch { if (generation !== signerGeneration) return activeSigner; }
280+ }
281+ announceSigner("reconnecting");
282+ let candidate;
283+ try {
284+ const stored = await storedRemote();
285+ if (!stored) { announceSigner("disconnected"); return; }
286+ candidate = window.NostrSigner.BunkerSigner.fromBunker(stored.key, stored.pointer);
287+ await withTimeout(candidate.connect(), 8000);
288+ verifySignerIdentity(await withTimeout(candidate.getPublicKey(), 5000), stored.expected);
289+ if (generation !== signerGeneration) { closeRemote(candidate); return activeSigner; }
290+ setSigner(candidate);
291+ return activeSigner;
292+ } catch (err) {
293+ closeRemote(candidate);
294+ if (generation === signerGeneration) {
295+ const previous = activeRemote;
296+ activeRemote = activeSigner = undefined;
297+ window.tinySigner = undefined;
298+ if (window.nostr === remoteFacade) window.nostr = nativeSigner;
299+ closeRemote(previous);
300+ announceSigner("lost", /signed-in account/.test(errorText(err)) ? errorText(err) : signerStatusText.lost);
301+ }
302+ throw err;
303+ }
304+ });
305+ reconnecting = task;
306+ task.finally(() => { if (reconnecting === task) reconnecting = undefined; }).catch(() => {});
307+ return task;
308+ };
309+ Object.assign(window.tiny, {signerState: () => signerStatus, reconnectSigner});
310+ document.addEventListener("tiny:navigation", updateSignerConnection);
311+ const resumeSigner = () => {
312+ if (document.visibilityState && document.visibilityState !== "visible") return;
313+ if (hasRememberedSigner() || nativeProvider()) reconnectSigner().catch(() => {});
314+ else updateSignerConnection();
315+ };
316+ document.addEventListener("visibilitychange", () => {
317+ if (document.visibilityState === "hidden") { lastCheck = -Infinity; return; }
318+ resumeSigner();
319+ });
320+ window.addEventListener("pageshow", event => { if (event.persisted) lastCheck = -Infinity; resumeSigner(); });
321+ document.addEventListener("resume", () => { lastCheck = -Infinity; resumeSigner(); });
322+ window.addEventListener("nostr:ready", () => { lastCheck = -Infinity; resumeSigner(); });
323+ window.addEventListener("focus", resumeSigner);
324+ window.addEventListener("online", () => { lastCheck = -Infinity; resumeSigner(); });
325+ window.addEventListener("offline", () => { if (activeRemote) loseRemote(activeRemote, "Offline. Reconnect when online."); });
326+ updateSignerConnection();
327
328 // Fixi can morph the content column without reloading this script. Bind
329 // controls by element identity so navigation can safely discover new ones.
330@@ -241,6 +470,10 @@
331 say("Signing out…");
332 const response = await fetch(localPath("/session/logout"), {method: "POST", credentials: "same-origin"});
333 if (!response.ok) throw Error(await response.text());
334+ signerGeneration++;
335+ closeRemote(activeRemote);
336+ activeRemote = activeSigner = undefined;
337+ window.tinySigner = undefined;
338 forgetRemote();
339 if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:logout"));
340 say("Signed out.");
341@@ -307,23 +540,8 @@
342
343 // Resume a remote signer stored for this tab so page loads keep working.
344 (async () => {
345- const store = sessionStorage.getItem(storage.key) ? sessionStorage : localStorage;
346- const uri = store.getItem(storage.uri);
347- const keyHex = store.getItem(storage.key);
348- const expected = store.getItem(storage.pubkey);
349- const remote = store.getItem(storage.remote);
350- if (!uri || !keyHex) return;
351 try {
352- const key = window.NostrSigner.hexToBytes(keyHex);
353- const pointer = remote ? JSON.parse(remote) : await window.NostrSigner.parseBunkerInput(uri);
354- const signer = window.NostrSigner.BunkerSigner.fromBunker(key, pointer);
355- await signer.connect();
356- const pubkey = await signer.getPublicKey();
357- if (expected && pubkey !== expected) throw Error("Remote signer identity changed");
358- setSigner(signer);
359- } catch (err) {
360- forgetRemote();
361- say("Remote signer resume error: " + errorText(err));
362- }
363+ if (hasRememberedSigner() || nativeProvider()) await reconnectSigner({force: true});
364+ } catch {}
365 })();
366 })();
367diff --git a/internal/webui/components.js b/internal/webui/components.js
368index 9ad41db..6a10df1 100644
369--- a/internal/webui/components.js
370+++ b/internal/webui/components.js
371@@ -1734,6 +1734,7 @@
372 };
373 const allowedRoute = path => /^(?:\/(?:inbox|approvals|profile|account|outbox|search|social|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files|wiki|rooms)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a|wiki|social)\/.+|\/chat\/dm\/[0-9a-f]{64}|\/rooms\/[a-z0-9_-]{1,64}(?:\/thread\/[0-9a-f]{64})?\/?)$/.test(path || "");
374 let navigationSerial = 0, activeAbort;
375+ let renderedURL = location.href;
376 const streams = new Set();
377 const closeStreams = () => {
378 streams.forEach(cfg => cfg.sse.close());
379@@ -1762,6 +1763,7 @@
380 };
381 const swapShell = (text, url, push) => {
382 if (url.__tinyNavigationSerial && url.__tinyNavigationSerial !== navigationSerial) return false;
383+ const routeChanged = renderedURL !== url.href;
384 const parsed = new DOMParser().parseFromString(text, "text/html");
385 const incoming = new Map(navTargets.map(selector => [selector, parsed.querySelector(selector)]));
386 if ([...incoming.values()].some(node => !node)) return false;
387@@ -1771,9 +1773,19 @@
388 repairComponents();
389 document.title = parsed.title || document.title;
390 if (push) history.pushState({}, "", url.href);
391- const focus = focusID && document.getElementById(focusID);
392- if (focus && typeof focus.focus === "function") focus.focus({preventScroll: true});
393- else document.querySelector("#content")?.focus({preventScroll: true});
394+ renderedURL = url.href;
395+ if (routeChanged) {
396+ document.getElementById("nav-menu")?.removeAttribute("open");
397+ document.getElementById("context-menu")?.removeAttribute("open");
398+ const content = document.querySelector("#content");
399+ if (content) content.scrollTop = 0;
400+ if (typeof window.scrollTo === "function") window.scrollTo(0, 0);
401+ content?.focus({preventScroll: true});
402+ } else {
403+ const focus = focusID && document.getElementById(focusID);
404+ if (focus && typeof focus.focus === "function") focus.focus({preventScroll: true});
405+ else document.querySelector("#content")?.focus({preventScroll: true});
406+ }
407 document.dispatchEvent(new CustomEvent("tiny:navigation", {detail: {url: url.href}}));
408 ensureModules();
409 decorate();
410diff --git a/internal/webui/mobile_header.go b/internal/webui/mobile_header.go
411new file mode 100644
412index 0000000..a759ec8
413--- /dev/null
414+++ b/internal/webui/mobile_header.go
415@@ -0,0 +1,216 @@
416+package webui
417+
418+import (
419+ "net/url"
420+ "strings"
421+)
422+
423+// mobileHeaderData is the small, route-aware context used by the mobile shell.
424+// It contains only data that has already passed page access checks.
425+type mobileHeaderData struct {
426+ Title string
427+ Subtitle string
428+ BackURL string
429+ BackLabel string
430+ PubKey string
431+ RoomID string
432+ Picture string
433+}
434+
435+// mobileHeader derives a compact header from rendered page data. Private and
436+// failed room pages deliberately use generic values so hidden room metadata
437+// cannot appear in the shell.
438+func mobileHeader(data PageData) mobileHeaderData {
439+ if data.Private {
440+ return mobileHeaderData{Title: "Sign in", Subtitle: data.Slug}
441+ }
442+ if data.Tab == "signin" {
443+ title := "Sign in"
444+ if data.Actor != "" {
445+ title = "Account"
446+ if data.Query.Get("connect") != "" {
447+ title = "Connect signer"
448+ }
449+ }
450+ result := mobileHeaderData{Title: title, Subtitle: data.Slug, BackLabel: "Back"}
451+ if next, ok := mobileReturnPath(data.Query.Get("next"), data.Base); ok {
452+ result.BackURL = next
453+ result.BackLabel = "Back"
454+ }
455+ return result
456+ }
457+ if data.Error != "" && roomRoute(data.Path).tab != "" {
458+ return mobileHeaderData{Title: "Chat", BackURL: "/chat", BackLabel: "Back to chats"}
459+ }
460+ route := roomRoute(data.Path)
461+ switch route.tab {
462+ case "room":
463+ return roomHeader(data, route, false)
464+ case "thread":
465+ return roomHeader(data, route, true)
466+ }
467+ if data.Tab == "direct" || strings.HasPrefix(data.Path, "/chat/dm/") {
468+ result := mobileHeaderData{Title: "Direct chat", Subtitle: "Private conversation", BackURL: "/chat", BackLabel: "Back to chats"}
469+ if eventIDPattern.MatchString(data.View) {
470+ result.PubKey = data.View
471+ }
472+ return result
473+ }
474+ return generalMobileHeader(data)
475+}
476+
477+func roomHeader(data PageData, route roomPath, thread bool) mobileHeaderData {
478+ result := mobileHeaderData{Title: "Group chat", Subtitle: "Chat", RoomID: route.id}
479+ room := valueMap(valueMap(data.Event)["room"])
480+ if name := plainString(room["name"]); name != "" {
481+ result.Title = name
482+ }
483+ result.Picture = socialMediaURL(plainString(room["picture"]))
484+ if thread {
485+ result.Title = "Thread"
486+ result.Subtitle = plainString(room["name"])
487+ if result.Subtitle == "" {
488+ result.Subtitle = "Group chat"
489+ }
490+ if eventIDPattern.MatchString(route.event) {
491+ result.BackURL = "/rooms/" + route.id + "#msg-" + route.event
492+ }
493+ result.BackLabel = "Back to room"
494+ return result
495+ }
496+ result.BackURL, result.BackLabel = "/chat", "Back to chats"
497+ return result
498+}
499+
500+func generalMobileHeader(data PageData) mobileHeaderData {
501+ path := data.Path
502+ result := mobileHeaderData{Title: mobileTitle(path, data), Subtitle: data.Slug, BackLabel: "Back"}
503+ if data.Tab == "social-thread" {
504+ result.Title = eventShortTitle(data.Event, "Social")
505+ result.Subtitle = "Social"
506+ }
507+ if data.Tab == "file" {
508+ result.Title = eventShortTitle(data.Event, "File")
509+ result.Subtitle = "Files"
510+ }
511+ if data.Tab == "files" && data.Query.Get("view") == "sites" {
512+ result.Title = "Sites"
513+ result.Subtitle = "Files"
514+ }
515+ if data.Tab == "repo" {
516+ if repo := data.Query.Get("repo"); repo != "" {
517+ result.Title = repo
518+ }
519+ result.Subtitle = repoView(data.Query)
520+ }
521+ if data.Tab == "wikipage" {
522+ result.Title = eventShortTitle(data.Event, data.Query.Get("d"))
523+ result.Subtitle = "Wiki"
524+ }
525+ switch {
526+ case path == "/" || path == "":
527+ case path == "/chat" || path == "/social" || path == "/files" || path == "/wiki" || path == "/repos":
528+ result.BackURL = "/"
529+ case path == "/manage" || path == "/manage/people":
530+ result.BackURL = "/"
531+ result.BackLabel = "Back to home"
532+ case strings.HasPrefix(path, "/manage/"):
533+ result.BackURL = "/manage/people"
534+ result.BackLabel = "Back to manage"
535+ case path == "/sites":
536+ result.BackURL = "/"
537+ case path == "/file":
538+ result.BackURL = "/files"
539+ result.BackLabel = "Back to files"
540+ case path == "/repo":
541+ result.BackURL = "/repos"
542+ result.BackLabel = "Back to repositories"
543+ case path == "/profile":
544+ result.BackURL = "/account"
545+ result.BackLabel = "Back to account"
546+ case strings.HasPrefix(path, "/social/"):
547+ result.BackURL = "/social"
548+ result.BackLabel = "Back to social"
549+ case strings.HasPrefix(path, "/files/"):
550+ result.BackURL = "/files"
551+ result.BackLabel = "Back to files"
552+ case strings.HasPrefix(path, "/wiki/"):
553+ result.BackURL = "/wiki"
554+ result.BackLabel = "Back to wiki"
555+ case strings.HasPrefix(path, "/repos/"):
556+ result.BackURL = "/repos"
557+ result.BackLabel = "Back to repositories"
558+ case path != "/social" && path != "/files" && path != "/wiki" && path != "/repos":
559+ result.BackURL = "/"
560+ }
561+ if strings.HasPrefix(path, "/manage/") {
562+ result.Subtitle = "Manage"
563+ }
564+ if data.Tab == "signin" {
565+ result.Title = "Sign in"
566+ result.Subtitle = data.Slug
567+ }
568+ return result
569+}
570+
571+func mobileReturnPath(raw, base string) (string, bool) {
572+ if raw == "" || strings.IndexFunc(raw, func(r rune) bool { return r < 0x20 || r == '\\' }) >= 0 {
573+ return "", false
574+ }
575+ u, err := url.Parse(raw)
576+ if err != nil || u.IsAbs() || u.Host != "" || u.Path == "/signin" || !strings.HasPrefix(u.Path, "/") {
577+ return "", false
578+ }
579+ decodedPath, err := url.PathUnescape(u.Path)
580+ if err != nil || strings.IndexFunc(decodedPath, func(r rune) bool { return r < 0x20 || r == '\\' }) >= 0 {
581+ return "", false
582+ }
583+ if base != "" && strings.HasPrefix(u.Path, "/r/") && !strings.HasPrefix(u.Path, strings.TrimSuffix(base, "/")+"/") {
584+ return "", false
585+ }
586+ return raw, true
587+}
588+
589+func eventShortTitle(value any, fallback string) string {
590+ row := valueMap(value)
591+ if post := valueMap(row["post"]); post != nil {
592+ row = post
593+ }
594+ for _, key := range []string{"name", "filename", "title", "d"} {
595+ if title := strings.TrimSpace(plainString(row[key])); title != "" && !eventIDPattern.MatchString(title) {
596+ return shortHeaderText(title)
597+ }
598+ }
599+ if content := strings.TrimSpace(plainString(row["content"])); content != "" {
600+ return shortHeaderText(strings.Split(content, "\n")[0])
601+ }
602+ return fallback
603+}
604+
605+func shortHeaderText(value string) string {
606+ value = strings.TrimSpace(value)
607+ if len([]rune(value)) > 64 {
608+ return string([]rune(value)[:61]) + "..."
609+ }
610+ return value
611+}
612+
613+func mobileTitle(path string, data PageData) string {
614+ known := map[string]string{"/": "Home", "/home": "Home", "/search": "Search", "/social": "Social", "/chat": "Chat", "/files": "Files", "/file": "File", "/wiki": "Wiki", "/repos": "Repositories", "/repo": "Repository", "/approvals": "Approvals", "/profile": "Profile", "/account": "Account", "/manage": "Manage", "/manage/people": "People", "/manage/agents": "Agents", "/manage/moderation": "Moderation", "/manage/rules": "Rules", "/manage/identity": "Identity", "/manage/connect": "Connect", "/manage/owner": "Owner", "/manage/sync": "Sync", "/manage/data": "Data", "/manage/views": "Views", "/manage/health": "Health", "/sites": "Sites"}
615+ if title, ok := known[path]; ok {
616+ return title
617+ }
618+ if strings.HasPrefix(path, "/social/") {
619+ return "Social"
620+ }
621+ if strings.HasPrefix(path, "/wiki/") {
622+ return "Wiki"
623+ }
624+ if strings.HasPrefix(path, "/repos/") {
625+ return "Repositories"
626+ }
627+ if title := strings.TrimSuffix(data.Title, " | "+data.Slug); title != "" && !eventIDPattern.MatchString(title) {
628+ return title
629+ }
630+ return "Tiny"
631+}
632diff --git a/internal/webui/mobile_header_test.go b/internal/webui/mobile_header_test.go
633new file mode 100644
634index 0000000..d32d606
635--- /dev/null
636+++ b/internal/webui/mobile_header_test.go
637@@ -0,0 +1,124 @@
638+package webui
639+
640+import (
641+ "net/url"
642+ "strings"
643+ "testing"
644+)
645+
646+func TestMobileHeaderGeneralRoutes(t *testing.T) {
647+ tests := []struct {
648+ path, title, wantTitle, wantSubtitle, wantBack string
649+ }{
650+ {"/", "tiny | relay", "Home", "tiny", ""},
651+ {"/social", "social | tiny", "Social", "tiny", "/"},
652+ {"/files", "Files | tiny", "Files", "tiny", "/"},
653+ {"/wiki", "Wiki | tiny", "Wiki", "tiny", "/"},
654+ {"/repos", "Repositories | tiny", "Repositories", "tiny", "/"},
655+ {"/manage/people", "people | tiny", "People", "Manage", "/"},
656+ {"/social/thread/abc", "Social | tiny", "Social", "tiny", "/social"},
657+ {"/file", "File | tiny", "File", "tiny", "/files"},
658+ {"/repo", "Repository | tiny", "Repository", "tiny", "/repos"},
659+ {"/profile", "Profile | tiny", "Profile", "tiny", "/account"},
660+ {"/sites", "sites | tiny", "Sites", "tiny", "/"},
661+ }
662+ for _, test := range tests {
663+ got := mobileHeader(PageData{Path: test.path, Title: test.title, Slug: "tiny"})
664+ if got.Title != test.wantTitle || got.Subtitle != test.wantSubtitle || got.BackURL != test.wantBack || got.BackLabel == "" && test.wantBack != "" {
665+ t.Errorf("mobileHeader(%q) = %+v, want title=%q subtitle=%q back=%q", test.path, got, test.wantTitle, test.wantSubtitle, test.wantBack)
666+ }
667+ }
668+}
669+
670+func TestMobileHeaderRoomAndThread(t *testing.T) {
671+ room := map[string]any{"room": map[string]any{"id": "general", "name": "The <General> Room", "picture": "https://cdn.example/room.png"}}
672+ got := mobileHeader(PageData{Path: "/rooms/general", Tab: "room", Event: room, URL: "https://relay.example"})
673+ if got.Title != "The <General> Room" || got.Subtitle != "Chat" || got.BackURL != "/chat" || got.RoomID != "general" || got.Picture == "" {
674+ t.Fatalf("room header = %+v", got)
675+ }
676+ root := strings.Repeat("a", 64)
677+ thread := mobileHeader(PageData{Path: "/rooms/general/thread/" + root, Tab: "thread", Event: map[string]any{
678+ "room": map[string]any{"id": "general", "name": "General"},
679+ "root": map[string]any{"id": root},
680+ }})
681+ if thread.Title != "Thread" || thread.Subtitle != "General" || thread.BackURL != "/rooms/general#msg-"+root || thread.BackLabel != "Back to room" {
682+ t.Fatalf("thread header = %+v", thread)
683+ }
684+}
685+
686+func TestMobileHeaderDirectAndHiddenData(t *testing.T) {
687+ peer := strings.Repeat("b", 64)
688+ got := mobileHeader(PageData{Path: "/chat/dm/" + peer, Tab: "direct", View: peer})
689+ if got.Title != "Direct chat" || got.Subtitle != "Private conversation" || got.PubKey != peer || got.BackURL != "/chat" {
690+ t.Fatalf("direct header = %+v", got)
691+ }
692+ private := mobileHeader(PageData{Path: "/rooms/secret", Tab: "room", Private: true, Event: map[string]any{"room": map[string]any{"name": "Hidden", "picture": "https://secret.example/p.png"}}})
693+ if private.Title != "Sign in" || private.Subtitle != "" || private.Picture != "" || private.RoomID != "" {
694+ t.Fatalf("private header leaked data: %+v", private)
695+ }
696+ privateDirect := mobileHeader(PageData{Path: "/chat/dm/" + peer, Tab: "direct", View: peer, Private: true, Slug: "tiny", Event: map[string]any{"pubkey": peer}})
697+ if privateDirect.Title != "Sign in" || privateDirect.PubKey != "" || privateDirect.RoomID != "" || privateDirect.Picture != "" {
698+ t.Fatalf("private direct header leaked data: %+v", privateDirect)
699+ }
700+ err := mobileHeader(PageData{Path: "/rooms/secret", Tab: "room", Error: "denied", Event: map[string]any{"room": map[string]any{"name": "Hidden"}}})
701+ if err.Title != "Chat" || err.RoomID != "" || err.BackURL != "/chat" || err.BackLabel != "Back to chats" {
702+ t.Fatalf("error header leaked data: %+v", err)
703+ }
704+ signin := mobileHeader(PageData{Path: "/manage/people", Tab: "signin", Title: "People | tiny", Slug: "tiny"})
705+ if signin.Title != "Sign in" || signin.Subtitle != "tiny" {
706+ t.Fatalf("signin header = %+v", signin)
707+ }
708+}
709+
710+func TestMobileHeaderUsesDeepLinkContext(t *testing.T) {
711+ article := mobileHeader(PageData{Path: "/social/" + strings.Repeat("a", 64), Tab: "social-thread", Slug: "tiny", Event: map[string]any{
712+ "post": map[string]any{"title": "A useful article"},
713+ }})
714+ if article.Title != "A useful article" || article.Subtitle != "Social" || article.BackURL != "/social" || article.BackLabel != "Back to social" {
715+ t.Fatalf("article header = %+v", article)
716+ }
717+ file := mobileHeader(PageData{Path: "/file", Tab: "file", Slug: "tiny", Event: map[string]any{"name": "photo.mp4"}})
718+ if file.Title != "photo.mp4" || file.Subtitle != "Files" || file.BackURL != "/files" || file.BackLabel != "Back to files" {
719+ t.Fatalf("file header = %+v", file)
720+ }
721+ repo := mobileHeader(PageData{Path: "/repo", Tab: "repo", Slug: "tiny", Query: url.Values{"repo": []string{"seedmark"}, "view": []string{"issues"}}})
722+ if repo.Title != "seedmark" || repo.Subtitle != "issues" || repo.BackURL != "/repos" || repo.BackLabel != "Back to repositories" {
723+ t.Fatalf("repo header = %+v", repo)
724+ }
725+ wiki := mobileHeader(PageData{Path: "/wiki/release-notes", Tab: "wikipage", Slug: "tiny", Query: url.Values{"d": []string{"release-notes"}}, Event: map[string]any{"title": "Release notes"}})
726+ if wiki.Title != "Release notes" || wiki.Subtitle != "Wiki" || wiki.BackURL != "/wiki" {
727+ t.Fatalf("wiki header = %+v", wiki)
728+ }
729+ sites := mobileHeader(PageData{Path: "/files", Tab: "files", Slug: "tiny", Query: url.Values{"view": []string{"sites"}}})
730+ if sites.Title != "Sites" || sites.Subtitle != "Files" || sites.BackURL != "/" {
731+ t.Fatalf("sites header = %+v", sites)
732+ }
733+}
734+
735+func TestMobileHeaderManagementParent(t *testing.T) {
736+ got := mobileHeader(PageData{Path: "/manage/agents", Tab: "agents", Slug: "tiny"})
737+ if got.BackURL != "/manage/people" || got.BackLabel != "Back to manage" {
738+ t.Fatalf("management header = %+v", got)
739+ }
740+}
741+
742+func TestMobileHeaderSigninReturnValidation(t *testing.T) {
743+ valid := mobileHeader(PageData{Tab: "signin", Slug: "tiny", Base: "/r/demo", Query: url.Values{"next": []string{"/r/demo/social?q=hello"}}})
744+ if valid.BackURL != "/r/demo/social?q=hello" {
745+ t.Fatalf("valid signin return = %+v", valid)
746+ }
747+ for _, next := range []string{"/\\evil.test", "//evil.test", "/r/other/chat", "/r/demo/%5Cevil", "/signin"} {
748+ got := mobileHeader(PageData{Tab: "signin", Slug: "tiny", Base: "/r/demo", Query: url.Values{"next": []string{next}}})
749+ if got.BackURL != "" {
750+ t.Errorf("unsafe signin return %q accepted as %+v", next, got)
751+ }
752+ }
753+ connected := mobileHeader(PageData{Tab: "signin", Actor: strings.Repeat("a", 64), Slug: "tiny", Query: url.Values{"connect": []string{"1"}}})
754+ if connected.Title != "Connect signer" {
755+ t.Fatalf("connected signin header = %+v", connected)
756+ }
757+ account := mobileHeader(PageData{Tab: "signin", Actor: strings.Repeat("a", 64), Slug: "tiny"})
758+ if account.Title != "Account" {
759+ t.Fatalf("account signin header = %+v", account)
760+ }
761+}
762diff --git a/internal/webui/page.html b/internal/webui/page.html
763index fbe8250..cd96ed9 100644
764--- a/internal/webui/page.html
765+++ b/internal/webui/page.html
766@@ -24,7 +24,15 @@ then feature modules are loaded for the current page. */}}
767 <script src="/webmcp.js"></script>
768 </head>
769 <body>
770-<header id="topbar"><details id="nav-menu"><summary id="menu" aria-controls="rail">menu</summary></details> <span><b>{{.Slug}}</b> &raquo; <page-link url="{{pageAddress .URL .Path .Query}}" title="Copy the page address">{{prompt .Path .Query}}</page-link></span></header>
771+{{$mobile := mobileHeader .}}<header id="topbar">
772+{{if $mobile.BackURL}}<a id="mobile-back" href="{{$mobile.BackURL}}" aria-label="{{$mobile.BackLabel}}" title="{{$mobile.BackLabel}}"><svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m14 6-6 6 6 6"/></svg></a>{{end}}
773+<details id="context-menu" name="mobile-chrome"><summary id="context-toggle" aria-controls="aside" title="Page details">
774+{{if $mobile.RoomID}}<room-avatar picture="{{$mobile.Picture}}" fallback="{{roomAvatarURL .URL $mobile.RoomID}}" aria-hidden="true"><img src="{{if $mobile.Picture}}{{$mobile.Picture}}{{else}}{{roomAvatarURL .URL $mobile.RoomID}}{{end}}" alt="" width="36" height="36" loading="lazy"></room-avatar>{{else if $mobile.PubKey}}<nostr-avatar pubkey="{{$mobile.PubKey}}" aria-hidden="true"><img src="{{avatarURL $mobile.PubKey}}" alt="" width="36" height="36" loading="lazy"></nostr-avatar>{{end}}
775+<span id="mobile-heading"><strong id="mobile-title" role="heading" aria-level="1">{{if $mobile.PubKey}}{{template "nostrName" $mobile.PubKey}}{{else}}{{$mobile.Title}}{{end}}</strong><small id="mobile-context">{{$mobile.Subtitle}}</small></span><svg data-chevron viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m8 10 4 4 4-4"/></svg>
776+</summary></details>
777+<details id="nav-menu" name="mobile-chrome"><summary id="menu" aria-controls="rail" aria-label="Menu" title="Menu"><svg data-menu-icon viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg><svg data-close-icon viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="m6 6 12 12M6 18 18 6"/></svg></summary></details>
778+{{if and .Actor (not .Private)}}<signer-connection actor="{{.Actor}}" hidden></signer-connection>{{end}}
779+</header>
780 <table id="layout" aria-label="{{.Slug}}"><tbody><tr>
781 <td id="rail"><div id="railbox">{{template "rail" .}}</div></td>
782 <td id="main"><div id="content" tabindex="-1">
783@@ -114,7 +122,7 @@ then feature modules are loaded for the current page. */}}
784 <p><a href="/chat#new-room">+ new room</a></p>
785 {{else}}<header><span><a id="mark" href="/" title="{{.Slug}}"><i>t</i><i>i</i><i>n</i><i>y</i></a></span></header>
786 {{range $group := navGroups relayItems}}<nav>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}{{if .Actor}}<p id="personal-nav"><a href="/account"{{if eq .Tab "account"}} aria-current="page"{{end}}>account</a></p>{{end}}
787-{{end}}<p id="rail-tools">{{if eq $kind "repo"}}<a href="/repos">&larr; all repositories</a>{{else if or (eq $kind "manage") (eq $kind "rooms")}}<a href="/">&larr; relay</a>{{end}}<button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}}
788+{{end}}<div id="mobile-actions">{{if .Actor}}<a href="/account">Account</a>{{end}}<page-link url="{{pageAddress .URL .Path .Query}}" title="Copy the page address"><button type="button">Copy page link</button></page-link></div><p id="rail-tools">{{if eq $kind "repo"}}<a href="/repos">&larr; all repositories</a>{{else if or (eq $kind "manage") (eq $kind "rooms")}}<a href="/">&larr; relay</a>{{end}}<button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}}
789
790 {{/* The panel holds facts and filters for the current page. */}}
791 {{define "panel"}}{{$kind := railKind .Tab}}{{if .Private}}{{template "panel-signin" .}}
792diff --git a/internal/webui/public.html b/internal/webui/public.html
793index 1337ffc..f06a2f3 100644
794--- a/internal/webui/public.html
795+++ b/internal/webui/public.html
796@@ -25,11 +25,12 @@
797 {{end}}
798
799 {{define "signin"}}<section id="signin">
800-<h1>{{if .Actor}}Your account{{else}}Sign in{{end}}</h1>
801+<h1>{{if and .Actor (.Query.Get "connect")}}Connect your signer{{else if .Actor}}Your account{{else}}Sign in{{end}}</h1>
802 {{if .Notice}}<p role="status">{{.Notice}}</p>{{end}}
803 {{if .Actor}}<p>Signed in as {{template "nostrKey" .Actor}}.</p>
804 <p><a href="/account">Open your account</a> | <a href="/chat">Chat</a></p>
805-<p>Use the sign out control in the footer to end this session.</p>
806+{{if not (.Query.Get "connect")}}<p><a href="/signin?connect=1{{with .Query.Get "next"}}&amp;next={{urlquery .}}{{end}}">Connect a signer</a> to restore signing without signing out.</p>{{end}}
807+{{if .Query.Get "connect"}}<p>Connect the signer for this account. Your sign-in session stays active.</p>{{template "signerControls" .}}{{else}}<p>Open Menu on a phone, or use the desktop footer, to sign out.</p>{{end}}
808 {{else}}<p>Use a browser extension, or connect Amber on your phone. The relay never sees a private key.</p>
809 {{template "signerControls" .}}
810 {{end}}</section>
811@@ -52,7 +53,7 @@
812 </tbody></table><output role="status"></output></relay-lists>
813 </section>{{end}}
814
815-{{define "signerControls"}}<p><button id="session-login" type="button">Sign in with connected signer</button></p>
816+{{define "signerControls"}}<p><button id="session-login" type="button">{{if .Actor}}Use connected signer{{else}}Sign in with connected signer{{end}}</button></p>
817 <h2>Phone signer</h2>
818 <p>Scan the QR code with Amber and approve the connection and sign-in request. You will return to the page you opened.</p>
819 <p><label><input type="checkbox" id="remember-signer"> Remember this device</label> <small>Keeps the phone signer connected after the app closes. Sign out to forget it.</small></p>
820diff --git a/internal/webui/style.css b/internal/webui/style.css
821index 7c54e87..a449af8 100644
822--- a/internal/webui/style.css
823+++ b/internal/webui/style.css
824@@ -24,7 +24,7 @@ img{max-width:100%}
825 #rail,#footer,#topbar,#panel h4,#panel label,#panel th,#rows th,#grid,#gridhead,#tree header small,#tree li>span,#preview header,button,summary,rpc-form label,signed-form label,publish-list label,nostr-compose label,wiki-compose label,file-tools label,file-upload label,nostr-event>header,approval-item>header,wiki-article>header,diff-view caption small{user-select:none;-webkit-user-select:none}
826
827 /* Shell: a table with three columns; each column scrolls on its own. */
828-#topbar{display:none}
829+#topbar,#mobile-actions{display:none}
830 #layout{border-collapse:collapse;table-layout:fixed;width:100%;height:calc(100vh - 28px - env(safe-area-inset-bottom,0px))}
831 #layout>tbody>tr>td{vertical-align:top;padding:0;position:relative;height:100%}
832 #railbox,#content,#panel{position:absolute;top:0;right:0;bottom:0;left:0;overflow:auto;overscroll-behavior:contain}
833@@ -368,26 +368,63 @@ room-action{display:block}#panel room-action label{margin-top:6px}#panel room-ac
834 #members li[data-agent] small{color:var(--bark)}
835 #panel #members li{font:12px var(--mono);line-height:22px}
836
837-/* Phones: a top bar with a menu, the rail slides in, the panel stacks under the content. */
838+/* Phones keep conversation identity and navigation in a safe-area-aware header.
839+ Native details controls expose the rail and contextual panel without scripts. */
840 @media(max-width:720px){
841-html,body{overflow:auto}
842-#topbar{display:block;position:fixed;top:0;left:0;right:0;height:40px;padding:0 2.5ch;background:var(--rail);border-bottom:1px solid var(--line);font:13px/40px var(--mono);color:var(--muted);z-index:3;box-sizing:border-box;white-space:nowrap;overflow:hidden}
843-#topbar b{color:var(--ink);font-weight:500}
844-#nav-menu{display:inline-block;margin-right:10px}
845-#menu{display:block;list-style:none;border:1px solid var(--line);padding:2px 10px;line-height:1.4;cursor:pointer}
846-#menu::-webkit-details-marker{display:none}
847+:root{--mobile-header-height:calc(64px + env(safe-area-inset-top,0px));--mobile-drawer-width:min(320px,100vw)}
848+html:has(signer-connection:not([hidden])){--mobile-header-height:calc(108px + env(safe-area-inset-top,0px))}
849+html,body{overflow:auto;scroll-padding-top:var(--mobile-header-height)}
850+#topbar{display:block;position:fixed;top:0;left:0;right:0;height:var(--mobile-header-height);background:var(--rail);border-bottom:1px solid var(--line);color:var(--ink);z-index:5;box-sizing:border-box}
851+signer-connection{display:block;position:absolute;top:calc(64px + env(safe-area-inset-top,0px));left:0;right:0;height:44px;padding:0 12px;box-sizing:border-box;border-top:1px solid var(--line);background:var(--soft);font:12px/44px var(--sans);color:var(--ink)}
852+signer-connection[hidden]{display:none}
853+signer-connection>span{display:block;padding-right:92px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
854+signer-connection>button{position:absolute;right:8px;top:0;min-width:88px;height:44px;border:0;background:transparent;color:var(--accent);font:600 12px var(--sans)}
855+body:has(signer-connection:not([hidden])) #session-status{display:none}
856+#mobile-back,#menu{display:block;box-sizing:border-box;width:44px;height:44px;padding:10px;border:0;border-radius:10px;background:transparent;color:var(--ink);line-height:0;cursor:pointer;text-align:center}
857+#mobile-back{position:absolute;top:calc(10px + env(safe-area-inset-top,0px));left:calc(6px + env(safe-area-inset-left,0px))}
858+#mobile-back:hover,#menu:hover,#context-toggle:hover{background:var(--hover);text-decoration:none}
859+#nav-menu{position:absolute;right:calc(8px + env(safe-area-inset-right,0px));top:calc(10px + env(safe-area-inset-top,0px));margin:0}
860+#menu,#context-toggle{list-style:none}
861+#menu::-webkit-details-marker,#context-toggle::-webkit-details-marker{display:none}
862+#menu [data-close-icon],#nav-menu[open] [data-menu-icon]{display:none}
863+#nav-menu[open] [data-close-icon]{display:inline}
864+#context-menu{position:absolute;left:calc(14px + env(safe-area-inset-left,0px));right:calc(62px + env(safe-area-inset-right,0px));top:calc(4px + env(safe-area-inset-top,0px));min-width:0}
865+#topbar:has(#mobile-back) #context-menu{left:calc(54px + env(safe-area-inset-left,0px))}
866+#context-toggle{position:relative;display:block;box-sizing:border-box;height:56px;padding:9px 20px 9px 0;border-radius:8px;cursor:pointer;min-width:0}
867+#context-toggle:has(room-avatar),#context-toggle:has(nostr-avatar){padding-left:44px}
868+#context-toggle>room-avatar,#context-toggle>nostr-avatar{position:absolute;left:0;top:10px;width:36px;height:36px}
869+#mobile-heading,#mobile-title,#mobile-context{display:block;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
870+#mobile-title{font:600 16px/21px var(--sans);color:var(--ink)}
871+#mobile-context{font:12px/17px var(--sans);color:var(--muted)}
872+#context-toggle>[data-chevron]{position:absolute;right:0;top:20px;color:var(--muted)}
873+#context-menu[open] #context-toggle>[data-chevron]{transform:rotate(180deg)}
874+#topbar a:focus-visible,#topbar summary:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
875 #layout,#layout>tbody,#layout>tbody>tr,#layout>tbody>tr>td{display:block;width:auto;height:auto}
876-#layout{padding:40px 0 28px}
877+#layout{padding:var(--mobile-header-height) 0 env(safe-area-inset-bottom,0px)}
878 #railbox,#content,#panel{position:static;overflow:visible}
879-#layout>tbody>tr>td#rail{position:fixed;top:40px;bottom:28px;left:0;width:240px;height:auto;box-sizing:border-box;border-right:1px solid var(--line);transform:translateX(-102%);visibility:hidden;transition:transform .15s;z-index:3;overflow:hidden auto}
880+#layout>tbody>tr>td#rail{position:fixed;top:var(--mobile-header-height);bottom:calc(96px + env(safe-area-inset-bottom,0px));left:0;width:var(--mobile-drawer-width);height:auto;box-sizing:border-box;border-right:1px solid var(--line);transform:translateX(-102%);visibility:hidden;transition:transform .15s;z-index:4;overflow:hidden auto}
881 #railbox{position:relative;min-height:100%}
882+body:has(#nav-menu[open]) #main::after{content:"";position:fixed;top:var(--mobile-header-height);bottom:0;left:0;right:0;background:rgba(0,0,0,.25);z-index:3}
883 #topbar:has(#nav-menu[open]) + #layout>tbody>tr>td#rail{transform:none;visibility:visible;box-shadow:8px 0 24px rgba(0,0,0,.15)}
884-#layout>tbody>tr>td#aside{width:auto;border-left:0;border-top:1px solid var(--line)}
885+#layout>tbody>tr>td#aside{display:none;width:auto;border-left:0;border-top:0}
886+body:has(#context-menu[open]) #layout>tbody>tr>td#aside{display:block;position:fixed;top:var(--mobile-header-height);bottom:0;left:0;right:0;height:auto;overflow:auto;z-index:4;background:var(--paper);padding-bottom:env(safe-area-inset-bottom,0px)}
887 #content>section{padding:14px 2.5ch 20px}
888-#prompt{display:none}#footer td:last-child{text-align:left;white-space:nowrap}
889-#footer{padding:0 12px}#who{font-size:11px}
890-#install{left:auto;right:12px;transform:none}
891-#navigation-status:not(:empty),#session-status:not(:empty){position:absolute;bottom:100%;left:0;right:0;padding:6px 12px;background:var(--rail);border-top:1px solid var(--line);white-space:normal}
892+#room>h1,#thread>#crumbs,#direct-chat>header>h1{display:none}
893+#thread>h1{font-size:0;margin-bottom:8px}#thread>h1 small{font-size:12px}
894+#mobile-actions{display:block;padding:12px 20px;border-top:1px solid var(--line)}
895+#personal-nav{display:none}
896+#mobile-actions>a,#mobile-actions button{display:block;box-sizing:border-box;width:100%;min-height:44px;padding:12px 0;font:13px/20px var(--sans);text-align:left;background:none;border:0;color:var(--accent)}
897+#mobile-actions page-link{display:block}
898+html[data-js=no] #mobile-actions page-link{display:none}
899+#prompt{display:none}
900+#footer{height:0;padding:0;border:0;background:none;z-index:4}
901+#footer>table,#footer #install{display:none}
902+body:has(#nav-menu[open]) #footer{width:var(--mobile-drawer-width);height:calc(96px + env(safe-area-inset-bottom,0px));padding:0 12px env(safe-area-inset-bottom,0px);border-top:1px solid var(--line);border-right:1px solid var(--line);background:var(--rail)}
903+body:has(#nav-menu[open]) #footer>table{display:table;height:56px;table-layout:fixed}
904+#footer td:first-child{display:none}#footer td:last-child{text-align:left;white-space:nowrap;overflow:hidden}
905+#who{font-size:11px}#who>a{display:inline-block;max-width:100px;vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#who button{min-height:44px}
906+body:has(#nav-menu[open]) #install:not([hidden]){display:block;position:static;transform:none;width:100%;height:40px;text-align:left}
907+#navigation-status:not(:empty),#session-status:not(:empty){position:fixed;bottom:env(safe-area-inset-bottom,0px);left:0;right:0;padding:6px 12px;background:var(--rail);border-top:1px solid var(--line);white-space:normal}
908 #split,#split>tbody,#split>tbody>tr,#split>tbody>tr>td{display:block;width:auto}
909 #tree{border-right:0;border-bottom:1px solid var(--line)}
910 #rows{table-layout:fixed}#rows td{white-space:normal;overflow-wrap:anywhere;height:auto;padding:8px 6px;line-height:1.35}#rows td:first-child{width:90px}#rows td:last-child{width:80px}#rows:has(td[data-state]) td:nth-child(3),#rows:has(td[data-state]) th:nth-child(3){display:none}#rows:has(td[data-state]) td:last-child{width:80px}connect-list #rows td:nth-child(2),connect-list #rows th:nth-child(2){display:none}connect-list #rows td:nth-child(3),connect-list #rows td:last-child{width:auto}connect-list #rows td:last-child{white-space:normal}connect-list #rows td:last-child button{margin:2px 0}
911@@ -411,8 +448,7 @@ relay-lists #rows td:last-child button{margin:2px 6px 2px 0}
912 room-message[data-own]{margin-left:12px;padding-right:10px}
913 /* Phones: each wiki row reads as a title line and one muted line of facts. */
914 #wiki-list #rows thead{display:none}#wiki-list #rows,#wiki-list #rows tbody,#wiki-list #rows tr{display:block;width:auto}#wiki-list #rows tr{padding:8px 0;border-bottom:1px solid var(--line)}#wiki-list #rows td{display:inline;padding:0;border:0;width:auto;height:auto;line-height:1.4;font:12px var(--mono);color:var(--muted)}#wiki-list #rows td:first-child{display:block;font:14px var(--sans);color:var(--ink);margin:0 0 2px}#wiki-list #rows td:first-child small{display:block;margin:2px 0 0}#wiki-list #rows td:nth-child(3){display:none}#wiki-list #rows td:nth-child(2)::after,#wiki-list #rows td:nth-child(4)::after{content:" | "}
915-room-compose{bottom:calc(28px + env(safe-area-inset-bottom,0px));margin:8px -2.5ch 0;padding:8px 12px;z-index:1}
916-body:has(#room) #layout>tbody>tr>td#aside,body:has(#thread) #layout>tbody>tr>td#aside{display:none}
917+room-compose{bottom:0;margin:8px -2.5ch 0;padding:8px 12px calc(8px + env(safe-area-inset-bottom,0px));z-index:1}
918 }
919
920 /* Social: a calm reading stream that keeps short notes quick and gives
921@@ -517,7 +553,7 @@ direct-settings button{width:100%;min-height:40px;white-space:normal;font-size:1
922 direct-thread{max-height:calc(100dvh - 340px);min-height:160px}
923 direct-thread>article{margin-right:0;padding-left:48px}
924 direct-thread>article[data-own=""]{margin-left:10px}
925-direct-compose{bottom:calc(28px + env(safe-area-inset-bottom,0px));padding-bottom:8px}
926+direct-compose{bottom:0;padding-bottom:calc(8px + env(safe-area-inset-bottom,0px))}
927 }
928 direct-thread article>blockquote{margin:4px 0 9px;padding:6px 10px;border-left:2px solid var(--muted);color:var(--muted);font:12px/1.5 var(--sans);white-space:pre-wrap}
929 direct-thread article>footer{margin-top:8px;font:12px var(--mono);color:var(--muted)}
930diff --git a/internal/webui/templates.go b/internal/webui/templates.go
931index 425795d..bb1ae66 100644
932--- a/internal/webui/templates.go
933+++ b/internal/webui/templates.go
934@@ -303,6 +303,7 @@ func parseTemplates(a *App) (*template.Template, error) {
935 "socialMediaURL": socialMediaURL,
936 "avatarURL": avatarURL,
937 "roomAvatarURL": roomAvatarURL,
938+ "mobileHeader": mobileHeader,
939 "markdown": a.markdown,
940 "chatMarkdown": a.chatMarkdown,
941 "hasPrefix": strings.HasPrefix,
942diff --git a/scripts/qa/signer-resume-fixture.mjs b/scripts/qa/signer-resume-fixture.mjs
943new file mode 100644
944index 0000000..1546b68
945--- /dev/null
946+++ b/scripts/qa/signer-resume-fixture.mjs
947@@ -0,0 +1,159 @@
948+#!/usr/bin/env node
949+// Local-only NIP-46 signer fixture for browser suspension and recovery checks.
950+// It exposes a tiny Nostr relay and a deterministic remote signer. The control
951+// endpoint deliberately drops transport connections so a browser can exercise
952+// its lost-signer and reconnect states without a phone or external relay.
953+import { createServer } from "node:http";
954+import { WebSocketServer, WebSocket } from "ws";
955+import { finalizeEvent, getPublicKey, verifyEvent } from "nostr-tools/pure";
956+import { BunkerSigner } from "nostr-tools/nip46";
957+import * as nip44 from "nostr-tools/nip44";
958+
959+const relayPort = Number(process.env.SIGNER_RELAY_PORT ?? 18471);
960+const controlPort = Number(process.env.SIGNER_CONTROL_PORT ?? 18472);
961+if (!Number.isInteger(relayPort) || !Number.isInteger(controlPort)) throw Error("ports must be integers");
962+
963+const key = n => Uint8Array.from(Buffer.from(String(n).padStart(64, "0"), "hex"));
964+// These are the public QA identities used by scripts/qa/buzz-chat-fixture.mjs.
965+const phoneSecret = key(process.env.SIGNER_PHONE_SECRET ?? "1");
966+const serviceSecret = key(process.env.SIGNER_SERVICE_SECRET ?? "4");
967+const clientSecret = key(process.env.SIGNER_CLIENT_SECRET ?? "5");
968+const phonePubkey = getPublicKey(phoneSecret);
969+const servicePubkey = getPublicKey(serviceSecret);
970+const clientPubkey = getPublicKey(clientSecret);
971+const relayURL = `ws://127.0.0.1:${relayPort}`;
972+const events = [];
973+const peers = new Map();
974+let offline = false;
975+
976+const matches = (event, filter) => {
977+ if (filter.ids && !filter.ids.includes(event.id)) return false;
978+ if (filter.kinds && !filter.kinds.includes(event.kind)) return false;
979+ if (filter.authors && !filter.authors.includes(event.pubkey)) return false;
980+ if (filter.since && event.created_at < filter.since) return false;
981+ if (filter.until && event.created_at > filter.until) return false;
982+ for (const [name, values] of Object.entries(filter)) {
983+ if (name.startsWith("#") && !event.tags.some(tag => tag[0] === name.slice(1) && values.includes(tag[1]))) return false;
984+ }
985+ return true;
986+};
987+const send = (socket, value) => {
988+ if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(value));
989+};
990+const broadcast = event => {
991+ events.push(event);
992+ for (const [socket, subscriptions] of peers) {
993+ for (const [id, filters] of subscriptions) {
994+ if (filters.some(filter => matches(event, filter))) send(socket, ["EVENT", id, event]);
995+ }
996+ }
997+};
998+
999+const relay = new WebSocketServer({ host: "127.0.0.1", port: relayPort });
1000+relay.on("connection", socket => {
1001+ if (offline) {
1002+ socket.close(1013, "fixture offline");
1003+ return;
1004+ }
1005+ const subscriptions = new Map();
1006+ peers.set(socket, subscriptions);
1007+ socket.on("message", raw => {
1008+ if (offline) return;
1009+ let message;
1010+ try { message = JSON.parse(raw); } catch { return; }
1011+ if (message[0] === "REQ") {
1012+ const [id, ...filters] = message.slice(1);
1013+ subscriptions.set(id, filters);
1014+ for (const event of events) if (filters.some(filter => matches(event, filter))) send(socket, ["EVENT", id, event]);
1015+ send(socket, ["EOSE", id]);
1016+ } else if (message[0] === "EVENT" && message[1]) {
1017+ const event = message[1];
1018+ if (verifyEvent(event)) {
1019+ broadcast(event);
1020+ send(socket, ["OK", event.id, true, ""]);
1021+ } else send(socket, ["OK", event.id, false, "invalid event"]);
1022+ } else if (message[0] === "CLOSE") subscriptions.delete(message[1]);
1023+ });
1024+ socket.on("close", () => peers.delete(socket));
1025+});
1026+
1027+const reply = (requestEvent, request) => {
1028+ const conversation = nip44.getConversationKey(serviceSecret, requestEvent.pubkey);
1029+ const result = (() => {
1030+ switch (request.method) {
1031+ case "connect": return "ack";
1032+ case "ping": return "pong";
1033+ case "get_public_key": return phonePubkey;
1034+ case "sign_event": return JSON.stringify(finalizeEvent(JSON.parse(request.params[0]), phoneSecret));
1035+ case "nip44_encrypt": return nip44.encrypt(request.params[1], nip44.getConversationKey(phoneSecret, request.params[0]));
1036+ case "nip44_decrypt": return nip44.decrypt(request.params[1], nip44.getConversationKey(phoneSecret, request.params[0]));
1037+ default: throw Error("unsupported method " + request.method);
1038+ }
1039+ })();
1040+ broadcast(finalizeEvent({
1041+ kind: 24133,
1042+ tags: [["p", requestEvent.pubkey]],
1043+ content: nip44.encrypt(JSON.stringify({ id: request.id, result }), conversation),
1044+ created_at: Math.floor(Date.now() / 1000)
1045+ }, serviceSecret));
1046+};
1047+
1048+const handled = new Set();
1049+const requestPoller = setInterval(() => {
1050+ if (offline) return;
1051+ for (const event of events) {
1052+ if (event.kind !== 24133 || event.pubkey !== clientPubkey || !event.tags.some(tag => tag[0] === "p" && tag[1] === servicePubkey) || handled.has(event)) continue;
1053+ handled.add(event);
1054+ try {
1055+ const conversation = nip44.getConversationKey(serviceSecret, event.pubkey);
1056+ reply(event, JSON.parse(nip44.decrypt(event.content, conversation)));
1057+ } catch (error) { console.error("signer request failed:", error.message); }
1058+ }
1059+}, 25);
1060+
1061+const control = createServer((request, response) => {
1062+ if (request.method !== "POST" || !["/offline", "/online"].includes(request.url)) {
1063+ response.writeHead(404, { "content-type": "text/plain" });
1064+ response.end("use POST /offline or POST /online\n");
1065+ return;
1066+ }
1067+ offline = request.url === "/offline";
1068+ if (offline) for (const socket of peers.keys()) socket.close(1013, "fixture offline");
1069+ response.writeHead(200, { "content-type": "application/json" });
1070+ response.end(JSON.stringify({ offline }));
1071+});
1072+
1073+const clientPointer = { pubkey: servicePubkey, relays: [relayURL] };
1074+const runSelfTest = async () => {
1075+ const signer = BunkerSigner.fromBunker(clientSecret, clientPointer);
1076+ await signer.connect({ name: "tinyrelay signer resume fixture" });
1077+ if ((await signer.getPublicKey()) !== phonePubkey) throw Error("unexpected signer identity");
1078+ const signed = await signer.signEvent({ kind: 1, content: "local signer fixture", tags: [], created_at: 1 });
1079+ if (!verifyEvent(signed) || signed.pubkey !== phonePubkey) throw Error("signer returned invalid event");
1080+ await signer.close();
1081+ signer.pool.destroy();
1082+ console.log("self-test: connect, get_public_key and sign_event passed");
1083+};
1084+
1085+control.listen(controlPort, "127.0.0.1", async () => {
1086+ console.log(JSON.stringify({
1087+ relay: relayURL,
1088+ control: `http://127.0.0.1:${controlPort}`,
1089+ bunker: `bunker://${servicePubkey}?relay=${encodeURIComponent(relayURL)}`,
1090+ phonePubkey,
1091+ servicePubkey,
1092+ clientPubkey,
1093+ localOnly: true,
1094+ controls: ["POST /offline", "POST /online"]
1095+ }, null, 2));
1096+ try { await runSelfTest(); } catch (error) { console.error("self-test failed:", error); process.exitCode = 1; }
1097+});
1098+
1099+const stop = () => {
1100+ clearInterval(requestPoller);
1101+ for (const socket of peers.keys()) socket.terminate();
1102+ control.close();
1103+ relay.close();
1104+};
1105+process.once("SIGINT", stop);
1106+process.once("SIGTERM", stop);
1107diff --git a/scripts/shell-navigation.test.mjs b/scripts/shell-navigation.test.mjs
1108new file mode 100644
1109index 0000000..77b46fb
1110--- /dev/null
1111+++ b/scripts/shell-navigation.test.mjs
1112@@ -0,0 +1,90 @@
1113+// Navigation keeps in-place refreshes comfortable while making a new route
1114+// start at a predictable position on small screens.
1115+import test from "node:test";
1116+import assert from "node:assert/strict";
1117+import fs from "node:fs";
1118+import vm from "node:vm";
1119+
1120+const source = fs.readFileSync("internal/webui/components.js", "utf8");
1121+const start = source.indexOf(" const swapShell = (text, url, push) => {");
1122+const end = source.indexOf(" const load = async (url, push) => {", start);
1123+const swapSource = source.slice(start, end);
1124+
1125+function setup({url = "https://relay.test/rooms/general", active = "content", contentScroll = 0, pageScroll = 0} = {}) {
1126+ const calls = {scroll: [], history: [], focus: []};
1127+ const elements = {
1128+ "#topbar": {outerHTML: "<header id=topbar></header>"},
1129+ "#railbox": {outerHTML: "<aside id=railbox></aside>"},
1130+ "#content": {outerHTML: "<main id=content></main>", scrollTop: contentScroll, focus(options) { calls.focus.push({id: "content", options}); }},
1131+ "#panel": {outerHTML: "<aside id=panel></aside>"},
1132+ "#footer": {outerHTML: "<footer id=footer></footer>"}
1133+ };
1134+ const nav = {open: false, removeAttribute(name) { if (name === "open") this.open = false; }};
1135+ const context = {open: false, removeAttribute(name) { if (name === "open") this.open = false; }};
1136+ const draft = {id: "draft", focus(options) { calls.focus.push({id: this.id, options}); }};
1137+ const document = {
1138+ activeElement: active === "draft" ? draft : elements["#content"],
1139+ title: "old",
1140+ querySelector(selector) { return elements[selector] || null; },
1141+ getElementById(id) { return id === "nav-menu" ? nav : id === "context-menu" ? context : id === "content" ? elements["#content"] : id === "draft" ? draft : null; },
1142+ dispatchEvent() {}
1143+ };
1144+ const parsed = {
1145+ title: "new",
1146+ querySelector(selector) { return elements[selector] || null; }
1147+ };
1148+ const sandbox = {
1149+ document,
1150+ DOMParser: class { parseFromString() { return parsed; } },
1151+ CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init.detail; } },
1152+ history: {pushState(_state, _title, href) { calls.history.push(href); }},
1153+ window: {scrollY: pageScroll, scrollTo(...args) { calls.scroll.push(args); }},
1154+ location: {href: url},
1155+ morph() {},
1156+ navTargets: ["#topbar", "#railbox", "#content", "#panel", "#footer"],
1157+ navigationSerial: 0,
1158+ closeStreams() {},
1159+ repairComponents() {}
1160+ };
1161+ // Keep the production closure intact, while supplying only its DOM shell
1162+ // dependencies. The returned function is the implementation under test.
1163+ const swap = vm.runInNewContext(`(() => { let renderedURL = ${JSON.stringify(url)}; const navTargets = ${JSON.stringify(sandbox.navTargets)}; let navigationSerial = 0; const closeStreams = () => {}; const repairComponents = () => {}; const ensureModules = () => {}; const decorate = () => {}; const decorateForms = () => {}; ${swapSource.replace(" const swapShell", "const swapShell")} return swapShell; })()`, sandbox);
1164+ nav.open = true;
1165+ context.open = true;
1166+ return {swap, nav, context, content: elements["#content"], document, calls};
1167+}
1168+
1169+test("a changed route resets both menu drawers and scroll", () => {
1170+ const s = setup({contentScroll: 420, pageScroll: 880});
1171+ s.swap("<html></html>", new URL("https://relay.test/rooms/general/thread/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), true);
1172+ assert.equal(s.nav.open, false);
1173+ assert.equal(s.context.open, false);
1174+ assert.equal(s.content.scrollTop, 0);
1175+ assert.deepEqual(s.calls.scroll, [[0, 0]]);
1176+ assert.equal(s.calls.focus.length, 1);
1177+ assert.equal(s.calls.focus[0].id, "content");
1178+ assert.equal(s.calls.focus[0].options.preventScroll, true);
1179+ assert.equal(s.calls.history.length, 1);
1180+});
1181+
1182+test("an in-place refresh preserves scroll and the active draft", () => {
1183+ const s = setup({active: "draft", contentScroll: 315, pageScroll: 640});
1184+ s.nav.open = false;
1185+ s.context.open = false;
1186+ s.swap("<html></html>", new URL("https://relay.test/rooms/general"), false);
1187+ assert.equal(s.content.scrollTop, 315);
1188+ assert.deepEqual(s.calls.scroll, []);
1189+ assert.equal(s.calls.focus.length, 1);
1190+ assert.equal(s.calls.focus[0].id, "draft");
1191+ assert.equal(s.calls.focus[0].options.preventScroll, true);
1192+ assert.equal(s.nav.open, false);
1193+ assert.equal(s.context.open, false);
1194+});
1195+
1196+test("a browser-back route change resets position without pushing history", () => {
1197+ const s = setup({url: "https://relay.test/rooms/general/thread/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", contentScroll: 190, pageScroll: 230});
1198+ s.swap("<html></html>", new URL("https://relay.test/rooms/general"), false);
1199+ assert.equal(s.content.scrollTop, 0);
1200+ assert.deepEqual(s.calls.scroll, [[0, 0]]);
1201+ assert.deepEqual(s.calls.history, []);
1202+});
1203diff --git a/scripts/signer-recovery.test.mjs b/scripts/signer-recovery.test.mjs
1204new file mode 100644
1205index 0000000..0afa04a
1206--- /dev/null
1207+++ b/scripts/signer-recovery.test.mjs
1208@@ -0,0 +1,178 @@
1209+import assert from "node:assert/strict";
1210+import {webcrypto} from "node:crypto";
1211+import {readFile} from "node:fs/promises";
1212+import test from "node:test";
1213+import vm from "node:vm";
1214+
1215+const bridge = await readFile(new URL("../internal/webui/bridge.js", import.meta.url), "utf8");
1216+const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8");
1217+
1218+class Element {
1219+ constructor(tag = "div") { this.tagName = tag.toUpperCase(); this.children = []; this.attributes = new Map(); this.hidden = false; }
1220+ setAttribute(k, v) { this.attributes.set(k, String(v)); }
1221+ getAttribute(k) { return this.attributes.get(k) ?? null; }
1222+ hasAttribute(k) { return this.attributes.has(k); }
1223+ append(...items) { this.children.push(...items); }
1224+ replaceChildren(...items) { this.children = items; }
1225+ addEventListener(name, fn) { this.events ||= {}; this.events[name] = fn; }
1226+ dispatch(name, event = {}) { return this.events?.[name]?.({...event, currentTarget: this}); }
1227+ matches() { return false; }
1228+ querySelector() { return null; }
1229+ get textContent() { return this.children.map(child => child.textContent ?? "").join(""); }
1230+ set textContent(value) { this.children = [{textContent: String(value)}]; }
1231+}
1232+
1233+async function page({actor = "a".repeat(64), native = null, stored = false, signerFactory = () => null, now = Date.now()} = {}) {
1234+ const nodes = new Map();
1235+ for (const id of ["session-status", "session-login", "session-logout", "signer-status", "bunker", "bunker-url"]) nodes.set(id, new Element(id));
1236+ const connection = new Element("signer-connection"); connection.setAttribute("actor", actor); nodes.set("signer-connection", connection);
1237+ const docEvents = new Map(), winEvents = new Map(), session = new Map(), local = new Map(), requests = [];
1238+ const on = (map, name, fn) => map.set(name, [...(map.get(name) || []), fn]);
1239+ const fire = (map, name, event = {}) => Promise.all((map.get(name) || []).map(fn => fn(event)));
1240+ const nativeObject = native;
1241+ const signer = nativeObject;
1242+ let clock = now;
1243+ const sandbox = {
1244+ tiny: {}, document: {
1245+ visibilityState: "visible", referrer: "", documentElement: {dataset: {}},
1246+ getElementById: id => nodes.get(id), querySelector: selector => selector === "signer-connection" ? nodes.get("signer-connection") : null,
1247+ querySelectorAll: () => [], createElement: tag => new Element(tag),
1248+ addEventListener: on.bind(null, docEvents), dispatchEvent: event => fire(docEvents, event.type, event)
1249+ },
1250+ crypto: webcrypto, TextEncoder, URL, URLSearchParams, Uint8Array, ArrayBuffer, Date: class extends Date { static now() { return clock; } }, setTimeout, clearTimeout,
1251+ btoa: value => Buffer.from(value).toString("base64"),
1252+ location: {pathname: "/r/work/rooms/general", href: "https://tiny.example/r/work/rooms/general?x=1#part", origin: "https://tiny.example", assign(url) { this.assigned = url; }, replace(url) { this.replaced = url; }, reload() { this.reloaded = true; }},
1253+ navigator: {clipboard: {writeText: async () => {}}}, getComputedStyle: () => ({getPropertyValue: () => ""}),
1254+ matchMedia: () => ({matches: false, addEventListener() {}}), addEventListener: on.bind(null, winEvents),
1255+ localStorage: {getItem: k => local.get(k) ?? null, setItem: (k, v) => local.set(k, String(v)), removeItem: k => local.delete(k)},
1256+ sessionStorage: {getItem: k => session.get(k) ?? null, setItem: (k, v) => session.set(k, String(v)), removeItem: k => session.delete(k)},
1257+ NostrSigner: {
1258+ hexToBytes: () => new Uint8Array(32), bytesToHex: () => "key", generateSecretKey: () => new Uint8Array(32), getPublicKey: () => "b".repeat(64),
1259+ parseBunkerInput: async () => ({pubkey: "c".repeat(64), relays: ["wss://relay.test"]}), BunkerSigner: {fromBunker: signerFactory}
1260+ },
1261+ fetch: async (url, options) => { requests.push({url, options}); return new Response("{}"); }
1262+ };
1263+ sandbox.window = sandbox;
1264+ if (signer) sandbox.nostr = signer;
1265+ vm.runInNewContext(shared + bridge, sandbox);
1266+ await new Promise(resolve => setTimeout(resolve, 0));
1267+ if (stored) { session.set("tiny.bunker/r/work.uri", "bunker://saved"); session.set("tiny.bunker/r/work.sk", "key"); session.set("tiny.bunker/r/work.identity", "c".repeat(64)); session.set("tiny.bunker/r/work.remote", JSON.stringify({pubkey: "c".repeat(64), relays: ["wss://relay.test"]})); }
1268+ return {sandbox, nodes, docEvents, winEvents, session, local, requests, setClock: value => { clock = value; }, fire: (map, name, event) => fire(map, name, event)};
1269+}
1270+
1271+const saved = (session, actor = "c".repeat(64)) => {
1272+ session.set("tiny.bunker/r/work.uri", "bunker://saved"); session.set("tiny.bunker/r/work.sk", "key"); session.set("tiny.bunker/r/work.identity", actor); session.set("tiny.bunker/r/work.remote", JSON.stringify({pubkey: actor, relays: ["wss://relay.test"]}));
1273+};
1274+
1275+test("recovery checks a late native signer without replacing window.nostr", async () => {
1276+ const native = {getPublicKey: async () => "a".repeat(64), signEvent: async event => event};
1277+ const {sandbox, winEvents} = await page();
1278+ sandbox.nostr = native;
1279+ await sandbox.tiny.reconnectSigner();
1280+ assert.equal(sandbox.nostr, native);
1281+ assert.equal(sandbox.tiny.signerState(), "ready");
1282+ assert.equal(sandbox.location.replaced, undefined);
1283+ assert.ok(winEvents.get("pageshow"));
1284+});
1285+
1286+test("missing remote credentials gives a connect path and navigation restores it", async () => {
1287+ const {sandbox, nodes, docEvents} = await page();
1288+ await sandbox.tiny.reconnectSigner({force: true}).catch(() => {});
1289+ assert.equal(nodes.get("signer-connection").hidden, false);
1290+ assert.equal(nodes.get("signer-connection").children[1].textContent, "Connect signer");
1291+ nodes.get("signer-connection").children[1].dispatch("click");
1292+ assert.match(sandbox.location.assigned, /signin\?connect=1&next=/);
1293+ const replacement = new Element("signer-connection"); replacement.setAttribute("actor", "a".repeat(64)); nodes.set("signer-connection", replacement);
1294+ await Promise.all((docEvents.get("tiny:navigation") || []).map(fn => fn()));
1295+ assert.equal(replacement.hidden, false);
1296+});
1297+
1298+test("remote ping keeps the same signer and failed replacement closes both pools", async () => {
1299+ let oldClose = 0, oldDestroy = 0, candidateClose = 0, candidateDestroy = 0, ping = 0;
1300+ const pool = () => ({destroy: () => { oldDestroy++; }});
1301+ const old = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, pool: pool(), connect: async () => {}, ping: async () => { ping++; }, close: () => { oldClose++; }, getPublicKey: async () => "c".repeat(64), signEvent: async e => e};
1302+ const candidate = {bp: old.bp, pool: {destroy: () => { candidateDestroy++; }}, connect: async () => {}, close: () => { candidateClose++; }, getPublicKey: async () => "c".repeat(64)};
1303+ const failedCandidate = {...candidate, connect: async () => { throw Error("offline"); }};
1304+ const {sandbox, session, setClock} = await page({actor: "c".repeat(64), signerFactory: () => old, now: 1000});
1305+ saved(session);
1306+ await sandbox.tiny.reconnectSigner({force: true});
1307+ const first = sandbox.tinySigner;
1308+ setClock(20000);
1309+ await sandbox.tiny.reconnectSigner();
1310+ assert.equal(sandbox.tinySigner, first); assert.equal(ping, 1);
1311+ // Force a replacement after ping fails.
1312+ old.ping = async () => { throw Error("offline"); };
1313+ sandbox.NostrSigner.BunkerSigner.fromBunker = () => failedCandidate;
1314+ await assert.rejects(sandbox.tiny.reconnectSigner({force: true}), /offline/);
1315+ assert.equal(sandbox.tiny.signerState(), "lost");
1316+ assert.ok(oldClose > 0 && oldDestroy > 0 && candidateClose > 0 && candidateDestroy > 0);
1317+ assert.equal(session.get("tiny.bunker/r/work.sk"), "key");
1318+});
1319+
1320+test("signer denial stays ready while transport failure reports lost", async () => {
1321+ let reject = false;
1322+ const remote = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, connect: async () => {}, getPublicKey: async () => "c".repeat(64), signEvent: async () => { throw Error(reject ? "user rejected" : "connection closed"); }};
1323+ const {sandbox, session} = await page({actor: "c".repeat(64), signerFactory: () => remote}); saved(session);
1324+ await sandbox.tiny.reconnectSigner({force: true});
1325+ await assert.rejects(sandbox.tinySigner.signEvent({}), /connection closed/); assert.equal(sandbox.tiny.signerState(), "lost");
1326+ reject = true;
1327+ await sandbox.tiny.reconnectSigner({force: true});
1328+ await assert.rejects(sandbox.tinySigner.signEvent({}), /user rejected/); assert.equal(sandbox.tiny.signerState(), "ready");
1329+});
1330+
1331+test("a brief background suspension forces a fresh remote health check", async () => {
1332+ let pings = 0;
1333+ const remote = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, connect: async () => {}, ping: async () => { pings++; }, getPublicKey: async () => "c".repeat(64)};
1334+ const {sandbox, session, fire, docEvents, setClock} = await page({actor: "c".repeat(64), signerFactory: () => remote, now: 1000}); saved(session);
1335+ await sandbox.tiny.reconnectSigner({force: true});
1336+ setClock(2000);
1337+ sandbox.document.visibilityState = "hidden";
1338+ await fire(docEvents, "visibilitychange");
1339+ sandbox.document.visibilityState = "visible";
1340+ await fire(docEvents, "visibilitychange");
1341+ await sandbox.tiny.reconnectSigner();
1342+ assert.equal(sandbox.tiny.signerState(), "ready");
1343+ assert.equal(pings, 1);
1344+});
1345+
1346+test("successful remote replacement closes the old signer and changes the facade", async () => {
1347+ let oldClose = 0, oldDestroy = 0;
1348+ const old = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, connect: async () => {}, pool: {destroy: () => { oldDestroy++; }}, close: () => { oldClose++; }, getPublicKey: async () => "c".repeat(64), ping: async () => { throw Error("offline"); }};
1349+ const next = {bp: old.bp, connect: async () => {}, pool: {destroy() {}}, close() {}, getPublicKey: async () => "c".repeat(64)};
1350+ const {sandbox, session} = await page({actor: "c".repeat(64), signerFactory: () => old}); saved(session);
1351+ await sandbox.tiny.reconnectSigner({force: true}); const previous = sandbox.tinySigner;
1352+ sandbox.NostrSigner.BunkerSigner.fromBunker = () => next;
1353+ await sandbox.tiny.reconnectSigner({force: true});
1354+ assert.notEqual(sandbox.tinySigner, previous); assert.equal(sandbox.tiny.signerState(), "ready");
1355+ assert.ok(oldClose > 0 && oldDestroy > 0);
1356+});
1357+
1358+test("an old pending signer operation cannot clear a replacement", async () => {
1359+ let release;
1360+ const old = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, connect: async () => {}, close() {}, pool: {destroy() {}}, getPublicKey: async () => "c".repeat(64), signEvent: async () => new Promise(resolve => { release = resolve; }), ping: async () => { throw Error("offline"); }};
1361+ const next = {bp: old.bp, connect: async () => {}, close() {}, pool: {destroy() {}}, getPublicKey: async () => "c".repeat(64)};
1362+ const {sandbox, session} = await page({actor: "c".repeat(64), signerFactory: () => old}); saved(session);
1363+ await sandbox.tiny.reconnectSigner({force: true});
1364+ const pending = sandbox.tinySigner.signEvent({});
1365+ sandbox.NostrSigner.BunkerSigner.fromBunker = () => next;
1366+ await sandbox.tiny.reconnectSigner({force: true});
1367+ await assert.rejects(pending, /connection changed/);
1368+ release({});
1369+ assert.equal(sandbox.tiny.signerState(), "ready");
1370+});
1371+
1372+test("native signer identity mismatch reports loss", async () => {
1373+ const native = {getPublicKey: async () => "b".repeat(64), signEvent: async event => event};
1374+ const {sandbox} = await page({actor: "a".repeat(64), native});
1375+ sandbox.nostr = native;
1376+ await assert.rejects(sandbox.tiny.reconnectSigner({force: true}), /signed-in account/);
1377+ assert.equal(sandbox.tiny.signerState(), "lost"); assert.equal(sandbox.nostr, native);
1378+});
1379+
1380+test("NIP-44 transport failure reports loss", async () => {
1381+ const remote = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, connect: async () => {}, getPublicKey: async () => "c".repeat(64), nip44Decrypt: async () => { throw Error("connection closed"); }};
1382+ const {sandbox, session} = await page({actor: "c".repeat(64), signerFactory: () => remote}); saved(session);
1383+ await sandbox.tiny.reconnectSigner({force: true});
1384+ await assert.rejects(sandbox.tinySigner.nip44Decrypt("peer", "ciphertext"), /connection closed/);
1385+ assert.equal(sandbox.tiny.signerState(), "lost");
1386+});
1387diff --git a/scripts/signer.test.mjs b/scripts/signer.test.mjs
1388index d7a47b2..35db69b 100644
1389--- a/scripts/signer.test.mjs
1390+++ b/scripts/signer.test.mjs
1391@@ -7,7 +7,7 @@ import vm from "node:vm";
1392 const bridge = await readFile(new URL("../internal/webui/bridge.js", import.meta.url), "utf8");
1393 const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8");
1394
1395-async function page({signedIn = false, bunkerResult = null, href = "https://tiny.example/r/work/signin", referrer = ""} = {}) {
1396+async function page({signedIn = false, bunkerResult = null, href = "https://tiny.example/r/work/signin", referrer = "", connect = async () => {}} = {}) {
1397 const elements = new Map();
1398 for (const id of ["session-login", "nostrconnect", "nostrconnect-open", "signer-qr", "signer-status", "bunker", "bunker-url", ...(signedIn ? ["session-logout"] : [])]) {
1399 elements.set(id, {hidden: true, value: "", addEventListener(event, handler) { this[event] = handler; }, setAttribute() {}, removeAttribute() {}});
1400@@ -15,11 +15,11 @@ async function page({signedIn = false, bunkerResult = null, href = "https://tiny
1401 const saved = new Map();
1402 const documentEvents = new Map();
1403 const requests = [];
1404- const signer = {bp: {pubkey: "a".repeat(64), relays: ["wss://tiny.example/r/work"]}, connect: async () => {}, getPublicKey: async () => "a".repeat(64), signEvent: async event => ({...event, pubkey: "a".repeat(64), sig: "test"})};
1405+ const signer = {bp: {pubkey: "a".repeat(64), relays: ["wss://tiny.example/r/work"]}, connect, getPublicKey: async () => "a".repeat(64), signEvent: async event => ({...event, pubkey: "a".repeat(64), sig: "test"})};
1406 const sandbox = {
1407 tiny: {},
1408 document: {getElementById: id => elements.get(id), querySelector: () => null, referrer, addEventListener(name, handler) {documentEvents.set(name, handler);}, dispatchEvent() {}, documentElement: {dataset: {}}},
1409- crypto: webcrypto, TextEncoder, URL, URLSearchParams, Uint8Array, ArrayBuffer,
1410+ crypto: webcrypto, TextEncoder, URL, URLSearchParams, Uint8Array, ArrayBuffer, setTimeout, clearTimeout,
1411 btoa: value => Buffer.from(value).toString("base64"),
1412 location: {pathname: new URL(href).pathname, href, origin: new URL(href).origin, assign(url) { sandbox.opened = url; }, replace(url) { sandbox.opened = url; sandbox.replaced = true; }, reload() { sandbox.reloaded = true; }},
1413 navigator: {clipboard: {writeText: async () => {} }},
1414@@ -61,11 +61,13 @@ test("invalid bunker input reports a useful error without connecting", async ()
1415
1416 test("NIP-07 signer can sign in directly", async () => {
1417 const {elements, requests, sandbox} = await page();
1418+ assert.equal(sandbox.tiny.signerState(), "disconnected");
1419 sandbox.nostr = {signEvent: async event => ({...event, pubkey: "c".repeat(64), sig: "extension"})};
1420 await elements.get("session-login").click({currentTarget: elements.get("session-login")});
1421 assert.equal(requests.length, 1);
1422 assert.equal(requests[0].url, "/r/work/session");
1423 assert.match(elements.get("signer-status").textContent, /Signing in|Signed in/);
1424+ assert.equal(sandbox.tiny.signerState(), "ready");
1425 assert.equal(sandbox.opened, "/r/work/");
1426 });
1427
1428@@ -125,3 +127,25 @@ test("sign-in links carry private fragments only in the browser fragment", async
1429 assert.equal(new URLSearchParams(login.hash.slice(1)).get("return"), target);
1430 assert.equal(attrs.get("fx-action"), link.href);
1431 });
1432+
1433+test("a suspended remembered signer can reconnect without revoking the web session", async () => {
1434+ const {saved, sandbox} = await page();
1435+ saved.set("tiny.bunker/r/work.uri", "bunker://remembered");
1436+ saved.set("tiny.bunker/r/work.sk", "test-key");
1437+ saved.set("tiny.bunker/r/work.identity", "a".repeat(64));
1438+ saved.set("tiny.bunker/r/work.remote", JSON.stringify({pubkey: "a".repeat(64), relays: ["wss://tiny.example/r/work"]}));
1439+ await sandbox.tiny.reconnectSigner({force: true});
1440+ assert.equal(sandbox.tiny.signerState(), "ready");
1441+ assert.equal(saved.get("tiny.bunker/r/work.identity"), "a".repeat(64));
1442+});
1443+
1444+test("failed signer recovery reports loss and preserves reconnect credentials", async () => {
1445+ const {saved, sandbox} = await page({connect: async () => { throw Error("suspended"); }});
1446+ saved.set("tiny.bunker/r/work.uri", "bunker://remembered");
1447+ saved.set("tiny.bunker/r/work.sk", "test-key");
1448+ saved.set("tiny.bunker/r/work.identity", "a".repeat(64));
1449+ saved.set("tiny.bunker/r/work.remote", JSON.stringify({pubkey: "a".repeat(64), relays: ["wss://tiny.example/r/work"]}));
1450+ await assert.rejects(sandbox.tiny.reconnectSigner({force: true}), /suspended/);
1451+ assert.equal(sandbox.tiny.signerState(), "lost");
1452+ assert.equal(saved.get("tiny.bunker/r/work.sk"), "test-key");
1453+});

About

ownerc01503e8035f
visibilitypublic
headd855534581df

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs