fix(wallet): gate lightning on node STATE, gate send too, add a shared CopyButton

Three defects from testing the previous commit on archi-dev-box:

1. The gate keyed on `id in packages`, which is not "installed and usable" —
   package-data carries an entry for a Lightning app that is known but not
   running. On a box with no lnd container at all the gate passed and the raw
   error came through as "Operation failed. Check server logs for details."
   Now keyed on PackageState.Running.

2. Because installed-but-stopped is a real and different situation, the modal
   has two modes: absent offers the install choices, stopped says the node
   isn't running and offers "Open My Apps". Neither dead-ends in an error.

3. Lightning SEND let you walk all the way to confirm-send with no node. The
   gate now runs in review(), before the confirm step — failing at submit
   after a review screen is the defect, not a smaller version of it.

Also adds CopyButton, the start of one consistent copy affordance: icon +
label, an emerald tick held 1.6s, a fixed box so the width never jumps, and a
document.execCommand fallback so copy still works over plain http on a LAN IP
(navigator.clipboard rejects on insecure origins, which is how a lot of nodes
are reached). Converted the wallet's own copies — the lightning invoice the
user reported, plus the on-chain/Ark addresses and the payment hash/txid.

20 of 25 copy sites across 15 other files still use ad-hoc markup; converting
them is mechanical but was not attempted here rather than half-done.

Verified: 5 gate tests; npm run build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 07:36:25 -04:00
co-authored by Claude Opus 5
parent 90ce4bcc46
commit fa26c5fc56
6 changed files with 240 additions and 60 deletions
@@ -23,24 +23,39 @@ describe('useLightningRequired', () => {
useLightningRequired().close()
})
it('lets the action through when a Lightning node is installed', () => {
packages.value = { lnd: {}, 'bitcoin-knots': {} }
it('lets the action through when a Lightning node is running', () => {
packages.value = { lnd: { state: 'running' }, 'bitcoin-knots': { state: 'running' } }
const lightning = useLightningRequired()
expect(lightning.hasLightningNode()).toBe(true)
expect(lightning.lightningStatus()).toBe('running')
expect(lightning.requireLightningNode()).toBe(true)
expect(lightning.show.value).toBe(false)
})
it('blocks and raises the install modal when no Lightning node is installed', () => {
packages.value = { 'bitcoin-knots': {}, immich: {} }
it('blocks when the node is present but NOT running, and says so', () => {
// The bug this closes: `id in packages` is not "usable". A node with an
// lnd entry in a non-running state produced a raw connection-refused
// error ("Operation failed. Check server logs for details.").
packages.value = { lnd: { state: 'stopped' } }
const lightning = useLightningRequired()
expect(lightning.lightningStatus()).toBe('stopped')
expect(lightning.requireLightningNode()).toBe(false)
expect(lightning.show.value).toBe(true)
expect(lightning.status.value).toBe('stopped')
})
it('blocks and raises the install modal when no Lightning node is installed', () => {
packages.value = { 'bitcoin-knots': { state: 'running' }, immich: { state: 'running' } }
const lightning = useLightningRequired()
expect(lightning.lightningStatus()).toBe('absent')
expect(lightning.hasLightningNode()).toBe(false)
// Returns false so the caller bails WITHOUT surfacing an error string —
// that was the whole defect: a missing prerequisite rendered as a failure.
expect(lightning.requireLightningNode()).toBe(false)
expect(lightning.show.value).toBe(true)
expect(lightning.status.value).toBe('absent')
})
it('shares one modal state across call sites', () => {
@@ -54,8 +69,8 @@ describe('useLightningRequired', () => {
expect(a.show.value).toBe(false)
})
it('treats an empty package list as no Lightning node', () => {
it('treats an empty package list as absent', () => {
packages.value = {}
expect(useLightningRequired().hasLightningNode()).toBe(false)
expect(useLightningRequired().lightningStatus()).toBe('absent')
})
})