Compare commits

...

5 Commits

Author SHA1 Message Date
lemon 9ac379b259 fix: remove duplicate community share action 2026-05-03 21:28:00 -07:00
lemon c8b3961da6 feat: add community editing 2026-05-03 21:28:00 -07:00
lemon 259c657c33 fix: improve community member management 2026-05-03 21:28:00 -07:00
lemon 2f6aeb05e4 refactor: split community creation into two steps
- CreateCommunityDialog now only publishes kind 34550 (name, image, description)
- New AddMemberDialog on the community detail page handles membership:
  - Founder can add moderators and members
  - Moderators can add members only
  - Badge definition (kind 30009) created lazily on first member add
  - Community definition republished once with all changes batched
  - Kind 8 badge awards published for each member
- Add Members button on Members tab, visible to rank 0 users
- Search dropdown moved outside ScrollArea to prevent clipping
2026-05-03 21:27:41 -07:00
lemon 5cea93de34 feat: add community creation flow and improve discovery UX
- Add CreateCommunityDialog with name, image upload, description, and moderator type-ahead search
- Publish kind 30009 badge definition + kind 34550 community definition with d-tag collision check
- Context-aware FAB on My Communities tab opens the create dialog
- Default Search page tab to Communities instead of Posts
- Add Search to default sidebar order for new accounts
- Improve empty states on both Activities and My Communities tabs to guide users toward discovery
2026-05-03 21:26:48 -07:00
7 changed files with 1087 additions and 43 deletions
+621
View File
@@ -0,0 +1,621 @@
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>
);
}
+2 -29
View File
@@ -1,15 +1,12 @@
import { useMemo, useCallback } from 'react';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Users, Share2, Globe } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Users, Globe } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Button } from '@/components/ui/button';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
@@ -43,7 +40,6 @@ function parseCommunityEvent(event: NostrEvent) {
// --- Main Component ---
export function CommunityContent({ event }: { event: NostrEvent }) {
const { toast } = useToast();
const { name, description, image } = useMemo(
() => parseCommunityEvent(event),
[event],
@@ -68,22 +64,6 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
return description.replace(new RegExp(`\\s*${descriptionUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), '').trim();
}, [description, descriptionUrl]);
const handleShare = useCallback(async () => {
const d = getTag(event.tags, 'd') ?? '';
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: d,
});
const url = `${window.location.origin}/${naddr}`;
try {
await navigator.clipboard.writeText(url);
toast({ title: 'Link copied to clipboard' });
} catch {
toast({ title: 'Failed to copy link', variant: 'destructive' });
}
}, [event, toast]);
return (
<div className="mt-3 space-y-5">
{/* Community hero image */}
@@ -110,13 +90,6 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
</div>
)}
{/* Share button */}
<div className="flex items-center">
<Button variant="outline" size="icon" className="ml-auto size-8 shrink-0" onClick={handleShare}>
<Share2 className="size-3.5" />
</Button>
</div>
{/* Description */}
{descriptionText && (
<p className="text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">{descriptionText}</p>
+60 -3
View File
@@ -5,14 +5,18 @@ import {
ArrowLeft,
Crown,
MessageCircle,
Pencil,
Shield,
ShieldBan,
Share2,
Target,
UserPlus,
Users,
} from 'lucide-react';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { AddMemberDialog } from '@/components/AddMemberDialog';
import { CreateCommunityDialog } from '@/components/CreateCommunityDialog';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Badge } from '@/components/ui/badge';
@@ -133,6 +137,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
const [activeTab, setActiveTab] = useState('members');
const [composeOpen, setComposeOpen] = useState(false);
const [goalDialogOpen, setGoalDialogOpen] = useState(false);
const [addMemberOpen, setAddMemberOpen] = useState(false);
const [editCommunityOpen, setEditCommunityOpen] = useState(false);
// Parse community definition
const community = useMemo(() => parseCommunityEvent(event), [event]);
@@ -156,6 +162,20 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
const { data: membership, moderation, rankMap, isLoading: membersLoading } = useCommunityMembers(community);
const viewerMember = user ? getViewerAuthority(user.pubkey, rankMap, moderation) : undefined;
// Founder can add moderators + members; moderators (rank 0) can add members
const isFounder = !!user && user.pubkey === event.pubkey;
const canAddMembers = !!viewerMember && viewerMember.rank === 0;
const handleAddMembersClick = useCallback(() => {
setAddMemberOpen(true);
}, []);
useLayoutOptions({
showFAB: canAddMembers && activeTab === 'members',
onFabClick: handleAddMembersClick,
fabIcon: <UserPlus className="size-5" />,
});
// Batch-fetch profiles for all members
const allMemberPubkeys = useMemo(
() => membership?.members.map((m) => m.pubkey) ?? [],
@@ -287,21 +307,28 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
}
}, [event, toast]);
// ── FAB — visible on comments & fundraising tabs ──────────────────────────
// ── FAB — visible on comments, fundraising, and members tabs ──────────────
const handleFabClick = useCallback(() => {
if (activeTab === 'comments') {
setComposeOpen(true);
} else if (activeTab === 'fundraising') {
setGoalDialogOpen(true);
} else if (activeTab === 'members') {
setAddMemberOpen(true);
}
}, [activeTab]);
const fabIcon = activeTab === 'fundraising'
? <Target strokeWidth={3} size={18} />
: undefined; // default Plus icon for comments
: activeTab === 'members'
? <UserPlus className="size-5" />
: undefined; // default Plus icon for comments
useLayoutOptions({
showFAB: activeTab === 'comments' || activeTab === 'fundraising',
showFAB:
activeTab === 'comments'
|| activeTab === 'fundraising'
|| (activeTab === 'members' && canAddMembers),
onFabClick: handleFabClick,
fabIcon,
});
@@ -324,6 +351,15 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<ArrowLeft className="size-5" />
</button>
<h1 className="text-xl font-bold flex-1 truncate">Community</h1>
{isFounder && community && (
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={() => setEditCommunityOpen(true)}
aria-label="Edit community"
>
<Pencil className="size-5" />
</button>
)}
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={handleShare}
@@ -535,6 +571,27 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
onOpenChange={setGoalDialogOpen}
/>
)}
{/* Add member dialog */}
{canAddMembers && community && (
<AddMemberDialog
open={addMemberOpen}
onOpenChange={setAddMemberOpen}
communityEvent={event}
community={community}
isFounder={isFounder}
/>
)}
{/* Edit community dialog — founder only */}
{isFounder && community && (
<CreateCommunityDialog
open={editCommunityOpen}
onOpenChange={setEditCommunityOpen}
communityEvent={event}
community={community}
/>
)}
</div>
);
}
+348
View File
@@ -0,0 +1,348 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Users, Upload, 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 { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { COMMUNITY_DEFINITION_KIND, type ParsedCommunity } from '@/lib/communityUtils';
// ── 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 [imagePreview, setImagePreview] = useState('');
const [isPublishing, setIsPublishing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Mutations
const { mutateAsync: publishEvent } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
// Derived
const effectiveSlug = isEditing && community ? community.dTag : slugify(name);
const populateFromCommunity = useCallback(() => {
setName(community?.name ?? '');
setDescription(community?.description ?? '');
setImageUrl(community?.image ?? '');
setImagePreview(community?.image ?? '');
setIsPublishing(false);
}, [community]);
const resetForm = useCallback(() => {
if (isEditing) {
populateFromCommunity();
} else {
setName('');
setDescription('');
setImageUrl('');
setImagePreview('');
setIsPublishing(false);
}
}, [isEditing, populateFromCommunity]);
useEffect(() => {
if (open && isEditing) {
populateFromCommunity();
}
}, [open, isEditing, populateFromCommunity]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
// ── Image upload ──────────────────────────────────────────────────────────
const handleFileSelect = 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) => setImagePreview(e.target?.result as string);
reader.readAsDataURL(file);
try {
const [[, url]] = await uploadFile(file);
setImageUrl(url);
toast({ title: 'Image uploaded' });
} catch {
setImagePreview('');
toast({ title: 'Upload failed', description: 'Please try again.', variant: 'destructive' });
}
}, [uploadFile, toast]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFileSelect(file);
}, [handleFileSelect]);
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()]);
}
if (imageUrl) {
nextTags.push(['image', imageUrl]);
}
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;
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<NostrEvent, 'id' | 'pubkey' | 'sig'> & { 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;
}
// Founder as moderator (p tag)
const communityTags = buildUpdatedCommunityTags([['p', user.pubkey, '', 'moderator']]);
// Publish community definition (kind 34550)
const createdEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: '',
tags: communityTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
// 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,
publishEvent, buildUpdatedCommunityTags, queryClient, toast, handleOpenChange, navigate,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<Users className="size-5 text-primary" />
{isEditing ? 'Edit Community' : 'Create a Community'}
</DialogTitle>
<DialogDescription>
{isEditing
? 'Update the name, image, and description. Moderators are preserved.'
: "Start a new community on Nostr. You'll be the founder."}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="px-5 pb-5 space-y-4">
{/* Community name */}
<div className="space-y-1.5">
<Label htmlFor="community-name">Community Name *</Label>
<Input
id="community-name"
placeholder="e.g. The Arbiter's Guard"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={100}
/>
{name.trim() && (
<p className="text-xs text-muted-foreground font-mono">
ID: {effectiveSlug || '...'}{isEditing ? ' (unchanged)' : ''}
</p>
)}
</div>
{/* Image upload */}
<div className="space-y-1.5">
<Label>
Community Image
<span className="text-muted-foreground font-normal ml-1">(recommended)</span>
</Label>
<div
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
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-32 border-2 border-dashed border-border rounded-xl bg-secondary/5 hover:bg-secondary/10 transition-colors cursor-pointer overflow-hidden"
>
{imagePreview ? (
<img src={imagePreview} alt="Community image preview" className="w-full h-full object-cover" />
) : isUploading ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
<span className="text-xs">Uploading...</span>
</div>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Upload className="size-6 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) handleFileSelect(file);
}}
/>
</div>
</div>
{/* Description */}
<div className="space-y-1.5">
<Label htmlFor="community-description">
Description
<span className="text-muted-foreground font-normal ml-1">(recommended)</span>
</Label>
<Textarea
id="community-description"
placeholder="What is this community about?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
{/* Submit button */}
<Button
onClick={handleCreate}
disabled={!name.trim() || !effectiveSlug || isPublishing || isUploading}
className="w-full gap-2"
>
{isPublishing ? (
<><Loader2 className="size-4 animate-spin" /> {isEditing ? 'Saving...' : 'Creating...'}</>
) : (
<><Users className="size-4" /> {isEditing ? 'Save Changes' : 'Create Community'}</>
)}
</Button>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
+1
View File
@@ -13,6 +13,7 @@ import { useCallback, useMemo } from "react";
*/
const DEFAULT_SIDEBAR_ORDER: string[] = [
'wallet',
'search',
'verified',
'actions',
'polls',
+53 -8
View File
@@ -1,11 +1,13 @@
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useSeoMeta } from '@unhead/react';
import { Loader2, Users } from 'lucide-react';
import { Loader2, Search, Users } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useInView } from 'react-intersection-observer';
import { CommunityCard } from '@/components/CommunityCard';
import { CreateCommunityDialog } from '@/components/CreateCommunityDialog';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { LoginArea } from '@/components/auth/LoginArea';
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
@@ -14,6 +16,7 @@ import { PageHeader } from '@/components/PageHeader';
import { PullToRefresh } from '@/components/PullToRefresh';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { CommunityModerationContext, type CommunityModerationContextValue } from '@/contexts/CommunityModerationContext';
import { COMMUNITY_DEFINITION_KIND, EMPTY_MODERATION } from '@/lib/communityUtils';
@@ -78,16 +81,20 @@ export function CommunitiesPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
useLayoutOptions({
hasSubHeader: !!user,
});
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useFeedTab<CommunitiesTab>('communities', [
'activities',
'mine',
]);
useLayoutOptions({
hasSubHeader: !!user,
showFAB: !!user && activeTab === 'mine',
onFabClick: () => setCreateDialogOpen(true),
fabIcon: <Users className="size-5" />,
});
useSeoMeta({
title: `Communities | ${config.appName}`,
description: 'Discover and join hierarchical communities on Nostr',
@@ -131,6 +138,8 @@ export function CommunitiesPage() {
}
/>
)}
<CreateCommunityDialog open={createDialogOpen} onOpenChange={setCreateDialogOpen} />
</main>
);
}
@@ -177,7 +186,25 @@ function MyCommunitiesContent() {
if (!myCommunities || myCommunities.length === 0) {
return (
<FeedEmptyState message="You haven't founded or joined any communities yet." />
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<Users className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">No communities yet</h2>
<p className="text-muted-foreground text-sm">
Discover communities to join via the Search page, or create your own using the button below.
</p>
</div>
<div className="flex flex-col gap-2 w-full max-w-xs">
<Button asChild className="rounded-full">
<Link to="/search?tab=communities">
<Search className="size-4 mr-2" />
Search communities
</Link>
</Button>
</div>
</div>
);
}
@@ -294,7 +321,25 @@ function ActivitiesTab({ onRefresh }: { onRefresh: () => Promise<void> }) {
) : membersOnly && activityEvents && activityEvents.length > 0 ? (
<FeedEmptyState message="No activity from members of your communities yet. Toggle the shield icon to see all community activity." />
) : (
<FeedEmptyState message="No activity from your communities yet." />
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<Users className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">No activity yet</h2>
<p className="text-muted-foreground text-sm">
Discover communities to join via the Search page, or create your own from the My Communities tab.
</p>
</div>
<div className="flex flex-col gap-2 w-full max-w-xs">
<Button asChild className="rounded-full">
<Link to="/search?tab=communities">
<Search className="size-4 mr-2" />
Search communities
</Link>
</Button>
</div>
</div>
)}
{!isLoading && hasNextPage && (
<div ref={scrollRef} className="py-4 flex justify-center">
+2 -3
View File
@@ -62,7 +62,7 @@ type TabType = 'communities' | 'posts' | 'accounts';
const VALID_TABS: TabType[] = ['communities', 'posts', 'accounts'];
function parseTab(value: string | null): TabType {
return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'posts';
return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'communities';
}
const VALID_AUTHOR_SCOPES = ['anyone', 'follows', 'people'] as const;
@@ -199,7 +199,7 @@ export function SearchPage() {
const setActiveTab = useCallback((tab: TabType) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (tab === 'posts') {
if (tab === 'communities') {
next.delete('tab');
} else {
next.set('tab', tab);
@@ -1208,4 +1208,3 @@ function SaveDestinationRow({
);
}