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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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(':');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 (0–100, 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user