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 // ─────────────────────────────────────────────────────────────────────