- 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.
228 lines
8.9 KiB
TypeScript
228 lines
8.9 KiB
TypeScript
import { useState, useRef, useEffect, useMemo } from 'react';
|
|
import { useParams, Link } from 'react-router-dom';
|
|
import { useSeoMeta } from '@unhead/react';
|
|
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 { 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 { useGroupConversation, useSendDirectMessage } from '@/hooks/useDirectMessages';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
|
|
import { ArrowLeft, Lock, Loader2, RefreshCw, Send, Users } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
function ParticipantName({ pubkey }: { pubkey: string }) {
|
|
const author = useAuthor(pubkey);
|
|
const name = author.data?.metadata?.name ?? genUserName(pubkey);
|
|
return <span className="truncate">{name}</span>;
|
|
}
|
|
|
|
/**
|
|
* Group DM thread (NIP-17 fan-out group messaging).
|
|
*
|
|
* The URL carries the OTHER participants' hex pubkeys (comma-separated);
|
|
* `useGroupConversation` takes the full set including us. This is the same
|
|
* gift-wrap primitive as 1:1 DMs with one `p` tag per recipient.
|
|
*/
|
|
export default function GroupConversationPage() {
|
|
const { t } = useTranslation();
|
|
const { participants: participantsParam } = useParams<{ participants: string }>();
|
|
const { user } = useCurrentUser();
|
|
const { logins } = useNostrLogin();
|
|
const { toast } = useToast();
|
|
const { mutate: sendMessage, isPending: isSending } = useSendDirectMessage();
|
|
|
|
const [newMessage, setNewMessage] = useState('');
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
const otherPubkeys = useMemo(
|
|
() => (participantsParam ? participantsParam.split(',').filter(Boolean) : []),
|
|
[participantsParam],
|
|
);
|
|
const allParticipants = useMemo(
|
|
() => (user ? [user.pubkey, ...otherPubkeys] : otherPubkeys),
|
|
[user, otherPubkeys],
|
|
);
|
|
|
|
useSeoMeta({ title: t('socialUi.groupConversation.seoTitle') });
|
|
|
|
const { data: messages, isLoading, refetch, isRefetching } = useGroupConversation(allParticipants);
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [messages]);
|
|
|
|
if (!participantsParam || otherPubkeys.length === 0) {
|
|
return (
|
|
<>
|
|
<div className="container mx-auto max-w-2xl px-4 py-16 text-center text-gray-400">
|
|
{t('socialUi.groupConversation.invalidLink')}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (!user) {
|
|
return (
|
|
<>
|
|
<div className="container mx-auto max-w-2xl px-4 py-8">
|
|
<Card className="border-dashed border-2 border-gray-700">
|
|
<CardContent className="py-16 text-center">
|
|
<Lock className="mx-auto mb-4 h-16 w-16 text-gray-600" />
|
|
<h2 className="mb-2 text-xl font-bold">{t('socialUi.groupConversation.loginTitle')}</h2>
|
|
<div className="mt-6 flex justify-center">
|
|
<LoginArea className="max-w-60" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (!checkNip44(user, logins)) {
|
|
return (
|
|
<>
|
|
<div className="container mx-auto max-w-2xl px-4 py-12 text-center">
|
|
<Card className="border-2 border-dashed border-orange-500/30">
|
|
<CardContent className="py-12">
|
|
<Lock className="mx-auto mb-4 h-12 w-12 text-orange-500" />
|
|
<h2 className="mb-2 text-lg font-bold">{t('socialUi.groupConversation.secureSignerTitle')}</h2>
|
|
<p className="text-sm text-gray-400">{t('socialUi.groupConversation.secureSignerDesc')}</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const handleSend = () => {
|
|
if (!newMessage.trim() || isSending) return;
|
|
sendMessage(
|
|
{ recipients: otherPubkeys, content: newMessage.trim() },
|
|
{
|
|
onSuccess: () => setNewMessage(''),
|
|
onError: (error) => {
|
|
toast({
|
|
title: t('socialUi.conversation.sendFailed'),
|
|
description: error instanceof Error ? error.message : t('socialUi.conversation.sendFailedDesc'),
|
|
variant: 'destructive',
|
|
});
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="container mx-auto flex h-[calc(100dvh-56px-80px)] max-w-2xl flex-col px-4 py-2 sm:h-[calc(100dvh-64px-60px)] sm:py-4 md:h-[calc(100vh-64px-60px)]">
|
|
{/* Header */}
|
|
<div className="flex flex-shrink-0 items-center gap-2 border-b border-gray-800 pb-3 sm:gap-3 sm:pb-4">
|
|
<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>
|
|
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-[#ccff00]/15 text-[#ccff00]">
|
|
<Users className="h-5 w-5" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-bold text-white sm:text-base">
|
|
{otherPubkeys.length === 1 ? (
|
|
<ParticipantName pubkey={otherPubkeys[0]} />
|
|
) : (
|
|
`${t('socialUi.dms.peopleCount', { count: otherPubkeys.length + 1 })}`
|
|
)}
|
|
</p>
|
|
<p className="flex items-center gap-1 text-[10px] text-gray-500 sm:text-xs">
|
|
<Lock className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
|
{t('socialUi.groupConversation.encryptedGroup')}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label={t('socialUi.conversation.refresh')}
|
|
onClick={() => refetch()}
|
|
disabled={isRefetching}
|
|
className="text-gray-400 hover:text-white"
|
|
>
|
|
<RefreshCw className={cn('h-4 w-4', isRefetching && 'animate-spin')} />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div className="flex-1 space-y-3 overflow-y-auto py-4">
|
|
{isLoading ? (
|
|
<>
|
|
<Skeleton className="h-16 w-3/4 rounded-2xl" />
|
|
<Skeleton className="ml-auto h-16 w-2/3 rounded-2xl" />
|
|
<Skeleton className="h-16 w-3/4 rounded-2xl" />
|
|
</>
|
|
) : messages && messages.length > 0 ? (
|
|
messages.map((msg) => {
|
|
const isMine = msg.pubkey === user.pubkey;
|
|
return (
|
|
<div key={msg.id} className={cn('flex flex-col', isMine ? 'items-end' : 'items-start')}>
|
|
{!isMine && (
|
|
<span className="mb-0.5 px-2 text-[10px] font-bold text-gray-500">
|
|
<ParticipantName pubkey={msg.pubkey} />
|
|
</span>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
'max-w-[80%] break-words rounded-2xl px-3.5 py-2 text-sm',
|
|
isMine ? 'bg-[#ff0040] text-white' : 'bg-white/8 text-gray-100',
|
|
)}
|
|
>
|
|
{msg.content}
|
|
</div>
|
|
<span className="mt-0.5 px-2 text-[10px] text-gray-600">
|
|
{formatDistanceToNow(new Date(msg.createdAt * 1000), { addSuffix: true })}
|
|
</span>
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
<div className="py-10 text-center text-sm text-gray-500">
|
|
{t('socialUi.groupConversation.empty')}
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Composer */}
|
|
<div className="flex flex-shrink-0 gap-2 border-t border-gray-800 pb-safe pt-3 sm:pt-4">
|
|
<Textarea
|
|
value={newMessage}
|
|
onChange={(e) => setNewMessage(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
}}
|
|
placeholder={t('socialUi.groupConversation.placeholder')}
|
|
className="min-h-[44px] max-h-24 resize-none border-gray-700 bg-gray-900 text-sm text-white sm:max-h-32 sm:text-base"
|
|
rows={1}
|
|
/>
|
|
<Button
|
|
onClick={handleSend}
|
|
disabled={!newMessage.trim() || isSending}
|
|
aria-label={t('socialUi.chat.sendAria')}
|
|
className="h-[44px] w-[44px] flex-shrink-0 bg-[#ff0040] p-0 text-white hover:bg-[#ff0040]/90 sm:h-auto sm:w-auto sm:px-4"
|
|
>
|
|
{isSending ? <Loader2 className="h-5 w-5 animate-spin" /> : <Send className="h-5 w-5" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|