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 (
);
}
// 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()}
{/* 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 */}
Messages are end-to-end encrypted using NIP-17
{/* A 1:1 NIP-17 DM encrypts + signs to the recipient AND to yourself
(so you keep a copy) = up to 4 signer approvals. Warn Amber users. */}
);
}