diff --git a/neode-ui/src/components/AppLauncherOverlay.vue b/neode-ui/src/components/AppLauncherOverlay.vue index 4bae8a02..a69e6b8a 100644 --- a/neode-ui/src/components/AppLauncherOverlay.vue +++ b/neode-ui/src/components/AppLauncherOverlay.vue @@ -353,6 +353,10 @@ function openInNewTab() { // (so the tap silently no-ops). The native bridge is reliable; fall back to // window.open in a plain mobile browser. const native = (window as any).ArchipelagoNative + if (native && typeof native.openInAppEx === 'function' && store.title) { + native.openInAppEx(store.url, '', store.title) + return + } if (native && typeof native.openInApp === 'function') { native.openInApp(store.url) return diff --git a/neode-ui/src/components/TransactionsModal.vue b/neode-ui/src/components/TransactionsModal.vue index 7f970121..d37c0282 100644 --- a/neode-ui/src/components/TransactionsModal.vue +++ b/neode-ui/src/components/TransactionsModal.vue @@ -20,11 +20,11 @@ -
+

{{ t('transactions.noTransactionsYet') }}

-
+

No {{ activeFilter }} transactions

@@ -65,7 +65,7 @@ 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 + {{ tx.direction === 'incoming' ? '+' : '-' }}{{ displayAmount(tx).toLocaleString() }} sats

{{ tx.tx_hash }}

+ fee {{ feeFor(tx).toLocaleString() }} sats {{ tx.label }}
@@ -166,6 +167,18 @@ function isOnchain(tx: WalletTransaction): boolean { return !tx.kind || tx.kind === 'onchain' } +function feeFor(tx: WalletTransaction): number { + return tx.direction === 'outgoing' ? (tx.total_fees || 0) : 0 +} + +/** Outgoing rows show the amount the RECIPIENT got (gross minus fee); the fee + * itself is broken out on its own tag. Incoming rows are untouched. */ +function displayAmount(tx: WalletTransaction): number { + const gross = Math.abs(tx.amount_sats) + const fee = feeFor(tx) + return fee > 0 && gross > fee ? gross - fee : gross +} + function kindLabel(tx: WalletTransaction): string { if (tx.kind === 'lightning') return '⚡ Lightning' if (tx.kind === 'cashu') return 'Cashu' diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index d01e59f6..ba427556 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -3,9 +3,10 @@ import { ref, watch } from 'vue' import { rpcClient } from '@/api/rpc-client' import { recordAppLaunch } from '@/utils/appUsage' import { requestExternalOpen } from '@/api/remote-relay' -import { openInAppOrNewTab, isCompanionApp } from '@/utils/openExternal' +import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils/openExternal' import { resolveAppUrl } from '@/views/appSession/appSessionConfig' import { useAppStore } from '@/stores/app' +import { resolveAppIcon } from '@/views/apps/appsConfig' import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro' /** @@ -200,6 +201,17 @@ export interface NostrConsentRequest { reject: () => void } +/** App identity (catalog icon + display name) for the companion's native + * branded loader. Undefined when the app isn't in package-data. */ +function launchMeta(appId: string): InAppLaunchMeta | undefined { + const pkg = useAppStore().data?.['package-data']?.[appId] + if (!pkg) return undefined + return { + iconUrl: resolveAppIcon(appId, pkg), + name: pkg.manifest?.title || appId, + } +} + export const useAppLauncherStore = defineStore('appLauncher', () => { const isOpen = ref(false) const url = ref('') @@ -225,7 +237,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl) if (launchUrl) { - openInAppOrNewTab(launchUrl) + openInAppOrNewTab(launchUrl, launchMeta(appId)) return } } @@ -235,7 +247,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { if (IS_DEMO && isDemoExternal(appId)) { const ext = demoAppUrl(appId) if (ext) { - if (mobile) openInAppOrNewTab(ext) + if (mobile) openInAppOrNewTab(ext, launchMeta(appId)) else openExternal(ext) return } @@ -248,7 +260,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { if (NEW_TAB_APP_IDS.has(appId) && !(IS_DEMO && isDemoApp(appId))) { const launchUrl = directAppUrl(appId) if (launchUrl) { - if (mobile) openInAppOrNewTab(launchUrl) + if (mobile) openInAppOrNewTab(launchUrl, launchMeta(appId)) else openExternal(launchUrl) return } @@ -327,7 +339,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { // Companion app: never fall through to the iframe overlay — hand the URL // to the native in-app WebView instead (see openSession). if (!IS_DEMO && isCompanionApp()) { - openInAppOrNewTab(launchUrl) + openInAppOrNewTab(launchUrl, resolvedId ? launchMeta(resolvedId) : undefined) return } diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index eaa2ceb8..e8cfdb94 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -1452,6 +1452,24 @@ html.kiosk-safe-area #app { overflow: hidden; } +/* Horizontal filter-pill rail: single row, swipes sideways on narrow + screens, no visible scrollbar — pills never wrap or squish. */ +.pill-rail { + display: flex; + gap: 0.375rem; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; +} +.pill-rail::-webkit-scrollbar { + display: none; +} +.pill-rail > * { + flex: 0 0 auto; + white-space: nowrap; +} + /* Custom scrollbar for glass containers */ .custom-scrollbar::-webkit-scrollbar { width: 10px; diff --git a/neode-ui/src/utils/openExternal.ts b/neode-ui/src/utils/openExternal.ts index e2d12efc..d55add68 100644 --- a/neode-ui/src/utils/openExternal.ts +++ b/neode-ui/src/utils/openExternal.ts @@ -12,6 +12,15 @@ interface ArchipelagoNativeBridge { openExternal?: (url: string) => void openInApp?: (url: string) => void + /** Richer launch (companion ≥0.5.26): catalog icon + display name drive the + * native branded loader instead of the site favicon. */ + openInAppEx?: (url: string, iconUrl: string, name: string) => void +} + +/** Optional app identity for the native loading screen. */ +export interface InAppLaunchMeta { + iconUrl?: string + name?: string } function nativeBridge(): ArchipelagoNativeBridge | undefined { @@ -46,9 +55,15 @@ export function openExternalUrl(url: string): void { * inside Archipelago with the native back/forward/reload/close controls. * - Plain mobile browser (PWA): open directly in a new browser tab. */ -export function openInAppOrNewTab(url: string): void { +export function openInAppOrNewTab(url: string, meta?: InAppLaunchMeta): void { if (!url) return const native = nativeBridge() + if (native && typeof native.openInAppEx === 'function' && (meta?.iconUrl || meta?.name)) { + // Absolutize the icon path so the native shell can fetch it directly. + const icon = meta.iconUrl ? new URL(meta.iconUrl, window.location.origin).href : '' + native.openInAppEx(url, icon, meta.name ?? '') + return + } if (native && typeof native.openInApp === 'function') { native.openInApp(url) return