Files
eranos/src/components/AddMemberDialog.tsx
T
2026-05-11 11:11:07 -07:00

622 lines
24 KiB
TypeScript

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<string, CommunityMember>;
}
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<PendingMember[]>([]);
const [badgeImageUrl, setBadgeImageUrl] = useState('');
const [badgeImagePreview, setBadgeImagePreview] = useState('');
const [isPublishing, setIsPublishing] = useState(false);
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
const fileInputRef = useRef<HTMLInputElement>(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<string, NostrEvent>) => {
queryClient.setQueryData<CommunityMembersCacheValue>(['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<NostrEvent, 'id' | 'pubkey' | 'sig'>);
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<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
}
// Step 3: Publish badge awards for each member
const memberAwardEvents = new Map<string, NostrEvent>();
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<NostrEvent, 'id' | 'pubkey' | 'sig'>);
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent ref={dialogContentRef} className="sm:max-w-md gap-0 p-0 overflow-visible">
<PortalContainerProvider value={portalContainer}>
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<UserPlus className="size-5 text-primary" />
Add Members
</DialogTitle>
<DialogDescription>
{isFounder
? 'Add moderators or members to your community.'
: 'Invite members to the community.'}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="px-5 pb-5 space-y-4">
{/* People search */}
<div className="space-y-1.5">
<Label>Search people</Label>
<PersonSearch
onAdd={addPerson}
excludePubkeys={[
community.founderPubkey,
...pendingMembers.map((m) => m.profile.pubkey),
]}
/>
</div>
{/* Pending members list */}
{pendingMembers.length > 0 && (
<div className="space-y-1.5">
<Label>
People to add
<span className="text-muted-foreground font-normal ml-1">({pendingMembers.length})</span>
</Label>
<div className="space-y-1">
{pendingMembers.map((pm) => (
<PendingMemberChip
key={pm.profile.pubkey}
pending={pm}
onRemove={removePerson}
onToggleRole={isFounder ? toggleRole : undefined}
/>
))}
</div>
</div>
)}
{/* Badge image — only shown when badge needs to be created */}
{needsBadgeCreation && (
<div className="space-y-1.5">
<Label>
Member Badge Image
<span className="text-muted-foreground font-normal ml-1">(optional)</span>
</Label>
<div
role="button"
tabIndex={0}
onClick={() => 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 ? (
<img src={badgeImagePreview} alt="Badge preview" className="h-full object-contain" />
) : isUploading ? (
<div className="flex flex-col items-center gap-1.5 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<span className="text-xs">Uploading...</span>
</div>
) : (
<div className="flex flex-col items-center gap-1.5 text-muted-foreground">
<Upload className="size-5 opacity-40" />
<span className="text-xs">Drop an image or click to upload</span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleBadgeFileSelect(file);
}}
/>
</div>
</div>
)}
{/* Submit button */}
<Button
onClick={handleSubmit}
disabled={pendingMembers.length === 0 || isPublishing || isUploading}
className="w-full gap-2"
>
{isPublishing ? (
<><Loader2 className="size-4 animate-spin" /> Adding...</>
) : (
<><UserPlus className="size-4" /> Add {pendingMembers.length || ''} {pendingMembers.length === 1 ? 'Person' : pendingMembers.length > 1 ? 'People' : 'Members'}</>
)}
</Button>
</div>
</ScrollArea>
</PortalContainerProvider>
</DialogContent>
</Dialog>
);
}
// ── 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<HTMLInputElement>(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 (
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
<PopoverTrigger asChild>
<div className="relative flex items-center">
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
{isFetching && query.trim() && (
<Loader2 className="absolute right-3 size-4 text-muted-foreground animate-spin" />
)}
<Input
ref={inputRef}
value={query}
onChange={(e) => 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"
/>
</div>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
className="z-[270] w-[var(--radix-popover-trigger-width)] rounded-xl border-border p-0 shadow-lg overflow-hidden"
>
{filteredProfiles.length > 0 ? (
<div className="max-h-[200px] overflow-y-auto py-1">
{filteredProfiles.map((profile) => (
<SearchResultItem key={profile.pubkey} profile={profile} onClick={handleSelect} />
))}
</div>
) : query.trim().length >= 2 && !isFetching ? (
<div className="py-4 text-center text-sm text-muted-foreground">
No people found
</div>
) : null}
</PopoverContent>
</Popover>
);
}
/** 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 (
<div className="flex items-center gap-2 p-2 rounded-lg bg-secondary/30 border border-border/50">
<Avatar shape={getAvatarShape(metadata)} className="size-7 shrink-0">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<span className="flex-1 text-sm truncate">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{/* Role badge — clickable if founder can toggle */}
<button
type="button"
onClick={onToggleRole ? () => onToggleRole(pubkey) : undefined}
disabled={!onToggleRole}
className={cn(
'flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full shrink-0 transition-colors',
role === 'moderator'
? 'bg-amber-500/10 text-amber-600 dark:text-amber-400'
: 'bg-primary/10 text-primary',
onToggleRole && 'cursor-pointer hover:opacity-80',
)}
title={onToggleRole ? 'Click to toggle role' : undefined}
>
{role === 'moderator' ? <Crown className="size-3" /> : <Users className="size-3" />}
{role === 'moderator' ? 'Moderator' : 'Member'}
</button>
<button
type="button"
onClick={() => onRemove(pubkey)}
className="shrink-0 size-6 rounded-full hover:bg-destructive/10 flex items-center justify-center transition-colors"
title="Remove"
>
<X className="size-3.5 text-muted-foreground hover:text-destructive" />
</button>
</div>
);
}
/** 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 (
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
onClick={() => onClick(profile)}
onMouseDown={(e) => e.preventDefault()}
>
<Avatar shape={getAvatarShape(metadata)} className="size-8 shrink-0">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{metadata.nip05 && (
<span className="text-xs text-muted-foreground truncate block">
{metadata.nip05.startsWith('_@') ? metadata.nip05.slice(2) : metadata.nip05}
</span>
)}
</div>
</button>
);
}