194 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|