import { useState, useRef, useEffect } from 'react'; import { useParams, Link, useSearchParams } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; import { nip19 } from 'nostr-tools'; import { Layout } from '@/components/Layout'; import { LoginArea } from '@/components/auth/LoginArea'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Skeleton } from '@/components/ui/skeleton'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useToast } from '@/hooks/useToast'; import { useNostrLogin } from '@nostrify/react/login'; import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; import { useConversation, useSendDirectMessage, type DirectMessage } from '@/hooks/useDirectMessages'; import { usePendingTradeConfirmations, useTradeAttestation, useIncomingTradeRequests } from '@/hooks/useTradeAttestations'; import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow'; import { EscrowPanel } from '@/components/EscrowPanel'; import { useDMRead } from '@/hooks/useDMRead'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { formatDistanceToNow } from 'date-fns'; import { ArrowLeft, Send, Lock, Loader2, AlertCircle, RefreshCw, CheckCircle2, Handshake, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { AmberApprovalHint } from '@/components/AmberApprovalHint'; import NotFound from './NotFound'; // Single message bubble function MessageBubble({ message, isFromMe }: { message: DirectMessage; isFromMe: boolean }) { const timeAgo = formatDistanceToNow(new Date(message.createdAt * 1000), { addSuffix: true }); return (

{message.content}

{timeAgo}

); } function MessageSkeleton({ isFromMe }: { isFromMe: boolean }) { return (
); } export default function ConversationPage() { const { npub } = useParams<{ npub: string }>(); const [searchParams] = useSearchParams(); const { user } = useCurrentUser(); const { toast } = useToast(); const { logins } = useNostrLogin(); const messagesEndRef = useRef(null); // Support pre-filled message from URL query param const prefillMessage = searchParams.get('message') || ''; const [newMessage, setNewMessage] = useState(''); // Set pre-filled message when URL param changes useEffect(() => { if (prefillMessage) { setNewMessage(prefillMessage); } }, [prefillMessage]); // Decode npub to get pubkey let otherPubkey: string | null = null; try { if (npub?.startsWith('npub1')) { const decoded = nip19.decode(npub); if (decoded.type === 'npub') { otherPubkey = decoded.data; } } } catch { // Invalid npub } const { data: messages, isLoading, error, refetch, isRefetching } = useConversation(otherPubkey || ''); const { mutate: sendMessage, isPending: isSending } = useSendDirectMessage(); const { data: pendingTrades } = usePendingTradeConfirmations(user?.pubkey); const { data: incomingRequests } = useIncomingTradeRequests(user?.pubkey); const { publishTrade, isPending: confirmingTrade } = useTradeAttestation(); const escrow = useCashuEscrow(); const [isRedeeming, setIsRedeeming] = useState(false); const { markRead } = useDMRead(); const author = useAuthor(otherPubkey || undefined); const metadata = author.data?.metadata; const displayName = metadata?.name ?? (otherPubkey ? genUserName(otherPubkey) : 'Unknown'); const avatar = metadata?.picture; useSeoMeta({ title: `Chat with ${displayName} - SkateSpots`, description: 'Private encrypted conversation', }); // Scroll to bottom when messages change useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); // Viewing a conversation marks it read up to its newest message, so the // unread dot on the inbox clears the moment you open it. useEffect(() => { if (!otherPubkey || !messages || messages.length === 0) return; const newest = messages.reduce((max, m) => Math.max(max, m.createdAt), 0); markRead(otherPubkey, newest); }, [otherPubkey, messages, markRead]); const handleSend = () => { if (!newMessage.trim() || !otherPubkey || isSending) return; sendMessage( { recipients: otherPubkey, content: newMessage.trim() }, { onSuccess: () => { setNewMessage(''); }, onError: (error) => { // Without this a failed send is silent (common with interactive // signers / relay hiccups) and the draft just sits there. toast({ title: 'Message failed to send', description: error instanceof Error ? error.message : 'Please try again in a moment.', variant: 'destructive', }); }, } ); }; // A pending trade the other party recorded but we haven't confirmed yet. const pendingTrade = otherPubkey ? pendingTrades?.find((t) => t.seller === otherPubkey) : undefined; const handleConfirmTrade = async () => { if (!pendingTrade || !otherPubkey) return; try { await publishTrade({ listing: pendingTrade.listing, role: 'bought', counterparty: otherPubkey }); toast({ title: 'Trade confirmed', description: 'Reputation updated for both of you.' }); } catch (e) { toast({ title: 'Could not confirm the trade', description: e instanceof Error ? e.message : 'Something went wrong.', variant: 'destructive', }); } }; // The other party asked us (the seller) to record a sale (kind 31872). const incomingRequest = otherPubkey ? incomingRequests?.find((r) => r.requestor === otherPubkey) : undefined; const handleFulfillRequest = async () => { if (!incomingRequest || !otherPubkey) return; try { await publishTrade({ listing: incomingRequest.listing, role: 'sold', counterparty: otherPubkey, requestCoord: incomingRequest.coord, note: `Sold to ${displayName}.`, }); toast({ title: 'Sale confirmed', description: `${displayName} can now confirm their side.`, }); } catch (e) { toast({ title: 'Could not confirm the sale', description: e instanceof Error ? e.message : 'Something went wrong.', variant: 'destructive', }); } }; // Escrow token attached to the incoming request, if any. const escrowToken = incomingRequest?.event.tags.find(([t]) => t === 'cashu_token')?.[1]; const handleRedeemEscrow = async () => { if (!escrowToken) return; setIsRedeeming(true); try { await escrow.redeemEscrow(escrowToken); toast({ title: 'Escrow redeemed', description: 'The funds are now in your Cashu wallet.', }); } catch (e) { toast({ title: 'Could not redeem escrow', description: e instanceof Error ? e.message : 'Something went wrong.', variant: 'destructive', }); } finally { setIsRedeeming(false); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; // Invalid npub if (!otherPubkey) { return ; } // Not logged in if (!user) { return (

Log in to view messages

); } // Check NIP-44 support if (!checkNip44(user, logins)) { const isAndroid = /Android/i.test(navigator.userAgent); const isMobile = isAndroid || /iPhone|iPad|iPod/i.test(navigator.userAgent); return (

Secure Login Required

Messages require a secure signer app.

{isMobile && isAndroid && ( Get Amber → )}
); } return (
{/* Header */}
{displayName.slice(0, 2).toUpperCase()}

{displayName}

Encrypted

{/* Pending trade confirmation (the seller recorded the sale) */} {pendingTrade && (

Did you complete this trade?

{displayName} recorded the sale. Confirm to update reputation for both of you.

)} {/* Incoming request: the other party asked us (seller) to record the sale */} {incomingRequest && (

Confirm this sale?

{displayName} asked you to record it. Confirming builds reputation for both of you.

{escrowToken && (
{escrow.available ? ( ) : (

Cashu escrow is app-only — open SkateSpots on your phone to redeem.

)}
)}
)} {/* Messages Area */}
{isLoading ? ( <> ) : error ? (

Failed to load messages

) : messages && messages.length > 0 ? ( messages.map((msg) => ( )) ) : (

No messages yet

Send a message to start the conversation

)}
{/* Message Input */}