fix(wallet): channel modals above settings modal, tx rail filters, LND seed-backup prompt

- Open/Close Channel modals sat at z-[60], BEHIND the wallet settings
  BaseModal (z-3000) — clicking 'Open Channel' looked like nothing
  happened while the form changed in the background. Raised to z-3100.
- TransactionsModal gains rail filter chips (All / On-chain / Lightning
  / Ecash with counts): instant ecash micro-payments were burying the
  standard transactions.
- Global LND seed-backup banner: once the wallet exists and the seed is
  un-acknowledged, a dismissible glass banner (24h snooze) prompts and
  routes to the LND backup card; polls only when authenticated, stops
  permanently once acked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-14 10:00:08 -04:00
parent e113a2560e
commit ec6172d7df
4 changed files with 234 additions and 4 deletions

View File

@ -40,6 +40,9 @@
<!-- Global "mesh device detected" setup flow (fires on any page) -->
<MeshDeviceSetupModal />
<!-- Nudge to back up the Lightning seed once a wallet exists (any page) -->
<LndSeedBackupPrompt />
<!-- Global persistent audio player (bottom bar) -->
<GlobalAudioPlayer />
@ -92,6 +95,7 @@ import Screensaver from './components/Screensaver.vue'
import HelpGuideModal from './components/HelpGuideModal.vue'
import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue'
import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue'
import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue'
import { useMeshStore } from './stores/mesh'
import { useControllerNav } from '@/composables/useControllerNav'

View File

@ -135,7 +135,7 @@
<!-- Open Channel Modal -->
<Teleport to="body">
<div v-if="showOpenModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showOpenModal = false">
<div v-if="showOpenModal" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showOpenModal = false">
<div class="glass-card p-6 w-full max-w-md mx-4 max-h-[90vh] overflow-y-auto">
<h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
@ -233,7 +233,7 @@
<!-- Close Confirmation Modal -->
<Teleport to="body">
<div v-if="closeTarget" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
<div v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
<div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>

View File

@ -0,0 +1,183 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useAppStore } from '@/stores/app'
// Global "back up your Lightning seed" nudge. The full backup flow lives on
// the LND app-details page (views/appDetails/LndSeedBackup.vue); this banner
// just surfaces it proactively once a wallet seed exists and hasn't been
// acknowledged as backed up yet.
//
// lnd.seed-backup-status { available, acknowledged }:
// available=false no captured seed (LND not installed / legacy wallet) stay quiet
// acknowledged=true user already confirmed the backup stay quiet
// available && !acknowledged prompt
const SNOOZE_KEY = 'lnd-seed-backup-prompt-snooze-until'
const SNOOZE_MS = 24 * 60 * 60 * 1000
const POLL_MS = 5 * 60 * 1000
const LND_DETAILS_PATH = '/dashboard/apps/lnd'
const router = useRouter()
const route = useRoute()
const appStore = useAppStore()
const needsBackup = ref(false)
const snoozedUntil = ref(readSnooze())
let pollTimer: ReturnType<typeof setInterval> | null = null
// Once the backend says acked (or there is no seed to back up), stop polling
// for the rest of the session nothing left for this banner to do.
let settled = false
function readSnooze(): number {
try {
const raw = localStorage.getItem(SNOOZE_KEY)
const ts = raw ? Number(raw) : 0
return Number.isFinite(ts) ? ts : 0
} catch {
return 0
}
}
function isAuthed(): boolean {
// Same guard as useMessageToast never poll unauthenticated (background
// 401s on the login page cause refresh loops).
try { return localStorage.getItem('neode-auth') === 'true' } catch { return false }
}
async function pollStatus() {
if (settled || !isAuthed()) return
try {
const res = await rpcClient.call<{ available: boolean; acknowledged: boolean }>({
method: 'lnd.seed-backup-status',
timeout: 5000,
})
if (!res.available || res.acknowledged) {
needsBackup.value = false
settled = true
stopPolling()
} else {
needsBackup.value = true
}
} catch {
// Transient RPC failure keep the current state, retry on next tick.
}
}
function startPolling() {
if (pollTimer || settled) return
void pollStatus()
pollTimer = setInterval(() => {
if (document.visibilityState === 'visible') void pollStatus()
}, POLL_MS)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
watch(() => appStore.isAuthenticated, (authed) => {
if (authed) startPolling()
else {
stopPolling()
needsBackup.value = false
}
}, { immediate: true })
// The LND details page renders its own prominent backup card hide the
// banner there, and re-check status when the user navigates away so an ack
// made on that page hides the banner immediately (not after the next tick).
const onLndPage = computed(() => route.path === LND_DETAILS_PATH)
watch(onLndPage, (now, was) => {
if (was && !now && !settled && isAuthed()) void pollStatus()
})
const show = computed(() =>
needsBackup.value && !onLndPage.value && Date.now() >= snoozedUntil.value,
)
function backUpNow() {
router.push(LND_DETAILS_PATH).catch(() => {})
}
function snooze() {
const until = Date.now() + SNOOZE_MS
snoozedUntil.value = until
try { localStorage.setItem(SNOOZE_KEY, String(until)) } catch { /* noop */ }
}
onBeforeUnmount(stopPolling)
</script>
<template>
<Teleport to="body">
<Transition name="seed-banner">
<div
v-if="show"
role="alert"
class="fixed bottom-4 left-4 right-4 z-[95] mx-auto w-auto max-w-lg rounded-xl border border-orange-400/30 p-4 seed-banner-glass md:left-auto md:right-6 md:bottom-6"
>
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-orange-500/20">
<svg class="h-5 w-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86l-8.4 14.55A1.5 1.5 0 003.19 21h17.62a1.5 1.5 0 001.3-2.59l-8.4-14.55a1.5 1.5 0 00-2.62 0z" />
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">Back up your Lightning wallet seed</p>
<p class="mt-0.5 text-sm text-white/70">
Your Lightning wallet has a recovery seed that hasn't been backed up yet.
Write it down now so your funds survive a disk failure.
</p>
<div class="mt-3 flex gap-2">
<button
type="button"
class="glass-button glass-button-warning rounded-lg px-4 py-2 text-sm font-medium"
@click="backUpNow"
>Back up now</button>
<button
type="button"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
@click="snooze"
>Later</button>
</div>
</div>
<button
type="button"
aria-label="Dismiss for now"
class="-mt-1 -mr-1 shrink-0 rounded-full p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white/80"
@click="snooze"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.seed-banner-glass {
background: rgba(15, 15, 20, 0.85);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
}
.seed-banner-enter-active,
.seed-banner-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.seed-banner-enter-from,
.seed-banner-leave-to {
opacity: 0;
transform: translateY(12px);
}
</style>

View File

@ -1,12 +1,32 @@
<template>
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[90vh] flex flex-col" @close="close">
<!-- Rail filter: instant ecash micro-payments pile up fast and bury
on-chain/Lightning rows; chips keep the standard txs reachable. -->
<div v-if="transactions.length > 0" class="flex gap-1.5 mb-3 shrink-0 flex-wrap">
<button
v-for="f in filters"
:key="f.key"
class="px-2.5 py-1 rounded-full text-xs transition-colors"
:class="activeFilter === f.key
? 'bg-orange-500/25 text-orange-200 border border-orange-400/40'
: 'bg-white/5 text-white/50 border border-white/10 hover:text-white/80'"
@click="activeFilter = f.key"
>
{{ f.label }}<span v-if="countFor(f.key)" class="text-white/35"> · {{ countFor(f.key) }}</span>
</button>
</div>
<div v-if="transactions.length === 0" class="flex-1 flex items-center justify-center py-12">
<p class="text-white/40 text-sm">{{ t('transactions.noTransactionsYet') }}</p>
</div>
<div v-else-if="filteredTransactions.length === 0" class="flex-1 flex items-center justify-center py-12">
<p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p>
</div>
<div v-else class="flex-1 overflow-y-auto -mx-2 px-2 divide-y divide-white/5">
<div
v-for="tx in transactions"
v-for="tx in filteredTransactions"
:key="(tx.kind || 'onchain') + tx.tx_hash + tx.time_stamp"
class="flex items-center justify-between gap-3 py-3 hover:bg-white/5 rounded-lg px-2 transition-colors"
:class="isOnchain(tx) ? 'cursor-pointer' : 'cursor-default'"
@ -75,6 +95,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseModal from '@/components/BaseModal.vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
@ -93,7 +114,7 @@ interface WalletTransaction {
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint'
}
defineProps<{
const props = defineProps<{
show: boolean
transactions: WalletTransaction[]
}>()
@ -101,6 +122,28 @@ defineProps<{
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash'
const filters: Array<{ key: FilterKey; label: string }> = [
{ key: 'all', label: 'All' },
{ key: 'onchain', label: 'On-chain' },
{ key: 'lightning', label: '⚡ Lightning' },
{ key: 'ecash', label: 'Ecash' },
]
const activeFilter = ref<FilterKey>('all')
function matchesFilter(tx: WalletTransaction, f: FilterKey): boolean {
if (f === 'all') return true
if (f === 'onchain') return isOnchain(tx)
if (f === 'lightning') return tx.kind === 'lightning'
return tx.kind === 'cashu' || tx.kind === 'fedimint'
}
const filteredTransactions = computed(() => props.transactions.filter(tx => matchesFilter(tx, activeFilter.value)))
function countFor(f: FilterKey): number {
if (f === 'all') return 0
return props.transactions.filter(tx => matchesFilter(tx, f)).length
}
function close() {
emit('close')
}