From 48794fa3b4ba23754c7ed20524bde859076ae718 Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 21 May 2026 10:35:47 -0700 Subject: [PATCH 01/17] Pin campaign activity updates --- NIP.md | 26 ++++++ src/components/ThreadedReplyList.tsx | 23 +++-- src/hooks/useCampaignPinnedEvents.ts | 106 ++++++++++++++++++++++ src/pages/CampaignDetailPage.tsx | 126 ++++++++++++++++++++++++++- 4 files changed, 273 insertions(+), 8 deletions(-) create mode 100644 src/hooks/useCampaignPinnedEvents.ts diff --git a/NIP.md b/NIP.md index 300653f1..5862fc40 100644 --- a/NIP.md +++ b/NIP.md @@ -322,6 +322,32 @@ This mirrors the community batch-zap pattern documented in the kind 8333 section Clients MUST verify each kind 8333 event on-chain before counting it toward the campaign total, per the verification rules in the kind 8333 section. +**Fetch pinned campaign activity:** + +Campaign creators MAY pin important activity feed events (comments, updates, or donation receipts) with a NIP-78 app-specific data event (`kind: 30078`) authored by the campaign creator. The `d` tag is scoped to the campaign coordinate: + +```json +{ + "kind": 30078, + "pubkey": "", + "content": "{\"pinnedEvents\":[\"\",\"\"]}", + "tags": [ + ["d", "agora-campaign-pins:30223::"], + ["a", "30223::"], + ["k", "30223"], + ["alt", "Pinned campaign activity"] + ] +} +``` + +Clients SHOULD query the pin list with: + +```json +{ "kinds": [30078], "authors": [""], "#d": ["agora-campaign-pins:30223::"], "limit": 1 } +``` + +The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned event removes it. Clients SHOULD ignore pin lists not authored by the campaign creator. + ### Client Behavior - **Recipient validity:** clients SHOULD reject `p` tag entries whose pubkey is not 64 hex characters and SHOULD ignore weights that are not positive finite decimals. diff --git a/src/components/ThreadedReplyList.tsx b/src/components/ThreadedReplyList.tsx index 9a1beb01..b97bf476 100644 --- a/src/components/ThreadedReplyList.tsx +++ b/src/components/ThreadedReplyList.tsx @@ -1,4 +1,5 @@ import type { NostrEvent } from '@nostrify/nostrify'; +import type { ReactNode } from 'react'; import { useState } from 'react'; import { NoteCard } from '@/components/NoteCard'; import { cn } from '@/lib/utils'; @@ -14,17 +15,17 @@ export interface ReplyNode { } /** Renders a fully threaded reply tree with collapsible deep branches. */ -export function ThreadedReplyList({ roots }: { roots: ReplyNode[] }) { +export function ThreadedReplyList({ roots, renderItemHeader }: { roots: ReplyNode[]; renderItemHeader?: (event: NostrEvent) => ReactNode }) { return (
{roots.map((node) => ( - + ))}
); } -function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: number; depthless?: boolean }) { +function ReplyThread({ node, depth, depthless, renderItemHeader }: { node: ReplyNode; depth: number; depthless?: boolean; renderItemHeader?: (event: NostrEvent) => ReactNode }) { const [expanded, setExpanded] = useState(false); const [showHidden, setShowHidden] = useState(false); const hasChildren = node.children.length > 0; @@ -34,6 +35,7 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe if (shouldCollapse) { return (
+ {renderItemHeader?.(node.event)} setExpanded(true)} isLast />
@@ -41,7 +43,12 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe } if (!hasChildren) { - return ; + return ( +
+ {renderItemHeader?.(node.event)} + +
+ ); } // Once expanded past the depth cap, skip further caps for this subtree @@ -49,6 +56,7 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe return (
+ {renderItemHeader?.(node.event)} {/* Show hidden sibling count between parent and first child */} {hiddenCount > 0 && !showHidden && ( @@ -56,10 +64,13 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe )} {/* Revealed hidden siblings render as threaded items before the inline child */} {showHidden && node.hiddenChildren!.map((child) => ( - +
+ {renderItemHeader?.(child.event)} + +
))} {node.children.map((child) => ( - + ))}
); diff --git a/src/hooks/useCampaignPinnedEvents.ts b/src/hooks/useCampaignPinnedEvents.ts new file mode 100644 index 00000000..5cd67c03 --- /dev/null +++ b/src/hooks/useCampaignPinnedEvents.ts @@ -0,0 +1,106 @@ +import { useNostr } from '@nostrify/react'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; + +const CAMPAIGN_PIN_LIST_KIND = 30078; +const CAMPAIGN_PIN_D_TAG_PREFIX = 'agora-campaign-pins:'; + +function campaignPinDTag(campaignATag: string): string { + return `${CAMPAIGN_PIN_D_TAG_PREFIX}${campaignATag}`; +} + +function parsePinnedIds(event: NostrEvent | null | undefined): string[] { + if (!event) return []; + + try { + const parsed = JSON.parse(event.content) as { pinnedEvents?: unknown }; + if (!Array.isArray(parsed.pinnedEvents)) return []; + return parsed.pinnedEvents.filter((id): id is string => typeof id === 'string'); + } catch { + return []; + } +} + +export function useCampaignPinnedEvents(campaignATag: string, campaignAuthorPubkey: string) { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const queryClient = useQueryClient(); + const { mutateAsync: publishEvent } = useNostrPublish(); + const dTag = campaignPinDTag(campaignATag); + const canManagePins = user?.pubkey === campaignAuthorPubkey; + + const pinnedListQuery = useQuery({ + queryKey: ['campaign-pinned-events-list', campaignATag, campaignAuthorPubkey], + queryFn: async ({ signal }) => { + const events = await nostr.query( + [{ kinds: [CAMPAIGN_PIN_LIST_KIND], authors: [campaignAuthorPubkey], '#d': [dTag], limit: 1 }], + { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, + ); + return events[0] ?? null; + }, + staleTime: 30_000, + }); + + const pinnedIds = parsePinnedIds(pinnedListQuery.data); + + const pinnedEventsQuery = useQuery({ + queryKey: ['campaign-pinned-events', campaignATag, pinnedIds], + queryFn: async ({ signal }) => { + if (pinnedIds.length === 0) return []; + const events = await nostr.query( + [{ ids: pinnedIds, limit: pinnedIds.length }], + { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, + ); + return events.sort((a, b) => pinnedIds.indexOf(a.id) - pinnedIds.indexOf(b.id)); + }, + enabled: pinnedIds.length > 0, + staleTime: 30_000, + }); + + const togglePin = useMutation({ + mutationFn: async (eventId: string) => { + if (!user) throw new Error('User is not logged in'); + if (user.pubkey !== campaignAuthorPubkey) throw new Error('Only the campaign author can pin updates.'); + + const prev = await fetchFreshEvent(nostr, { + kinds: [CAMPAIGN_PIN_LIST_KIND], + authors: [campaignAuthorPubkey], + '#d': [dTag], + }); + + const current = parsePinnedIds(prev); + const next = current.includes(eventId) + ? current.filter((id) => id !== eventId) + : [eventId, ...current.filter((id) => id !== eventId)]; + + await publishEvent({ + kind: CAMPAIGN_PIN_LIST_KIND, + content: JSON.stringify({ pinnedEvents: next }), + tags: [ + ['d', dTag], + ['a', campaignATag], + ['k', campaignATag.split(':')[0] ?? '30223'], + ['alt', 'Pinned campaign activity'], + ], + prev: prev ?? undefined, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['campaign-pinned-events-list', campaignATag, campaignAuthorPubkey] }); + queryClient.invalidateQueries({ queryKey: ['campaign-pinned-events', campaignATag] }); + }, + }); + + return { + pinnedIds, + pinnedEvents: pinnedEventsQuery.data ?? [], + isLoading: pinnedListQuery.isLoading || pinnedEventsQuery.isLoading, + isPinned: (eventId: string) => pinnedIds.includes(eventId), + canManagePins, + togglePin, + }; +} diff --git a/src/pages/CampaignDetailPage.tsx b/src/pages/CampaignDetailPage.tsx index ac9233c1..55c6b30e 100644 --- a/src/pages/CampaignDetailPage.tsx +++ b/src/pages/CampaignDetailPage.tsx @@ -12,6 +12,7 @@ import { HandHeart, MapPin, Pencil, + Pin, Share2, Users, } from 'lucide-react'; @@ -51,6 +52,7 @@ import { useAuthor } from '@/hooks/useAuthor'; import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; import { useCampaign } from '@/hooks/useCampaign'; import { useCampaignDonations } from '@/hooks/useCampaignDonations'; +import { useCampaignPinnedEvents } from '@/hooks/useCampaignPinnedEvents'; import { useComments } from '@/hooks/useComments'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useEventStats } from '@/hooks/useTrending'; @@ -98,6 +100,28 @@ function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } return { label: `Ends ${new Date(unixSeconds * 1000).toLocaleDateString()}`, isPast: false }; } +function collectReplyEvents(nodes: ReplyNode[], out = new Map()): Map { + for (const node of nodes) { + out.set(node.event.id, node.event); + collectReplyEvents(node.children, out); + if (node.hiddenChildren) collectReplyEvents(node.hiddenChildren, out); + } + return out; +} + +function removePinnedReplyNodes(nodes: ReplyNode[], pinnedIds: Set): ReplyNode[] { + return nodes.flatMap((node): ReplyNode[] => { + if (pinnedIds.has(node.event.id)) return []; + return [{ + ...node, + children: removePinnedReplyNodes(node.children, pinnedIds), + hiddenChildren: node.hiddenChildren + ? removePinnedReplyNodes(node.hiddenChildren, pinnedIds) + : undefined, + }]; + }); +} + export function CampaignDetailPage({ pubkey, identifier, relays }: CampaignDetailPageProps) { // Drop the default 600px column cap and the default right widget sidebar // — this page renders its own GoFundMe-style 2-column layout (article on @@ -146,6 +170,13 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { campaign.event, 500, ); + const { + pinnedIds, + pinnedEvents, + isPinned, + canManagePins, + togglePin, + } = useCampaignPinnedEvents(campaign.aTag, campaign.pubkey); // Aggregate kind 8333 donation receipts by `(txid, donor)` so each // donation surfaces as a single event in the donor list and the inline @@ -208,6 +239,21 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { ); }, [commentsData, donationReceipts]); + const pinnedIdSet = useMemo(() => new Set(pinnedIds), [pinnedIds]); + const feedEventsById = useMemo(() => collectReplyEvents(replyTree), [replyTree]); + + const activityTree = useMemo((): ReplyNode[] => { + const pinnedEventNodes = pinnedIds + .map((id) => feedEventsById.get(id) ?? pinnedEvents.find((event) => event.id === id)) + .filter((event): event is NostrEvent => !!event) + .map((event): ReplyNode => ({ event, children: [] })); + + return [ + ...pinnedEventNodes, + ...removePinnedReplyNodes(replyTree, pinnedIdSet), + ]; + }, [feedEventsById, pinnedEvents, pinnedIdSet, pinnedIds, replyTree]); + // Engagement counters above the action bar. Zaps are intentionally excluded // for campaigns — donations are on-chain (kind 8333), so showing a zap // count here would suggest the wrong CTA. @@ -429,9 +475,31 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { ))} - ) : replyTree.length > 0 ? ( + ) : activityTree.length > 0 ? (
- + ( + { + const wasPinned = isPinned(event.id); + togglePin.mutate(event.id, { + onSuccess: () => { + toast({ title: wasPinned ? 'Unpinned from campaign' : 'Pinned to campaign' }); + }, + onError: () => { + toast({ title: 'Failed to update campaign pins', variant: 'destructive' }); + }, + }); + }} + /> + )} + />
) : ( + )} + + ); +} + // ───────────────────────────────────────────────────────────────────── // Hero // ───────────────────────────────────────────────────────────────────── From f0724f705f9fb943dd5bd60cd891c6052f3af813 Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 21 May 2026 10:39:25 -0700 Subject: [PATCH 02/17] Refine campaigner activity badge --- src/pages/CampaignDetailPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/CampaignDetailPage.tsx b/src/pages/CampaignDetailPage.tsx index 55c6b30e..44ce3cc9 100644 --- a/src/pages/CampaignDetailPage.tsx +++ b/src/pages/CampaignDetailPage.tsx @@ -619,8 +619,8 @@ function CampaignActivityItemHeader({ )} {isCampaignAuthor && ( - - Campaign author + + Campaigner )} From c6ca9b8042e3581ae74a1c61d9e7b30b4d63e1d2 Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 21 May 2026 10:42:07 -0700 Subject: [PATCH 03/17] Add campaigner badge icon --- src/pages/CampaignDetailPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/CampaignDetailPage.tsx b/src/pages/CampaignDetailPage.tsx index 44ce3cc9..4b26290e 100644 --- a/src/pages/CampaignDetailPage.tsx +++ b/src/pages/CampaignDetailPage.tsx @@ -14,6 +14,7 @@ import { Pencil, Pin, Share2, + ShieldCheck, Users, } from 'lucide-react'; @@ -619,7 +620,8 @@ function CampaignActivityItemHeader({ )} {isCampaignAuthor && ( - + + Campaigner )} From dc959f636088c92eb9bfa97a5f94eab9fb471340 Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 21 May 2026 10:45:42 -0700 Subject: [PATCH 04/17] Move campaign actions below hero --- src/pages/CampaignDetailPage.tsx | 91 +++++--------------------------- 1 file changed, 14 insertions(+), 77 deletions(-) diff --git a/src/pages/CampaignDetailPage.tsx b/src/pages/CampaignDetailPage.tsx index 4b26290e..e3cd42c8 100644 --- a/src/pages/CampaignDetailPage.tsx +++ b/src/pages/CampaignDetailPage.tsx @@ -43,10 +43,6 @@ import { PostActionBar } from '@/components/PostActionBar'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { NoteMoreMenu } from '@/components/NoteMoreMenu'; import { Progress } from '@/components/ui/progress'; -import { - InteractionsModal, - type InteractionTab, -} from '@/components/InteractionsModal'; import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList'; import { useArchiveCampaign } from '@/hooks/useArchiveCampaign'; import { useAuthor } from '@/hooks/useAuthor'; @@ -153,18 +149,11 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { const [donateOpen, setDonateOpen] = useState(false); const [replyOpen, setReplyOpen] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); - const [interactionsOpen, setInteractionsOpen] = useState(false); const [storyExpanded, setStoryExpanded] = useState(false); - const [interactionsTab, setInteractionsTab] = useState('reposts'); const [archiveConfirmOpen, setArchiveConfirmOpen] = useState(false); const archiveMutation = useArchiveCampaign(); - const openInteractions = (tab: InteractionTab) => { - setInteractionsTab(tab); - setInteractionsOpen(true); - }; - const { data: engagementStats } = useEventStats(campaign.event.id, campaign.event); const { data: commentsData, isLoading: commentsLoading } = useComments( @@ -255,14 +244,6 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { ]; }, [feedEventsById, pinnedEvents, pinnedIdSet, pinnedIds, replyTree]); - // Engagement counters above the action bar. Zaps are intentionally excluded - // for campaigns — donations are on-chain (kind 8333), so showing a zap - // count here would suggest the wrong CTA. - const hasStats = - !!engagementStats?.replies || - !!engagementStats?.reposts || - !!engagementStats?.quotes || - !!engagementStats?.reactions; const cover = sanitizeUrl(campaign.image); const creatorMetadata = author.data?.metadata; const creatorName = @@ -387,6 +368,18 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { onReopen={handleToggleArchive} /> +
+
+ setReplyOpen(true)} + onMore={() => setMoreMenuOpen(true)} + /> +
+
+ {/* Two-column body. On mobile the right column collapses inline immediately below the hero so the donate CTA stays above the fold. On lg+ the right column sticks to the viewport edge of @@ -405,59 +398,9 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { onToggle={() => setStoryExpanded((v) => !v)} /> - {/* Engagement: stats counters, action bar, threaded replies - + donation receipts interleaved. */} + {/* Activity: threaded replies + donation receipts interleaved. */}
-
- {hasStats && ( -
- {engagementStats?.reposts ? ( - - ) : null} - {engagementStats?.quotes ? ( - - ) : null} - {engagementStats?.reactions ? ( - - ) : null} -
- )} - - setReplyOpen(true)} - onMore={() => setMoreMenuOpen(true)} - className={hasStats ? 'pt-3 border-t border-border/60' : undefined} - /> -
- -
+

Comments & donations @@ -558,12 +501,6 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { open={moreMenuOpen} onOpenChange={setMoreMenuOpen} /> - From 1a53f3047d4256df485338723199047eba277c5c Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 21 May 2026 10:57:04 -0700 Subject: [PATCH 05/17] Add campaign activity composer --- src/components/DetailCommentComposer.tsx | 32 ++++++++++++++++++++++++ src/pages/CampaignDetailPage.tsx | 7 ++++++ 2 files changed, 39 insertions(+) create mode 100644 src/components/DetailCommentComposer.tsx diff --git a/src/components/DetailCommentComposer.tsx b/src/components/DetailCommentComposer.tsx new file mode 100644 index 00000000..1e8e319a --- /dev/null +++ b/src/components/DetailCommentComposer.tsx @@ -0,0 +1,32 @@ +import type { NostrEvent } from '@nostrify/nostrify'; + +import { ComposeBox } from '@/components/ComposeBox'; +import { cn } from '@/lib/utils'; + +interface DetailCommentComposerProps { + event: NostrEvent; + placeholder?: string; + onSuccess?: () => void; + className?: string; +} + +export function DetailCommentComposer({ + event, + placeholder = "What's on your mind?", + onSuccess, + className, +}: DetailCommentComposerProps) { + return ( +
+ +
+ ); +} diff --git a/src/pages/CampaignDetailPage.tsx b/src/pages/CampaignDetailPage.tsx index e3cd42c8..2601db95 100644 --- a/src/pages/CampaignDetailPage.tsx +++ b/src/pages/CampaignDetailPage.tsx @@ -39,6 +39,7 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { DonateDialog } from '@/components/DonateDialog'; +import { DetailCommentComposer } from '@/components/DetailCommentComposer'; import { PostActionBar } from '@/components/PostActionBar'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { NoteMoreMenu } from '@/components/NoteMoreMenu'; @@ -413,6 +414,12 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { ) : null}

+ queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] })} + /> + {commentsLoading && statsLoading && replyTree.length === 0 ? (
{Array.from({ length: 3 }).map((_, i) => ( From 0f85584294489ed76dbe79728e820d432766e150 Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 21 May 2026 10:59:57 -0700 Subject: [PATCH 06/17] Reuse detail comment composer --- src/components/CommunityDetailPage.tsx | 83 +++++--------------------- src/pages/ActionDetailPage.tsx | 76 ++++++----------------- 2 files changed, 35 insertions(+), 124 deletions(-) diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index b02698a4..c503fc84 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -25,6 +25,7 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; import { CampaignCard } from '@/components/CampaignCard'; import { PeopleAvatarStack } from '@/components/PeopleAvatarStack'; import { PostActionBar } from '@/components/PostActionBar'; +import { DetailCommentComposer } from '@/components/DetailCommentComposer'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -35,7 +36,6 @@ import { Skeleton } from '@/components/ui/skeleton'; import { DonateDialog } from '@/components/DonateDialog'; import { NoteContent } from '@/components/NoteContent'; import { FollowToggleButton } from '@/components/FollowButton'; -import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal'; import { NoteMoreMenu } from '@/components/NoteMoreMenu'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList'; @@ -618,8 +618,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { const [donateOpen, setDonateOpen] = useState(false); const [replyOpen, setReplyOpen] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); - const [interactionsOpen, setInteractionsOpen] = useState(false); - const [interactionsTab, setInteractionsTab] = useState('reposts'); // Parse community definition const community = useMemo(() => parseCommunityEvent(event), [event]); @@ -765,21 +763,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { }, [community, event.kind, event.pubkey]); // ── Engagement stats for the community event itself ────────────────────── - // Pulled from NIP-85. Used both for the inline counters above the action - // bar and for the threaded-comments header. Matches the rhythm of the - // campaign and pledge detail pages. + // Pulled from NIP-85 for the threaded-comments header. const { data: engagementStats, isLoading: statsLoading } = useEventStats(event.id, event); - const hasStats = - !!engagementStats?.replies || - !!engagementStats?.reposts || - !!engagementStats?.quotes || - !!engagementStats?.reactions; - - const openInteractions = useCallback((tab: InteractionTab) => { - setInteractionsTab(tab); - setInteractionsOpen(true); - }, []); - const replyTree = useMemo((): ReplyNode[] => { if (!commentsData) return []; const topLevel = commentsData.topLevelComments ?? []; @@ -977,6 +962,16 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
+
+ setReplyOpen(true)} + onMore={() => setMoreMenuOpen(true)} + /> +
+ {/* ── Body — single column, pledge-detail-style ─────────────────── */}
{/* Donate (when there's a member set) and Share buttons. Sits @@ -1022,48 +1017,9 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { now={now} /> - {/* Engagement card — stats counters + post action bar. Matches - the pledge / campaign detail layout. No funding progress bar - here; an organization isn't a fundraising target itself. */} + {/* Comments — NIP-22 thread on the community event itself. */}
-
- {hasStats && ( -
- {engagementStats?.reposts ? ( - - ) : null} - {engagementStats?.quotes ? ( - - ) : null} - {engagementStats?.reactions ? ( - - ) : null} -
- )} - - setReplyOpen(true)} - onMore={() => setMoreMenuOpen(true)} - className={hasStats ? 'pt-3 border-t border-border/60' : undefined} - /> -
- - {/* Comments — NIP-22 thread on the community event itself. - Member-filter aware (see replyTree) and routed through - CommunityModerationContext so per-reply ban actions work. */} -
+

Comments

{engagementStats?.replies ? ( @@ -1074,6 +1030,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { ) : null}
+ + {commentsLoading && statsLoading && replyTree.length === 0 ? (
{Array.from({ length: 3 }).map((_, i) => ( @@ -1195,15 +1153,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { onOpenChange={setMoreMenuOpen} /> - {/* Tapping a repost / quote / like counter on the stats row opens - a modal listing the people who took that action. */} - -
); diff --git a/src/pages/ActionDetailPage.tsx b/src/pages/ActionDetailPage.tsx index 98327f5e..30305600 100644 --- a/src/pages/ActionDetailPage.tsx +++ b/src/pages/ActionDetailPage.tsx @@ -17,7 +17,6 @@ import { useAction, type Action } from '@/hooks/useActions'; import { useAuthor } from '@/hooks/useAuthor'; import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; import { useComments } from '@/hooks/useComments'; -import { useEventStats } from '@/hooks/useTrending'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { useSubmissionZapTotals } from '@/hooks/useSubmissionZapTotals'; import { useToast } from '@/hooks/useToast'; @@ -33,13 +32,10 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; import { Skeleton } from '@/components/ui/skeleton'; +import { DetailCommentComposer } from '@/components/DetailCommentComposer'; import { PostActionBar } from '@/components/PostActionBar'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { NoteMoreMenu } from '@/components/NoteMoreMenu'; -import { - InteractionsModal, - type InteractionTab, -} from '@/components/InteractionsModal'; import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList'; import NotFound from '@/pages/NotFound'; @@ -81,13 +77,10 @@ function PledgeDetailContent({ action }: { action: Action }) { const author = useAuthor(action.pubkey); const navigate = useNavigate(); const { toast } = useToast(); - const { data: engagementStats } = useEventStats(action.event.id, action.event); const { data: commentsData, isLoading: commentsLoading } = useComments(action.event, 500); const [replyOpen, setReplyOpen] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); - const [interactionsOpen, setInteractionsOpen] = useState(false); - const [interactionsTab, setInteractionsTab] = useState('reposts'); const topLevel = useMemo( () => commentsData?.topLevelComments ?? [], @@ -136,11 +129,6 @@ function PledgeDetailContent({ action }: { action: Action }) { const deadline = action.deadline ? formatDeadline(action.deadline) : null; const cover = sanitizeUrl(action.image); const progressValue = action.bounty > 0 ? Math.min(100, Math.round((fundedSats / action.bounty) * 100)) : 0; - const hasStats = - !!engagementStats?.replies || - !!engagementStats?.reposts || - !!engagementStats?.quotes || - !!engagementStats?.reactions; const naddr = nip19.naddrEncode({ kind: 36639, @@ -156,11 +144,6 @@ function PledgeDetailContent({ action }: { action: Action }) { [action.event], ); - const openInteractions = (tab: InteractionTab) => { - setInteractionsTab(tab); - setInteractionsOpen(true); - }; - const handleShare = async () => { const url = `${window.location.origin}/${naddr}`; try { @@ -187,6 +170,17 @@ function PledgeDetailContent({ action }: { action: Action }) { onBack={() => navigate(-1)} /> +
+
+ setReplyOpen(true)} + onMore={() => setMoreMenuOpen(true)} + /> +
+
+
@@ -205,39 +199,7 @@ function PledgeDetailContent({ action }: { action: Action }) { 0} />
-
- {hasStats && ( -
- {engagementStats?.reposts ? ( - - ) : null} - {engagementStats?.quotes ? ( - - ) : null} - {engagementStats?.reactions ? ( - - ) : null} -
- )} - - setReplyOpen(true)} - onMore={() => setMoreMenuOpen(true)} - /> -
- -
+

Submissions

{topLevel.length > 0 ? ( @@ -247,6 +209,12 @@ function PledgeDetailContent({ action }: { action: Action }) { ) : null}
+ + {commentsLoading && replyTree.length === 0 ? (
{Array.from({ length: 3 }).map((_, i) => )} @@ -289,12 +257,6 @@ function PledgeDetailContent({ action }: { action: Action }) { - ); } From ad2e9a2ee96de1bd4f3c18538782bdd23a958511 Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 21 May 2026 11:03:03 -0700 Subject: [PATCH 07/17] Align event detail comments layout --- src/pages/PostDetailPage.tsx | 60 +++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index a245106e..335a0aa3 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -37,6 +37,7 @@ const CustomNipCard = lazy(() => import("@/components/CustomNipCard").then(m => import { FileMetadataContent } from "@/components/FileMetadataContent"; import { FollowPackContent } from "@/components/FollowPackContent"; import { GoalCard } from "@/components/GoalCard"; +import { DetailCommentComposer } from "@/components/DetailCommentComposer"; import { FollowPackDetailContent } from "@/components/FollowPackDetailContent"; import { FoundLogContent } from "@/components/FoundLogContent"; import { GeocacheContent } from "@/components/GeocacheContent"; @@ -436,8 +437,8 @@ function ProfileBadgesDetailView({ event }: { event: NostrEvent }) { }, [commentsData, muteItems]); return ( -
- +
+
@@ -1397,13 +1398,13 @@ function PostDetailContent({ event }: { event: NostrEvent }) { return ( -
+
{/* Focused post card — ancestor previews, ancestor thread, and the focused event itself share one rounded surface so the page reads as "thread context → this post" instead of an edge-to-edge Twitter timeline. Replies sit in their own FeedCard below. */} - + {/* Content preview for kind 1111 comments: external content, profile, or community */} {externalIdentifier && ( @@ -2075,22 +2076,51 @@ function PostDetailContent({ event }: { event: NostrEvent }) { {/* Replies */} -
+
+
+

+ {isKind1 ? 'Replies' : 'Comments'} +

+ {replyTree.length > 0 ? ( + + {formatNumber(replyTree.length)}{' '} + {replyTree.length === 1 + ? isKind1 ? 'reply' : 'comment' + : isKind1 ? 'replies' : 'comments'} + + ) : null} +
+ + + {repliesLoading ? ( - +
{Array.from({ length: 3 }).map((_, i) => ( ))} - - ) : replyTree.length > 0 ? ( - - - - ) : !parentEventId ? ( -
- No replies yet. Be the first to reply!
- ) : null} + ) : replyTree.length > 0 ? ( +
+ +
+ ) : ( + + )}
From b32ae751a2027ceaa0bf0649f704e861263c26a5 Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 21 May 2026 11:09:49 -0700 Subject: [PATCH 08/17] Use megaphone for boost action --- src/components/RepostMenu.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/RepostMenu.tsx b/src/components/RepostMenu.tsx index 53e75bdb..35931fab 100644 --- a/src/components/RepostMenu.tsx +++ b/src/components/RepostMenu.tsx @@ -1,4 +1,4 @@ -import { Quote, Rocket, Undo2 } from 'lucide-react'; +import { Megaphone, Quote, Undo2 } from 'lucide-react'; import { RepostIcon } from '@/components/icons/RepostIcon'; import { useState } from 'react'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -195,7 +195,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) { }} className="flex items-center gap-3 w-full px-4 py-3 text-[15px] text-foreground hover:bg-secondary/60 transition-colors" > - + Boost
From 62517cc062d526fcc15e913a42d6b00c0ee4a306 Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 21 May 2026 13:26:56 -0700 Subject: [PATCH 09/17] Join detail hero action bars --- src/components/CommunityDetailPage.tsx | 4 ++-- src/pages/ActionDetailPage.tsx | 6 +++--- src/pages/CampaignDetailPage.tsx | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index c503fc84..53eca1c7 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -833,7 +833,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
{/* ── Hero ─────────────────────────────────────────────────────── */} -
+
{cover ? ( ) : ( @@ -962,7 +962,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
-
+
navigate(-1)} /> -
-
+
+
-
+
-
-
+
+
-
+
{cover ? ( ) : ( From 0cf561450253710aef32fe2345cd0a55c46a2402 Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 21 May 2026 13:30:34 -0700 Subject: [PATCH 10/17] Move campaign pins below hero --- src/pages/CampaignDetailPage.tsx | 73 +++++++++++++++++--------------- 1 file changed, 38 insertions(+), 35 deletions(-) diff --git a/src/pages/CampaignDetailPage.tsx b/src/pages/CampaignDetailPage.tsx index 358cbaf6..3cece562 100644 --- a/src/pages/CampaignDetailPage.tsx +++ b/src/pages/CampaignDetailPage.tsx @@ -107,19 +107,6 @@ function collectReplyEvents(nodes: ReplyNode[], out = new Map): ReplyNode[] { - return nodes.flatMap((node): ReplyNode[] => { - if (pinnedIds.has(node.event.id)) return []; - return [{ - ...node, - children: removePinnedReplyNodes(node.children, pinnedIds), - hiddenChildren: node.hiddenChildren - ? removePinnedReplyNodes(node.hiddenChildren, pinnedIds) - : undefined, - }]; - }); -} - export function CampaignDetailPage({ pubkey, identifier, relays }: CampaignDetailPageProps) { // Drop the default 600px column cap and the default right widget sidebar // — this page renders its own GoFundMe-style 2-column layout (article on @@ -230,20 +217,14 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { ); }, [commentsData, donationReceipts]); - const pinnedIdSet = useMemo(() => new Set(pinnedIds), [pinnedIds]); const feedEventsById = useMemo(() => collectReplyEvents(replyTree), [replyTree]); - const activityTree = useMemo((): ReplyNode[] => { - const pinnedEventNodes = pinnedIds + const pinnedNodes = useMemo((): ReplyNode[] => { + return pinnedIds .map((id) => feedEventsById.get(id) ?? pinnedEvents.find((event) => event.id === id)) .filter((event): event is NostrEvent => !!event) .map((event): ReplyNode => ({ event, children: [] })); - - return [ - ...pinnedEventNodes, - ...removePinnedReplyNodes(replyTree, pinnedIdSet), - ]; - }, [feedEventsById, pinnedEvents, pinnedIdSet, pinnedIds, replyTree]); + }, [feedEventsById, pinnedEvents, pinnedIds]); const cover = sanitizeUrl(campaign.image); const creatorMetadata = author.data?.metadata; @@ -381,6 +362,26 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
+ {pinnedNodes.length > 0 && ( +
+
+ ( + handleTogglePin(event)} + /> + )} + /> +
+
+ )} + {/* Two-column body. On mobile the right column collapses inline immediately below the hero so the donate CTA stays above the fold. On lg+ the right column sticks to the viewport edge of @@ -426,10 +427,10 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { ))}
- ) : activityTree.length > 0 ? ( + ) : replyTree.length > 0 ? (
( { - const wasPinned = isPinned(event.id); - togglePin.mutate(event.id, { - onSuccess: () => { - toast({ title: wasPinned ? 'Unpinned from campaign' : 'Pinned to campaign' }); - }, - onError: () => { - toast({ title: 'Failed to update campaign pins', variant: 'destructive' }); - }, - }); - }} + onTogglePin={() => handleTogglePin(event)} /> )} /> @@ -535,6 +526,18 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { ); + + function handleTogglePin(event: NostrEvent) { + const wasPinned = isPinned(event.id); + togglePin.mutate(event.id, { + onSuccess: () => { + toast({ title: wasPinned ? 'Unpinned from campaign' : 'Pinned to campaign' }); + }, + onError: () => { + toast({ title: 'Failed to update campaign pins', variant: 'destructive' }); + }, + }); + } } function CampaignActivityItemHeader({ From cf10654ea6677c022ddbe50adcc6c442b86cc7bf Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 21 May 2026 13:34:00 -0700 Subject: [PATCH 11/17] Align campaign activity widths --- src/pages/CampaignDetailPage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/CampaignDetailPage.tsx b/src/pages/CampaignDetailPage.tsx index 3cece562..5e7d3d5c 100644 --- a/src/pages/CampaignDetailPage.tsx +++ b/src/pages/CampaignDetailPage.tsx @@ -364,7 +364,7 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { {pinnedNodes.length > 0 && (
-
+
( @@ -417,7 +417,7 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] })} /> @@ -428,7 +428,7 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { ))}
) : replyTree.length > 0 ? ( -
+
( From e41e8396d76d51cc263ac969e21b879bd7dde0a6 Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 21 May 2026 13:38:28 -0700 Subject: [PATCH 12/17] Align detail comment widths --- src/components/CommunityDetailPage.tsx | 2 +- src/components/DetailCommentComposer.tsx | 3 +-- src/pages/ActionDetailPage.tsx | 2 +- src/pages/CampaignDetailPage.tsx | 2 +- src/pages/PostDetailPage.tsx | 4 ++-- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index 53eca1c7..430c3b0f 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -1039,7 +1039,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { ))}
) : replyTree.length > 0 ? ( -
+
) : ( diff --git a/src/components/DetailCommentComposer.tsx b/src/components/DetailCommentComposer.tsx index 1e8e319a..45654081 100644 --- a/src/components/DetailCommentComposer.tsx +++ b/src/components/DetailCommentComposer.tsx @@ -1,7 +1,6 @@ import type { NostrEvent } from '@nostrify/nostrify'; import { ComposeBox } from '@/components/ComposeBox'; -import { cn } from '@/lib/utils'; interface DetailCommentComposerProps { event: NostrEvent; @@ -17,7 +16,7 @@ export function DetailCommentComposer({ className, }: DetailCommentComposerProps) { return ( -
+
)}
) : replyTree.length > 0 ? ( -
+
) : ( diff --git a/src/pages/CampaignDetailPage.tsx b/src/pages/CampaignDetailPage.tsx index 5e7d3d5c..9298120f 100644 --- a/src/pages/CampaignDetailPage.tsx +++ b/src/pages/CampaignDetailPage.tsx @@ -417,7 +417,7 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] })} /> diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 335a0aa3..f8b693a9 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -2098,13 +2098,13 @@ function PostDetailContent({ event }: { event: NostrEvent }) { /> {repliesLoading ? ( -
+
{Array.from({ length: 3 }).map((_, i) => ( ))}
) : replyTree.length > 0 ? ( -
+
) : ( From 4dd913d3ca34e5c912533e45a891b6553c1aba7a Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 21 May 2026 13:43:04 -0700 Subject: [PATCH 13/17] Redesign calendar event details --- src/components/CalendarEventDetailPage.tsx | 499 ++++++++++++--------- 1 file changed, 289 insertions(+), 210 deletions(-) diff --git a/src/components/CalendarEventDetailPage.tsx b/src/components/CalendarEventDetailPage.tsx index 9512a197..0ca04340 100644 --- a/src/components/CalendarEventDetailPage.tsx +++ b/src/components/CalendarEventDetailPage.tsx @@ -1,8 +1,8 @@ import { useMemo, useCallback, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { - ArrowLeft, CalendarDays, + ChevronLeft, MapPin, Clock, Users, @@ -18,7 +18,8 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Separator } from '@/components/ui/separator'; +import { Card, CardContent } from '@/components/ui/card'; +import { DetailCommentComposer } from '@/components/DetailCommentComposer'; import { NoteContent } from '@/components/NoteContent'; import { NoteMoreMenu } from '@/components/NoteMoreMenu'; import { PostActionBar } from '@/components/PostActionBar'; @@ -36,6 +37,7 @@ import { usePublishRSVP } from '@/hooks/usePublishRSVP'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { useToast } from '@/hooks/useToast'; import { genUserName } from '@/lib/genUserName'; +import { openUrl } from '@/lib/downloadFile'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; @@ -126,6 +128,26 @@ function formatDetailDate(event: NostrEvent): string { return startStr; } +function formatCalendarHeroDate(event: NostrEvent): string | null { + const startRaw = getTag(event.tags, 'start'); + if (!startRaw) return null; + + if (event.kind === 31922) { + const [year, month, day] = startRaw.split('-').map(Number); + const date = new Date(year, month - 1, day); + if (isNaN(date.getTime())) return startRaw; + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + } + + const timestamp = Number(startRaw); + if (!Number.isFinite(timestamp)) return startRaw; + const startTzid = getTag(event.tags, 'start_tzid'); + return new Date(timestamp * 1000).toLocaleDateString('en-US', { + month: 'short', day: 'numeric', year: 'numeric', + ...(startTzid ? { timeZone: startTzid } : {}), + }); +} + const ROLE_ORDER = ['host', 'speaker', 'moderator', 'participant']; function roleSort(a: string, b: string): number { const ai = ROLE_ORDER.indexOf(a.toLowerCase()); @@ -162,6 +184,15 @@ function PersonRow({ pubkey, label, size = 'md' }: { pubkey: string; label?: str ); } +function EventDetailRow({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) { + return ( +
+
{icon}
+
{children}
+
+ ); +} + // --- Main Component --- export function CalendarEventDetailPage({ event }: { event: NostrEvent }) { @@ -170,7 +201,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) { const { toast } = useToast(); const title = getTag(event.tags, 'title') ?? 'Untitled Event'; - const image = getTag(event.tags, 'image'); + const image = sanitizeUrl(getTag(event.tags, 'image')); const locationRaw = getTag(event.tags, 'location'); const location = locationRaw ? parseLocation(locationRaw) : undefined; const summary = getTag(event.tags, 'summary'); @@ -179,6 +210,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) { const eventCoord = useMemo(() => getEventCoord(event), [event]); const dateStr = useMemo(() => formatDetailDate(event), [event]); + const heroDate = useMemo(() => formatCalendarHeroDate(event), [event]); // Participants grouped by role const participantsByRole = useMemo(() => { @@ -240,202 +272,274 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) { }, [eventCoord, event.pubkey, myRsvp.status, publishRSVP, toast]); const showRSVP = !!user; + const attendeeCount = rsvps.accepted.length + rsvps.tentative.length; return ( -
- {/* ── Standard top bar ── */} -
- -

Event Details

- {canEdit && ( - - )} -
- - {/* ── Cover image ── */} - {image ? ( -
- {title} -
- ) : ( -
- -
- )} - - {/* ── Content ── */} -
- {/* Title */} -

{title}

- {/* Organizer row */} -
-
- -
-
- - {/* Date & Location — sidebar-style pills */} -
-
- - {dateStr} -
- {location && ( -
- - {location} +
+
+
+ {image ? ( + + ) : ( +
+
)} -
+
- {/* Hashtags */} - {hashtags.length > 0 && ( -
- {hashtags.map((tag) => ( - - - #{tag} - - - ))} +
+ + {canEdit && ( + + )}
- )} - {/* Description */} - {(event.content || summary) && ( - <> - -
-

About

- {event.content ? ( - - ) : ( -

{summary}

+
+
+

+ {title} +

+ calendar event +
+
+ {heroDate && ( + + + {heroDate} + + )} + {location && ( + + + {location} + + )} + {attendeeCount > 0 && ( + + + {attendeeCount} interested + + )} +
+ {summary && ( +

+ {summary} +

+ )} +
+
+
+ +
+
+ setReplyOpen(true)} + onMore={() => setMoreMenuOpen(true)} + /> +
+
+ +
+
+
+
+
+
Hosted by
+ +
+ + {hashtags.length > 0 && ( +
+ {hashtags.map((tag) => ( + + + #{tag} + + + ))} +
+ )} + + {(event.content || summary) && ( +
+ {event.content ? ( + + ) : ( +

{summary}

+ )} +
)}
- - )} - {/* External links */} - {links.length > 0 && ( -
- {links.map((url) => ( - - - {url.replace(/^https?:\/\//, '')} - - - ))} +
+
+

Comments

+ {replyTree.length > 0 ? ( + + {replyTree.length.toLocaleString()} {replyTree.length === 1 ? 'comment' : 'comments'} + + ) : null} +
+ + + + {commentsLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ +
+ + + +
+
+
+ ))} +
+ ) : replyTree.length > 0 ? ( +
+ +
+ ) : ( + + )} +
- )} - {/* Participants */} - {participantsByRole.length > 0 && ( - <> - -
-

- Participants -

-
- {participantsByRole.map(([role, pubkeys]) => - pubkeys.map((pk) => ), - )} -
-
- - )} - - {/* Attendees */} - {rsvps.total > 0 && ( - <> - -
-

- Attendees -

-
- {([ - ['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'], - ['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'], - ["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'], - ] as const).map(([label, pks, cls]) => pks.length > 0 && ( -
- {label} ({pks.length}) - +
- - )} - {/* RSVP section */} - {showRSVP && ( - <> - -
-

- RSVP -

-
- - - -
-
- - )} + {showRSVP && ( +
+
RSVP
+
+ + + +
+
+ )} - setReplyOpen(true)} - onMore={() => setMoreMenuOpen(true)} - className="-mx-5 px-5" - /> + {rsvps.total > 0 && ( +
+
Attendees
+
+ {([ + ['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'], + ['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'], + ["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'], + ] as const).map(([label, pks, cls]) => pks.length > 0 && ( +
+ {label} ({pks.length}) + +
+ ))} +
+
+ )} + + {links.length > 0 && ( +
+
Links
+
+ {links.map((url) => ( + + ))} +
+
+ )} + + + + {participantsByRole.length > 0 && ( + + +
Participants
+
+ {participantsByRole.map(([role, pubkeys]) => + pubkeys.map((pk) => ), + )} +
+
+
+ )} +
+ +
@@ -446,32 +550,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) { event={event} /> )} - -
- {commentsLoading ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
- -
- - - -
-
- ))} -
- ) : replyTree.length > 0 ? ( -
- -
- ) : ( -
- No comments yet. Be the first to comment! -
- )} -
-
+
); } From 97ec528b5049c3acd16e61f008d782571a4eb304 Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 21 May 2026 13:46:34 -0700 Subject: [PATCH 14/17] Normalize organization activity cards --- src/components/CampaignCard.tsx | 12 ++++++-- src/components/CommunityDetailPage.tsx | 39 +++++++++++++------------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/components/CampaignCard.tsx b/src/components/CampaignCard.tsx index e3feb639..b43e6c66 100644 --- a/src/components/CampaignCard.tsx +++ b/src/components/CampaignCard.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import type { ReactNode } from 'react'; import { Link } from 'react-router-dom'; import { CalendarClock, HandHeart, MapPin, Target, Users, Archive } from 'lucide-react'; @@ -68,13 +69,15 @@ interface CampaignCardProps { /** Visual variant: `compact` for grid items, `featured` for hero placement. */ variant?: 'compact' | 'featured'; className?: string; + /** Optional footer affordance rendered opposite the author line. */ + footerBadge?: ReactNode; } /** * Renders a single campaign as a clickable card. The whole card is a * `` to the campaign's naddr-based detail route. */ -export function CampaignCard({ campaign, variant = 'compact', className }: CampaignCardProps) { +export function CampaignCard({ campaign, variant = 'compact', className, footerBadge }: CampaignCardProps) { const author = useAuthor(campaign.pubkey); const { data: stats } = useCampaignDonations(campaign.aTag); const { data: btcPrice } = useBtcPrice(); @@ -224,8 +227,11 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa )}
-
- by {creatorName} +
+
+ by {creatorName} +
+ {footerBadge &&
{footerBadge}
}
diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index 430c3b0f..f0522745 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -217,7 +217,7 @@ function parseShelfLocation(raw: string): string { function ActivityTypePill({ icon, label }: { icon: React.ReactNode; label: string }) { return ( -
+
{icon} {label}
@@ -243,11 +243,8 @@ function PledgeShelfCard({ pledge }: { pledge: Action }) { return ( -
- } label="Pledge" /> -
-
- by {displayName} +
+
+ by {displayName} +
+ } label="Pledge" />
@@ -336,11 +336,8 @@ function CalendarEventShelfCard({ event }: { event: NostrEvent }) { return ( -
- } label="Event" /> -
{coverImage ? ( @@ -405,8 +402,11 @@ function CalendarEventShelfCard({ event }: { event: NostrEvent }) { ) : null}
-
- by {displayName} +
+
+ by {displayName} +
+ } label="Event" />
@@ -438,7 +438,7 @@ function OfficialShelf({ title, count, isLoading, isEmpty, children }: OfficialS )}
-
+
{children}
@@ -529,11 +529,12 @@ function OfficialActivityShelves({ {mixedActivity.map((item) => { if (item.type === 'campaign') { return ( -
-
- } label="Campaign" /> -
- +
+ } label="Campaign" />} + />
); } From ffb9c93ee658ed19c6099779d8121b96b758d53f Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 21 May 2026 13:52:00 -0700 Subject: [PATCH 15/17] Add pins across detail comments --- NIP.md | 18 ++--- src/components/CalendarEventDetailPage.tsx | 76 +++++++++++++++++- src/components/CommunityDetailPage.tsx | 76 +++++++++++++++++- src/components/PinnedCommentHeader.tsx | 53 +++++++++++++ ...nedEvents.ts => usePinnedEventComments.ts} | 43 ++++++----- src/pages/ActionDetailPage.tsx | 77 ++++++++++++++++++- src/pages/CampaignDetailPage.tsx | 63 +++++---------- 7 files changed, 329 insertions(+), 77 deletions(-) create mode 100644 src/components/PinnedCommentHeader.tsx rename src/hooks/{useCampaignPinnedEvents.ts => usePinnedEventComments.ts} (64%) diff --git a/NIP.md b/NIP.md index 5862fc40..177f952d 100644 --- a/NIP.md +++ b/NIP.md @@ -322,20 +322,20 @@ This mirrors the community batch-zap pattern documented in the kind 8333 section Clients MUST verify each kind 8333 event on-chain before counting it toward the campaign total, per the verification rules in the kind 8333 section. -**Fetch pinned campaign activity:** +**Fetch pinned event comments:** -Campaign creators MAY pin important activity feed events (comments, updates, or donation receipts) with a NIP-78 app-specific data event (`kind: 30078`) authored by the campaign creator. The `d` tag is scoped to the campaign coordinate: +Event owners MAY pin important comments or activity feed events with a NIP-78 app-specific data event (`kind: 30078`) authored by the root event owner. The `d` tag is scoped to the root event coordinate. Agora uses this for campaigns (`30223`), pledges (`36639`), organizations (`34550`), and calendar events (`31922` / `31923`). ```json { "kind": 30078, - "pubkey": "", + "pubkey": "", "content": "{\"pinnedEvents\":[\"\",\"\"]}", "tags": [ - ["d", "agora-campaign-pins:30223::"], - ["a", "30223::"], - ["k", "30223"], - ["alt", "Pinned campaign activity"] + ["d", "agora-pinned-comments:::"], + ["a", "::"], + ["k", ""], + ["alt", "Pinned event comments"] ] } ``` @@ -343,10 +343,10 @@ Campaign creators MAY pin important activity feed events (comments, updates, or Clients SHOULD query the pin list with: ```json -{ "kinds": [30078], "authors": [""], "#d": ["agora-campaign-pins:30223::"], "limit": 1 } +{ "kinds": [30078], "authors": [""], "#d": ["agora-pinned-comments:::"], "limit": 1 } ``` -The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned event removes it. Clients SHOULD ignore pin lists not authored by the campaign creator. +The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned event removes it. Clients SHOULD ignore pin lists not authored by the root event owner. ### Client Behavior diff --git a/src/components/CalendarEventDetailPage.tsx b/src/components/CalendarEventDetailPage.tsx index 0ca04340..1c672740 100644 --- a/src/components/CalendarEventDetailPage.tsx +++ b/src/components/CalendarEventDetailPage.tsx @@ -23,6 +23,7 @@ import { DetailCommentComposer } from '@/components/DetailCommentComposer'; import { NoteContent } from '@/components/NoteContent'; import { NoteMoreMenu } from '@/components/NoteMoreMenu'; import { PostActionBar } from '@/components/PostActionBar'; +import { PinnedCommentHeader } from '@/components/PinnedCommentHeader'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog'; import { RSVPAvatars } from '@/components/RSVPAvatars'; @@ -34,6 +35,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useEventRSVPs } from '@/hooks/useEventRSVPs'; import { useMyRSVP } from '@/hooks/useMyRSVP'; import { usePublishRSVP } from '@/hooks/usePublishRSVP'; +import { usePinnedEventComments } from '@/hooks/usePinnedEventComments'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { useToast } from '@/hooks/useToast'; import { genUserName } from '@/lib/genUserName'; @@ -232,6 +234,12 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) { const myRsvp = useMyRSVP(eventCoord); const publishRSVP = usePublishRSVP(); const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500); + const { + pinnedEvents, + isPinned, + canManagePins, + togglePin, + } = usePinnedEventComments(eventCoord, event.pubkey); const [replyOpen, setReplyOpen] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); @@ -257,6 +265,11 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) { .map((comment) => buildNode(comment)); }, [commentsData]); + const pinnedNodes = useMemo( + () => pinnedEvents.map((event): ReplyNode => ({ event, children: [] })), + [pinnedEvents], + ); + const handleRSVP = useCallback(async (status: 'accepted' | 'declined' | 'tentative') => { if (status === myRsvp.status) return; try { @@ -356,6 +369,24 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
+ {pinnedNodes.length > 0 && ( +
+
+ ( + handleTogglePin(event)} + /> + )} + /> +
+
+ )} +
@@ -417,7 +448,17 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
) : replyTree.length > 0 ? (
- + ( + handleTogglePin(event)} + /> + )} + />
) : (
+ {pinnedNodes.length > 0 && ( +
+
+ ( + handleTogglePin(event)} + /> + )} + /> +
+
+ )} + {/* ── Body — single column, pledge-detail-style ─────────────────── */}
{/* Donate (when there's a member set) and Share buttons. Sits @@ -1041,7 +1072,17 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
) : replyTree.length > 0 ? (
- + ( + handleTogglePin(event)} + /> + )} + />
) : ( + )} +
+ ); +} diff --git a/src/hooks/useCampaignPinnedEvents.ts b/src/hooks/usePinnedEventComments.ts similarity index 64% rename from src/hooks/useCampaignPinnedEvents.ts rename to src/hooks/usePinnedEventComments.ts index 5cd67c03..50a29985 100644 --- a/src/hooks/useCampaignPinnedEvents.ts +++ b/src/hooks/usePinnedEventComments.ts @@ -6,11 +6,11 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; -const CAMPAIGN_PIN_LIST_KIND = 30078; -const CAMPAIGN_PIN_D_TAG_PREFIX = 'agora-campaign-pins:'; +const PIN_LIST_KIND = 30078; +const PIN_D_TAG_PREFIX = 'agora-pinned-comments:'; -function campaignPinDTag(campaignATag: string): string { - return `${CAMPAIGN_PIN_D_TAG_PREFIX}${campaignATag}`; +function pinDTag(rootATag: string): string { + return `${PIN_D_TAG_PREFIX}${rootATag}`; } function parsePinnedIds(event: NostrEvent | null | undefined): string[] { @@ -25,30 +25,32 @@ function parsePinnedIds(event: NostrEvent | null | undefined): string[] { } } -export function useCampaignPinnedEvents(campaignATag: string, campaignAuthorPubkey: string) { +export function usePinnedEventComments(rootATag: string | undefined, ownerPubkey: string | undefined) { const { nostr } = useNostr(); const { user } = useCurrentUser(); const queryClient = useQueryClient(); const { mutateAsync: publishEvent } = useNostrPublish(); - const dTag = campaignPinDTag(campaignATag); - const canManagePins = user?.pubkey === campaignAuthorPubkey; + const dTag = rootATag ? pinDTag(rootATag) : undefined; + const canManagePins = !!user && !!ownerPubkey && user.pubkey === ownerPubkey; const pinnedListQuery = useQuery({ - queryKey: ['campaign-pinned-events-list', campaignATag, campaignAuthorPubkey], + queryKey: ['pinned-event-comments-list', rootATag, ownerPubkey], queryFn: async ({ signal }) => { + if (!rootATag || !ownerPubkey || !dTag) return null; const events = await nostr.query( - [{ kinds: [CAMPAIGN_PIN_LIST_KIND], authors: [campaignAuthorPubkey], '#d': [dTag], limit: 1 }], + [{ kinds: [PIN_LIST_KIND], authors: [ownerPubkey], '#d': [dTag], limit: 1 }], { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, ); return events[0] ?? null; }, + enabled: !!rootATag && !!ownerPubkey && !!dTag, staleTime: 30_000, }); const pinnedIds = parsePinnedIds(pinnedListQuery.data); const pinnedEventsQuery = useQuery({ - queryKey: ['campaign-pinned-events', campaignATag, pinnedIds], + queryKey: ['pinned-event-comments', rootATag, pinnedIds], queryFn: async ({ signal }) => { if (pinnedIds.length === 0) return []; const events = await nostr.query( @@ -57,18 +59,19 @@ export function useCampaignPinnedEvents(campaignATag: string, campaignAuthorPubk ); return events.sort((a, b) => pinnedIds.indexOf(a.id) - pinnedIds.indexOf(b.id)); }, - enabled: pinnedIds.length > 0, + enabled: !!rootATag && pinnedIds.length > 0, staleTime: 30_000, }); const togglePin = useMutation({ mutationFn: async (eventId: string) => { if (!user) throw new Error('User is not logged in'); - if (user.pubkey !== campaignAuthorPubkey) throw new Error('Only the campaign author can pin updates.'); + if (!rootATag || !ownerPubkey || !dTag) throw new Error('Missing pin context.'); + if (user.pubkey !== ownerPubkey) throw new Error('Only the event owner can pin comments.'); const prev = await fetchFreshEvent(nostr, { - kinds: [CAMPAIGN_PIN_LIST_KIND], - authors: [campaignAuthorPubkey], + kinds: [PIN_LIST_KIND], + authors: [ownerPubkey], '#d': [dTag], }); @@ -78,20 +81,20 @@ export function useCampaignPinnedEvents(campaignATag: string, campaignAuthorPubk : [eventId, ...current.filter((id) => id !== eventId)]; await publishEvent({ - kind: CAMPAIGN_PIN_LIST_KIND, + kind: PIN_LIST_KIND, content: JSON.stringify({ pinnedEvents: next }), tags: [ ['d', dTag], - ['a', campaignATag], - ['k', campaignATag.split(':')[0] ?? '30223'], - ['alt', 'Pinned campaign activity'], + ['a', rootATag], + ['k', rootATag.split(':')[0] ?? ''], + ['alt', 'Pinned event comments'], ], prev: prev ?? undefined, }); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['campaign-pinned-events-list', campaignATag, campaignAuthorPubkey] }); - queryClient.invalidateQueries({ queryKey: ['campaign-pinned-events', campaignATag] }); + queryClient.invalidateQueries({ queryKey: ['pinned-event-comments-list', rootATag, ownerPubkey] }); + queryClient.invalidateQueries({ queryKey: ['pinned-event-comments', rootATag] }); }, }); diff --git a/src/pages/ActionDetailPage.tsx b/src/pages/ActionDetailPage.tsx index 7971298f..15abae84 100644 --- a/src/pages/ActionDetailPage.tsx +++ b/src/pages/ActionDetailPage.tsx @@ -34,9 +34,11 @@ import { Progress } from '@/components/ui/progress'; import { Skeleton } from '@/components/ui/skeleton'; import { DetailCommentComposer } from '@/components/DetailCommentComposer'; import { PostActionBar } from '@/components/PostActionBar'; +import { PinnedCommentHeader } from '@/components/PinnedCommentHeader'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { NoteMoreMenu } from '@/components/NoteMoreMenu'; import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList'; +import { usePinnedEventComments } from '@/hooks/usePinnedEventComments'; import NotFound from '@/pages/NotFound'; function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } { @@ -78,6 +80,13 @@ function PledgeDetailContent({ action }: { action: Action }) { const navigate = useNavigate(); const { toast } = useToast(); const { data: commentsData, isLoading: commentsLoading } = useComments(action.event, 500); + const rootATag = `36639:${action.pubkey}:${action.id}`; + const { + pinnedEvents, + isPinned, + canManagePins, + togglePin, + } = usePinnedEventComments(rootATag, action.pubkey); const [replyOpen, setReplyOpen] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); @@ -123,6 +132,11 @@ function PledgeDetailContent({ action }: { action: Action }) { .map((c) => buildNode(c)); }, [commentsData, topLevel, zapTotals]); + const pinnedNodes = useMemo( + () => pinnedEvents.map((event): ReplyNode => ({ event, children: [] })), + [pinnedEvents], + ); + const metadata: NostrMetadata | undefined = author.data?.metadata; const creatorName = getDisplayName(metadata, action.pubkey); const creatorProfileUrl = useProfileUrl(action.pubkey, metadata); @@ -181,6 +195,24 @@ function PledgeDetailContent({ action }: { action: Action }) {
+ {pinnedNodes.length > 0 && ( +
+
+ ( + handleTogglePin(event)} + /> + )} + /> +
+
+ )} +
@@ -221,7 +253,17 @@ function PledgeDetailContent({ action }: { action: Action }) {
) : replyTree.length > 0 ? (
- + ( + handleTogglePin(event)} + /> + )} + />
) : ( + + {isCampaignAuthor && ( + + + Campaigner + )} -
+ ); } From b7d33577f1ad1c7ba1f249a298ba6ab5f8891398 Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 21 May 2026 13:54:20 -0700 Subject: [PATCH 16/17] Restore event RSVP detail card --- src/components/CalendarEventDetailPage.tsx | 228 +++++++++++---------- 1 file changed, 124 insertions(+), 104 deletions(-) diff --git a/src/components/CalendarEventDetailPage.tsx b/src/components/CalendarEventDetailPage.tsx index 1c672740..75e78bb0 100644 --- a/src/components/CalendarEventDetailPage.tsx +++ b/src/components/CalendarEventDetailPage.tsx @@ -286,6 +286,122 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) { const showRSVP = !!user; const attendeeCount = rsvps.accepted.length + rsvps.tentative.length; + const rsvpStatusLabel = myRsvp.status === 'accepted' + ? 'You are going' + : myRsvp.status === 'tentative' + ? 'You are interested' + : myRsvp.status === 'declined' + ? "You can't go" + : 'Choose your RSVP'; + + const eventDetailsCard = ( + + +
+ }> + {dateStr} + + {location && ( + }> + {location} + + )} +
+ + {showRSVP && ( +
+
+
RSVP
+ {rsvpStatusLabel} +
+
+ + + +
+
+ )} + + {rsvps.total > 0 && ( +
+
Attendees
+
+ {([ + ['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'], + ['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'], + ["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'], + ] as const).map(([label, pks, cls]) => pks.length > 0 && ( +
+ {label} ({pks.length}) + +
+ ))} +
+
+ )} + + {links.length > 0 && ( +
+
Links
+
+ {links.map((url) => ( + + ))} +
+
+ )} +
+
+ ); + + const participantsCard = participantsByRole.length > 0 ? ( + + +
Participants
+
+ {participantsByRole.map(([role, pubkeys]) => + pubkeys.map((pk) => ), + )} +
+
+
+ ) : null; return (
@@ -388,6 +504,11 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) { )}
+
+ {eventDetailsCard} + {participantsCard} +
+
@@ -473,111 +594,10 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
-
From 48744aa13d751f9903ca92d551fb2ae7c3f2daa2 Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 21 May 2026 14:05:15 -0700 Subject: [PATCH 17/17] Refine calendar event details --- src/components/CalendarEventDetailPage.tsx | 35 ++++++++++++++++------ 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/components/CalendarEventDetailPage.tsx b/src/components/CalendarEventDetailPage.tsx index 75e78bb0..370b54b0 100644 --- a/src/components/CalendarEventDetailPage.tsx +++ b/src/components/CalendarEventDetailPage.tsx @@ -285,7 +285,8 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) { }, [eventCoord, event.pubkey, myRsvp.status, publishRSVP, toast]); const showRSVP = !!user; - const attendeeCount = rsvps.accepted.length + rsvps.tentative.length; + const attendingCount = rsvps.accepted.length; + const interestedCount = rsvps.tentative.length; const rsvpStatusLabel = myRsvp.status === 'accepted' ? 'You are going' : myRsvp.status === 'tentative' @@ -297,6 +298,22 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) { const eventDetailsCard = ( +
+
Hosted by
+ +
+ + {(event.content || summary) && ( +
+
Description
+ {event.content ? ( + + ) : ( +

{summary}

+ )} +
+ )} +
}> {dateStr} @@ -443,7 +460,6 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {

{title}

- calendar event
{heroDate && ( @@ -458,10 +474,16 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) { {location} )} - {attendeeCount > 0 && ( + {attendingCount > 0 && ( - {attendeeCount} interested + {attendingCount} attending + + )} + {interestedCount > 0 && ( + + + {interestedCount} interested )}
@@ -512,11 +534,6 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
-
-
Hosted by
- -
- {hashtags.length > 0 && (
{hashtags.map((tag) => (