Optimize bunker connections on mobile

This commit is contained in:
dtonon 2026-09-19 17:49:45 +02:00
parent 0f487eb33a
commit d4e644a964
14 changed files with 343 additions and 124 deletions

View file

@ -38,8 +38,15 @@
(typeof location !== "undefined" ? location.host : ""),
);
// Client-initiated NIP-46 flow, alive only while the bunker view is shown
let connect = $state<ReturnType<typeof loginWithNostrConnect> | null>(null);
// Client-initiated NIP-46 flow, alive only while the bunker view is shown.
// `finalizing` flips when the signer first answers, so the QR gives way to
// a progress state while the session completes.
// $state.raw so identity checks against the raw object (connect === nc)
// hold; the object is replaced wholesale, never mutated.
let connect = $state.raw<ReturnType<typeof loginWithNostrConnect> | null>(
null,
);
let finalizing = $state(false);
const qrSvg = $derived(
connect ? renderSVG(connect.uri, { pixelSize: 4, border: 2 }) : "",
);
@ -57,6 +64,7 @@
function stopConnect() {
connect?.cancel();
connect = null;
finalizing = false;
}
function reset() {
@ -116,7 +124,9 @@
function startConnect() {
stopConnect();
const nc = loginWithNostrConnect();
const nc = loginWithNostrConnect(() => {
if (connect === nc) finalizing = true;
});
connect = nc;
nc.done
.then(() => {
@ -127,6 +137,7 @@
.catch((e) => {
if (connect !== nc) return; // cancelled by leaving the view
connect = null;
finalizing = false;
error = `${e instanceof Error ? e.message : "Connection failed"}. Go back and retry.`;
});
}
@ -325,71 +336,89 @@
Sign up
</button>
{:else if view === "bunker"}
{#if connect}
<a
href={connect.uri}
aria-label="Open in your signer app"
class="mx-auto block w-48 rounded bg-white p-1 [&>svg]:h-auto [&>svg]:w-full"
>
{@html qrSvg}
</a>
<button
type="button"
onclick={copyUri}
aria-label={copied ? "Copied" : "Copy the connection string"}
class="mx-auto mt-3 flex w-full max-w-64 items-center gap-2 rounded-full bg-neutral-100 px-3 py-1.5 text-xs text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<span class="min-w-0 flex-1 truncate font-mono">{connect.uri}</span>
<span class="shrink-0">{copied ? "Copied" : "Copy"}</span>
</button>
<p
class="mt-2 text-center text-xs text-neutral-500 dark:text-neutral-400"
{#if finalizing}
<div
class="flex flex-col items-center gap-3 py-10"
role="status"
aria-live="polite"
>
Scan or paste this in your signer app, then approve the connection.
<span
class="inline-block h-5 w-5 animate-spin rounded-full border-2 border-neutral-300 border-t-neutral-600 dark:border-neutral-600 dark:border-t-neutral-300"
></span>
<p class="text-sm text-neutral-600 dark:text-neutral-400">
Connected to your signer — finishing login…
</p>
</div>
{:else}
{#if connect}
<a
href={connect.uri}
aria-label="Open in your signer app"
class="mx-auto block w-48 rounded bg-white p-1 [&>svg]:h-auto [&>svg]:w-full"
>
{@html qrSvg}
</a>
<button
type="button"
onclick={copyUri}
aria-label={copied ? "Copied" : "Copy the connection string"}
class="mx-auto mt-3 flex w-full max-w-64 items-center gap-2 rounded-full bg-neutral-100 px-3 py-1.5 text-xs text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<span class="min-w-0 flex-1 truncate font-mono"
>{connect.uri}</span
>
<span class="shrink-0">{copied ? "Copied" : "Copy"}</span>
</button>
<p
class="mt-2 text-center text-xs text-neutral-500 dark:text-neutral-400"
aria-live="polite"
>
Scan or paste this in your signer app, then approve the
connection.
</p>
{/if}
<div class="my-4 flex items-center gap-3" aria-hidden="true">
<div
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
></div>
<span class="text-xs text-neutral-400">or</span>
<div
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
></div>
</div>
<label
for="bunker-input"
class="mb-1 block text-sm text-neutral-700 dark:text-neutral-300"
>
Paste a bunker URL
</label>
<input
id="bunker-input"
bind:this={bunkerInput}
type="text"
placeholder="bunker://…"
bind:value={bunkerUrl}
disabled={busy}
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && handleBunker()}
class="focus:ring-accent w-full rounded border border-neutral-200 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
/>
<button
onclick={handleBunker}
disabled={busy || !bunkerUrl.trim()}
class="bg-accent hover:bg-accent-hover mt-3 w-full rounded px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? "Connecting…" : "Log in"}
</button>
<p class="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
A NIP-05 address that points to a bunker works too.
</p>
{/if}
<div class="my-4 flex items-center gap-3" aria-hidden="true">
<div
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
></div>
<span class="text-xs text-neutral-400">or</span>
<div
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
></div>
</div>
<label
for="bunker-input"
class="mb-1 block text-sm text-neutral-700 dark:text-neutral-300"
>
Paste a bunker URL
</label>
<input
id="bunker-input"
bind:this={bunkerInput}
type="text"
placeholder="bunker://…"
bind:value={bunkerUrl}
disabled={busy}
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && handleBunker()}
class="focus:ring-accent w-full rounded border border-neutral-200 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
/>
<button
onclick={handleBunker}
disabled={busy || !bunkerUrl.trim()}
class="bg-accent hover:bg-accent-hover mt-3 w-full rounded px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? "Connecting…" : "Log in"}
</button>
<p class="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
A NIP-05 address that points to a bunker works too.
</p>
<button
type="button"
onclick={showExtensionView}