Fix paid-file recovery, app lifecycle regressions and wallet controls
Demo images / Build & push demo images (push) Failing after 1m10s

This commit is contained in:
archipelago
2026-10-01 10:31:55 -04:00
parent 227174e541
commit f4d3455496
31 changed files with 1638 additions and 269 deletions
@@ -0,0 +1,37 @@
<template>
<div class="relative min-w-0 flex-1">
<input
ref="input"
v-model="query"
type="text"
:placeholder="placeholder"
:aria-label="label"
data-controller-no-submit
class="app-header-search w-full pr-10 text-white placeholder-white/50 focus:outline-none transition-colors"
@keydown.esc.prevent="clear"
/>
<button
v-if="query.length"
type="button"
aria-label="Clear search"
title="Clear search"
class="absolute right-1 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-400/60 transition-colors"
@click="clear"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m6 6 12 12M6 18 18 6" />
</svg>
</button>
</div>
</template>
<script setup lang="ts">
import { nextTick, ref } from 'vue'
defineProps<{ placeholder: string; label: string }>()
const query = defineModel<string>({ default: '' })
const input = ref<HTMLInputElement | null>(null)
async function clear() {
query.value = ''
await nextTick()
input.value?.focus()
}
</script>
@@ -353,15 +353,57 @@
<!-- Close Confirmation Modal -->
<Teleport to="body">
<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 v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="!closingChannel && (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>
<!-- Fee selection -->
<div class="mb-4">
<label class="text-white/60 text-sm block mb-1">Fee</label>
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
<button
v-for="preset in feePresets"
:key="preset.key"
@click="closeForm.feePreset = preset.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="closeForm.feePreset === preset.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ preset.label }}</button>
</div>
<p v-if="closeForm.feePreset !== 'custom'" class="text-white/40 text-xs mt-1">
{{ feePresets.find(p => p.key === closeForm.feePreset)?.hint }}
</p>
<div v-else class="grid grid-cols-2 gap-3 mt-2">
<div>
<label class="text-white/60 text-xs block mb-1">Target confirmations</label>
<input
v-model.number="closeForm.customConfTarget"
type="number"
min="1"
max="1008"
placeholder="6"
class="w-full input-glass"
/>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Sats per vByte</label>
<input
v-model.number="closeForm.customSatPerVbyte"
type="number"
min="1"
max="5000"
placeholder="—"
class="w-full input-glass"
/>
</div>
<p class="text-white/40 text-xs col-span-2">Set one — sats per vByte takes precedence when both are set</p>
</div>
</div>
<div v-if="closeError" class="mb-3 alert-error">
<p class="text-xs">{{ closeError }}</p>
</div>
<div class="flex gap-3">
<button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button @click="closeTarget = null" :disabled="closingChannel" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="closeChannel"
:disabled="closingChannel"
@@ -457,8 +499,8 @@ function closeTypeLabel(ch: ClosedChannel): string {
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [
{ key: 'standard', label: 'Standard', hint: 'Confirms within ~6 blocks (about an hour)', confTarget: 6 },
{ key: 'medium', label: 'Medium', hint: 'Confirms within ~3 blocks (about 30 minutes)', confTarget: 3 },
{ key: 'standard', label: 'Standard', hint: 'Targets ~6 blocks (about an hour)', confTarget: 6 },
{ key: 'medium', label: 'Medium', hint: 'Targets ~3 blocks (about 30 minutes)', confTarget: 3 },
{ key: 'fast', label: 'Fast', hint: 'Targets the next block', confTarget: 1 },
{ key: 'custom', label: 'Custom' },
]
@@ -577,22 +619,24 @@ function loadChannels(): Promise<void> {
return main
}
function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
const form = openForm.value
function feeParams(
form: { feePreset: FeePreset; customSatPerVbyte: number | null; customConfTarget: number | null } = openForm.value,
setError: (message: string) => void = message => { openError.value = message },
): { target_conf?: number; sat_per_vbyte?: number } | null {
if (form.feePreset !== 'custom') {
return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 6 }
}
const rate = form.customSatPerVbyte
const conf = form.customConfTarget
if (rate != null && rate !== 0) {
if (rate < 1 || rate > 5000) { openError.value = 'Sats per vByte must be between 1 and 5000'; return null }
if (!Number.isInteger(rate) || rate < 1 || rate > 5000) { setError('Sats per vByte must be a whole number between 1 and 5000'); return null }
return { sat_per_vbyte: Math.floor(rate) }
}
if (conf != null && conf !== 0) {
if (conf < 1 || conf > 1008) { openError.value = 'Target confirmations must be between 1 and 1008'; return null }
if (!Number.isInteger(conf) || conf < 1 || conf > 1008) { setError('Target confirmations must be a whole number between 1 and 1008'); return null }
return { target_conf: Math.floor(conf) }
}
openError.value = 'Custom fee requires target confirmations or sats per vByte'
setError('Custom fee requires target confirmations or sats per vByte')
return null
}
@@ -629,7 +673,12 @@ async function openChannel() {
}
}
const defaultCloseForm = () => ({ feePreset: 'standard' as FeePreset, customConfTarget: null as number | null, customSatPerVbyte: null as number | null })
const closeForm = ref(defaultCloseForm())
function confirmClose(ch: Channel) {
if (closingChannel.value) return
closeForm.value = defaultCloseForm()
closeTarget.value = ch
closeError.value = null
}
@@ -637,12 +686,15 @@ function confirmClose(ch: Channel) {
async function closeChannel() {
if (closingChannel.value || !closeTarget.value) return
closeError.value = null
const fee = feeParams(closeForm.value, message => { closeError.value = message })
if (!fee) return
closingChannel.value = true
try {
await rpcClient.call({
method: 'lnd.closechannel',
params: { channel_point: closeTarget.value.channel_point },
timeout: 30000,
params: { channel_point: closeTarget.value.channel_point, ...fee },
timeout: 45000,
maxRetries: 1,
})
closeTarget.value = null
await loadChannels()
@@ -9,11 +9,11 @@
<!-- Transparent glass, not a black slab (operator, 2026-08-09): the
backdrop blur alone keeps the pinned tabs legible over scrolling
rows without painting an opaque container onto the modal. -->
<div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-wrap bg-white/5 backdrop-blur-md">
<div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-nowrap overflow-x-auto bg-transparent backdrop-blur-md">
<button
v-for="f in filters"
:key="f.key"
class="px-2.5 py-1 rounded-full text-xs transition-colors"
class="shrink-0 whitespace-nowrap 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'"
@@ -0,0 +1,53 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LightningChannelsPanel from '../LightningChannelsPanel.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('@/composables/useTxExplorer', () => ({ useTxExplorer: () => ({ openTx: vi.fn() }) }))
vi.mock('@/composables/useCachedResource', async () => {
const { ref } = await import('vue')
return { useCachedResource: () => ({ data: ref(null), loadState: ref('ready'), error: ref(null), refresh: vi.fn().mockResolvedValue(undefined) }) }
})
const channel = { chan_id: 'test', remote_pubkey: '02' + 'a'.repeat(64), channel_point: 'b'.repeat(64) + ':0', capacity: 100000, local_balance: 50000, remote_balance: 50000, active: true }
function open() {
const wrapper = mount(LightningChannelsPanel, { global: { stubs: { Teleport: true } } })
const vm = (wrapper.vm as any).$.setupState
vm.confirmClose(channel)
return { wrapper, vm }
}
beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockResolvedValue({ success: true } as never) })
describe('channel closing fee choice', () => {
it.each([['standard', 6], ['medium', 3], ['fast', 1]])('forwards %s target and never automatically retries the mutation', async (preset, target) => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = preset
await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.closechannel', params: { channel_point: channel.channel_point, target_conf: target }, maxRetries: 1 }))
expect(vm.closeTarget).toBeNull(); wrapper.unmount()
})
it('sends the custom rate instead of a confirmation target', async () => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'; vm.closeForm.customSatPerVbyte = 25; vm.closeForm.customConfTarget = 3
await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, sat_per_vbyte: 25 } }))
wrapper.unmount()
})
it.each([0.5, -1, 5001, NaN, Infinity])('rejects invalid custom rate %s before RPC', async rate => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'; vm.closeForm.customSatPerVbyte = rate
await vm.closeChannel(); expect(rpcClient.call).not.toHaveBeenCalled(); expect(vm.closeError).toBeTruthy(); wrapper.unmount()
})
it('requires a custom value and accepts a custom confirmation target', async () => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'
await vm.closeChannel(); expect(rpcClient.call).not.toHaveBeenCalled()
vm.closeForm.customConfTarget = 2; await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, target_conf: 2 } })); wrapper.unmount()
})
it('keeps the chosen fee and error visible when LND rejects a close', async () => {
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Peer is offline'))
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'fast'
await vm.closeChannel(); expect(vm.closeTarget).not.toBeNull(); expect(vm.closeError).toBe('Peer is offline'); expect(vm.closeForm.feePreset).toBe('fast'); wrapper.unmount()
})
it('prevents duplicate submits while a close is pending', async () => {
let finish!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(() => new Promise(resolve => { finish = resolve }) as never)
const { wrapper, vm } = open(); const pending = vm.closeChannel(); await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledTimes(1); finish({ success: true }); await pending; await flushPromises(); wrapper.unmount()
})
})
@@ -45,7 +45,7 @@
</button>
</div>
<button class="glass-button cloud-toolbar-btn" title="Upload file" @click="triggerUpload">
<button class="glass-button cloud-toolbar-btn" title="Upload file" :disabled="uploading" @click="triggerUpload">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
@@ -61,6 +61,7 @@
<input
ref="fileInput"
type="file"
:disabled="uploading"
class="hidden"
multiple
@change="handleFileSelect"
@@ -74,6 +75,7 @@ import { ref } from 'vue'
defineProps<{
breadcrumbs: { name: string; path: string }[]
viewMode: 'list' | 'grid'
uploading?: boolean
}>()
const emit = defineEmits<{
@@ -0,0 +1,29 @@
<template>
<div v-if="task" :class="floating ? 'fixed z-50 top-20 left-4 right-4 md:left-auto md:w-96' : 'mb-3 shrink-0'">
<div class="glass-card relative overflow-hidden h-11 px-3 flex items-center gap-2" role="status" aria-live="polite">
<div v-if="task.active" class="absolute inset-y-0 left-0 bg-emerald-400/10 transition-[width] duration-200 pointer-events-none" :style="{ width: `${percent}%` }" />
<div v-if="task.active" class="absolute bottom-0 left-0 h-0.5 bg-emerald-400 transition-[width] duration-200" :style="{ width: `${percent}%` }" role="progressbar" :aria-valuenow="percent" aria-valuemin="0" aria-valuemax="100" :aria-label="`Uploading ${task.filename}`" />
<span class="relative min-w-0 flex-1 text-sm truncate" :class="task.error ? 'text-red-300' : 'text-white/80'" :title="label">{{ label }}</span>
<span v-if="task.active" class="relative shrink-0 text-xs tabular-nums text-white/60">{{ percent }}%</span>
<button class="relative shrink-0 w-8 h-8 flex items-center justify-center rounded-lg text-white/60 hover:text-white hover:bg-white/10" :aria-label="task.active ? 'Cancel upload' : 'Dismiss upload'" @click="task.active ? store.cancelUpload() : store.dismissUpload()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-width="2" d="m6 6 12 12M6 18 18 6" /></svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useCloudStore } from '@/stores/cloud'
defineProps<{ floating?: boolean }>()
const store = useCloudStore()
const task = computed(() => store.upload)
const percent = computed(() => !task.value ? 0 : task.value.total ? Math.min(100, Math.floor(task.value.sent * 100 / task.value.total)) : task.value.active ? 0 : 100)
const label = computed(() => {
const t = task.value
if (!t) return ''
if (t.error) return t.error
if (t.cancelled) return `Upload stopped · ${t.completed}/${t.count} saved`
if (!t.active) return `${t.count === 1 ? t.filename : `${t.count} files`} uploaded`
return `${percent.value === 100 ? 'Saving' : 'Uploading'} ${t.filename}${t.count > 1 ? ` · ${t.completed + 1}/${t.count}` : ''}`
})
</script>