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(null); + // ── Tab + FAB state ──────────────────────────────────────────────────────── + const [activeTab, setActiveTab] = useState('members'); + const [composeOpen, setComposeOpen] = useState(false); + const [goalDialogOpen, setGoalDialogOpen] = useState(false); + // Parse community definition const community = useMemo(() => parseCommunityEvent(event), [event]); const name = community?.name ?? 'Unnamed Community'; @@ -191,6 +203,38 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500); const { membersOnly } = useMembersOnlyFilter(); + // ── Fundraising goals (NIP-75) ────────────────────────────────────────────── + const { data: goals, isLoading: goalsLoading } = useCommunityGoals(communityATag || undefined); + const now = useNow(60_000); + + /** Check if a goal event's `closed_at` deadline has passed. */ + const isExpired = useCallback((e: NostrEvent): boolean => { + const v = e.tags.find(([n]) => n === 'closed_at')?.[1]; + if (!v) return false; + const ts = parseInt(v, 10); + return !isNaN(ts) && now > ts; + }, [now]); + + const moderatedGoals = useMemo( + () => applyCommunityModerationToEvents(goals ?? [], moderation), + [goals, moderation], + ); + const activeGoals = useMemo(() => { + const all = moderatedGoals.filter((e) => !isExpired(e)); + if (!membersOnly) return all; + return all.filter((e) => rankMap.has(e.pubkey)); + }, [moderatedGoals, membersOnly, rankMap, isExpired]); + const pastGoals = useMemo(() => { + const all = moderatedGoals.filter((e) => isExpired(e)); + const filtered = membersOnly ? all.filter((e) => rankMap.has(e.pubkey)) : all; + // Sort by deadline descending so the most recently ended goal appears first. + return filtered.sort((a, b) => { + const aClose = parseInt(a.tags.find(([n]) => n === 'closed_at')?.[1] ?? '0', 10); + const bClose = parseInt(b.tags.find(([n]) => n === 'closed_at')?.[1] ?? '0', 10); + return bClose - aClose; + }); + }, [moderatedGoals, membersOnly, rankMap, isExpired]); + const replyTree = useMemo((): ReplyNode[] => { if (!commentsData) return []; const topLevel = commentsData.topLevelComments ?? []; @@ -243,6 +287,30 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { } }, [event, toast]); + // ── FAB — visible on comments & fundraising tabs ────────────────────────── + const handleFabClick = useCallback(() => { + if (activeTab === 'comments') { + setComposeOpen(true); + } else if (activeTab === 'fundraising') { + setGoalDialogOpen(true); + } + }, [activeTab]); + + const fabIcon = activeTab === 'fundraising' + ? + : undefined; // default Plus icon for comments + + useLayoutOptions({ + showFAB: activeTab === 'comments' || activeTab === 'fundraising', + onFabClick: handleFabClick, + fabIcon, + }); + + const moderationCtx = useMemo( + () => communityATag ? { communityATag, moderation, rankMap } : null, + [communityATag, moderation, rankMap], + ); + // ── Render ────────────────────────────────────────────────────────────────── return (
@@ -303,8 +371,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
{/* ── Tabs ── */} - - + + Comments + + + Fundraising + {/* ── Members tab ── */} @@ -391,6 +466,42 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { )} + + {/* ── Fundraising tab ── */} + + {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.map((e) => ( + + ))} + + {/* Past/expired goals */} + {pastGoals.length > 0 && activeGoals.length > 0 && ( +
+

+ Past Goals +

+
+ )} + {pastGoals.map((e) => ( + + ))} +
+ )} +
@@ -408,6 +519,22 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { }} /> )} + + {/* FAB-triggered compose modal for the comments tab */} + + + {/* FAB-triggered goal creation dialog for the fundraising tab */} + {communityATag && ( + + )} ); } diff --git a/src/components/CreateGoalDialog.tsx b/src/components/CreateGoalDialog.tsx new file mode 100644 index 00000000..12af7941 --- /dev/null +++ b/src/components/CreateGoalDialog.tsx @@ -0,0 +1,218 @@ +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 { getEffectiveRelays } from '@/lib/appRelays'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; +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; + /** Controlled open state. When provided, the component is controlled externally. */ + open?: boolean; + /** Callback when the open state changes (for controlled mode). */ + onOpenChange?: (open: boolean) => void; +} + +export function CreateGoalDialog({ communityATag, children, open: controlledOpen, onOpenChange }: CreateGoalDialogProps) { + const [internalOpen, setInternalOpen] = useState(false); + const open = controlledOpen ?? internalOpen; + const setOpen = onOpenChange ?? setInternalOpen; + 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; + + // NIP-75 relay hints are where zap receipts should be published and tallied. + const relayUrls = getEffectiveRelays(config.relayMetadata, true).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()) { + const sanitizedImage = sanitizeUrl(imageUrl.trim()); + if (sanitizedImage) { + tags.push(['image', sanitizedImage]); + } + } + 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, + }); + + // 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 ( + + {controlledOpen === undefined && ( + + {children ?? ( + + )} + + )} + + + + Create Fundraising Goal + + +
+
+ + setTitle(e.target.value)} + required + /> +
+ +
+ + setAmountSats(e.target.value)} + required + /> +
+ +
+ +