import { useState, useCallback, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { Users, Loader2 } from 'lucide-react'; import { useNostr } from '@nostrify/react'; import { useQueryClient } from '@tanstack/react-query'; import { nip19 } from 'nostr-tools'; 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 { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { ScrollArea } from '@/components/ui/scroll-area'; import { ImageUploadField } from '@/components/ImageUploadField'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; import { BADGE_DEFINITION_KIND, COMMUNITY_DEFINITION_KIND, type ParsedCommunity } from '@/lib/communityUtils'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; // ── Helpers ─────────────────────────────────────────────────────────────────── /** Convert text into a URL-safe slug for the d-tag identifier. */ function slugify(text: string): string { return text .toLowerCase() .trim() .replace(/[^\w\s-]/g, '') .replace(/[\s_]+/g, '-') .replace(/^-+|-+$/g, ''); } // ── Types ───────────────────────────────────────────────────────────────────── interface CreateCommunityDialogProps { open: boolean; onOpenChange: (open: boolean) => void; /** Existing community event when editing. Omit to create a new community. */ communityEvent?: NostrEvent; /** Parsed existing community data when editing. */ community?: ParsedCommunity; } // ── Component ───────────────────────────────────────────────────────────────── export function CreateCommunityDialog({ open, onOpenChange, communityEvent, community }: CreateCommunityDialogProps) { const { user } = useCurrentUser(); const { nostr } = useNostr(); const { toast } = useToast(); const navigate = useNavigate(); const queryClient = useQueryClient(); const isEditing = !!communityEvent && !!community; // Form state const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [imageUrl, setImageUrl] = useState(''); const [isPublishing, setIsPublishing] = useState(false); const [isImageUploading, setIsImageUploading] = useState(false); // Mutations const { mutateAsync: publishEvent } = useNostrPublish(); // Derived const effectiveSlug = isEditing && community ? community.dTag : slugify(name); const populateFromCommunity = useCallback(() => { setName(community?.name ?? ''); setDescription(community?.description ?? ''); setImageUrl(community?.image ?? ''); setIsPublishing(false); setIsImageUploading(false); }, [community]); const resetForm = useCallback(() => { if (isEditing) { populateFromCommunity(); } else { setName(''); setDescription(''); setImageUrl(''); setIsPublishing(false); setIsImageUploading(false); } }, [isEditing, populateFromCommunity]); useEffect(() => { if (open && isEditing) { populateFromCommunity(); } }, [open, isEditing, populateFromCommunity]); const handleOpenChange = useCallback((nextOpen: boolean) => { if (!nextOpen) resetForm(); onOpenChange(nextOpen); }, [onOpenChange, resetForm]); const buildUpdatedCommunityTags = useCallback((baseTags: string[][]): string[][] => { const tags = baseTags.filter(([name]) => !['d', 'name', 'description', 'image', 'alt'].includes(name)); const nextTags: string[][] = [ ['d', effectiveSlug], ['name', name.trim()], ]; if (description.trim()) { nextTags.push(['description', description.trim()]); } const sanitizedImage = sanitizeUrl(imageUrl.trim()); if (sanitizedImage) { nextTags.push(['image', sanitizedImage]); } nextTags.push(...tags); nextTags.push(['alt', `Community: ${name.trim()}`]); return nextTags; }, [description, effectiveSlug, imageUrl, name]); // ── Publish ─────────────────────────────────────────────────────────────── const handleCreate = useCallback(async () => { if (!user || !name.trim() || !effectiveSlug) return; if (isImageUploading) { toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' }); return; } if (imageUrl.trim() && !sanitizeUrl(imageUrl.trim())) { toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' }); return; } setIsPublishing(true); try { if (isEditing && communityEvent && community) { const prev = await fetchFreshEvent(nostr, { kinds: [COMMUNITY_DEFINITION_KIND], authors: [communityEvent.pubkey], '#d': [community.dTag], }); const updatedEvent = await publishEvent({ kind: COMMUNITY_DEFINITION_KIND, content: prev?.content ?? communityEvent.content, tags: buildUpdatedCommunityTags(prev?.tags ?? communityEvent.tags), prev: prev ?? undefined, } as Omit & { prev?: NostrEvent }); queryClient.setQueryData( ['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag], updatedEvent, ); queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false }); queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false }); toast({ title: 'Community updated!' }); handleOpenChange(false); return; } // Check for d-tag collision (same author, same kind, same d-tag) const existing = await nostr.query([{ kinds: [COMMUNITY_DEFINITION_KIND], authors: [user.pubkey], '#d': [effectiveSlug], limit: 1, }]); if (existing.length > 0) { toast({ title: 'Name already in use', description: 'You already have a community with this name. Please choose a different name.', variant: 'destructive', }); setIsPublishing(false); return; } const badgeDTag = `${effectiveSlug}-member`; const existingBadge = await nostr.query([{ kinds: [BADGE_DEFINITION_KIND], authors: [user.pubkey], '#d': [badgeDTag], limit: 1, }]); if (existingBadge.length > 0) { toast({ title: 'Member badge ID already in use', description: 'Choose a different community name so the member badge can be created safely.', variant: 'destructive', }); setIsPublishing(false); return; } const badgeEvent = await publishEvent({ kind: BADGE_DEFINITION_KIND, content: '', tags: [ ['d', badgeDTag], ['name', 'Member'], ['description', `Member of ${name.trim()}`], ['alt', `Badge definition: Member of ${name.trim()}`], ], } as Omit); // Founder as moderator (p tag) plus one flat member badge reference. const communityTags = buildUpdatedCommunityTags([ ['a', `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`, '', 'member'], ['p', user.pubkey, '', 'moderator'], ]); // Publish community definition (kind 34550) const createdEvent = await publishEvent({ kind: COMMUNITY_DEFINITION_KIND, content: '', tags: communityTags, } as Omit); // Navigate to the new community const naddr = nip19.naddrEncode({ kind: COMMUNITY_DEFINITION_KIND, pubkey: createdEvent.pubkey, identifier: effectiveSlug, }); toast({ title: 'Community created!' }); handleOpenChange(false); navigate(`/${naddr}`); } catch (err) { toast({ title: isEditing ? 'Failed to update community' : 'Failed to create community', description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive', }); } finally { setIsPublishing(false); } }, [ user, name, effectiveSlug, isEditing, communityEvent, community, nostr, isImageUploading, imageUrl, publishEvent, buildUpdatedCommunityTags, queryClient, toast, handleOpenChange, navigate, ]); if (!user) return null; return ( {isEditing ? 'Edit Community' : 'Create a Community'} {isEditing ? 'Update the name, image, and description. Moderators are preserved.' : "Start a new community on Nostr. You'll be the founder."}
{/* Community name */}
setName(e.target.value)} maxLength={100} /> {name.trim() && (

ID: {effectiveSlug || '...'}{isEditing ? ' (unchanged)' : ''}

)}
Community Image (recommended)} value={imageUrl} onChange={setImageUrl} onUploadingChange={setIsImageUploading} previewAlt="Community image preview" dropAreaClassName="min-h-32" /> {/* Description */}