mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 16:37:06 +02:00
* feat: add Nostr Wallet Connect (NWC) integration
Add NWC (NIP-47) support to connect Lightning wallets:
- Add NWCConnection type and state management
- Implement custom NWC client service for wallet communication
- Create ConnectWalletDialog for entering connection strings
- Add wallet button to user menu showing balance
- Display wallet info (balance, alias) in user menu dropdown
- Support get_info, get_balance, pay_invoice, make_invoice commands
- Persist wallet connection to localStorage
- Install applesauce-wallet (for future NIP-60 support)
Note: Current implementation uses custom NWC client. Can optionally
switch to @getalby/sdk for official Alby SDK support.
* refactor: use applesauce-wallet-connect and move wallet to header
Replace custom NWC client implementation with applesauce-wallet-connect:
- Install applesauce-wallet-connect for official NIP-47 support
- Create nwc.ts service wrapper for WalletConnect singleton
- Update NWCConnection type to match WalletConnectURI interface
- Use service/relays/secret properties instead of custom names
Move wallet display from user menu to header:
- Create standalone WalletButton component
- Add WalletButton to header next to UserMenu
- Remove wallet UI from user menu dropdown
- Show balance in header with yellow zap icon
- Clicking wallet button opens connect dialog
This provides better UX with wallet status visible in header
and uses the official applesauce implementation for reliability.
* feat: improve wallet button UX and add detailed info dialog
UI improvements:
- Remove border and padding from wallet button (use ghost variant)
- Remove "sats" suffix from balance display (show just the number)
- Change click behavior to show detailed wallet info dialog
Wallet info dialog:
- Show balance prominently without suffix
- Display wallet alias if available
- Show lightning address (lud16) if present
- List all supported NWC methods as badges
- Display connected relay URLs
- Add disconnect button with confirmation toast
This provides a cleaner header appearance and better wallet
management with all details accessible in one place.
* refactor: move wallet functionality to user menu
Move wallet connection and info from separate header button into
the user menu dropdown for better organization.
Changes:
- Remove standalone WalletButton component
- Add wallet section to user menu dropdown
- Show "Connect Wallet" option when no wallet is connected
- Display wallet balance and alias when connected
- Clicking wallet info opens detailed dialog with:
- Balance (without suffix)
- Wallet name/alias
- Lightning address (lud16)
- Supported NWC methods
- Connected relay URLs
- Disconnect button
This consolidates all user-related settings (account, relays,
blossom servers, wallet) in one consistent location.
* feat: improve wallet UX with profile-based naming and better layout
UX improvements:
- Use service pubkey username/profile as wallet name instead of alias
- Remove format hint text from connection string input
- Move wallet to single row directly below user name (remove label)
- Use RelayLink component for relay URLs in wallet info dialog
- Show wallet name from service profile with fallback to alias/pubkey
This provides better integration with Nostr profiles and a cleaner,
more compact menu layout. The wallet service provider's identity is
now shown using their actual Nostr profile name.
* feat: add persistent wallet service with auto-updating balance
Implement comprehensive NWC wallet management architecture:
**Service Layer** (`src/services/nwc.ts`):
- Maintain singleton WalletConnect instance across app lifetime
- Poll balance every 30 seconds for automatic updates
- Subscribe to NIP-47 notifications (kind 23197) for real-time updates
- Expose RxJS observable (balance$) for reactive balance changes
- Restore wallet from saved connection on app startup
- Proper cleanup on disconnect
**Hook Layer** (`src/hooks/useWallet.ts`):
- useWallet() hook exposes wallet throughout the app
- Provides methods: payInvoice, makeInvoice, getBalance, etc.
- Auto-syncs balance updates with Jotai state
- Handles wallet restoration on mount
- Type-safe wrapper around WalletConnect API
**UI Updates**:
- Add refresh button to wallet info dialog
- Auto-update balance display when transactions occur
- Proper cleanup on wallet disconnect
**Architecture**:
```
User Action → useWallet() → NWC Service → WalletConnect
↓ ↓
Update State ← balance$ observable ← Polling/Notifications
```
This enables:
- Real-time balance updates when paying/receiving
- Easy wallet access: `const { payInvoice, balance } = useWallet()`
- Persistent connection across page reloads
- Automatic polling as fallback for notifications
* refactor: make NWC fully reactive with notifications and graceful balance handling
Changes:
- Remove polling mechanism in favor of pure reactive notifications$ observable
- Subscribe to wallet.notifications$ for real-time balance updates
- Make balance display conditional (only show if available)
- Fix TypeScript errors (notification.type access, unused variable)
- Remove Jotai callback mechanism for balance updates
- Use use$() directly for reactive balance subscription
- Update comments to reflect reactive architecture (no polling)
The wallet now updates balance automatically when payments are sent/received
via NIP-47 notifications, with no polling overhead.
* feat: improve wallet UX with profile-based naming and better layout
Improvements to NWC wallet UI:
- Add separator between user info and wallet section in menu
- Show wallet icon instead of zap icon for better clarity
- Display connection status indicator (green/red dot) in both menu and dialog
- Make wallet service username clickable in wallet info dialog to open profile
- Use wallet relays as hints when fetching service profile for better resolution
- Enhanced useProfile hook to accept optional relay hints parameter
The wallet now properly resolves service profiles using the NWC relay
and shows visual connection status at a glance.
* fix: remove toast descriptions for better contrast
---------
Co-authored-by: Claude <noreply@anthropic.com>
92 lines
2.8 KiB
TypeScript
92 lines
2.8 KiB
TypeScript
import { useState, useEffect, useRef } from "react";
|
|
import { profileLoader } from "@/services/loaders";
|
|
import { ProfileContent, getProfileContent } from "applesauce-core/helpers";
|
|
import { kinds } from "nostr-tools";
|
|
import db from "@/services/db";
|
|
|
|
/**
|
|
* Hook to fetch and cache user profile metadata
|
|
*
|
|
* Uses AbortController to prevent race conditions when:
|
|
* - Component unmounts during async operations
|
|
* - Pubkey changes while a fetch is in progress
|
|
*
|
|
* @param pubkey - The user's public key (hex)
|
|
* @param relayHints - Optional relay URLs to try fetching from
|
|
* @returns ProfileContent or undefined if loading/not found
|
|
*/
|
|
export function useProfile(
|
|
pubkey?: string,
|
|
relayHints?: string[],
|
|
): ProfileContent | undefined {
|
|
const [profile, setProfile] = useState<ProfileContent | undefined>();
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!pubkey) {
|
|
setProfile(undefined);
|
|
return;
|
|
}
|
|
|
|
// Abort any in-flight requests from previous effect runs
|
|
abortControllerRef.current?.abort();
|
|
const controller = new AbortController();
|
|
abortControllerRef.current = controller;
|
|
|
|
// Load from IndexedDB first (fast path)
|
|
db.profiles.get(pubkey).then((cachedProfile) => {
|
|
if (controller.signal.aborted) return;
|
|
if (cachedProfile) {
|
|
setProfile(cachedProfile);
|
|
}
|
|
});
|
|
|
|
// Fetch from network with optional relay hints
|
|
const sub = profileLoader({
|
|
kind: kinds.Metadata,
|
|
pubkey,
|
|
...(relayHints && relayHints.length > 0 && { relays: relayHints }),
|
|
}).subscribe({
|
|
next: async (fetchedEvent) => {
|
|
if (controller.signal.aborted) return;
|
|
if (!fetchedEvent || !fetchedEvent.content) return;
|
|
|
|
// Use applesauce helper for safe profile parsing
|
|
const profileData = getProfileContent(fetchedEvent);
|
|
if (!profileData) {
|
|
console.error("[useProfile] Failed to parse profile for:", pubkey);
|
|
return;
|
|
}
|
|
|
|
// Only update state and cache if not aborted
|
|
if (controller.signal.aborted) return;
|
|
|
|
setProfile(profileData);
|
|
|
|
// Save to IndexedDB after state update to avoid blocking UI
|
|
try {
|
|
await db.profiles.put({
|
|
...profileData,
|
|
pubkey,
|
|
created_at: fetchedEvent.created_at,
|
|
});
|
|
} catch (err) {
|
|
// Log but don't throw - cache failure shouldn't break the UI
|
|
console.error("[useProfile] Failed to cache profile:", err);
|
|
}
|
|
},
|
|
error: (err) => {
|
|
if (controller.signal.aborted) return;
|
|
console.error("[useProfile] Error fetching profile:", err);
|
|
},
|
|
});
|
|
|
|
return () => {
|
|
controller.abort();
|
|
sub.unsubscribe();
|
|
};
|
|
}, [pubkey, relayHints]);
|
|
|
|
return profile;
|
|
}
|