SkateSpots/src/pages/ContactsPage.tsx
shakespeare.diy a31583079b Private Contacts: fetch-before-write to prevent list clobber
Same encrypted-replaceable-list data-safety pattern as the Stash fix, applied
to usePrivateContacts (kind 30000, NIP-44 encrypted to self).

add/remove built the new list from the React Query cache, which is empty until
the Contacts page has decrypted — and with Amber the read query is gated /
staleTime:Infinity. Saving a contact before that decrypt (fresh device, or if a
"save privately" action is ever wired up elsewhere) could publish a list with
only the new pubkey and silently wipe every other private contact.

Fix: add/remove now call fetchLatest() — a fresh relay fetch + decrypt of the
current list immediately before publishing — and union it with the warm cache
so an optimistic just-added pubkey is never dropped. On fetch/decrypt failure
it falls back to the cache rather than risk a wipe.

UX: clarified the contacts search placeholder ("Paste an npub to filter…") so
it no longer implies name search that the npub-only filter doesn't do. Left the
star toggle un-confirmed on purpose — it's a low-stakes, one-tap-reversible
action and a dialog would only add friction. ContactsPage is otherwise already
clean/elegant (tabbed, skeletons, thoughtful empty states, encryption notice).

Bumps to 1.2.24 (Android versionCode 10224 auto-derived).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-07 20:36:01 +07:00

353 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 { 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 (
<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" 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 (
<Layout>
<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>
</Layout>
);
}
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 (
<Layout>
<div className="container max-w-2xl px-4 py-6">
{/* Header */}
<div className="mb-4">
<h1
className="flex items-center gap-2 text-3xl font-black uppercase text-teal-400"
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}
>
<Users className="h-7 w-7" /> Contacts
</h1>
<p className="mt-1 text-sm text-gray-400">
Your scene — crew-mates, follows &amp; conversations
</p>
</div>
{/* 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="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 && (
<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>
</Layout>
);
}
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>
);
}