SkateSpots/src/pages/ConversationPage.tsx
SkateSpots f4192e2f3d fix(ux): Batch A part 1 — dead flows
- SpotDetailPage: 'Claim This Shop' was a no-op button; wire to NIP-05 settings
  (and stop the full-page <a href> reload)
- TourDetailPage: 'Contact Guide to Book' used /messages?to= which nothing read,
  so booking silently opened an empty inbox -> /messages/:npub
- ContactsPage: row 'Message' went to the generic inbox -> the contact's thread
- SettingsPage: ?section=follows|language|stash rendered a blank dead end;
  unknown sections now fall back to the menu
- DirectMessages/Conversation: logged-out states were dead ends -> add LoginArea
- Conversation: failed sends were silent -> error toast
2026-09-26 17:43:19 +07:00

325 lines
12 KiB
TypeScript

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 { 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,
} 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 (
<div className={cn(
"flex",
isFromMe ? "justify-end" : "justify-start"
)}>
<div className={cn(
"max-w-[85%] sm:max-w-[80%] rounded-2xl px-3 py-2 sm:px-4",
isFromMe
? "bg-[#ff0040] text-white rounded-br-sm"
: "bg-gray-800 text-white rounded-bl-sm"
)}>
<p className="whitespace-pre-wrap break-words text-sm sm:text-base">{message.content}</p>
<p className={cn(
"text-[10px] sm:text-xs mt-1",
isFromMe ? "text-white/60" : "text-gray-500"
)}>
{timeAgo}
</p>
</div>
</div>
);
}
function MessageSkeleton({ isFromMe }: { isFromMe: boolean }) {
return (
<div className={cn("flex", isFromMe ? "justify-end" : "justify-start")}>
<Skeleton className={cn(
"rounded-2xl h-16",
isFromMe ? "w-48" : "w-64"
)} />
</div>
);
}
export default function ConversationPage() {
const { npub } = useParams<{ npub: string }>();
const [searchParams] = useSearchParams();
const { user } = useCurrentUser();
const { toast } = useToast();
const { logins } = useNostrLogin();
const messagesEndRef = useRef<HTMLDivElement>(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 { 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',
});
},
}
);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Invalid npub
if (!otherPubkey) {
return <NotFound />;
}
// Not logged in
if (!user) {
return (
<Layout>
<div className="container px-4 py-8">
<Card className="border-dashed border-2 border-gray-700">
<CardContent className="py-16 text-center">
<Lock className="h-16 w-16 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Log in to view messages</h2>
<div className="mt-6 flex justify-center">
<LoginArea className="max-w-60" />
</div>
</CardContent>
</Card>
</div>
</Layout>
);
}
// 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 (
<Layout>
<div className="container px-4 py-8">
<Card className="border-dashed border-2 border-orange-500/30">
<CardContent className="py-12 text-center px-4">
<AlertCircle className="h-12 w-12 mx-auto mb-4 text-orange-500" />
<h2 className="text-lg font-bold mb-2">Secure Login Required</h2>
<p className="text-gray-400 text-sm mb-3">
Messages require a secure signer app.
</p>
{isMobile && isAndroid && (
<a
href="https://github.com/greenart7c3/Amber"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[#ccff00] text-black font-bold text-sm"
>
Get Amber →
</a>
)}
</CardContent>
</Card>
</div>
</Layout>
);
}
return (
<Layout>
<div className="container px-4 py-2 sm:py-4 max-w-2xl h-[calc(100dvh-56px-80px)] sm:h-[calc(100dvh-64px-60px)] md:h-[calc(100vh-64px-60px)] flex flex-col">
{/* Header */}
<div className="flex items-center gap-2 sm:gap-3 pb-3 sm:pb-4 border-b border-gray-800 flex-shrink-0">
<Link to="/messages">
<Button variant="ghost" size="icon" className="text-gray-400 hover:text-white">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<Avatar className="h-8 w-8 sm:h-10 sm:w-10 border-2 border-gray-700 flex-shrink-0">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-xs sm:text-sm">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-bold text-white truncate text-sm sm:text-base">{displayName}</p>
<p className="text-[10px] sm:text-xs text-gray-500 flex items-center gap-1">
<Lock className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
Encrypted
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => refetch()}
disabled={isRefetching}
className="text-gray-400 hover:text-white"
>
<RefreshCw className={cn("h-4 w-4", isRefetching && "animate-spin")} />
</Button>
</div>
{/* Messages Area */}
<div
className="flex-1 overflow-y-auto py-3 sm:py-4 space-y-2 sm:space-y-3 min-h-0"
role="log"
aria-live="polite"
aria-label="Message conversation"
>
{isLoading ? (
<>
<MessageSkeleton isFromMe={false} />
<MessageSkeleton isFromMe={true} />
<MessageSkeleton isFromMe={false} />
</>
) : error ? (
<div className="text-center py-8">
<AlertCircle className="h-12 w-12 mx-auto mb-3 text-red-500" />
<p className="text-gray-400">Failed to load messages</p>
<Button
variant="outline"
onClick={() => refetch()}
className="mt-4 border-gray-700"
>
Try Again
</Button>
</div>
) : messages && messages.length > 0 ? (
messages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
isFromMe={msg.pubkey === user.pubkey}
/>
))
) : (
<div className="text-center py-12">
<p className="text-gray-500">No messages yet</p>
<p className="text-gray-600 text-sm mt-1">
Send a message to start the conversation
</p>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Message Input */}
<div className="pt-3 sm:pt-4 border-t border-gray-800 flex-shrink-0 pb-safe">
<div className="flex gap-2">
<Textarea
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
className="bg-gray-900 border-gray-700 text-white resize-none min-h-[44px] max-h-24 sm:max-h-32 text-sm sm:text-base"
rows={1}
/>
<Button
onClick={handleSend}
disabled={!newMessage.trim() || isSending}
className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white h-[44px] w-[44px] sm:h-auto sm:w-auto p-0 sm:px-4 flex-shrink-0"
>
{isSending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Send className="h-5 w-5" />
)}
</Button>
</div>
<p className="text-[10px] sm:text-xs text-gray-600 mt-1.5 sm:mt-2 text-center hidden sm:block">
Messages are end-to-end encrypted using NIP-17
</p>
{/* 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. */}
<AmberApprovalHint approvals={4} action="send this message" className="mt-1.5 justify-center" />
</div>
</div>
</Layout>
);
}