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:
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: [