From 434fc954ce6ec9ce65e577d75e485d8c873ad683 Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Thu, 3 Sep 2026 21:57:18 +0000 Subject: [PATCH] Keep the zap dialog open while connecting a wallet (#421) Co-authored-by: Coracle-Bot --- e2e/ARCHITECTURE.md | 2 + e2e/USER_STORIES.md | 24 +++- e2e/harness/app/webln.ts | 32 +++++ e2e/harness/index.ts | 9 ++ e2e/specs/rooms.spec.ts | 71 +++++++++- src/app/components/Zap.svelte | 184 ++++++++++++++++++------- src/app/components/ZapButton.svelte | 12 +- src/app/components/ZapInvoice.svelte | 178 ------------------------ src/routes/settings/about/+page.svelte | 4 +- 9 files changed, 267 insertions(+), 249 deletions(-) create mode 100644 e2e/harness/app/webln.ts delete mode 100644 src/app/components/ZapInvoice.svelte diff --git a/e2e/ARCHITECTURE.md b/e2e/ARCHITECTURE.md index b1194531..bcef743c 100644 --- a/e2e/ARCHITECTURE.md +++ b/e2e/ARCHITECTURE.md @@ -260,6 +260,7 @@ starts from. Both take the same options, over and above the scenario's own relay | `context` | merged over the project's context options: a viewport, a colour scheme, a permission | | `env` | `VITE_` values applied over the ones derived from the scenario's relays | | `nip07` | a `window.nostr` backed by that identity's own signer, for an extension login | +| `webln` | a `window.webln` that answers the connection handshake, for connecting a wallet | | `relayInfo` | fields merged over a relay's own NIP-11 document, keyed by relay url | | `hosting` | what the hosting backend already knows about this user | @@ -293,6 +294,7 @@ e2e/ the overrides session.ts NIP-01 session injection nip07.ts a window.nostr backed by a test identity's own signer + webln.ts a window.webln that enables and reports what it supports seed/ scenario.ts the `seed()` builder and relative-time helpers space.ts one space's fixtures: rooms, members, messages, replies, profiles, diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index 596e3088..a30d073c 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -416,6 +416,19 @@ Acceptance: quote of the shared message. - Sending posts the quoted message in the destination, visible to bob there. +### US-115 — Connect a wallet while sending a zap + +As alice, I want to connect a wallet from the zap dialog and go on zapping, so +that reaching for one mid-zap is not a dead end. + +Acceptance: + +- Zapping a message from someone with a lightning address offers "Create + invoice" and a prompt to connect a wallet. +- Connecting one over WebLN reports success and closes only the wallet dialog. +- The zap dialog behind it drops the prompt, offers "Send Zap" instead, and + still holds the amount that was typed before the detour. + ## Direct messages ### US-029 — Start a one-on-one chat @@ -1595,12 +1608,13 @@ Features the e2e suite cannot exercise, and what stops it. **Lightning payments and wallets.** Sending a zap on a message, article, thread post, comment, or note; contributing to a funding goal; connecting a wallet over -WebLN or Nostr Wallet Connect; the wallet page's connection status and balance; +Nostr Wallet Connect; the wallet page's connection status and balance; disconnecting a wallet; paying and receiving invoices. The harness mocks zapper -_discovery_ (Dufflepud's `/zapper/info`) but not the LNURL invoice callback or -the payment leg, and a connected wallet needs a real extension or an NWC -responder on its own relay. Existing zap receipts can be seeded, so a zap total -rendered on a message is testable; the send-and-settle flow is not. +_discovery_ (Dufflepud's `/zapper/info`) and stands up a WebLN provider that +answers the connection handshake, but not the LNURL invoice callback, the +payment leg, or an NWC responder on a relay of its own. Existing zap receipts +can be seeded, so a zap total rendered on a message is testable; the +send-and-settle flow is not. **Voice and video rooms.** Creating or joining a Voice room, the mic-preview and device-picker dialog, mute/camera/screen-share controls, speaking indicators, diff --git a/e2e/harness/app/webln.ts b/e2e/harness/app/webln.ts new file mode 100644 index 00000000..02e58cb0 --- /dev/null +++ b/e2e/harness/app/webln.ts @@ -0,0 +1,32 @@ +import type {BrowserContext} from "@playwright/test" + +// What `getInfo` answers with. `supports` is what src/app/components/WalletConnect.svelte gates the +// connection on, and the node's alias is what the wallet page names the connection by. +export type WebLnInfo = { + supports?: string[] + node?: {alias?: string; pubkey?: string} + version?: string +} + +/** + * A WebLN provider on `window`, in the shape a browser extension installs. Connecting is a + * capability handshake and nothing more, so the whole provider answers from a literal in the page — + * paying an invoice and issuing one are past the boundary this harness stops at, and calling either + * here throws rather than pretending. + * + * Install it before the page navigates. WalletConnect reads `window.webln` while it renders, to + * decide whether to offer the button at all. + */ +export const injectWebLn = (context: BrowserContext, info: WebLnInfo = {}) => + context.addInitScript( + $info => { + Object.assign(window, { + webln: { + enable: () => Promise.resolve(), + getInfo: () => Promise.resolve($info), + getBalance: () => Promise.resolve({balance: 0}), + }, + }) + }, + {supports: ["lightning"], ...info}, + ) diff --git a/e2e/harness/index.ts b/e2e/harness/index.ts index eaf53539..852b8c34 100644 --- a/e2e/harness/index.ts +++ b/e2e/harness/index.ts @@ -17,9 +17,11 @@ import type {BlossomOptions, HostingFixtures, RelayInfoOverrides} from "./net/ht import {assertNoLeaks, installWebSocketRoutes} from "./net/websocket" import {boot} from "./app/boot" import {injectNip07} from "./app/nip07" +import {injectWebLn} from "./app/webln" import {seed} from "./seed/scenario" import type {Scenario, SeedTools} from "./seed/scenario" import type {TestUser} from "./keys" +import type {WebLnInfo} from "./app/webln" export {expect} export {makeTestUser, users} from "./keys" @@ -38,6 +40,7 @@ export { mockLivekit, } from "./net/http" export type {DufflepudFixtures, HostingFixtures, HostingHandle, HostingRecord} from "./net/http" +export type {WebLnInfo} from "./app/webln" // Mirrors encodeRelay in src/app/relays.ts. Importing it reaches the app's module graph, and with // it sveltekit. @@ -62,6 +65,8 @@ export type PageOptions = { env?: Record // A NIP-07 provider signing as this user, for a login that goes through an extension. nip07?: TestUser + // A WebLN provider on window, for a wallet that gets connected through an extension. + webln?: WebLnInfo // A blossom server, installed before the page boots. mockBlossom called on the page `as()` // returns arrives after src/app/sync.ts has probed and cached a space's own url, so a spec whose // server is one the app probes on load has to name it here instead. @@ -181,6 +186,10 @@ export const test = base.extend({ await injectNip07(context, options.nip07) } + if (options.webln) { + await injectWebLn(context, options.webln) + } + // Headless Chromium reports `Notification.permission` as "denied" even where playwright has // granted the permission at the browser level, so the grant is reflected into the API the app // reads. diff --git a/e2e/specs/rooms.spec.ts b/e2e/specs/rooms.spec.ts index 73bfa632..05a3ebfa 100644 --- a/e2e/specs/rooms.spec.ts +++ b/e2e/specs/rooms.spec.ts @@ -1,7 +1,8 @@ -import {DAY, HOUR, WEEK} from "@welshman/lib" -import {MessagingRelayList, RelayList, displayPubkey} from "@welshman/domain" +import {DAY, HOUR, WEEK, bech32ToHex} from "@welshman/lib" +import {getLnUrl} from "@welshman/util" +import {MessagingRelayList, Profile, RelayList, displayPubkey} from "@welshman/domain" import type {Locator, Page} from "@playwright/test" -import {expect, makeTestUser, roomPath, spacePath, test, users} from "../harness" +import {expect, makeTestUser, mockDufflepud, roomPath, spacePath, test, users} from "../harness" import type {SeededSpace, TestUser} from "../harness" // A handle to a seeded event. SeededEvent isn't exported from the harness, and only its id and @@ -751,3 +752,67 @@ test("US-028 share a message somewhere else", async ({seed, as}) => { await expect(message(alice, "heads up")).toContainText("the dock is closed on sunday") await expect(message(bob, "heads up")).toContainText("the dock is closed on sunday") }) + +test("US-115 connect a wallet without losing the zap you were composing", async ({seed, as}) => { + // The lightning address on bob's profile, and the lnurl endpoint it resolves to. Zapping only gets + // as far as a dialog once dufflepud answers with a zapper for that endpoint. + const lud16 = "bob@zap.test" + // A zapper's receipts are signed by the recipient's lightning provider, so it is an identity of + // its own even where, as here, nothing is ever paid. + const provider = makeTestUser("zapper") + + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + space.profile(user.alice, {name: "Alice Anchor"}) + space.event(user.bob, () => + space.kind(Profile).writer().update({name: "Bob Barnacle", lud16}).renderTemplate(), + ) + space.message(user.bob, "general", "the new sail came in") + }) + + const {url} = scenario.space("space") + const path = roomPath(url, "general") + const page = await as(users.alice, path, {webln: {node: {alias: "Test Node"}}}) + + // Registered after the page was opened, so it answers ahead of the empty dufflepud `as()` + // installs, and before the navigation below, since a zapper is looked up once per page load. + await mockDufflepud(page.context(), { + zappers: [ + { + lnurl: bech32ToHex(getLnUrl(lud16)!), + info: {pubkey: users.bob.pubkey, nostrPubkey: provider.pubkey, allowsNostr: true}, + }, + ], + }) + + await page.goto(path) + + await expect(message(page, "the new sail came in")).toBeVisible() + + await messageActions(page, "the new sail came in").first().click() + + const zap = dialog(page, "Send a Zap") + const amount = zap.locator('input[type="number"]') + + await expect(zap.getByRole("button", {name: "Create invoice"})).toBeVisible() + + await amount.fill("210") + await zap.getByRole("button", {name: "Connect a lightning wallet"}).click() + + const connect = dialog(page, "Connect a Wallet") + + await connect.getByRole("button", {name: "Connect with WebLN"}).click() + + await expect(page.getByRole("alert")).toContainText("Wallet successfully connected!") + await expect(connect).toHaveCount(0) + + // The zap dialog was underneath rather than replaced, so it answers to the wallet she now has + // instead of still asking for one, and the amount she had typed survived the detour. + await expect(zap.getByRole("button", {name: "Connect a lightning wallet"})).toHaveCount(0) + await expect(zap.getByRole("button", {name: "Send Zap"})).toBeVisible() + await expect(amount).toHaveValue("210") +}) diff --git a/src/app/components/Zap.svelte b/src/app/components/Zap.svelte index da1757ee..4c960f44 100644 --- a/src/app/components/Zap.svelte +++ b/src/app/components/Zap.svelte @@ -1,9 +1,11 @@ @@ -99,22 +150,55 @@ Send a Zap To - + + {#if invoice} +
+
+ +

+ Scan with your lightning wallet, or copy the invoice below. +

+
+ +
+ {:else} + + {#if !$wallet} +
+

+ Connect a wallet to pay instantly without scanning a QR code. +

+ +
+ {/if} +
+ {/if} - + {#if !invoice} + + {/if}
diff --git a/src/app/components/ZapButton.svelte b/src/app/components/ZapButton.svelte index 39b9e9ce..15bfb09b 100644 --- a/src/app/components/ZapButton.svelte +++ b/src/app/components/ZapButton.svelte @@ -7,11 +7,9 @@ import {Zappers} from "@welshman/app" import Button from "@lib/components/Button.svelte" import Zap from "@app/components/Zap.svelte" - import ZapInvoice from "@app/components/ZapInvoice.svelte" import InfoZapperError from "@app/components/InfoZapperError.svelte" import {pushModal} from "@app/modal" import {app, reader} from "@app/core" - import {wallet} from "@app/lightning" type Props = { url?: string @@ -37,16 +35,10 @@ try { const zapper = await zapperPromise - if (!zapper?.allowsNostr) { - pushModal(InfoZapperError, {url, pubkey: event.pubkey, eventId: event.id}, {replaceState}) - } else if ($wallet) { + if (zapper?.allowsNostr) { pushModal(Zap, {url, pubkey: event.pubkey, eventId: event.id, goalRelays}, {replaceState}) } else { - pushModal( - ZapInvoice, - {url, pubkey: event.pubkey, eventId: event.id, goalRelays}, - {replaceState}, - ) + pushModal(InfoZapperError, {url, pubkey: event.pubkey, eventId: event.id}, {replaceState}) } } finally { loading = false diff --git a/src/app/components/ZapInvoice.svelte b/src/app/components/ZapInvoice.svelte deleted file mode 100644 index 18404ce1..00000000 --- a/src/app/components/ZapInvoice.svelte +++ /dev/null @@ -1,178 +0,0 @@ - - - - - - Send a Zap - To - - - {#if invoice} -
-
- -

- Scan with your lightning wallet, or copy the invoice below. -

-
- -
- {:else} - -
-

- Connect a wallet to pay instantly without scanning a QR code. -

- -
-
- {/if} -
- - - {#if !invoice} - - {/if} - -
diff --git a/src/routes/settings/about/+page.svelte b/src/routes/settings/about/+page.svelte index 5325d85f..7534eae8 100644 --- a/src/routes/settings/about/+page.svelte +++ b/src/routes/settings/about/+page.svelte @@ -9,10 +9,8 @@ import Pen from "@assets/icons/pen.svg?dataurl" import HeadphonesRound from "@assets/icons/headphones-round.svg?dataurl" import Zap from "@app/components/Zap.svelte" - import ZapInvoice from "@app/components/ZapInvoice.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte" import {PLATFORM_NAME, PLATFORM_ABOUT} from "@app/env" - import {wallet} from "@app/lightning" import {pushModal} from "@app/modal" import {makeSpacePath} from "@app/routes" @@ -22,7 +20,7 @@ const openProfile = () => pushModal(ProfileDetail, {pubkey}) - const zap = () => pushModal($wallet ? Zap : ZapInvoice, {pubkey}) + const zap = () => pushModal(Zap, {pubkey})