Merge branch 'feat/fundraising' into 'main'

Add NIP-75 community fundraising goals

Closes #9

See merge request soapbox-pub/agora-3!12
This commit is contained in:
Sam Thomson
2026-05-08 04:48:37 +00:00
23 changed files with 1229 additions and 73 deletions
+72
View File
@@ -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:<community-author-pubkey>:<community-d-identifier>"],
["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:<pubkey>:<d-tag>`) 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:<pubkey>:<d-tag>"], "limit": 50 }
```
### Progress Tallying
Goal progress is calculated from kind `9735` zap receipts targeting the goal event:
```
{ "kinds": [9735], "#e": ["<goal-event-id>"], "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
+5 -2
View File
@@ -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<number, string> = {
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<Record<number, React.ComponentType<{ className?: strin
7516: ChestIcon,
39089: PartyPopper,
3367: Palette,
9041: Target,
31124: Egg,
9735: Zap,
};
+129 -2
View File
@@ -8,6 +8,7 @@ import {
Shield,
ShieldBan,
Share2,
Target,
Users,
} from 'lucide-react';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
@@ -19,17 +20,23 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { BanConfirmDialog } from '@/components/BanConfirmDialog';
import { ComposeBox } from '@/components/ComposeBox';
import { CreateGoalDialog } from '@/components/CreateGoalDialog';
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
import { NoteCard } from '@/components/NoteCard';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { useComments } from '@/hooks/useComments';
import { useCommunityMembers } from '@/hooks/useCommunityMembers';
import { useCommunityGoals } from '@/hooks/useCommunityGoals';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useMembersOnlyFilter } from '@/hooks/useMembersOnlyFilter';
import { useNow } from '@/hooks/useNow';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { CommunityModerationContext } from '@/contexts/CommunityModerationContext';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { applyCommunityModerationToEvents, canBanTarget, getViewerAuthority, parseCommunityEvent, type CommunityMember } from '@/lib/communityUtils';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
@@ -122,6 +129,11 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
const [banDialogOpen, setBanDialogOpen] = useState(false);
const [banTargetPubkey, setBanTargetPubkey] = useState<string | null>(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'
? <Target strokeWidth={3} size={18} />
: 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 (
<div className="max-w-2xl mx-auto pb-16">
@@ -303,8 +371,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
</div>
{/* ── Tabs ── */}
<CommunityModerationContext.Provider value={communityATag ? { communityATag, moderation, rankMap } : null}>
<Tabs defaultValue="members" className="-mx-5">
<CommunityModerationContext.Provider value={moderationCtx}>
<Tabs value={activeTab} onValueChange={setActiveTab} className="-mx-5">
<TabsList className="w-full rounded-none border-b border-border bg-transparent p-0 h-auto">
<TabsTrigger
value="members"
@@ -320,6 +388,13 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<MessageCircle className="size-4 mr-1.5" />
Comments
</TabsTrigger>
<TabsTrigger
value="fundraising"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
>
<Target className="size-4 mr-1.5" />
Fundraising
</TabsTrigger>
</TabsList>
{/* ── Members tab ── */}
@@ -391,6 +466,42 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
</div>
)}
</TabsContent>
{/* ── Fundraising tab ── */}
<TabsContent value="fundraising" className="mt-0">
{goalsLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 2 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</div>
) : activeGoals.length === 0 && pastGoals.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
{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!' : ''}</>}
</div>
) : (
<div className="divide-y divide-border">
{/* Active goals first */}
{activeGoals.map((e) => (
<NoteCard key={e.id} event={e} />
))}
{/* Past/expired goals */}
{pastGoals.length > 0 && activeGoals.length > 0 && (
<div className="px-5 pt-4 pb-1">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Past Goals
</h4>
</div>
)}
{pastGoals.map((e) => (
<NoteCard key={e.id} event={e} />
))}
</div>
)}
</TabsContent>
</Tabs>
</CommunityModerationContext.Provider>
</div>
@@ -408,6 +519,22 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
}}
/>
)}
{/* FAB-triggered compose modal for the comments tab */}
<ReplyComposeModal
event={event}
open={composeOpen}
onOpenChange={setComposeOpen}
/>
{/* FAB-triggered goal creation dialog for the fundraising tab */}
{communityATag && (
<CreateGoalDialog
communityATag={communityATag}
open={goalDialogOpen}
onOpenChange={setGoalDialogOpen}
/>
)}
</div>
);
}
+218
View File
@@ -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:<pubkey>:<d-tag>`). */
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 (
<Dialog open={open} onOpenChange={setOpen}>
{controlledOpen === undefined && (
<DialogTrigger asChild>
{children ?? (
<Button variant="outline" size="sm" className="gap-1.5">
<Target className="size-4" />
New Goal
</Button>
)}
</DialogTrigger>
)}
<DialogContent className="sm:max-w-md">
<DialogTitle className="flex items-center gap-2">
<Target className="size-5" />
Create Fundraising Goal
</DialogTitle>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
<div className="space-y-2">
<Label htmlFor="goal-title">Title</Label>
<Input
id="goal-title"
placeholder="e.g. Community meetup expenses"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="goal-amount">Target Amount (sats)</Label>
<Input
id="goal-amount"
type="number"
min="1"
placeholder="e.g. 100000"
value={amountSats}
onChange={(e) => setAmountSats(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="goal-summary">Description (optional)</Label>
<Textarea
id="goal-summary"
placeholder="Tell people what this fundraiser is for..."
value={summary}
onChange={(e) => setSummary(e.target.value)}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="goal-image">Image URL (optional)</Label>
<Input
id="goal-image"
type="url"
placeholder="https://..."
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="goal-deadline">Deadline (optional)</Label>
<Input
id="goal-deadline"
type="datetime-local"
value={deadlineDate}
onChange={(e) => setDeadlineDate(e.target.value)}
/>
</div>
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Goal'}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
+1
View File
@@ -1082,6 +1082,7 @@ function hasVideo(tags: string[][]): boolean {
/** Fallback labels for well-known kinds not in EXTRA_KINDS. */
const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
9041: 'Fundraising Goal',
31990: 'App',
32267: 'Zapstore App',
30063: 'Zapstore Release',
+152
View File
@@ -0,0 +1,152 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Clock, Info, Target, Users, Zap } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useGoalDisplay } from '@/hooks/useGoalDisplay';
import { formatSats, parseGoalEvent, type ParsedGoal } from '@/lib/goalUtils';
import { cn } from '@/lib/utils';
interface GoalCardProps {
event: NostrEvent;
}
/**
* Inline goal content renderer for NoteCard feeds and PostDetailPage.
* Displays progress bar, recipient info, deadline, and community link.
*/
export function GoalCard({ event }: GoalCardProps) {
const goal = useMemo(() => parseGoalEvent(event), [event]);
if (!goal) return null;
return <GoalCardInner event={event} goal={goal} />;
}
function GoalCardInner({ event, goal }: { event: NostrEvent; goal: ParsedGoal }) {
const d = useGoalDisplay(event, goal);
const [imgError, setImgError] = useState(false);
return (
<div className="mt-3 space-y-3">
{/* Goal image */}
{d.image && !imgError && (
<div className="-mx-4 aspect-[21/9] overflow-hidden">
<img
src={d.image}
alt={goal.title}
className="w-full h-full object-cover"
onError={() => setImgError(true)}
/>
</div>
)}
{/* Title + community link / status */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Target className="size-4 text-primary shrink-0" />
<h3 className="font-semibold text-[15px] leading-tight">{goal.title}</h3>
</div>
<div className="flex items-center gap-2 shrink-0">
{d.communityUrl && d.communityName && (
<Link
to={d.communityUrl}
className="flex items-center gap-1 text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
<Users className="size-3" />
{d.communityName}
</Link>
)}
{d.funded ? (
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
Funded
</Badge>
) : d.expired ? (
<Badge variant="secondary">Ended</Badge>
) : d.deadlineLabel ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="size-3" />
{d.deadlineLabel}
</span>
) : null}
</div>
</div>
{/* Summary */}
{goal.summary && (
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">{goal.summary}</p>
)}
{/* Progress bar */}
<div className="space-y-1.5">
<Progress
value={d.percentage}
className={cn('h-2.5', d.funded && '[&>div]:bg-emerald-500')}
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{d.progressLoading ? (
<Skeleton className="h-3.5 w-16 inline-block" />
) : d.progressIsPartial ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help underline decoration-dotted underline-offset-2">
~{formatSats(d.currentSats)} sats
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
Showing at least this amount. More zap receipts exist than we
could tally in a single fetch, so the real total is higher.
</TooltipContent>
</Tooltip>
) : (
<>{formatSats(d.currentSats)} sats</>
)}
</span>
<span>of {formatSats(goal.amountSats)} sats ({d.percentage}%)</span>
</div>
</div>
{goal.hasZapSplits ? (
// TODO: Render and support NIP-57 zap splits for NIP-75 goals.
<div className="flex items-start gap-2.5 rounded-lg bg-muted/50 px-3 py-2 text-muted-foreground">
<Info className="size-4 mt-0.5 shrink-0" />
<p className="text-xs leading-relaxed">
This goal uses split recipients. Split zap support is not available in this app yet.
</p>
</div>
) : (
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2">
<Link to={d.profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={d.avatarShape} className="size-8 ring-2 ring-background">
<AvatarImage src={d.metadata?.picture} />
<AvatarFallback className="bg-muted text-muted-foreground text-xs">
{d.displayName.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
<div className="min-w-0 flex-1">
<p className="text-xs text-muted-foreground">Receiving zaps</p>
<Link
to={d.profileUrl}
className="text-sm font-medium truncate block hover:underline"
onClick={(e) => e.stopPropagation()}
>
{d.displayName}
</Link>
{d.lightningAddress && (
<p className="text-xs text-muted-foreground truncate" title={d.lightningAddress}>
<Zap className="size-3 inline-block mr-0.5 -mt-0.5" />
{d.lightningAddress}
</p>
)}
</div>
</div>
)}
</div>
);
}
+15 -1
View File
@@ -16,6 +16,7 @@ import {
Share2,
SmilePlus,
PartyPopper,
Target,
Users,
Zap,
} from "lucide-react";
@@ -48,6 +49,7 @@ import { FollowPackContent } from "@/components/FollowPackContent";
import { FoundLogContent } from "@/components/FoundLogContent";
import { GeocacheContent } from "@/components/GeocacheContent";
import { GitRepoCard } from "@/components/GitRepoCard";
import { GoalCard } from "@/components/GoalCard";
import { NsiteCard } from "@/components/NsiteCard";
import { ImageGallery } from "@/components/ImageGallery";
import { CardsIcon } from "@/components/icons/CardsIcon";
@@ -104,6 +106,7 @@ import { formatNumber } from "@/lib/formatNumber";
import { publishedAtAction } from "@/lib/publishedAtAction";
import { getEffectiveStreamStatus } from "@/lib/streamStatus";
import { cn } from "@/lib/utils";
import { hasGoalZapSplits } from "@/lib/goalUtils";
/** Profile card for use in feeds (kind 0). */
@@ -342,7 +345,8 @@ export const NoteCard = memo(function NoteCard({
const [replyOpen, setReplyOpen] = useState(false);
// Check if the current user can zap this event's author
const canZapAuthor = user && canZap(metadata);
// TODO: Enable zapping split-recipient NIP-75 goals once zap split payments are supported.
const canZapAuthor = user && canZap(metadata) && !hasGoalZapSplits(event);
const { onClick: openPost, onAuxClick: auxOpenPost } = useOpenPost(
`/${encodedId}`,
@@ -400,6 +404,7 @@ export const NoteCard = memo(function NoteCard({
const isProfileBadges = event.kind === 10008 || event.kind === 30008;
const isBadge = isBadgeDefinition || isProfileBadges;
const isCommunity = event.kind === 34550;
const isZapGoal = event.kind === 9041;
const isReaction = event.kind === 7;
const isPollVote = event.kind === 1018;
const isRepost = event.kind === 6 || event.kind === 16;
@@ -444,6 +449,7 @@ export const NoteCard = memo(function NoteCard({
!isEmojiPack &&
!isBadge &&
!isCommunity &&
!isZapGoal &&
!isReaction &&
!isPollVote &&
!isRepost &&
@@ -601,6 +607,9 @@ export const NoteCard = memo(function NoteCard({
<ProfileBadgesContent event={event} />
) : isCommunity ? (
<CommunityContent event={event} />
) : isZapGoal ? (
<GoalCard event={event} />
) : isVoiceMessage ? (
<VoiceMessagePlayer event={event} />
) : isCalendarEvent ? (
@@ -1690,6 +1699,11 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
noun: "community",
nounRoute: "/communities",
},
9041: {
icon: Target,
action: "created a",
noun: "fundraising goal",
},
30009: {
icon: Award,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }),
+3 -1
View File
@@ -13,6 +13,7 @@ import { useEventStats } from '@/hooks/useTrending';
import { useToast } from '@/hooks/useToast';
import { canZap } from '@/lib/canZap';
import { formatNumber } from '@/lib/formatNumber';
import { hasGoalZapSplits } from '@/lib/goalUtils';
import { shareOrCopy } from '@/lib/share';
interface PostActionBarProps {
@@ -36,7 +37,8 @@ export function PostActionBar({
const { user } = useCurrentUser();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const canZapAuthor = user && canZap(metadata);
// TODO: Enable zapping split-recipient NIP-75 goals once zap split payments are supported.
const canZapAuthor = user && canZap(metadata) && !hasGoalZapSplits(event);
const { data: stats } = useEventStats(event.id, event);
const repostTotal = (stats?.reposts ?? 0) + (stats?.quotes ?? 0);
+88 -18
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useMyCommunities } from './useMyCommunities';
@@ -14,6 +14,8 @@ import {
resolveCommunityModeration,
resolveMembership,
} from '@/lib/communityUtils';
import { ZAP_GOAL_KIND } from '@/lib/goalUtils';
import { getPaginationCursor } from '@/lib/feedUtils';
/** Internal result type — events plus per-community moderation/membership data. */
interface ActivityFeedResult {
@@ -22,10 +24,15 @@ interface ActivityFeedResult {
moderationByATag: Map<string, CommunityModeration>;
/** Chain-validated rank maps keyed by community A tag (pre-moderation, for authority checks). */
rankMapByATag: Map<string, Map<string, CommunityMember>>;
/** Cursor for the next comments/goals page. */
oldestActivityTimestamp: number;
/** Whether at least one paginated activity filter returned a full page. */
hasMoreActivity: boolean;
}
const EMPTY_MODERATION_BY_A_TAG: ReadonlyMap<string, CommunityModeration> = new Map();
const EMPTY_RANK_MAP_BY_A_TAG: ReadonlyMap<string, Map<string, CommunityMember>> = new Map();
const ACTIVITY_PAGE_SIZE = 100;
/**
* Fetches a chronological activity feed for communities the current user
@@ -47,20 +54,28 @@ const EMPTY_RANK_MAP_BY_A_TAG: ReadonlyMap<string, Map<string, CommunityMember>>
*/
export function useCommunityActivityFeed() {
const { nostr } = useNostr();
const queryClient = useQueryClient();
const { data: myCommunities, isLoading: communitiesLoading } = useMyCommunities();
const aTags = myCommunities?.map((c) => c.community.aTag).filter(Boolean) ?? [];
const aTagsKey = aTags.join(',');
const query = useQuery<ActivityFeedResult>({
const query = useInfiniteQuery<ActivityFeedResult, Error>({
queryKey: ['community-activity-feed', aTagsKey],
queryFn: async ({ signal }) => {
queryFn: async ({ pageParam, signal }) => {
if (aTags.length === 0 || !myCommunities) {
return { events: [], moderationByATag: new Map(), rankMapByATag: new Map() };
return {
events: [],
moderationByATag: new Map(),
rankMapByATag: new Map(),
oldestActivityTimestamp: Math.floor(Date.now() / 1000),
hasMoreActivity: false,
};
}
const timeout = AbortSignal.timeout(8_000);
const combinedSignal = AbortSignal.any([signal, timeout]);
const until = pageParam as number | undefined;
// Collect all badge a-tag coordinates across all communities for membership resolution
const allBadgeATags: string[] = [];
@@ -70,8 +85,8 @@ export function useCommunityActivityFeed() {
}
}
// Fetch community definitions, comments, reports, and badge awards in parallel
const [definitionEvents, comments, reports, awards] = await Promise.all([
// Fetch community definitions, comments, reports, badge awards, and goals in parallel
const [definitionEvents, comments, reports, awards, goals] = await Promise.all([
// The community definitions themselves
nostr.query(
[{
@@ -87,7 +102,8 @@ export function useCommunityActivityFeed() {
[{
kinds: [1111],
'#A': aTags,
limit: 100,
limit: ACTIVITY_PAGE_SIZE,
...(until ? { until } : {}),
}],
{ signal: combinedSignal },
),
@@ -107,6 +123,16 @@ export function useCommunityActivityFeed() {
{ signal: combinedSignal },
)
: Promise.resolve([]),
// NIP-75 zap goals linked to these communities (lowercase a tag)
nostr.query(
[{
kinds: [ZAP_GOAL_KIND],
'#a': aTags,
limit: ACTIVITY_PAGE_SIZE,
...(until ? { until } : {}),
}],
{ signal: combinedSignal },
),
]);
// ── Resolve membership and moderation per community ──
@@ -147,7 +173,9 @@ export function useCommunityActivityFeed() {
// ── Check whether an event survives moderation in its community ──
const isAllowed = (event: NostrEvent): boolean => {
const eventATag = event.tags.find(([n]) => n === 'A')?.[1];
// NIP-22 comments use uppercase A; goals use lowercase a with a 34550: prefix
const eventATag = event.tags.find(([n]) => n === 'A')?.[1]
?? event.tags.find(([n, v]) => n === 'a' && v?.startsWith('34550:'))?.[1];
if (!eventATag) return true; // No community scope — not bannable here
const moderation = moderationByATag.get(eventATag);
if (!moderation) return true; // No moderation data for this community
@@ -158,7 +186,7 @@ export function useCommunityActivityFeed() {
const seen = new Set<string>();
const merged: NostrEvent[] = [];
for (const event of [...definitionEvents, ...comments]) {
for (const event of [...definitionEvents, ...comments, ...goals]) {
if (seen.has(event.id)) continue;
seen.add(event.id);
if (!isAllowed(event)) continue;
@@ -168,18 +196,60 @@ export function useCommunityActivityFeed() {
// Sort by created_at descending
merged.sort((a, b) => b.created_at - a.created_at);
return { events: merged, moderationByATag, rankMapByATag };
const paginatedActivity = [...comments, ...goals];
const oldestActivityTimestamp = getPaginationCursor(paginatedActivity);
// Seed the ['event', id] cache so embedded previews (quotes, reply
// context, etc.) resolve instantly instead of refetching.
for (const event of merged) {
if (!queryClient.getQueryData(['event', event.id])) {
queryClient.setQueryData(['event', event.id], event);
}
}
return {
events: merged,
moderationByATag,
rankMapByATag,
oldestActivityTimestamp,
hasMoreActivity: comments.length === ACTIVITY_PAGE_SIZE || goals.length === ACTIVITY_PAGE_SIZE,
};
},
getNextPageParam: (lastPage) => {
if (!lastPage.hasMoreActivity) return undefined;
return lastPage.oldestActivityTimestamp - 1;
},
initialPageParam: undefined as number | undefined,
enabled: !communitiesLoading && aTags.length > 0,
staleTime: 2 * 60_000,
gcTime: 30 * 60_000,
placeholderData: (prev) => prev,
refetchOnWindowFocus: false,
});
return useMemo(() => ({
data: query.data?.events,
moderationByATag: (query.data?.moderationByATag ?? EMPTY_MODERATION_BY_A_TAG) as Map<string, CommunityModeration>,
rankMapByATag: (query.data?.rankMapByATag ?? EMPTY_RANK_MAP_BY_A_TAG) as Map<string, Map<string, CommunityMember>>,
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
}), [query.data, query.isLoading, query.isError, query.error]);
return useMemo(() => {
const pages = query.data?.pages ?? [];
const seen = new Set<string>();
const events = pages
.flatMap((page) => page.events)
.filter((event) => {
if (seen.has(event.id)) return false;
seen.add(event.id);
return true;
})
.sort((a, b) => b.created_at - a.created_at);
const latestPage = pages[pages.length - 1];
return {
data: query.data ? events : undefined,
moderationByATag: (latestPage?.moderationByATag ?? EMPTY_MODERATION_BY_A_TAG) as Map<string, CommunityModeration>,
rankMapByATag: (latestPage?.rankMapByATag ?? EMPTY_RANK_MAP_BY_A_TAG) as Map<string, Map<string, CommunityMember>>,
isLoading: communitiesLoading || query.isLoading,
isError: query.isError,
error: query.error,
hasNextPage: query.hasNextPage,
isFetchingNextPage: query.isFetchingNextPage,
fetchNextPage: query.fetchNextPage,
};
}, [query.data, communitiesLoading, query.isLoading, query.isError, query.error, query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]);
}
+32
View File
@@ -0,0 +1,32 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { ZAP_GOAL_KIND, parseGoalEvent } from '@/lib/goalUtils';
/**
* Fetches kind 9041 zap goals linked to a community via an `a` tag.
* Returns validated events sorted newest-first.
*/
export function useCommunityGoals(communityATag: string | undefined) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['community-goals', communityATag],
queryFn: async (c): Promise<NostrEvent[]> => {
if (!communityATag) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
const events = await nostr.query(
[{ kinds: [ZAP_GOAL_KIND], '#a': [communityATag], limit: 50 }],
{ signal },
);
return events
.filter((e) => parseGoalEvent(e) !== null)
.sort((a, b) => b.created_at - a.created_at);
},
enabled: !!communityATag,
staleTime: 60_000,
});
}
+7 -5
View File
@@ -10,23 +10,25 @@ import { COMMUNITY_DEFINITION_KIND, parseCommunityEvent } from '@/lib/communityU
/**
* Resolve the community moderation context for a single event.
*
* Extracts the community `A` tag from the event, fetches the community
* Extracts the community tag from the event, fetches the community
* definition, resolves membership & moderation, and returns a value shaped
* for `CommunityModerationContext.Provider`. Callers install it as a
* Provider so that nested components (`NoteCard`, `NoteMoreMenu`) pick up
* the same moderation data via `useCommunityModerationContext()`.
*
* Returns `null` when:
* - The event has no community `A` tag
* - The `A` tag doesn't point to a kind 34550 community
* - The event has no community tag
* - The tag doesn't point to a kind 34550 community
* - The community definition hasn't loaded yet
*/
export function useCommunityModerationForEvent(event: NostrEvent): CommunityModerationContextValue | null {
const { nostr } = useNostr();
// Extract the community A tag and its addressable parts in one pass.
// Extract the community tag and its addressable parts in one pass.
// NIP-22 comments use uppercase A; NIP-75 goals link with lowercase a.
const parsed = useMemo(() => {
const aValue = event.tags.find(([n]) => n === 'A')?.[1];
const aValue = event.tags.find(([n]) => n === 'A')?.[1]
?? event.tags.find(([n, v]) => n === 'a' && v?.startsWith(`${COMMUNITY_DEFINITION_KIND}:`))?.[1];
if (!aValue) return null;
if (!aValue.startsWith(`${COMMUNITY_DEFINITION_KIND}:`)) return null;
const parts = aValue.split(':');
+2 -16
View File
@@ -1,6 +1,7 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { parseBolt11AmountMsats } from '@/lib/bolt11';
import { isCustomEmoji, getCustomEmojiUrl } from '@/lib/customEmoji';
export interface RepostEntry {
@@ -65,28 +66,13 @@ export function extractZapAmount(event: NostrEvent): number {
const bolt11Tag = event.tags.find(([name]) => name === 'bolt11');
if (bolt11Tag?.[1]) {
const msats = parseBolt11Amount(bolt11Tag[1]);
const msats = parseBolt11AmountMsats(bolt11Tag[1]);
if (msats > 0) return msats;
}
return 0;
}
function parseBolt11Amount(bolt11: string): number {
const match = bolt11.toLowerCase().match(/^ln\w+?(\d+)([munp]?)1/);
if (!match) return 0;
const value = parseInt(match[1], 10);
if (isNaN(value)) return 0;
const multiplier = match[2];
switch (multiplier) {
case 'm': return value * 100_000_000;
case 'u': return value * 100_000;
case 'n': return value * 100;
case 'p': return value / 10;
default: return value * 100_000_000_000;
}
}
/** Extracts the sender pubkey from a kind 9735 zap receipt. */
export function extractZapSender(event: NostrEvent): string {
// First check the P tag (uppercase) which NIP-57 specifies for sender pubkey
+104
View File
@@ -0,0 +1,104 @@
import { useMemo } from 'react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { getAvatarShape } from '@/lib/avatarShape';
import { isGoalExpired, parseCommunityATag, type ParsedGoal } from '@/lib/goalUtils';
import { genUserName } from '@/lib/genUserName';
import { useAddrEvent } from '@/hooks/useEvent';
import { useAuthor } from '@/hooks/useAuthor';
import { useGoalProgress } from '@/hooks/useGoalProgress';
import { useNow } from '@/hooks/useNow';
import { useProfileUrl } from '@/hooks/useProfileUrl';
export interface GoalDisplayData {
expired: boolean;
funded: boolean;
currentSats: number;
percentage: number;
progressLoading: boolean;
/** True when the zap tally hit the safety cap — the displayed total is a lower bound. */
progressIsPartial: boolean;
metadata: NostrMetadata | undefined;
displayName: string;
avatarShape: string | undefined;
profileUrl: string;
lightningAddress: string | undefined;
deadlineLabel: string | null;
communityName: string | undefined;
communityUrl: string | undefined;
/** Already sanitized at parse time. */
image: string | undefined;
}
/**
* Consolidates all display-related hooks and derived state for a goal event.
* Used by GoalCard inside NoteCard feeds and PostDetailPage.
*/
export function useGoalDisplay(event: NostrEvent, goal: ParsedGoal): GoalDisplayData {
const now = useNow(60_000);
const expired = isGoalExpired(goal);
const { currentSats, percentage, isLoading: progressLoading, isPartial: progressIsPartial } =
useGoalProgress(event, goal);
const funded = percentage >= 100;
// Recipient info
const author = useAuthor(goal.beneficiary);
const metadata: NostrMetadata | undefined = author.data?.metadata;
const displayName = metadata?.display_name || metadata?.name || genUserName(goal.beneficiary);
const avatarShape = getAvatarShape(metadata);
const profileUrl = useProfileUrl(goal.beneficiary, metadata);
const lightningAddress = metadata?.lud16 || metadata?.lud06 || undefined;
// Deadline label — `now` dependency ensures it refreshes every minute
const deadlineLabel = useMemo(() => {
if (!goal.closedAt) return null;
const diff = goal.closedAt - now;
if (diff <= 0) return 'Ended';
const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
if (days > 0) return `${days}d ${hours}h left`;
const mins = Math.floor((diff % 3600) / 60);
return hours > 0 ? `${hours}h ${mins}m left` : `${mins}m left`;
}, [goal.closedAt, now]);
// Community link
const communityAddr = useMemo(
() => (goal.communityATag ? parseCommunityATag(goal.communityATag) : undefined),
[goal.communityATag],
);
const { data: communityEvent } = useAddrEvent(communityAddr);
const communityName =
communityEvent?.tags.find(([n]) => n === 'name')?.[1] ||
communityEvent?.tags.find(([n]) => n === 'd')?.[1];
const communityUrl = useMemo(() => {
if (!communityAddr) return undefined;
try {
return `/${nip19.naddrEncode({
kind: communityAddr.kind,
pubkey: communityAddr.pubkey,
identifier: communityAddr.identifier,
})}`;
} catch {
return undefined;
}
}, [communityAddr]);
return {
expired,
funded,
currentSats,
percentage,
progressLoading,
progressIsPartial,
metadata,
displayName,
avatarShape,
profileUrl,
lightningAddress,
deadlineLabel,
communityName,
communityUrl,
image: goal.image,
};
}
+136
View File
@@ -0,0 +1,136 @@
import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { extractZapAmount, extractZapSender } from '@/hooks/useEventInteractions';
import { getPaginationCursor } from '@/lib/feedUtils';
import type { ParsedGoal } from '@/lib/goalUtils';
const ZAP_RECEIPT_PAGE_SIZE = 500;
const MAX_ZAP_RECEIPT_PAGES = 20;
export interface GoalProgress {
/** Total zapped in millisatoshis. */
currentMsat: number;
/** Total zapped in satoshis. */
currentSats: number;
/** Target in millisatoshis. */
targetMsat: number;
/** Target in satoshis. */
targetSats: number;
/** Percentage funded (0100, capped at 100). */
percentage: number;
/** Unique contributor pubkeys. */
contributors: string[];
/** Number of individual zap receipts. */
zapCount: number;
/** True when the tally reached the safety cap before exhausting relay results. */
isPartial: boolean;
}
interface ZapReceiptItem {
msats: number;
sender: string;
createdAt: number;
}
/**
* Queries kind 9735 zap receipts targeting a goal event and tallies progress.
* Respects the goal's `relays` and `closed_at` deadline.
*
* Zap receipts are tallied at face value (same as the rest of the app).
* Full NIP-57 validation was removed because the LNURL signer resolution
* added a network request per beneficiary for a trust level that is still
* spoofable and that no other zap display in the app enforces.
*/
export function useGoalProgress(goalEvent: NostrEvent | undefined, goal: ParsedGoal) {
const { nostr } = useNostr();
const goalEventId = goalEvent?.id;
const relaysKey = goal.relays.join(',');
const query = useQuery({
queryKey: ['goal-progress', goalEventId, relaysKey, goal.closedAt ?? null],
queryFn: async (c) => {
if (!goalEventId) {
return { receipts: [] as ZapReceiptItem[], isPartial: false };
}
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
const relay = goal.relays.length > 0 ? nostr.group(goal.relays) : nostr;
const receipts: ZapReceiptItem[] = [];
const seen = new Set<string>();
let until = goal.closedAt;
let isPartial = false;
for (let page = 0; page < MAX_ZAP_RECEIPT_PAGES; page++) {
const events = await relay.query(
[{ kinds: [9735], '#e': [goalEventId], limit: ZAP_RECEIPT_PAGE_SIZE, ...(until ? { until } : {}) }],
{ signal },
);
const validEvents = events.filter((event) => !goal.closedAt || event.created_at <= goal.closedAt);
for (const event of validEvents) {
if (seen.has(event.id)) continue;
seen.add(event.id);
receipts.push({
msats: extractZapAmount(event),
sender: extractZapSender(event),
createdAt: event.created_at,
});
}
if (events.length < ZAP_RECEIPT_PAGE_SIZE || validEvents.length === 0) {
break;
}
until = getPaginationCursor(validEvents) - 1;
if (page === MAX_ZAP_RECEIPT_PAGES - 1) {
isPartial = true;
}
}
return { receipts, isPartial };
},
enabled: !!goalEventId,
staleTime: 30_000,
});
const progress = useMemo((): GoalProgress => {
const items = query.data?.receipts ?? [];
let totalMsat = 0;
const contributorSet = new Set<string>();
let count = 0;
for (const item of items) {
// Skip receipts after the deadline
if (goal.closedAt && item.createdAt > goal.closedAt) continue;
if (item.msats <= 0) continue;
totalMsat += item.msats;
if (item.sender) contributorSet.add(item.sender);
count++;
}
const targetMsat = goal.amountMsat;
const targetSats = Math.floor(targetMsat / 1000);
const currentSats = Math.floor(totalMsat / 1000);
return {
currentMsat: totalMsat,
currentSats,
targetMsat,
targetSats,
percentage: targetMsat > 0 ? Math.min(100, Math.round((totalMsat / targetMsat) * 100)) : 0,
contributors: Array.from(contributorSet),
zapCount: count,
isPartial: query.data?.isPartial ?? false,
};
}, [query.data, goal.amountMsat, goal.closedAt]);
return {
...progress,
isLoading: query.isLoading,
};
}
+11
View File
@@ -0,0 +1,11 @@
import { useEffect, useState } from 'react';
/** Re-renders every `intervalMs` so time-dependent values stay fresh. */
export function useNow(intervalMs: number): number {
const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
useEffect(() => {
const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return now;
}
+9 -1
View File
@@ -10,6 +10,8 @@ import type { Event } from 'nostr-tools';
import type { WebLNProvider } from '@webbtc/webln-types';
import { useQueryClient } from '@tanstack/react-query';
import { notificationSuccess } from '@/lib/haptics';
import { parseGoalEvent } from '@/lib/goalUtils';
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Hook for sending zaps to an event author.
@@ -110,11 +112,17 @@ export function useZaps(
const zapAmount = amount * 1000; // convert to millisats
const goalRelays = target.kind === 9041
? parseGoalEvent(target as unknown as NostrEvent)?.relays
: undefined;
const zapRequest = nip57.makeZapRequest({
profile: target.pubkey,
event: event,
amount: zapAmount,
relays: config.relayMetadata.relays.map(r => r.url),
relays: goalRelays && goalRelays.length > 0
? goalRelays
: config.relayMetadata.relays.map(r => r.url),
comment
});
+41
View File
@@ -0,0 +1,41 @@
/**
* Parse the amount from a BOLT11 invoice string.
* Returns the amount in millisatoshis, or 0 if the invoice has no amount or cannot be parsed.
*
* BOLT11 encodes the amount in the human-readable part (HRP) of the bech32 string:
* ln + <coin type> + [amount + multiplier]
* The last `1` in the string separates the HRP from the data part.
* The amount is optional — invoices with no amount (e.g. `lnbc1p...`) return 0.
*/
export function parseBolt11AmountMsats(bolt11: string | undefined): number {
if (!bolt11) return 0;
const lower = bolt11.toLowerCase();
// Find the last '1' which separates HRP from data in bech32.
const lastOne = lower.lastIndexOf('1');
if (lastOne < 2) return 0;
const hrp = lower.substring(0, lastOne);
// HRP format: ln + coin type (bc/tb/bcrt/sb/ltc/tltc) + optional amount
// Extract the amount+multiplier portion from the end of the HRP.
const amountMatch = hrp.match(/(\d+)([munp]?)$/);
if (!amountMatch) return 0; // No amount in HRP (zero-amount invoice)
const value = parseInt(amountMatch[1], 10);
if (isNaN(value) || value <= 0) return 0;
// Convert to millisatoshis based on multiplier.
// The numeric value represents BTC divided by the multiplier:
// (none) = BTC, m = milli-BTC, u = micro-BTC, n = nano-BTC, p = pico-BTC
// 1 BTC = 100_000_000_000 msats
const multiplier = amountMatch[2];
switch (multiplier) {
case 'm': return value * 100_000_000; // milli-BTC: 1e8 msats
case 'u': return value * 100_000; // micro-BTC: 1e5 msats
case 'n': return value * 100; // nano-BTC: 1e2 msats
case 'p': return value / 10; // pico-BTC: 0.1 msats
default: return value * 100_000_000_000; // BTC: 1e11 msats
}
}
+1
View File
@@ -327,6 +327,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
id: 'communities',
showKey: 'showCommunities',
feedKey: 'feedIncludeCommunities',
extraFeedKinds: [9041],
label: 'Communities',
description: 'Hierarchical communities with ranked membership (NIP-72)',
route: 'communities',
+121
View File
@@ -0,0 +1,121 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { parseATagCoordinate } from '@/lib/nostrEvents';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// ── Kind constant ─────────────────────────────────────────────────────────────
/** NIP-75 Zap Goal (regular event). */
export const ZAP_GOAL_KIND = 9041;
function normalizeRelayUrl(value: string | undefined): string | undefined {
if (!value) return undefined;
try {
const url = new URL(value);
if (url.protocol !== 'wss:' && url.protocol !== 'ws:') return undefined;
return url.toString();
} catch {
return undefined;
}
}
// ── Parsed goal ───────────────────────────────────────────────────────────────
export interface ParsedGoal {
/** Human-readable title (the event `content`). */
title: string;
/** Target amount in millisatoshis. */
amountMsat: number;
/** Target amount in satoshis (convenience). */
amountSats: number;
/** Relay URLs for tallying zaps. */
relays: string[];
/** Optional deadline timestamp (unix seconds). */
closedAt?: number;
/** Optional sanitized image URL. */
image?: string;
/** Optional short summary. */
summary?: string;
/** If the goal links to a community, the `a` tag coordinate. */
communityATag?: string;
/** The pubkey receiving zaps (event author). */
beneficiary: string;
/** Whether this goal declares NIP-57 zap splits. */
hasZapSplits: boolean;
}
export function hasGoalZapSplits(event: NostrEvent): boolean {
return event.kind === ZAP_GOAL_KIND && event.tags.some(([n]) => n === 'zap');
}
/**
* Parse a kind 9041 zap goal event into structured data.
* Returns `null` if the event is invalid or missing required tags.
*/
export function parseGoalEvent(event: NostrEvent): ParsedGoal | null {
if (event.kind !== ZAP_GOAL_KIND) return null;
const title = event.content.trim();
if (!title) return null;
// Required: amount tag (millisats)
const amountStr = event.tags.find(([n]) => n === 'amount')?.[1];
if (!amountStr) return null;
const amountMsat = parseInt(amountStr, 10);
if (isNaN(amountMsat) || amountMsat <= 0) return null;
// Relays tag — preferred but not required. When empty, the goal progress
// hook falls back to the user's configured relays.
const relaysTag = event.tags.find(([n]) => n === 'relays');
const relays = relaysTag
? [...new Set(relaysTag.slice(1).map(normalizeRelayUrl).filter((v): v is string => !!v))]
: [];
// Optional tags
const closedAtStr = event.tags.find(([n]) => n === 'closed_at')?.[1];
const closedAt = closedAtStr ? parseInt(closedAtStr, 10) : undefined;
const rawImage = event.tags.find(([n]) => n === 'image')?.[1];
const image = sanitizeUrl(rawImage);
const summary = event.tags.find(([n]) => n === 'summary')?.[1] || undefined;
// Check for community link (a tag pointing to kind 34550)
const communityATag = event.tags
.find(([n, v]) => n === 'a' && v?.startsWith('34550:'))?.[1];
return {
title,
amountMsat,
amountSats: Math.floor(amountMsat / 1000),
relays,
closedAt: closedAt && !isNaN(closedAt) ? closedAt : undefined,
image: image || undefined,
summary,
communityATag,
beneficiary: event.pubkey,
hasZapSplits: hasGoalZapSplits(event),
};
}
/** Check whether a goal's deadline has passed. */
export function isGoalExpired(goal: ParsedGoal): boolean {
if (!goal.closedAt) return false;
return Math.floor(Date.now() / 1000) > goal.closedAt;
}
/** Format satoshis with locale-aware separators. */
export function formatSats(sats: number): string {
return sats.toLocaleString();
}
/**
* Parse a community `a` tag coordinate (`34550:<pubkey>:<d-tag>`) into its
* constituent parts. Returns `undefined` if the format is invalid or the kind
* is not 34550.
*/
export function parseCommunityATag(aTag: string): { kind: number; pubkey: string; identifier: string } | undefined {
const addr = parseATagCoordinate(aTag);
if (!addr || addr.kind !== 34550) return undefined;
return addr;
}
+18
View File
@@ -78,3 +78,21 @@ function getParentEventTag(event: NostrEvent): string[] | undefined {
// Deprecated positional scheme: last non-mention e-tag is the reply target
return eTags[eTags.length - 1];
}
/**
* Parse a Nostr `a`-tag coordinate string (`kind:pubkey:identifier`) into its
* constituent parts. Returns `undefined` if the format is invalid.
*
* Handles d-tags that contain colons by joining everything after the second
* colon back together.
*/
export function parseATagCoordinate(aTag: string): { kind: number; pubkey: string; identifier: string } | undefined {
const parts = aTag.split(':');
if (parts.length < 3) return undefined;
const kind = parseInt(parts[0], 10);
if (isNaN(kind) || kind < 0) return undefined;
const pubkey = parts[1];
if (!pubkey) return undefined;
const identifier = parts.slice(2).join(':');
return { kind, pubkey, identifier };
}
+57 -27
View File
@@ -1,7 +1,9 @@
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useSeoMeta } from '@unhead/react';
import { Users } from 'lucide-react';
import { Loader2, Users } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useInView } from 'react-intersection-observer';
import { CommunityCard } from '@/components/CommunityCard';
import { FeedEmptyState } from '@/components/FeedEmptyState';
@@ -196,10 +198,31 @@ function MyCommunitiesContent() {
// Activities Tab
// ═══════════════════════════════════════════════════════════════════════════════
/** Extract the community a-tag from an event (uppercase A for NIP-22, lowercase a with 34550: prefix for goals). */
function getCommunityATag(event: NostrEvent): string | undefined {
return event.tags.find(([n]) => n === 'A')?.[1]
?? event.tags.find(([n, v]) => n === 'a' && v?.startsWith('34550:'))?.[1];
}
function ActivitiesTab({ onRefresh }: { onRefresh: () => Promise<void> }) {
const { user } = useCurrentUser();
const { data: activityEvents, isLoading, moderationByATag, rankMapByATag } = useCommunityActivityFeed();
const {
data: activityEvents,
isLoading,
moderationByATag,
rankMapByATag,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useCommunityActivityFeed();
const { membersOnly } = useMembersOnlyFilter();
const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
// Build per-community context values for NoteMoreMenu moderation actions.
// Keyed by community A tag — each NoteCard is wrapped in its own provider.
@@ -222,7 +245,7 @@ function ActivitiesTab({ onRefresh }: { onRefresh: () => Promise<void> }) {
if (!membersOnly) return activityEvents;
return activityEvents.filter((event) => {
if (event.kind === COMMUNITY_DEFINITION_KIND) return true;
const aTag = event.tags.find(([n]) => n === 'A')?.[1];
const aTag = getCommunityATag(event);
if (!aTag) return true; // No community scope — pass through
const rankMap = rankMapByATag.get(aTag);
if (!rankMap) return true; // Moderation data not resolved — avoid hiding
@@ -249,29 +272,36 @@ function ActivitiesTab({ onRefresh }: { onRefresh: () => Promise<void> }) {
return (
<PullToRefresh onRefresh={onRefresh}>
{isLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardSkeleton key={i} />
))}
</div>
) : displayedEvents && displayedEvents.length > 0 ? (
<div>
{displayedEvents.map((event) => {
const aTag = event.tags.find(([n]) => n === 'A')?.[1];
const ctx = aTag ? contextByATag.get(aTag) ?? null : null;
return (
<CommunityModerationContext.Provider key={event.id} value={ctx}>
<NoteCard event={event} />
</CommunityModerationContext.Provider>
);
})}
</div>
) : membersOnly && activityEvents && activityEvents.length > 0 ? (
<FeedEmptyState message="No activity from members of your communities yet. Toggle the shield icon to see all community activity." />
) : (
<FeedEmptyState message="No activity from your communities yet." />
)}
<>
{isLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardSkeleton key={i} />
))}
</div>
) : displayedEvents && displayedEvents.length > 0 ? (
<div>
{displayedEvents.map((event) => {
const aTag = getCommunityATag(event);
const ctx = aTag ? contextByATag.get(aTag) ?? null : null;
return (
<CommunityModerationContext.Provider key={event.id} value={ctx}>
<NoteCard event={event} />
</CommunityModerationContext.Provider>
);
})}
</div>
) : membersOnly && activityEvents && activityEvents.length > 0 ? (
<FeedEmptyState message="No activity from members of your communities yet. Toggle the shield icon to see all community activity." />
) : (
<FeedEmptyState message="No activity from your communities yet." />
)}
{!isLoading && hasNextPage && (
<div ref={scrollRef} className="py-4 flex justify-center">
{isFetchingNextPage && <Loader2 className="size-5 animate-spin text-muted-foreground" />}
</div>
)}
</>
</PullToRefresh>
);
}
+1
View File
@@ -91,6 +91,7 @@ const NOTIFICATION_KIND_NOUNS: Record<number, string> = {
34139: 'playlist',
34236: 'divine',
34550: 'community',
9041: 'fundraising goal',
35128: 'nsite',
36787: 'track',
37381: 'Magic deck',
+6
View File
@@ -39,6 +39,7 @@ import {
const CustomNipCard = lazy(() => import("@/components/CustomNipCard").then(m => ({ default: m.CustomNipCard })));
import { FileMetadataContent } from "@/components/FileMetadataContent";
import { FollowPackContent } from "@/components/FollowPackContent";
import { GoalCard } from "@/components/GoalCard";
import { FollowPackDetailContent } from "@/components/FollowPackDetailContent";
import { FoundLogContent } from "@/components/FoundLogContent";
import { GeocacheContent } from "@/components/GeocacheContent";
@@ -127,6 +128,7 @@ function shellTitleForKind(kind?: number): string {
if (PODCAST_KINDS.has(kind)) return "Episode Details";
if (CALENDAR_EVENT_KINDS.has(kind)) return "Event Details";
if (kind === 34550) return "Community";
if (kind === 9041) return "Fundraising Goal";
if (FOLLOW_PACK_KINDS.has(kind)) return "Follow Pack";
if (kind === LIVE_STREAM_KIND) return "Live Stream";
if (kind === 30617) return "Repository";
@@ -1025,6 +1027,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
const isLetter = event.kind === 8211;
const isVanish = event.kind === VANISH_KIND;
const isZap = event.kind === 9735;
const isZapGoal = event.kind === 9041;
const isProfile = event.kind === 0;
const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite;
const isTextNote =
@@ -1054,6 +1057,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
!isLetter &&
!isVanish &&
!isZap &&
!isZapGoal &&
!isProfile;
const { data: stats } = useEventStats(event.id, event);
@@ -2137,6 +2141,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<EncryptedMessageContent event={event} />
) : isLetter ? (
<EncryptedLetterContent event={event} />
) : isZapGoal ? (
<GoalCard event={event} />
) : isVine ||
isPoll ||
isGeocache ||