283 lines
10 KiB
TypeScript
283 lines
10 KiB
TypeScript
/**
|
|
* AddToListDialog
|
|
*
|
|
* A dialog for adding a user (by pubkey) to one of the current user's
|
|
* NIP-51 Follow Sets (kind 30000) or Follow Packs (kind 39089),
|
|
* or creating a new Follow Set on the fly.
|
|
*/
|
|
import { useState } from 'react';
|
|
import { Plus, Loader2, List, Users, X, PartyPopper, Check } from 'lucide-react';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { useUserLists } from '@/hooks/useUserLists';
|
|
import { useFollowPacks } from '@/hooks/useFollowPacks';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { toast } from '@/hooks/useToast';
|
|
import type { FollowPack } from '@/hooks/useFollowPacks';
|
|
|
|
interface AddToListDialogProps {
|
|
/** Hex pubkey of the user to add */
|
|
pubkey: string;
|
|
/** Display name for the user being added */
|
|
displayName?: string;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
|
|
export function AddToListDialog({ pubkey, displayName, open, onOpenChange }: AddToListDialogProps) {
|
|
const { user } = useCurrentUser();
|
|
const { lists, isLoading: listsLoading, addToList, createList, isInList } = useUserLists();
|
|
const { data: followPacks = [], isLoading: packsLoading } = useFollowPacks();
|
|
const { mutateAsync: publishEvent } = useNostrPublish();
|
|
const queryClient = useQueryClient();
|
|
|
|
const [newListName, setNewListName] = useState('');
|
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
|
const [creatingNew, setCreatingNew] = useState(false);
|
|
// Optimistic tracking for packs added this session
|
|
const [addedPackIds, setAddedPackIds] = useState<Set<string>>(new Set());
|
|
|
|
const handleOpenChange = (o: boolean) => {
|
|
if (!o) setAddedPackIds(new Set());
|
|
onOpenChange(o);
|
|
};
|
|
|
|
const close = () => handleOpenChange(false);
|
|
|
|
const isLoading = listsLoading || packsLoading;
|
|
|
|
/** Add pubkey to a Follow Set (kind 30000). */
|
|
const handleAddToList = async (listId: string) => {
|
|
if (pendingId) return;
|
|
setPendingId(listId);
|
|
try {
|
|
await addToList.mutateAsync({ listId, pubkey });
|
|
toast({ title: 'Added to list' });
|
|
} catch {
|
|
toast({ title: 'Failed to add to list', variant: 'destructive' });
|
|
} finally {
|
|
setPendingId(null);
|
|
}
|
|
};
|
|
|
|
/** Add pubkey to a Follow Pack (kind 39089). */
|
|
const handleAddToPack = async (pack: FollowPack) => {
|
|
if (pendingId) return;
|
|
setPendingId(pack.id);
|
|
try {
|
|
const newTags = [...pack.event.tags, ['p', pubkey]];
|
|
await publishEvent({
|
|
kind: 39089,
|
|
content: pack.event.content ?? '',
|
|
tags: newTags,
|
|
});
|
|
setAddedPackIds((prev) => new Set(prev).add(pack.id));
|
|
queryClient.invalidateQueries({ queryKey: ['own-follow-packs', user?.pubkey] });
|
|
toast({ title: `Added to "${pack.title}"` });
|
|
} catch {
|
|
toast({ title: 'Failed to add to pack', variant: 'destructive' });
|
|
} finally {
|
|
setPendingId(null);
|
|
}
|
|
};
|
|
|
|
const handleCreateAndAdd = async () => {
|
|
if (!newListName.trim() || creatingNew) return;
|
|
setCreatingNew(true);
|
|
try {
|
|
const result = await createList.mutateAsync({
|
|
title: newListName.trim(),
|
|
pubkeys: [pubkey],
|
|
});
|
|
toast({ title: `Created "${result.title}" and added` });
|
|
setNewListName('');
|
|
} catch {
|
|
toast({ title: 'Failed to create list', variant: 'destructive' });
|
|
} finally {
|
|
setCreatingNew(false);
|
|
}
|
|
};
|
|
|
|
const hasAny = lists.length > 0 || followPacks.length > 0;
|
|
|
|
if (!user) return null;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
<DialogContent className="max-w-sm max-h-[80dvh] p-0 gap-0 rounded-2xl overflow-hidden [&>button]:hidden flex flex-col">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 shrink-0">
|
|
<DialogHeader className="p-0 space-y-0">
|
|
<DialogTitle className="text-base font-bold">
|
|
{displayName ? `Add ${displayName} to list` : 'Add to list'}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<Button variant="ghost" size="icon" className="rounded-full size-8 shrink-0" onClick={close}>
|
|
<X className="size-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="overflow-y-auto flex-1">
|
|
{isLoading ? (
|
|
<div className="py-2 space-y-1">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<div key={i} className="flex items-center justify-between px-4 py-2.5">
|
|
<div className="flex items-center gap-2.5 flex-1">
|
|
<Skeleton className="size-4 rounded" />
|
|
<Skeleton className="h-4 w-32" />
|
|
</div>
|
|
<Skeleton className="h-7 w-14 rounded-md" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : !hasAny ? (
|
|
<div className="py-8 px-4 text-center text-sm text-muted-foreground">
|
|
<List className="size-8 mx-auto mb-2 text-muted-foreground/40" />
|
|
No lists or packs yet. Create one below.
|
|
</div>
|
|
) : (
|
|
<div className="py-1">
|
|
{/* Follow Sets */}
|
|
{lists.length > 0 && (
|
|
<>
|
|
{followPacks.length > 0 && (
|
|
<p className="px-4 pt-1 pb-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Lists
|
|
</p>
|
|
)}
|
|
{lists.map((list) => {
|
|
const inList = isInList(list.id, pubkey);
|
|
const isPending = pendingId === list.id;
|
|
return (
|
|
<ListRow
|
|
key={list.id}
|
|
icon={<List className="size-3.5 text-muted-foreground shrink-0" />}
|
|
label={list.title}
|
|
count={list.pubkeys.length}
|
|
inList={inList}
|
|
isPending={isPending}
|
|
disabled={!!pendingId}
|
|
onAdd={() => handleAddToList(list.id)}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
|
|
{/* Follow Packs */}
|
|
{followPacks.length > 0 && (
|
|
<>
|
|
{lists.length > 0 && <Separator className="my-1" />}
|
|
<p className="px-4 pt-1 pb-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Follow Packs
|
|
</p>
|
|
{followPacks.map((pack) => {
|
|
const inPack = pack.pubkeys.includes(pubkey) || addedPackIds.has(pack.id);
|
|
const isPending = pendingId === pack.id;
|
|
return (
|
|
<ListRow
|
|
key={pack.id}
|
|
icon={<PartyPopper className="size-3.5 text-muted-foreground shrink-0" />}
|
|
label={pack.title}
|
|
count={pack.pubkeys.length}
|
|
inList={inPack}
|
|
isPending={isPending}
|
|
disabled={!!pendingId}
|
|
onAdd={() => handleAddToPack(pack)}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Create new list */}
|
|
<div className="p-3 space-y-2 shrink-0">
|
|
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide px-1">
|
|
New list
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="List name…"
|
|
value={newListName}
|
|
onChange={(e) => setNewListName(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateAndAdd(); }}
|
|
className="flex-1 h-8 text-base md:text-sm"
|
|
disabled={creatingNew}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleCreateAndAdd}
|
|
disabled={!newListName.trim() || creatingNew}
|
|
className="h-8 gap-1"
|
|
>
|
|
{creatingNew ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
|
|
Create
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// ─── ListRow ──────────────────────────────────────────────────────────────────
|
|
|
|
interface ListRowProps {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
count: number;
|
|
inList: boolean;
|
|
isPending: boolean;
|
|
disabled: boolean;
|
|
onAdd: () => void;
|
|
}
|
|
|
|
function ListRow({ icon, label, count, inList, isPending, disabled, onAdd }: ListRowProps) {
|
|
return (
|
|
<div className="flex items-center justify-between px-4 py-2.5 hover:bg-secondary/40 transition-colors">
|
|
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
|
{icon}
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium truncate">{label}</div>
|
|
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
|
<Users className="size-3 shrink-0" />
|
|
{count} {count === 1 ? 'person' : 'people'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
size="sm"
|
|
variant={inList ? 'secondary' : 'default'}
|
|
className="h-7 px-2.5 text-xs shrink-0 gap-1.5"
|
|
disabled={disabled || inList}
|
|
onClick={onAdd}
|
|
>
|
|
{isPending ? (
|
|
<Loader2 className="size-3 animate-spin" />
|
|
) : inList ? (
|
|
<><Check className="size-3" /> Added</>
|
|
) : (
|
|
<><Plus className="size-3" /> Add</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|