import { useMemo, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { PageHeader } from '@/components/PageHeader';
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 */}
{/* Search */}
setQuery(e.target.value)}
placeholder="Filter by npub…"
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) => (
))}
);
}