diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index 0dcb8b52..165cff25 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -39,6 +39,7 @@ + @@ -95,6 +96,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 ExternalExplorerModal from './components/ExternalExplorerModal.vue' import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue' import { useMeshStore } from './stores/mesh' diff --git a/neode-ui/src/components/ExternalExplorerModal.vue b/neode-ui/src/components/ExternalExplorerModal.vue new file mode 100644 index 00000000..00542a44 --- /dev/null +++ b/neode-ui/src/components/ExternalExplorerModal.vue @@ -0,0 +1,71 @@ + + + diff --git a/neode-ui/src/components/TransactionsModal.vue b/neode-ui/src/components/TransactionsModal.vue index 6a0d0818..9f8424db 100644 --- a/neode-ui/src/components/TransactionsModal.vue +++ b/neode-ui/src/components/TransactionsModal.vue @@ -100,7 +100,7 @@ import { ref, computed } from 'vue' import { useI18n } from 'vue-i18n' import BaseModal from '@/components/BaseModal.vue' -import { useAppLauncherStore } from '@/stores/appLauncher' +import { useTxExplorer } from '@/composables/useTxExplorer' interface WalletTransaction { tx_hash: string @@ -167,10 +167,12 @@ function kindLabel(tx: WalletTransaction): string { return '' } +const txExplorer = useTxExplorer() function openInMempool(tx: WalletTransaction) { if (!isOnchain(tx)) return - // Overlay the explorer above the current page — never navigate away. - useAppLauncherStore().openSession('mempool', { path: `/tx/${tx.tx_hash}` }) + // 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 { diff --git a/neode-ui/src/components/WalletSettingsModal.vue b/neode-ui/src/components/WalletSettingsModal.vue index 57d403fe..580108bc 100644 --- a/neode-ui/src/components/WalletSettingsModal.vue +++ b/neode-ui/src/components/WalletSettingsModal.vue @@ -149,6 +149,41 @@

+ +
+

+ Where "view transaction" links open. When the Mempool app is installed and running + on this node, it is always used — private, no third parties. Without it (pruned + Bitcoin nodes can't run Mempool), links open on the explorer below. +

+ + + +

+ Any Mempool-compatible instance works. Default: tx1138.com. +

+ +
+ ⚠️ Opening a transaction on an external explorer tells that server's operator which + transaction you're interested in, plus your IP address. Use a server you trust. +
+ + + +
+ +
+
+
@@ -253,24 +288,38 @@ import { useI18n } from 'vue-i18n' import { rpcClient } from '@/api/rpc-client' import BaseModal from '@/components/BaseModal.vue' import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue' +import { useTxExplorer, EXPLORER_PLACEHOLDER } from '@/composables/useTxExplorer' const { t } = useI18n() const props = defineProps<{ show: boolean }>() const emit = defineEmits<{ close: []; changed: [] }>() +// Short labels on purpose — five tabs share one row ("Fedi", not +// "Fedimint Federations") so On-chain fits. const tabs = [ { key: 'channels' as const, label: 'Channels' }, - { key: 'cashu' as const, label: 'Cashu Mints' }, - { key: 'fedimint' as const, label: 'Fedimint Federations' }, + { key: 'cashu' as const, label: 'Cashu' }, + { key: 'fedimint' as const, label: 'Fedi' }, { key: 'ark' as const, label: 'Ark' }, + { key: 'onchain' as const, label: 'On-chain' }, ] -const activeTab = ref<'channels' | 'cashu' | 'fedimint' | 'ark'>('channels') +const activeTab = ref<'channels' | 'cashu' | 'fedimint' | 'ark' | 'onchain'>('channels') // Backed by wallet.fedimint-list / -join / -leave (fedimint-clientd HTTP bridge). // Join degrades gracefully with a clear error if the Fedimint client app isn't installed. const fedimintBackendReady = true +// ---- On-chain: external transaction explorer ---- +const txExplorer = useTxExplorer() +const explorerUrlInput = ref(txExplorer.prefs.value.url) +const explorerAcknowledged = ref(txExplorer.prefs.value.acknowledged) +function saveExplorer() { + txExplorer.setExplorer(explorerUrlInput.value, explorerAcknowledged.value) + // Reflect normalization (empty input falls back to the default). + explorerUrlInput.value = txExplorer.prefs.value.url +} + // ---- Cashu mints ---- const mints = ref([]) const newMint = ref('') diff --git a/neode-ui/src/composables/useTxExplorer.ts b/neode-ui/src/composables/useTxExplorer.ts new file mode 100644 index 00000000..efecc879 --- /dev/null +++ b/neode-ui/src/composables/useTxExplorer.ts @@ -0,0 +1,106 @@ +// Shared "open this transaction in an explorer" logic (2026-07-22). +// +// Nodes with a PRUNED bitcoin node can't run the Mempool app at all, but the +// wallet UI's tx links used to blindly open the local app anyway (blank app +// frame). Routing: +// - local Mempool app running → open it, exactly as before +// - otherwise → an EXTERNAL explorer, after a one-time +// consent modal: viewing a tx on someone else's mempool tells that +// server's operator which transaction you're interested in (plus your +// IP), so the user must knowingly opt in and may point the link at +// their own trusted instance instead. +// +// Preference + acknowledgement persist per browser in localStorage +// (`archipelago.tx-explorer.v1`); the Settings → System section edits the +// same values. +import { ref } from 'vue' +import { useAppLauncherStore } from '@/stores/appLauncher' +import { useContainerStore } from '@/stores/container' + +export const DEFAULT_TX_EXPLORER = 'https://tx1138.com' +export const EXPLORER_PLACEHOLDER = 'https://mempool.guide' + +const KEY = 'archipelago.tx-explorer.v1' + +interface TxExplorerPrefs { + url: string + acknowledged: boolean +} + +function loadPrefs(): TxExplorerPrefs { + const defaults: TxExplorerPrefs = { url: DEFAULT_TX_EXPLORER, acknowledged: false } + try { + return { ...defaults, ...JSON.parse(localStorage.getItem(KEY) || '{}') } + } catch { + return defaults + } +} + +// Module-scope state: one source of truth shared by every caller, the global +// consent modal, and the Settings section. +const prefs = ref(loadPrefs()) +/** Tx hash awaiting user consent — non-null shows ExternalExplorerModal. */ +const pendingTx = ref(null) + +function savePrefs() { + localStorage.setItem(KEY, JSON.stringify(prefs.value)) +} + +export function useTxExplorer() { + const launcher = useAppLauncherStore() + const containers = useContainerStore() + + /** Normalized explorer base URL (no trailing slash). */ + function explorerUrl(): string { + return (prefs.value.url || DEFAULT_TX_EXPLORER).trim().replace(/\/+$/, '') + } + + function openExternal(txHash: string) { + window.open(`${explorerUrl()}/tx/${txHash}`, '_blank', 'noopener,noreferrer') + } + + /** Entry point for every "view transaction" affordance in the app. */ + function openTx(txHash: string) { + if (containers.getAppState('mempool') === 'running') { + launcher.openSession('mempool', { path: `/tx/${txHash}` }) + return + } + if (prefs.value.acknowledged) { + openExternal(txHash) + return + } + pendingTx.value = txHash + } + + /** Consent modal confirm: persist the (possibly edited) URL + ack, open. */ + function confirmPending(url: string, dontAskAgain: boolean) { + const trimmed = url.trim() + prefs.value.url = trimmed || DEFAULT_TX_EXPLORER + if (dontAskAgain) prefs.value.acknowledged = true + savePrefs() + const tx = pendingTx.value + pendingTx.value = null + if (tx) openExternal(tx) + } + + function cancelPending() { + pendingTx.value = null + } + + /** Settings hook: change the explorer and/or reset the consent. */ + function setExplorer(url: string, acknowledged?: boolean) { + prefs.value.url = url.trim() || DEFAULT_TX_EXPLORER + if (acknowledged !== undefined) prefs.value.acknowledged = acknowledged + savePrefs() + } + + return { + prefs, + pendingTx, + explorerUrl, + openTx, + confirmPending, + cancelPending, + setExplorer, + } +} diff --git a/neode-ui/src/views/web5/Web5Wallet.vue b/neode-ui/src/views/web5/Web5Wallet.vue index 5d8cc6cb..00195014 100644 --- a/neode-ui/src/views/web5/Web5Wallet.vue +++ b/neode-ui/src/views/web5/Web5Wallet.vue @@ -151,7 +151,7 @@ import { ref } from 'vue' import { useI18n } from 'vue-i18n' import { formatTxTime } from './utils' import type { WalletTransaction } from './types' -import { useAppLauncherStore } from '@/stores/appLauncher' +import { useTxExplorer } from '@/composables/useTxExplorer' const { t } = useI18n() @@ -177,8 +177,10 @@ defineEmits<{ openReceive: [] }>() +const txExplorer = useTxExplorer() function openInMempool(txHash: string) { - // Overlay the explorer above the current page — never navigate away. - useAppLauncherStore().openSession('mempool', { path: `/tx/${txHash}` }) + // Local Mempool app when running; external explorer with consent + // otherwise (pruned nodes can't run Mempool). + txExplorer.openTx(txHash) }