- New src/lib/dateFnsI18n.ts re-exports date-fns with formatDistanceToNow /
formatDistanceToNowStrict / formatDistance / format defaulting to the active
locale, plus dateLocaleCode() for native toLocale{Date,Time}String calls.
- LanguageProvider keeps the locale in sync during render, so the first paint is
already in-language.
- Codemod: 11 date-fns imports now use the shim; ~39 native date/time calls pass
the app locale code instead of undefined/[]/hardcoded 'en-US'. Number
.toLocaleString() calls are deliberately untouched.
479 lines
18 KiB
TypeScript
479 lines
18 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 { 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 { useTranslation } from '@/contexts/language';
|
|
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 '@/lib/dateFnsI18n';
|
|
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 (
|
|
<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 { t } = useTranslation();
|
|
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 { 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) : t('socialUi.conversation.unknown'));
|
|
const avatar = metadata?.picture;
|
|
|
|
useSeoMeta({
|
|
title: t('socialUi.conversation.seoTitle', { name: displayName }),
|
|
description: t('socialUi.conversation.seoDescription'),
|
|
});
|
|
|
|
// 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: t('socialUi.conversation.sendFailed'),
|
|
description: error instanceof Error ? error.message : t('socialUi.conversation.sendFailedDesc'),
|
|
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: t('socialUi.conversation.tradeConfirmed'), description: t('socialUi.conversation.tradeConfirmedDesc') });
|
|
} catch (e) {
|
|
toast({
|
|
title: t('socialUi.conversation.tradeConfirmFailed'),
|
|
description: e instanceof Error ? e.message : t('socialUi.conversation.somethingWrong'),
|
|
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: t('socialUi.conversation.soldNote', { name: displayName }),
|
|
});
|
|
toast({
|
|
title: t('socialUi.conversation.saleConfirmed'),
|
|
description: t('socialUi.conversation.saleConfirmedDesc', { name: displayName }),
|
|
});
|
|
} catch (e) {
|
|
toast({
|
|
title: t('socialUi.conversation.saleConfirmFailed'),
|
|
description: e instanceof Error ? e.message : t('socialUi.conversation.somethingWrong'),
|
|
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: t('socialUi.conversation.escrowRedeemed'),
|
|
description: t('socialUi.conversation.escrowRedeemedDesc'),
|
|
});
|
|
} catch (e) {
|
|
toast({
|
|
title: t('socialUi.conversation.escrowRedeemFailed'),
|
|
description: e instanceof Error ? e.message : t('socialUi.conversation.somethingWrong'),
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setIsRedeeming(false);
|
|
}
|
|
};
|
|
|
|
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 (
|
|
<>
|
|
<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">{t('socialUi.conversation.loginTitle')}</h2>
|
|
<div className="mt-6 flex justify-center">
|
|
<LoginArea className="max-w-60" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<>
|
|
<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">{t('socialUi.conversation.secureLoginTitle')}</h2>
|
|
<p className="text-gray-400 text-sm mb-3">
|
|
{t('socialUi.conversation.secureLoginDesc')}
|
|
</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"
|
|
>
|
|
{t('socialUi.dms.getAmber')}
|
|
</a>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<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" aria-label={t('common.back')} 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" />
|
|
{t('socialUi.conversation.encrypted')}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => refetch()}
|
|
disabled={isRefetching}
|
|
aria-label={t('socialUi.conversation.refresh')}
|
|
className="text-gray-400 hover:text-white"
|
|
>
|
|
<RefreshCw className={cn("h-4 w-4", isRefetching && "animate-spin")} />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Pending trade confirmation (the seller recorded the sale) */}
|
|
{pendingTrade && (
|
|
<Card className="mb-3 flex-shrink-0 border-2 border-[#ccff00]/40 bg-[#ccff00]/5">
|
|
<CardContent className="flex items-center gap-3 p-3">
|
|
<CheckCircle2 className="h-5 w-5 shrink-0 text-[#ccff00]" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-bold text-white">{t('socialUi.conversation.didCompleteTrade')}</p>
|
|
<p className="text-xs text-gray-400">
|
|
{t('socialUi.conversation.recordedSale', { name: displayName })}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleConfirmTrade}
|
|
disabled={confirmingTrade}
|
|
className="shrink-0 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
|
>
|
|
{confirmingTrade ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
|
|
{t('socialUi.conversation.confirm')}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Incoming request: the other party asked us (seller) to record the sale */}
|
|
{incomingRequest && (
|
|
<Card className="mb-3 flex-shrink-0 border-2 border-[#ff6600]/40 bg-[#ff6600]/5">
|
|
<CardContent className="space-y-3 p-3">
|
|
<div className="flex items-center gap-3">
|
|
<Handshake className="h-5 w-5 shrink-0 text-[#ff6600]" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-bold text-white">{t('socialUi.conversation.confirmSale')}</p>
|
|
<p className="text-xs text-gray-400">
|
|
{t('socialUi.conversation.askedToRecord', { name: displayName })}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleFulfillRequest}
|
|
disabled={confirmingTrade}
|
|
className="shrink-0 gap-1.5 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
|
>
|
|
{confirmingTrade ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Handshake className="h-3.5 w-3.5" />}
|
|
{t('socialUi.conversation.confirmSaleButton')}
|
|
</Button>
|
|
</div>
|
|
|
|
{escrowToken && (
|
|
<div className="space-y-2">
|
|
<EscrowPanel token={escrowToken} />
|
|
{escrow.available ? (
|
|
<Button
|
|
size="sm"
|
|
onClick={handleRedeemEscrow}
|
|
disabled={isRedeeming}
|
|
className="w-full gap-1.5 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
|
>
|
|
{isRedeeming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Handshake className="h-3.5 w-3.5" />}
|
|
{t('socialUi.conversation.redeemEscrow')}
|
|
</Button>
|
|
) : (
|
|
<p className="text-[11px] text-gray-500">
|
|
{t('socialUi.conversation.escrowAppOnly')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* 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">{t('socialUi.dms.loadFailed')}</p>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => refetch()}
|
|
className="mt-4 border-gray-700"
|
|
>
|
|
{t('common.tryAgain')}
|
|
</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">{t('socialUi.dms.emptyTitle')}</p>
|
|
<p className="text-gray-600 text-sm mt-1">
|
|
{t('socialUi.conversation.startThread')}
|
|
</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={t('socialUi.conversation.placeholder')}
|
|
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}
|
|
aria-label={t('socialUi.chat.sendAria')}
|
|
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">
|
|
{t('socialUi.conversation.e2eeNote')}
|
|
</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>
|
|
</>
|
|
);
|
|
}
|