Pin campaign activity updates

This commit is contained in:
lemon
2026-05-21 10:35:47 -07:00
parent 5920523b57
commit 48794fa3b4
4 changed files with 273 additions and 8 deletions
+26
View File
@@ -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": "<campaign-creator-pubkey>",
"content": "{\"pinnedEvents\":[\"<event-id-2>\",\"<event-id-1>\"]}",
"tags": [
["d", "agora-campaign-pins:30223:<creator-pubkey>:<slug>"],
["a", "30223:<creator-pubkey>:<slug>"],
["k", "30223"],
["alt", "Pinned campaign activity"]
]
}
```
Clients SHOULD query the pin list with:
```json
{ "kinds": [30078], "authors": ["<creator-pubkey>"], "#d": ["agora-campaign-pins:30223:<creator-pubkey>:<slug>"], "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.
+17 -6
View File
@@ -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 (
<div>
{roots.map((node) => (
<ReplyThread key={node.event.id} node={node} depth={0} />
<ReplyThread key={node.event.id} node={node} depth={0} renderItemHeader={renderItemHeader} />
))}
</div>
);
}
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 (
<div>
{renderItemHeader?.(node.event)}
<NoteCard event={node.event} threaded />
<ExpandThreadButton count={countDescendants(node)} onClick={() => setExpanded(true)} isLast />
</div>
@@ -41,7 +43,12 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
}
if (!hasChildren) {
return <NoteCard event={node.event} />;
return (
<div>
{renderItemHeader?.(node.event)}
<NoteCard event={node.event} />
</div>
);
}
// 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 (
<div>
{renderItemHeader?.(node.event)}
<NoteCard event={node.event} threaded />
{/* 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) => (
<NoteCard key={child.event.id} event={child.event} threaded threadedLineClassName="bg-primary/30" />
<div key={child.event.id}>
{renderItemHeader?.(child.event)}
<NoteCard event={child.event} threaded threadedLineClassName="bg-primary/30" />
</div>
))}
{node.children.map((child) => (
<ReplyThread key={child.event.id} node={child} depth={depth + 1} depthless={childDepthless} />
<ReplyThread key={child.event.id} node={child} depth={depth + 1} depthless={childDepthless} renderItemHeader={renderItemHeader} />
))}
</div>
);
+106
View File
@@ -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,
};
}
+124 -2
View File
@@ -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<string, NostrEvent>()): Map<string, NostrEvent> {
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<string>): 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 }) {
<CampaignReplySkeleton key={i} />
))}
</div>
) : replyTree.length > 0 ? (
) : activityTree.length > 0 ? (
<div className="-mx-2 sm:-mx-4 rounded-2xl bg-card border border-border/60 overflow-hidden">
<ThreadedReplyList roots={replyTree} />
<ThreadedReplyList
roots={activityTree}
renderItemHeader={(event) => (
<CampaignActivityItemHeader
event={event}
isCampaignAuthor={event.pubkey === campaign.pubkey}
canManagePins={canManagePins}
isPinned={isPinned(event.id)}
pinPending={togglePin.isPending}
onTogglePin={() => {
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' });
},
});
}}
/>
)}
/>
</div>
) : (
<button
@@ -524,6 +592,60 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
);
}
function CampaignActivityItemHeader({
event,
isCampaignAuthor,
canManagePins,
isPinned,
pinPending,
onTogglePin,
}: {
event: NostrEvent;
isCampaignAuthor: boolean;
canManagePins: boolean;
isPinned: boolean;
pinPending: boolean;
onTogglePin: () => void;
}) {
if (!isCampaignAuthor && !canManagePins && !isPinned) return null;
return (
<div className="flex items-center justify-between gap-3 px-4 pt-3 pb-0 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
{isPinned && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary">
<Pin className="size-3 rotate-45 fill-current" />
Pinned
</span>
)}
{isCampaignAuthor && (
<span className="inline-flex items-center rounded-full bg-amber-500/10 px-2 py-0.5 font-medium text-amber-700 dark:text-amber-300">
Campaign author
</span>
)}
</div>
{canManagePins && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onTogglePin();
}}
disabled={pinPending}
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-medium transition-colors hover:bg-primary/10 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60',
isPinned && 'text-primary',
)}
aria-label={`${isPinned ? 'Unpin' : 'Pin'} campaign activity from ${event.id}`}
>
<Pin className={cn('size-3 rotate-45', isPinned && 'fill-current')} />
{isPinned ? 'Unpin' : 'Pin'}
</button>
)}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────
// Hero
// ─────────────────────────────────────────────────────────────────────