import { useMemo, useState } from 'react'; import { useSeoMeta } from '@unhead/react'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { Layout } from '@/components/Layout'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { LoginArea } from '@/components/auth/LoginArea'; import { ZapButton } from '@/components/ZapButton'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrLogin } from '@nostrify/react/login'; import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; import { useAuthor } from '@/hooks/useAuthor'; import { useFollowing } from '@/hooks/useFollowing'; import { useConversations } from '@/hooks/useDirectMessages'; import { useCrewmates, type Crewmate } from '@/hooks/useCrewmates'; import { usePrivateContacts, usePrivateContactActions } from '@/hooks/usePrivateContacts'; import { useToast } from '@/hooks/useToast'; import { genUserName } from '@/lib/genUserName'; import { Users, MessageCircle, UserCheck, Lock, Star, StarOff, Search, X, Loader2, } from 'lucide-react'; import { cn } from '@/lib/utils'; function ContactRow({ pubkey, subtitle, isSaved, onToggleSave, saving, }: { pubkey: string; subtitle?: React.ReactNode; isSaved: boolean; onToggleSave: () => void; saving: boolean; }) { const author = useAuthor(pubkey); const meta = author.data?.metadata; const name = meta?.name ?? genUserName(pubkey); const npub = nip19.npubEncode(pubkey); // Build a minimal event-like object for ZapButton (needs pubkey). const zapTarget = author.data?.event; return (
{name.slice(0, 2).toUpperCase()}
{name} {subtitle &&
{subtitle}
}
{zapTarget && }
); } function EmptyTab({ icon: Icon, text }: { icon: typeof Users; text: string }) { return (

{text}

); } export default function ContactsPage() { useSeoMeta({ title: 'Contacts - SkateSpots', description: 'Your scene — crew-mates, people you follow and recent conversations, with private encrypted contacts.', }); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); const { toast } = useToast(); const [query, setQuery] = useState(''); const { data: following, isLoading: followingLoading } = useFollowing(); const { crewmates, isLoading: crewmatesLoading } = useCrewmates(); const { data: conversations, isLoading: convosLoading } = useConversations(); const { data: savedContacts } = usePrivateContacts(); const { addContact, removeContact, isAdding, isRemoving } = usePrivateContactActions(); const savedSet = useMemo(() => new Set(savedContacts ?? []), [savedContacts]); const [pendingPubkey, setPendingPubkey] = useState(null); const toggleSave = async (pubkey: string) => { if (!checkNip44(user, logins)) { toast({ title: 'Encryption unavailable', description: 'Your signer does not support NIP-44 encryption.', variant: 'destructive', }); return; } setPendingPubkey(pubkey); try { if (savedSet.has(pubkey)) { await removeContact(pubkey); toast({ title: 'Removed from private contacts' }); } else { await addContact(pubkey); toast({ title: 'Saved to private contacts', description: 'Only you can see this list.' }); } } catch (err) { toast({ title: 'Could not update contacts', description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive', }); } finally { setPendingPubkey(null); } }; const q = query.trim().toLowerCase(); const matchNpub = (pk: string) => !q || nip19.npubEncode(pk).toLowerCase().includes(q); if (!user) { return (

Your scene, in one place

Log in to see your crew-mates, people you follow, and recent conversations — and keep a private, encrypted contacts list.

); } const crewmatesFiltered = crewmates.filter((c) => matchNpub(c.pubkey)); const followingFiltered = (following ?? []).filter(matchNpub); const convoPubkeys = (conversations ?? []).map((c) => c.pubkey).filter(matchNpub); const savedFiltered = (savedContacts ?? []).filter(matchNpub); const renderList = (pubkeys: string[], subtitleFor?: (pk: string) => React.ReactNode) => (
{pubkeys.map((pk) => ( toggleSave(pk)} saving={(isAdding || isRemoving) && pendingPubkey === pk} /> ))}
); const crewmateSubtitle = (c: Crewmate) => c.sharedCrews.length > 0 ? ( {c.sharedCrews.slice(0, 2).join(', ')} {c.sharedCrews.length > 2 && ` +${c.sharedCrews.length - 2}`} ) : undefined; return (
{/* Header */}

Contacts

Your scene — crew-mates, follows & conversations

{/* Search */}
setQuery(e.target.value)} placeholder="Paste an npub to filter…" className="h-10 w-full rounded-xl border border-white/10 bg-white/5 pl-9 pr-9 text-sm text-white placeholder:text-gray-600 focus:border-teal-500/50 focus:outline-none" /> {query && ( )}
Crew Follows DMs Saved {/* Crew-mates */} {crewmatesLoading ? ( ) : crewmatesFiltered.length === 0 ? ( ) : ( renderList( crewmatesFiltered.map((c) => c.pubkey), (pk) => { const cm = crewmatesFiltered.find((c) => c.pubkey === pk); return cm ? crewmateSubtitle(cm) : undefined; }, ) )} {/* Following */} {followingLoading ? ( ) : followingFiltered.length === 0 ? ( ) : ( renderList(followingFiltered) )} {/* Recent DMs */} {convosLoading ? ( ) : convoPubkeys.length === 0 ? ( ) : ( renderList(convoPubkeys) )} {/* Saved private contacts */}

This list is encrypted to your key — only you can read it. Tap the star on anyone to add them.

{savedFiltered.length === 0 ? ( ) : ( renderList(savedFiltered) )}
); } function ListSkeleton() { return (
{Array.from({ length: 5 }).map((_, i) => (
))}
); }