taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit b0a3542a8f1e368c55293d1ec271e016ed75ded1
parent e2396eb8f3652595070e6a8fd7b8dd563cd3be1e
Author: Florian Dold <dold@taler.net>
Date:   Wed, 26 Aug 2026 02:12:26 +0200

wallet web UI: remember selected balance scope

Diffstat:
Apackages/wallet-webui/src/platform/balance-preference.ts | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/wallet-webui/src/routes/App.tsx | 34+++++++++++++++++++++++++++++-----
Mpackages/wallet-webui/src/routes/balance-model.ts | 12++++++++++++
Mpackages/wallet-webui/test/balance-model.test.ts | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 208 insertions(+), 5 deletions(-)

diff --git a/packages/wallet-webui/src/platform/balance-preference.ts b/packages/wallet-webui/src/platform/balance-preference.ts @@ -0,0 +1,75 @@ +import { Amounts } from "@gnu-taler/taler-util"; + +export const BALANCE_SELECTION_PREFERENCE_KEY = + "taler-wallet-webui-balance-selection"; + +export interface BalanceSelectionPreference { + scopeId: string; + currency: string; +} + +export interface BalanceSelectionPreferenceStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +function preferenceStorage(): BalanceSelectionPreferenceStorage | undefined { + try { + return typeof window === "undefined" ? undefined : window.localStorage; + } catch { + return undefined; + } +} + +export function readBalanceSelectionPreference( + storage: BalanceSelectionPreferenceStorage | undefined = preferenceStorage(), +): BalanceSelectionPreference | undefined { + if (!storage) return undefined; + try { + const stored = storage.getItem(BALANCE_SELECTION_PREFERENCE_KEY); + if (!stored) return undefined; + const parsed: unknown = JSON.parse(stored); + if (typeof parsed === "object" && parsed !== null) { + const { scopeId, currency: rawCurrency } = parsed as Record< + string, + unknown + >; + const currency = + typeof rawCurrency === "string" + ? Amounts.normalizeCurrency(rawCurrency) + : undefined; + if (typeof scopeId === "string" && scopeId && currency) { + return { scopeId, currency }; + } + } + storage.removeItem(BALANCE_SELECTION_PREFERENCE_KEY); + } catch { + // Storage can be unavailable in privacy-restricted browser contexts. + } + return undefined; +} + +export function writeBalanceSelectionPreference( + preference: BalanceSelectionPreference, + storage: BalanceSelectionPreferenceStorage | undefined = preferenceStorage(), +): BalanceSelectionPreference | undefined { + const currency = Amounts.normalizeCurrency(preference.currency); + const normalized = + preference.scopeId && currency + ? { scopeId: preference.scopeId, currency } + : undefined; + try { + if (normalized) { + storage?.setItem( + BALANCE_SELECTION_PREFERENCE_KEY, + JSON.stringify(normalized), + ); + } else { + storage?.removeItem(BALANCE_SELECTION_PREFERENCE_KEY); + } + } catch { + // Selecting a balance remains possible even when persistence is blocked. + } + return normalized; +} diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx @@ -161,7 +161,15 @@ import { type ImportDumpSummary, } from "./import-model.js"; import { tokenListView } from "./token-model.js"; -import { balancesToView, balanceTrendView } from "./balance-model.js"; +import { + balancesToView, + balanceTrendView, + selectedBalanceView, +} from "./balance-model.js"; +import { + readBalanceSelectionPreference, + writeBalanceSelectionPreference, +} from "../platform/balance-preference.js"; import { scopeFromIdentity, scopeIdentity, @@ -634,9 +642,25 @@ function BalanceRoute() { const query = useWalletQuery(connection, WalletApiOperation.GetBalances, {}); const view = balancesToView(query.data); const [selectedScopeId, setSelectedScopeId] = useState<string>(); - const selectedBalance = - view.balances?.find((balance) => balance.scopeId === selectedScopeId) ?? - view.balances?.[0]; + const [preferredBalance, setPreferredBalance] = useState( + readBalanceSelectionPreference, + ); + const selectedBalance = selectedBalanceView( + view.balances, + selectedScopeId ?? preferredBalance?.scopeId, + preferredBalance?.currency, + ); + const selectScope = (scopeId: string) => { + setSelectedScopeId(scopeId); + const currency = view.balances?.find( + (balance) => balance.scopeId === scopeId, + )?.currency; + if (currency) { + setPreferredBalance( + writeBalanceSelectionPreference({ scopeId, currency }), + ); + } + }; const activitySupported = selectedBalance?.scopeInfo.type === ScopeType.Global || selectedBalance?.scopeInfo.type === ScopeType.Exchange; @@ -753,7 +777,7 @@ function BalanceRoute() { ) : undefined } - onSelectScope={setSelectedScopeId} + onSelectScope={selectScope} onWithdraw={withdraw} onGetDemoCash={() => void getDemoCash()} gettingDemoCash={gettingDemoCash} diff --git a/packages/wallet-webui/src/routes/balance-model.ts b/packages/wallet-webui/src/routes/balance-model.ts @@ -211,3 +211,15 @@ export function balancesToView(response: BalancesResponse | undefined): { ), }; } + +export function selectedBalanceView( + balances: BalanceView[] | undefined, + selectedScopeId: string | undefined, + preferredCurrency: string | undefined, +): BalanceView | undefined { + return ( + balances?.find((balance) => balance.scopeId === selectedScopeId) ?? + balances?.find((balance) => balance.currency === preferredCurrency) ?? + balances?.[0] + ); +} diff --git a/packages/wallet-webui/test/balance-model.test.ts b/packages/wallet-webui/test/balance-model.test.ts @@ -10,7 +10,30 @@ import { import { balancesToView, balanceTrendView, + selectedBalanceView, } from "../src/routes/balance-model.js"; +import { + BALANCE_SELECTION_PREFERENCE_KEY, + readBalanceSelectionPreference, + writeBalanceSelectionPreference, + type BalanceSelectionPreferenceStorage, +} from "../src/platform/balance-preference.js"; + +class MemoryStorage implements BalanceSelectionPreferenceStorage { + readonly values = new Map<string, string>(); + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } + + removeItem(key: string): void { + this.values.delete(key); + } +} test("balance model exposes shopping, capability flags, and sorted Donau summaries", () => { const response = { @@ -66,6 +89,75 @@ test("balance model exposes shopping, capability flags, and sorted Donau summari assert.equal(view.donauSummaries[0].legalDomain, "Bern"); }); +test("balance selection restores the exact scope after a reload", () => { + const balances = balancesToView({ + balances: [ + { + scopeInfo: { type: ScopeType.Global, currency: "CHF" }, + available: "CHF:5", + pendingIncoming: "CHF:0", + pendingOutgoing: "CHF:0", + flags: [], + }, + { + scopeInfo: { + type: ScopeType.Exchange, + currency: "EUR", + url: "https://exchange-one.example/", + }, + available: "EUR:10", + pendingIncoming: "EUR:0", + pendingOutgoing: "EUR:0", + flags: [], + }, + { + scopeInfo: { + type: ScopeType.Exchange, + currency: "EUR", + url: "https://exchange-two.example/", + }, + available: "EUR:20", + pendingIncoming: "EUR:0", + pendingOutgoing: "EUR:0", + flags: [], + }, + ], + haveProdBalance: true, + }).balances; + const storage = new MemoryStorage(); + const selectedScopeId = balances?.[2]?.scopeId; + assert(selectedScopeId); + + writeBalanceSelectionPreference( + { scopeId: selectedScopeId, currency: "eur" }, + storage, + ); + const preference = readBalanceSelectionPreference(storage); + const afterReload = selectedBalanceView( + balances, + preference?.scopeId, + preference?.currency, + ); + + assert.deepEqual( + JSON.parse(storage.getItem(BALANCE_SELECTION_PREFERENCE_KEY) ?? "null"), + { scopeId: selectedScopeId, currency: "EUR" }, + ); + assert.equal(afterReload?.scopeId, selectedScopeId); + assert.equal( + selectedBalanceView( + balances?.slice(0, 2), + preference?.scopeId, + preference?.currency, + )?.scopeId, + balances?.[1]?.scopeId, + ); + assert.equal( + selectedBalanceView(balances, "missing", "USD")?.currency, + "CHF", + ); +}); + test("balance trend walks completed scoped transaction effects backwards", () => { const balance = balancesToView({ balances: [