Changeset View
Changeset View
Standalone View
Standalone View
apps/marlin-wallet/web/src/screen/main.ts
| // Copyright (c) 2026 The Bitcoin developers | // Copyright (c) 2026 The Bitcoin developers | ||||
| // Distributed under the MIT software license, see the accompanying | // Distributed under the MIT software license, see the accompanying | ||||
| // file COPYING or http://www.opensource.org/licenses/mit-license.php. | // file COPYING or http://www.opensource.org/licenses/mit-license.php. | ||||
| import { Navigation } from '../navigation'; | import { Navigation } from '../navigation'; | ||||
| import { AppSettings } from '../settings'; | import { AppSettings } from '../settings'; | ||||
| import { Wallet } from 'ecash-wallet'; | import { Wallet } from 'ecash-wallet'; | ||||
| import { CryptoTicker, formatPrice } from 'ecash-price'; | import { CryptoTicker, formatPrice } from 'ecash-price'; | ||||
| import type { MarlinPriceFetcher } from '../price'; | import type { MarlinPriceFetcher } from '../price'; | ||||
| import { config } from '../config'; | import { | ||||
| import { satsToXec } from '../amount'; | activeAssetDecimals, | ||||
| activeAssetDefinition, | |||||
| activeAssetTicker, | |||||
| activeCryptoTicker, | |||||
| activeQuoteCurrency, | |||||
| allowFiatForActiveAsset, | |||||
| setActiveAsset, | |||||
| } from '../active-asset'; | |||||
| import { coinIconUrlForAssetKey } from '../coin-icon-url'; | |||||
| import { SUPPORTED_ASSETS } from '../supported-assets'; | |||||
| import { atomsToUnit } from '../amount'; | |||||
| import { copyAddress, isValidECashAddress } from '../address'; | import { copyAddress, isValidECashAddress } from '../address'; | ||||
| import { getAddress } from '../wallet'; | import { getAddress } from '../wallet'; | ||||
| import { | import { | ||||
| generateQRCode, | generateQRCode, | ||||
| startQRScanner, | startQRScanner, | ||||
| stopQRScanner, | stopQRScanner, | ||||
| hideNoCameraFallback, | hideNoCameraFallback, | ||||
| } from '../qrcode'; | } from '../qrcode'; | ||||
| import { parseBip21Uri, Bip21ParseResult } from '../bip21'; | import { parseBip21Uri, Bip21ParseResult } from '../bip21'; | ||||
| import { webViewError, webViewLog } from '../common'; | import { webViewError, webViewLog } from '../common'; | ||||
| export interface MainScreenParams { | export interface MainScreenParams { | ||||
| ecashWallet: Wallet | null; | ecashWallet: Wallet | null; | ||||
| navigation: Navigation; | navigation: Navigation; | ||||
| appSettings: AppSettings; | appSettings: AppSettings; | ||||
| priceFetcher: MarlinPriceFetcher | null; | priceFetcher: MarlinPriceFetcher | null; | ||||
| /** Ticker used for primary crypto balance formatting */ | |||||
| primaryBalanceTicker: CryptoTicker; | |||||
| /** Decimal places when showing the primary crypto amount */ | |||||
| primaryBalanceDecimals: number; | |||||
| onQRScanResult: ( | onQRScanResult: ( | ||||
| result?: Bip21ParseResult | { address: string }, | result?: Bip21ParseResult | { address: string }, | ||||
| ) => Promise<void>; | ) => Promise<void>; | ||||
| /** Sync wallet state after the user picks a different asset (e.g. TransactionManager + UTXOs). */ | |||||
| onAssetSwitched: () => Promise<void>; | |||||
| /** Updates shared ticker labels (main + send) when the active asset changes. */ | |||||
| refreshStaticTickerLabels: () => void; | |||||
| } | } | ||||
| interface UpdateBalanceElementParams { | interface UpdateBalanceElementParams { | ||||
| elementId: string; | elementId: string; | ||||
| fromXec: number; | fromXec: number; | ||||
| // null means hide the balance element | // null means hide the balance element | ||||
| toXec: number | null; | toXec: number | null; | ||||
| // null means show as XEC, otherwise show as Fiat | // null means show as XEC, otherwise show as Fiat | ||||
| pricePerXec: number | null; | pricePerXec: number | null; | ||||
| animate: boolean; | animate: boolean; | ||||
| } | } | ||||
| export class MainScreen { | export class MainScreen { | ||||
| private params: MainScreenParams; | private params: MainScreenParams; | ||||
| private ui: { | private ui: { | ||||
| address: HTMLElement; | address: HTMLElement; | ||||
| qrCode: HTMLElement; | qrCode: HTMLElement; | ||||
| primaryBalance: HTMLElement; | primaryBalance: HTMLElement; | ||||
| secondaryBalance: HTMLElement; | secondaryBalance: HTMLElement; | ||||
| transitionalBalance: HTMLElement; | transitionalBalance: HTMLElement; | ||||
| scanBtn: HTMLButtonElement; | scanBtn: HTMLButtonElement; | ||||
| manualEntryBtn: HTMLButtonElement; | manualEntryBtn: HTMLButtonElement; | ||||
| closeCameraBtn: HTMLButtonElement; | closeCameraBtn: HTMLButtonElement; | ||||
| cameraModal: HTMLElement; | cameraModal: HTMLElement; | ||||
| assetPickerBtn: HTMLButtonElement; | |||||
| assetPickerMenu: HTMLElement; | |||||
| }; | }; | ||||
| constructor(params: MainScreenParams) { | constructor(params: MainScreenParams) { | ||||
| this.params = params; | this.params = params; | ||||
| this.assertUIElements(); | this.assertUIElements(); | ||||
| this.initializeEventListeners(); | this.initializeEventListeners(); | ||||
| } | } | ||||
| updateBalanceDisplayConfig( | |||||
| primaryBalanceTicker: CryptoTicker, | |||||
| primaryBalanceDecimals: number, | |||||
| ): void { | |||||
| this.params.primaryBalanceTicker = primaryBalanceTicker; | |||||
| this.params.primaryBalanceDecimals = primaryBalanceDecimals; | |||||
| } | |||||
| /** | |||||
| * Rebuild asset picker options from `SUPPORTED_ASSETS`. | |||||
| */ | |||||
| private populateAssetPickerMenu(): void { | |||||
| const { assetPickerMenu: menu } = this.ui; | |||||
| menu.replaceChildren(); | |||||
| for (const def of SUPPORTED_ASSETS) { | |||||
| const opt = document.createElement('button'); | |||||
| opt.type = 'button'; | |||||
| opt.className = 'asset-picker-option'; | |||||
| opt.dataset.asset = def.key; | |||||
| opt.setAttribute('role', 'option'); | |||||
| const assetLabel = `${def.displayName} (${def.ticker})`; | |||||
| const iconUrl = coinIconUrlForAssetKey(def.key); | |||||
| if (iconUrl) { | |||||
| const img = document.createElement('img'); | |||||
| img.src = iconUrl; | |||||
| img.alt = assetLabel; | |||||
| img.className = 'asset-picker-coin-icon'; | |||||
| img.decoding = 'async'; | |||||
| opt.appendChild(img); | |||||
| } | |||||
| const label = document.createElement('span'); | |||||
| label.textContent = assetLabel; | |||||
| opt.appendChild(label); | |||||
| menu.appendChild(opt); | |||||
| } | |||||
| } | |||||
| private setAssetPickerButtonContent(): void { | |||||
| const btn = this.ui.assetPickerBtn; | |||||
| btn.replaceChildren(); | |||||
| const def = activeAssetDefinition(); | |||||
| const assetLabel = `${def.displayName} (${def.ticker})`; | |||||
| const iconUrl = coinIconUrlForAssetKey(def.key); | |||||
| if (!iconUrl) { | |||||
| btn.setAttribute('aria-label', assetLabel); | |||||
| return; | |||||
| } | |||||
| btn.removeAttribute('aria-label'); | |||||
| const img = document.createElement('img'); | |||||
| img.src = iconUrl; | |||||
| img.alt = assetLabel; | |||||
| img.className = 'asset-picker-btn-icon'; | |||||
| img.decoding = 'async'; | |||||
| btn.appendChild(img); | |||||
| } | |||||
| /** | |||||
| * Wire the asset picker dropdown (main screen header). | |||||
| */ | |||||
| initAssetPicker(): void { | |||||
| this.populateAssetPickerMenu(); | |||||
| const { assetPickerBtn: btn, assetPickerMenu: menu } = this.ui; | |||||
| this.setAssetPickerButtonContent(); | |||||
| btn.addEventListener('click', e => { | |||||
| e.stopPropagation(); | |||||
| menu.classList.toggle('open'); | |||||
| }); | |||||
| document.addEventListener('click', () => { | |||||
| menu.classList.remove('open'); | |||||
| }); | |||||
| menu.addEventListener('click', async e => { | |||||
| e.stopPropagation(); | |||||
| const opt = (e.target as HTMLElement).closest( | |||||
| '[data-asset]', | |||||
| ) as HTMLElement | null; | |||||
| if (!opt || !opt.dataset.asset) { | |||||
| return; | |||||
| } | |||||
| menu.classList.remove('open'); | |||||
| const key = opt.dataset.asset; | |||||
| const def = SUPPORTED_ASSETS.find(a => a.key === key); | |||||
| if (!def) { | |||||
| // Should never happen | |||||
| return; | |||||
| } | |||||
| setActiveAsset({ def }); | |||||
| try { | |||||
| await this.applyAssetSwitch(); | |||||
| } catch (error) { | |||||
| webViewError('Failed to switch asset:', error); | |||||
| } | |||||
| }); | |||||
| } | |||||
| private async applyAssetSwitch(): Promise<void> { | |||||
| this.updateBalanceDisplayConfig( | |||||
| activeCryptoTicker(), | |||||
| activeAssetDecimals(), | |||||
| ); | |||||
| this.params.refreshStaticTickerLabels(); | |||||
| this.setAssetPickerButtonContent(); | |||||
| await this.params.onAssetSwitched(); | |||||
| } | |||||
| // Update wallet reference and display (called when wallet is reloaded) | // Update wallet reference and display (called when wallet is reloaded) | ||||
| async updateWallet( | async updateWallet( | ||||
| newWallet: Wallet | null, | newWallet: Wallet | null, | ||||
| availableBalanceSats: number, | availableBalanceSats: number, | ||||
| transitionalBalanceSats: number, | transitionalBalanceSats: number, | ||||
| ): Promise<void> { | ): Promise<void> { | ||||
| this.params.ecashWallet = newWallet; | this.params.ecashWallet = newWallet; | ||||
| if (!newWallet) { | if (!newWallet) { | ||||
| return; | return; | ||||
| } | } | ||||
| this.updateAddressDisplay(); | this.updateAddressDisplay(); | ||||
| const pricePerXec = await this.params.priceFetcher?.current({ | const pricePerXec = allowFiatForActiveAsset() | ||||
| source: CryptoTicker.XEC, | ? await this.params.priceFetcher?.current({ | ||||
| source: activeQuoteCurrency(), | |||||
| quote: this.params.appSettings.fiatCurrency, | quote: this.params.appSettings.fiatCurrency, | ||||
| }); | }) | ||||
| : null; | |||||
| this.updateAvailableBalanceDisplay( | this.updateAvailableBalanceDisplay( | ||||
| 0, | 0, | ||||
| satsToXec(availableBalanceSats), | atomsToUnit(availableBalanceSats, activeAssetDecimals()), | ||||
| pricePerXec, | pricePerXec, | ||||
| false, | false, | ||||
| ); | ); | ||||
| this.updateTransitionalBalance(transitionalBalanceSats, pricePerXec); | this.updateTransitionalBalance(transitionalBalanceSats, pricePerXec); | ||||
| } | } | ||||
| updateAddressDisplay(): void { | updateAddressDisplay(): void { | ||||
| const address = getAddress(this.params.ecashWallet); | const address = getAddress(this.params.ecashWallet); | ||||
| Show All 9 Lines | export class MainScreen { | ||||
| // Update available balance display with optional animation | // Update available balance display with optional animation | ||||
| updateAvailableBalanceDisplay( | updateAvailableBalanceDisplay( | ||||
| fromXec: number, | fromXec: number, | ||||
| toXec: number, | toXec: number, | ||||
| pricePerXec: number | null, | pricePerXec: number | null, | ||||
| animate: boolean = true, | animate: boolean = true, | ||||
| ): void { | ): void { | ||||
| webViewLog( | webViewLog( | ||||
| `Available balance updated from ${fromXec} ${config.ticker} to ${toXec} ${config.ticker}`, | `Available balance updated from ${fromXec} ${activeAssetTicker()} to ${toXec} ${activeAssetTicker()}`, | ||||
| ); | ); | ||||
| this.updateBalanceElement({ | this.updateBalanceElement({ | ||||
| elementId: 'primary-balance', | elementId: 'primary-balance', | ||||
| fromXec, | fromXec, | ||||
| toXec, | toXec, | ||||
| // If primary balance type is XEC or price is not available, show as | // If primary balance type is XEC or price is not available, show as | ||||
| // XEC, otherwise show as Fiat | // XEC, otherwise show as Fiat | ||||
| Show All 23 Lines | updateTransitionalBalance( | ||||
| pricePerXec: number | null, | pricePerXec: number | null, | ||||
| ): void { | ): void { | ||||
| if (transitionalBalanceSats === 0) { | if (transitionalBalanceSats === 0) { | ||||
| this.ui.transitionalBalance.classList.add('hidden'); | this.ui.transitionalBalance.classList.add('hidden'); | ||||
| return; | return; | ||||
| } | } | ||||
| const type = transitionalBalanceSats > 0 ? 'receive' : 'spend'; | const type = transitionalBalanceSats > 0 ? 'receive' : 'spend'; | ||||
| const transitionalXec = satsToXec(transitionalBalanceSats); | const transitionalXec = atomsToUnit( | ||||
| transitionalBalanceSats, | |||||
| activeAssetDecimals(), | |||||
| ); | |||||
| const xecFormatOptions = { | const cryptoFormatOptions = { | ||||
| locale: this.params.appSettings.locale, | locale: this.params.appSettings.locale, | ||||
| decimals: 2, | decimals: this.params.primaryBalanceDecimals, | ||||
| alwaysShowSign: true, | alwaysShowSign: true, | ||||
| }; | }; | ||||
| const fiatFormatOptions = { | const fiatFormatOptions = { | ||||
| locale: this.params.appSettings.locale, | locale: this.params.appSettings.locale, | ||||
| alwaysShowSign: true, | alwaysShowSign: true, | ||||
| }; | }; | ||||
| const displayText = | const displayText = | ||||
| this.params.appSettings.primaryBalanceType === 'XEC' || | this.params.appSettings.primaryBalanceType === 'XEC' || | ||||
| pricePerXec === null | pricePerXec === null | ||||
| ? formatPrice( | ? formatPrice( | ||||
| transitionalXec, | transitionalXec, | ||||
| CryptoTicker.XEC, | this.params.primaryBalanceTicker, | ||||
| xecFormatOptions, | cryptoFormatOptions, | ||||
| ) | ) | ||||
| : formatPrice( | : formatPrice( | ||||
| transitionalXec * pricePerXec, | transitionalXec * pricePerXec, | ||||
| this.params.appSettings.fiatCurrency, | this.params.appSettings.fiatCurrency, | ||||
| fiatFormatOptions, | fiatFormatOptions, | ||||
| ); | ); | ||||
| this.ui.transitionalBalance.textContent = displayText; | this.ui.transitionalBalance.textContent = displayText; | ||||
| Show All 25 Lines | private assertUIElements(): void { | ||||
| scanBtn: document.getElementById('scan-btn') as HTMLButtonElement, | scanBtn: document.getElementById('scan-btn') as HTMLButtonElement, | ||||
| manualEntryBtn: document.getElementById( | manualEntryBtn: document.getElementById( | ||||
| 'manual-entry-btn', | 'manual-entry-btn', | ||||
| ) as HTMLButtonElement, | ) as HTMLButtonElement, | ||||
| closeCameraBtn: document.getElementById( | closeCameraBtn: document.getElementById( | ||||
| 'close-camera', | 'close-camera', | ||||
| ) as HTMLButtonElement, | ) as HTMLButtonElement, | ||||
| cameraModal: document.getElementById('camera-modal') as HTMLElement, | cameraModal: document.getElementById('camera-modal') as HTMLElement, | ||||
| assetPickerBtn: document.getElementById( | |||||
| 'asset-picker-btn', | |||||
| ) as HTMLButtonElement, | |||||
| assetPickerMenu: document.getElementById( | |||||
| 'asset-picker-menu', | |||||
| ) as HTMLElement, | |||||
| }; | }; | ||||
| if ( | if ( | ||||
| !this.ui.address || | !this.ui.address || | ||||
| !this.ui.qrCode || | !this.ui.qrCode || | ||||
| !this.ui.primaryBalance || | !this.ui.primaryBalance || | ||||
| !this.ui.secondaryBalance || | !this.ui.secondaryBalance || | ||||
| !this.ui.transitionalBalance || | !this.ui.transitionalBalance || | ||||
| !this.ui.scanBtn || | !this.ui.scanBtn || | ||||
| !this.ui.manualEntryBtn || | !this.ui.manualEntryBtn || | ||||
| !this.ui.closeCameraBtn || | !this.ui.closeCameraBtn || | ||||
| !this.ui.cameraModal | !this.ui.cameraModal || | ||||
| !this.ui.assetPickerBtn || | |||||
| !this.ui.assetPickerMenu | |||||
| ) { | ) { | ||||
| webViewError('Missing required UI elements for main screen'); | webViewError('Missing required UI elements for main screen'); | ||||
| throw new Error('Missing required UI elements for main screen'); | throw new Error('Missing required UI elements for main screen'); | ||||
| } | } | ||||
| } | } | ||||
| private initializeEventListeners(): void { | private initializeEventListeners(): void { | ||||
| // Address click listener for copying | // Address click listener for copying | ||||
| ▲ Show 20 Lines • Show All 83 Lines • ▼ Show 20 Lines | private updateBalanceElement(params: UpdateBalanceElementParams): void { | ||||
| // If pricePerXec is null show as XEC, otherwise show as Fiat | // If pricePerXec is null show as XEC, otherwise show as Fiat | ||||
| const fromValue = | const fromValue = | ||||
| pricePerXec === null ? fromXec : fromXec * pricePerXec; | pricePerXec === null ? fromXec : fromXec * pricePerXec; | ||||
| const toValue = pricePerXec === null ? toXec : toXec * pricePerXec; | const toValue = pricePerXec === null ? toXec : toXec * pricePerXec; | ||||
| const formatValue = | const formatValue = | ||||
| pricePerXec === null | pricePerXec === null | ||||
| ? (value: number) => { | ? (value: number) => { | ||||
| return formatPrice(value, CryptoTicker.XEC, { | return formatPrice( | ||||
| value, | |||||
| this.params.primaryBalanceTicker, | |||||
| { | |||||
| locale: this.params.appSettings.locale, | locale: this.params.appSettings.locale, | ||||
| decimals: 2, | decimals: this.params.primaryBalanceDecimals, | ||||
| }); | }, | ||||
| ); | |||||
| } | } | ||||
| : (value: number) => { | : (value: number) => { | ||||
| return formatPrice( | return formatPrice( | ||||
| value, | value, | ||||
| this.params.appSettings.fiatCurrency, | this.params.appSettings.fiatCurrency, | ||||
| { locale: this.params.appSettings.locale }, | { locale: this.params.appSettings.locale }, | ||||
| ); | ); | ||||
| }; | }; | ||||
| ▲ Show 20 Lines • Show All 60 Lines • Show Last 20 Lines | |||||