SkateSpots/src/pages/ContactsPage.tsx
SkateSpots 087f490acc refactor(layout): hoist Layout into the router so the chrome mounts once
- AppRouter wraps every route (except /amber-callback and the 404) in ONE
  <Route element={<Layout/>}> with an <Outlet/>; the chrome now persists across
  navigation instead of being torn down and rebuilt on every page.
- Layout: children optional (falls back to <Outlet/>); the map is full-bleed via
  a route check (isFullBleed) instead of a per-page noPadding prop.
- All 59 pages + CraftProfileForm/ErrorState/NewsletterIssueView now render
  fragments instead of <Layout> (structure-preserving), imports dropped.

Removes the whole remount class: chrome no longer re-runs its hooks per
navigation, Sasquatch chat state survives navigation, and the header can't
re-open Amber on every page change.
2026-09-27 13:41:42 +07:00

348 lines
12 KiB
TypeScript

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 (
<div className="flex items-center gap-3 rounded-xl border border-white/5 bg-black/40 p-3">
<Link to={`/${npub}`} className="shrink-0">
<Avatar className="h-11 w-11">
<AvatarImage src={meta?.picture} alt={name} />
<AvatarFallback className="bg-teal-900/40 text-teal-300 font-bold">
{name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
<div className="min-w-0 flex-1">
<Link to={`/${npub}`} className="block truncate font-bold text-white hover:text-teal-300">
{name}
</Link>
{subtitle && <div className="truncate text-xs text-gray-500">{subtitle}</div>}
</div>
<div className="flex shrink-0 items-center gap-1">
{zapTarget && <ZapButton target={zapTarget} className="text-xs" showCount={false} />}
<Link to={`/messages/${npub}`} title="Message">
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-400 hover:text-teal-400">
<MessageCircle className="h-4 w-4" />
</Button>
</Link>
<Button
variant="ghost"
size="icon"
onClick={onToggleSave}
disabled={saving}
title={isSaved ? 'Remove from private contacts' : 'Save to private contacts'}
className={cn(
'h-8 w-8',
isSaved ? 'text-amber-400 hover:text-amber-300' : 'text-gray-500 hover:text-amber-400',
)}
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isSaved ? (
<Star className="h-4 w-4 fill-current" />
) : (
<StarOff className="h-4 w-4" />
)}
</Button>
</div>
</div>
);
}
function EmptyTab({ icon: Icon, text }: { icon: typeof Users; text: string }) {
return (
<Card className="border-dashed border-white/10 bg-transparent">
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5">
<Icon className="h-7 w-7 text-gray-600" />
</div>
<p className="max-w-xs text-sm text-gray-500">{text}</p>
</CardContent>
</Card>
);
}
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<string | null>(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 (
<>
<div className="container max-w-2xl px-4 py-12">
<Card className="border-dashed">
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
<Users className="h-12 w-12 text-teal-400/50" />
<div>
<h2 className="mb-1 text-xl font-black">Your scene, in one place</h2>
<p className="max-w-sm text-sm text-muted-foreground">
Log in to see your crew-mates, people you follow, and recent conversations —
and keep a private, encrypted contacts list.
</p>
</div>
<LoginArea className="justify-center" />
</CardContent>
</Card>
</div>
</>
);
}
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) => (
<div className="space-y-2">
{pubkeys.map((pk) => (
<ContactRow
key={pk}
pubkey={pk}
subtitle={subtitleFor?.(pk)}
isSaved={savedSet.has(pk)}
onToggleSave={() => toggleSave(pk)}
saving={(isAdding || isRemoving) && pendingPubkey === pk}
/>
))}
</div>
);
const crewmateSubtitle = (c: Crewmate) =>
c.sharedCrews.length > 0 ? (
<span className="flex items-center gap-1">
<Users className="h-3 w-3 text-teal-500" />
{c.sharedCrews.slice(0, 2).join(', ')}
{c.sharedCrews.length > 2 && ` +${c.sharedCrews.length - 2}`}
</span>
) : undefined;
return (
<>
<div className="container max-w-2xl px-4 py-6">
{/* Header */}
<PageHeader
title="Contacts"
icon={Users}
accent="#2dd4bf"
subtitle="Your scene — crew-mates, follows &amp; conversations"
/>
{/* Search */}
<div className="relative mb-4">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<input
type="text"
value={query}
onChange={(e) => 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 && (
<button
onClick={() => setQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Tabs defaultValue="crewmates">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="crewmates" className="gap-1 text-xs">
<Users className="h-3.5 w-3.5" /> Crew
</TabsTrigger>
<TabsTrigger value="following" className="gap-1 text-xs">
<UserCheck className="h-3.5 w-3.5" /> Follows
</TabsTrigger>
<TabsTrigger value="dms" className="gap-1 text-xs">
<MessageCircle className="h-3.5 w-3.5" /> DMs
</TabsTrigger>
<TabsTrigger value="saved" className="gap-1 text-xs">
<Lock className="h-3.5 w-3.5" /> Saved
</TabsTrigger>
</TabsList>
{/* Crew-mates */}
<TabsContent value="crewmates" className="mt-4">
{crewmatesLoading ? (
<ListSkeleton />
) : crewmatesFiltered.length === 0 ? (
<EmptyTab
icon={Users}
text="No crew-mates yet. Join or start a crew to build your scene network."
/>
) : (
renderList(
crewmatesFiltered.map((c) => c.pubkey),
(pk) => {
const cm = crewmatesFiltered.find((c) => c.pubkey === pk);
return cm ? crewmateSubtitle(cm) : undefined;
},
)
)}
</TabsContent>
{/* Following */}
<TabsContent value="following" className="mt-4">
{followingLoading ? (
<ListSkeleton />
) : followingFiltered.length === 0 ? (
<EmptyTab icon={UserCheck} text="You're not following anyone yet." />
) : (
renderList(followingFiltered)
)}
</TabsContent>
{/* Recent DMs */}
<TabsContent value="dms" className="mt-4">
{convosLoading ? (
<ListSkeleton />
) : convoPubkeys.length === 0 ? (
<EmptyTab
icon={MessageCircle}
text="No conversations yet. Message a skater to start one."
/>
) : (
renderList(convoPubkeys)
)}
</TabsContent>
{/* Saved private contacts */}
<TabsContent value="saved" className="mt-4">
<p className="mb-3 flex items-center gap-1.5 rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-[11px] text-amber-300/80">
<Lock className="h-3 w-3 shrink-0" />
This list is encrypted to your key — only you can read it. Tap the star on anyone to
add them.
</p>
{savedFiltered.length === 0 ? (
<EmptyTab
icon={Star}
text="No saved contacts yet. Star people from any tab to build your private address book."
/>
) : (
renderList(savedFiltered)
)}
</TabsContent>
</Tabs>
</div>
</>
);
}
function ListSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 rounded-xl border border-white/5 p-3">
<Skeleton className="h-11 w-11 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
</div>
);
}