feat(wallet): offer to install a Lightning node instead of failing an invoice
Demo images / Build & push demo images (push) Successful in 3m16s
Demo images / Build & push demo images (push) Successful in 3m16s
Creating a Lightning invoice with no Lightning implementation installed failed
at the RPC layer — lnd.createinvoice returned connection-refused and the
Receive screen rendered it as a red error. That reads as the wallet being
broken when the node simply has no Lightning node installed yet.
useLightningRequired() gates the three invoice paths (wallet Receive, the Web5
send/receive sheet, and the app launcher's paywall — both arms there, since
paying an invoice needs a node as much as minting one). With none installed it
raises a modal offering to install one and the caller bails without surfacing
an error at all.
The modal lists the choice rather than assuming LND: LND installs today, Core
Lightning is listed greyed as "Coming soon" so the platform doesn't read as
LND-only. When CLN ships it is two lines — flip `available` and add the id to
LIGHTNING_NODE_APP_IDS.
Detection is install state, NOT reachability, deliberately: an installed node
that is merely stopped or still starting is a different problem ("start it")
and must not be answered with "install a Lightning node".
Also fixes the credentials modal, which painted its own rgba(8,10,18,.98)
navy card instead of the house glass-card — it read as blue against every
other modal. It existed twice (Apps.vue and apps/AppIconGrid.vue); both now
use BaseModal, so they also inherit Esc/focus handling, body scroll lock and
the standard pinned-header/footer scroll contract they were missing. Dead
panel CSS removed from both.
Verified: 4 new tests; full suite 103 files / 826 tests green; npm run build
clean with the new strings present in the built bundle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
06e0e6954e
commit
5718179e2f
@@ -44,6 +44,11 @@
|
|||||||
<!-- Nudge to back up the Lightning seed once a wallet exists (any page) -->
|
<!-- Nudge to back up the Lightning seed once a wallet exists (any page) -->
|
||||||
<LndSeedBackupPrompt />
|
<LndSeedBackupPrompt />
|
||||||
|
|
||||||
|
<!-- "You need a Lightning node" install prompt. Global because it is
|
||||||
|
raised from inside other modals (wallet Receive, the Web5 sheet, the
|
||||||
|
app launcher's paywall) — one instance, shared state. -->
|
||||||
|
<LightningRequiredModal />
|
||||||
|
|
||||||
<!-- Global persistent audio player (bottom bar) -->
|
<!-- Global persistent audio player (bottom bar) -->
|
||||||
<GlobalAudioPlayer />
|
<GlobalAudioPlayer />
|
||||||
|
|
||||||
@@ -98,6 +103,7 @@ import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue'
|
|||||||
import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue'
|
import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue'
|
||||||
import ExternalExplorerModal from './components/ExternalExplorerModal.vue'
|
import ExternalExplorerModal from './components/ExternalExplorerModal.vue'
|
||||||
import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue'
|
import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue'
|
||||||
|
import LightningRequiredModal from './components/LightningRequiredModal.vue'
|
||||||
import { useMeshStore } from './stores/mesh'
|
import { useMeshStore } from './stores/mesh'
|
||||||
|
|
||||||
import { useControllerNav } from '@/composables/useControllerNav'
|
import { useControllerNav } from '@/composables/useControllerNav'
|
||||||
|
|||||||
@@ -196,6 +196,9 @@ import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
|||||||
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
|
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
|
||||||
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||||
|
|
||||||
|
const lightning = useLightningRequired()
|
||||||
|
|
||||||
interface PaymentRequest {
|
interface PaymentRequest {
|
||||||
request_id: string
|
request_id: string
|
||||||
@@ -524,6 +527,9 @@ async function approvePayment() {
|
|||||||
})
|
})
|
||||||
receipt = { method: 'ecash', token: res.token, amount_sats: res.amount_sats }
|
receipt = { method: 'ecash', token: res.token, amount_sats: res.amount_sats }
|
||||||
} else if (method === 'lightning') {
|
} else if (method === 'lightning') {
|
||||||
|
// Both arms below need a Lightning node — paying an invoice and minting
|
||||||
|
// one. With none installed, raise the install modal instead of failing.
|
||||||
|
if (!lightning.requireLightningNode()) return
|
||||||
if (pay.invoice) {
|
if (pay.invoice) {
|
||||||
// Tracked to a real terminal state — slow routing is not a failure.
|
// Tracked to a real terminal state — slow routing is not a failure.
|
||||||
const res = await rpcClient.payLightningInvoice({ payment_request: pay.invoice })
|
const res = await rpcClient.payLightningInvoice({ payment_request: pay.invoice })
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<!-- z-3600: this is raised from INSIDE another modal (Receive, the Web5
|
||||||
|
send/receive sheet, the app launcher's paywall), so it must sit above
|
||||||
|
the standard modal layer (3000) but below the app overlay (4000) —
|
||||||
|
same reasoning as ExternalExplorerModal. -->
|
||||||
|
<BaseModal
|
||||||
|
:show="lightning.show.value"
|
||||||
|
title="Lightning node required"
|
||||||
|
max-width="max-w-md"
|
||||||
|
z-index="z-[3600]"
|
||||||
|
@close="onClose"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-white/70 leading-relaxed">
|
||||||
|
Creating a Lightning invoice needs a Lightning node running on this
|
||||||
|
Archipelago node. You don't have one installed yet — pick an
|
||||||
|
implementation below and it'll be installed for you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="node in nodes"
|
||||||
|
:key="node.id"
|
||||||
|
class="rounded-xl border border-white/10 bg-white/[0.04] p-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-medium text-white">{{ node.name }}</span>
|
||||||
|
<span
|
||||||
|
v-if="!node.available"
|
||||||
|
class="text-[10px] uppercase tracking-wide px-2 py-0.5 rounded-full bg-white/10 text-white/50"
|
||||||
|
>Coming soon</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-white/50 mt-0.5 leading-relaxed">{{ node.blurb }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="node.available"
|
||||||
|
:disabled="installing !== null"
|
||||||
|
class="shrink-0 glass-button glass-button-warning px-3.5 py-1.5 rounded-lg text-xs font-medium disabled:opacity-50"
|
||||||
|
@click="install(node.id)"
|
||||||
|
>
|
||||||
|
{{ installing === node.id ? 'Installing…' : 'Install' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
disabled
|
||||||
|
class="shrink-0 glass-button px-3.5 py-1.5 rounded-lg text-xs opacity-40 cursor-not-allowed"
|
||||||
|
>Install</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="mt-3 alert-error text-sm">{{ error }}</p>
|
||||||
|
|
||||||
|
<p v-if="installing" class="mt-3 text-xs text-white/50 leading-relaxed">
|
||||||
|
This takes a few minutes — the image has to be pulled and the node
|
||||||
|
started. You can close this and carry on; the install keeps running.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-6">
|
||||||
|
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="onClose">
|
||||||
|
{{ installing ? 'Close' : 'Not now' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</BaseModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import BaseModal from '@/components/BaseModal.vue'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||||
|
|
||||||
|
interface NodeChoice {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
blurb: string
|
||||||
|
/** False renders the row as "Coming soon" with a dead Install button. */
|
||||||
|
available: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core Lightning is listed deliberately while unavailable: the choice is the
|
||||||
|
// point of this modal, and showing it greyed tells the user the platform is
|
||||||
|
// not LND-only. When its app id lands in the catalog, flip `available` here
|
||||||
|
// and add the id to LIGHTNING_NODE_APP_IDS — nothing else changes.
|
||||||
|
const nodes: NodeChoice[] = [
|
||||||
|
{
|
||||||
|
id: 'lnd',
|
||||||
|
name: 'LND',
|
||||||
|
blurb: 'Lightning Network Daemon. The implementation Archipelago ships today — wallet, channels and payments are wired to it.',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'core-lightning',
|
||||||
|
name: 'Core Lightning',
|
||||||
|
blurb: 'Blockstream\'s implementation. Not packaged yet — it will appear here as a choice once it ships.',
|
||||||
|
available: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const lightning = useLightningRequired()
|
||||||
|
|
||||||
|
/** App id currently installing, or null. */
|
||||||
|
const installing = ref<string | null>(null)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function install(id: string) {
|
||||||
|
installing.value = id
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await appStore.installPackage(id, '', 'latest')
|
||||||
|
// The gate reads install state from the package list, so once the install
|
||||||
|
// lands the modal simply stops being raised. Close on success rather than
|
||||||
|
// holding the user here watching a spinner.
|
||||||
|
lightning.close()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = `Install failed: ${err instanceof Error ? err.message : 'Unknown error'}`
|
||||||
|
} finally {
|
||||||
|
installing.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
error.value = ''
|
||||||
|
lightning.close()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -91,8 +91,10 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import BaseModal from '@/components/BaseModal.vue'
|
import BaseModal from '@/components/BaseModal.vue'
|
||||||
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||||
|
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const lightning = useLightningRequired()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
@@ -154,6 +156,10 @@ async function receive() {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
if (receiveMethod.value === 'lightning') {
|
if (receiveMethod.value === 'lightning') {
|
||||||
|
// No Lightning implementation installed is not an error — it is a
|
||||||
|
// missing prerequisite. Raise the install modal instead of letting
|
||||||
|
// lnd.createinvoice fail with connection-refused (FED-08 follow-up).
|
||||||
|
if (!lightning.requireLightningNode()) return
|
||||||
if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return }
|
if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return }
|
||||||
const res = await rpcClient.call<{ payment_request: string }>({
|
const res = await rpcClient.call<{ payment_request: string }>({
|
||||||
method: 'lnd.createinvoice',
|
method: 'lnd.createinvoice',
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { useLightningRequired } from '../useLightningRequired'
|
||||||
|
|
||||||
|
// The gate reads install state off the app store's package list. Stub the
|
||||||
|
// store rather than the RPC layer so the test pins the decision, not the
|
||||||
|
// transport.
|
||||||
|
const packages = vi.hoisted(() => ({ value: {} as Record<string, unknown> }))
|
||||||
|
vi.mock('@/stores/app', () => ({
|
||||||
|
useAppStore: () => ({
|
||||||
|
get packages() {
|
||||||
|
return packages.value
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useLightningRequired', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
packages.value = {}
|
||||||
|
// Module-scope `show` is shared by design (one global modal), so reset it
|
||||||
|
// between cases or the first opener leaks into the next test.
|
||||||
|
useLightningRequired().close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets the action through when a Lightning node is installed', () => {
|
||||||
|
packages.value = { lnd: {}, 'bitcoin-knots': {} }
|
||||||
|
const lightning = useLightningRequired()
|
||||||
|
|
||||||
|
expect(lightning.hasLightningNode()).toBe(true)
|
||||||
|
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: {} }
|
||||||
|
const lightning = useLightningRequired()
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shares one modal state across call sites', () => {
|
||||||
|
packages.value = {}
|
||||||
|
const a = useLightningRequired()
|
||||||
|
const b = useLightningRequired()
|
||||||
|
|
||||||
|
a.requireLightningNode()
|
||||||
|
expect(b.show.value).toBe(true)
|
||||||
|
b.close()
|
||||||
|
expect(a.show.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats an empty package list as no Lightning node', () => {
|
||||||
|
packages.value = {}
|
||||||
|
expect(useLightningRequired().hasLightningNode()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Shared "this action needs a Lightning node" gate (2026-08-02).
|
||||||
|
//
|
||||||
|
// Creating a Lightning invoice with no Lightning node installed used to fail
|
||||||
|
// at the RPC layer — `lnd.createinvoice` returns a connection-refused error
|
||||||
|
// and the Receive screen showed it as a red failure string. That reads as the
|
||||||
|
// wallet being broken, when in fact the node simply has no Lightning
|
||||||
|
// implementation installed yet.
|
||||||
|
//
|
||||||
|
// Callers ask `requireLightningNode()` BEFORE attempting the call. When no
|
||||||
|
// node is installed it opens the global LightningRequiredModal (which offers
|
||||||
|
// to install one) and returns false, so the caller bails without surfacing an
|
||||||
|
// error at all.
|
||||||
|
//
|
||||||
|
// Detection is install-state, not reachability, on purpose: an installed node
|
||||||
|
// that is merely stopped or still starting is a different situation (wait or
|
||||||
|
// start it) and must NOT be answered with "install a Lightning node".
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
|
/** Package ids that provide a Lightning node.
|
||||||
|
*
|
||||||
|
* `lnd` ships today. Core Lightning is the next implementation the modal
|
||||||
|
* offers — when its app id lands in the catalog, add it here and flip its
|
||||||
|
* `available` flag in LightningRequiredModal so the same gate recognises it
|
||||||
|
* with no other change. */
|
||||||
|
export const LIGHTNING_NODE_APP_IDS = ['lnd'] as const
|
||||||
|
|
||||||
|
// Module-scope: one source of truth shared by every caller and the single
|
||||||
|
// global modal mounted in App.vue.
|
||||||
|
const show = ref(false)
|
||||||
|
|
||||||
|
export function useLightningRequired() {
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
/** True when some Lightning implementation is installed on this node. */
|
||||||
|
function hasLightningNode(): boolean {
|
||||||
|
const installed = Object.keys(appStore.packages ?? {})
|
||||||
|
return installed.some((pkgId) =>
|
||||||
|
(LIGHTNING_NODE_APP_IDS as readonly string[]).includes(pkgId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate a Lightning-only action. Returns true to proceed; returns false and
|
||||||
|
* opens the install modal when this node has no Lightning implementation.
|
||||||
|
*/
|
||||||
|
function requireLightningNode(): boolean {
|
||||||
|
if (hasLightningNode()) return true
|
||||||
|
show.value = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return { show, hasLightningNode, requireLightningNode, close }
|
||||||
|
}
|
||||||
+28
-63
@@ -262,42 +262,36 @@
|
|||||||
@confirm="onConfirmUninstall"
|
@confirm="onConfirmUninstall"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Teleport to="body">
|
<!-- House modal (BaseModal glass-card), not a hand-rolled panel: the old
|
||||||
<Transition name="fade">
|
one painted its own rgba(8,10,18,.98) navy card, which read as blue
|
||||||
<div
|
against every other modal in the app. BaseModal also brings the
|
||||||
v-if="credentialModal.show"
|
standard scroll contract, Esc/focus handling and body scroll lock. -->
|
||||||
class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/80 backdrop-blur-md p-4"
|
<BaseModal
|
||||||
@click.self="closeCredentialModal"
|
:show="credentialModal.show"
|
||||||
>
|
:title="credentialModal.title"
|
||||||
<div class="credential-modal-panel">
|
max-width="max-w-lg"
|
||||||
<div class="flex items-start justify-between gap-4 mb-5">
|
z-index="z-[2700]"
|
||||||
<div>
|
@close="closeCredentialModal"
|
||||||
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
|
>
|
||||||
<p class="text-sm text-white/55 mt-1">{{ credentialModal.description }}</p>
|
<p v-if="credentialModal.description" class="text-sm text-white/55 -mt-1 mb-4">
|
||||||
</div>
|
{{ credentialModal.description }}
|
||||||
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeCredentialModal">
|
</p>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div class="space-y-3">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||||
</svg>
|
<div class="flex items-center justify-between gap-3 mb-1">
|
||||||
</button>
|
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||||
</div>
|
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||||
<div class="credential-modal-body space-y-3">
|
|
||||||
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
|
||||||
<div class="flex items-center justify-between gap-3 mb-1">
|
|
||||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
|
||||||
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
|
||||||
</div>
|
|
||||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="credential-modal-actions mt-5 flex flex-col sm:flex-row gap-3">
|
|
||||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
|
|
||||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
<template #footer>
|
||||||
</Teleport>
|
<div class="flex flex-col sm:flex-row gap-3">
|
||||||
|
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
|
||||||
|
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BaseModal>
|
||||||
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
@@ -388,6 +382,7 @@ import { useServerStore } from '@/stores/server'
|
|||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { type AppCredential, type AppCredentialsResponse, type PackageDataEntry, type PackageState } from '@/types/api'
|
import { type AppCredential, type AppCredentialsResponse, type PackageDataEntry, type PackageState } from '@/types/api'
|
||||||
|
import BaseModal from '@/components/BaseModal.vue'
|
||||||
import AppCard from './apps/AppCard.vue'
|
import AppCard from './apps/AppCard.vue'
|
||||||
import AppIconGrid from './apps/AppIconGrid.vue'
|
import AppIconGrid from './apps/AppIconGrid.vue'
|
||||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||||
@@ -910,34 +905,4 @@ async function submitSideload() {
|
|||||||
}
|
}
|
||||||
.sideload-input::placeholder { color: rgba(255, 255, 255, 0.38); }
|
.sideload-input::placeholder { color: rgba(255, 255, 255, 0.38); }
|
||||||
.sideload-input:focus { border-color: rgba(255, 255, 255, 0.38); }
|
.sideload-input:focus { border-color: rgba(255, 255, 255, 0.38); }
|
||||||
.credential-modal-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 34rem;
|
|
||||||
/* Centered card that never exceeds the visible viewport (minus safe areas),
|
|
||||||
matching the wallet receive modal / AppIconGrid credential modal. The body
|
|
||||||
scrolls if content overflows rather than the panel stretching edge-to-edge. */
|
|
||||||
max-height: calc(
|
|
||||||
100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) -
|
|
||||||
var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem
|
|
||||||
);
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
||||||
border-radius: 1.5rem;
|
|
||||||
background: rgba(8, 10, 18, 0.98);
|
|
||||||
padding: 1.25rem;
|
|
||||||
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
|
||||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
|
||||||
}
|
|
||||||
.credential-modal-body {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
.credential-modal-actions {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -91,43 +91,41 @@
|
|||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Teleport to="body">
|
<!-- House modal (BaseModal glass-card), not a hand-rolled panel: the old
|
||||||
<Transition name="fade">
|
one painted its own rgba(8,10,18,.98) navy card, which read as blue
|
||||||
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/80 backdrop-blur-md p-4" @click.self="closeCredentialModal">
|
against every other modal in the app. -->
|
||||||
<div class="credential-modal-panel">
|
<BaseModal
|
||||||
<div class="flex items-start justify-between gap-4 mb-5">
|
:show="credentialModal.show"
|
||||||
<div>
|
:title="credentialModal.title"
|
||||||
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
|
max-width="max-w-lg"
|
||||||
<p class="text-sm text-white/55 mt-1">{{ credentialModal.description }}</p>
|
z-index="z-[2700]"
|
||||||
</div>
|
@close="closeCredentialModal"
|
||||||
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeCredentialModal">
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<p v-if="credentialModal.description" class="text-sm text-white/55 -mt-1 mb-4">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
{{ credentialModal.description }}
|
||||||
</svg>
|
</p>
|
||||||
</button>
|
<div class="space-y-3">
|
||||||
</div>
|
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||||
<div class="credential-modal-body space-y-3">
|
<div class="flex items-center justify-between gap-3 mb-1">
|
||||||
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||||
<div class="flex items-center justify-between gap-3 mb-1">
|
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
|
||||||
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
|
||||||
</div>
|
|
||||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="credential-modal-actions mt-5 flex flex-col sm:flex-row gap-3">
|
|
||||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
|
|
||||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
<template #footer>
|
||||||
</Teleport>
|
<div class="flex flex-col sm:flex-row gap-3">
|
||||||
|
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
|
||||||
|
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BaseModal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import BaseModal from '@/components/BaseModal.vue'
|
||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api'
|
import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api'
|
||||||
@@ -367,33 +365,4 @@ function scrollToPage(index: number) {
|
|||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
.credential-modal-body {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
.credential-modal-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 34rem;
|
|
||||||
/* Centered card that never exceeds the visible viewport (minus safe areas),
|
|
||||||
matching the wallet receive modal. The body scrolls if content overflows
|
|
||||||
rather than the panel stretching edge-to-edge. */
|
|
||||||
max-height: calc(
|
|
||||||
100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) -
|
|
||||||
var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem
|
|
||||||
);
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
||||||
border-radius: 1.5rem;
|
|
||||||
background: rgba(8, 10, 18, 0.98);
|
|
||||||
padding: 1.25rem;
|
|
||||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
|
||||||
}
|
|
||||||
.credential-modal-actions {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -180,11 +180,14 @@
|
|||||||
import { ref, computed, nextTick } from 'vue'
|
import { ref, computed, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||||
import { useTransportStore } from '@/stores/transport'
|
import { useTransportStore } from '@/stores/transport'
|
||||||
import { useMeshStore } from '@/stores/mesh'
|
import { useMeshStore } from '@/stores/mesh'
|
||||||
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||||
import { safeClipboardWrite } from './utils'
|
import { safeClipboardWrite } from './utils'
|
||||||
|
|
||||||
|
const lightning = useLightningRequired()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const transportStore = useTransportStore()
|
const transportStore = useTransportStore()
|
||||||
const meshStore = useMeshStore()
|
const meshStore = useMeshStore()
|
||||||
@@ -466,6 +469,9 @@ async function unifiedReceive() {
|
|||||||
unifiedReceiveError.value = ''
|
unifiedReceiveError.value = ''
|
||||||
try {
|
try {
|
||||||
if (receiveMethod.value === 'lightning') {
|
if (receiveMethod.value === 'lightning') {
|
||||||
|
// Missing prerequisite, not an error — raise the install modal rather
|
||||||
|
// than letting lnd.createinvoice fail with connection-refused.
|
||||||
|
if (!lightning.requireLightningNode()) return
|
||||||
if (!receiveInvoiceAmount.value || receiveInvoiceAmount.value < 1) {
|
if (!receiveInvoiceAmount.value || receiveInvoiceAmount.value < 1) {
|
||||||
unifiedReceiveError.value = t('web5.enterAmount')
|
unifiedReceiveError.value = t('web5.enterAmount')
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user