import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { UserPlus, Upload, Loader2, X, Search, Crown, Users } from 'lucide-react'; import { useNostr } from '@nostrify/react'; import { useQueryClient } from '@tanstack/react-query'; import type { NostrEvent } from '@nostrify/nostrify'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; import { EmojifiedText } from '@/components/CustomEmoji'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useToast } from '@/hooks/useToast'; import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles'; import { PortalContainerProvider } from '@/hooks/usePortalContainer'; import { genUserName } from '@/lib/genUserName'; import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; import { COMMUNITY_DEFINITION_KIND, BADGE_DEFINITION_KIND, BADGE_AWARD_KIND, EMPTY_MODERATION, type CommunityMember, type CommunityMembership, type CommunityModeration, type ParsedCommunity, } from '@/lib/communityUtils'; import { cn } from '@/lib/utils'; // ── Types ───────────────────────────────────────────────────────────────────── type MemberRole = 'moderator' | 'member'; interface PendingMember { profile: SearchProfile; role: MemberRole; } interface CommunityMembersCacheValue { membership: CommunityMembership; moderation: CommunityModeration; rankMap: Map; } interface AddMemberDialogProps { open: boolean; onOpenChange: (open: boolean) => void; /** The raw community definition event. */ communityEvent: NostrEvent; /** Parsed community data. */ community: ParsedCommunity; /** Whether the current user is the founder (can add moderators). */ isFounder: boolean; } // ── Component ───────────────────────────────────────────────────────────────── export function AddMemberDialog({ open, onOpenChange, communityEvent, community, isFounder, }: AddMemberDialogProps) { const { user } = useCurrentUser(); const { nostr } = useNostr(); const { toast } = useToast(); const queryClient = useQueryClient(); // Form state const [pendingMembers, setPendingMembers] = useState([]); const [badgeImageUrl, setBadgeImageUrl] = useState(''); const [badgeImagePreview, setBadgeImagePreview] = useState(''); const [isPublishing, setIsPublishing] = useState(false); const [portalContainer, setPortalContainer] = useState(undefined); const fileInputRef = useRef(null); const dialogContentRef = useCallback((node: HTMLElement | null) => { setPortalContainer(node ?? undefined); }, []); // Mutations const { mutateAsync: publishEvent } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); // Does this community already have a badge definition? const existingBadgeATag = community.ranks.find((r) => r.rank === 1)?.badgeATag; const hasBadge = !!existingBadgeATag; // Are there any pending members with the "member" role? const hasPendingMembers = pendingMembers.some((m) => m.role === 'member'); // Will we need to create a badge? (members added + no badge exists yet) const needsBadgeCreation = hasPendingMembers && !hasBadge; const resetForm = useCallback(() => { setPendingMembers([]); setBadgeImageUrl(''); setBadgeImagePreview(''); setIsPublishing(false); }, []); const handleOpenChange = useCallback((nextOpen: boolean) => { if (!nextOpen) resetForm(); onOpenChange(nextOpen); }, [onOpenChange, resetForm]); // ── People management ───────────────────────────────────────────────────── const addPerson = useCallback((profile: SearchProfile) => { if (!user) return; if (profile.pubkey === community.founderPubkey) { toast({ title: 'Already the founder' }); return; } if (pendingMembers.some((m) => m.profile.pubkey === profile.pubkey)) { toast({ title: 'Already added' }); return; } // Default role: member if they're not already a moderator, moderator if founder is adding const defaultRole: MemberRole = isFounder ? 'moderator' : 'member'; setPendingMembers((prev) => [...prev, { profile, role: defaultRole }]); }, [user, community.founderPubkey, pendingMembers, isFounder, toast]); const removePerson = useCallback((pubkey: string) => { setPendingMembers((prev) => prev.filter((m) => m.profile.pubkey !== pubkey)); }, []); const toggleRole = useCallback((pubkey: string) => { if (!isFounder) return; // Only founder can toggle to moderator setPendingMembers((prev) => prev.map((m) => m.profile.pubkey === pubkey ? { ...m, role: m.role === 'moderator' ? 'member' : 'moderator' } : m, )); }, [isFounder]); const applyOptimisticMembership = useCallback((members: PendingMember[], awardEvents: Map) => { queryClient.setQueryData(['community-members', community.aTag], (prev) => { const moderation = prev?.moderation ?? EMPTY_MODERATION; const rankMap = new Map(prev?.rankMap ?? []); const membershipByPubkey = new Map( (prev?.membership.members ?? []).map((member) => [member.pubkey, member] as const), ); const seedRankZero = (pubkey: string) => { if (moderation.bannedPubkeys.has(pubkey)) return; const member: CommunityMember = { pubkey, rank: 0 }; if (!membershipByPubkey.has(pubkey)) membershipByPubkey.set(pubkey, member); if (!rankMap.has(pubkey)) rankMap.set(pubkey, member); }; seedRankZero(community.founderPubkey); community.moderatorPubkeys.forEach(seedRankZero); for (const pending of members) { if (moderation.bannedPubkeys.has(pending.profile.pubkey)) continue; const nextMember: CommunityMember = pending.role === 'moderator' ? { pubkey: pending.profile.pubkey, rank: 0 } : { pubkey: pending.profile.pubkey, rank: 1, awardEvent: awardEvents.get(pending.profile.pubkey), awardedBy: user?.pubkey, }; const current = membershipByPubkey.get(nextMember.pubkey); if (!current || nextMember.rank < current.rank) { membershipByPubkey.set(nextMember.pubkey, nextMember); } const currentRank = rankMap.get(nextMember.pubkey); if (!currentRank || nextMember.rank < currentRank.rank) { rankMap.set(nextMember.pubkey, nextMember); } } const membership: CommunityMembership = { members: Array.from(membershipByPubkey.values()).sort((a, b) => a.rank - b.rank), }; return { membership, moderation, rankMap }; }); }, [community.aTag, community.founderPubkey, community.moderatorPubkeys, queryClient, user?.pubkey]); // ── Badge image upload ──────────────────────────────────────────────────── const handleBadgeFileSelect = useCallback(async (file: File) => { if (!file.type.startsWith('image/')) { toast({ title: 'Invalid file', description: 'Please select an image file.', variant: 'destructive' }); return; } const reader = new FileReader(); reader.onload = (e) => setBadgeImagePreview(e.target?.result as string); reader.readAsDataURL(file); try { const [[, url]] = await uploadFile(file); setBadgeImageUrl(url); toast({ title: 'Badge image uploaded' }); } catch { setBadgeImagePreview(''); toast({ title: 'Upload failed', description: 'Please try again.', variant: 'destructive' }); } }, [uploadFile, toast]); const handleBadgeDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); const file = e.dataTransfer.files[0]; if (file) handleBadgeFileSelect(file); }, [handleBadgeFileSelect]); // ── Publish ─────────────────────────────────────────────────────────────── const handleSubmit = useCallback(async () => { if (!user || pendingMembers.length === 0) return; setIsPublishing(true); try { const newModerators = pendingMembers.filter((m) => m.role === 'moderator'); const newMembers = pendingMembers.filter((m) => m.role === 'member'); let badgeATag = existingBadgeATag; // Step 1: Create badge definition if needed if (newMembers.length > 0 && !hasBadge) { const badgeDTag = `${community.dTag}-member`; const badgeTags: string[][] = [ ['d', badgeDTag], ['name', 'Member'], ['description', `Member of ${community.name}`], ]; if (badgeImageUrl) { badgeTags.push(['image', badgeImageUrl, '1024x1024']); } badgeTags.push(['alt', `Badge definition: Member of ${community.name}`]); const badgeEvent = await publishEvent({ kind: BADGE_DEFINITION_KIND, content: '', tags: badgeTags, } as Omit); badgeATag = `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`; } // Step 2: Republish community definition if needed // Needed when: adding moderators (new p tags) OR badge was just created (new a tag) const needsCommunityUpdate = newModerators.length > 0 || (newMembers.length > 0 && !hasBadge); if (needsCommunityUpdate) { // Fetch fresh community event to avoid stale overwrites const prev = await fetchFreshEvent(nostr, { kinds: [COMMUNITY_DEFINITION_KIND], authors: [communityEvent.pubkey], '#d': [community.dTag], }); const baseTags = prev?.tags ?? communityEvent.tags; const updatedTags = [...baseTags]; // Add new moderator p tags for (const mod of newModerators) { // Don't add if already exists const exists = updatedTags.some( ([n, v, , role]) => n === 'p' && v === mod.profile.pubkey && role === 'moderator', ); if (!exists) { updatedTags.push(['p', mod.profile.pubkey, '', 'moderator']); } } // Add badge a tag if badge was just created if (badgeATag && !hasBadge) { updatedTags.push(['a', badgeATag, '', '1']); } await publishEvent({ kind: COMMUNITY_DEFINITION_KIND, content: prev?.content ?? '', tags: updatedTags, prev: prev ?? undefined, } as Omit & { prev?: NostrEvent }); } // Step 3: Publish badge awards for each member const memberAwardEvents = new Map(); if (newMembers.length > 0 && badgeATag) { for (const member of newMembers) { const awardEvent = await publishEvent({ kind: BADGE_AWARD_KIND, content: '', tags: [ ['a', badgeATag], ['p', member.profile.pubkey], ['alt', `Badge award: Member in ${community.name}`], ], } as Omit); memberAwardEvents.set(member.profile.pubkey, awardEvent); } } applyOptimisticMembership(pendingMembers, memberAwardEvents); queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false }); if (!hasBadge && newMembers.length > 0) { queryClient.invalidateQueries({ queryKey: ['badge-feed'] }); } const addedCount = pendingMembers.length; toast({ title: `Added ${addedCount} ${addedCount === 1 ? 'person' : 'people'} to the community` }); handleOpenChange(false); } catch (err) { toast({ title: 'Failed to add members', description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive', }); } finally { setIsPublishing(false); } }, [ user, pendingMembers, existingBadgeATag, hasBadge, community, communityEvent, badgeImageUrl, nostr, publishEvent, queryClient, toast, handleOpenChange, applyOptimisticMembership, ]); if (!user) return null; return ( Add Members {isFounder ? 'Add moderators or members to your community.' : 'Invite members to the community.'}
{/* People search */}
m.profile.pubkey), ]} />
{/* Pending members list */} {pendingMembers.length > 0 && (
{pendingMembers.map((pm) => ( ))}
)} {/* Badge image — only shown when badge needs to be created */} {needsBadgeCreation && (
fileInputRef.current?.click()} onDrop={handleBadgeDrop} onDragOver={(e) => e.preventDefault()} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click(); }} className="relative flex flex-col items-center justify-center w-full h-24 border-2 border-dashed border-border rounded-xl bg-secondary/5 hover:bg-secondary/10 transition-colors cursor-pointer overflow-hidden" > {badgeImagePreview ? ( Badge preview ) : isUploading ? (
Uploading...
) : (
Drop an image or click to upload
)} { const file = e.target.files?.[0]; if (file) handleBadgeFileSelect(file); }} />
)} {/* Submit button */}
); } // ── Sub-Components ──────────────────────────────────────────────────────────── /** Inline type-ahead person search. */ function PersonSearch({ onAdd, excludePubkeys, }: { onAdd: (profile: SearchProfile) => void; excludePubkeys: string[]; }) { const [query, setQuery] = useState(''); const [dropdownOpen, setDropdownOpen] = useState(false); const inputRef = useRef(null); const { data: profiles, isFetching } = useSearchProfiles(query); const excludeSet = useMemo(() => new Set(excludePubkeys), [excludePubkeys]); const filteredProfiles = useMemo( () => (profiles ?? []).filter((p) => !excludeSet.has(p.pubkey)), [profiles, excludeSet], ); useEffect(() => { if (query.trim().length > 0 && filteredProfiles.length > 0) { setDropdownOpen(true); } else if (query.trim().length === 0) { setDropdownOpen(false); } }, [filteredProfiles, query]); const handleSelect = useCallback((profile: SearchProfile) => { onAdd(profile); setQuery(''); setDropdownOpen(false); inputRef.current?.focus(); }, [onAdd]); return (
{isFetching && query.trim() && ( )} setQuery(e.target.value)} onFocus={() => { if (query.trim().length > 0 && filteredProfiles.length > 0) { setDropdownOpen(true); } }} placeholder="Search people to add..." className="pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 text-sm" autoComplete="off" />
e.preventDefault()} className="z-[270] w-[var(--radix-popover-trigger-width)] rounded-xl border-border p-0 shadow-lg overflow-hidden" > {filteredProfiles.length > 0 ? (
{filteredProfiles.map((profile) => ( ))}
) : query.trim().length >= 2 && !isFetching ? (
No people found
) : null}
); } /** A pending member chip with role toggle and remove button. */ function PendingMemberChip({ pending, onRemove, onToggleRole, }: { pending: PendingMember; onRemove: (pubkey: string) => void; onToggleRole?: (pubkey: string) => void; }) { const { profile, role } = pending; const { metadata, pubkey } = profile; const displayName = metadata.display_name || metadata.name || genUserName(pubkey); return (
{displayName[0]?.toUpperCase() || '?'} {displayName} {/* Role badge — clickable if founder can toggle */}
); } /** A profile search result row. */ function SearchResultItem({ profile, onClick }: { profile: SearchProfile; onClick: (profile: SearchProfile) => void }) { const { metadata, pubkey } = profile; const displayName = metadata.display_name || metadata.name || genUserName(pubkey); return ( ); }