archy/neode-ui/src/components/TransactionsModal.vue
archipelago c0aef2f03e feat(wallet): external tx-explorer fallback with consent + On-chain settings tab
Pruned nodes can't run the Mempool app, but tx links blindly opened it
anyway. Now: local app when running; otherwise an external explorer
(default tx1138.com) behind a one-time amber consent modal that spells
out what the other server's operator learns (tx of interest + IP) and
lets the user point at their own instance (placeholder mempool.guide).
Wallet Settings gains an On-chain tab (explorer URL + don't-warn toggle);
tabs renamed Cashu/Fedi so five fit in the row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:31:48 -04:00

193 lines
8.6 KiB
Vue

<template>
<!-- Mobile: cap at ~60% of the LIVE visible viewport (not dvh see
syncViewportHeightVar in main.ts) so the tx list doesn't fill the screen. -->
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[calc(var(--visual-viewport-height,100dvh)*0.6)] md: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 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'"
@click="openInMempool(tx)"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<div
class="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
:class="tx.direction === 'incoming'
? (tx.num_confirmations === 0 ? 'bg-yellow-500/15' : 'bg-green-500/15')
: 'bg-red-500/10'"
>
<svg
class="w-4 h-4"
:class="tx.direction === 'incoming'
? (tx.num_confirmations === 0 ? 'text-yellow-400' : 'text-green-400')
: 'text-red-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path v-if="tx.direction === 'incoming'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="text-sm font-medium"
:class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'"
>
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ Math.abs(tx.amount_sats).toLocaleString() }} sats
</span>
<span
v-if="isOnchain(tx)"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="tx.num_confirmations === 0
? 'bg-yellow-500/15 text-yellow-400'
: tx.num_confirmations < 3
? 'bg-green-500/15 text-green-400'
: 'bg-white/10 text-white/50'"
>
{{ tx.num_confirmations === 0 ? t('transactions.unconfirmed') : t('transactions.confirmations', { count: tx.num_confirmations }) }}
</span>
<span
v-else
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="tx.kind === 'lightning' ? 'bg-yellow-500/15 text-yellow-400' : tx.kind === 'cashu' ? 'bg-purple-500/15 text-purple-400' : tx.kind === 'ark' ? 'bg-teal-500/15 text-teal-400' : 'bg-blue-500/15 text-blue-400'"
>
{{ kindLabel(tx) }}
</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<p class="text-[11px] text-white/40 font-mono truncate">{{ tx.tx_hash }}</p>
<span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
<svg v-if="isOnchain(tx)" class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
</div>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseModal from '@/components/BaseModal.vue'
import { useTxExplorer } from '@/composables/useTxExplorer'
interface WalletTransaction {
tx_hash: string
amount_sats: number
direction: 'incoming' | 'outgoing'
num_confirmations: number
time_stamp: number
total_fees: number
dest_addresses: string[]
label: string
block_height: number
// Which rail the transaction happened on; absent = onchain (older backends)
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint' | 'ark'
}
const props = defineProps<{
show: boolean
transactions: WalletTransaction[]
}>()
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash' | 'ark'
// The Ark chip only appears once an Ark transaction exists — most nodes
// don't run the barkd sidecar.
const filters = computed<Array<{ key: FilterKey; label: string }>>(() => [
{ key: 'all', label: 'All' },
{ key: 'onchain', label: 'On-chain' },
{ key: 'lightning', label: '⚡ Lightning' },
{ key: 'ecash', label: 'Ecash' },
...(props.transactions.some(tx => tx.kind === 'ark') ? [{ key: 'ark' as const, label: 'Ark' }] : []),
])
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'
if (f === 'ark') return tx.kind === 'ark'
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')
}
// Only on-chain transactions exist in mempool; Lightning/ecash rows don't link
function isOnchain(tx: WalletTransaction): boolean {
return !tx.kind || tx.kind === 'onchain'
}
function kindLabel(tx: WalletTransaction): string {
if (tx.kind === 'lightning') return '⚡ Lightning'
if (tx.kind === 'cashu') return 'Cashu'
if (tx.kind === 'fedimint') return 'Fedimint'
if (tx.kind === 'ark') return 'Ark'
return ''
}
const txExplorer = useTxExplorer()
function openInMempool(tx: WalletTransaction) {
if (!isOnchain(tx)) return
// Local Mempool app when running (overlaid above this modal); external
// explorer with consent otherwise (pruned nodes can't run Mempool).
txExplorer.openTx(tx.tx_hash)
}
function formatTxTime(timestamp: number): string {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return t('transactions.justNow')
if (diffMins < 60) return t('transactions.minutesAgo', { count: diffMins })
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return t('transactions.hoursAgo', { count: diffHours })
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 7) return t('transactions.daysAgo', { count: diffDays })
return date.toLocaleDateString()
}
</script>