680 lines
29 KiB
TypeScript
680 lines
29 KiB
TypeScript
import { useMemo, useCallback, useState } from 'react';
|
|
import { Link, useNavigate } from 'react-router-dom';
|
|
import { nip19 } from 'nostr-tools';
|
|
import {
|
|
ArrowLeft,
|
|
Bookmark,
|
|
CalendarDays,
|
|
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 { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
|
import { BanConfirmDialog } from '@/components/BanConfirmDialog';
|
|
import { ComposeBox } from '@/components/ComposeBox';
|
|
import { CreateGoalDialog } from '@/components/CreateGoalDialog';
|
|
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
|
|
import { NoteCard } from '@/components/NoteCard';
|
|
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
|
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useAuthors } from '@/hooks/useAuthors';
|
|
import { useComments } from '@/hooks/useComments';
|
|
import { useCommunityBookmarks } from '@/hooks/useCommunityBookmarks';
|
|
import { useCommunityEvents } from '@/hooks/useCommunityEvents';
|
|
import { useCommunityMembers } from '@/hooks/useCommunityMembers';
|
|
import { useCommunityGoals } from '@/hooks/useCommunityGoals';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useMembersOnlyFilter } from '@/hooks/useMembersOnlyFilter';
|
|
import { useNow } from '@/hooks/useNow';
|
|
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { CommunityModerationContext } from '@/contexts/CommunityModerationContext';
|
|
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
|
import { applyCommunityModerationToEvents, canBanTarget, getViewerAuthority, parseCommunityEvent, type CommunityMember } from '@/lib/communityUtils';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// ── Sub-components ────────────────────────────────────────────────────────────
|
|
|
|
function PersonRow({ pubkey, label, size = 'md', onBan }: { pubkey: string; label?: string; size?: 'sm' | 'md'; onBan?: () => void }) {
|
|
const { data } = useAuthor(pubkey);
|
|
const metadata: NostrMetadata | undefined = data?.metadata;
|
|
const name = metadata?.display_name || metadata?.name || genUserName(pubkey);
|
|
const profileUrl = useProfileUrl(pubkey, metadata);
|
|
const avatarCls = size === 'sm' ? 'size-8' : 'size-10';
|
|
const fallbackCls = size === 'sm' ? 'text-xs' : '';
|
|
|
|
return (
|
|
<div className="flex items-center gap-3 py-1">
|
|
<Link to={profileUrl} className="flex items-center gap-3 group flex-1 min-w-0">
|
|
<Avatar className={cn(avatarCls, 'ring-2 ring-background')}>
|
|
<AvatarImage src={metadata?.picture} />
|
|
<AvatarFallback className={cn('bg-muted text-muted-foreground', fallbackCls)}>
|
|
{name.charAt(0).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0 flex-1">
|
|
<p className={cn('font-medium truncate group-hover:underline', size === 'sm' ? 'text-sm' : 'text-[15px]')}>{name}</p>
|
|
{metadata?.nip05 && (
|
|
<p className="text-xs text-muted-foreground truncate">{metadata.nip05}</p>
|
|
)}
|
|
</div>
|
|
{label && (
|
|
<Badge variant="secondary" className="ml-auto capitalize text-xs shrink-0">{label}</Badge>
|
|
)}
|
|
</Link>
|
|
{onBan && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onBan(); }}
|
|
className="p-1.5 rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors shrink-0"
|
|
aria-label="Ban from community"
|
|
title="Ban from community"
|
|
>
|
|
<ShieldBan className="size-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MembersSkeleton() {
|
|
return (
|
|
<div className="space-y-4 px-5 py-4">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div key={i} className="flex items-center gap-3">
|
|
<Skeleton className="size-10 rounded-full" />
|
|
<div className="space-y-1.5 flex-1">
|
|
<Skeleton className="h-4 w-28" />
|
|
<Skeleton className="h-3 w-20" />
|
|
</div>
|
|
<Skeleton className="h-5 w-16 rounded-full" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ReplyCardSkeleton() {
|
|
return (
|
|
<div className="px-4 py-3 border-b border-border">
|
|
<div className="flex gap-3">
|
|
<Skeleton className="size-10 rounded-full shrink-0" />
|
|
<div className="flex-1 space-y-2">
|
|
<Skeleton className="h-4 w-32" />
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-2/3" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getTag(tags: string[][], name: string): string | undefined {
|
|
return tags.find(([n]) => n === name)?.[1];
|
|
}
|
|
|
|
function getCalendarEventStart(event: NostrEvent): number {
|
|
const start = getTag(event.tags, 'start');
|
|
if (!start) return 0;
|
|
|
|
if (event.kind === 31922) {
|
|
const date = new Date(`${start}T00:00:00Z`);
|
|
return isNaN(date.getTime()) ? 0 : Math.floor(date.getTime() / 1000);
|
|
}
|
|
|
|
const timestamp = parseInt(start, 10);
|
|
return isNaN(timestamp) ? 0 : timestamp;
|
|
}
|
|
|
|
// ── Main component ────────────────────────────────────────────────────────────
|
|
|
|
export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
|
const navigate = useNavigate();
|
|
const { toast } = useToast();
|
|
const { user } = useCurrentUser();
|
|
|
|
// ── Member ban dialog state ────────────────────────────────────────────────
|
|
const [banDialogOpen, setBanDialogOpen] = useState(false);
|
|
const [banTargetPubkey, setBanTargetPubkey] = useState<string | null>(null);
|
|
|
|
// ── Tab + FAB state ────────────────────────────────────────────────────────
|
|
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]);
|
|
const name = community?.name ?? 'Unnamed Community';
|
|
const description = community?.description ?? '';
|
|
const image = community?.image;
|
|
const communityATag = community?.aTag ?? '';
|
|
|
|
// Extract website URL from description
|
|
const descriptionUrl = useMemo(() => {
|
|
const urlMatch = description.match(/https?:\/\/[^\s]+/);
|
|
return sanitizeUrl(urlMatch?.[0]);
|
|
}, [description]);
|
|
|
|
const descriptionText = useMemo(() => {
|
|
if (!descriptionUrl) return description;
|
|
return description.replace(new RegExp(`\\s*${descriptionUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), '').trim();
|
|
}, [description, descriptionUrl]);
|
|
|
|
// ── Members ─────────────────────────────────────────────────────────────────
|
|
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;
|
|
|
|
// NIP-51 kind 10004 community bookmark toggle. Toasts are fired from inside
|
|
// the mutation so they survive even if this page unmounts mid-publish.
|
|
const {
|
|
isBookmarked: isCommunityBookmarked,
|
|
toggleBookmark: toggleCommunityBookmark,
|
|
} = useCommunityBookmarks();
|
|
const bookmarked = !!communityATag && isCommunityBookmarked(communityATag);
|
|
const handleToggleBookmark = useCallback(() => {
|
|
if (!user || !communityATag || toggleCommunityBookmark.isPending) return;
|
|
toggleCommunityBookmark.mutate({ aTag: communityATag });
|
|
}, [user, communityATag, toggleCommunityBookmark]);
|
|
|
|
// Batch-fetch profiles for all members
|
|
const allMemberPubkeys = useMemo(
|
|
() => membership?.members.map((m) => m.pubkey) ?? [],
|
|
[membership],
|
|
);
|
|
useAuthors(allMemberPubkeys);
|
|
|
|
// Group members by rank
|
|
const membersByRank = useMemo(() => {
|
|
if (!membership || !community) return [];
|
|
const groups = new Map<number, CommunityMember[]>();
|
|
for (const m of membership.members) {
|
|
const list = groups.get(m.rank) ?? [];
|
|
list.push(m);
|
|
groups.set(m.rank, list);
|
|
}
|
|
// Build ordered groups with labels
|
|
const result: { rank: number; label: string; members: CommunityMember[] }[] = [];
|
|
const sortedRanks = Array.from(groups.keys()).sort((a, b) => a - b);
|
|
for (const rank of sortedRanks) {
|
|
const members = groups.get(rank)!;
|
|
let label: string;
|
|
if (rank === 0) {
|
|
label = 'Leadership';
|
|
} else {
|
|
// Find the badge a-tag for this rank from community definition
|
|
const tier = community.ranks.find((r) => r.rank === rank);
|
|
// Use the badge d-tag suffix as a label hint, or fall back to "Rank N"
|
|
if (tier?.badgeATag) {
|
|
const parts = tier.badgeATag.split(':');
|
|
const dTag = parts.slice(2).join(':');
|
|
// Try to extract a human-readable name from the d-tag (after the UUID prefix)
|
|
const namePart = dTag.split('-').pop();
|
|
label = namePart ? namePart.charAt(0).toUpperCase() + namePart.slice(1) : `Rank ${rank}`;
|
|
} else {
|
|
label = `Rank ${rank}`;
|
|
}
|
|
}
|
|
result.push({ rank, label, members });
|
|
}
|
|
return result;
|
|
}, [membership, community]);
|
|
|
|
// ── Comments (NIP-22 on the community event) ───────────────────────────────
|
|
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
|
|
const { membersOnly } = useMembersOnlyFilter();
|
|
|
|
// ── Fundraising goals (NIP-75) ──────────────────────────────────────────────
|
|
const { data: goals, isLoading: goalsLoading } = useCommunityGoals(communityATag || undefined);
|
|
const { data: communityEvents, isLoading: eventsLoading } = useCommunityEvents(communityATag || undefined);
|
|
const now = useNow(60_000);
|
|
|
|
/** Check if a goal event's `closed_at` deadline has passed. */
|
|
const isExpired = useCallback((e: NostrEvent): boolean => {
|
|
const v = e.tags.find(([n]) => n === 'closed_at')?.[1];
|
|
if (!v) return false;
|
|
const ts = parseInt(v, 10);
|
|
return !isNaN(ts) && now > ts;
|
|
}, [now]);
|
|
|
|
const moderatedGoals = useMemo(
|
|
() => applyCommunityModerationToEvents(goals ?? [], moderation),
|
|
[goals, moderation],
|
|
);
|
|
const activeGoals = useMemo(() => {
|
|
const all = moderatedGoals.filter((e) => !isExpired(e));
|
|
if (!membersOnly) return all;
|
|
return all.filter((e) => rankMap.has(e.pubkey));
|
|
}, [moderatedGoals, membersOnly, rankMap, isExpired]);
|
|
const pastGoals = useMemo(() => {
|
|
const all = moderatedGoals.filter((e) => isExpired(e));
|
|
const filtered = membersOnly ? all.filter((e) => rankMap.has(e.pubkey)) : all;
|
|
// Sort by deadline descending so the most recently ended goal appears first.
|
|
return filtered.sort((a, b) => {
|
|
const aClose = parseInt(a.tags.find(([n]) => n === 'closed_at')?.[1] ?? '0', 10);
|
|
const bClose = parseInt(b.tags.find(([n]) => n === 'closed_at')?.[1] ?? '0', 10);
|
|
return bClose - aClose;
|
|
});
|
|
}, [moderatedGoals, membersOnly, rankMap, isExpired]);
|
|
|
|
const eventItems = useMemo(() => {
|
|
const moderated = applyCommunityModerationToEvents(communityEvents ?? [], moderation);
|
|
const visible = membersOnly ? moderated.filter((e) => rankMap.has(e.pubkey)) : moderated;
|
|
|
|
return [...visible].sort((a, b) => {
|
|
const aStart = getCalendarEventStart(a);
|
|
const bStart = getCalendarEventStart(b);
|
|
const aFuture = aStart >= now;
|
|
const bFuture = bStart >= now;
|
|
if (aFuture && !bFuture) return -1;
|
|
if (!aFuture && bFuture) return 1;
|
|
if (aFuture && bFuture) return aStart - bStart;
|
|
return bStart - aStart;
|
|
});
|
|
}, [communityEvents, moderation, membersOnly, rankMap, now]);
|
|
|
|
const replyTree = useMemo((): ReplyNode[] => {
|
|
if (!commentsData) return [];
|
|
const topLevel = commentsData.topLevelComments ?? [];
|
|
|
|
// Filter: omit banned events and posts by banned members, then optionally
|
|
// restrict to chain-validated members when the "members only" toggle is
|
|
// active. The member filter is a presentation-layer choice — the NIP
|
|
// recommends it as the canonical-feed default, but users may opt out.
|
|
const applyModeration = (events: NostrEvent[]): NostrEvent[] => {
|
|
const moderated = applyCommunityModerationToEvents(events, moderation);
|
|
if (!membersOnly) return moderated;
|
|
return moderated.filter((ev) => rankMap.has(ev.pubkey));
|
|
};
|
|
|
|
const buildNode = (ev: NostrEvent): ReplyNode => {
|
|
const allChildren = applyModeration(commentsData.getDirectReplies(ev.id) ?? []);
|
|
if (allChildren.length <= 1) {
|
|
return {
|
|
event: ev,
|
|
children: allChildren.map((c) => buildNode(c)),
|
|
};
|
|
}
|
|
const [first, ...rest] = allChildren;
|
|
return {
|
|
event: ev,
|
|
children: [buildNode(first)],
|
|
hiddenChildren: rest.map((c) => buildNode(c)),
|
|
};
|
|
};
|
|
|
|
return applyModeration([...topLevel])
|
|
.sort((a, b) => a.created_at - b.created_at)
|
|
.map((r) => buildNode(r));
|
|
}, [commentsData, moderation, membersOnly, rankMap]);
|
|
|
|
// ── Share handler ───────────────────────────────────────────────────────────
|
|
const handleShare = useCallback(async () => {
|
|
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
|
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]);
|
|
|
|
// ── 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} />
|
|
: activeTab === 'members'
|
|
? <UserPlus className="size-5" />
|
|
: undefined; // default Plus icon for comments
|
|
|
|
useLayoutOptions({
|
|
showFAB:
|
|
activeTab === 'comments'
|
|
|| activeTab === 'fundraising'
|
|
|| activeTab === 'events'
|
|
|| (activeTab === 'members' && canAddMembers),
|
|
fabKind: activeTab === 'events' ? 31923 : 1,
|
|
onFabClick: activeTab === 'events' ? undefined : handleFabClick,
|
|
fabIcon,
|
|
});
|
|
|
|
const moderationCtx = useMemo(
|
|
() => communityATag ? { communityATag, moderation, rankMap } : null,
|
|
[communityATag, moderation, rankMap],
|
|
);
|
|
|
|
// ── Render ──────────────────────────────────────────────────────────────────
|
|
return (
|
|
<div className="max-w-2xl mx-auto pb-16">
|
|
{/* ── Top bar ── */}
|
|
<div className="flex items-center gap-4 px-4 pt-4 pb-3">
|
|
<button
|
|
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
|
|
className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
|
|
aria-label="Go back"
|
|
>
|
|
<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>
|
|
)}
|
|
{user && communityATag && (
|
|
<button
|
|
className="p-2 rounded-full hover:bg-secondary/60 transition-colors disabled:opacity-50 disabled:pointer-events-none"
|
|
onClick={handleToggleBookmark}
|
|
disabled={toggleCommunityBookmark.isPending}
|
|
aria-label={bookmarked ? 'Remove community bookmark' : 'Bookmark community'}
|
|
aria-pressed={bookmarked}
|
|
aria-busy={toggleCommunityBookmark.isPending}
|
|
>
|
|
<Bookmark className={cn('size-5', bookmarked && 'fill-current')} />
|
|
</button>
|
|
)}
|
|
<button
|
|
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
|
|
onClick={handleShare}
|
|
aria-label="Share"
|
|
>
|
|
<Share2 className="size-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── Hero image ── */}
|
|
{image ? (
|
|
<div className="relative aspect-[21/9] w-full overflow-hidden">
|
|
<img src={image} alt={name} className="w-full h-full object-cover" />
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
|
<div className="absolute bottom-0 left-0 right-0 px-5 pb-4">
|
|
<h2 className="text-2xl font-bold text-white leading-tight drop-shadow-lg">{name}</h2>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="relative aspect-[21/9] w-full bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
|
|
<Users className="size-16 text-primary/20" />
|
|
<div className="absolute bottom-0 left-0 right-0 px-5 pb-4">
|
|
<h2 className="text-2xl font-bold leading-tight">{name}</h2>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Community info ── */}
|
|
<div className="px-5 mt-4 space-y-4">
|
|
{/* Description */}
|
|
{descriptionText && (
|
|
<p className="text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">{descriptionText}</p>
|
|
)}
|
|
|
|
{/* Founder + community-wide filter toggle. The toggle sits
|
|
right-justified on the same row as the "Founded by" label so
|
|
it clearly scopes the whole community (every content feed
|
|
below the tabs), not any one tab. */}
|
|
<div>
|
|
<div className="flex items-start justify-between gap-2 mb-1">
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Founded by</p>
|
|
<MembersOnlyToggle className="-my-2 -mr-2" />
|
|
</div>
|
|
<PersonRow pubkey={event.pubkey} />
|
|
</div>
|
|
|
|
{/* ── Tabs ── */}
|
|
<CommunityModerationContext.Provider value={moderationCtx}>
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="-mx-5">
|
|
<TabsList className="w-full rounded-none border-b border-border bg-transparent p-0 h-auto">
|
|
<TabsTrigger
|
|
value="members"
|
|
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
|
|
>
|
|
<Users className="size-4 mr-1.5" />
|
|
Members
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="comments"
|
|
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
|
|
>
|
|
<MessageCircle className="size-4 mr-1.5" />
|
|
Comments
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="fundraising"
|
|
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
|
|
>
|
|
<Target className="size-4 mr-1.5" />
|
|
Fundraising
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="events"
|
|
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
|
|
>
|
|
<CalendarDays className="size-4 mr-1.5" />
|
|
Events
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* ── Members tab ── */}
|
|
<TabsContent value="members" className="mt-0">
|
|
{membersLoading ? (
|
|
<MembersSkeleton />
|
|
) : membersByRank.length === 0 ? (
|
|
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
|
No members found.
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-border">
|
|
{membersByRank.map(({ rank, label, members }) => (
|
|
<section key={rank} className="px-5 py-4">
|
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3 flex items-center gap-2">
|
|
{rank === 0 ? <Crown className="size-3.5 text-amber-500" /> : <Shield className="size-3.5" />}
|
|
{label}
|
|
<span className="text-muted-foreground/60 font-normal">({members.length})</span>
|
|
</h3>
|
|
<div className="space-y-0.5">
|
|
{members.map((m) => {
|
|
let roleLabel: string | undefined;
|
|
if (rank === 0) {
|
|
roleLabel = m.pubkey === event.pubkey ? 'Founder' : 'Moderator';
|
|
}
|
|
// Determine if the current user can ban this member
|
|
const canBanMember = viewerMember
|
|
&& m.pubkey !== user?.pubkey
|
|
&& canBanTarget(viewerMember, m);
|
|
return (
|
|
<PersonRow
|
|
key={m.pubkey}
|
|
pubkey={m.pubkey}
|
|
label={roleLabel}
|
|
size="sm"
|
|
onBan={canBanMember ? () => {
|
|
setBanTargetPubkey(m.pubkey);
|
|
setBanDialogOpen(true);
|
|
} : undefined}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* ── Comments tab ── */}
|
|
<TabsContent value="comments" className="mt-0">
|
|
<ComposeBox compact replyTo={event} />
|
|
|
|
{commentsLoading ? (
|
|
<div className="divide-y divide-border">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<ReplyCardSkeleton key={i} />
|
|
))}
|
|
</div>
|
|
) : replyTree.length > 0 ? (
|
|
<ThreadedReplyList roots={replyTree} />
|
|
) : membersOnly && commentsData && (commentsData.topLevelComments?.length ?? 0) > 0 ? (
|
|
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
|
No comments from community members yet. Toggle the shield icon to see all comments.
|
|
</div>
|
|
) : (
|
|
<div className="py-12 text-center text-muted-foreground text-sm">
|
|
No comments yet. Be the first to comment!
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* ── Fundraising tab ── */}
|
|
<TabsContent value="fundraising" className="mt-0">
|
|
{goalsLoading ? (
|
|
<div className="divide-y divide-border">
|
|
{Array.from({ length: 2 }).map((_, i) => (
|
|
<ReplyCardSkeleton key={i} />
|
|
))}
|
|
</div>
|
|
) : activeGoals.length === 0 && pastGoals.length === 0 ? (
|
|
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
|
{membersOnly && (goals ?? []).length > 0
|
|
? 'No fundraising goals from community members yet. Toggle the shield icon to see all goals.'
|
|
: <>No fundraising goals yet.{user ? ' Create one to get started!' : ''}</>}
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-border">
|
|
{/* Active goals first */}
|
|
{activeGoals.map((e) => (
|
|
<NoteCard key={e.id} event={e} />
|
|
))}
|
|
|
|
{/* Past/expired goals */}
|
|
{pastGoals.length > 0 && activeGoals.length > 0 && (
|
|
<div className="px-5 pt-4 pb-1">
|
|
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
|
Past Goals
|
|
</h4>
|
|
</div>
|
|
)}
|
|
{pastGoals.map((e) => (
|
|
<NoteCard key={e.id} event={e} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* ── Events tab ── */}
|
|
<TabsContent value="events" className="mt-0">
|
|
{eventsLoading ? (
|
|
<div className="divide-y divide-border">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<ReplyCardSkeleton key={i} />
|
|
))}
|
|
</div>
|
|
) : eventItems.length === 0 ? (
|
|
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
|
{membersOnly && (communityEvents ?? []).length > 0
|
|
? 'No events from community members yet. Toggle the shield icon to see all events.'
|
|
: <>No events yet.{user ? ' Create one to get started!' : ''}</>}
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-border">
|
|
{eventItems.map((e) => (
|
|
<NoteCard key={e.id} event={e} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</CommunityModerationContext.Provider>
|
|
</div>
|
|
|
|
{/* Member ban confirmation dialog */}
|
|
{banTargetPubkey && communityATag && (
|
|
<BanConfirmDialog
|
|
mode="member"
|
|
targetPubkey={banTargetPubkey}
|
|
communityATag={communityATag}
|
|
open={banDialogOpen}
|
|
onOpenChange={(open) => {
|
|
setBanDialogOpen(open);
|
|
if (!open) setBanTargetPubkey(null);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* FAB-triggered compose modal for the comments tab */}
|
|
<ReplyComposeModal
|
|
event={event}
|
|
open={composeOpen}
|
|
onOpenChange={setComposeOpen}
|
|
/>
|
|
|
|
{/* FAB-triggered goal creation dialog for the fundraising tab */}
|
|
{communityATag && (
|
|
<CreateGoalDialog
|
|
communityATag={communityATag}
|
|
open={goalDialogOpen}
|
|
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>
|
|
);
|
|
}
|