import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCrewmates } from '@/hooks/useCrewmates'; import { useFollowing } from '@/hooks/useFollowing'; import { useTranslation } from '@/contexts/language'; import { genUserName } from '@/lib/genUserName'; import { cn } from '@/lib/utils'; import { MessageCircle, Search, Users, UserCheck, ArrowRight, CheckCircle2 } from 'lucide-react'; interface NewMessageDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } interface Candidate { pubkey: string; /** Short context line, e.g. shared crews or "Following". */ hint?: string; /** Grouping bucket for section headers. */ group: 'crew' | 'following'; } /** A single selectable person row. Resolves its own profile for name/avatar. */ function PersonRow({ candidate, query, onPick, selected, }: { candidate: Candidate; query: string; onPick: (pubkey: string) => void; selected: boolean; }) { const author = useAuthor(candidate.pubkey); const metadata = author.data?.metadata; const name = metadata?.name ?? genUserName(candidate.pubkey); const nip05 = metadata?.nip05; // Name-based filtering happens here (names are resolved async per row). const q = query.trim().toLowerCase(); if (q) { const haystack = `${name} ${nip05 ?? ''} ${candidate.hint ?? ''}`.toLowerCase(); if (!haystack.includes(q)) return null; } return ( ); } /** * A best-in-class "New Message" picker: search your crew-mates and the people * you follow by name, or paste a raw npub / hex key. Replaces the old * paste-only flow so starting a DM feels like a real contacts screen. */ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps) { const { t } = useTranslation(); const navigate = useNavigate(); const { user } = useCurrentUser(); const { crewmates, isLoading: crewmatesLoading } = useCrewmates(); const { data: following, isLoading: followingLoading } = useFollowing(user?.pubkey); const [query, setQuery] = useState(''); const [rawError, setRawError] = useState(''); const [selected, setSelected] = useState([]); // Merge crew-mates (with shared-crew hints) and followed accounts, de-duped. const candidates = useMemo(() => { const seen = new Set(); const list: Candidate[] = []; for (const cm of crewmates) { if (seen.has(cm.pubkey)) continue; seen.add(cm.pubkey); list.push({ pubkey: cm.pubkey, group: 'crew', hint: cm.sharedCrews.length > 0 ? t('socialUi.newMessage.inCrew', { crews: cm.sharedCrews.join(', ') }) : t('socialUi.newMessage.crewmate'), }); } for (const pk of following ?? []) { if (pk === user?.pubkey || seen.has(pk)) continue; seen.add(pk); list.push({ pubkey: pk, group: 'following', hint: t('socialUi.newMessage.following') }); } return list; }, [crewmates, following, user?.pubkey, t]); const crewCandidates = candidates.filter((c) => c.group === 'crew'); const followingCandidates = candidates.filter((c) => c.group === 'following'); const reset = () => { onOpenChange(false); setQuery(''); setRawError(''); setSelected([]); }; const goToConversation = (pubkey: string) => { reset(); navigate(`/messages/${nip19.npubEncode(pubkey)}`); }; /** Add/remove a person from the group selection. */ const toggleSelected = (pubkey: string) => setSelected((prev) => prev.includes(pubkey) ? prev.filter((p) => p !== pubkey) : [...prev, pubkey], ); /** One person → 1:1 thread; two or more → an encrypted group thread. */ const startChat = () => { if (selected.length === 0) return; const to = selected.length === 1 ? `/messages/${nip19.npubEncode(selected[0])}` : `/messages/group/${selected.join(',')}`; reset(); navigate(to); }; // If the query looks like a raw key, offer to open it directly. const trimmed = query.trim(); const looksLikeNpub = trimmed.startsWith('npub1'); const looksLikeHex = /^[a-f0-9]{64}$/i.test(trimmed); const handleOpenRaw = () => { setRawError(''); try { let pubkey = trimmed; if (looksLikeNpub) { const decoded = nip19.decode(trimmed); if (decoded.type !== 'npub') throw new Error('Invalid npub'); pubkey = decoded.data; } else if (!looksLikeHex) { throw new Error('Invalid key'); } goToConversation(pubkey); } catch { setRawError(t('socialUi.newMessage.invalidKey')); } }; const isLoading = crewmatesLoading || followingLoading; const hasAnyCandidates = candidates.length > 0; return ( {t('socialUi.dms.newMessage')} {t('socialUi.newMessage.description')}
{ setQuery(e.target.value); setRawError(''); }} placeholder={t('socialUi.newMessage.searchPlaceholder')} className="bg-gray-900 border-gray-700 text-white pl-9 h-11" autoFocus />
{/* Direct-open affordance when a raw key is pasted */} {(looksLikeNpub || looksLikeHex) && ( )} {rawError &&

{rawError}

} {/* People list */} {!looksLikeNpub && !looksLikeHex && ( {isLoading ? (
{t('socialUi.newMessage.loadingPeople')}
) : !hasAnyCandidates ? (

{t('socialUi.newMessage.noPeople')}

{t('socialUi.newMessage.noPeopleHint')}

) : (
{crewCandidates.length > 0 && (

{t('socialUi.newMessage.yourCrews')}

{crewCandidates.map((c) => ( ))}
)} {followingCandidates.length > 0 && (

{t('socialUi.newMessage.following')}

{followingCandidates.map((c) => ( ))}
)}
)}
)} {selected.length > 0 && (
{t('socialUi.newMessage.selected', { count: selected.length })}
)}
); }