Pin campaign activity updates
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user