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