mirror of
https://gitlab.com/soapbox-pub/mkstack.git
synced 2025-08-27 04:59:22 +00:00
wallet configuration settings + nwc baseline support
This commit is contained in:
parent
55bf545646
commit
14eccbcb6f
268
src/components/WalletModal.tsx
Normal file
268
src/components/WalletModal.tsx
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Wallet, Plus, Trash2, Zap, Globe, Settings, CheckCircle } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { useNWC } from '@/hooks/useNWC';
|
||||||
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
|
||||||
|
interface WalletModalProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WalletModal({ children, className }: WalletModalProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||||
|
const [connectionUri, setConnectionUri] = useState('');
|
||||||
|
const [alias, setAlias] = useState('');
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
connections,
|
||||||
|
activeConnection,
|
||||||
|
connectionInfo,
|
||||||
|
addConnection,
|
||||||
|
removeConnection,
|
||||||
|
setActiveConnection
|
||||||
|
} = useNWC();
|
||||||
|
|
||||||
|
const { hasWebLN, hasNWC, isDetecting } = useWallet();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleAddConnection = async () => {
|
||||||
|
if (!connectionUri.trim()) {
|
||||||
|
toast({
|
||||||
|
title: 'Connection URI required',
|
||||||
|
description: 'Please enter a valid NWC connection URI.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConnecting(true);
|
||||||
|
try {
|
||||||
|
const success = await addConnection(connectionUri.trim(), alias.trim() || undefined);
|
||||||
|
if (success) {
|
||||||
|
setConnectionUri('');
|
||||||
|
setAlias('');
|
||||||
|
setAddDialogOpen(false);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsConnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveConnection = (walletPubkey: string) => {
|
||||||
|
removeConnection(walletPubkey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetActive = (walletPubkey: string) => {
|
||||||
|
setActiveConnection(walletPubkey);
|
||||||
|
toast({
|
||||||
|
title: 'Active wallet changed',
|
||||||
|
description: 'The selected wallet is now active for zaps.',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{children || (
|
||||||
|
<Button variant="outline" size="sm" className={className}>
|
||||||
|
<Wallet className="h-4 w-4 mr-2" />
|
||||||
|
Wallet Settings
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[500px] max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Wallet className="h-5 w-5" />
|
||||||
|
Lightning Wallet
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Connect your lightning wallet to send zaps instantly.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Current Status */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-medium">Current Status</h3>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{/* WebLN */}
|
||||||
|
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">WebLN</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Browser extension</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasWebLN && <CheckCircle className="h-4 w-4 text-green-600" />}
|
||||||
|
<Badge variant={hasWebLN ? "default" : "secondary"} className="text-xs">
|
||||||
|
{isDetecting ? "..." : hasWebLN ? "Ready" : "Not Found"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* NWC */}
|
||||||
|
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Nostr Wallet Connect</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{connections.length > 0
|
||||||
|
? `${connections.length} wallet${connections.length !== 1 ? 's' : ''} connected`
|
||||||
|
: "Remote wallet connection"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasNWC && <CheckCircle className="h-4 w-4 text-green-600" />}
|
||||||
|
<Badge variant={hasNWC ? "default" : "secondary"} className="text-xs">
|
||||||
|
{hasNWC ? "Ready" : "None"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* NWC Management */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-medium">Nostr Wallet Connect</h3>
|
||||||
|
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Connect NWC Wallet</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter your connection string from a compatible wallet.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="alias">Wallet Name (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="alias"
|
||||||
|
placeholder="My Lightning Wallet"
|
||||||
|
value={alias}
|
||||||
|
onChange={(e) => setAlias(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="connection-uri">Connection URI</Label>
|
||||||
|
<Textarea
|
||||||
|
id="connection-uri"
|
||||||
|
placeholder="nostr+walletconnect://..."
|
||||||
|
value={connectionUri}
|
||||||
|
onChange={(e) => setConnectionUri(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddConnection}
|
||||||
|
disabled={isConnecting || !connectionUri.trim()}
|
||||||
|
>
|
||||||
|
{isConnecting ? 'Connecting...' : 'Connect'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
{/* Connected Wallets List */}
|
||||||
|
{connections.length === 0 ? (
|
||||||
|
<div className="text-center py-6 text-muted-foreground">
|
||||||
|
<p className="text-sm">No wallets connected</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{connections.map((connection) => {
|
||||||
|
const info = connectionInfo[connection.walletPubkey];
|
||||||
|
const isActive = activeConnection === connection.walletPubkey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={connection.walletPubkey} className={`flex items-center justify-between p-3 border rounded-lg ${isActive ? 'ring-2 ring-primary' : ''}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{connection.alias || info?.alias || 'Lightning Wallet'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{connection.walletPubkey.slice(0, 16)}...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isActive && <CheckCircle className="h-4 w-4 text-green-600" />}
|
||||||
|
{!isActive && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleSetActive(connection.walletPubkey)}
|
||||||
|
>
|
||||||
|
<Zap className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleRemoveConnection(connection.walletPubkey)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Help */}
|
||||||
|
{!hasWebLN && connections.length === 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="text-center py-4 space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Install a WebLN extension (like Alby) or connect an NWC wallet for instant zaps.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Zap, Copy, Sparkle, Sparkles, Star, Rocket } from 'lucide-react';
|
import { Zap, Copy, Sparkle, Sparkles, Star, Rocket, Wallet, Globe } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -17,8 +17,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { useZaps } from '@/hooks/useZaps';
|
import { useZaps } from '@/hooks/useZaps';
|
||||||
import type { WebLNProvider } from 'webln';
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
import { requestProvider } from 'webln';
|
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import type { Event } from 'nostr-tools';
|
import type { Event } from 'nostr-tools';
|
||||||
|
|
||||||
@ -38,11 +37,11 @@ const presetAmounts = [
|
|||||||
|
|
||||||
export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [webln, setWebln] = useState<WebLNProvider | null>(null);
|
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { data: author } = useAuthor(target.pubkey);
|
const { data: author } = useAuthor(target.pubkey);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { zap, isZapping, invoice, setInvoice } = useZaps(target, webln, () => setOpen(false));
|
const { webln, activeNWC, hasWebLN, hasNWC, detectWebLN } = useWallet();
|
||||||
|
const { zap, isZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false));
|
||||||
const [amount, setAmount] = useState<number | string>(100);
|
const [amount, setAmount] = useState<number | string>(100);
|
||||||
const [comment, setComment] = useState<string>('');
|
const [comment, setComment] = useState<string>('');
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -56,20 +55,10 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
|||||||
|
|
||||||
// Detect WebLN when dialog opens
|
// Detect WebLN when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const detectWebLN = async () => {
|
if (open && !hasWebLN) {
|
||||||
if (open && !webln) {
|
detectWebLN();
|
||||||
try {
|
}
|
||||||
const provider = await requestProvider();
|
}, [open, hasWebLN, detectWebLN]);
|
||||||
setWebln(provider);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('WebLN requestProvider failed:', error);
|
|
||||||
setWebln(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
detectWebLN();
|
|
||||||
}, [open, webln]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (invoice && qrCodeRef.current) {
|
if (invoice && qrCodeRef.current) {
|
||||||
@ -136,6 +125,28 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* Payment Method Indicator */}
|
||||||
|
<div className="flex items-center justify-center py-2 px-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
{hasNWC ? (
|
||||||
|
<>
|
||||||
|
<Wallet className="h-4 w-4 text-green-600" />
|
||||||
|
<span>Wallet Connected</span>
|
||||||
|
</>
|
||||||
|
) : hasWebLN ? (
|
||||||
|
<>
|
||||||
|
<Globe className="h-4 w-4 text-blue-600" />
|
||||||
|
<span>WebLN Available</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
<span>Manual Payment</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
type="single"
|
type="single"
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
// NOTE: This file is stable and usually should not be modified.
|
// NOTE: This file is stable and usually should not be modified.
|
||||||
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
|
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
|
||||||
|
|
||||||
import { ChevronDown, LogOut, UserIcon, UserPlus } from 'lucide-react';
|
import { ChevronDown, LogOut, UserIcon, UserPlus, Wallet } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu.tsx';
|
} from '@/components/ui/dropdown-menu.tsx';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx';
|
||||||
import { RelaySelector } from '@/components/RelaySelector';
|
import { RelaySelector } from '@/components/RelaySelector';
|
||||||
|
import { WalletModal } from '@/components/WalletModal';
|
||||||
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
|
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
|
||||||
@ -63,6 +64,15 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
<WalletModal>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className='flex items-center gap-2 cursor-pointer p-2 rounded-md'
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<Wallet className='w-4 h-4' />
|
||||||
|
<span>Wallet Settings</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</WalletModal>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={onAddAccountClick}
|
onClick={onAddAccountClick}
|
||||||
className='flex items-center gap-2 cursor-pointer p-2 rounded-md'
|
className='flex items-center gap-2 cursor-pointer p-2 rounded-md'
|
||||||
|
300
src/hooks/useNWC.ts
Normal file
300
src/hooks/useNWC.ts
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { nip04 } from 'nostr-tools';
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
|
||||||
|
export interface NWCConnection {
|
||||||
|
walletPubkey: string;
|
||||||
|
secret: string;
|
||||||
|
relayUrls: string[];
|
||||||
|
lud16?: string;
|
||||||
|
alias?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NWCInfo {
|
||||||
|
alias?: string;
|
||||||
|
color?: string;
|
||||||
|
pubkey?: string;
|
||||||
|
network?: string;
|
||||||
|
methods?: string[];
|
||||||
|
notifications?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNWC() {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const [connections, setConnections] = useLocalStorage<NWCConnection[]>('nwc-connections', []);
|
||||||
|
const [activeConnection, setActiveConnection] = useLocalStorage<string | null>('nwc-active-connection', null);
|
||||||
|
const [connectionInfo, setConnectionInfo] = useState<Record<string, NWCInfo>>({});
|
||||||
|
|
||||||
|
// Parse NWC URI
|
||||||
|
const parseNWCUri = (uri: string): NWCConnection | null => {
|
||||||
|
try {
|
||||||
|
const url = new URL(uri);
|
||||||
|
if (url.protocol !== 'nostr+walletconnect:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const walletPubkey = url.pathname.replace('//', '');
|
||||||
|
const secret = url.searchParams.get('secret');
|
||||||
|
const relayParam = url.searchParams.getAll('relay');
|
||||||
|
const lud16 = url.searchParams.get('lud16') || undefined;
|
||||||
|
|
||||||
|
if (!walletPubkey || !secret || relayParam.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
walletPubkey,
|
||||||
|
secret,
|
||||||
|
relayUrls: relayParam,
|
||||||
|
lud16,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add new connection
|
||||||
|
const addConnection = async (uri: string, alias?: string): Promise<boolean> => {
|
||||||
|
const connection = parseNWCUri(uri);
|
||||||
|
if (!connection) {
|
||||||
|
toast({
|
||||||
|
title: 'Invalid NWC URI',
|
||||||
|
description: 'Please check the connection string and try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if connection already exists
|
||||||
|
const existingConnection = connections.find(c => c.walletPubkey === connection.walletPubkey);
|
||||||
|
if (existingConnection) {
|
||||||
|
toast({
|
||||||
|
title: 'Connection already exists',
|
||||||
|
description: 'This wallet is already connected.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alias) {
|
||||||
|
connection.alias = alias;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test connection by fetching info
|
||||||
|
await fetchWalletInfo(connection);
|
||||||
|
|
||||||
|
setConnections(prev => [...prev, connection]);
|
||||||
|
|
||||||
|
// Set as active if it's the first connection
|
||||||
|
if (connections.length === 0) {
|
||||||
|
setActiveConnection(connection.walletPubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Wallet connected',
|
||||||
|
description: `Successfully connected to ${alias || 'wallet'}.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Connection failed',
|
||||||
|
description: 'Could not connect to the wallet. Please check your connection.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove connection
|
||||||
|
const removeConnection = (walletPubkey: string) => {
|
||||||
|
setConnections(prev => prev.filter(c => c.walletPubkey !== walletPubkey));
|
||||||
|
|
||||||
|
if (activeConnection === walletPubkey) {
|
||||||
|
const remaining = connections.filter(c => c.walletPubkey !== walletPubkey);
|
||||||
|
setActiveConnection(remaining.length > 0 ? remaining[0].walletPubkey : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnectionInfo(prev => {
|
||||||
|
const newInfo = { ...prev };
|
||||||
|
delete newInfo[walletPubkey];
|
||||||
|
return newInfo;
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Wallet disconnected',
|
||||||
|
description: 'The wallet connection has been removed.',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get active connection
|
||||||
|
const getActiveConnection = (): NWCConnection | null => {
|
||||||
|
if (!activeConnection) return null;
|
||||||
|
return connections.find(c => c.walletPubkey === activeConnection) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send NWC request
|
||||||
|
const sendNWCRequest = useCallback(async (
|
||||||
|
connection: NWCConnection,
|
||||||
|
request: { method: string; params: Record<string, unknown> }
|
||||||
|
): Promise<{ result_type: string; error?: { code: string; message: string }; result?: unknown }> => {
|
||||||
|
if (!user?.signer) {
|
||||||
|
throw new Error('User not logged in or signer not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create request event
|
||||||
|
const requestEvent = {
|
||||||
|
kind: 23194,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [['p', connection.walletPubkey]],
|
||||||
|
content: await nip04.encrypt(connection.secret, connection.walletPubkey, JSON.stringify(request)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sign and publish request
|
||||||
|
const signedRequest = await user.signer.signEvent(requestEvent);
|
||||||
|
if (!signedRequest) {
|
||||||
|
throw new Error('Failed to sign NWC request');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish to NWC relays
|
||||||
|
try {
|
||||||
|
await nostr.event(signedRequest, {
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
relays: connection.relayUrls
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to publish NWC request:', error);
|
||||||
|
throw new Error('Failed to publish NWC request');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for response
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error('NWC request timeout'));
|
||||||
|
}, 30000); // 30 second timeout
|
||||||
|
|
||||||
|
// Query for response events
|
||||||
|
const checkForResponse = async () => {
|
||||||
|
try {
|
||||||
|
const responseEvents = await nostr.query([
|
||||||
|
{
|
||||||
|
kinds: [23195],
|
||||||
|
authors: [connection.walletPubkey],
|
||||||
|
'#p': [user.pubkey],
|
||||||
|
'#e': [signedRequest.id],
|
||||||
|
since: Math.floor(Date.now() / 1000) - 60,
|
||||||
|
},
|
||||||
|
], { signal: AbortSignal.timeout(30000) });
|
||||||
|
|
||||||
|
for (const event of responseEvents) {
|
||||||
|
try {
|
||||||
|
const decrypted = await nip04.decrypt(
|
||||||
|
connection.secret,
|
||||||
|
connection.walletPubkey,
|
||||||
|
event.content
|
||||||
|
);
|
||||||
|
const response = JSON.parse(decrypted);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(response);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to decrypt NWC response:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no response found, wait and try again
|
||||||
|
setTimeout(checkForResponse, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start checking for responses
|
||||||
|
setTimeout(checkForResponse, 1000); // Wait 1 second before first check
|
||||||
|
});
|
||||||
|
}, [nostr, user]);
|
||||||
|
|
||||||
|
// Fetch wallet info
|
||||||
|
const fetchWalletInfo = useCallback(async (connection: NWCConnection): Promise<NWCInfo> => {
|
||||||
|
// First, try to get the info event (kind 13194)
|
||||||
|
try {
|
||||||
|
const infoEvents = await nostr.query([
|
||||||
|
{
|
||||||
|
kinds: [13194],
|
||||||
|
authors: [connection.walletPubkey],
|
||||||
|
limit: 1,
|
||||||
|
}
|
||||||
|
], { signal: AbortSignal.timeout(5000) });
|
||||||
|
|
||||||
|
if (infoEvents.length > 0) {
|
||||||
|
const infoEvent = infoEvents[0];
|
||||||
|
const capabilities = infoEvent.content.split(' ');
|
||||||
|
const notificationsTag = infoEvent.tags.find(tag => tag[0] === 'notifications');
|
||||||
|
const notifications = notificationsTag ? notificationsTag[1].split(' ') : [];
|
||||||
|
|
||||||
|
const info: NWCInfo = {
|
||||||
|
methods: capabilities,
|
||||||
|
notifications,
|
||||||
|
};
|
||||||
|
|
||||||
|
setConnectionInfo(prev => ({
|
||||||
|
...prev,
|
||||||
|
[connection.walletPubkey]: info,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch NWC info event:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try to send a get_info request
|
||||||
|
try {
|
||||||
|
const response = await sendNWCRequest(connection, { method: 'get_info', params: {} });
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
throw new Error(response.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = response.result as NWCInfo;
|
||||||
|
setConnectionInfo(prev => ({
|
||||||
|
...prev,
|
||||||
|
[connection.walletPubkey]: info,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return info;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch wallet info:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [nostr, sendNWCRequest]);
|
||||||
|
|
||||||
|
// Fetch info for all connections on mount
|
||||||
|
useEffect(() => {
|
||||||
|
connections.forEach(connection => {
|
||||||
|
if (!connectionInfo[connection.walletPubkey]) {
|
||||||
|
fetchWalletInfo(connection).catch(console.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [connections, connectionInfo, fetchWalletInfo]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
connections,
|
||||||
|
activeConnection,
|
||||||
|
connectionInfo,
|
||||||
|
addConnection,
|
||||||
|
removeConnection,
|
||||||
|
setActiveConnection,
|
||||||
|
getActiveConnection,
|
||||||
|
fetchWalletInfo,
|
||||||
|
sendNWCRequest,
|
||||||
|
parseNWCUri,
|
||||||
|
};
|
||||||
|
}
|
79
src/hooks/useWallet.ts
Normal file
79
src/hooks/useWallet.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useNWC } from '@/hooks/useNWC';
|
||||||
|
import type { WebLNProvider } from 'webln';
|
||||||
|
import { requestProvider } from 'webln';
|
||||||
|
|
||||||
|
export interface WalletStatus {
|
||||||
|
hasWebLN: boolean;
|
||||||
|
hasNWC: boolean;
|
||||||
|
webln: WebLNProvider | null;
|
||||||
|
activeNWC: ReturnType<typeof useNWC>['getActiveConnection'] extends () => infer T ? T : null;
|
||||||
|
isDetecting: boolean;
|
||||||
|
preferredMethod: 'nwc' | 'webln' | 'manual';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWallet() {
|
||||||
|
const [webln, setWebln] = useState<WebLNProvider | null>(null);
|
||||||
|
const [isDetecting, setIsDetecting] = useState(false);
|
||||||
|
const { getActiveConnection } = useNWC();
|
||||||
|
|
||||||
|
const activeNWC = getActiveConnection();
|
||||||
|
|
||||||
|
// Detect WebLN
|
||||||
|
const detectWebLN = useCallback(async () => {
|
||||||
|
if (webln || isDetecting) return webln;
|
||||||
|
|
||||||
|
setIsDetecting(true);
|
||||||
|
try {
|
||||||
|
const provider = await requestProvider();
|
||||||
|
setWebln(provider);
|
||||||
|
return provider;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('WebLN not available:', error);
|
||||||
|
setWebln(null);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsDetecting(false);
|
||||||
|
}
|
||||||
|
}, [webln, isDetecting]);
|
||||||
|
|
||||||
|
// Auto-detect on mount
|
||||||
|
useEffect(() => {
|
||||||
|
detectWebLN();
|
||||||
|
}, [detectWebLN]);
|
||||||
|
|
||||||
|
// Test WebLN connection
|
||||||
|
const testWebLN = useCallback(async (): Promise<boolean> => {
|
||||||
|
if (!webln) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await webln.enable();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WebLN test failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [webln]);
|
||||||
|
|
||||||
|
// Determine preferred payment method
|
||||||
|
const preferredMethod: WalletStatus['preferredMethod'] = activeNWC
|
||||||
|
? 'nwc'
|
||||||
|
: webln
|
||||||
|
? 'webln'
|
||||||
|
: 'manual';
|
||||||
|
|
||||||
|
const status: WalletStatus = {
|
||||||
|
hasWebLN: !!webln,
|
||||||
|
hasNWC: !!activeNWC,
|
||||||
|
webln,
|
||||||
|
activeNWC,
|
||||||
|
isDetecting,
|
||||||
|
preferredMethod,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...status,
|
||||||
|
detectWebLN,
|
||||||
|
testWebLN,
|
||||||
|
};
|
||||||
|
}
|
@ -3,21 +3,51 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useAppContext } from '@/hooks/useAppContext';
|
import { useAppContext } from '@/hooks/useAppContext';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useNWC } from '@/hooks/useNWC';
|
||||||
|
import type { NWCConnection } from '@/hooks/useNWC';
|
||||||
import { nip57, nip19 } from 'nostr-tools';
|
import { nip57, nip19 } from 'nostr-tools';
|
||||||
import type { Event } from 'nostr-tools';
|
import type { Event } from 'nostr-tools';
|
||||||
import type { WebLNProvider } from 'webln';
|
import type { WebLNProvider } from 'webln';
|
||||||
import { LNURL } from '@nostrify/nostrify/ln';
|
import { LNURL } from '@nostrify/nostrify/ln';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNostr } from '@nostrify/react';
|
import { useNostr } from '@nostrify/react';
|
||||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||||
|
|
||||||
export function useZaps(target: Event, webln: WebLNProvider | null, onZapSuccess?: () => void) {
|
// NWC utility functions
|
||||||
|
function parseNWCUri(uri: string): NWCConnection | null {
|
||||||
|
try {
|
||||||
|
const url = new URL(uri);
|
||||||
|
if (url.protocol !== 'nostr+walletconnect:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const walletPubkey = url.pathname.replace('//', '');
|
||||||
|
const secret = url.searchParams.get('secret');
|
||||||
|
const relayParam = url.searchParams.getAll('relay');
|
||||||
|
const lud16 = url.searchParams.get('lud16') || undefined;
|
||||||
|
|
||||||
|
if (!walletPubkey || !secret || relayParam.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
walletPubkey,
|
||||||
|
secret,
|
||||||
|
relayUrls: relayParam,
|
||||||
|
lud16,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useZaps(target: Event, webln: WebLNProvider | null, nwcConnection: NWCConnection | null, onZapSuccess?: () => void) {
|
||||||
const { nostr } = useNostr();
|
const { nostr } = useNostr();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { config } = useAppContext();
|
const { config } = useAppContext();
|
||||||
const author = useAuthor(target?.pubkey);
|
const author = useAuthor(target?.pubkey);
|
||||||
|
const { sendNWCRequest } = useNWC();
|
||||||
const [isZapping, setIsZapping] = useState(false);
|
const [isZapping, setIsZapping] = useState(false);
|
||||||
const [invoice, setInvoice] = useState<string | null>(null);
|
const [invoice, setInvoice] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -86,7 +116,6 @@ export function useZaps(target: Event, webln: WebLNProvider | null, onZapSuccess
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
if (!author.data || !author.data?.metadata) {
|
if (!author.data || !author.data?.metadata) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Author not found',
|
title: 'Author not found',
|
||||||
@ -123,6 +152,38 @@ export function useZaps(target: Event, webln: WebLNProvider | null, onZapSuccess
|
|||||||
nostr: zapRequest,
|
nostr: zapRequest,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Try NWC first if available
|
||||||
|
if (nwcConnection) {
|
||||||
|
try {
|
||||||
|
const response = await sendNWCRequest(nwcConnection, {
|
||||||
|
method: 'pay_invoice',
|
||||||
|
params: {
|
||||||
|
invoice: newInvoice,
|
||||||
|
amount: zapAmount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
throw new Error(`NWC Error: ${response.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Zap successful!',
|
||||||
|
description: `You sent ${amount} sats via NWC to the author.`,
|
||||||
|
});
|
||||||
|
onZapSuccess?.();
|
||||||
|
return;
|
||||||
|
} catch (nwcError) {
|
||||||
|
console.error('NWC payment failed, falling back:', nwcError);
|
||||||
|
toast({
|
||||||
|
title: 'NWC payment failed',
|
||||||
|
description: 'Falling back to manual payment...',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to WebLN or manual payment
|
||||||
if (webln) {
|
if (webln) {
|
||||||
await webln.sendPayment(newInvoice);
|
await webln.sendPayment(newInvoice);
|
||||||
toast({
|
toast({
|
||||||
@ -145,5 +206,13 @@ export function useZaps(target: Event, webln: WebLNProvider | null, onZapSuccess
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { zaps, ...query, zap, isZapping, invoice, setInvoice };
|
return {
|
||||||
|
zaps,
|
||||||
|
...query,
|
||||||
|
zap,
|
||||||
|
isZapping,
|
||||||
|
invoice,
|
||||||
|
setInvoice,
|
||||||
|
parseNWCUri,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user