| 1 | diff --git a/internal/webui/bridge.js b/internal/webui/bridge.js |
| 2 | index 13a5ed7..eaecd9b 100644 |
| 3 | --- a/internal/webui/bridge.js |
| 4 | +++ b/internal/webui/bridge.js |
| 5 | @@ -105,6 +105,9 @@ |
| 6 | document.getElementById("menu")?.focus(); |
| 7 | }); |
| 8 | if ("serviceWorker" in navigator) navigator.serviceWorker.register(localPath("/sw.js")).catch(() => {}); |
| 9 | + // An installed app asks to keep its cached shell and parked shares. Browsers |
| 10 | + // grant this silently for installed apps, so it never shows a prompt. |
| 11 | + if (matchMedia("(display-mode: standalone)").matches) navigator.storage?.persist?.().catch(() => {}); |
| 12 | Object.assign(window.tiny, {root, localPath, sha256hex, authorization, signedFetch}); |
| 13 | window.tinySignedFetch = signedFetch; |
| 14 | |
| 15 | @@ -115,16 +118,21 @@ |
| 16 | location.assign(localPath("/")); |
| 17 | }; |
| 18 | |
| 19 | + // A remote signer session lives in this tab unless the person chose to |
| 20 | + // remember the device; then it survives the installed app being closed. |
| 21 | const forgetRemote = () => { |
| 22 | - Object.values(storage).forEach(name => sessionStorage.removeItem(name)); |
| 23 | - sessionStorage.removeItem("tiny.bunker"); |
| 24 | + for (const store of [sessionStorage, localStorage]) { |
| 25 | + Object.values(storage).forEach(name => store.removeItem(name)); |
| 26 | + store.removeItem("tiny.bunker"); |
| 27 | + } |
| 28 | }; |
| 29 | |
| 30 | const rememberRemote = async (uri, key, signer) => { |
| 31 | - sessionStorage.setItem(storage.uri, uri); |
| 32 | - sessionStorage.setItem(storage.key, window.NostrSigner.bytesToHex(key)); |
| 33 | - sessionStorage.setItem(storage.pubkey, await signer.getPublicKey()); |
| 34 | - sessionStorage.setItem(storage.remote, JSON.stringify(signer.bp)); |
| 35 | + const store = document.getElementById("remember-signer")?.checked ? localStorage : sessionStorage; |
| 36 | + store.setItem(storage.uri, uri); |
| 37 | + store.setItem(storage.key, window.NostrSigner.bytesToHex(key)); |
| 38 | + store.setItem(storage.pubkey, await signer.getPublicKey()); |
| 39 | + store.setItem(storage.remote, JSON.stringify(signer.bp)); |
| 40 | }; |
| 41 | |
| 42 | const setSigner = signer => { |
| 43 | @@ -229,10 +237,11 @@ |
| 44 | |
| 45 | // Resume a remote signer stored for this tab so page loads keep working. |
| 46 | (async () => { |
| 47 | - const uri = sessionStorage.getItem(storage.uri); |
| 48 | - const keyHex = sessionStorage.getItem(storage.key); |
| 49 | - const expected = sessionStorage.getItem(storage.pubkey); |
| 50 | - const remote = sessionStorage.getItem(storage.remote); |
| 51 | + const store = sessionStorage.getItem(storage.key) ? sessionStorage : localStorage; |
| 52 | + const uri = store.getItem(storage.uri); |
| 53 | + const keyHex = store.getItem(storage.key); |
| 54 | + const expected = store.getItem(storage.pubkey); |
| 55 | + const remote = store.getItem(storage.remote); |
| 56 | if (!uri || !keyHex) return; |
| 57 | try { |
| 58 | const key = window.NostrSigner.hexToBytes(keyHex); |
| 59 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 60 | index e3bc926..f1386c3 100644 |
| 61 | --- a/internal/webui/components.js |
| 62 | +++ b/internal/webui/components.js |
| 63 | @@ -26,6 +26,8 @@ |
| 64 | // <nostr-compose kind="…" coordinate="30617:…" [root="…" root-pubkey="…" root-kind="…"] [parent="…" …]> |
| 65 | // Signs a NIP-34 issue, pull request, NIP-22 reply or status event and |
| 66 | // posts it to the relay. |
| 67 | +// <share-link [url="…"] [title="…"]> |
| 68 | +// The device share sheet for the page; empty without browser support. |
| 69 | // <push-toggle> |
| 70 | // Opts this browser into relay notifications; asks for permission only |
| 71 | // when pressed and registers the device with a signed request. |
| 72 | @@ -218,7 +220,7 @@ |
| 73 | connectedCallback() { |
| 74 | if (this.bound) return; |
| 75 | this.bound = true; |
| 76 | - this.innerHTML = '<h3>Share</h3><p><button type="button" data-copy>Copy Blossom URI</button> <button type="button" data-link>Copy share link</button></p><form data-share><label>Send to <input name="recipient" inputmode="text" placeholder="npub or hex pubkey"></label><button>Send privately</button></form><output role="status"></output>'; |
| 77 | + this.innerHTML = '<h3>Share</h3><p><button type="button" data-copy>Copy Blossom URI</button> <button type="button" data-link>Copy share link</button> <share-link></share-link></p><form data-share><label>Send to <input name="recipient" inputmode="text" placeholder="npub or hex pubkey"></label><button>Send privately</button></form><output role="status"></output>'; |
| 78 | this.output = this.querySelector("output"); |
| 79 | this.querySelector("[data-copy]").addEventListener("click", () => this.copy(this.blossomURI())); |
| 80 | this.querySelector("[data-link]").addEventListener("click", () => this.copy(this.shareLink())); |
| 81 | @@ -405,6 +407,24 @@ |
| 82 | // JsonView renders any JSON value. Arrays of objects become tables, objects |
| 83 | // become definition lists, scalar arrays become lists, and deep nesting |
| 84 | // falls back to compact JSON so a response never explodes the page. |
| 85 | + // ShareLink offers the device's own share sheet for the current page, or |
| 86 | + // the url and title attributes when set. It stays empty where the browser |
| 87 | + // has no share support, so the page reads the same without it. |
| 88 | + class ShareLink extends HTMLElement { |
| 89 | + connectedCallback() { |
| 90 | + if (this.bound) return; |
| 91 | + this.bound = true; |
| 92 | + if (!navigator.share) return; |
| 93 | + const button = el("button", "Share…"); |
| 94 | + button.type = "button"; |
| 95 | + button.addEventListener("click", () => { |
| 96 | + const data = {url: this.getAttribute("url") || location.href.split("#", 1)[0], title: this.getAttribute("title") || document.title}; |
| 97 | + navigator.share(data).catch(() => {}); |
| 98 | + }); |
| 99 | + this.replaceChildren(button); |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | // PushToggle opts this browser into relay notifications. Nothing happens |
| 104 | // until the button is pressed: permission is requested then, and the |
| 105 | // device subscription is registered with a signed request. |
| 106 | @@ -810,6 +830,7 @@ |
| 107 | customElements.define("publish-list", PublishList); |
| 108 | customElements.define("nostr-compose", NostrCompose); |
| 109 | customElements.define("push-toggle", PushToggle); |
| 110 | + customElements.define("share-link", ShareLink); |
| 111 | customElements.define("nostr-key", NostrKey); |
| 112 | customElements.define("json-view", JsonView); |
| 113 | |
| 114 | diff --git a/internal/webui/file-workspace.js b/internal/webui/file-workspace.js |
| 115 | index 53550e3..ffed562 100644 |
| 116 | --- a/internal/webui/file-workspace.js |
| 117 | +++ b/internal/webui/file-workspace.js |
| 118 | @@ -276,6 +276,8 @@ |
| 119 | this.controller = new AbortController(); |
| 120 | this.controls(true, false); |
| 121 | let completed = false; |
| 122 | + // Keep the phone awake for the length of the upload. |
| 123 | + const wakeLock = await navigator.wakeLock?.request?.("screen").catch(() => null); |
| 124 | try { |
| 125 | const {files, folder, encrypt} = this.selection; |
| 126 | let link; |
| 127 | @@ -299,6 +301,7 @@ |
| 128 | ); |
| 129 | throw error; |
| 130 | } finally { |
| 131 | + wakeLock?.release?.().catch?.(() => {}); |
| 132 | this.busy = false; |
| 133 | this.controls(false, completed); |
| 134 | if (completed) this.selection = this.chosen = null; |
| 135 | diff --git a/internal/webui/public.html b/internal/webui/public.html |
| 136 | index d5e6e89..225ae4a 100644 |
| 137 | --- a/internal/webui/public.html |
| 138 | +++ b/internal/webui/public.html |
| 139 | @@ -52,6 +52,7 @@ |
| 140 | {{define "signerControls"}}<p><button id="session-login" type="button">Sign in with connected signer</button></p> |
| 141 | <h2>Phone signer</h2> |
| 142 | <p>Scan the QR code with Amber and approve the connection and sign-in request. This page will open your relay when you finish.</p> |
| 143 | +<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> |
| 144 | <p><button id="nostrconnect" type="button">Generate QR code</button> <a id="nostrconnect-open" hidden>Open in signer</a></p> |
| 145 | <img id="signer-qr" alt="Nostr Connect QR code" hidden width="240" height="240"> |
| 146 | <p id="signer-status" role="status"></p> |
| 147 | @@ -100,7 +101,7 @@ |
| 148 | |
| 149 | {{define "event"}}<section> |
| 150 | <h1>Event</h1> |
| 151 | -{{if .Feed}}{{template "eventList" .}}{{else}}{{template "nostrEvent" .Event}}{{end}} |
| 152 | +{{if .Feed}}{{template "eventList" .}}{{else}}{{template "nostrEvent" .Event}}<share-link></share-link>{{end}} |
| 153 | <p><a href="/feed.xml">Atom feed</a> | <a href="/articles.json">JSON feed</a></p> |
| 154 | </section> |
| 155 | {{end}} |
| 156 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 157 | index 630fd67..ff19462 100644 |
| 158 | --- a/internal/webui/style.css |
| 159 | +++ b/internal/webui/style.css |
| 160 | @@ -37,6 +37,7 @@ img{max-width:100%} |
| 161 | #who{color:var(--muted)}#who b,#who nostr-key{color:var(--ink)} |
| 162 | #who button{border:0;background:none;padding:0;color:var(--accent);font:inherit}#who button:hover{text-decoration:underline} |
| 163 | #rail-tools{margin-top:12px} |
| 164 | +share-link{display:inline-block}share-link:empty{display:none} |
| 165 | #theme{border:0;background:none;padding:0;color:var(--muted);font:12px var(--mono)}#theme:hover{color:var(--ink);text-decoration:underline} |
| 166 | |
| 167 | /* Rail */ |
| 168 | diff --git a/scripts/signer.test.mjs b/scripts/signer.test.mjs |
| 169 | index ed216c2..08b2771 100644 |
| 170 | --- a/scripts/signer.test.mjs |
| 171 | +++ b/scripts/signer.test.mjs |
| 172 | @@ -24,7 +24,7 @@ async function page({signedIn = false, bunkerResult = null} = {}) { |
| 173 | navigator: {clipboard: {writeText: async () => {} }}, |
| 174 | getComputedStyle: () => ({getPropertyValue: () => ""}), |
| 175 | matchMedia: () => ({matches: false, addEventListener() {}}), |
| 176 | - localStorage: {getItem: () => null, setItem() {}}, |
| 177 | + localStorage: {getItem: () => null, setItem() {}, removeItem() {}}, |
| 178 | sessionStorage: {getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value), removeItem: key => saved.delete(key)}, |
| 179 | NostrSigner: { |
| 180 | generateSecretKey: () => new Uint8Array(32), getPublicKey: () => "b".repeat(64), createNostrConnectURI: () => "nostrconnect://test", |