Files
eranos/src/components/AddMembersDialog.tsx
T
2026-05-28 00:52:39 -07:00

194 lines
7.8 KiB
TypeScript

/**
* AddMembersDialog
*
* Search-based dialog for adding profiles to a specific list.
* Uses NIP-50 profile search and allows keyboard navigation.
*/
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import { Search, Loader2, Plus, UserPlus, Check } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { useSearchProfiles } from '@/hooks/useSearchProfiles';
import { useUserLists } from '@/hooks/useUserLists';
import { toast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import type { SearchProfile } from '@/hooks/useSearchProfiles';
interface AddMembersDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
listId: string;
listPubkeys: string[];
}
export function AddMembersDialog({ open, onOpenChange, listId, listPubkeys }: AddMembersDialogProps) {
const [query, setQuery] = useState('');
const [selectedIdx, setSelectedIdx] = useState(0);
const [addingPubkeys, setAddingPubkeys] = useState<Set<string>>(new Set());
const [addedPubkeys, setAddedPubkeys] = useState<Set<string>>(new Set());
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const { data: searchResults, isLoading, isFetching } = useSearchProfiles(query);
const { addToList } = useUserLists();
// Existing member set for filtering
const existingMembers = useMemo(() => new Set(listPubkeys), [listPubkeys]);
// Filter out profiles already in the list
const filteredResults = useMemo(() => {
if (!searchResults) return [];
return searchResults.filter((p) => !existingMembers.has(p.pubkey));
}, [searchResults, existingMembers]);
// Reset state when dialog opens/closes
useEffect(() => {
if (open) {
setQuery('');
setSelectedIdx(0);
setAddingPubkeys(new Set());
setAddedPubkeys(new Set());
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [open]);
// Reset selection when results change
useEffect(() => {
setSelectedIdx(0);
}, [filteredResults.length]);
const handleAdd = useCallback(async (profile: SearchProfile) => {
if (addingPubkeys.has(profile.pubkey) || addedPubkeys.has(profile.pubkey)) return;
setAddingPubkeys((prev) => new Set(prev).add(profile.pubkey));
try {
await addToList.mutateAsync({ listId, pubkey: profile.pubkey });
setAddedPubkeys((prev) => new Set(prev).add(profile.pubkey));
const name = profile.metadata.name || profile.metadata.display_name || genUserName(profile.pubkey);
toast({ title: `Added ${name} to list` });
} catch {
toast({ title: 'Failed to add member', variant: 'destructive' });
} finally {
setAddingPubkeys((prev) => {
const next = new Set(prev);
next.delete(profile.pubkey);
return next;
});
}
}, [addToList, listId, addingPubkeys, addedPubkeys]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIdx((prev) => Math.min(prev + 1, filteredResults.length - 1));
scrollSelectedIntoView(selectedIdx + 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIdx((prev) => Math.max(prev - 1, 0));
scrollSelectedIntoView(selectedIdx - 1);
} else if (e.key === 'Enter' && filteredResults[selectedIdx]) {
e.preventDefault();
handleAdd(filteredResults[selectedIdx]);
}
};
const scrollSelectedIntoView = (idx: number) => {
const container = listRef.current;
if (!container) return;
const item = container.children[idx] as HTMLElement | undefined;
item?.scrollIntoView({ block: 'nearest' });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[70dvh] p-0 gap-0 rounded-2xl overflow-hidden [&>button]:hidden flex flex-col">
<div className="px-4 py-3 border-b border-border shrink-0">
<DialogHeader className="p-0 space-y-0 mb-3">
<DialogTitle className="text-base font-bold">Add Members</DialogTitle>
</DialogHeader>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search by name or NIP-05…"
className="pl-9 pr-8"
/>
{isFetching && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground animate-spin" />
)}
</div>
</div>
<div ref={listRef} className="overflow-y-auto flex-1">
{!query.trim() ? (
<div className="py-12 text-center text-muted-foreground text-sm">
<UserPlus className="size-8 mx-auto mb-2" />
Search for people to add to this list.
</div>
) : isLoading && !searchResults ? (
<div className="py-12 text-center">
<Loader2 className="size-5 animate-spin mx-auto text-muted-foreground" />
</div>
) : filteredResults.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm">
{searchResults && searchResults.length > 0
? 'All matching users are already in this list.'
: 'No profiles found.'}
</div>
) : (
filteredResults.map((profile, idx) => {
const name = profile.metadata.name || profile.metadata.display_name || genUserName(profile.pubkey);
const isAdding = addingPubkeys.has(profile.pubkey);
const isAdded = addedPubkeys.has(profile.pubkey);
const isSelected = idx === selectedIdx;
return (
<div
key={profile.pubkey}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
isSelected ? 'bg-secondary/60' : 'hover:bg-secondary/30'
}`}
onClick={() => handleAdd(profile)}
onMouseEnter={() => setSelectedIdx(idx)}
>
<Avatar className="size-9 shrink-0">
<AvatarImage src={profile.metadata.picture} alt={name} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{name[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{name}</div>
{profile.metadata.nip05 && (
<div className="text-xs text-muted-foreground truncate">{profile.metadata.nip05}</div>
)}
</div>
<Button
size="sm"
variant={isAdded ? 'secondary' : 'outline'}
className="h-7 px-2.5 text-xs shrink-0 gap-1"
disabled={isAdding || isAdded}
onClick={(e) => { e.stopPropagation(); handleAdd(profile); }}
>
{isAdding ? (
<Loader2 className="size-3 animate-spin" />
) : isAdded ? (
<><Check className="size-3" /> Added</>
) : (
<><Plus className="size-3" /> Add</>
)}
</Button>
</div>
);
})
)}
</div>
</DialogContent>
</Dialog>
);
}