diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 0a3c78d4..07331916 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -44,6 +44,7 @@ const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ de const BooksPage = lazy(() => import("./pages/BooksPage").then(m => ({ default: m.BooksPage }))); const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage }))); const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage }))); +const CreateCommunityPage = lazy(() => import("./pages/CreateCommunityPage").then(m => ({ default: m.CreateCommunityPage }))); const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage }))); const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage }))); const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage }))); @@ -291,6 +292,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/CommunitiesPage.tsx b/src/pages/CommunitiesPage.tsx index c6da4a02..ec042aa9 100644 --- a/src/pages/CommunitiesPage.tsx +++ b/src/pages/CommunitiesPage.tsx @@ -6,7 +6,6 @@ import { Globe2, HandHeart, Loader2, PlusCircle, Search, Users } from 'lucide-re import type { NostrEvent } from '@nostrify/nostrify'; import { useInView } from 'react-intersection-observer'; -import { CreateCommunityDialog } from '@/components/CreateCommunityDialog'; import { FeedEmptyState } from '@/components/FeedEmptyState'; import { HeroAtmosphere } from '@/components/HeroAtmosphere'; import { HeroBanner } from '@/components/HeroBanner'; @@ -71,7 +70,6 @@ export function CommunitiesPage() { const queryClient = useQueryClient(); const navigate = useNavigate(); const { toast } = useToast(); - const [createDialogOpen, setCreateDialogOpen] = useState(false); useLayoutOptions({ noMaxWidth: true, @@ -92,7 +90,7 @@ export function CommunitiesPage() { }); return; } - setCreateDialogOpen(true); + navigate('/communities/new'); }; return ( @@ -140,8 +138,6 @@ export function CommunitiesPage() { - - ); } diff --git a/src/pages/CreateCommunityPage.tsx b/src/pages/CreateCommunityPage.tsx new file mode 100644 index 00000000..2f8f78b0 --- /dev/null +++ b/src/pages/CreateCommunityPage.tsx @@ -0,0 +1,448 @@ +import { useCallback, useMemo, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useSeoMeta } from '@unhead/react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import { nip19 } from 'nostr-tools'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { + AlertTriangle, + ArrowLeft, + Loader2, + Users, + X, +} from 'lucide-react'; + +import { PersonSearch } from '@/components/AddMemberDialog'; +import { CoverImageField } from '@/components/CoverImageField'; +import { FormSection } from '@/components/FormSection'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useToast } from '@/hooks/useToast'; +import type { SearchProfile } from '@/hooks/useSearchProfiles'; +import { useLayoutOptions } from '@/contexts/LayoutContext'; +import { + BADGE_DEFINITION_KIND, + COMMUNITY_DEFINITION_KIND, +} from '@/lib/communityUtils'; +import { genUserName } from '@/lib/genUserName'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; + +/** + * Convert text into a URL-safe slug for the NIP-72 community's d-tag. + * Lifted verbatim from CreateCommunityDialog so the same name produces the + * same identifier. + */ +function slugify(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/[\s_]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** + * Build a minimal SearchProfile shell when we only know a pubkey. PersonSearch + * hands us full profiles, but anywhere we synthesize a pending moderator + * row (e.g. the founder pinned at the top of the list) we need this stub. + */ +function makeProfileFromPubkey(pubkey: string): SearchProfile { + return { + pubkey, + metadata: {}, + event: { + id: '', + pubkey, + created_at: 0, + kind: 0, + tags: [], + content: '{}', + sig: '', + }, + }; +} + +export function CreateCommunityPage() { + useLayoutOptions({ noMaxWidth: true, rightSidebar: null }); + + const { user } = useCurrentUser(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { nostr } = useNostr(); + const { mutateAsync: publishEvent } = useNostrPublish(); + const { toast } = useToast(); + + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [imageUrl, setImageUrl] = useState(''); + // Additional moderators on top of the founder, who is always the first + // moderator and rendered separately so they can't be removed. + const [moderators, setModerators] = useState([]); + const [formError, setFormError] = useState(''); + + const derivedSlug = useMemo(() => slugify(name), [name]); + + useSeoMeta({ + title: 'Create community | Agora', + description: 'Start a new community on Agora.', + }); + + const addModerator = useCallback((profile: SearchProfile) => { + setModerators((prev) => + prev.some((m) => m.pubkey === profile.pubkey) ? prev : [...prev, profile], + ); + }, []); + + const addModerators = useCallback((profiles: SearchProfile[]) => { + setModerators((prev) => { + const seen = new Set(prev.map((m) => m.pubkey)); + const next = [...prev]; + for (const profile of profiles) { + if (seen.has(profile.pubkey)) continue; + seen.add(profile.pubkey); + next.push(profile); + } + return next; + }); + }, []); + + const removeModerator = useCallback((pubkey: string) => { + setModerators((prev) => prev.filter((m) => m.pubkey !== pubkey)); + }, []); + + const submitMutation = useMutation({ + mutationFn: async () => { + if (!user) throw new Error('You must be logged in to create a community.'); + + const trimmedName = name.trim(); + if (!trimmedName) throw new Error('Name is required.'); + + const slug = derivedSlug; + if (!slug) { + throw new Error( + 'Name must include letters or numbers so a community URL can be created.', + ); + } + + // The founder is always pubkey #0 in the moderator list — they + // shouldn't end up in the extra-moderators array, but defensively + // strip if they did. + const extraModerators = moderators.filter( + (m) => m.pubkey !== user.pubkey, + ); + + // d-tag collision check: don't silently overwrite an existing + // community of yours with the same slug. + const existing = await nostr.query([ + { + kinds: [COMMUNITY_DEFINITION_KIND], + authors: [user.pubkey], + '#d': [slug], + limit: 1, + }, + ]); + if (existing.length > 0) { + throw new Error( + `You already have a community with the identifier "${slug}". Choose another name.`, + ); + } + + // Same collision check for the implicitly-minted member badge. + const badgeDTag = `${slug}-member`; + const existingBadge = await nostr.query([ + { + kinds: [BADGE_DEFINITION_KIND], + authors: [user.pubkey], + '#d': [badgeDTag], + limit: 1, + }, + ]); + if (existingBadge.length > 0) { + throw new Error( + 'You already have a member badge with this identifier. Choose a different community name so the badge can be created safely.', + ); + } + + const sanitizedImage = imageUrl.trim() + ? sanitizeUrl(imageUrl.trim()) + : undefined; + + // Mint the implicit "Member of " badge (kind 30009). + // Mirrors CreateCommunityDialog so existing badge-award flows on + // CommunityDetailPage keep working. + const badgeEvent: NostrEvent = await publishEvent({ + kind: BADGE_DEFINITION_KIND, + content: '', + tags: [ + ['d', badgeDTag], + ['name', 'Member'], + ['description', `Member of ${trimmedName}`], + ['alt', `Badge definition: Member of ${trimmedName}`], + ], + }); + + const badgeATag = `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`; + + // Build the kind 34550 community-definition tag set. + const tags: string[][] = [ + ['d', slug], + ['name', trimmedName], + ]; + if (description.trim()) { + tags.push(['description', description.trim()]); + } + if (sanitizedImage) { + tags.push(['image', sanitizedImage]); + } + // Member badge reference (NIP-72 style: `a` tag with role "member"). + tags.push(['a', badgeATag, '', 'member']); + // Founder is the first moderator. + tags.push(['p', user.pubkey, '', 'moderator']); + for (const mod of extraModerators) { + tags.push(['p', mod.pubkey, '', 'moderator']); + } + tags.push(['alt', `Community: ${trimmedName}`]); + + const created = await publishEvent({ + kind: COMMUNITY_DEFINITION_KIND, + content: '', + tags, + }); + + return { event: created, slug }; + }, + onSuccess: async ({ event, slug }) => { + const naddr = nip19.naddrEncode({ + kind: COMMUNITY_DEFINITION_KIND, + pubkey: event.pubkey, + identifier: slug, + }); + queryClient.setQueryData( + ['addr-event', COMMUNITY_DEFINITION_KIND, event.pubkey, slug], + event, + ); + void queryClient.invalidateQueries({ + queryKey: ['my-communities'], + exact: false, + }); + void queryClient.invalidateQueries({ + queryKey: ['community-activity-feed'], + exact: false, + }); + toast({ title: 'Community created!' }); + navigate(`/${naddr}`); + }, + onError: (error: unknown) => { + const msg = error instanceof Error ? error.message : String(error); + setFormError(msg); + toast({ + title: 'Could not create community', + description: msg, + variant: 'destructive', + }); + }, + }); + + if (!user) { + return ( +
+
+ + + +

Log in to start a community

+

+ Communities are signed Nostr events. You need a Nostr login to publish one. +

+ +
+
+
+
+ ); + } + + // Founder is always rendered first and isn't removable; that mirrors + // CreateCommunityDialog (founder is auto-added as the only moderator) + // while making the role visible to the user. + const founderProfile = makeProfileFromPubkey(user.pubkey); + + return ( +
+
{ + e.preventDefault(); + setFormError(''); + submitMutation.mutate(); + }} + > +
+
+ +

+ Create community +

+
+
+ +
+ {/* Name */} + + setName(e.target.value)} + placeholder="e.g. The Arbiter's Guard" + maxLength={100} + required + /> +

+ URL preview:{' '} + + /{derivedSlug || 'your-community-name'} + +

+
+ + {/* Description */} + +