feat(web): app-identity launch bridge + transactions scroll/pills/fee fixes

- openInAppEx(url, icon, name): companion's native loader gets the app's
  catalog icon + display name instead of a favicon.
- TransactionsModal: BaseModal's scroller owns scrolling (nested scroll
  container broke touch scroll on phones); filter pills move to a
  swipeable single-row rail (.pill-rail) that never wraps or squishes;
  outgoing rows show recipient-received amount with fee broken out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-08-02 13:09:15 +01:00
co-authored by Claude Fable 5
parent 4ea3fb2deb
commit 9628dd8bc2
5 changed files with 71 additions and 9 deletions
@@ -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
+16 -3
View File
@@ -20,11 +20,11 @@
</button>
</div>
<div v-if="transactions.length === 0" class="flex-1 flex items-center justify-center py-12">
<div v-if="transactions.length === 0" class="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">
<div v-else-if="filteredTransactions.length === 0" class="flex items-center justify-center py-12">
<p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p>
</div>
@@ -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
</span>
<span
v-if="isOnchain(tx)"
@@ -88,6 +88,7 @@
</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="feeFor(tx)" class="text-[10px] text-white/35 shrink-0">fee {{ feeFor(tx).toLocaleString() }} sats</span>
<span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span>
</div>
</div>
@@ -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'
+17 -5
View File
@@ -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
}
+18
View File
@@ -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;
+16 -1
View File
@@ -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