mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-05 18:21:28 +02:00
feat: add Nostr Wallet Connect (NWC) integration (#131)
* 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>
This commit is contained in:
176
src/components/ConnectWalletDialog.tsx
Normal file
176
src/components/ConnectWalletDialog.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Wallet, AlertCircle } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { createWalletFromURI } from "@/services/nwc";
|
||||
|
||||
interface ConnectWalletDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ConnectWalletDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ConnectWalletDialogProps) {
|
||||
const [connectionString, setConnectionString] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { setNWCConnection, updateNWCBalance, updateNWCInfo } = useGrimoire();
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setConnectionString("");
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function handleConnect() {
|
||||
if (!connectionString.trim()) {
|
||||
setError("Please enter a connection string");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!connectionString.startsWith("nostr+walletconnect://")) {
|
||||
setError(
|
||||
"Invalid connection string. Must start with nostr+walletconnect://",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Create wallet instance from connection string
|
||||
const wallet = createWalletFromURI(connectionString);
|
||||
|
||||
// Test the connection by getting wallet info
|
||||
const info = await wallet.getInfo();
|
||||
|
||||
// Get initial balance
|
||||
let balance: number | undefined;
|
||||
try {
|
||||
const balanceResult = await wallet.getBalance();
|
||||
balance = balanceResult.balance;
|
||||
} catch (err) {
|
||||
console.warn("[NWC] Failed to get balance:", err);
|
||||
// Balance is optional, continue anyway
|
||||
}
|
||||
|
||||
// Get connection details from the wallet instance
|
||||
const serialized = wallet.toJSON();
|
||||
|
||||
// Save connection to state
|
||||
setNWCConnection({
|
||||
service: serialized.service,
|
||||
relays: serialized.relays,
|
||||
secret: serialized.secret,
|
||||
lud16: serialized.lud16,
|
||||
balance,
|
||||
info: {
|
||||
alias: info.alias,
|
||||
methods: info.methods,
|
||||
notifications: info.notifications,
|
||||
},
|
||||
});
|
||||
|
||||
// Update balance if we got it
|
||||
if (balance !== undefined) {
|
||||
updateNWCBalance(balance);
|
||||
}
|
||||
|
||||
// Update info
|
||||
updateNWCInfo({
|
||||
alias: info.alias,
|
||||
methods: info.methods,
|
||||
notifications: info.notifications,
|
||||
});
|
||||
|
||||
// Show success toast
|
||||
toast.success("Wallet Connected");
|
||||
|
||||
// Close dialog
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
console.error("Wallet connection error:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to connect wallet");
|
||||
toast.error("Failed to connect wallet");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect Wallet</DialogTitle>
|
||||
<DialogDescription>
|
||||
Connect to a Nostr Wallet Connect (NWC) enabled Lightning wallet
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your wallet connection string. You can get this from your
|
||||
wallet provider (Alby, Mutiny, etc.)
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="connection-string"
|
||||
className="text-sm font-medium leading-none"
|
||||
>
|
||||
Connection String
|
||||
</label>
|
||||
<Input
|
||||
id="connection-string"
|
||||
placeholder="nostr+walletconnect://..."
|
||||
value={connectionString}
|
||||
onChange={(e) => setConnectionString(e.target.value)}
|
||||
disabled={loading}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={loading || !connectionString.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wallet className="mr-2 size-4" />
|
||||
Connect Wallet
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { User, HardDrive, Palette } from "lucide-react";
|
||||
import { User, HardDrive, Palette, Wallet, X, RefreshCw } from "lucide-react";
|
||||
import accounts from "@/services/accounts";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
@@ -17,13 +17,23 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import Nip05 from "./nip05";
|
||||
import { RelayLink } from "./RelayLink";
|
||||
import SettingsDialog from "@/components/SettingsDialog";
|
||||
import LoginDialog from "./LoginDialog";
|
||||
import ConnectWalletDialog from "@/components/ConnectWalletDialog";
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "@/lib/themes";
|
||||
import { toast } from "sonner";
|
||||
import { useWallet } from "@/hooks/useWallet";
|
||||
|
||||
function UserAvatar({ pubkey }: { pubkey: string }) {
|
||||
const profile = useProfile(pubkey);
|
||||
@@ -56,13 +66,30 @@ function UserLabel({ pubkey }: { pubkey: string }) {
|
||||
|
||||
export default function UserMenu() {
|
||||
const account = use$(accounts.active$);
|
||||
const { state, addWindow } = useGrimoire();
|
||||
const { state, addWindow, disconnectNWC } = useGrimoire();
|
||||
const relays = state.activeAccount?.relays;
|
||||
const blossomServers = state.activeAccount?.blossomServers;
|
||||
const nwcConnection = state.nwcConnection;
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [showConnectWallet, setShowConnectWallet] = useState(false);
|
||||
const [showWalletInfo, setShowWalletInfo] = useState(false);
|
||||
const { themeId, setTheme, availableThemes } = useTheme();
|
||||
|
||||
// Get wallet service profile for display name, using wallet relays as hints
|
||||
const walletServiceProfile = useProfile(
|
||||
nwcConnection?.service,
|
||||
nwcConnection?.relays,
|
||||
);
|
||||
|
||||
// Use wallet hook for real-time balance and methods
|
||||
const {
|
||||
disconnect: disconnectWallet,
|
||||
refreshBalance,
|
||||
balance,
|
||||
wallet,
|
||||
} = useWallet();
|
||||
|
||||
function openProfile() {
|
||||
if (!account?.pubkey) return;
|
||||
addWindow(
|
||||
@@ -77,10 +104,182 @@ export default function UserMenu() {
|
||||
accounts.removeAccount(account);
|
||||
}
|
||||
|
||||
function formatBalance(millisats?: number): string {
|
||||
if (millisats === undefined) return "—";
|
||||
const sats = Math.floor(millisats / 1000);
|
||||
return sats.toLocaleString();
|
||||
}
|
||||
|
||||
function handleDisconnectWallet() {
|
||||
// Disconnect from NWC service (stops notifications, clears wallet instance)
|
||||
disconnectWallet();
|
||||
// Clear connection from state
|
||||
disconnectNWC();
|
||||
setShowWalletInfo(false);
|
||||
toast.success("Wallet disconnected");
|
||||
}
|
||||
|
||||
async function handleRefreshBalance() {
|
||||
try {
|
||||
await refreshBalance();
|
||||
toast.success("Balance refreshed");
|
||||
} catch (_error) {
|
||||
toast.error("Failed to refresh balance");
|
||||
}
|
||||
}
|
||||
|
||||
function getWalletName(): string {
|
||||
if (!nwcConnection) return "";
|
||||
// Use service pubkey profile name, fallback to alias, then pubkey slice
|
||||
return (
|
||||
getDisplayName(nwcConnection.service, walletServiceProfile) ||
|
||||
nwcConnection.info?.alias ||
|
||||
nwcConnection.service.slice(0, 8)
|
||||
);
|
||||
}
|
||||
|
||||
function openWalletServiceProfile() {
|
||||
if (!nwcConnection?.service) return;
|
||||
addWindow(
|
||||
"profile",
|
||||
{ pubkey: nwcConnection.service },
|
||||
`Profile ${nwcConnection.service.slice(0, 8)}...`,
|
||||
);
|
||||
setShowWalletInfo(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsDialog open={showSettings} onOpenChange={setShowSettings} />
|
||||
<LoginDialog open={showLogin} onOpenChange={setShowLogin} />
|
||||
<ConnectWalletDialog
|
||||
open={showConnectWallet}
|
||||
onOpenChange={setShowConnectWallet}
|
||||
/>
|
||||
|
||||
{/* Wallet Info Dialog */}
|
||||
{nwcConnection && (
|
||||
<Dialog open={showWalletInfo} onOpenChange={setShowWalletInfo}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Wallet Info</DialogTitle>
|
||||
<DialogDescription>
|
||||
Connected Lightning wallet details
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Balance */}
|
||||
{(balance !== undefined ||
|
||||
nwcConnection.balance !== undefined) && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Balance:
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold">
|
||||
{formatBalance(balance ?? nwcConnection.balance)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleRefreshBalance}
|
||||
title="Refresh balance"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wallet Name */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Wallet:</span>
|
||||
<button
|
||||
onClick={openWalletServiceProfile}
|
||||
className="text-sm font-medium hover:underline cursor-crosshair text-primary"
|
||||
>
|
||||
{getWalletName()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Connection Status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Status:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`size-2 rounded-full ${
|
||||
wallet ? "bg-green-500" : "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{wallet ? "Connected" : "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lightning Address */}
|
||||
{nwcConnection.lud16 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Address:
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
{nwcConnection.lud16}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supported Methods */}
|
||||
{nwcConnection.info?.methods &&
|
||||
nwcConnection.info.methods.length > 0 && (
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Supported Methods:
|
||||
</span>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{nwcConnection.info.methods.map((method) => (
|
||||
<span
|
||||
key={method}
|
||||
className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium"
|
||||
>
|
||||
{method}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Relays */}
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Relays:</span>
|
||||
<div className="mt-2 space-y-1">
|
||||
{nwcConnection.relays.map((relay) => (
|
||||
<RelayLink
|
||||
key={relay}
|
||||
url={relay}
|
||||
className="py-1"
|
||||
urlClassname="text-xs"
|
||||
iconClassname="size-3.5"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disconnect Button */}
|
||||
<Button
|
||||
onClick={handleDisconnectWallet}
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
>
|
||||
<X className="mr-2 size-4" />
|
||||
Disconnect Wallet
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -107,6 +306,44 @@ export default function UserMenu() {
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Wallet Section */}
|
||||
{nwcConnection ? (
|
||||
<DropdownMenuItem
|
||||
className="cursor-crosshair flex items-center justify-between"
|
||||
onClick={() => setShowWalletInfo(true)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wallet className="size-4 text-muted-foreground" />
|
||||
{balance !== undefined ||
|
||||
nwcConnection.balance !== undefined ? (
|
||||
<span className="text-sm">
|
||||
{formatBalance(balance ?? nwcConnection.balance)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`size-1.5 rounded-full ${
|
||||
wallet ? "bg-green-500" : "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getWalletName()}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="cursor-crosshair"
|
||||
onClick={() => setShowConnectWallet(true)}
|
||||
>
|
||||
<Wallet className="size-4 text-muted-foreground mr-2" />
|
||||
<span className="text-sm">Connect Wallet</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{relays && relays.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
WindowInstance,
|
||||
RelayInfo,
|
||||
LayoutConfig,
|
||||
NWCConnection,
|
||||
} from "@/types/app";
|
||||
import { insertWindow } from "@/lib/layout-utils";
|
||||
import { applyPresetToLayout, type LayoutPreset } from "@/lib/layout-presets";
|
||||
@@ -526,3 +527,69 @@ export const clearActiveSpellbook = (state: GrimoireState): GrimoireState => {
|
||||
activeSpellbook: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets or updates the NWC (Nostr Wallet Connect) connection.
|
||||
*/
|
||||
export const setNWCConnection = (
|
||||
state: GrimoireState,
|
||||
connection: NWCConnection,
|
||||
): GrimoireState => {
|
||||
return {
|
||||
...state,
|
||||
nwcConnection: {
|
||||
...connection,
|
||||
lastConnected: Date.now(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the balance of the current NWC connection.
|
||||
*/
|
||||
export const updateNWCBalance = (
|
||||
state: GrimoireState,
|
||||
balance: number,
|
||||
): GrimoireState => {
|
||||
if (!state.nwcConnection) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
nwcConnection: {
|
||||
...state.nwcConnection,
|
||||
balance,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the info of the current NWC connection.
|
||||
*/
|
||||
export const updateNWCInfo = (
|
||||
state: GrimoireState,
|
||||
info: NWCConnection["info"],
|
||||
): GrimoireState => {
|
||||
if (!state.nwcConnection) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
nwcConnection: {
|
||||
...state.nwcConnection,
|
||||
info,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Disconnects and clears the current NWC connection.
|
||||
*/
|
||||
export const disconnectNWC = (state: GrimoireState): GrimoireState => {
|
||||
return {
|
||||
...state,
|
||||
nwcConnection: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -345,6 +345,31 @@ export const useGrimoire = () => {
|
||||
dispatch({ type: "DISCARD_TEMP" });
|
||||
}, [dispatch]);
|
||||
|
||||
const setNWCConnection = useCallback(
|
||||
(connection: any) => {
|
||||
setState((prev) => Logic.setNWCConnection(prev, connection));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const updateNWCBalance = useCallback(
|
||||
(balance: number) => {
|
||||
setState((prev) => Logic.updateNWCBalance(prev, balance));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const updateNWCInfo = useCallback(
|
||||
(info: any) => {
|
||||
setState((prev) => Logic.updateNWCInfo(prev, info));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const disconnectNWC = useCallback(() => {
|
||||
setState((prev) => Logic.disconnectNWC(prev));
|
||||
}, [setState]);
|
||||
|
||||
return {
|
||||
state,
|
||||
isTemporary,
|
||||
@@ -371,5 +396,9 @@ export const useGrimoire = () => {
|
||||
switchToTemporary,
|
||||
applyTemporaryToPersistent,
|
||||
discardTemporary,
|
||||
setNWCConnection,
|
||||
updateNWCBalance,
|
||||
updateNWCInfo,
|
||||
disconnectNWC,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -12,9 +12,13 @@ import db from "@/services/db";
|
||||
* - 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): ProfileContent | undefined {
|
||||
export function useProfile(
|
||||
pubkey?: string,
|
||||
relayHints?: string[],
|
||||
): ProfileContent | undefined {
|
||||
const [profile, setProfile] = useState<ProfileContent | undefined>();
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
@@ -37,8 +41,12 @@ export function useProfile(pubkey?: string): ProfileContent | undefined {
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch from network
|
||||
const sub = profileLoader({ kind: kinds.Metadata, pubkey }).subscribe({
|
||||
// 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;
|
||||
@@ -77,7 +85,7 @@ export function useProfile(pubkey?: string): ProfileContent | undefined {
|
||||
controller.abort();
|
||||
sub.unsubscribe();
|
||||
};
|
||||
}, [pubkey]);
|
||||
}, [pubkey, relayHints]);
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
149
src/hooks/useWallet.ts
Normal file
149
src/hooks/useWallet.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* useWallet Hook
|
||||
*
|
||||
* Provides access to the NWC wallet throughout the application.
|
||||
* Fully reactive using observables - balance updates automatically via use$()
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { wallet, balance, payInvoice, makeInvoice } = useWallet();
|
||||
*
|
||||
* async function handlePay() {
|
||||
* if (!wallet) return;
|
||||
* await payInvoice("lnbc...");
|
||||
* // Balance automatically updates via notifications!
|
||||
* }
|
||||
*
|
||||
* return <div>Balance: {balance ? Math.floor(balance / 1000) : 0} sats</div>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import {
|
||||
getWallet,
|
||||
restoreWallet,
|
||||
clearWallet as clearWalletService,
|
||||
refreshBalance as refreshBalanceService,
|
||||
balance$,
|
||||
} from "@/services/nwc";
|
||||
import type { WalletConnect } from "applesauce-wallet-connect";
|
||||
|
||||
export function useWallet() {
|
||||
const { state } = useGrimoire();
|
||||
const nwcConnection = state.nwcConnection;
|
||||
const [wallet, setWallet] = useState<WalletConnect | null>(getWallet());
|
||||
|
||||
// Subscribe to balance updates from observable (fully reactive!)
|
||||
const balance = use$(balance$);
|
||||
|
||||
// Initialize wallet on mount if connection exists but no wallet instance
|
||||
useEffect(() => {
|
||||
if (nwcConnection && !wallet) {
|
||||
console.log("[useWallet] Restoring wallet from saved connection");
|
||||
const restoredWallet = restoreWallet(nwcConnection);
|
||||
setWallet(restoredWallet);
|
||||
|
||||
// Fetch initial balance
|
||||
refreshBalanceService();
|
||||
}
|
||||
}, [nwcConnection, wallet]);
|
||||
|
||||
// Update local wallet ref when connection changes
|
||||
useEffect(() => {
|
||||
const currentWallet = getWallet();
|
||||
if (currentWallet !== wallet) {
|
||||
setWallet(currentWallet);
|
||||
}
|
||||
}, [nwcConnection, wallet]);
|
||||
|
||||
/**
|
||||
* Pay a BOLT11 invoice
|
||||
* Balance will auto-update via notification subscription
|
||||
*/
|
||||
async function payInvoice(invoice: string, amount?: number) {
|
||||
if (!wallet) throw new Error("No wallet connected");
|
||||
|
||||
const result = await wallet.payInvoice(invoice, amount);
|
||||
|
||||
// Balance will update automatically via notifications
|
||||
// But we can also refresh immediately for instant feedback
|
||||
await refreshBalanceService();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new invoice
|
||||
*/
|
||||
async function makeInvoice(
|
||||
amount: number,
|
||||
options?: {
|
||||
description?: string;
|
||||
description_hash?: string;
|
||||
expiry?: number;
|
||||
},
|
||||
) {
|
||||
if (!wallet) throw new Error("No wallet connected");
|
||||
|
||||
return await wallet.makeInvoice(amount, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wallet info (capabilities, alias, etc.)
|
||||
*/
|
||||
async function getInfo() {
|
||||
if (!wallet) throw new Error("No wallet connected");
|
||||
|
||||
return await wallet.getInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current balance
|
||||
*/
|
||||
async function getBalance() {
|
||||
if (!wallet) throw new Error("No wallet connected");
|
||||
|
||||
const result = await wallet.getBalance();
|
||||
return result.balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually refresh the balance
|
||||
*/
|
||||
async function refreshBalance() {
|
||||
return await refreshBalanceService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the wallet
|
||||
*/
|
||||
function disconnect() {
|
||||
clearWalletService();
|
||||
setWallet(null);
|
||||
}
|
||||
|
||||
return {
|
||||
/** The wallet instance (null if not connected) */
|
||||
wallet,
|
||||
/** Current balance in millisats (auto-updates via observable!) */
|
||||
balance,
|
||||
/** Whether a wallet is connected */
|
||||
isConnected: !!wallet,
|
||||
/** Pay a BOLT11 invoice */
|
||||
payInvoice,
|
||||
/** Generate a new invoice */
|
||||
makeInvoice,
|
||||
/** Get wallet information */
|
||||
getInfo,
|
||||
/** Get current balance */
|
||||
getBalance,
|
||||
/** Manually refresh balance */
|
||||
refreshBalance,
|
||||
/** Disconnect wallet */
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
150
src/services/nwc.ts
Normal file
150
src/services/nwc.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* NWC (Nostr Wallet Connect) Service
|
||||
*
|
||||
* Provides a singleton WalletConnect instance for the application using
|
||||
* applesauce-wallet-connect for NIP-47 Lightning wallet integration.
|
||||
*
|
||||
* Features:
|
||||
* - Maintains persistent wallet connection across app lifetime
|
||||
* - Subscribes to NIP-47 notifications (kind 23197) for balance updates
|
||||
* - Fully reactive using RxJS observables (no polling!)
|
||||
* - Components use use$() to reactively subscribe to balance changes
|
||||
*/
|
||||
|
||||
import { WalletConnect } from "applesauce-wallet-connect";
|
||||
import type { NWCConnection } from "@/types/app";
|
||||
import pool from "./relay-pool";
|
||||
import { BehaviorSubject, Subscription } from "rxjs";
|
||||
|
||||
// Set the pool for wallet connect to use
|
||||
WalletConnect.pool = pool;
|
||||
|
||||
let walletInstance: WalletConnect | null = null;
|
||||
let notificationSubscription: Subscription | null = null;
|
||||
|
||||
/**
|
||||
* Observable for wallet balance updates
|
||||
* Components can subscribe to this for real-time balance changes using use$()
|
||||
*/
|
||||
export const balance$ = new BehaviorSubject<number | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Helper to convert hex string to Uint8Array
|
||||
*/
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to wallet notifications (NIP-47 kind 23197)
|
||||
* This enables real-time balance updates when transactions occur
|
||||
*/
|
||||
function subscribeToNotifications(wallet: WalletConnect) {
|
||||
// Clean up existing subscription
|
||||
if (notificationSubscription) {
|
||||
notificationSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
console.log("[NWC] Subscribing to wallet notifications");
|
||||
|
||||
// Subscribe to the wallet's notifications$ observable
|
||||
// This receives events like payment_received, payment_sent, etc.
|
||||
notificationSubscription = wallet.notifications$.subscribe({
|
||||
next: (notification) => {
|
||||
console.log("[NWC] Notification received:", notification);
|
||||
|
||||
// When we get a notification, refresh the balance
|
||||
// The notification types include: payment_received, payment_sent, etc.
|
||||
wallet
|
||||
.getBalance()
|
||||
.then((result) => {
|
||||
const newBalance = result.balance;
|
||||
if (balance$.value !== newBalance) {
|
||||
balance$.next(newBalance);
|
||||
console.log("[NWC] Balance updated from notification:", newBalance);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"[NWC] Failed to fetch balance after notification:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
console.error("[NWC] Notification subscription error:", error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new WalletConnect instance from a connection string
|
||||
* Automatically subscribes to notifications for balance updates
|
||||
*/
|
||||
export function createWalletFromURI(connectionString: string): WalletConnect {
|
||||
walletInstance = WalletConnect.fromConnectURI(connectionString);
|
||||
subscribeToNotifications(walletInstance);
|
||||
return walletInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a wallet from saved connection data
|
||||
* Used on app startup to reconnect to a previously connected wallet
|
||||
*/
|
||||
export function restoreWallet(connection: NWCConnection): WalletConnect {
|
||||
walletInstance = new WalletConnect({
|
||||
service: connection.service,
|
||||
relays: connection.relays,
|
||||
secret: hexToBytes(connection.secret),
|
||||
});
|
||||
|
||||
// Set initial balance from cache
|
||||
if (connection.balance !== undefined) {
|
||||
balance$.next(connection.balance);
|
||||
}
|
||||
|
||||
subscribeToNotifications(walletInstance);
|
||||
return walletInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current wallet instance
|
||||
*/
|
||||
export function getWallet(): WalletConnect | null {
|
||||
return walletInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the current wallet instance and stops notifications
|
||||
*/
|
||||
export function clearWallet(): void {
|
||||
if (notificationSubscription) {
|
||||
notificationSubscription.unsubscribe();
|
||||
notificationSubscription = null;
|
||||
}
|
||||
walletInstance = null;
|
||||
balance$.next(undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually refresh the balance from the wallet
|
||||
* Useful for initial load or manual refresh button
|
||||
*/
|
||||
export async function refreshBalance(): Promise<number | undefined> {
|
||||
if (!walletInstance) return undefined;
|
||||
|
||||
try {
|
||||
const result = await walletInstance.getBalance();
|
||||
const newBalance = result.balance;
|
||||
|
||||
balance$.next(newBalance);
|
||||
return newBalance;
|
||||
} catch (error) {
|
||||
console.error("[NWC] Failed to refresh balance:", error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,30 @@ export interface RelayInfo {
|
||||
write: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nostr Wallet Connect (NIP-47) wallet connection
|
||||
*/
|
||||
export interface NWCConnection {
|
||||
/** The wallet service's public key */
|
||||
service: string;
|
||||
/** Relay URL(s) for communication */
|
||||
relays: string[];
|
||||
/** Shared secret for encryption */
|
||||
secret: string;
|
||||
/** Optional lightning address (lud16) */
|
||||
lud16?: string;
|
||||
/** Optional cached balance in millisats */
|
||||
balance?: number;
|
||||
/** Optional wallet info */
|
||||
info?: {
|
||||
alias?: string;
|
||||
methods?: string[];
|
||||
notifications?: string[];
|
||||
};
|
||||
/** Last connection time */
|
||||
lastConnected?: number;
|
||||
}
|
||||
|
||||
export interface GrimoireState {
|
||||
__version: number; // Schema version for migrations
|
||||
windows: Record<string, WindowInstance>;
|
||||
@@ -110,4 +134,5 @@ export interface GrimoireState {
|
||||
localId?: string; // Local DB ID if saved to library
|
||||
isPublished?: boolean; // Whether it has been published to Nostr
|
||||
};
|
||||
nwcConnection?: NWCConnection;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user