From f2805ed9d80bb891645d85ea86fab2cfcaf75e59 Mon Sep 17 00:00:00 2001 From: lemon Date: Tue, 19 May 2026 00:06:50 -0700 Subject: [PATCH] Add a dedicated Create Community page CommunitiesPage used to open CreateCommunityDialog when the user hit 'Create community' in the hero, the My Communities shelf, or the empty state. Replace those entry points with navigate('/communities/new'), mirroring how CreateCampaignPage and CreateActionPage already work. The new page covers the same three NIP-72 fields the dialog handled (name + description + cover image) plus a Moderators section that the dialog never exposed: - Name, with a live URL preview of the derived slug for transparency. - Description, in the same Textarea shape as the dialog. - Cover image, via the shared so drag-and-drop + paste-URL behavior matches the campaign and action create pages. - Moderators, via the same CreateCampaignPage uses for recipients. The founder is pinned at the top of the list as a non-removable row labeled 'Founder'; PersonSearch is told to exclude the founder so they can't be added a second time. Extra moderators go into the kind 34550 event as additional ['p', pk, '', 'moderator'] tags alongside the founder's. Submit logic is the kind 30009 badge mint + kind 34550 community publish from CreateCommunityDialog's create branch, including the d-tag and badge-d-tag collision checks. Cache keys touched on success match the dialog: ['addr-event', 34550, pubkey, dTag] is seeded, ['my-communities'] and ['community-activity-feed'] are invalidated. CreateCommunityDialog itself stays in the tree because CommunityDetailPage still opens it in edit mode. A unified edit page that folds in 'View members', 'Add members', and 'Edit badge' is explicitly out of scope for this change. --- src/AppRouter.tsx | 2 + src/pages/CommunitiesPage.tsx | 6 +- src/pages/CreateCommunityPage.tsx | 448 ++++++++++++++++++++++++++++++ 3 files changed, 451 insertions(+), 5 deletions(-) create mode 100644 src/pages/CreateCommunityPage.tsx 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 */} + +