feat: add tap-to-blur privacy feature for wallet balances (#143)

* feat: add tap-to-blur privacy feature for wallet balances

Implement privacy toggle for wallet balances and transaction amounts.
Tapping any balance display toggles a global blur effect across all
wallet UIs. Persisted to localStorage for consistent privacy.

- Add walletBalancesBlurred state to GrimoireState
- Add toggleWalletBalancesBlur pure function in core/logic
- Make big balance in WalletViewer clickable with eye icon indicator
- Apply blur to all transaction amounts in list and detail views
- Add blur to send/receive dialog amounts
- Make balance in user menu wallet info clickable with eye icon
- Apply blur to balance in dropdown menu item

UX matches common financial app pattern: tap balance → blur on/off

* refactor: replace blur with fixed-width placeholders for privacy

Prevent balance size information leakage by using fixed-width
placeholder characters instead of blur effect. A blurred "1000000"
would still reveal it's a large balance vs "100" even when blurred.

Changes:
- Replace blur-sm class with conditional placeholder text
- Use "••••••" for main balances
- Use "••••" for transaction amounts in lists
- Use "•••••• sats" for detailed amounts with unit
- Use "•••• sats" for smaller amounts like fees

Security improvement: No information about balance size is leaked
when privacy mode is enabled. All hidden amounts appear identical.

* refactor: improve privacy UX with stars and clearer send flow

Three UX improvements to the wallet privacy feature:

1. Don't hide amounts in send confirmation dialog
   - Users need to verify invoice amounts before sending
   - Privacy mode now only affects viewing, not sending

2. Replace bullet placeholders (••••) with stars (✦✦✦✦)
   - More visually distinct and recognizable as privacy indicator
   - Unicode BLACK FOUR POINTED STAR (U+2726)
   - Better matches common "redacted" aesthetic

3. Reduce eye icon sizes for subtler presence
   - Main balance: size-6 → size-5
   - Wallet info dialog: size-3.5 → size-3
   - Smaller icons feel less intrusive

Result: Clearer privacy state, safer payment flow, better aesthetics.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-18 23:22:00 +01:00
committed by GitHub
parent 72fb47224c
commit 9f6e524ea9
5 changed files with 76 additions and 13 deletions

View File

@@ -20,6 +20,8 @@ import {
LogOut,
ChevronDown,
ChevronRight,
Eye,
EyeOff,
} from "lucide-react";
import { Virtuoso } from "react-virtuoso";
import { useWallet } from "@/hooks/useWallet";
@@ -341,7 +343,11 @@ function TransactionLabel({ transaction }: { transaction: Transaction }) {
}
export default function WalletViewer() {
const { state, disconnectNWC: disconnectNWCFromState } = useGrimoire();
const {
state,
disconnectNWC: disconnectNWCFromState,
toggleWalletBalancesBlur,
} = useGrimoire();
const {
wallet,
balance,
@@ -1053,9 +1059,20 @@ export default function WalletViewer() {
{/* Big Centered Balance */}
<div className="py-4 flex flex-col items-center justify-center">
<div className="text-4xl font-bold font-mono">
{formatSats(balance)}
</div>
<button
onClick={toggleWalletBalancesBlur}
className="text-4xl font-bold font-mono hover:opacity-70 transition-opacity cursor-pointer flex items-center gap-3"
title="Click to toggle privacy blur"
>
<span>
{state.walletBalancesBlurred ? "✦✦✦✦✦✦" : formatSats(balance)}
</span>
{state.walletBalancesBlurred ? (
<EyeOff className="size-5 text-muted-foreground" />
) : (
<Eye className="size-5 text-muted-foreground" />
)}
</button>
</div>
{/* Send / Receive Buttons */}
@@ -1145,7 +1162,9 @@ export default function WalletViewer() {
</div>
<div className="flex-shrink-0 ml-4">
<p className="text-sm font-semibold font-mono">
{formatSats(tx.amount)}
{state.walletBalancesBlurred
? "✦✦✦✦"
: formatSats(tx.amount)}
</p>
</div>
</div>
@@ -1233,7 +1252,9 @@ export default function WalletViewer() {
: "Sent"}
</p>
<p className="text-2xl font-bold font-mono">
{formatSats(selectedTransaction.amount)} sats
{state.walletBalancesBlurred
? "✦✦✦✦✦✦ sats"
: `${formatSats(selectedTransaction.amount)} sats`}
</p>
</div>
</div>
@@ -1267,7 +1288,9 @@ export default function WalletViewer() {
Fees Paid
</Label>
<p className="text-sm font-mono">
{formatSats(selectedTransaction.fees_paid)} sats
{state.walletBalancesBlurred
? "✦✦✦✦ sats"
: `${formatSats(selectedTransaction.fees_paid)} sats`}
</p>
</div>
)}

View File

@@ -1,4 +1,13 @@
import { User, HardDrive, Palette, Wallet, X, RefreshCw } from "lucide-react";
import {
User,
HardDrive,
Palette,
Wallet,
X,
RefreshCw,
Eye,
EyeOff,
} from "lucide-react";
import accounts from "@/services/accounts";
import { useProfile } from "@/hooks/useProfile";
import { use$ } from "applesauce-react/hooks";
@@ -66,7 +75,8 @@ function UserLabel({ pubkey }: { pubkey: string }) {
export default function UserMenu() {
const account = use$(accounts.active$);
const { state, addWindow, disconnectNWC } = useGrimoire();
const { state, addWindow, disconnectNWC, toggleWalletBalancesBlur } =
useGrimoire();
const relays = state.activeAccount?.relays;
const blossomServers = state.activeAccount?.blossomServers;
const nwcConnection = state.nwcConnection;
@@ -182,9 +192,22 @@ export default function UserMenu() {
Balance:
</span>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">
{formatBalance(balance ?? nwcConnection.balance)}
</span>
<button
onClick={toggleWalletBalancesBlur}
className="text-lg font-semibold hover:opacity-70 transition-opacity cursor-pointer flex items-center gap-1.5"
title="Click to toggle privacy blur"
>
<span>
{state.walletBalancesBlurred
? "✦✦✦✦✦✦"
: formatBalance(balance ?? nwcConnection.balance)}
</span>
{state.walletBalancesBlurred ? (
<EyeOff className="size-3 text-muted-foreground" />
) : (
<Eye className="size-3 text-muted-foreground" />
)}
</button>
<Button
size="sm"
variant="ghost"
@@ -326,7 +349,9 @@ export default function UserMenu() {
{balance !== undefined ||
nwcConnection.balance !== undefined ? (
<span className="text-sm">
{formatBalance(balance ?? nwcConnection.balance)}
{state.walletBalancesBlurred
? "✦✦✦✦"
: formatBalance(balance ?? nwcConnection.balance)}
</span>
) : null}
</div>

View File

@@ -593,3 +593,12 @@ export const disconnectNWC = (state: GrimoireState): GrimoireState => {
nwcConnection: undefined,
};
};
export const toggleWalletBalancesBlur = (
state: GrimoireState,
): GrimoireState => {
return {
...state,
walletBalancesBlurred: !state.walletBalancesBlurred,
};
};

View File

@@ -370,6 +370,10 @@ export const useGrimoire = () => {
setState((prev) => Logic.disconnectNWC(prev));
}, [setState]);
const toggleWalletBalancesBlur = useCallback(() => {
setState((prev) => Logic.toggleWalletBalancesBlur(prev));
}, [setState]);
return {
state,
isTemporary,
@@ -400,5 +404,6 @@ export const useGrimoire = () => {
updateNWCBalance,
updateNWCInfo,
disconnectNWC,
toggleWalletBalancesBlur,
};
};

View File

@@ -137,4 +137,5 @@ export interface GrimoireState {
isPublished?: boolean; // Whether it has been published to Nostr
};
nwcConnection?: NWCConnection;
walletBalancesBlurred?: boolean; // Privacy: blur balances and transaction amounts
}