~3,470 new keys (5,770 total, EN=ES), ~115 more files wired. - nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets (sort/trust/weather), spot-type label helper. - placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications, collectives, crew chat/coordinator. settingsUi: settings, auth/signup, onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints, app shop, newsletter, community stats, leaderboards, profile. - Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
349 lines
12 KiB
TypeScript
349 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 { useTranslation } from '@/contexts/language';
|
|
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 { t } = useTranslation();
|
|
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={t('socialUi.contacts.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 ? t('socialUi.contacts.removePrivate') : t('socialUi.contacts.savePrivate')}
|
|
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() {
|
|
const { t } = useTranslation();
|
|
useSeoMeta({
|
|
title: t('socialUi.contacts.seoTitle'),
|
|
description: t('socialUi.contacts.seoDescription'),
|
|
});
|
|
|
|
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: inbox, 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: t('socialUi.chat.encryptionUnavailable'),
|
|
description: t('socialUi.contacts.encryptionUnavailableDesc'),
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
setPendingPubkey(pubkey);
|
|
try {
|
|
if (savedSet.has(pubkey)) {
|
|
await removeContact(pubkey);
|
|
toast({ title: t('socialUi.contacts.removed') });
|
|
} else {
|
|
await addContact(pubkey);
|
|
toast({ title: t('socialUi.contacts.saved'), description: t('socialUi.contacts.savedDesc') });
|
|
}
|
|
} catch (err) {
|
|
toast({
|
|
title: t('socialUi.contacts.updateFailed'),
|
|
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
|
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">{t('socialUi.contacts.loginTitle')}</h2>
|
|
<p className="max-w-sm text-sm text-muted-foreground">
|
|
{t('socialUi.contacts.loginDesc')}
|
|
</p>
|
|
</div>
|
|
<LoginArea className="justify-center" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const crewmatesFiltered = crewmates.filter((c) => matchNpub(c.pubkey));
|
|
const followingFiltered = (following ?? []).filter(matchNpub);
|
|
const convoPubkeys = (inbox?.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={t('socialUi.contacts.title')}
|
|
icon={Users}
|
|
accent="#2dd4bf"
|
|
subtitle={t('socialUi.contacts.subtitle')}
|
|
/>
|
|
|
|
{/* 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={t('socialUi.contacts.filterPlaceholder')}
|
|
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" /> {t('socialUi.contacts.tabCrew')}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="following" className="gap-1 text-xs">
|
|
<UserCheck className="h-3.5 w-3.5" /> {t('socialUi.contacts.tabFollows')}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="dms" className="gap-1 text-xs">
|
|
<MessageCircle className="h-3.5 w-3.5" /> {t('socialUi.contacts.tabDms')}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="saved" className="gap-1 text-xs">
|
|
<Lock className="h-3.5 w-3.5" /> {t('socialUi.contacts.tabSaved')}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Crew-mates */}
|
|
<TabsContent value="crewmates" className="mt-4">
|
|
{crewmatesLoading ? (
|
|
<ListSkeleton />
|
|
) : crewmatesFiltered.length === 0 ? (
|
|
<EmptyTab
|
|
icon={Users}
|
|
text={t('socialUi.contacts.emptyCrewmates')}
|
|
/>
|
|
) : (
|
|
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={t('socialUi.contacts.emptyFollowing')} />
|
|
) : (
|
|
renderList(followingFiltered)
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Recent DMs */}
|
|
<TabsContent value="dms" className="mt-4">
|
|
{convosLoading ? (
|
|
<ListSkeleton />
|
|
) : convoPubkeys.length === 0 ? (
|
|
<EmptyTab
|
|
icon={MessageCircle}
|
|
text={t('socialUi.contacts.emptyDms')}
|
|
/>
|
|
) : (
|
|
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" />
|
|
{t('socialUi.contacts.savedHint')}
|
|
</p>
|
|
{savedFiltered.length === 0 ? (
|
|
<EmptyTab
|
|
icon={Star}
|
|
text={t('socialUi.contacts.emptySaved')}
|
|
/>
|
|
) : (
|
|
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>
|
|
);
|
|
}
|