From d1017697a4c126313656a5761696af124b37cfb5 Mon Sep 17 00:00:00 2001 From: lemon Date: Mon, 27 Apr 2026 11:20:16 -0700 Subject: [PATCH 01/19] Add NIP-75 community fundraising goals Implement zap goals (kind 9041) linked to communities via a-tag. Includes goal creation dialog, progress tracking from zap receipts, recipient profile/lightning address display, community link, and members-only filtering. Goals appear in community detail Fundraising tab, activity feed, and main feed via NoteCard. --- NIP.md | 72 +++++++ src/components/CommentContext.tsx | 7 +- src/components/CommunityDetailPage.tsx | 74 +++++++ src/components/CreateGoalDialog.tsx | 206 +++++++++++++++++++ src/components/ExternalContentHeader.tsx | 1 + src/components/GoalCard.tsx | 247 +++++++++++++++++++++++ src/components/GoalContent.tsx | 181 +++++++++++++++++ src/components/NoteCard.tsx | 11 + src/hooks/useCommunityActivityFeed.ts | 16 +- src/hooks/useCommunityGoals.ts | 45 +++++ src/hooks/useGoalProgress.ts | 95 +++++++++ src/lib/extraKinds.ts | 1 + src/lib/goalUtils.ts | 109 ++++++++++ src/pages/NotificationsPage.tsx | 1 + src/pages/PostDetailPage.tsx | 2 + 15 files changed, 1063 insertions(+), 5 deletions(-) create mode 100644 src/components/CreateGoalDialog.tsx create mode 100644 src/components/GoalCard.tsx create mode 100644 src/components/GoalContent.tsx create mode 100644 src/hooks/useCommunityGoals.ts create mode 100644 src/hooks/useGoalProgress.ts create mode 100644 src/lib/goalUtils.ts diff --git a/NIP.md b/NIP.md index 6fc9fe20..84ccd864 100644 --- a/NIP.md +++ b/NIP.md @@ -810,6 +810,78 @@ Both kind `34550` and kind `30009` are addressable events. To add or remove rank --- +## Community Fundraising Goals (NIP-75) + +### Summary + +Communities can host fundraising campaigns using [NIP-75 Zap Goals](https://github.com/nostr-protocol/nips/blob/master/75.md) (kind `9041`). A zap goal linked to a community allows members and supporters to contribute sats toward a shared target. + +### Linking Goals to Communities + +A zap goal is linked to a community by including an `a` tag pointing to the community's kind `34550` definition: + +```json +{ + "kind": 9041, + "content": "Community meetup travel fund", + "tags": [ + ["amount", "500000000"], + ["relays", "wss://relay.ditto.pub", "wss://relay.primal.net"], + ["a", "34550::"], + ["alt", "Zap goal: Community meetup travel fund"], + ["summary", "Help fund travel for our annual meetup"], + ["image", "https://example.com/meetup.jpg"], + ["closed_at", "1735689600"] + ] +} +``` + +### Required Tags (per NIP-75) + +- `amount` -- Target amount in millisatoshis +- `relays` -- Relay URLs where zaps should be sent and tallied from + +### Optional Tags (per NIP-75) + +- `closed_at` -- Unix timestamp deadline; zap receipts after this time are excluded from the tally +- `image` -- Image URL for the goal +- `summary` -- Brief description + +### Additional Tags (Agora-specific) + +- `a` -- Community link (`34550::`) scoping the goal to a community +- `alt` -- NIP-31 human-readable description + +### Querying + +Community goals are queried by filtering on the `a` tag: + +``` +{ "kinds": [9041], "#a": ["34550::"], "limit": 50 } +``` + +### Progress Tallying + +Goal progress is calculated from kind `9735` zap receipts targeting the goal event: + +``` +{ "kinds": [9735], "#e": [""], "limit": 500 } +``` + +Receipts with `created_at` after the `closed_at` deadline (if set) are excluded from the tally. + +### Access Control + +Anyone may create a zap goal linked to a community. The existing community members-only feed filter controls whether non-member goals are displayed. Anyone may zap a goal. + +### Dependencies + +- [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md) -- Lightning Zaps +- [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md) -- Moderated Communities +- [NIP-75](https://github.com/nostr-protocol/nips/blob/master/75.md) -- Zap Goals + +--- + ## Kind 0 Extension: Avatar Shape ### Summary diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index 7384383b..f270f120 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -3,10 +3,10 @@ import { type ReactNode, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { - Award, BarChart3, BookOpen, Camera, Clapperboard, FileText, Film, + Award, BarChart3, BookOpen, Camera, Clapperboard, Egg, FileText, Film, GitBranch, GitPullRequest, Mail, MapPin, MessageSquare, Mic, Music, Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, - Users, Vote, Zap, + Target, Users, Vote, Zap, } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -134,6 +134,7 @@ const KIND_LABELS: Record = { 34139: 'a playlist', 34236: 'a divine', 34550: 'a community', + 9041: 'a fundraising goal', 35128: 'an nsite', 36639: 'an action', 36787: 'a track', @@ -184,6 +185,8 @@ const KIND_ICONS: Partial { + const all = (goals ?? []).filter((g) => !isGoalExpired(g.goal)); + if (!membersOnly) return all; + return all.filter((g) => rankMap.has(g.event.pubkey)); + }, [goals, membersOnly, rankMap]); + const pastGoals = useMemo(() => { + const all = (goals ?? []).filter((g) => isGoalExpired(g.goal)); + if (!membersOnly) return all; + return all.filter((g) => rankMap.has(g.event.pubkey)); + }, [goals, membersOnly, rankMap]); + const replyTree = useMemo((): ReplyNode[] => { if (!commentsData) return []; const topLevel = commentsData.topLevelComments ?? []; @@ -320,6 +338,13 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { Comments + + + Fundraising + {/* ── Members tab ── */} @@ -391,6 +416,55 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { )} + + {/* ── Fundraising tab ── */} + + {/* Create goal button */} + {user && communityATag && ( +
+ +
+ )} + + {goalsLoading ? ( +
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+ ) : activeGoals.length === 0 && pastGoals.length === 0 ? ( +
+ {membersOnly && (goals ?? []).length > 0 + ? 'No fundraising goals from community members yet. Toggle the shield icon to see all goals.' + : <>No fundraising goals yet.{user ? ' Create one to get started!' : ''}} +
+ ) : ( +
+ {/* Active goals first */} + {activeGoals.length > 0 && ( +
+ {activeGoals.map((g) => ( + + ))} +
+ )} + + {/* Past/expired goals */} + {pastGoals.length > 0 && ( +
+ {activeGoals.length > 0 && ( +

+ Past Goals +

+ )} + {pastGoals.map((g) => ( + + ))} +
+ )} +
+ )} +
diff --git a/src/components/CreateGoalDialog.tsx b/src/components/CreateGoalDialog.tsx new file mode 100644 index 00000000..1a8e5111 --- /dev/null +++ b/src/components/CreateGoalDialog.tsx @@ -0,0 +1,206 @@ +import { useState } from 'react'; +import { Target } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { useAppContext } from '@/hooks/useAppContext'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useToast } from '@/hooks/useToast'; +import { useQueryClient } from '@tanstack/react-query'; +import { ZAP_GOAL_KIND } from '@/lib/goalUtils'; + +interface CreateGoalDialogProps { + /** The community `a` tag coordinate (e.g. `34550::`). */ + communityATag: string; + children?: React.ReactNode; +} + +export function CreateGoalDialog({ communityATag, children }: CreateGoalDialogProps) { + const [open, setOpen] = useState(false); + const { user } = useCurrentUser(); + const { toast } = useToast(); + const { config } = useAppContext(); + const { mutateAsync: publishEvent, isPending } = useNostrPublish(); + const queryClient = useQueryClient(); + + const [title, setTitle] = useState(''); + const [amountSats, setAmountSats] = useState(''); + const [summary, setSummary] = useState(''); + const [imageUrl, setImageUrl] = useState(''); + const [deadlineDate, setDeadlineDate] = useState(''); + + const resetForm = () => { + setTitle(''); + setAmountSats(''); + setSummary(''); + setImageUrl(''); + setDeadlineDate(''); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!user) return; + + const sats = parseInt(amountSats, 10); + if (isNaN(sats) || sats <= 0) { + toast({ title: 'Enter a valid amount in sats', variant: 'destructive' }); + return; + } + + if (!title.trim()) { + toast({ title: 'Enter a title for the goal', variant: 'destructive' }); + return; + } + + const msats = sats * 1000; + + // Build relay list from the user's configured relays + const relayUrls = config.relayMetadata.relays + .filter((r) => r.write) + .map((r) => r.url); + if (relayUrls.length === 0) { + toast({ title: 'No write relays configured', variant: 'destructive' }); + return; + } + + const tags: string[][] = [ + ['amount', String(msats)], + ['relays', ...relayUrls], + ['a', communityATag], + ['alt', `Zap goal: ${title.trim()}`], + ]; + + if (summary.trim()) { + tags.push(['summary', summary.trim()]); + } + if (imageUrl.trim()) { + tags.push(['image', imageUrl.trim()]); + } + if (deadlineDate) { + const deadline = Math.floor(new Date(deadlineDate).getTime() / 1000); + if (!isNaN(deadline) && deadline > 0) { + tags.push(['closed_at', String(deadline)]); + } + } + + try { + await publishEvent({ + kind: ZAP_GOAL_KIND, + content: title.trim(), + tags, + created_at: Math.floor(Date.now() / 1000), + }); + + // Refresh the fundraising tab and the community activity feed + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['community-goals', communityATag] }), + queryClient.invalidateQueries({ + predicate: (q) => { + const [root, aTagsKey] = q.queryKey; + return root === 'community-activity-feed' + && typeof aTagsKey === 'string' + && aTagsKey.split(',').includes(communityATag); + }, + }), + ]); + + toast({ title: 'Fundraising goal created!' }); + resetForm(); + setOpen(false); + } catch { + toast({ title: 'Failed to create goal', variant: 'destructive' }); + } + }; + + if (!user) return null; + + return ( + + + {children ?? ( + + )} + + + + + Create Fundraising Goal + + +
+
+ + setTitle(e.target.value)} + required + /> +
+ +
+ + setAmountSats(e.target.value)} + required + /> +
+ +
+ +