Merge remote-tracking branch 'origin/main' into hdwallet

# Conflicts:
#	src/components/DonateDialog.tsx
#	src/hooks/useDonateCampaign.ts
#	src/hooks/useOnchainZaps.ts
This commit is contained in:
Alex Gleason
2026-05-22 00:16:29 -05:00
111 changed files with 5309 additions and 6313 deletions
+31 -32
View File
@@ -43,7 +43,6 @@ const hardcodedConfig: AppConfig = {
shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined,
homePage: "campaigns",
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
magicMouse: false,
theme: "system",
useAppRelays: true,
useUserRelays: false,
@@ -57,48 +56,48 @@ const hardcodedConfig: AppConfig = {
feedIncludeReposts: true,
feedIncludeGenericReposts: true,
feedIncludeReactions: false,
feedIncludeZaps: false,
feedIncludeZaps: true,
feedIncludeArticles: true,
showArticles: true,
showHighlights: true,
feedIncludeHighlights: false,
feedIncludeHighlights: true,
showEvents: true,
feedIncludeEvents: true,
showVines: true,
showVines: false,
showPolls: true,
showTreasures: true,
showTreasureGeocaches: true,
showTreasureFoundLogs: true,
showColors: true,
showTreasures: false,
showTreasureGeocaches: false,
showTreasureFoundLogs: false,
showColors: false,
showPeopleLists: true,
feedIncludeVines: true,
feedIncludeVines: false,
feedIncludePolls: true,
feedIncludeTreasureGeocaches: true,
feedIncludeTreasureFoundLogs: true,
feedIncludeColors: true,
feedIncludeTreasureGeocaches: false,
feedIncludeTreasureFoundLogs: false,
feedIncludeColors: false,
feedIncludePeopleLists: true,
showDecks: true,
feedIncludeDecks: true,
showWebxdc: true,
feedIncludeWebxdc: true,
showDecks: false,
feedIncludeDecks: false,
showWebxdc: false,
feedIncludeWebxdc: false,
showPhotos: true,
feedIncludePhotos: true,
showVideos: true,
feedIncludeNormalVideos: true,
feedIncludeShortVideos: true,
feedIncludeVoiceMessages: true,
showEmojiPacks: true,
feedIncludeEmojiPacks: true,
showCustomEmojis: true,
showUserStatuses: true,
showMusic: true,
feedIncludeMusicTracks: true,
feedIncludeMusicPlaylists: true,
showPodcasts: true,
feedIncludePodcastEpisodes: true,
feedIncludePodcastTrailers: true,
showDevelopment: true,
feedIncludeDevelopment: true,
showEmojiPacks: false,
feedIncludeEmojiPacks: false,
showCustomEmojis: false,
showUserStatuses: false,
showMusic: false,
feedIncludeMusicTracks: false,
feedIncludeMusicPlaylists: false,
showPodcasts: false,
feedIncludePodcastEpisodes: false,
feedIncludePodcastTrailers: false,
showDevelopment: false,
feedIncludeDevelopment: false,
showCommunities: true,
feedIncludeCommunities: true,
showBadges: true,
@@ -109,10 +108,10 @@ const hardcodedConfig: AppConfig = {
feedIncludeProfileBadges: true,
feedIncludeBadgeAwards: true,
feedIncludeVanish: true,
showBirdstar: true,
feedIncludeBirdDetections: true,
feedIncludeBirdex: true,
feedIncludeConstellations: true,
showBirdstar: false,
feedIncludeBirdDetections: false,
feedIncludeBirdex: false,
feedIncludeConstellations: false,
followsFeedShowReplies: true,
},
sidebarOrder: [
+1 -9
View File
@@ -25,7 +25,7 @@ const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").th
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
// Campaigns: home + create. (Campaign detail is dispatched from NIP19Page
// when an naddr resolves to kind 30223.) The campaigns list IS the homepage;
// when an naddr resolves to kind 33863.) The campaigns list IS the homepage;
// the configurable HomePage delegation from the Twitter-era app is gone.
const CampaignsPage = lazy(() => import("./pages/CampaignsPage").then(m => ({ default: m.CampaignsPage })));
const CreateCampaignPage = lazy(() => import("./pages/CreateCampaignPage").then(m => ({ default: m.CreateCampaignPage })));
@@ -47,10 +47,8 @@ const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ de
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
const CreateCommunityPage = lazy(() => import("./pages/CreateCommunityPage").then(m => ({ default: m.CreateCommunityPage })));
const CreateEventPage = lazy(() => import("./pages/CreateEventPage").then(m => ({ default: m.CreateEventPage })));
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
const DiscoverPage = lazy(() => import("./pages/DiscoverPage").then(m => ({ default: m.DiscoverPage })));
const DomainFeedPage = lazy(() => import("./pages/DomainFeedPage").then(m => ({ default: m.DomainFeedPage })));
const EventsFeedPage = lazy(() => import("./pages/EventsFeedPage").then(m => ({ default: m.EventsFeedPage })));
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
@@ -63,7 +61,6 @@ const KindFeedPage = lazy(() => import("./pages/KindFeedPage").then(m => ({ defa
const LetterComposePage = lazy(() => import("./pages/LetterComposePage").then(m => ({ default: m.LetterComposePage })));
const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage").then(m => ({ default: m.LetterPreferencesPage })));
const LettersPage = lazy(() => import("./pages/LettersPage").then(m => ({ default: m.LettersPage })));
const MagicSettingsPage = lazy(() => import("./pages/MagicSettingsPage").then(m => ({ default: m.MagicSettingsPage })));
const MusicPage = lazy(() => import("./pages/MusicPage").then(m => ({ default: m.MusicPage })));
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
@@ -93,7 +90,6 @@ const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ de
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
const ReceivePage = lazy(() => import("./pages/ReceivePage").then(m => ({ default: m.ReceivePage })));
const ClaimPage = lazy(() => import("./pages/ClaimPage").then(m => ({ default: m.ClaimPage })));
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
const pollsDef = getExtraKindDef("polls")!;
@@ -167,12 +163,10 @@ export function AppRouter() {
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
<Route path="/follow/:npub" element={<FollowPage />} />
<Route path="/receive" element={<ReceivePage />} />
<Route path="/claim" element={<ClaimPage />} />
{/* All routes share the persistent FundraiserLayout (top nav + footer) */}
<Route element={<FundraiserLayout />}>
<Route path="/" element={<CampaignsPage />} />
<Route path="/discover" element={<DiscoverPage />} />
<Route path="/feed" element={<Index />} />
<Route path="/campaigns" element={<Navigate to="/" replace />} />
<Route path="/campaigns/new" element={<CreateCampaignPage />} />
@@ -189,7 +183,6 @@ export function AppRouter() {
<Route path="/settings/appearance" element={<AppearanceSettingsPage />} />
<Route path="/settings/profile" element={<ProfileSettings />} />
<Route path="/settings/feed" element={<ContentSettingsPage />} />
<Route path="/settings/content" element={<ContentPage />} />
<Route path="/settings/wallet" element={<WalletSettingsPage />} />
<Route
path="/settings/notifications"
@@ -199,7 +192,6 @@ export function AppRouter() {
path="/settings/advanced"
element={<AdvancedSettingsPage />}
/>
<Route path="/settings/magic" element={<MagicSettingsPage />} />
<Route path="/settings/network" element={<NetworkSettingsPage />} />
<Route path="/lists" element={<UserListsPage />} />
<Route path="/events" element={<EventsFeedPage />} />
-190
View File
@@ -1,190 +0,0 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { AlertTriangle, Check, Copy, ExternalLink } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
interface BeneficiaryDonatePanelProps {
/** Hex pubkey of the beneficiary. */
pubkey: string;
}
/**
* Inline panel rendering a beneficiary's Taproot address as a scannable
* BIP-21 QR code, a copyable string, and an "Open in wallet" button.
*
* Used both by `BeneficiaryDonateDialog` (modal context) and embedded
* directly into the campaign page when there's a single beneficiary.
*
* Always shows the beneficiary's profile preview (avatar + name) as a
* link to their Nostr profile — even when the surrounding page also
* identifies a campaign organizer, the beneficiary is a distinct party
* (the organizer may be running the campaign on someone else's behalf).
*
* Intentionally minimal: no amount input, no PSBT/in-app wallet flow —
* that's `DonateDialog`'s job.
*/
export function BeneficiaryDonatePanel({
pubkey,
}: BeneficiaryDonatePanelProps) {
const { toast } = useToast();
const [copied, setCopied] = useState(false);
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName =
metadata?.display_name || metadata?.name || genUserName(pubkey);
const picture = sanitizeUrl(metadata?.picture);
const profileUrl = useProfileUrl(pubkey, metadata);
const address = useMemo(
() => nostrPubkeyToBitcoinAddress(pubkey),
[pubkey],
);
// BIP-21 URI: most wallets recognize the `bitcoin:` scheme when scanning.
// No amount field — donor picks one in their wallet.
const bip21 = address ? `bitcoin:${address}` : '';
const copyAddress = async () => {
if (!address) return;
try {
await navigator.clipboard.writeText(address);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
toast({ title: 'Address copied' });
} catch {
toast({
title: 'Copy failed',
description: 'Select and copy the address manually.',
variant: 'destructive',
});
}
};
if (!address) {
return (
<div className="flex items-center gap-2 text-sm">
<AlertTriangle className="size-5 text-orange-500 shrink-0" />
<span>We couldn't derive a Bitcoin address for this beneficiary.</span>
</div>
);
}
return (
<div className="space-y-4">
<Link
to={profileUrl}
className="flex items-center gap-3 rounded-md -mx-2 px-2 py-1.5 motion-safe:transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Avatar className="size-10 ring-1 ring-border">
{picture && <AvatarImage src={picture} alt="" />}
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="font-medium truncate">{displayName}</div>
</div>
</Link>
{/* QR code */}
<div className="flex justify-center">
<div className="rounded-2xl bg-white p-4 shadow-sm">
<QRCodeCanvas value={bip21} size={200} level="M" />
</div>
</div>
{/* Copyable address */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
Bitcoin address
</Label>
<button
type="button"
onClick={copyAddress}
className="w-full flex items-center justify-between gap-2 rounded-lg border bg-muted/40 px-3 py-2.5 font-mono text-xs break-all text-left hover:bg-muted/60 motion-safe:transition-colors"
aria-label="Copy Bitcoin address"
>
<span className="break-all">{address}</span>
{copied ? (
<Check className="size-4 text-green-500 shrink-0" />
) : (
<Copy className="size-4 text-muted-foreground shrink-0" />
)}
</button>
</div>
{/* Privacy notice — informational only. Bitcoin is a public
ledger, so the donation can be traced back to the donor's
wallet. */}
<BitcoinPublicDisclaimer
tone="soft"
includeCashOutAdvice={false}
leadText="Donations are public and can be traced back to you."
/>
{/* Open in wallet — relies on the `bitcoin:` URI handler. */}
<Button asChild className="w-full">
<a href={bip21}>
<ExternalLink className="size-4 mr-1.5" />
Open in wallet
</a>
</Button>
</div>
);
}
interface BeneficiaryDonateDialogProps {
/** Hex pubkey of the beneficiary. */
pubkey: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Modal wrapper around `BeneficiaryDonatePanel` for places that still want
* the dialog UX (e.g. multi-beneficiary campaigns, where each row's
* "Donate" button opens this dialog).
*/
export function BeneficiaryDonateDialog({
pubkey,
open,
onOpenChange,
}: BeneficiaryDonateDialogProps) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName =
metadata?.display_name || metadata?.name || genUserName(pubkey);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Donate to {displayName}</DialogTitle>
<DialogDescription className="sr-only">
Scan the QR code or copy the Bitcoin address below to donate.
</DialogDescription>
</DialogHeader>
<BeneficiaryDonatePanel pubkey={pubkey} />
</DialogContent>
</Dialog>
);
}
+6 -1
View File
@@ -67,7 +67,12 @@ export function BitcoinPublicDisclaimer({
role={isSoft ? 'note' : 'alert'}
className={cn(
isSoft
? 'border-amber-300/60 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100'
// Use the project's foreground token (not raw amber-900) so
// the text always contrasts against the page in both light
// and dark themes. The faint amber tint keeps the
// "informational notice" cue without leaning on hard-coded
// amber text that disappears on the wrong backdrop.
? 'border-amber-500/30 bg-amber-500/10 text-foreground'
: 'border-destructive/50 bg-destructive/5 text-destructive dark:border-destructive',
)}
>
+400 -210
View File
@@ -1,8 +1,8 @@
import { useMemo, useCallback, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
ArrowLeft,
CalendarDays,
ChevronLeft,
MapPin,
Clock,
Users,
@@ -18,10 +18,12 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Card, CardContent } from '@/components/ui/card';
import { DetailCommentComposer } from '@/components/DetailCommentComposer';
import { NoteContent } from '@/components/NoteContent';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { PostActionBar } from '@/components/PostActionBar';
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog';
import { RSVPAvatars } from '@/components/RSVPAvatars';
@@ -33,9 +35,11 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
import { useMyRSVP } from '@/hooks/useMyRSVP';
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { openUrl } from '@/lib/downloadFile';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
@@ -126,6 +130,26 @@ function formatDetailDate(event: NostrEvent): string {
return startStr;
}
function formatCalendarHeroDate(event: NostrEvent): string | null {
const startRaw = getTag(event.tags, 'start');
if (!startRaw) return null;
if (event.kind === 31922) {
const [year, month, day] = startRaw.split('-').map(Number);
const date = new Date(year, month - 1, day);
if (isNaN(date.getTime())) return startRaw;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
const timestamp = Number(startRaw);
if (!Number.isFinite(timestamp)) return startRaw;
const startTzid = getTag(event.tags, 'start_tzid');
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
...(startTzid ? { timeZone: startTzid } : {}),
});
}
const ROLE_ORDER = ['host', 'speaker', 'moderator', 'participant'];
function roleSort(a: string, b: string): number {
const ai = ROLE_ORDER.indexOf(a.toLowerCase());
@@ -162,6 +186,15 @@ function PersonRow({ pubkey, label, size = 'md' }: { pubkey: string; label?: str
);
}
function EventDetailRow({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3 rounded-xl bg-muted/40 px-3 py-3">
<div className="mt-0.5 text-primary shrink-0">{icon}</div>
<div className="min-w-0 text-sm leading-relaxed">{children}</div>
</div>
);
}
// --- Main Component ---
export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
@@ -170,7 +203,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const { toast } = useToast();
const title = getTag(event.tags, 'title') ?? 'Untitled Event';
const image = getTag(event.tags, 'image');
const image = sanitizeUrl(getTag(event.tags, 'image'));
const locationRaw = getTag(event.tags, 'location');
const location = locationRaw ? parseLocation(locationRaw) : undefined;
const summary = getTag(event.tags, 'summary');
@@ -179,6 +212,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const eventCoord = useMemo(() => getEventCoord(event), [event]);
const dateStr = useMemo(() => formatDetailDate(event), [event]);
const heroDate = useMemo(() => formatCalendarHeroDate(event), [event]);
// Participants grouped by role
const participantsByRole = useMemo(() => {
@@ -200,6 +234,12 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const myRsvp = useMyRSVP(eventCoord);
const publishRSVP = usePublishRSVP();
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
const {
pinnedEvents,
isPinned,
canManagePins,
togglePin,
} = usePinnedEventComments(eventCoord, event.pubkey);
const [replyOpen, setReplyOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
@@ -225,6 +265,11 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
.map((comment) => buildNode(comment));
}, [commentsData]);
const pinnedNodes = useMemo(
() => pinnedEvents.map((event): ReplyNode => ({ event, children: [] })),
[pinnedEvents],
);
const handleRSVP = useCallback(async (status: 'accepted' | 'declined' | 'tentative') => {
if (status === myRsvp.status) return;
try {
@@ -240,202 +285,339 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
}, [eventCoord, event.pubkey, myRsvp.status, publishRSVP, toast]);
const showRSVP = !!user;
const attendingCount = rsvps.accepted.length;
const interestedCount = rsvps.tentative.length;
const rsvpStatusLabel = myRsvp.status === 'accepted'
? 'You are going'
: myRsvp.status === 'tentative'
? 'You are interested'
: myRsvp.status === 'declined'
? "You can't go"
: 'Choose your RSVP';
return (
<div className="max-w-2xl mx-auto pb-16">
{/* ── Standard top bar ── */}
<div className="flex items-center gap-4 px-4 pt-4 pb-5">
<button
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
aria-label="Go back"
>
<ArrowLeft className="size-5" />
</button>
<h1 className="text-xl font-bold flex-1">Event Details</h1>
{canEdit && (
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={() => setEditOpen(true)}
aria-label="Edit event"
>
<Pencil className="size-5" />
</button>
const eventDetailsCard = (
<Card className="overflow-hidden">
<CardContent className="p-5 space-y-5">
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Hosted by</div>
<PersonRow pubkey={event.pubkey} size="sm" />
</div>
{(event.content || summary) && (
<div className="space-y-2 border-t border-border/60 pt-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Description</div>
{event.content ? (
<NoteContent event={event} className="text-sm leading-relaxed text-foreground" hideEmbedImages={!!image} disableEmbeds disableNoteEmbeds />
) : (
<p className="text-sm leading-relaxed text-muted-foreground">{summary}</p>
)}
</div>
)}
</div>
{/* ── Cover image ── */}
{image ? (
<div className="aspect-[2/1] w-full overflow-hidden">
<img src={image} alt={title} className="w-full h-full object-cover" />
</div>
) : (
<div className="aspect-[3/1] w-full bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
<CalendarDays className="size-20 text-primary/20" />
</div>
)}
{/* ── Content ── */}
<div className="px-5 mt-5 space-y-5">
{/* Title */}
<h2 className="text-2xl font-bold leading-tight tracking-tight">{title}</h2>
{/* Organizer row */}
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<PersonRow pubkey={event.pubkey} />
</div>
</div>
{/* Date & Location — sidebar-style pills */}
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85">
<Clock className="size-5 text-primary shrink-0" />
<span className="text-sm">{dateStr}</span>
</div>
<div className="space-y-3">
<EventDetailRow icon={<Clock className="size-5" />}>
{dateStr}
</EventDetailRow>
{location && (
<div className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85">
<MapPin className="size-5 text-primary shrink-0" />
<span className="text-sm">{location}</span>
</div>
<EventDetailRow icon={<MapPin className="size-5" />}>
{location}
</EventDetailRow>
)}
</div>
{/* Hashtags */}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-2">
{hashtags.map((tag) => (
<Link key={tag} to={`/t/${tag}`}>
<Badge variant="secondary" className="cursor-pointer hover:bg-secondary/80 text-xs px-2.5 py-0.5">
#{tag}
</Badge>
</Link>
))}
{showRSVP && (
<div className="space-y-3 border-t border-border/60 pt-4">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">RSVP</div>
<span className="text-xs font-medium text-muted-foreground">{rsvpStatusLabel}</span>
</div>
<div className="grid grid-cols-3 gap-2">
<Button
size="sm"
variant={myRsvp.status === 'accepted' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('rounded-full px-2', myRsvp.status === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
onClick={() => handleRSVP('accepted')}
>
<Check className="size-3.5 mr-1" />
Going
</Button>
<Button
size="sm"
variant={myRsvp.status === 'tentative' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('rounded-full px-2', myRsvp.status === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
onClick={() => handleRSVP('tentative')}
>
<Star className="size-3.5 mr-1" />
Interested
</Button>
<Button
size="sm"
variant={myRsvp.status === 'declined' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('rounded-full px-2', myRsvp.status === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
onClick={() => handleRSVP('declined')}
>
<XIcon className="size-3.5 mr-1" />
Can't Go
</Button>
</div>
</div>
)}
{/* Description */}
{(event.content || summary) && (
<>
<Separator />
<section className="space-y-2">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">About</h2>
{event.content ? (
<NoteContent event={event} className="text-sm leading-relaxed text-foreground" hideEmbedImages={!!image} />
) : (
<p className="text-sm leading-relaxed text-muted-foreground">{summary}</p>
{rsvps.total > 0 && (
<div className="space-y-3 border-t border-border/60 pt-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Attendees</div>
<div className="space-y-3">
{([
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
] as const).map(([label, pks, cls]) => pks.length > 0 && (
<div key={label} className="space-y-2">
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
</div>
))}
</div>
</div>
)}
{links.length > 0 && (
<div className="space-y-2 border-t border-border/60 pt-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Links</div>
<div className="space-y-1">
{links.map((url) => (
<button
key={url}
type="button"
onClick={() => void openUrl(url)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted/50 transition-colors"
>
<LinkIcon className="size-4 text-primary shrink-0" />
<span className="truncate flex-1">{url.replace(/^https?:\/\//, '')}</span>
<ExternalLink className="size-3.5 text-muted-foreground shrink-0" />
</button>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
const participantsCard = participantsByRole.length > 0 ? (
<Card>
<CardContent className="p-5 space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Participants</div>
<div className="space-y-2">
{participantsByRole.map(([role, pubkeys]) =>
pubkeys.map((pk) => <PersonRow key={`${role}-${pk}`} pubkey={pk} label={role} size="sm" />),
)}
</div>
</CardContent>
</Card>
) : null;
return (
<main className="min-h-screen pb-16">
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-4">
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-t-xl rounded-b-none overflow-hidden bg-gradient-to-br from-primary/30 via-primary/15 to-secondary">
{image ? (
<img src={image} alt="" className="absolute inset-0 size-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center">
<CalendarDays className="size-16 sm:size-20 text-primary/40" />
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-black/45" />
<div className="absolute left-0 right-0 top-0 z-10 flex items-center justify-between gap-3 px-4 pt-4">
<button
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
className="p-2.5 -ml-2 rounded-full text-white/90 hover:bg-white/15 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 motion-safe:transition-colors"
aria-label="Go back"
>
<ChevronLeft className="size-6 drop-shadow-[0_1px_2px_rgba(0,0,0,0.85)]" />
</button>
{canEdit && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setEditOpen(true)}
className="rounded-full bg-transparent text-white/90 shadow-none hover:bg-white/15 hover:text-white focus-visible:ring-white/80"
>
<Pencil className="size-4 mr-2" />
Edit
</Button>
)}
</div>
<div className="absolute inset-x-0 bottom-0 z-10 space-y-2 p-5 sm:p-6 [text-shadow:0_1px_4px_rgba(0,0,0,0.75),0_2px_10px_rgba(0,0,0,0.45)]">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h1 className="text-3xl sm:text-4xl font-bold leading-tight tracking-tight text-white">
{title}
</h1>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs sm:text-sm font-medium text-white/85">
{heroDate && (
<span className="inline-flex items-center gap-1.5">
<CalendarDays className="size-3.5 sm:size-4" />
{heroDate}
</span>
)}
{location && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5 sm:size-4" />
{location}
</span>
)}
{attendingCount > 0 && (
<span className="inline-flex items-center gap-1.5">
<Users className="size-3.5 sm:size-4" />
{attendingCount} attending
</span>
)}
{interestedCount > 0 && (
<span className="inline-flex items-center gap-1.5">
<Users className="size-3.5 sm:size-4" />
{interestedCount} interested
</span>
)}
</div>
{summary && (
<p className="max-w-2xl text-base sm:text-lg text-white/90 line-clamp-3">
{summary}
</p>
)}
</div>
</div>
</div>
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="rounded-b-xl rounded-t-none bg-card border border-t-0 border-border/60 shadow-sm px-4 sm:px-5 py-3">
<PostActionBar
event={event}
replyLabel="Comment"
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
/>
</div>
</div>
{pinnedNodes.length > 0 && (
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-6">
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
<ThreadedReplyList
roots={pinnedNodes}
renderItemHeader={(event) => (
<EventPinHeader
isPinned={isPinned(event.id)}
canManagePins={canManagePins}
pinPending={togglePin.isPending}
onTogglePin={() => handleTogglePin(event)}
/>
)}
/>
</div>
</div>
)}
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 lg:py-10">
<div className="lg:hidden mb-6 space-y-4">
{eventDetailsCard}
{participantsCard}
</div>
<div className="lg:flex lg:gap-8 lg:items-start">
<div className="flex-1 min-w-0 space-y-8">
<section className="space-y-5">
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-2">
{hashtags.map((tag) => (
<Link key={tag} to={`/t/${tag}`}>
<Badge variant="secondary" className="cursor-pointer hover:bg-secondary/80 text-xs px-2.5 py-0.5">
#{tag}
</Badge>
</Link>
))}
</div>
)}
{(event.content || summary) && (
<article className="prose prose-neutral dark:prose-invert max-w-none">
{event.content ? (
<NoteContent event={event} hideEmbedImages={!!image} />
) : (
<p className="text-muted-foreground">{summary}</p>
)}
</article>
)}
</section>
</>
)}
{/* External links */}
{links.length > 0 && (
<div className="flex flex-col gap-0.5">
{links.map((url) => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85 hover:bg-secondary/60 transition-colors"
>
<LinkIcon className="size-5 text-primary shrink-0" />
<span className="text-sm truncate flex-1">{url.replace(/^https?:\/\//, '')}</span>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</a>
))}
<section id="event-comments" className="scroll-mt-20">
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
<h2 className="text-lg font-semibold tracking-tight">Comments</h2>
{replyTree.length > 0 ? (
<span className="text-sm text-muted-foreground tabular-nums">
{replyTree.length.toLocaleString()} {replyTree.length === 1 ? 'comment' : 'comments'}
</span>
) : null}
</div>
<DetailCommentComposer event={event} className="mb-3" />
{commentsLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-2xl bg-card border border-border/60 px-4 py-3">
<div className="flex gap-3">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
</div>
))}
</div>
) : replyTree.length > 0 ? (
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
<ThreadedReplyList
roots={replyTree}
renderItemHeader={(event) => (
<EventPinHeader
isPinned={isPinned(event.id)}
canManagePins={canManagePins}
pinPending={togglePin.isPending}
onTogglePin={() => handleTogglePin(event)}
/>
)}
/>
</div>
) : (
<button
type="button"
onClick={() => setReplyOpen(true)}
className="block w-full rounded-2xl border border-dashed border-border/80 bg-card/50 px-6 py-10 text-center hover:bg-card hover:border-primary/40 transition-colors"
>
<p className="text-base font-medium text-foreground">No comments yet</p>
<p className="mt-1 text-sm text-muted-foreground">Be the first to comment.</p>
</button>
)}
</section>
</div>
)}
{/* Participants */}
{participantsByRole.length > 0 && (
<>
<Separator />
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Users className="size-4" /> Participants
</h2>
<div className="space-y-2">
{participantsByRole.map(([role, pubkeys]) =>
pubkeys.map((pk) => <PersonRow key={pk} pubkey={pk} label={role} size="sm" />),
)}
</div>
</section>
</>
)}
{/* Attendees */}
{rsvps.total > 0 && (
<>
<Separator />
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Users className="size-4" /> Attendees
</h2>
<div className="space-y-2.5">
{([
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
] as const).map(([label, pks, cls]) => pks.length > 0 && (
<div key={label} className="flex items-center gap-3">
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
</div>
))}
</div>
</section>
</>
)}
{/* RSVP section */}
{showRSVP && (
<>
<Separator />
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Check className="size-4" /> RSVP
</h2>
<div className="flex gap-2">
<Button
size="sm"
variant={myRsvp.status === 'accepted' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
onClick={() => handleRSVP('accepted')}
>
<Check className="size-3.5 mr-1.5" /> Going
</Button>
<Button
size="sm"
variant={myRsvp.status === 'tentative' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
onClick={() => handleRSVP('tentative')}
>
<Star className="size-3.5 mr-1.5" /> Interested
</Button>
<Button
size="sm"
variant={myRsvp.status === 'declined' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
onClick={() => handleRSVP('declined')}
>
<XIcon className="size-3.5 mr-1.5" /> Can't Go
</Button>
</div>
</section>
</>
)}
<PostActionBar
event={event}
replyLabel="Comments"
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
className="-mx-5 px-5"
/>
<aside className="hidden lg:block lg:w-[360px] lg:shrink-0 lg:self-start">
<div className="lg:sticky lg:top-4 space-y-4">
{eventDetailsCard}
{participantsCard}
</div>
</aside>
</div>
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
@@ -446,32 +628,40 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
event={event}
/>
)}
<section>
{commentsLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
))}
</div>
) : replyTree.length > 0 ? (
<div className="-mx-5">
<ThreadedReplyList roots={replyTree} />
</div>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
No comments yet. Be the first to comment!
</div>
)}
</section>
</div>
</div>
</main>
);
function handleTogglePin(event: NostrEvent) {
const wasPinned = isPinned(event.id);
togglePin.mutate(event.id, {
onSuccess: () => {
toast({ title: wasPinned ? 'Unpinned from event' : 'Pinned to event' });
},
onError: () => {
toast({ title: 'Failed to update event pins', variant: 'destructive' });
},
});
}
}
function EventPinHeader({
isPinned,
canManagePins,
pinPending,
onTogglePin,
}: {
isPinned: boolean;
canManagePins: boolean;
pinPending: boolean;
onTogglePin: () => void;
}) {
return (
<PinnedCommentHeader
isPinned={isPinned}
canManagePins={canManagePins}
pinPending={pinPending}
onTogglePin={onTogglePin}
/>
);
}
+67 -33
View File
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { CalendarClock, HandHeart, MapPin, Target, Users, Archive } from 'lucide-react';
import { CalendarClock, EyeOff, HandHeart, MapPin, ShieldCheck, Target } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
@@ -15,9 +16,8 @@ import {
type ParsedCampaign,
encodeCampaignNaddr,
getCampaignCountryLabel,
getCampaignPrimaryTagLabel,
} from '@/lib/campaign';
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
@@ -33,20 +33,32 @@ function formatDeadline(unixSeconds: number): { label: string; isPast: boolean }
return { label: `${months} mo left`, isPast: false };
}
/** Short helper rendered both inline (cards) and in the detail page. */
/**
* Short helper rendered both inline (cards) and in the detail page.
*
* Per NIP.md Kind 33863, the campaign **goal** is integer USD and the
* **raised** total is the sum of verified sats. We render both in the
* goal's unit (USD) for consistency, converting the sats total at view
* time using the live BTC price. While the price is loading the raised
* amount falls back to sats.
*/
export function CampaignProgress({
raisedSats,
goalSats,
goalUsd,
btcPrice,
className,
}: {
raisedSats: number;
goalSats?: number;
goalUsd?: number;
btcPrice?: number;
className?: string;
}) {
const hasGoal = !!goalSats && goalSats > 0;
const pct = hasGoal ? Math.min(100, Math.round((raisedSats / goalSats!) * 100)) : 0;
const hasGoal = !!goalUsd && goalUsd > 0;
const raisedUsd = satsToUsd(raisedSats, btcPrice);
const pct = hasGoal && raisedUsd !== undefined
? Math.min(100, Math.round((raisedUsd / goalUsd!) * 100))
: 0;
return (
<div className={cn('space-y-1.5', className)}>
{hasGoal && <Progress value={pct} className="h-2" />}
@@ -56,32 +68,59 @@ export function CampaignProgress({
{!hasGoal && <span className="ml-1 font-normal text-muted-foreground">raised</span>}
</span>
{hasGoal && (
<span className="text-muted-foreground">of {formatCampaignAmount(goalSats!, btcPrice)} goal</span>
<span className="text-muted-foreground">of {formatUsdGoal(goalUsd!)} goal</span>
)}
</div>
</div>
);
}
/**
* Replaces {@link CampaignProgress} for silent-payment campaigns, where
* on-chain totals are unobservable by design. Shows the goal as a target
* (if set) but no progress bar or raised amount.
*/
export function CampaignPrivateNotice({
goalUsd,
className,
}: {
goalUsd?: number;
className?: string;
}) {
return (
<div className={cn('space-y-1.5 text-sm', className)}>
<div className="flex items-center gap-1.5 text-muted-foreground">
<ShieldCheck className="size-3.5" />
<span>Private campaign totals are not public</span>
</div>
{goalUsd && goalUsd > 0 && (
<div className="text-xs text-muted-foreground">Target: {formatUsdGoal(goalUsd)}</div>
)}
</div>
);
}
interface CampaignCardProps {
campaign: ParsedCampaign;
/** Visual variant: `compact` for grid items, `featured` for hero placement. */
variant?: 'compact' | 'featured';
className?: string;
/** Optional footer affordance rendered opposite the author line. */
footerBadge?: ReactNode;
}
/**
* Renders a single campaign as a clickable card. The whole card is a
* `<Link>` to the campaign's naddr-based detail route.
*/
export function CampaignCard({ campaign, variant = 'compact', className }: CampaignCardProps) {
export function CampaignCard({ campaign, variant = 'compact', className, footerBadge }: CampaignCardProps) {
const author = useAuthor(campaign.pubkey);
const { data: stats } = useCampaignDonations(campaign.aTag);
const { data: stats } = useCampaignDonations(campaign);
const { data: btcPrice } = useBtcPrice();
const { data: moderation } = useCampaignModeration();
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
const cover = sanitizeUrl(campaign.image);
const cover = sanitizeUrl(campaign.banner);
const creatorName =
author.data?.metadata?.display_name ||
author.data?.metadata?.name ||
@@ -89,7 +128,7 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null;
const raisedSats = stats?.totalSats ?? 0;
const countryLabel = getCampaignCountryLabel(campaign);
const tagLabel = getCampaignPrimaryTagLabel(campaign);
const isSilentPayment = campaign.wallet.mode === 'sp';
const isFeaturedVariant = variant === 'featured';
const isApproved = moderation.approvedCoords.has(campaign.aTag);
@@ -129,29 +168,22 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
<HandHeart className="size-12 text-primary/40" />
</div>
)}
{tagLabel && (
{isSilentPayment && (
<Badge
variant="secondary"
className="absolute top-3 left-3 backdrop-blur bg-background/80 border-border/40"
>
{tagLabel}
<ShieldCheck className="size-3.5 mr-1" />
Private
</Badge>
)}
<div className="absolute top-3 right-3 flex items-center gap-2">
{campaign.archived && (
<Badge
variant="secondary"
className="backdrop-blur bg-background/85 border-border/40"
>
<Archive className="size-3.5 mr-1" />
Archived
</Badge>
)}
{isHidden && (
<Badge
variant="secondary"
className="backdrop-blur bg-destructive/15 text-destructive border-destructive/30"
>
<EyeOff className="size-3.5 mr-1" />
Hidden
</Badge>
)}
@@ -190,16 +222,15 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
<div className="flex-1" />
<CampaignProgress raisedSats={raisedSats} goalSats={campaign.goalSats} btcPrice={btcPrice} />
{isSilentPayment ? (
<CampaignPrivateNotice goalUsd={campaign.goalUsd} />
) : (
<CampaignProgress raisedSats={raisedSats} goalUsd={campaign.goalUsd} btcPrice={btcPrice} />
)}
{/* Meta row */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground pt-1">
<span className="inline-flex items-center gap-1.5">
<Users className="size-3.5" />
{campaign.recipients.length}{' '}
{campaign.recipients.length === 1 ? 'recipient' : 'recipients'}
</span>
{stats && stats.donorCount > 0 && (
{!isSilentPayment && stats && stats.donorCount > 0 && (
<span className="inline-flex items-center gap-1.5">
<Target className="size-3.5" />
{stats.donorCount} {stats.donorCount === 1 ? 'donor' : 'donors'}
@@ -224,8 +255,11 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
)}
</div>
<div className="text-xs text-muted-foreground border-t border-border/60 pt-3 truncate">
by <span className="font-medium text-foreground">{creatorName}</span>
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<div className="truncate">
by <span className="font-medium text-foreground">{creatorName}</span>
</div>
{footerBadge && <div className="shrink-0">{footerBadge}</div>}
</div>
</div>
</Card>
@@ -0,0 +1,22 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { CampaignCard } from '@/components/CampaignCard';
import { parseCampaign } from '@/lib/campaign';
/**
* Renders a kind 33863 Campaign event inside the activity feed using the
* same polished {@link CampaignCard} component that powers the campaign
* directory. The whole card is a `<Link>` to the campaign's naddr-based
* detail route, so taps from the feed land directly on the campaign page.
*
* Malformed events (missing required fields, invalid wallet endpoint,
* etc.) silently drop — `parseCampaign` returns `null` and we return
* `null` from the component. A future enhancement could render a
* "Malformed campaign" fallback, but for now keeping the feed clean
* wins over surfacing parse errors to viewers.
*/
export function CampaignNoteCardContent({ event }: { event: NostrEvent }) {
const campaign = parseCampaign(event);
if (!campaign) return null;
return <CampaignCard campaign={campaign} className="mt-2" />;
}
@@ -0,0 +1,147 @@
import { useState } from 'react';
import { AlertTriangle, Check, Copy, ExternalLink, ShieldCheck } from 'lucide-react';
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
import { Button } from '@/components/ui/button';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { useToast } from '@/hooks/useToast';
import type { CampaignWallet } from '@/lib/campaign';
interface CampaignWalletDonatePanelProps {
/** Parsed wallet endpoint declared by the campaign's `w` tag. */
wallet: CampaignWallet;
}
/**
* Inline panel rendering the campaign's wallet endpoint as a scannable
* QR code, a copyable string, and an "Open in wallet" button.
*
* Behavior forks on the wallet's mode:
*
* - **on-chain** (`bc1q…` / `bc1p…`) — BIP-21 QR with the address; a
* public-ledger disclaimer reminds donors that the donation is
* traceable.
* - **sp** (`sp1…`) — raw silent-payment code QR; an "unlinkable by
* design" notice replaces the traceability disclaimer.
*
* Intentionally minimal: no amount input, no PSBT/in-app wallet flow —
* that's `DonateDialog`'s job. This panel is the always-available
* "scan and pay from any wallet" affordance.
*/
export function CampaignWalletDonatePanel({
wallet,
}: CampaignWalletDonatePanelProps) {
const { toast } = useToast();
const [copied, setCopied] = useState(false);
// Build the QR payload. For on-chain we use BIP-21 so any wallet that
// recognizes the `bitcoin:` scheme can pre-fill the address; for SP we
// use the BIP-21 `bitcoin:?sp=` extension. Donors pick the amount in
// their wallet either way.
const qrPayload = wallet.mode === 'onchain'
? `bitcoin:${wallet.value}`
: `bitcoin:?sp=${wallet.value}`;
const copyValue = async () => {
try {
await navigator.clipboard.writeText(wallet.value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
toast({ title: wallet.mode === 'sp' ? 'Silent-payment code copied' : 'Address copied' });
} catch {
toast({
title: 'Copy failed',
description: 'Select and copy the value manually.',
variant: 'destructive',
});
}
};
return (
<div className="space-y-5">
{/* QR — large, centered on a clean white tile with the Agora logo
embedded in an orange circular badge in the center.
Error-correction level H tolerates the centered occlusion
(~30% of modules can be missing and the code still scans). */}
<div className="flex justify-center">
<div className="relative rounded-2xl bg-white p-4 shadow-sm">
<QRCodeCanvas value={qrPayload} size={280} level="H" />
<div
aria-hidden
className="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div className="rounded-full bg-primary p-2 ring-[6px] ring-white">
<img
src="/logo.svg"
alt=""
className="size-16 object-contain brightness-0 invert"
draggable={false}
/>
</div>
</div>
</div>
</div>
{/* Copyable value — single line, tap to copy. No wrapping
container; sits flush with the rest of the column. */}
<button
type="button"
onClick={copyValue}
className="w-full flex items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2.5 font-mono text-xs text-left hover:bg-muted/60 motion-safe:transition-colors"
aria-label={wallet.mode === 'sp' ? 'Copy silent-payment code' : 'Copy Bitcoin address'}
>
<span className="flex-1 min-w-0 truncate" title={wallet.value}>
{wallet.value}
</span>
{copied ? (
<Check className="size-4 text-green-500 shrink-0" />
) : (
<Copy className="size-4 text-muted-foreground shrink-0" />
)}
</button>
{wallet.mode === 'onchain' ? (
<BitcoinPublicDisclaimer
tone="soft"
includeCashOutAdvice={false}
leadText="Donations are public and can be traced back to you."
/>
) : (
<div className="flex items-start gap-2 rounded-lg bg-muted/40 px-3 py-2.5 text-xs text-muted-foreground">
<ShieldCheck className="size-4 shrink-0 mt-0.5 text-primary" />
<span>
Silent-payment campaigns are unlinkable by design. Your donation
cannot be tied to the campaign by anyone other than the organizer.
</span>
</div>
)}
{/* Open in wallet — relies on the `bitcoin:` URI handler. SP codes
inside `bitcoin:?sp=` are still understood by BIP-352-aware
wallets. Older wallets that don't know about SP will ignore
the parameter and either refuse the link or show an error — at
which point the donor falls back to copy/paste anyway. */}
<Button asChild className="w-full text-white">
<a href={qrPayload}>
<ExternalLink className="size-4 mr-1.5" />
Open in wallet
</a>
</Button>
</div>
);
}
/**
* Fallback rendered when the wallet failed to parse. The detail page
* should normally never reach this — `parseCampaign` rejects events
* without a valid `w` tag — but a defensive surface is cheap and helps
* debugging.
*/
export function CampaignWalletMissing() {
return (
<div className="flex items-center gap-2 text-sm">
<AlertTriangle className="size-5 text-orange-500 shrink-0" />
<span>This campaign is missing a valid wallet endpoint.</span>
</div>
);
}
+14 -103
View File
@@ -1,10 +1,10 @@
import type React from 'react';
import { type ReactNode, useMemo, useState } from 'react';
import { type ReactNode, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
Award, BarChart3, Bird, BookOpen, Camera, Clapperboard, FileText, Film,
GitBranch, GitPullRequest, Highlighter, Mail, MapPin, Megaphone, MessageSquare, Mic, Music,
GitBranch, GitPullRequest, HandHeart, Highlighter, Mail, MapPin, Megaphone, MessageSquare, Mic, Music,
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus,
Stars, Target, Users, UserCheck, Vote, Zap,
} from 'lucide-react';
@@ -29,13 +29,11 @@ import { useLinkPreview } from '@/hooks/useLinkPreview';
import { useScryfallCard } from '@/hooks/useScryfallCard';
import { getDisplayName } from '@/lib/getDisplayName';
import { genUserName } from '@/lib/genUserName';
import { getCountryInfo, getWikipediaTitle } from '@/lib/countries';
import { getCountryInfo } from '@/lib/countries';
import { CountryFlag } from '@/components/CountryFlag';
import { customFlagAsset, hasCustomFlag } from '@/lib/customFlags';
import { hasCustomFlag } from '@/lib/customFlags';
import { useCountryFeed } from '@/contexts/CountryFeedContext';
import { cn } from '@/lib/utils';
import { useFlagPalette } from '@/lib/flagPalette';
import { useWikipediaSummary } from '@/hooks/useWikipediaSummary';
import { extractGathererCard, type GathererCard } from '@/lib/linkEmbed';
import { cardPrimaryImage } from '@/lib/scryfall';
@@ -148,6 +146,7 @@ const KIND_LABELS: Record<number, string> = {
34236: 'a divine',
34550: 'an organization',
9041: 'a goal',
33863: 'a campaign',
35128: 'an nsite',
36639: 'a pledge',
36787: 'a track',
@@ -205,6 +204,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
39089: PartyPopper,
3367: Palette,
9041: Target,
33863: HandHeart,
9735: Zap,
9802: Highlighter,
2473: Bird,
@@ -247,6 +247,7 @@ const KIND_SUFFIXES: Partial<Record<number, string>> = {
37516: 'treasure',
30621: 'constellation',
34550: 'organization',
33863: 'campaign',
30054: 'episode',
30055: 'trailer',
34139: 'playlist',
@@ -912,10 +913,9 @@ function useCountryRootContext(event: NostrEvent): { iTag: string; code: string
}
/**
* Whether the given event is rendering with country chrome (pill + flag
* backdrop) in the current context. Useful for sibling components that want
* to coordinate styling — e.g. NoteCard switching its text to white when a
* flag is showing through behind the author row.
* Whether the given event is rendering with country chrome (the corner
* flag pill) in the current context. Useful for sibling components that
* want to coordinate styling.
*/
// eslint-disable-next-line react-refresh/only-export-components
export function useIsCountryRooted(event: NostrEvent): boolean {
@@ -939,100 +939,11 @@ export function CountryCommentPill({ event, className }: { event: NostrEvent; cl
}
/**
* Decorative flag backdrop for country-rooted kind-1111 posts. Renders the
* country's Wikipedia lead image (the flag, for country articles) faded
* behind the post, echoing the country detail page's hero
* (`CountryContentHeader` in `ExternalContentHeader.tsx`) but scaled down
* to a card. Pairs with `CountryCommentPill`.
*
* Designed to be rendered as the first child of a `relative overflow-hidden`
* parent. The wrapper is absolutely positioned at `z-0`; its foreground
* siblings must declare `relative` (any positioned value works) so they
* paint above the backdrop. Pointer events are disabled so the post body
* stays fully interactive.
*
* The Wikipedia summary fetch is cached for 24 h across all cards
* referencing the same country code, so a feed of N Venezuelan posts only
* pays the network cost once.
*
* Visibility rules: see `useCountryRootContext` (identical to the pill).
* Decorative flag backdrop for country-rooted kind-1111 posts has been
* removed in favor of a cleaner card surface. The `CountryCommentPill`
* in the upper-right of the header is now the sole country chrome for
* world posts.
*/
export function CountryFlagBackdrop({ event }: { event: NostrEvent }) {
const ctx = useCountryRootContext(event);
const info = ctx ? getCountryInfo(ctx.code) : null;
const wikiTitle = ctx ? getWikipediaTitle(ctx.code) : null;
const { data: wiki } = useWikipediaSummary(wikiTitle);
// Sample dominant colors from the flag emoji at render time. Used as the
// fallback gradient while Wikipedia is still resolving and after image
// load failures, so the backdrop never reverts to a giant blurred emoji.
const palette = useFlagPalette(info?.flag);
// Track image load failures so we cleanly fall back to the flag-color
// gradient. Wikipedia hosts these PNGs from upload.wikimedia.org which is
// generally CORS-friendly, but hotlink-protection or transient 4xx
// responses can still happen.
const [imageFailed, setImageFailed] = useState(false);
if (!ctx) return null;
// Bundled-asset override: for codes with a curated flag SVG (currently
// CN-XZ / Tibet) we skip Wikipedia entirely — its lead image is often
// a parent-country map or an administrative-region inset, neither of
// which reads as a flag behind a note. The Snow Lion SVG is what the
// post is editorially "about", so it earns the backdrop slot.
const bundledAsset = customFlagAsset(ctx.code);
// For country articles Wikipedia returns the flag as the page's lead image
// — the same source used by `CountryContentHeader`. Prefer the original
// (full-resolution) over the 330px thumbnail; the thumbnail gets upscaled
// and looks fuzzy when stretched across a full-width feed card.
const flagImage = !imageFailed
? (bundledAsset ?? wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null)
: null;
// Pre-built gradient using the palette (sampled from the flag emoji at
// mount). Used as the fallback when Wikipedia hasn't returned an image or
// its image failed to load. Single-color palettes get duplicated so
// linear-gradient still has two stops.
const paletteGradient =
palette && palette.length > 0
? `linear-gradient(135deg, ${palette.length === 1 ? `${palette[0]}, ${palette[0]}` : palette.join(', ')})`
: null;
return (
<div aria-hidden className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-64 sm:h-72">
{flagImage ? (
// Full-width flag banner across the top of the card. A mask-image
// gradient fades the image to nothing at its bottom edge, so the
// flag dissolves into the card with no hard seam.
<img
src={flagImage}
alt=""
decoding="async"
onError={() => setImageFailed(true)}
className="w-full h-full object-cover opacity-20 select-none"
style={{
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
}}
/>
) : paletteGradient ? (
// Wikipedia not yet resolved (or its image failed) — paint the
// flag-color gradient as a placeholder/fallback. Same opacity and
// mask shape as the image so the visual swap is seamless when the
// image arrives.
<div
className="absolute inset-0 opacity-20"
style={{
backgroundImage: paletteGradient,
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
}}
/>
) : null}
</div>
</div>
);
}
/**
* Body-level comment context for ISO 3166 roots — intentionally renders
+223 -140
View File
@@ -1,5 +1,6 @@
import { useMemo, useCallback, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import {
CalendarClock,
@@ -15,6 +16,7 @@ import {
Pencil,
Shield,
Share2,
Trash2,
UserCheck,
UserMinus,
UserPlus,
@@ -25,17 +27,27 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { CampaignCard } from '@/components/CampaignCard';
import { PeopleAvatarStack } from '@/components/PeopleAvatarStack';
import { PostActionBar } from '@/components/PostActionBar';
import { DetailCommentComposer } from '@/components/DetailCommentComposer';
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Skeleton } from '@/components/ui/skeleton';
import { DonateDialog } from '@/components/DonateDialog';
import { NoteContent } from '@/components/NoteContent';
import { FollowToggleButton } from '@/components/FollowButton';
import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
@@ -46,9 +58,11 @@ import { useBitcoinWallet } from '@/hooks/useBitcoinWallet';
import { useCommunityBookmarks } from '@/hooks/useCommunityBookmarks';
import { useCommunityMembers } from '@/hooks/useCommunityMembers';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDeleteEvent } from '@/hooks/useDeleteEvent';
import { useEventStats } from '@/hooks/useTrending';
import { useNow } from '@/hooks/useNow';
import { useOrganizationActivity } from '@/hooks/useOrganizationActivity';
import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
@@ -217,7 +231,7 @@ function parseShelfLocation(raw: string): string {
function ActivityTypePill({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<div className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background/95 px-2.5 py-1 text-xs font-semibold text-muted-foreground shadow-sm">
<div className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background/95 px-2.5 py-1 text-xs font-semibold text-muted-foreground">
{icon}
{label}
</div>
@@ -243,11 +257,8 @@ function PledgeShelfCard({ pledge }: { pledge: Action }) {
return (
<Link
to={`/${naddr}`}
className="group block w-[280px] shrink-0 rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
className="group block h-[430px] w-[280px] shrink-0 rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
>
<div className="mb-2">
<ActivityTypePill icon={<Megaphone className="size-3.5 text-primary" />} label="Pledge" />
</div>
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col">
<div className="relative w-full aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
<img
@@ -302,8 +313,11 @@ function PledgeShelfCard({ pledge }: { pledge: Action }) {
)}
</div>
<div className="text-xs text-muted-foreground border-t border-border/60 pt-3 truncate">
by <span className="font-medium text-foreground">{displayName}</span>
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<div className="truncate">
by <span className="font-medium text-foreground">{displayName}</span>
</div>
<ActivityTypePill icon={<Megaphone className="size-3.5 text-primary" />} label="Pledge" />
</div>
</div>
</Card>
@@ -336,11 +350,8 @@ function CalendarEventShelfCard({ event }: { event: NostrEvent }) {
return (
<Link
to={`/${naddr}`}
className="group block w-[280px] shrink-0 rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
className="group block h-[430px] w-[280px] shrink-0 rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
>
<div className="mb-2">
<ActivityTypePill icon={<CalendarDays className="size-3.5 text-primary" />} label="Event" />
</div>
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col">
<div className="relative w-full aspect-[16/9] overflow-hidden bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
{coverImage ? (
@@ -405,8 +416,11 @@ function CalendarEventShelfCard({ event }: { event: NostrEvent }) {
) : null}
</div>
<div className="text-xs text-muted-foreground border-t border-border/60 pt-3 truncate">
by <span className="font-medium text-foreground">{displayName}</span>
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<div className="truncate">
by <span className="font-medium text-foreground">{displayName}</span>
</div>
<ActivityTypePill icon={<CalendarDays className="size-3.5 text-primary" />} label="Event" />
</div>
</div>
</Card>
@@ -438,7 +452,7 @@ function OfficialShelf({ title, count, isLoading, isEmpty, children }: OfficialS
)}
</h2>
</div>
<div className="-mx-4 sm:-mx-6 px-4 sm:px-6 flex gap-3 overflow-x-auto scrollbar-none pb-1">
<div className="-mx-4 sm:-mx-6 px-4 sm:px-6 flex items-stretch gap-3 overflow-x-auto scrollbar-none pb-1">
{children}
</div>
</section>
@@ -462,11 +476,9 @@ function OfficialActivityShelves({
eventsLoading: boolean;
now: number;
}) {
// Drop archived campaigns; mixed activity is sorted newest publish first.
const liveCampaigns = useMemo(
() => campaigns.filter((c) => !c.archived),
[campaigns],
);
// All loaded campaigns. Closure is via NIP-09 deletion (relay-level),
// so anything that reached us is current.
const liveCampaigns = campaigns;
// Drop expired pledges; mixed activity is sorted newest publish first.
const livePledges = useMemo(() => {
@@ -529,11 +541,12 @@ function OfficialActivityShelves({
{mixedActivity.map((item) => {
if (item.type === 'campaign') {
return (
<div key={`campaign:${item.id}`} className="w-[280px] shrink-0">
<div className="mb-2">
<ActivityTypePill icon={<HandHeart className="size-3.5 text-primary" />} label="Campaign" />
</div>
<CampaignCard campaign={item.campaign} />
<div key={`campaign:${item.id}`} className="h-[430px] w-[280px] shrink-0">
<CampaignCard
campaign={item.campaign}
className="h-full"
footerBadge={<ActivityTypePill icon={<HandHeart className="size-3.5 text-primary" />} label="Campaign" />}
/>
</div>
);
}
@@ -609,17 +622,15 @@ function CommunityCreateActions({
export function CommunityDetailPage({ event }: { event: NostrEvent }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const { user } = useCurrentUser();
const { btcPrice } = useBitcoinWallet();
const [membersDialogOpen, setMembersDialogOpen] = useState(false);
const [descriptionDialogOpen, setDescriptionDialogOpen] = useState(false);
const [donateOpen, setDonateOpen] = useState(false);
const [replyOpen, setReplyOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [interactionsOpen, setInteractionsOpen] = useState(false);
const [interactionsTab, setInteractionsTab] = useState<InteractionTab>('reposts');
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const deleteMutation = useDeleteEvent();
// Parse community definition
const community = useMemo(() => parseCommunityEvent(event), [event]);
@@ -669,29 +680,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
// (content bans, reports) used by the comment thread below.
const { moderation, rankMap, isLoading: membersLoading } = useCommunityMembers(community);
const communityDonationTarget = useMemo<ParsedCampaign | null>(() => {
if (!community) return null;
const recipients = [
{ pubkey: community.founderPubkey, weight: 1 },
...community.moderatorPubkeys.map((pubkey) => ({ pubkey, weight: 1 })),
];
return {
event,
pubkey: event.pubkey,
identifier: community.dTag,
aTag: community.aTag,
title: community.name,
summary: community.description,
story: community.description,
image: community.image,
category: 'community',
tags: ['community'],
recipients,
createdAt: event.created_at,
archived: false,
};
}, [community, event]);
// Only the founder can edit organization metadata. Moderators can
// moderate content via the community context but don't get the
// "Edit community" action.
@@ -742,6 +730,12 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
// ── Comments (NIP-22 on the community event) ───────────────────────────────
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
const {
pinnedEvents,
isPinned,
canManagePins,
togglePin,
} = usePinnedEventComments(communityATag || undefined, event.pubkey);
// ── Official activity shelves ─────────────────────────────────────────────
// Author-filtered to founder + moderators (see useOrganizationActivity).
@@ -765,21 +759,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
}, [community, event.kind, event.pubkey]);
// ── Engagement stats for the community event itself ──────────────────────
// Pulled from NIP-85. Used both for the inline counters above the action
// bar and for the threaded-comments header. Matches the rhythm of the
// campaign and pledge detail pages.
// Pulled from NIP-85 for the threaded-comments header.
const { data: engagementStats, isLoading: statsLoading } = useEventStats(event.id, event);
const hasStats =
!!engagementStats?.replies ||
!!engagementStats?.reposts ||
!!engagementStats?.quotes ||
!!engagementStats?.reactions;
const openInteractions = useCallback((tab: InteractionTab) => {
setInteractionsTab(tab);
setInteractionsOpen(true);
}, []);
const replyTree = useMemo((): ReplyNode[] => {
if (!commentsData) return [];
const topLevel = commentsData.topLevelComments ?? [];
@@ -811,6 +792,11 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
.map((r) => buildNode(r));
}, [commentsData, moderation]);
const pinnedNodes = useMemo(
() => pinnedEvents.map((event): ReplyNode => ({ event, children: [] })),
[pinnedEvents],
);
// ── Share handler ───────────────────────────────────────────────────────────
const handleShare = useCallback(async () => {
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
@@ -828,6 +814,51 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
}
}, [event, toast]);
// ── Delete handler ─────────────────────────────────────────────────────────
// Founder-only. Publishes a NIP-09 kind 5 deletion request referencing the
// community definition (kind 34550) by both `e` and `a` tags so relays can
// drop it from both id-based and addressable lookups. After the request
// ships we invalidate every org-related cache so the page the user lands
// on (`/communities`) shows the deletion immediately, even if some relays
// haven't propagated yet.
const handleDeleteOrganization = useCallback(() => {
if (!community) return;
deleteMutation.mutate(
{
eventId: event.id,
eventKind: event.kind,
eventPubkey: event.pubkey,
eventDTag: community.dTag,
},
{
onSuccess: () => {
toast({
title: 'Organization deleted',
description:
'A deletion request was published. Well-behaved relays will drop the organization from feeds.',
});
setDeleteConfirmOpen(false);
void queryClient.invalidateQueries({
queryKey: ['addr-event', event.kind, event.pubkey, community.dTag],
});
void queryClient.invalidateQueries({ queryKey: ['community-definition', community.aTag] });
void queryClient.invalidateQueries({ queryKey: ['manageable-organizations'] });
void queryClient.invalidateQueries({ queryKey: ['featured-organizations'] });
void queryClient.invalidateQueries({ queryKey: ['followed-organizations'] });
navigate('/communities');
},
onError: (error: unknown) => {
const msg = error instanceof Error ? error.message : String(error);
toast({
title: 'Could not delete organization',
description: msg,
variant: 'destructive',
});
},
},
);
}, [community, deleteMutation, event, navigate, queryClient, toast]);
useLayoutOptions({
noMaxWidth: true,
rightSidebar: null,
@@ -848,7 +879,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-4">
<CommunityModerationContext.Provider value={moderationCtx}>
{/* ── Hero ─────────────────────────────────────────────────────── */}
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-xl overflow-hidden bg-gradient-to-br from-primary/40 via-primary/20 to-secondary">
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-t-xl rounded-b-none overflow-hidden bg-gradient-to-br from-primary/40 via-primary/20 to-secondary">
{cover ? (
<img src={cover} alt="" className="absolute inset-0 size-full object-cover" />
) : (
@@ -970,6 +1001,18 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
Edit organization
</DropdownMenuItem>
)}
{isFounder && community && (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setDeleteConfirmOpen(true);
}}
className="text-destructive focus:text-destructive focus:bg-destructive/10"
>
<Trash2 className="size-4 mr-2" />
Delete organization
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -977,21 +1020,39 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
</div>
</div>
<div className="rounded-b-xl rounded-t-none bg-card border border-t-0 border-border/60 shadow-sm px-4 sm:px-5 py-3">
<PostActionBar
event={event}
replyLabel="Comment"
hideZap
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
/>
</div>
{pinnedNodes.length > 0 && (
<div className="pt-6">
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
<ThreadedReplyList
roots={pinnedNodes}
renderItemHeader={(event) => (
<OrganizationPinHeader
isPinned={isPinned(event.id)}
canManagePins={canManagePins}
pinPending={togglePin.isPending}
onTogglePin={() => handleTogglePin(event)}
/>
)}
/>
</div>
</div>
)}
{/* ── Body — single column, pledge-detail-style ─────────────────── */}
<div className="py-6 lg:py-10 space-y-8">
{/* Donate (when there's a member set) and Share buttons. Sits
just below the hero like the pledge page's action row. */}
<div className={cn('grid gap-2', communityDonationTarget ? 'grid-cols-4' : 'grid-cols-1')}>
{communityDonationTarget && (
<Button
size="lg"
className="w-full col-span-3"
onClick={() => setDonateOpen(true)}
>
<HandHeart className="size-5 mr-2" />
Donate
</Button>
)}
<div className="grid gap-2 grid-cols-1">
<Button
type="button"
variant="outline"
@@ -1022,48 +1083,9 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
now={now}
/>
{/* Engagement card — stats counters + post action bar. Matches
the pledge / campaign detail layout. No funding progress bar
here; an organization isn't a fundraising target itself. */}
{/* Comments — NIP-22 thread on the community event itself. */}
<div id="org-activity" className="scroll-mt-20">
<div className="rounded-2xl bg-card border border-border/60 shadow-sm px-4 sm:px-5 py-4 sm:py-5">
{hasStats && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs sm:text-sm text-muted-foreground pb-2">
{engagementStats?.reposts ? (
<button onClick={() => openInteractions('reposts')} className="hover:underline transition-colors">
<span className="font-bold text-foreground">{formatNumber(engagementStats.reposts)}</span>{' '}
Repost{engagementStats.reposts !== 1 ? 's' : ''}
</button>
) : null}
{engagementStats?.quotes ? (
<button onClick={() => openInteractions('quotes')} className="hover:underline transition-colors">
<span className="font-bold text-foreground">{formatNumber(engagementStats.quotes)}</span>{' '}
Quote{engagementStats.quotes !== 1 ? 's' : ''}
</button>
) : null}
{engagementStats?.reactions ? (
<button onClick={() => openInteractions('reactions')} className="hover:underline transition-colors">
<span className="font-bold text-foreground">{formatNumber(engagementStats.reactions)}</span>{' '}
Like{engagementStats.reactions !== 1 ? 's' : ''}
</button>
) : null}
</div>
)}
<PostActionBar
event={event}
replyLabel="Comment"
hideZap
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
className={hasStats ? 'pt-3 border-t border-border/60' : undefined}
/>
</div>
{/* Comments — NIP-22 thread on the community event itself.
Member-filter aware (see replyTree) and routed through
CommunityModerationContext so per-reply ban actions work. */}
<div className="mt-6">
<div>
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
<h2 className="text-lg font-semibold tracking-tight">Comments</h2>
{engagementStats?.replies ? (
@@ -1074,6 +1096,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
) : null}
</div>
<DetailCommentComposer event={event} className="mb-3" />
{commentsLoading && statsLoading && replyTree.length === 0 ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
@@ -1081,8 +1105,18 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
))}
</div>
) : replyTree.length > 0 ? (
<div className="-mx-2 sm:-mx-4 rounded-2xl bg-card border border-border/60 overflow-hidden">
<ThreadedReplyList roots={replyTree} />
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
<ThreadedReplyList
roots={replyTree}
renderItemHeader={(event) => (
<OrganizationPinHeader
isPinned={isPinned(event.id)}
canManagePins={canManagePins}
pinPending={togglePin.isPending}
onTogglePin={() => handleTogglePin(event)}
/>
)}
/>
</div>
) : (
<button
@@ -1103,15 +1137,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
</div>
</CommunityModerationContext.Provider>
{communityDonationTarget && (
<DonateDialog
campaign={communityDonationTarget}
open={donateOpen}
onOpenChange={setDonateOpen}
btcPrice={btcPrice}
/>
)}
{/* Description dialog — opened by clicking the truncated description in
the banner. Renders the full raw description plus a clickable
website link when the description ends with a URL. */}
@@ -1195,16 +1220,74 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
onOpenChange={setMoreMenuOpen}
/>
{/* Tapping a repost / quote / like counter on the stats row opens
a modal listing the people who took that action. */}
<InteractionsModal
eventId={event.id}
open={interactionsOpen}
onOpenChange={setInteractionsOpen}
initialTab={interactionsTab}
/>
{/* Founder-only delete confirmation. NIP-09 is advisory — relays decide
whether to honor the request — so the copy makes the limitation
explicit and steers founders toward "Edit organization" if they
just want to change something. */}
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this organization?</AlertDialogTitle>
<AlertDialogDescription>
This publishes a NIP-09 deletion request for{' '}
<span className="font-medium text-foreground">{name}</span>.
Well-behaved relays will drop the organization from feeds and
direct links. Campaigns, pledges, and posts published under
the organization stay on-chain regardless. This action cannot
be undone to change the name, banner, or moderators, edit
the organization instead.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDeleteOrganization();
}}
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteMutation.isPending ? 'Deleting…' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</main>
);
function handleTogglePin(event: NostrEvent) {
const wasPinned = isPinned(event.id);
togglePin.mutate(event.id, {
onSuccess: () => {
toast({ title: wasPinned ? 'Unpinned from organization' : 'Pinned to organization' });
},
onError: () => {
toast({ title: 'Failed to update organization pins', variant: 'destructive' });
},
});
}
}
function OrganizationPinHeader({
isPinned,
canManagePins,
pinPending,
onTogglePin,
}: {
isPinned: boolean;
canManagePins: boolean;
pinPending: boolean;
onTogglePin: () => void;
}) {
return (
<PinnedCommentHeader
isPinned={isPinned}
canManagePins={canManagePins}
pinPending={pinPending}
onTogglePin={onTogglePin}
/>
);
}
+209
View File
@@ -0,0 +1,209 @@
import { useState } from 'react';
import { Check, EyeOff, Eye, Loader2, MoreHorizontal, Sparkles, SparklesIcon } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
import { useToast } from '@/hooks/useToast';
import type { ModerationLabel } from '@/lib/agoraModeration';
interface CommunityModerationMenuProps {
/** The organization's `34550:<pubkey>:<d>` coordinate. */
coord: string;
/** Visible name for the organization (for toast feedback). */
organizationName: string;
className?: string;
}
/**
* Per-card kebab menu exposing the moderator actions for an organization:
*
* Hide / Unhide (axis = hide)
* Feature / Unfeature (axis = featured)
*
* Organizations intentionally do **not** have an `approved` axis — unlike
* campaigns, which gate homepage placement on moderator approval, every
* Agora-tagged organization is publicly visible by default. Moderators
* curate via two narrower controls: lifting an org into the Featured
* shelf, or suppressing it with a Hidden label.
*
* Renders `null` for users who are not Team Soapbox pack members. Sits
* inside the clickable `CommunityMiniCard` `<Link>`, so the trigger
* swallows its own click and the dropdown content stops propagation —
* otherwise every menu interaction would navigate to the organization
* detail page.
*
* The moderation rollup is read inside this component (after the
* moderator gate) instead of at the parent so non-moderator viewers
* never subscribe to the heavy `useOrganizationModeration` query — every
* `CommunityMiniCard` in a grid would otherwise wake the same cache
* subscription up to 18+ times per page.
*/
export function CommunityModerationMenu({
coord,
organizationName,
className,
}: CommunityModerationMenuProps) {
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
// Bail before the heavy moderation query subscribes. Non-moderators
// (the overwhelming majority) never pay the network or render cost.
if (!isMod) return null;
return <CommunityModerationMenuInner coord={coord} organizationName={organizationName} className={className} />;
}
function CommunityModerationMenuInner({
coord,
organizationName,
className,
}: CommunityModerationMenuProps) {
const { data: moderation, moderate } = useOrganizationModeration();
const { toast } = useToast();
const [busy, setBusy] = useState<ModerationLabel | null>(null);
const isHidden = moderation.hiddenCoords.has(coord);
const isFeatured = moderation.featuredCoords.has(coord);
const runAction = async (action: ModerationLabel, verbPast: string) => {
if (busy) return;
setBusy(action);
try {
await moderate.mutateAsync({ coord, action });
toast({ title: verbPast, description: organizationName });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
toast({
title: `Failed to ${action}`,
description: message,
variant: 'destructive',
});
} finally {
setBusy(null);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
aria-label="Moderate organization"
className={className ?? 'h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground'}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <MoreHorizontal className="h-4 w-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Moderator actions
</DropdownMenuLabel>
<DropdownMenuSeparator />
{isFeatured ? (
<DropdownMenuItem onClick={() => runAction('unfeatured', 'Removed from featured')}>
<SparklesIcon className="h-4 w-4 mr-2" />
Unfeature
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Featured
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => runAction('featured', 'Featured organization')}>
<Sparkles className="h-4 w-4 mr-2" />
Feature
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{isHidden ? (
<DropdownMenuItem onClick={() => runAction('unhidden', 'Unhidden')}>
<Eye className="h-4 w-4 mr-2" />
Unhide
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Hidden
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => runAction('hidden', 'Hidden')}
className="text-destructive focus:text-destructive"
>
<EyeOff className="h-4 w-4 mr-2" />
Hide
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
/**
* Banner-overlay wrapper for `CommunityMiniCard` cards. Renders the
* moderator kebab plus a "Hidden" badge when applicable, both
* absolutely-positioned at the card's top-right. Returns `null` for
* non-moderators so non-mod grids never subscribe to the moderation
* query at all.
*
* Pulling the overlay (and its `useOrganizationModeration` subscription)
* out of `CommunityMiniCard` into a single moderator-gated component is
* the perf win that lets `/communities` paint Featured/My orgs
* immediately without waiting for the moderator pack or the label query
* for every card on the page.
*/
export function CommunityModerationOverlay({
coord,
organizationName,
}: {
coord: string;
organizationName: string;
}) {
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
if (!isMod) return null;
return (
<CommunityModerationOverlayInner coord={coord} organizationName={organizationName} />
);
}
function CommunityModerationOverlayInner({
coord,
organizationName,
}: {
coord: string;
organizationName: string;
}) {
const { data: moderation } = useOrganizationModeration();
const isHidden = moderation.hiddenCoords.has(coord);
return (
<div className="absolute top-2 right-2 flex items-center gap-1.5">
{isHidden && (
<Badge
variant="secondary"
className="backdrop-blur bg-destructive/15 text-destructive border-destructive/30 h-6 px-1.5 text-[10px]"
>
<EyeOff className="size-3 mr-1" />
Hidden
</Badge>
)}
{/* The kebab inner uses the same moderation cache subscription, so
no extra round-trip is incurred. */}
<CommunityModerationMenuInner coord={coord} organizationName={organizationName} />
</div>
);
}
+205 -44
View File
@@ -1,6 +1,6 @@
import { lazy, Suspense, useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft, HelpCircle } from 'lucide-react';
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft, Check, Globe, HelpCircle } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { encode as blurhashEncode } from 'blurhash';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -12,7 +12,21 @@ import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { GifPicker } from '@/components/GifPicker';
@@ -29,7 +43,8 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePostComment } from '@/hooks/usePostComment';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useCountryFollows } from '@/hooks/useCountryFollows';
import { getCountryInfo } from '@/lib/countries';
import { useDefaultPostCountry } from '@/hooks/useDefaultPostCountry';
import { COUNTRY_LIST, getCountryInfo } from '@/lib/countries';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
import { useQueryClient } from '@tanstack/react-query';
import { useToast } from '@/hooks/useToast';
@@ -50,6 +65,7 @@ import { DITTO_RELAY } from '@/lib/appRelays';
import { resizeImage } from '@/lib/resizeImage';
import { extractHashtags } from '@/lib/hashtag';
import { useIsMobile } from '@/hooks/useIsMobile';
import { AGORA_DEFAULT_NOTE_TAGS } from '@/lib/agoraNoteTags';
const MAX_CHARS = 5000;
@@ -156,7 +172,15 @@ interface ComposeBoxProps {
hidePoll?: boolean;
/** Label for the primary submit button. */
submitLabel?: string;
/** Tags added to new top-level kind 1 notes without putting them in content. */
/**
* Tags added to new top-level kind 1 notes without putting them in content.
*
* Defaults to {@link AGORA_DEFAULT_NOTE_TAGS} (the silent `t:agora` tag) when
* the composer is producing a top-level kind 1 note (no replyTo, not a quote,
* not poll mode, no custom publish, no country-scoped destination). Replies,
* quotes, polls, comments, and custom-kind publishes do not receive these
* tags regardless of this prop. Pass `[]` to opt out explicitly.
*/
defaultTags?: string[][];
/** If true, the composer starts expanded without taking modal/flex behavior. */
defaultExpanded?: boolean;
@@ -218,7 +242,7 @@ export function ComposeBox({
customPublish,
hidePoll = false,
submitLabel = 'Post!',
defaultTags = [],
defaultTags,
defaultExpanded = false,
}: ComposeBoxProps) {
const { user, metadata, isLoading: isProfileLoading } = useCurrentUser();
@@ -269,23 +293,39 @@ export function ComposeBox({
// from the home feed (no replyTo, not a custom-kind publish). When a
// country code is selected, the post is published as a NIP-22 kind
// 1111 comment rooted on that country instead of a plain kind 1 note.
// Dropdown lists only the countries the user follows, with "Global"
// always at the top.
//
// The dropdown shows: Global + the countries the user follows (quick
// picks) + a "Choose another country…" item that opens a searchable
// dialog over the full country list. So a user can post about any
// country, even one they don't follow.
const { followedCountries } = useCountryFollows();
const canChooseDestination =
!replyTo && !customPublish && mode === 'post' && !!user && followedCountries.length > 0;
!replyTo && !customPublish && mode === 'post' && !!user;
/**
* User's saved default destination (persisted to localStorage). Used as
* the initial value of `destination` on every fresh compose, and updated
* when the user clicks "Set as default" in the destination menu.
*/
const [defaultPostCountry, setDefaultPostCountry] = useDefaultPostCountry();
/** `'world'` for a regular kind-1 note, or an ISO 3166 country code for a kind-1111 community post. */
const [destination, setDestination] = useState<'world' | string>('world');
const [destination, setDestination] = useState<'world' | string>(defaultPostCountry);
/** Open state for the "Choose another country" searchable picker dialog. */
const [countryPickerOpen, setCountryPickerOpen] = useState(false);
const selectedCountryCode = destination !== 'world' ? destination : null;
const selectedCountryInfo = selectedCountryCode ? getCountryInfo(selectedCountryCode) : null;
// If the user unfollows the currently-selected country mid-session,
// snap back to world so we don't try to publish a kind 1111 with
// a root the user no longer cares about.
// Snap back to world if the currently selected destination is an
// invalid ISO code (e.g. a previously-followed country that was later
// removed from the country directory). Picking a non-followed but
// valid country is allowed — users can post about any country via the
// "Choose another country" picker, so following is not a prerequisite.
useEffect(() => {
if (selectedCountryCode && !followedCountries.includes(selectedCountryCode)) {
if (selectedCountryCode && !getCountryInfo(selectedCountryCode)) {
setDestination('world');
if (defaultPostCountry === selectedCountryCode) {
setDefaultPostCountry('world');
}
}
}, [selectedCountryCode, followedCountries]);
}, [selectedCountryCode, defaultPostCountry, setDefaultPostCountry]);
const [pollOptions, setPollOptions] = useState([
{ id: pollOptionId(), label: '' },
{ id: pollOptionId(), label: '' },
@@ -323,10 +363,10 @@ export function ComposeBox({
setUploadedFileGroups(new Map());
setWebxdcUuids(new Map());
setWebxdcMetas(new Map());
setDestination('world');
setDestination(defaultPostCountry);
// Clear the auto-saved draft
try { localStorage.removeItem(draftKey); } catch { /* ignore */ }
}, [initialMode, draftKey, defaultExpanded]);
}, [initialMode, draftKey, defaultExpanded, defaultPostCountry]);
// Use controlled preview mode if provided, otherwise use internal state
const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode;
@@ -1114,10 +1154,14 @@ export function ComposeBox({
const countryRoot = new URL(createCountryIdentifier(selectedCountryCode));
await postComment({ root: countryRoot, reply: undefined, content: finalContent, tags });
} else {
// Top-level kind 1 note. If the caller hasn't supplied `defaultTags`,
// auto-attach the silent Agora tag so the post surfaces in the Agora
// activity feed. Callers can opt out by passing `defaultTags={[]}`.
const effectiveDefaultTags = defaultTags ?? AGORA_DEFAULT_NOTE_TAGS;
await createEvent({
kind: 1,
content: finalContent,
tags: [...defaultTags, ...tags],
tags: [...effectiveDefaultTags, ...tags],
created_at: Math.floor(Date.now() / 1000),
});
}
@@ -1537,14 +1581,14 @@ export function ComposeBox({
})()}
</PopoverContent>
</Popover>
<Select value={destination} onValueChange={setDestination}>
<SelectTrigger
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Post destination"
className={cn(
'h-8 w-auto gap-1.5 px-2.5 py-1 text-base leading-none',
'border-0 bg-muted/50 hover:bg-muted shadow-none',
'focus:ring-2 focus:ring-primary/50 focus:ring-offset-0',
'rounded-lg',
'inline-flex items-center justify-center h-8 w-auto gap-1.5 px-2.5 py-1 text-base leading-none',
'bg-muted/50 hover:bg-muted shadow-none',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-0',
'rounded-lg motion-safe:transition-colors',
)}
>
{/* Show just the flag in the trigger to keep the row
@@ -1553,28 +1597,145 @@ export function ComposeBox({
<span aria-hidden="true">
{selectedCountryInfo?.flag ?? '🌍'}
</span>
</SelectTrigger>
<SelectContent align="end" className="min-w-[180px]">
<SelectItem value="world">
<span className="inline-flex items-center gap-2">
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[240px]">
<DropdownMenuItem
onSelect={() => setDestination('world')}
className="cursor-pointer"
>
<span className="inline-flex items-center gap-2 flex-1">
<span aria-hidden="true">🌍</span>
<span>Global</span>
</span>
</SelectItem>
{followedCountries.map((code) => {
const info = getCountryInfo(code);
if (!info) return null;
return (
<SelectItem key={code} value={code}>
<span className="inline-flex items-center gap-2">
<span aria-hidden="true">{info.flag}</span>
<span>{info.name}</span>
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
{destination === 'world' && (
<Check className="size-4 text-primary" aria-hidden />
)}
</DropdownMenuItem>
{/* Build the quick-pick list. Followed countries appear first;
if the user has selected an ad-hoc country via the
searchable picker that they don't follow, show it too so
they have a one-tap way back to it. De-duplicates by code. */}
{(() => {
const codes = new Set<string>();
const quickPicks: string[] = [];
for (const code of followedCountries) {
if (!codes.has(code) && getCountryInfo(code)) {
codes.add(code);
quickPicks.push(code);
}
}
if (selectedCountryCode && !codes.has(selectedCountryCode) && getCountryInfo(selectedCountryCode)) {
quickPicks.push(selectedCountryCode);
}
return quickPicks.map((code) => {
const info = getCountryInfo(code);
if (!info) return null;
return (
<DropdownMenuItem
key={code}
onSelect={() => setDestination(code)}
className="cursor-pointer"
>
<span className="inline-flex items-center gap-2 flex-1">
<span aria-hidden="true">{info.flag}</span>
<span>{info.name}</span>
</span>
{destination === code && (
<Check className="size-4 text-primary" aria-hidden />
)}
</DropdownMenuItem>
);
});
})()}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setCountryPickerOpen(true);
}}
className="cursor-pointer text-sm"
>
<Globe className="size-4 mr-2 text-muted-foreground" aria-hidden />
Choose another country
</DropdownMenuItem>
<DropdownMenuSeparator />
{destination === defaultPostCountry ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{(() => {
if (defaultPostCountry === 'world') return 'Global is your default';
const info = getCountryInfo(defaultPostCountry);
return info ? `${info.name} is your default` : 'This is your default';
})()}
</div>
) : (
<DropdownMenuItem
onSelect={() => {
setDefaultPostCountry(destination);
const info = destination === 'world'
? null
: getCountryInfo(destination);
toast({
title: 'Default updated',
description: info
? `New posts will go to ${info.name} by default.`
: 'New posts will be global by default.',
});
}}
className="cursor-pointer text-sm"
>
Set as default
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Searchable picker over the full country list. Opened from the
"Choose another country…" item in the destination dropdown,
so users can post to any country without having to follow it
first. */}
<CommandDialog
open={countryPickerOpen}
onOpenChange={setCountryPickerOpen}
>
<CommandInput placeholder="Search countries..." />
<CommandList>
<CommandEmpty>No countries found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="Global 🌍"
onSelect={() => {
setDestination('world');
setCountryPickerOpen(false);
}}
>
<span aria-hidden="true" className="mr-2">🌍</span>
<span>Global</span>
{destination === 'world' && (
<Check className="ml-auto size-4 text-primary" aria-hidden />
)}
</CommandItem>
{COUNTRY_LIST.map((country) => (
<CommandItem
key={country.code}
// Include code + name in the searchable value so users
// can type either "iran" or "IR".
value={`${country.name} ${country.code}`}
onSelect={() => {
setDestination(country.code);
setCountryPickerOpen(false);
}}
>
<span aria-hidden="true" className="mr-2">{country.flag}</span>
<span>{country.name}</span>
<span className="ml-2 text-xs text-muted-foreground">{country.code}</span>
{destination === country.code && (
<Check className="ml-auto size-4 text-primary" aria-hidden />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
</div>
)}
@@ -1775,7 +1936,7 @@ export function ComposeBox({
<Button
onClick={handlePollSubmit}
disabled={!isPollValid || isPollPending || !user}
className="rounded-full px-5 font-bold"
className="rounded-full px-5 font-bold text-white"
size="sm"
>
{isPollPending ? 'Publishing...' : 'Publish poll'}
@@ -1784,7 +1945,7 @@ export function ComposeBox({
<Button
onClick={handleSubmit}
disabled={!content.trim() || isPending || isCommentPending || !user || charCount > MAX_CHARS}
className="rounded-full px-5 font-bold"
className="rounded-full px-5 font-bold text-white"
size="sm"
>
{isPending || isCommentPending ? 'Posting...' : submitLabel}
+77 -199
View File
@@ -1,8 +1,7 @@
import { useState } from 'react';
import { IntroImage } from '@/components/IntroImage';
import { useState, type ReactNode } from 'react';
import {
Users, Download, Loader2, X, Pencil, Home, Globe, MapPin,
Palette, Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink, ShieldAlert,
Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -26,220 +25,108 @@ import { useAuthor } from '@/hooks/useAuthor';
import { FeedEditModal } from '@/components/FeedEditModal';
import { buildKindOptions } from '@/lib/feedFilterUtils';
import { genUserName } from '@/lib/genUserName';
import { EXTRA_KINDS, FEED_KINDS, SECTION_ORDER, SECTION_LABELS } from '@/lib/extraKinds';
import { CONTENT_KIND_ICONS, SIDEBAR_ITEMS } from '@/lib/sidebarItems';
import type { SavedFeed, TabFilter, ContentWarningPolicy } from '@/contexts/AppContext';
import type { ExtraKindDef, SubKindDef } from '@/lib/extraKinds';
import { EXTRA_KINDS } from '@/lib/extraKinds';
import { SIDEBAR_ITEMS } from '@/lib/sidebarItems';
import type { FeedSettings, SavedFeed, TabFilter, ContentWarningPolicy } from '@/contexts/AppContext';
import type { ExtraKindDef } from '@/lib/extraKinds';
export function ContentSettings() {
return (
<div>
{/* Intro */}
<div className="px-3 pt-2 pb-4">
<h2 className="text-sm font-semibold">What You See</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Customize your feed, choose what content appears, and control what you want to hide.
</p>
</div>
{/* Homepage Section */}
<div className="space-y-8">
<HomePageSetting />
{/* Feed Tabs Section */}
<div>
<div className="relative px-3 py-3.5">
<h2 className="text-base font-semibold">Home Feed Tabs</h2>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</div>
<div className="pb-4">
<FeedTabsSection />
</div>
</div>
<Section title="Saved Feeds">
<FeedTabsSection />
</Section>
{/* Notes Section */}
<div>
<div className="relative px-3 py-3.5">
<h2 className="text-base font-semibold">Basic Home Feed Options</h2>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</div>
<div className="pb-4">
<div className="px-3 pt-3 pb-4">
<p className="text-xs text-muted-foreground leading-relaxed">
Core content types that appear in your feed.
</p>
</div>
<Section title="Content in Home Feed">
<FlatContentList />
</Section>
{/* Column headers */}
<div className="flex items-center justify-end gap-2 px-3 pb-2 border-b border-border">
<span className="text-[11px] font-medium text-muted-foreground w-[52px] text-center">Feed</span>
</div>
<NotesFeedSettings />
</div>
</div>
{/* Other Stuff Section */}
<div>
<div className="relative px-3 py-3.5">
<h2 className="text-base font-semibold">Show More Content Types in Home Feed</h2>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</div>
<div className="pb-4">
{/* Intro section for Other Stuff */}
<div className="flex items-center gap-4 px-3 pt-3 pb-4">
<IntroImage src="/feed-intro.png" />
<div className="min-w-0">
<h3 className="text-sm font-semibold">Other Stuff</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Nostr isn't just text posts — people publish all kinds of things. Pick what shows up in your sidebar and feed.
</p>
</div>
</div>
{/* Column headers */}
<div className="flex items-center justify-end gap-2 px-3 pb-2 border-b border-border">
<span className="text-[11px] font-medium text-muted-foreground w-[52px] text-center">Feed</span>
</div>
{/* Content type rows - reuse the internals from FeedSettingsForm */}
<FeedSettingsFormInternals />
</div>
</div>
<Section title="Muted">
<MuteSettingsInternals />
</Section>
<Section title="Sensitive Content">
<SensitiveContentSection />
</Section>
</div>
);
}
function KindBadge({ kind }: { kind: number }) {
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<span className="text-[10px] font-mono text-muted-foreground/60 shrink-0">
[{kind}]
</span>
<section>
<h2 className="text-base font-semibold px-3 pb-2 border-b border-border">{title}</h2>
<div className="pt-2">{children}</div>
</section>
);
}
function SubKindRow({ sub }: { sub: SubKindDef }) {
const { feedSettings, updateFeedSettings } = useFeedSettings();
const { updateSettings } = useEncryptedSettings();
const { user } = useCurrentUser();
const handleToggle = async (key: string, value: boolean) => {
updateFeedSettings({ [key]: value });
if (user) {
await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } });
}
};
function FlatContentList() {
// Flat, ordered list of curated kinds. No section grouping, no sub-rows, no kind badges.
const orderedIds = [
'posts', 'replies', 'reposts', 'articles', 'highlights',
'photos', 'videos', 'voice',
'events', 'polls', 'communities', 'badges',
'reactions', 'zaps',
];
const byId = new Map(EXTRA_KINDS.map((def) => [def.id, def]));
// Replies is id 'comments' in the registry; alias here for readability.
byId.set('replies', byId.get('comments')!);
const rows = orderedIds.map((id) => byId.get(id)).filter((d): d is ExtraKindDef => !!d && !!d.agora);
return (
<div className="flex items-center justify-between py-2.5 pl-12 pr-3 transition-colors">
<div className="min-w-0">
<span className="text-sm">{sub.label}</span>
<p className="text-xs text-muted-foreground mt-0.5">
<KindBadge kind={sub.kind} />{' '}{sub.description}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="w-[52px] flex justify-center">
<Switch
checked={feedSettings[sub.feedKey]}
onCheckedChange={(checked) => handleToggle(sub.feedKey, checked)}
className="scale-90"
/>
</div>
</div>
</div>
<ul className="divide-y divide-border">
{rows.map((def) => (
<li key={def.id}>
<ContentTypeRow def={def} />
</li>
))}
</ul>
);
}
function ContentTypeRow({ def }: { def: ExtraKindDef }) {
const { feedSettings, updateFeedSettings } = useFeedSettings();
const { updateSettings } = useEncryptedSettings();
const { user } = useCurrentUser();
const IconComponent = CONTENT_KIND_ICONS[def.id] ?? Palette;
const icon = <IconComponent className="size-5" />;
const hasSubKinds = !!def.subKinds;
const handleToggle = async (key: string, value: boolean) => {
updateFeedSettings({ [key]: value });
// Toggle key: prefer the feed inclusion key; fall back to the sidebar visibility key
// for kinds that have no direct feed key of their own (e.g. parent kinds with sub-kinds).
const toggleKey: keyof FeedSettings | undefined = def.feedKey ?? def.showKey;
if (!toggleKey) return null;
const checked = feedSettings[toggleKey] !== false;
const handleToggle = async (value: boolean) => {
const next: Partial<FeedSettings> = { [toggleKey]: value };
// Parent kinds with sub-kinds: toggle all sub-kind feed keys together so the
// single parent switch governs everything below it.
if (def.subKinds) {
for (const sub of def.subKinds) {
next[sub.feedKey] = value;
}
}
updateFeedSettings(next);
if (user) {
await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } });
await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, ...next } });
}
};
return (
<div className="border-b border-border last:border-b-0">
<div className="flex items-center justify-between py-3.5 px-3">
<div className="flex items-center gap-3 min-w-0">
<span className="text-muted-foreground shrink-0">{icon}</span>
<div className="min-w-0">
<span className="text-sm font-medium">{def.label}</span>
<p className="text-xs text-muted-foreground mt-0.5">
<KindBadge kind={def.kind} />{' '}{def.description}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="w-[52px] flex justify-center">
{!hasSubKinds && def.feedKey ? (
<Switch
checked={feedSettings[def.feedKey]}
onCheckedChange={(checked) => handleToggle(def.feedKey!, checked)}
/>
) : !hasSubKinds && def.feedOnly && def.showKey ? (
<Switch
checked={feedSettings[def.showKey] !== false}
onCheckedChange={(checked) => handleToggle(def.showKey!, checked)}
/>
) : null}
</div>
</div>
<div className="flex items-center justify-between gap-4 py-3.5 px-3">
<div className="min-w-0">
<span className="text-sm font-medium">{def.label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{def.description}</p>
</div>
{hasSubKinds && def.subKinds && def.subKinds.map((sub) => (
<SubKindRow
key={sub.feedKey}
sub={sub}
/>
))}
<Switch checked={checked} onCheckedChange={handleToggle} className="shrink-0" />
</div>
);
}
function NotesFeedSettings() {
return (
<>
{FEED_KINDS.map((def) => (
<ContentTypeRow key={def.id} def={def} />
))}
</>
);
}
function FeedSettingsFormInternals() {
return (
<>
{SECTION_ORDER.map((section) => {
const sectionKinds = EXTRA_KINDS.filter((def) => def.section === section);
if (sectionKinds.length === 0) return null;
return (
<div key={section}>
<div className="px-3 pt-4 pb-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{SECTION_LABELS[section]}
</span>
</div>
{sectionKinds.map((def) => (
<ContentTypeRow key={def.id} def={def} />
))}
</div>
);
})}
</>
);
}
// Feed Tabs Section Component
function FeedTabsSection() {
const { toast } = useToast();
@@ -407,14 +294,11 @@ function FeedTabsSection() {
return (
<div>
{/* Intro section for Feed Tabs */}
<div className="flex items-center gap-4 px-3 pt-3 pb-4">
<IntroImage src="/community-intro.png" />
<div className="min-w-0">
<h3 className="text-sm font-semibold">Feed Navigation</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Manage which feed tabs appear in your navigation and follow communities by domain.
</p>
</div>
<div className="px-3 pt-3 pb-4">
<h3 className="text-sm font-semibold">Feed Navigation</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Manage which feed tabs appear in your navigation and follow communities by domain.
</p>
</div>
{/* Feed Tab Toggles */}
@@ -922,16 +806,10 @@ export function SensitiveContentSection() {
return (
<div>
{/* Intro */}
<div className="flex items-center gap-4 px-3 pt-3 pb-4">
<div className="w-40 shrink-0 flex items-center justify-center">
<ShieldAlert className="size-16 text-muted-foreground/40" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold">Content Warnings</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Some posts are tagged with content warnings (NIP-36) by their authors. This can include NSFW material, spoilers, or other sensitive content.
</p>
</div>
<div className="px-3 pt-3 pb-4">
<p className="text-xs text-muted-foreground leading-relaxed">
Some posts are tagged by their authors as sensitive — NSFW, graphic, or otherwise needing a content warning. Choose how to handle them.
</p>
</div>
{/* Policy options — consistent row style with other settings */}
+23 -2
View File
@@ -25,6 +25,14 @@ interface CoverImageFieldProps {
onChange: (url: string) => void;
/** Notifies parent forms so they can block submit while Blossom upload runs. */
onUploadingChange?: (uploading: boolean) => void;
/**
* Fires after a successful Blossom upload with the NIP-94-style tag
* array returned by `useUploadFile`:
* `[["url", "<url>"], ["x", "<sha256>"], ["ox", "<sha256>"], ["size", "<bytes>"], ["m", "image/jpeg"]]`.
* Parents that want to publish a paired NIP-92 `imeta` tag in their
* Nostr event should convert this array — see Kind 33863 publishing.
*/
onUploadComplete?: (nip94Tags: string[][]) => void;
/** Optional template gallery shown between the dropzone and the URL input. */
templates?: readonly CoverImageTemplate[];
}
@@ -45,7 +53,7 @@ interface CoverImageFieldProps {
* anything other than a well-formed https URL — that's deliberate, since
* the same value is what gets published in the Nostr event's `image` tag.
*/
export function CoverImageField({ value, onChange, onUploadingChange, templates }: CoverImageFieldProps) {
export function CoverImageField({ value, onChange, onUploadingChange, onUploadComplete, templates }: CoverImageFieldProps) {
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false);
@@ -71,8 +79,21 @@ export function CoverImageField({ value, onChange, onUploadingChange, templates
return;
}
try {
const [[, url]] = await uploadFile(file);
const tags = await uploadFile(file);
const [[, url]] = tags;
onChange(url);
// Forward the raw NIP-94 tag array to the parent so it can build a
// paired NIP-92 imeta tag. The URL inside the tags is what Blossom
// returned; the parent's `value` may pick up an appended extension
// via the useUploadFile post-processing, but the sha256 ("x") still
// identifies the same byte stream.
if (onUploadComplete) {
// Replace the URL in the first tag with the extension-corrected
// value the parent now holds (matches the rendered banner src).
const adjusted = tags.map((t) => [...t]);
if (adjusted[0]?.[0] === 'url') adjusted[0][1] = url;
onUploadComplete(adjusted);
}
} catch (error) {
toast({
title: 'Upload failed',
@@ -25,6 +25,7 @@ import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { createOrganizationAssociationTags } from '@/lib/organizationContext';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { withAgoraTag } from '@/lib/agoraNoteTags';
interface CreateCommunityEventDialogProps {
communityATag?: string;
@@ -307,7 +308,7 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
const publishedEvent = await publishEvent({
kind,
content: description.trim(),
tags,
tags: withAgoraTag(tags),
prev: prev ?? undefined,
});
+2 -1
View File
@@ -20,6 +20,7 @@ import { getEffectiveRelays } from '@/lib/appRelays';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { useQueryClient } from '@tanstack/react-query';
import { ZAP_GOAL_KIND } from '@/lib/goalUtils';
import { withAgoraTag } from '@/lib/agoraNoteTags';
interface CreateGoalDialogProps {
/** The community `a` tag coordinate (e.g. `34550:<pubkey>:<d-tag>`). */
@@ -116,7 +117,7 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
await publishEvent({
kind: ZAP_GOAL_KIND,
content: title.trim(),
tags,
tags: withAgoraTag(tags),
});
// Refresh the goals tab and the community activity feed
-257
View File
@@ -1,257 +0,0 @@
import { useEffect, useRef } from 'react';
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
life: number;
size: number;
}
interface Ring {
x: number;
y: number;
radius: number;
maxRadius: number;
life: number; // 1 → 0
}
function parseHslString(hsl: string): { h: number; s: number; l: number } {
const parts = hsl.trim().split(/\s+/);
return {
h: parseFloat(parts[0] ?? '30'),
s: parseFloat(parts[1] ?? '100'),
l: parseFloat(parts[2] ?? '55'),
};
}
export function CursorFireEffect() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particles = useRef<Particle[]>([]);
const rings = useRef<Ring[]>([]);
const cursor = useRef<{ x: number; y: number } | null>(null);
const active = useRef(false);
const raf = useRef(0);
const pulse = useRef(0);
const frame = useRef(0);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
function resize() {
if (!canvas) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
resize();
window.addEventListener('resize', resize);
function onMouseMove(e: MouseEvent) {
cursor.current = { x: e.clientX, y: e.clientY };
active.current = true;
}
function onTouchMove(e: TouchEvent) {
const t = e.touches[0];
if (t) { cursor.current = { x: t.clientX, y: t.clientY }; active.current = true; }
}
function onLeave() { active.current = false; }
function onClick(e: MouseEvent) {
spawnClickBurst(e.clientX, e.clientY);
}
function onTouchStart(e: TouchEvent) {
const t = e.touches[0];
if (t) spawnClickBurst(t.clientX, t.clientY);
}
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('touchmove', onTouchMove, { passive: true });
window.addEventListener('mouseleave', onLeave);
window.addEventListener('touchend', onLeave);
window.addEventListener('click', onClick);
window.addEventListener('touchstart', onTouchStart, { passive: true });
function getPrimary() {
const raw = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim();
return raw ? parseHslString(raw) : { h: 270, s: 80, l: 60 };
}
function spawnWispParticles(x: number, y: number) {
const count = Math.floor(Math.random() * 2) + 2;
for (let i = 0; i < count; i++) {
const angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.3;
const speed = Math.random() * 0.6 + 0.3;
particles.current.push({
x: x + (Math.random() - 0.5) * 6,
y,
vx: Math.cos(angle) * speed * 0.2,
vy: Math.sin(angle) * speed,
life: 1,
size: Math.random() * 28 + 20,
});
}
}
function spawnClickBurst(x: number, y: number) {
// Expanding shockwave ring
rings.current.push({ x, y, radius: 0, maxRadius: 120, life: 1 });
// Secondary smaller ring
rings.current.push({ x, y, radius: 0, maxRadius: 60, life: 1 });
// Radial burst of particles in all directions
const count = 18;
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2;
const speed = Math.random() * 3.5 + 1.5;
particles.current.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 1,
size: Math.random() * 20 + 12,
});
}
// Extra upward plume
for (let i = 0; i < 8; i++) {
const angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.8;
const speed = Math.random() * 4 + 2;
particles.current.push({
x: x + (Math.random() - 0.5) * 10,
y,
vx: Math.cos(angle) * speed * 0.3,
vy: Math.sin(angle) * speed,
life: 1,
size: Math.random() * 30 + 18,
});
}
}
function draw() {
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const { h, s, l } = getPrimary();
// Spawn wisp particles every 4th frame
frame.current++;
if (active.current && cursor.current && frame.current % 4 === 0) {
spawnWispParticles(cursor.current.x, cursor.current.y);
}
ctx.globalCompositeOperation = 'screen';
// Draw expanding rings
const aliveRings: Ring[] = [];
for (const r of rings.current) {
r.life -= 0.022;
if (r.life <= 0) continue;
r.radius += (r.maxRadius - r.radius) * 0.08;
const t = r.life;
const lineAlpha = Math.pow(t, 1.5) * 0.8;
const glowAlpha = Math.pow(t, 2) * 0.4;
const lineWidth = t * 3;
// Outer glow halo
ctx.beginPath();
ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2);
ctx.strokeStyle = `hsla(${h}, ${s}%, ${Math.min(l + 20, 85)}%, ${glowAlpha})`;
ctx.lineWidth = lineWidth + 8;
ctx.stroke();
// Sharp ring
ctx.beginPath();
ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2);
ctx.strokeStyle = `hsla(${h - 10}, ${s}%, 90%, ${lineAlpha})`;
ctx.lineWidth = lineWidth;
ctx.stroke();
aliveRings.push(r);
}
rings.current = aliveRings;
// Draw flame particles
const alive: Particle[] = [];
for (const p of particles.current) {
p.life -= 0.005 + Math.random() * 0.002;
if (p.life <= 0) continue;
p.x += p.vx;
p.y += p.vy;
p.vy -= 0.018;
p.vx *= 0.98;
p.size *= 0.985;
const t = p.life;
const ph = h + (1 - t) * 25;
const pl = Math.min(l + t * 40, 90);
const alpha = Math.pow(t, 1.5) * 0.18;
const radius = p.size * (0.4 + t * 0.6);
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, radius);
g.addColorStop(0, `hsla(${ph - 5}, ${s}%, ${pl}%, ${alpha})`);
g.addColorStop(0.35, `hsla(${ph}, ${s}%, ${Math.max(l, 40)}%, ${alpha * 0.6})`);
g.addColorStop(0.7, `hsla(${ph + 15}, ${s}%, ${Math.max(l - 15, 20)}%, ${alpha * 0.2})`);
g.addColorStop(1, `hsla(${ph + 25}, ${s}%, ${Math.max(l - 25, 5)}%, 0)`);
ctx.beginPath();
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
ctx.fillStyle = g;
ctx.fill();
alive.push(p);
}
particles.current = alive;
// Orb: slow pulsing core glow at cursor
if (active.current && cursor.current) {
const { x, y } = cursor.current;
pulse.current += 0.025;
const pv = (Math.sin(pulse.current) + 1) / 2;
const r = 20 + pv * 12;
const a = 0.5 + pv * 0.3;
const orb = ctx.createRadialGradient(x, y, 0, x, y, r);
orb.addColorStop(0, `hsla(${h - 10}, ${Math.max(s - 10, 0)}%, 95%, ${a})`);
orb.addColorStop(0.4, `hsla(${h}, ${s}%, ${Math.min(l + 15, 85)}%, ${a * 0.5})`);
orb.addColorStop(1, `hsla(${h + 15}, ${s}%, ${l}%, 0)`);
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = orb;
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
raf.current = requestAnimationFrame(draw);
}
raf.current = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(raf.current);
window.removeEventListener('resize', resize);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('touchmove', onTouchMove);
window.removeEventListener('mouseleave', onLeave);
window.removeEventListener('touchend', onLeave);
window.removeEventListener('click', onClick);
window.removeEventListener('touchstart', onTouchStart);
};
}, []);
return (
<canvas
ref={canvasRef}
className="pointer-events-none fixed inset-0 z-[9999]"
aria-hidden="true"
/>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { ComposeBox } from '@/components/ComposeBox';
interface DetailCommentComposerProps {
event: NostrEvent;
placeholder?: string;
onSuccess?: () => void;
className?: string;
}
export function DetailCommentComposer({
event,
placeholder = "What's on your mind?",
onSuccess,
className,
}: DetailCommentComposerProps) {
return (
<div className={className}>
<ComposeBox
compact
defaultExpanded
hideBorder
replyTo={event}
placeholder={placeholder}
onSuccess={onSuccess}
className="bg-transparent"
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
+5 -9
View File
@@ -29,7 +29,6 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { z } from 'zod';
import { IntroImage } from '@/components/IntroImage';
import { ImageCropDialog } from '@/components/ImageCropDialog';
// Extended form schema that includes custom fields
@@ -248,14 +247,11 @@ export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onValuesChange
return (
<div>
{/* Intro */}
<div className="flex items-center gap-4 px-3 pt-2 pb-4">
<IntroImage src="/profile-intro.png" />
<div className="min-w-0">
<h2 className="text-sm font-semibold">Your Identity</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Customize your profile with a name, bio, images, and verification. This is how others will see you on Nostr.
</p>
</div>
<div className="px-3 pt-2 pb-4">
<h2 className="text-sm font-semibold">Your Identity</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Customize your profile with a name, bio, images, and verification. This is how others will see you on Nostr.
</p>
</div>
{/* Crop dialog */}
+30 -58
View File
@@ -3,8 +3,7 @@ import { Link } from 'react-router-dom';
import { ArrowLeft, BookOpen, Coins, ExternalLink, FileText, Globe, Landmark, Languages, MapPin, Megaphone, MessageCircle, Package, Pause, Play, Repeat2, Share2, User, UserCheck, UserMinus, UserPlus, Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { Skeleton } from '@/components/ui/skeleton';import { ExternalFavicon } from '@/components/ExternalFavicon';
import { ExternalReactionButton } from '@/components/ExternalReactionButton';
import { FollowToggleButton } from '@/components/FollowButton';
import { LinkEmbed } from '@/components/LinkEmbed';
@@ -22,7 +21,6 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useCountryFollows } from '@/hooks/useCountryFollows';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useWeather } from '@/hooks/useWeather';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { getCountryInfo, getWikipediaTitle } from '@/lib/countries';
@@ -638,9 +636,11 @@ function WikipediaExtract({ extract, articleUrl }: { extract: string; articleUrl
* above the Wikipedia extract doesn't draw against a phantom row.
*/
function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts | undefined }) {
const { data: weather, isLoading } = useWeather(code);
const capital = facts?.capital ?? null;
// Weather has been removed; this row now renders only the country vitals
// (population / languages / currency). The legacy name is preserved so
// the mount call sites don't churn — the row still vanishes when there
// are no vitals to show, matching the original behavior.
void code;
const vitals: { key: string; icon: React.ReactNode; label: string; value: string }[] = [];
if (facts) {
if (facts.population !== null) {
@@ -670,40 +670,17 @@ function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts |
}
}
if (isLoading && vitals.length === 0) {
return (
<div className="px-4 py-2 flex items-center gap-3">
<Skeleton className="size-6 rounded-md" />
<Skeleton className="h-4 w-40" />
</div>
);
}
if (vitals.length === 0) return null;
const hasWeatherSide = !!weather || !!capital;
const hasVitalsSide = vitals.length > 0;
if (!hasWeatherSide && !hasVitalsSide) return null;
const capital = facts?.capital ?? null;
const hasCapitalSide = !!capital;
return (
<div className="px-4 py-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 text-sm">
{/* Left group — weather + capital. */}
{hasWeatherSide && (
{/* Left group — capital. */}
{hasCapitalSide && (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 min-w-0">
{weather && (
<>
<span className="flex items-baseline gap-2 text-foreground">
<span className="text-xl leading-none" role="img" aria-label={weather.description}>
{weather.icon}
</span>
<span className="font-bold tabular-nums">{weather.temperature}°</span>
</span>
<span className="text-muted-foreground">{weather.description}</span>
</>
)}
{capital && (
// The country's capital is the stable national place anchor for
// the header. The weather-station city is intentionally omitted
// — it's often a smaller, less-recognised town nearby and
// duplicates a less-meaningful place name on the same line.
<span className="flex items-center gap-1 text-muted-foreground/80 text-xs">
<Landmark className="size-3 shrink-0" />
<span>{capital}</span>
@@ -712,28 +689,21 @@ function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts |
</div>
)}
{/* Right group — vitals (population, language, currency). On narrow
viewports this wraps onto its own line under the weather group
rather than getting crushed beside it. Styled to match the
capital chip on the left (text-xs muted-foreground/80 with a
size-3 icon) so the row reads as a single uniform metadata
strip rather than two competing weights. */}
{hasVitalsSide && (
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground/80 min-w-0">
{vitals.map((item) => (
<li
key={item.key}
className="flex items-center gap-1 min-w-0"
title={`${item.label}: ${item.value}`}
>
{item.icon}
<span className="truncate max-w-[14ch] sm:max-w-[18ch]">
{item.value}
</span>
</li>
))}
</ul>
)}
{/* Right group — vitals (population, language, currency). */}
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground/80 min-w-0">
{vitals.map((item) => (
<li
key={item.key}
className="flex items-center gap-1 min-w-0"
title={`${item.label}: ${item.value}`}
>
{item.icon}
<span className="truncate max-w-[14ch] sm:max-w-[18ch]">
{item.value}
</span>
</li>
))}
</ul>
</div>
);
}
@@ -877,7 +847,6 @@ export function CountryContentHeader({ code }: { code: string }) {
// Country facts are only fetched for sovereign countries (alpha-2 codes);
// the hook's internal guard returns `null` for subdivisions like `US-CA`.
const { data: facts } = useCountryFacts(info?.subdivision ? null : code);
const { data: weather } = useWeather(code);
const { user } = useCurrentUser();
const { isFollowingCountry, toggleCountryFollow, isPending } = useCountryFollows();
const { toast } = useToast();
@@ -916,7 +885,10 @@ export function CountryContentHeader({ code }: { code: string }) {
// map or administrative photo, which contradicts the editorial choice
// to surface Tibet as a country in its own right.
const heroImage = customFlagAsset(code) ?? wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null;
const isDay = weather?.isDay ?? true;
// Always render the daytime sky overlay. Previously we keyed this off the
// live `weather.isDay` flag to flip into a night palette; weather has been
// removed so we default to the warm amber/rose daytime tint.
const isDay = true;
// Sky-tint gradient layered above the hero photo. Warm amber/rose during
// local daytime, deep indigo/violet at night. Same gradient shape, only
// the colour palette flips — preserves the cinematic curve while the mood
+118 -84
View File
@@ -2,13 +2,13 @@ import { useState, useEffect, useMemo } from 'react';
import { useInView } from 'react-intersection-observer';
import { usePageRefresh } from '@/hooks/usePageRefresh';
import { ComposeBox } from '@/components/ComposeBox';
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
import { HeroGlobe } from '@/components/HeroGlobe';
import { LandingHero } from '@/components/LandingHero';
import { NoteCard } from '@/components/NoteCard';
import { PullToRefresh } from '@/components/PullToRefresh';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { FeedModeSwitcher } from '@/components/FeedModeSwitcher';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
import AuthDialog from '@/components/auth/AuthDialog';
import { useFeed } from '@/hooks/useFeed';
@@ -16,9 +16,8 @@ import { useFollowingFeed } from '@/hooks/useFollowingFeed';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedTab } from '@/hooks/useFeedTab';
import { useMuteList } from '@/hooks/useMuteList';
import { useAgoraFeed } from '@/hooks/useAgoraFeed';
import { useMixedFeed, type FeedMode } from '@/hooks/useMixedFeed';
import { shouldHideFeedEvent } from '@/lib/feedUtils';
import { HOPE_PALETTE } from '@/lib/hopePalette';
import { isEventMuted } from '@/lib/muteHelpers';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
@@ -39,40 +38,14 @@ interface FeedProps {
hideCompose?: boolean;
/** Message shown when the feed is empty. */
emptyMessage?: string;
/** Unique identifier for this feed page, used to persist the active tab in sessionStorage. Defaults to 'home'. */
/** Unique identifier for this feed page, used to persist the active tab/mode in localStorage. Defaults to 'home'. */
feedId?: string;
}
const FEED_BACKDROP_HUE_INTERVAL_MS = 45_000;
const FEED_BACKDROP_HUE_FADE_MS = 18_000;
const AGORA_DEFAULT_NOTE_TAGS = [['t', 'agora']];
const FEED_MODES: readonly FeedMode[] = ['agora', 'all-nostr', 'following'] as const;
function FeedGlobeBackground() {
const [hueIndex, setHueIndex] = useState(0);
useEffect(() => {
const id = window.setInterval(() => {
setHueIndex((i) => (i + 1) % HOPE_PALETTE.length);
}, FEED_BACKDROP_HUE_INTERVAL_MS);
return () => window.clearInterval(id);
}, []);
const activeHue = HOPE_PALETTE[hueIndex];
return (
<div className="fixed inset-0 z-0 pointer-events-none overflow-hidden bg-secondary/30" aria-hidden="true">
<HeroAtmosphere hue={activeHue} fadeMs={FEED_BACKDROP_HUE_FADE_MS} className="opacity-55" />
<div className="absolute inset-0 bg-gradient-to-b from-background/10 via-background/20 to-background/55" />
<div className="absolute inset-0 flex items-center justify-center">
<HeroGlobe
hue={activeHue}
className="aspect-square max-w-none opacity-70 drop-shadow-2xl"
style={{ width: 'clamp(552px, 86.4dvw, 984px)' }}
/>
</div>
<div className="absolute inset-0 bg-background/70" />
</div>
);
function isFeedMode(value: string): value is FeedMode {
return (FEED_MODES as readonly string[]).includes(value);
}
export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, feedId = 'home' }: FeedProps = {}) {
@@ -83,14 +56,19 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
const [authDialogOpen, setAuthDialogOpen] = useState(false);
const isHomeAgoraFeed = !kinds && !tagFilters;
// The home feed is Agora-only. Specialized feed pages keep Follows + Global.
// For the home /feed page we use a three-mode picker instead of the
// Follows/Global tab pair. Mode persists via the same useFeedTab storage,
// keyed under the same feedId.
const homeFeedMode: FeedMode = (() => {
if (!isHomeAgoraFeed) return 'agora';
if (isFeedMode(rawActiveTab)) return rawActiveTab;
// Legacy values get coerced to the Agora default.
return 'agora';
})();
// Specialized feed pages keep the original Follows + Global tabs.
const activeTab: FeedTab = (() => {
if (isHomeAgoraFeed) return 'agora';
if (!kinds) {
if (rawActiveTab === 'global') return 'global';
if (rawActiveTab === 'follows' && user) return 'follows';
return user ? 'follows' : 'global';
}
if (isHomeAgoraFeed) return homeFeedMode;
if (rawActiveTab === 'global') return 'global';
if (rawActiveTab === 'follows' && user) return 'follows';
return user ? 'follows' : 'global';
@@ -106,43 +84,48 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
}
}, [rawActiveTab, handleSetActiveTab]);
const handleModeChange = (mode: FeedMode) => {
handleSetActiveTab(mode);
};
// Kind-specific pages (e.g. Development, WebXDC) only show Follows + Global tabs.
const isKindSpecificPage = !!kinds;
// When the Agora tab is active, show the mixed Agora activity feed.
// Disabled on kind-specific pages — the Agora tab is not shown there.
const isAgoraActive = isHomeAgoraFeed;
// -------------------------------------------------------------------------
// Home feed (mixed-mode): drives off useMixedFeed.
// -------------------------------------------------------------------------
const mixedFeed = useMixedFeed(homeFeedMode, isHomeAgoraFeed);
// Standard feed query (used when logged in, or on kind-specific pages, or core tabs)
const isHomeFollowingActive = activeTab === 'follows' && !isKindSpecificPage && !tagFilters;
// -------------------------------------------------------------------------
// Specialized feed pages: original Follows/Global behavior.
// -------------------------------------------------------------------------
const isHomeFollowingActive = activeTab === 'follows' && !isKindSpecificPage && !tagFilters && !isHomeAgoraFeed;
const isCoreFeedTab = activeTab === 'follows' || activeTab === 'network' || activeTab === 'global' || activeTab === 'communities' || activeTab === 'world' || activeTab === 'agora';
type UseFeedTab = 'follows' | 'network' | 'global' | 'communities';
const feedTabForQuery: UseFeedTab =
activeTab === 'follows'
? (isHomeFollowingActive ? 'network' : 'network')
? 'network'
: activeTab === 'network' || activeTab === 'global' || activeTab === 'communities'
? (activeTab as UseFeedTab)
: 'global';
const standardFeedOptions = (kinds || tagFilters)
? { kinds, tagFilters, enabled: !isHomeFollowingActive && !isAgoraActive }
: { enabled: !isHomeFollowingActive && !isAgoraActive };
? { kinds, tagFilters, enabled: !isHomeFollowingActive && !isHomeAgoraFeed }
: { enabled: !isHomeFollowingActive && !isHomeAgoraFeed };
const feedQuery = useFeed(
isCoreFeedTab && !isAgoraActive ? feedTabForQuery : 'global',
isCoreFeedTab && !isHomeAgoraFeed ? feedTabForQuery : 'global',
standardFeedOptions,
);
const followingFeed = useFollowingFeed(isHomeFollowingActive);
const agoraFeed = useAgoraFeed(isAgoraActive);
// For non-world tabs, use the standard feed query
const queryKey = useMemo(
() => isAgoraActive
? ['agora-feed']
() => isHomeAgoraFeed
? ['mixed-feed', homeFeedMode]
: isHomeFollowingActive
? [['feed', 'network'], ['community-activity-feed'], ['following-country-feed']]
: ['feed', activeTab],
[isAgoraActive, isHomeFollowingActive, activeTab],
[isHomeAgoraFeed, homeFeedMode, isHomeFollowingActive, activeTab],
);
const handleRefresh = usePageRefresh(queryKey);
@@ -157,16 +140,16 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
} = isHomeFollowingActive ? followingFeed : feedQuery;
// Unify pagination interface
const fetchNextPage = isAgoraActive ? agoraFeed.fetchNextPage : fetchNextPageStandard;
const hasNextPage = isAgoraActive ? agoraFeed.hasNextPage : hasNextPageStandard;
const isFetchingNextPage = isAgoraActive ? agoraFeed.isFetchingNextPage : isFetchingNextPageStandard;
const fetchNextPage = isHomeAgoraFeed ? mixedFeed.fetchNextPage : fetchNextPageStandard;
const hasNextPage = isHomeAgoraFeed ? mixedFeed.hasNextPage : hasNextPageStandard;
const isFetchingNextPage = isHomeAgoraFeed ? mixedFeed.isFetchingNextPage : isFetchingNextPageStandard;
// Auto-fetch page 2 as soon as page 1 arrives for smoother scrolling
useEffect(() => {
if (!isHomeFollowingActive && !isAgoraActive && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
if (!isHomeFollowingActive && !isHomeAgoraFeed && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
fetchNextPage();
}
}, [isHomeFollowingActive, isAgoraActive, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
}, [isHomeFollowingActive, isHomeAgoraFeed, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
// Intersection observer for infinite scroll
const { ref: scrollRef, inView } = useInView({
@@ -182,8 +165,8 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
// Flatten, deduplicate, and filter muted content.
const feedItems = useMemo(() => {
if (isAgoraActive) {
return agoraFeed.events.map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
if (isHomeAgoraFeed) {
return mixedFeed.items;
}
if (!rawData?.pages) return [];
@@ -199,24 +182,29 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
return true;
});
}, [isAgoraActive, agoraFeed.events, rawData?.pages, muteItems]);
}, [isHomeAgoraFeed, mixedFeed.items, rawData?.pages, muteItems]);
// Show skeletons while loading.
const showSkeleton = isAgoraActive
? agoraFeed.isLoading
const showSkeleton = isHomeAgoraFeed
? mixedFeed.isLoading && feedItems.length === 0
: (isPending || (isLoading && !rawData));
const useGlobeBackdrop = feedId === 'home' && !kinds && !tagFilters && !header;
const translucentCardClassName = useGlobeBackdrop
? 'bg-transparent border-border/50 hover:bg-transparent'
: undefined;
const transparentFeedSurfaceClassName = useGlobeBackdrop ? 'bg-transparent' : undefined;
// Per-mode empty-state copy for the home feed.
const homeEmptyMessage = (() => {
if (homeFeedMode === 'agora') {
return "Quiet moment on Agora. New campaigns, pledges, donations, and posts will appear here as they happen.";
}
if (homeFeedMode === 'following') {
return user
? "Your follow feed is empty. Follow some people to see what they're up to, or switch to Agora or All Nostr."
: "Log in to see posts from people you follow.";
}
return 'Nothing to show. Check your relay connections or try again in a moment.';
})();
return (
<main className={cn('flex-1 min-w-0 min-h-dvh', useGlobeBackdrop && 'relative isolate overflow-x-clip')}>
{useGlobeBackdrop && <FeedGlobeBackground />}
<div className={cn(useGlobeBackdrop && 'relative z-10')}>
<main className="flex-1 min-w-0 min-h-dvh bg-background">
<div>
{header}
{/* CTA (logged out, main feed only) */}
@@ -224,20 +212,31 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
<LandingHero onJoinClick={() => setAuthDialogOpen(true)} />
)}
{/* Home-feed mode switcher: top-left, anchors the page visually */}
{isHomeAgoraFeed && (
<div className="px-4 pt-5 pb-3 sm:pt-6">
<FeedModeSwitcher
value={homeFeedMode}
onChange={handleModeChange}
followingAvailable={!!user}
onLoginRequested={() => setAuthDialogOpen(true)}
/>
</div>
)}
{!hideCompose && (
<ComposeBox
compact
hideBorder
className={transparentFeedSurfaceClassName}
defaultTags={AGORA_DEFAULT_NOTE_TAGS}
hideBorder={isHomeAgoraFeed}
defaultExpanded
placeholder="What's happening?"
/>
)}
{/* Tabs are only kept for specialized feed pages. The home feed is Agora-only. */}
{/* Tabs are only kept for specialized feed pages. The home feed uses
the FeedModeSwitcher above. */}
{user && (isKindSpecificPage || tagFilters) && (
<SubHeaderBar backgroundFillClassName={transparentFeedSurfaceClassName && 'fill-transparent'}>
<SubHeaderBar>
<TabButton label={isKindSpecificPage || tagFilters ? 'Follows' : 'Following'} active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
<TabButton label="Global" active={activeTab === 'global'} onClick={() => handleSetActiveTab('global')} />
</SubHeaderBar>
@@ -247,7 +246,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
{showSkeleton ? (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardSkeleton key={i} className={translucentCardClassName} />
<NoteCardSkeleton key={i} />
))}
</div>
) : feedItems.length > 0 ? (
@@ -257,7 +256,6 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
className={translucentCardClassName}
/>
))}
{hasNextPage && (
@@ -270,15 +268,20 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
</div>
)}
</div>
) : isHomeAgoraFeed ? (
<HomeFeedEmptyState
mode={homeFeedMode}
message={homeEmptyMessage}
onSwitchToAgora={homeFeedMode !== 'agora' ? () => handleModeChange('agora') : undefined}
onLoginClick={!user && homeFeedMode === 'following' ? () => setAuthDialogOpen(true) : undefined}
/>
) : (
<FeedEmptyState
message={
emptyMessage ?? (
activeTab === 'follows'
? 'Your feed is empty. Follow some people to see their posts here.'
: activeTab === 'agora'
? 'No Agora activity found. Check your relay connections or come back soon.'
: 'No posts found. Check your relay connections or come back soon.'
: 'No posts found. Check your relay connections or come back soon.'
)
}
showDiscover={!emptyMessage && activeTab === 'follows'}
@@ -303,6 +306,37 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
);
}
interface HomeFeedEmptyStateProps {
mode: FeedMode;
message: string;
onSwitchToAgora?: () => void;
onLoginClick?: () => void;
}
function HomeFeedEmptyState({ mode, message, onSwitchToAgora, onLoginClick }: HomeFeedEmptyStateProps) {
return (
<div className="py-20 px-8 flex flex-col items-center text-center">
<p className="text-muted-foreground max-w-sm leading-relaxed">{message}</p>
<div className="flex flex-col gap-2 mt-6 w-full max-w-xs">
{onLoginClick && (
<Button className="rounded-full" onClick={onLoginClick}>
Log in
</Button>
)}
{onSwitchToAgora && (
<Button
variant={mode === 'following' ? 'default' : 'ghost'}
className="rounded-full"
onClick={onSwitchToAgora}
>
Browse the Agora feed
</Button>
)}
</div>
</div>
);
}
function NoteCardSkeleton({ className }: { className?: string }) {
return (
<div className={cn('px-4 py-3 border-b border-border', className)}>
+125
View File
@@ -0,0 +1,125 @@
import { Check, ChevronDown, Globe, Sparkles, Users } from 'lucide-react';
import type { ComponentType } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { FeedMode } from '@/hooks/useMixedFeed';
import { cn } from '@/lib/utils';
interface FeedModeOption {
mode: FeedMode;
label: string;
icon: ComponentType<{ className?: string }>;
}
const OPTIONS: FeedModeOption[] = [
{ mode: 'agora', label: 'Agora', icon: Sparkles },
{ mode: 'all-nostr', label: 'All Nostr', icon: Globe },
{ mode: 'following', label: 'Following', icon: Users },
];
interface FeedModeSwitcherProps {
value: FeedMode;
onChange: (mode: FeedMode) => void;
/** When false, Following mode is disabled (requires login). */
followingAvailable: boolean;
/** Click handler for the disabled Following item (typically opens the auth dialog). */
onLoginRequested?: () => void;
className?: string;
}
/**
* The primary feed-mode picker rendered at the top-left of the home feed page.
*
* Visually anchored as the page heading — the active mode label is the largest
* text on the page. Clicking opens a compact dropdown menu offering the three
* modes; the active one is marked with a check.
*
* Logged-out users see "Following" greyed out; clicking it invokes
* {@link FeedModeSwitcherProps.onLoginRequested} to surface the auth dialog.
*/
export function FeedModeSwitcher({
value,
onChange,
followingAvailable,
onLoginRequested,
className,
}: FeedModeSwitcherProps) {
const active = OPTIONS.find((opt) => opt.mode === value) ?? OPTIONS[0];
return (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
'group inline-flex items-center gap-2 rounded-lg -ml-1 px-1 py-1 outline-none',
'text-foreground hover:text-foreground motion-safe:transition-colors',
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background',
className,
)}
aria-label={`Feed mode: ${active.label}. Click to change.`}
>
<span className="text-2xl sm:text-3xl font-bold tracking-tight leading-none">
{active.label}
</span>
<ChevronDown
className="size-5 text-muted-foreground motion-safe:transition-transform group-data-[state=open]:rotate-180"
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-56 p-1.5">
{OPTIONS.map((opt) => {
const Icon = opt.icon;
const isActive = opt.mode === value;
const isFollowing = opt.mode === 'following';
const disabled = isFollowing && !followingAvailable;
const handleSelect = (event: Event) => {
if (disabled) {
event.preventDefault();
onLoginRequested?.();
return;
}
onChange(opt.mode);
};
const itemContent = (
<DropdownMenuItem
key={opt.mode}
onSelect={handleSelect}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 cursor-pointer',
disabled && 'opacity-60 data-[disabled]:opacity-60',
)}
data-disabled={disabled || undefined}
>
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1 text-sm font-medium">{opt.label}</span>
{isActive && <Check className="size-4 shrink-0 text-primary" aria-hidden />}
</DropdownMenuItem>
);
if (disabled) {
return (
<Tooltip key={opt.mode}>
<TooltipTrigger asChild>{itemContent}</TooltipTrigger>
<TooltipContent side="right">
Log in to see posts from people you follow
</TooltipContent>
</Tooltip>
);
}
return itemContent;
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
+1 -1
View File
@@ -102,7 +102,7 @@ function FundraiserLayoutInner() {
function SiteFooter() {
return (
<footer className="border-t border-border bg-background mt-auto">
<footer className="bg-background mt-auto pt-12">
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
<span>&copy; {new Date().getFullYear()} Agora. Fundraisers on Nostr.</span>
<nav className="flex items-center gap-5">
+25 -8
View File
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom';
import { ArrowRight, MapPin } from 'lucide-react';
import { ArrowRight, MapPin, ShieldCheck } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@@ -10,7 +10,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
interface HeroCampaignSpotlightProps {
/** Campaign to feature. `null` renders the empty placeholder. */
@@ -39,7 +39,7 @@ export function HeroCampaignSpotlight({
// when there's no campaign yet we pass an empty pubkey and ignore the
// (no-op) result below. Same for donations + BTC price.
const author = useAuthor(campaign?.pubkey ?? '');
const { data: stats } = useCampaignDonations(campaign?.aTag);
const { data: stats } = useCampaignDonations(campaign ?? undefined);
const { data: btcPrice } = useBtcPrice();
if (isLoading && !campaign) {
@@ -59,6 +59,7 @@ export function HeroCampaignSpotlight({
const authorName = meta?.display_name || meta?.name || genUserName(campaign.pubkey);
const authorPicture = sanitizeUrl(meta?.picture);
const countryLabel = getCampaignCountryLabel(campaign);
const isSilentPayment = campaign.wallet.mode === 'sp';
return (
<div
@@ -83,12 +84,28 @@ export function HeroCampaignSpotlight({
so we can tune the bar for legibility on top of a photo: dark
translucent track, glowing primary fill. When the campaign has no
goal tag, the bar is omitted entirely and we only show the raised
total. */}
{(() => {
total. Silent-payment campaigns hide totals by design (per
NIP.md Kind 33863). */}
{isSilentPayment ? (
<div className="space-y-1.5 pt-1 max-w-xs">
<div className="inline-flex items-center gap-1.5 text-[11px] text-white/85 [text-shadow:none]">
<ShieldCheck className="size-3" />
<span>Private campaign totals not public</span>
</div>
{campaign.goalUsd && campaign.goalUsd > 0 && (
<div className="text-[11px] text-white/70 [text-shadow:none]">
Target: {formatUsdGoal(campaign.goalUsd)}
</div>
)}
</div>
) : (() => {
const raised = stats?.totalSats ?? 0;
const goal = campaign.goalSats;
const goal = campaign.goalUsd;
const hasGoal = !!goal && goal > 0;
const pct = hasGoal ? Math.min(100, Math.round((raised / goal!) * 100)) : 0;
const raisedUsd = satsToUsd(raised, btcPrice);
const pct = hasGoal && raisedUsd !== undefined
? Math.min(100, Math.round((raisedUsd / goal!) * 100))
: 0;
return (
<div className="space-y-1.5 pt-1 max-w-xs">
{hasGoal && (
@@ -106,7 +123,7 @@ export function HeroCampaignSpotlight({
</span>
{hasGoal && (
<span className="text-white/70">
of {formatCampaignAmount(goal!, btcPrice)} goal
of {formatUsdGoal(goal!)} goal
</span>
)}
</div>
-25
View File
@@ -1,25 +0,0 @@
interface IntroImageProps {
src: string;
/** Tailwind size class, e.g. "w-40" (default) or "w-10" */
size?: string;
className?: string;
}
export function IntroImage({ src, size = 'w-40', className }: IntroImageProps) {
return (
<div
className={`${size} shrink-0 bg-primary opacity-90 ${className ?? ''}`}
style={{
maskImage: `url(${src})`,
maskSize: 'contain',
maskRepeat: 'no-repeat',
maskPosition: 'center',
WebkitMaskImage: `url(${src})`,
WebkitMaskSize: 'contain',
WebkitMaskRepeat: 'no-repeat',
WebkitMaskPosition: 'center',
aspectRatio: '1 / 1',
}}
/>
);
}
-6
View File
@@ -4,10 +4,8 @@ import { LeftSidebar } from '@/components/LeftSidebar';
import { MobileTopBar } from '@/components/MobileTopBar';
import { MobileDrawer } from '@/components/MobileDrawer';
import { FloatingComposeButton } from '@/components/FloatingComposeButton';
import { CursorFireEffect } from '@/components/CursorFireEffect';
import { Skeleton } from '@/components/ui/skeleton';
import { CenterColumnContext, DrawerContext, LayoutStore, LayoutStoreContext, NavHiddenContext, useLayoutSnapshot } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
import { useScrollDirection } from '@/hooks/useScrollDirection';
import { cn } from '@/lib/utils';
@@ -55,15 +53,11 @@ function MainLayoutInner() {
const openDrawer = useCallback(() => setDrawerOpen(true), []);
const centerColumnRef = useRef<HTMLDivElement>(null);
const [centerColumnEl, setCenterColumnEl] = useState<HTMLElement | null>(null);
const { config } = useAppContext();
const { hidden: navHidden } = useScrollDirection(scrollContainer);
return (
<CenterColumnContext.Provider value={centerColumnEl}>
<DrawerContext.Provider value={openDrawer}>
<NavHiddenContext.Provider value={navHidden}>
{/* Magic Mouse fire particle overlay */}
{config.magicMouse && <CursorFireEffect />}
{/* Mobile top bar - only on small screens, hidden when page requests immersive mode */}
{!hideTopBar && <MobileTopBar onAvatarClick={() => setDrawerOpen(true)} hasSubHeader={hasSubHeader} />}
+1 -1
View File
@@ -110,7 +110,7 @@ export function MobileBottomNav() {
{/* Organizations */}
<NavItem
icon={Users}
label="Organize"
label="Groups"
active={isOnCommunities}
to="/communities"
onClick={() => { selectionChanged(); setSearchOpen(false); }}
+17 -27
View File
@@ -6,6 +6,7 @@ import {
FileText,
GitBranch,
GitPullRequest,
HandHeart,
Mail,
Megaphone,
MessageCircle,
@@ -33,13 +34,14 @@ import {
} from "@/components/AudioKindContent";
import { ActionContent } from "@/components/ActionContent";
import { BadgeContent } from "@/components/BadgeContent";
import { CampaignNoteCardContent } from "@/components/CampaignNoteCardContent";
import { CommunityContent } from "@/components/CommunityContent";
import { CalendarEventContent } from "@/components/CalendarEventContent";
import {
ColorMomentContent,
ColorMomentEyeButton,
} from "@/components/ColorMomentContent";
import { CommentContext, CountryCommentPill, CountryFlagBackdrop } from "@/components/CommentContext";
import { CommentContext, CountryCommentPill } from "@/components/CommentContext";
import { CommunityContentWarning } from "@/components/CommunityContentWarning";
import { ContentWarningGuard } from "@/components/ContentWarningGuard";
import { EmojifiedText, ReactionEmoji } from "@/components/CustomEmoji";
@@ -58,7 +60,6 @@ import { ChestIcon } from "@/components/icons/ChestIcon";
import { RepostIcon } from "@/components/icons/RepostIcon";
import { LiveStreamPlayer } from "@/components/LiveStreamPlayer";
import { MagicDeckContent } from "@/components/MagicDeckContent";
import { Nip05Badge } from "@/components/Nip05Badge";
import { NoteContent } from "@/components/NoteContent";
import { NoteMoreMenu } from "@/components/NoteMoreMenu";
import { PatchCard } from "@/components/PatchCard";
@@ -86,7 +87,6 @@ import { ZapDialog } from "@/components/ZapDialog";
import { useAppContext } from "@/hooks/useAppContext";
import { useAuthor } from "@/hooks/useAuthor";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useNip05Verify } from "@/hooks/useNip05Verify";
import { useOpenPost } from "@/hooks/useOpenPost";
import { useProfileUrl } from "@/hooks/useProfileUrl";
import { toast } from "@/hooks/useToast";
@@ -398,11 +398,6 @@ export const NoteCard = memo(function NoteCard({
const metadata = author.data?.metadata;
const actionMetadata = actionEvent ? actionAuthor.data?.metadata : metadata;
const displayName = getDisplayName(metadata, event.pubkey);
const nip05 = metadata?.nip05;
const { data: nip05Verified, isPending: nip05Pending } = useNip05Verify(
nip05,
event.pubkey,
);
const profileUrl = useProfileUrl(event.pubkey, metadata);
const encodedId = useMemo(() => encodeEventId(actionTarget), [actionTarget]);
const { data: stats } = useEventStats(actionTarget.id, actionTarget);
@@ -474,6 +469,7 @@ export const NoteCard = memo(function NoteCard({
const isCommunity = event.kind === 34550;
const isZapGoal = event.kind === 9041;
const isAction = event.kind === 36639;
const isCampaign = event.kind === 33863;
const isReaction = event.kind === 7;
const isPollVote = event.kind === 1018;
const isRepost = event.kind === 6 || event.kind === 16;
@@ -520,6 +516,7 @@ export const NoteCard = memo(function NoteCard({
!isCommunity &&
!isZapGoal &&
!isAction &&
!isCampaign &&
!isReaction &&
!isPollVote &&
!isRepost &&
@@ -683,6 +680,9 @@ export const NoteCard = memo(function NoteCard({
) : isAction ? (
<ActionContent event={event} />
) : isCampaign ? (
<CampaignNoteCardContent event={event} />
) : isVoiceMessage ? (
<VoiceMessagePlayer event={event} />
) : isCalendarEvent ? (
@@ -776,12 +776,6 @@ export const NoteCard = memo(function NoteCard({
)}
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground min-w-0 pr-2">
{nip05 && nip05Pending && <Skeleton className="h-3 w-24" />}
{nip05 && nip05Pending && <span className="shrink-0">·</span>}
{nip05 && nip05Verified && (
<Nip05Badge nip05={nip05} pubkey={event.pubkey} />
)}
{nip05 && nip05Verified && <span className="shrink-0">·</span>}
<span className="shrink-0 hover:underline whitespace-nowrap">
{timeAgo(event.created_at)}
</span>
@@ -1111,9 +1105,6 @@ export const NoteCard = memo(function NoteCard({
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<CountryFlagBackdrop event={event} />
{/* Foreground wrapper — `relative` lifts the entire post above the
absolute backdrop layer rendered by CountryFlagBackdrop. */}
<div className="relative">
{threadedKindHeader && (
<div>
@@ -1164,9 +1155,6 @@ export const NoteCard = memo(function NoteCard({
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<CountryFlagBackdrop event={event} />
{/* Foreground wrapper — `relative` lifts the entire post above the
absolute backdrop layer rendered by CountryFlagBackdrop. */}
<div className="relative">
<div>
{/* Action header — repost takes priority, otherwise derived from event kind */}
@@ -1206,17 +1194,13 @@ export const NoteCard = memo(function NoteCard({
})()
)}
{/* Header: avatar + name/handle stacked. The country pill is
appended outside this flag-mode wrapper as a flex sibling, so
it keeps its own surface treatment. */}
{/* Header: avatar + name/handle with the country pill anchored
right. The pill is a flex sibling of the author row so it
keeps its own surface treatment regardless of context. */}
<div className="flex items-center gap-3">
{avatarElement}
{authorInfo}
{isColor && <ColorMomentEyeButton event={event} />}
{/* Country pill — rendered outside the flag-mode color flip via
`[&]:` to escape the parent's color rules. It's wrapped in
its own flex slot so the row layout matches the non-flag
case (pill anchored right). */}
<CountryCommentPill
event={event}
className="shrink-0 [text-shadow:none]"
@@ -1814,6 +1798,12 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
noun: "organization",
nounRoute: "/communities",
},
33863: {
icon: HandHeart,
action: (event) => publishedAtAction(event, { created: "launched a", updated: "updated a", fallback: "shared a" }),
noun: "campaign",
nounRoute: "/campaigns/all",
},
30009: {
icon: Award,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }),
+53
View File
@@ -0,0 +1,53 @@
import { Pin } from 'lucide-react';
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface PinnedCommentHeaderProps {
isPinned: boolean;
canManagePins: boolean;
pinPending: boolean;
onTogglePin: () => void;
children?: ReactNode;
}
export function PinnedCommentHeader({
isPinned,
canManagePins,
pinPending,
onTogglePin,
children,
}: PinnedCommentHeaderProps) {
if (!isPinned && !canManagePins && !children) return null;
return (
<div className="flex items-center justify-between gap-3 px-4 pt-3 pb-0 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
{isPinned && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary">
<Pin className="size-3 rotate-45 fill-current" />
Pinned
</span>
)}
{children}
</div>
{canManagePins && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onTogglePin();
}}
disabled={pinPending}
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-medium transition-colors hover:bg-primary/10 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60',
isPinned && 'text-primary',
)}
>
<Pin className={cn('size-3 rotate-45', isPinned && 'fill-current')} />
{isPinned ? 'Unpin' : 'Pin'}
</button>
)}
</div>
);
}
-226
View File
@@ -1,226 +0,0 @@
import { useEffect, useRef, memo } from 'react';
import type { PrecipitationIntensity, PrecipitationType } from '@/hooks/useWeather';
interface PrecipitationEffectProps {
type: PrecipitationType;
intensity: PrecipitationIntensity;
}
// ---------------------------------------------------------------------------
// Particle pool sizes by intensity
// ---------------------------------------------------------------------------
const RAIN_COUNT: Record<PrecipitationIntensity, number> = {
light: 80,
moderate: 160,
heavy: 280,
};
const SNOW_COUNT: Record<PrecipitationIntensity, number> = {
light: 50,
moderate: 100,
heavy: 180,
};
// ---------------------------------------------------------------------------
// Raindrop particle
// ---------------------------------------------------------------------------
interface RainDrop {
x: number;
y: number;
speed: number;
length: number;
opacity: number;
drift: number;
}
function createRainDrop(w: number, h: number, intensity: PrecipitationIntensity): RainDrop {
const speedBase = intensity === 'heavy' ? 14 : intensity === 'moderate' ? 10 : 7;
const speedRange = intensity === 'heavy' ? 8 : intensity === 'moderate' ? 5 : 3;
return {
x: Math.random() * (w + 100) - 50,
y: Math.random() * h * -1 - 20,
speed: speedBase + Math.random() * speedRange,
length: intensity === 'heavy' ? 18 + Math.random() * 12 : 10 + Math.random() * 10,
opacity: 0.15 + Math.random() * 0.2,
drift: intensity === 'heavy' ? 1.5 + Math.random() : 0.5 + Math.random() * 0.8,
};
}
// ---------------------------------------------------------------------------
// Snowflake particle
// ---------------------------------------------------------------------------
interface SnowFlake {
x: number;
y: number;
speed: number;
radius: number;
opacity: number;
wobbleAmp: number;
wobbleFreq: number;
wobblePhase: number;
}
function createSnowFlake(w: number, h: number, intensity: PrecipitationIntensity): SnowFlake {
const sizeBase = intensity === 'heavy' ? 2.5 : intensity === 'moderate' ? 2 : 1.5;
const sizeRange = intensity === 'heavy' ? 3 : intensity === 'moderate' ? 2.5 : 2;
return {
x: Math.random() * (w + 60) - 30,
y: Math.random() * h * -1 - 10,
speed: 0.5 + Math.random() * (intensity === 'heavy' ? 1.8 : intensity === 'moderate' ? 1.2 : 0.8),
radius: sizeBase + Math.random() * sizeRange,
opacity: 0.4 + Math.random() * 0.4,
wobbleAmp: 0.3 + Math.random() * 0.8,
wobbleFreq: 0.01 + Math.random() * 0.02,
wobblePhase: Math.random() * Math.PI * 2,
};
}
// ---------------------------------------------------------------------------
// Canvas precipitation renderer
// ---------------------------------------------------------------------------
export const PrecipitationEffect = memo(function PrecipitationEffect({
type,
intensity,
}: PrecipitationEffectProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rafRef = useRef(0);
const rainDrops = useRef<RainDrop[]>([]);
const snowFlakes = useRef<SnowFlake[]>([]);
const frameRef = useRef(0);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !type) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
function resize() {
if (!canvas) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
resize();
window.addEventListener('resize', resize);
// Initialize particle pools
const w = canvas.width;
const h = canvas.height;
if (type === 'rain') {
const count = RAIN_COUNT[intensity];
rainDrops.current = [];
for (let i = 0; i < count; i++) {
const drop = createRainDrop(w, h, intensity);
// Scatter initial y positions across the screen for instant coverage
drop.y = Math.random() * h;
rainDrops.current.push(drop);
}
} else {
const count = SNOW_COUNT[intensity];
snowFlakes.current = [];
for (let i = 0; i < count; i++) {
const flake = createSnowFlake(w, h, intensity);
flake.y = Math.random() * h;
snowFlakes.current.push(flake);
}
}
function drawRain() {
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const drop of rainDrops.current) {
drop.y += drop.speed;
drop.x += drop.drift;
// Reset when off screen
if (drop.y > canvas.height + 20) {
drop.y = -drop.length - Math.random() * 40;
drop.x = Math.random() * (canvas.width + 100) - 50;
}
if (drop.x > canvas.width + 50) {
drop.x = -50;
}
// Draw the raindrop as a thin line with a subtle glow
ctx.beginPath();
ctx.moveTo(drop.x, drop.y);
ctx.lineTo(drop.x + drop.drift * 0.5, drop.y + drop.length);
ctx.strokeStyle = `rgba(174, 194, 224, ${drop.opacity})`;
ctx.lineWidth = 1.2;
ctx.lineCap = 'round';
ctx.stroke();
}
rafRef.current = requestAnimationFrame(drawRain);
}
function drawSnow() {
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
frameRef.current++;
for (const flake of snowFlakes.current) {
flake.y += flake.speed;
flake.x += Math.sin(frameRef.current * flake.wobbleFreq + flake.wobblePhase) * flake.wobbleAmp;
// Reset when off screen
if (flake.y > canvas.height + 10) {
flake.y = -flake.radius * 2 - Math.random() * 30;
flake.x = Math.random() * (canvas.width + 60) - 30;
}
if (flake.x > canvas.width + 30) {
flake.x = -30;
} else if (flake.x < -30) {
flake.x = canvas.width + 30;
}
// Draw the snowflake as a soft glowing circle
ctx.beginPath();
ctx.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2);
const gradient = ctx.createRadialGradient(
flake.x, flake.y, 0,
flake.x, flake.y, flake.radius,
);
gradient.addColorStop(0, `rgba(255, 255, 255, ${flake.opacity})`);
gradient.addColorStop(0.5, `rgba(230, 238, 255, ${flake.opacity * 0.6})`);
gradient.addColorStop(1, `rgba(210, 225, 250, 0)`);
ctx.fillStyle = gradient;
ctx.fill();
}
rafRef.current = requestAnimationFrame(drawSnow);
}
if (type === 'rain') {
rafRef.current = requestAnimationFrame(drawRain);
} else {
rafRef.current = requestAnimationFrame(drawSnow);
}
return () => {
cancelAnimationFrame(rafRef.current);
window.removeEventListener('resize', resize);
rainDrops.current = [];
snowFlakes.current = [];
};
}, [type, intensity]);
if (!type) return null;
return (
<canvas
ref={canvasRef}
className="pointer-events-none fixed inset-0 z-[100]"
aria-hidden="true"
/>
);
});
-2
View File
@@ -214,8 +214,6 @@ export function ReactionButton({
<span className={cn('tabular-nums', variant === 'chip' ? '' : 'text-sm', hasReacted && 'text-pink-500')}>
{formatNumber(reactionCount)}
</span>
) : variant === 'chip' ? (
<span className="hidden sm:inline">React</span>
) : null}
</button>
</PopoverTrigger>
-3
View File
@@ -13,8 +13,6 @@ import { ComposeBox, type ExternalReplyRoot } from '@/components/ComposeBox';
import { LinkEmbed } from '@/components/LinkEmbed';
import { cn } from '@/lib/utils';
const AGORA_DEFAULT_NOTE_TAGS = [['t', 'agora']];
interface ReplyComposeModalProps {
/** The event being replied to, a URL for commenting on web content, or a NIP-73 identifier (e.g. `bitcoin:tx:...`, `isbn:...`). When `null`, the modal acts as a "New post" composer. */
event?: NostrEvent | ExternalReplyRoot | null;
@@ -162,7 +160,6 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
onHasPreviewableContentChange={setHasPreviewableContent}
initialContent={initialContent}
initialMode={initialMode}
defaultTags={!isReply && !isQuote && initialMode !== 'poll' ? AGORA_DEFAULT_NOTE_TAGS : undefined}
/>
</div>
</PortalContainerProvider>
+2 -2
View File
@@ -1,4 +1,4 @@
import { Quote, Rocket, Undo2 } from 'lucide-react';
import { Megaphone, Quote, Undo2 } from 'lucide-react';
import { RepostIcon } from '@/components/icons/RepostIcon';
import { useState } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -195,7 +195,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
}}
className="flex items-center gap-3 w-full px-4 py-3 text-[15px] text-foreground hover:bg-secondary/60 transition-colors"
>
<Rocket className="size-5" />
<Megaphone className="size-5" />
<span>Boost</span>
</button>
</div>
+50 -5
View File
@@ -44,7 +44,7 @@ type KindOption = {
// ─── Kind options (built once) ───────────────────────────────────────────────
import { buildKindOptions } from '@/lib/feedFilterUtils';
import { buildKindOptions, AGORA_PRESET_KIND_VALUES } from '@/lib/feedFilterUtils';
// ─── useScrollCarets ─────────────────────────────────────────────────────────
@@ -152,7 +152,26 @@ export function KindPicker({ value, options, onChange }: {
);
}, [options, search]);
const selected = value === 'all' || value === 'custom' ? null : options.find((o) => o.value === value);
// Partition into Agora preset (top of picker) and the rest.
// When the user is searching, we skip the partition and show a flat list.
const presetSet = useMemo(() => new Set(AGORA_PRESET_KIND_VALUES), []);
const { presetOptions, otherOptions } = useMemo(() => {
if (search) return { presetOptions: [], otherOptions: filtered };
const preset: KindOption[] = [];
const other: KindOption[] = [];
// Preserve AGORA_PRESET_KIND_VALUES order for the preset section.
const byValue = new Map(filtered.map((o) => [o.value, o]));
for (const v of AGORA_PRESET_KIND_VALUES) {
const opt = byValue.get(v);
if (opt) preset.push(opt);
}
for (const o of filtered) {
if (!presetSet.has(o.value)) other.push(o);
}
return { presetOptions: preset, otherOptions: other };
}, [filtered, presetSet, search]);
const selected = value === 'all' || value === 'agora' || value === 'custom' ? null : options.find((o) => o.value === value);
const SelectedIcon = selected?.icon;
const handleSelect = (v: string) => { onChange(v); setOpen(false); setSearch(''); };
@@ -170,7 +189,13 @@ export function KindPicker({ value, options, onChange }: {
? <SelectedIcon className="size-3.5 shrink-0 text-muted-foreground" />
: <Hash className="size-3.5 shrink-0 text-muted-foreground" />}
<span className="flex-1 truncate">
{value === 'all' ? 'All' : value === 'custom' ? 'Custom...' : (selected?.label ?? value)}
{value === 'all'
? 'All kinds'
: value === 'agora'
? 'Agora content'
: value === 'custom'
? 'Custom...'
: (selected?.label ?? value)}
</span>
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
</button>
@@ -198,8 +223,28 @@ export function KindPicker({ value, options, onChange }: {
</div>
{canScrollUp && <KindScrollCaret direction="up" onMouseEnter={() => startScroll('up')} onMouseLeave={stopScroll} />}
<div ref={refCallback} className="overflow-y-auto flex-1 min-h-0" onScroll={onScroll}>
{!search && <KindPickerItem icon={null} label="All kinds" active={value === 'all'} onClick={() => handleSelect('all')} />}
{filtered.map((opt) => (
{!search && (
<>
<KindPickerItem icon={null} label="Agora content" active={value === 'agora'} onClick={() => handleSelect('agora')} />
<KindPickerItem icon={null} label="All kinds" active={value === 'all'} onClick={() => handleSelect('all')} />
</>
)}
{!search && presetOptions.length > 0 && (
<>
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Agora content
</div>
{presetOptions.map((opt) => (
<KindPickerItem key={opt.value} icon={opt.icon ?? null} label={opt.label} active={value === opt.value} onClick={() => handleSelect(opt.value)} />
))}
{otherOptions.length > 0 && (
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
All kinds
</div>
)}
</>
)}
{otherOptions.map((opt) => (
<KindPickerItem key={opt.value} icon={opt.icon ?? null} label={opt.label} active={value === opt.value} onClick={() => handleSelect(opt.value)} />
))}
{(!search || 'custom'.includes(search.toLowerCase())) && (
+3 -2
View File
@@ -45,6 +45,7 @@ import { useNip05Resolve } from '@/hooks/useNip05Resolve';
import { detectIdentifier, type IdentifierMatch } from '@/lib/nostrIdentifier';
import { isNostrId } from '@/lib/nostrId';
import { notificationSuccess } from '@/lib/haptics';
import { withAgoraTag } from '@/lib/agoraNoteTags';
import {
nostrPubkeyToBitcoinAddress,
validateBitcoinAddress,
@@ -333,12 +334,12 @@ export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDial
await publishEvent({
kind: 8333,
content: '',
tags: [
tags: withAgoraTag([
['i', `bitcoin:tx:${txid}`],
['p', recipient.pubkey],
['amount', String(amountSats)],
['alt', `On-chain zap: ${amountSats.toLocaleString()} sats`],
],
]),
});
} catch (err) {
// The Bitcoin transaction already broadcast — the kind 8333 is a
+24 -7
View File
@@ -1,4 +1,5 @@
import type { NostrEvent } from '@nostrify/nostrify';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { NoteCard } from '@/components/NoteCard';
import { cn } from '@/lib/utils';
@@ -14,17 +15,23 @@ export interface ReplyNode {
}
/** Renders a fully threaded reply tree with collapsible deep branches. */
export function ThreadedReplyList({ roots }: { roots: ReplyNode[] }) {
export function ThreadedReplyList({ roots, renderItemHeader }: { roots: ReplyNode[]; renderItemHeader?: (event: NostrEvent) => ReactNode }) {
return (
<div>
// Drop the trailing border on the last comment in the list — when
// the surrounding page doesn't wrap us in a card, that border
// floats orphaned below the final note. Two selectors are needed
// because the last root may be either a bare <article> (no
// children) or a <div> wrapping an <article> chain. `!important`
// overrides NoteCard's own `border-b border-border` utility.
<div className="[&>article:last-child]:!border-b-transparent [&>div:last-child_article]:!border-b-transparent">
{roots.map((node) => (
<ReplyThread key={node.event.id} node={node} depth={0} />
<ReplyThread key={node.event.id} node={node} depth={0} renderItemHeader={renderItemHeader} />
))}
</div>
);
}
function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: number; depthless?: boolean }) {
function ReplyThread({ node, depth, depthless, renderItemHeader }: { node: ReplyNode; depth: number; depthless?: boolean; renderItemHeader?: (event: NostrEvent) => ReactNode }) {
const [expanded, setExpanded] = useState(false);
const [showHidden, setShowHidden] = useState(false);
const hasChildren = node.children.length > 0;
@@ -34,6 +41,7 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
if (shouldCollapse) {
return (
<div>
{renderItemHeader?.(node.event)}
<NoteCard event={node.event} threaded />
<ExpandThreadButton count={countDescendants(node)} onClick={() => setExpanded(true)} isLast />
</div>
@@ -41,7 +49,12 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
}
if (!hasChildren) {
return <NoteCard event={node.event} />;
return (
<div>
{renderItemHeader?.(node.event)}
<NoteCard event={node.event} />
</div>
);
}
// Once expanded past the depth cap, skip further caps for this subtree
@@ -49,6 +62,7 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
return (
<div>
{renderItemHeader?.(node.event)}
<NoteCard event={node.event} threaded />
{/* Show hidden sibling count between parent and first child */}
{hiddenCount > 0 && !showHidden && (
@@ -56,10 +70,13 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
)}
{/* Revealed hidden siblings render as threaded items before the inline child */}
{showHidden && node.hiddenChildren!.map((child) => (
<NoteCard key={child.event.id} event={child.event} threaded threadedLineClassName="bg-primary/30" />
<div key={child.event.id}>
{renderItemHeader?.(child.event)}
<NoteCard event={child.event} threaded threadedLineClassName="bg-primary/30" />
</div>
))}
{node.children.map((child) => (
<ReplyThread key={child.event.id} node={child} depth={depth + 1} depthless={childDepthless} />
<ReplyThread key={child.event.id} node={child} depth={depth + 1} depthless={childDepthless} renderItemHeader={renderItemHeader} />
))}
</div>
);
+19 -3
View File
@@ -1,5 +1,5 @@
import { useState, type ComponentType } from 'react';
import { Link, NavLink } from 'react-router-dom';
import { Link, NavLink, useNavigate } from 'react-router-dom';
import {
Activity,
Bell,
@@ -33,8 +33,9 @@ interface NavItem {
}
const NAV_ITEMS: NavItem[] = [
{ label: 'Discover', to: '/discover', icon: HandHeart },
{ label: 'Organize', to: '/communities', icon: Users },
{ label: 'Activity', to: '/feed', icon: Activity },
{ label: 'Campaigns', to: '/campaigns/all', icon: HandHeart },
{ label: 'Groups', to: '/communities', icon: Users },
{ label: 'Pledge', to: '/pledges', icon: Megaphone },
];
@@ -53,6 +54,9 @@ export function TopNav() {
const { user } = useCurrentUser();
const { orderedItems } = useFeedSettings();
const [mobileOpen, setMobileOpen] = useState(false);
const navigate = useNavigate();
const goToSearch = () => navigate('/search');
return (
<header className="sticky top-0 z-40 w-full border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/70">
@@ -88,6 +92,18 @@ export function TopNav() {
{/* Right cluster */}
<div className="flex items-center gap-2 sm:gap-3">
{/* Search — navigates to the /search page. Visible on all
breakpoints so users can always reach search from the chrome. */}
<button
type="button"
onClick={goToSearch}
className="shrink-0 size-9 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="Search"
title="Search"
>
<Search className="size-5" />
</button>
{/* LoginArea handles both logged-in (account avatar dropdown) and
logged-out (Log in / Sign up) states. We render it inline-flex
and let it style its own children. */}
@@ -0,0 +1,38 @@
import { cn } from '@/lib/utils';
interface CommunityGridProps {
children: React.ReactNode;
/** Extra classes on the grid container. */
className?: string;
}
/**
* Responsive grid container for community/organization cards on the
* `/communities` page. Replaces the previous horizontal-scroll shelves so
* organizations wrap onto multiple rows instead of disappearing off the
* right edge.
*
* Column counts are tuned to the page's `max-w-5xl` (~1024px) content
* column so each cell ends up close to the legacy 256px `CommunityMiniCard`
* width at the `lg` breakpoint:
* - <640px: 1 column
* - sm 640+: 2 columns
* - md 768+: 3 columns
* - lg 1024+: 4 columns
*
* Cards passed in should be `w-full` so they fill their grid cell — the
* default `w-64` on `CommunityMiniCard` can be overridden with
* `className="w-full"` thanks to `tailwind-merge`.
*/
export function CommunityGrid({ children, className }: CommunityGridProps) {
return (
<div
className={cn(
'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 px-4 sm:px-6',
className,
)}
>
{children}
</div>
);
}
@@ -2,6 +2,7 @@ import { Link } from 'react-router-dom';
import { Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { CommunityModerationOverlay } from '@/components/CommunityModerationMenu';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuthor } from '@/hooks/useAuthor';
@@ -29,6 +30,13 @@ interface CommunityMiniCardProps {
* - founder avatar + display name in a muted row.
*
* Kept narrow enough to fit ~4 cards across a desktop content column.
*
* Moderators (Team Soapbox pack members) see a kebab menu overlaid on the
* banner exposing the Feature / Hide actions plus a Hidden badge when the
* org is currently hidden. Non-moderators see no overlay — the whole
* moderation pipeline (including the heavy `useOrganizationModeration`
* query) is bypassed for them so grids of dozens of cards don't fan out
* a per-card cache subscription on every viewer.
*/
export function CommunityMiniCard({ community, className }: CommunityMiniCardProps) {
const founder = useAuthor(community.founderPubkey);
@@ -67,6 +75,11 @@ export function CommunityMiniCard({ community, className }: CommunityMiniCardPro
<Users className="size-10 text-primary/40" />
</div>
)}
{/* Moderator overlay (Hidden badge + kebab). Renders `null` for
non-moderators, which is why this component owns the
`useOrganizationModeration` subscription rather than the
card — keeps non-mod grids free of the heavy label query. */}
<CommunityModerationOverlay coord={community.aTag} organizationName={community.name} />
</div>
<div className="flex flex-col gap-2 p-3.5 flex-1">
<h3 className="font-semibold leading-tight text-sm tracking-tight line-clamp-1">
-355
View File
@@ -1,355 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Globe2, HandHeart, PlusCircle, Users } from 'lucide-react';
import { HeroGlobe, type GlobeMarkerKind } from '@/components/HeroGlobe';
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useCampaigns } from '@/hooks/useCampaigns';
import { useGlobalActivity } from '@/hooks/useGlobalActivity';
import { useGlobalDonations } from '@/hooks/useGlobalDonations';
import { useDiscoverCommunities } from '@/hooks/useDiscoverCommunities';
import { HOPE_PALETTE } from '@/lib/hopePalette';
import { searchCountry } from '@/lib/countries';
import { getCoordinates } from '@/lib/coordinates';
import { formatSatsShort } from '@/lib/formatCampaignAmount';
import { cn } from '@/lib/utils';
interface DiscoverHeroProps {
className?: string;
}
interface GlobeMarker {
key: string;
lat: number;
lng: number;
label: string;
kind: GlobeMarkerKind;
}
interface TickerStat {
/** Stable React key. */
id: string;
/** Big number / value text. */
value: string;
/** Trailing label that describes what the number is. */
label: string;
/** Decorative leading icon. */
icon: React.ReactNode;
}
/** Country code → lat/lng. Used to seed country-pulse markers. */
function lookupCountryCoords(code: string) {
const coords = getCoordinates(code);
return coords ? { lat: coords.latitude, lng: coords.longitude } : null;
}
/**
* Discover-page hero. The same hand-drawn `HeroGlobe` that anchors the
* fundraising home page (`/`), but reframed: the globe is the
* protagonist, three marker types sit on it at once — campaigns
* (hearts), communities (rings), and country-pulse (warm dots) — and a
* rotating stat ticker headlines what the network has done.
*
* Visual chrome:
* - Slow hue drift through `HOPE_PALETTE` every ~8s (the page literally
* pulses with hope).
* - `HeroAtmosphere` carries the warm scrim + radial glow + sunrise rim,
* same component the campaigns hero uses for crossfade.
* - No background photo — Discover isn't selling any one campaign, so
* the sphere reads against a soft secondary wash instead.
*/
export function DiscoverHero({ className }: DiscoverHeroProps) {
// ─── Data ──────────────────────────────────────────────────────────────
const { data: campaigns } = useCampaigns({ limit: 60 });
const { data: communities } = useDiscoverCommunities({ limit: 60 });
const { data: activityByCountry } = useGlobalActivity();
const { data: donations, isLoading: donationsLoading } = useGlobalDonations();
// ─── Globe markers ─────────────────────────────────────────────────────
// Layer three pin types. We dedupe primarily by country so the globe
// never piles dozens of markers on top of each other — the goal is a
// sparse, hopeful constellation, not a heatmap. Hearts win over rings
// win over dots when the same country shows up in multiple sources.
const markers = useMemo<GlobeMarker[]>(() => {
const out: GlobeMarker[] = [];
const claimedCountries = new Set<string>();
// 1. Campaigns → hearts. Newest first; cap at 18 so they don't crowd.
let heartCount = 0;
for (const c of campaigns ?? []) {
if (heartCount >= 18) break;
if (!c.location) continue;
const match = searchCountry(c.location);
if (!match) continue;
if (claimedCountries.has(match.country.code)) continue;
const coords = getCoordinates(match.country.code);
if (!coords) continue;
claimedCountries.add(match.country.code);
out.push({
key: `campaign:${c.aTag}`,
lat: coords.latitude,
lng: coords.longitude,
label: c.title,
kind: 'campaign',
});
heartCount++;
}
// 2. Country-pulse dots — the trusted-stats country activity, sized
// implicitly by the marker glyph. Cap at 28 so the back of the globe
// doesn't bristle when it rotates into view.
let pulseCount = 0;
if (activityByCountry) {
const sortedCodes = [...activityByCountry.entries()]
.sort((a, b) => b[1] - a[1])
.map(([code]) => code);
for (const code of sortedCodes) {
if (pulseCount >= 28) break;
if (claimedCountries.has(code)) continue;
const coords = lookupCountryCoords(code);
if (!coords) continue;
claimedCountries.add(code);
out.push({
key: `pulse:${code}`,
lat: coords.lat,
lng: coords.lng,
label: `Active in ${code}`,
kind: 'country-pulse',
});
pulseCount++;
}
}
// 3. Community rings — only when we can geolocate one of the
// moderators. Communities don't carry a location tag of their own,
// so we use a small heuristic: spread the first N communities across
// continents by scattering them on a stable hash. Keeps the layer
// present without inventing coordinates we can't justify.
//
// To keep ourselves honest we cap this at 6 rings and never overwrite
// a country that already has a campaign heart or pulse dot. If we
// genuinely can't place any, we skip the layer.
const scatter: Array<{ lat: number; lng: number }> = [
{ lat: 40.7, lng: -74.0 }, // Americas
{ lat: -23.5, lng: -46.6 }, // S. America
{ lat: 51.5, lng: -0.1 }, // Europe
{ lat: -1.3, lng: 36.8 }, // Africa
{ lat: 35.7, lng: 139.7 }, // E. Asia
{ lat: -33.9, lng: 151.2 }, // Oceania
];
let ringCount = 0;
for (const community of communities ?? []) {
if (ringCount >= scatter.length) break;
const slot = scatter[ringCount];
out.push({
key: `community:${community.aTag}`,
lat: slot.lat,
lng: slot.lng,
label: community.name,
kind: 'community',
});
ringCount++;
}
return out;
}, [campaigns, communities, activityByCountry]);
// ─── Hue drift ─────────────────────────────────────────────────────────
// Cycle through the hopeful palette on a slow ~9s interval. We seed
// HeroAtmosphere with a stable string per cycle so its crossfade logic
// kicks in correctly between hues.
const [hueIndex, setHueIndex] = useState(0);
useEffect(() => {
const id = window.setInterval(() => {
setHueIndex((i) => (i + 1) % HOPE_PALETTE.length);
}, 9_000);
return () => window.clearInterval(id);
}, []);
const activeHue = HOPE_PALETTE[hueIndex];
const atmosphereSeed = `discover-hue-${activeHue.name}`;
// ─── Stat ticker ───────────────────────────────────────────────────────
// Three rotating, immutable network-wide stats. We compute them
// defensively — when the underlying query is still loading we surface
// a small skeleton inside the ticker row instead of "0" so the page
// doesn't lie about the network's scale.
const stats = useMemo<TickerStat[]>(() => {
const items: TickerStat[] = [];
if (donations && donations.totalSats > 0) {
items.push({
id: 'sats',
value: formatSatsShort(donations.totalSats),
label: `raised on-chain across ${donations.campaignCount.toLocaleString()} ${
donations.campaignCount === 1 ? 'campaign' : 'campaigns'
}`,
icon: <HandHeart className="size-5" aria-hidden />,
});
}
if (communities && communities.length > 0) {
items.push({
id: 'communities',
value: communities.length.toLocaleString(),
label: `${communities.length === 1 ? 'organization' : 'organizations'} gathering on Nostr`,
icon: <Users className="size-5" aria-hidden />,
});
}
if (activityByCountry && activityByCountry.size > 0) {
items.push({
id: 'countries',
value: activityByCountry.size.toLocaleString(),
label: `${activityByCountry.size === 1 ? 'country' : 'countries'} posting today`,
icon: <Globe2 className="size-5" aria-hidden />,
});
}
return items;
}, [donations, communities, activityByCountry]);
// Auto-advance the ticker. Holds at the first slot until at least one
// stat is known so the visitor doesn't see an empty pill.
const [tickerIndex, setTickerIndex] = useState(0);
useEffect(() => {
if (stats.length <= 1) return;
const id = window.setInterval(() => {
setTickerIndex((i) => (i + 1) % stats.length);
}, 4_000);
return () => window.clearInterval(id);
}, [stats.length]);
const currentStat = stats[tickerIndex % Math.max(stats.length, 1)];
return (
<section
className={cn(
'relative overflow-hidden border-b border-border bg-secondary/30',
className,
)}
>
{/* Atmosphere — same scrim + radial glow + sunrise rim used on
`/`. Seeded by the active hue so the whole hero blooms together
when the palette advances. */}
<HeroAtmosphere seed={atmosphereSeed} />
{/* Globe — centered, dominant. Slight upward bias so the headline
beneath has breathing room. */}
<div className="absolute inset-0 pointer-events-none flex items-center justify-center">
<div className="pointer-events-auto opacity-90">
<HeroGlobe
markers={markers}
hue={activeHue}
className="aspect-square max-w-none drop-shadow-2xl"
style={{ width: 'clamp(440px, 62dvw, 720px)' }}
/>
</div>
</div>
{/* Readability scrim. Sits above the globe + atmosphere but below
the foreground content so the headline / paragraph stay legible
regardless of which hue the palette is currently cycling
through. Top-down so the eye-line lands on the darkest pixels;
we taper to transparent before the ticker pill so the CTAs and
stat row underneath keep their warm wash. */}
<div
className="absolute inset-x-0 top-0 h-72 sm:h-80 pointer-events-none bg-gradient-to-b from-black/55 via-black/25 to-transparent"
aria-hidden="true"
/>
{/* Foreground content — headline above the sphere, ticker + CTAs
below it. Uses the same `max-w-5xl` container as the rest of
the Discover page so the hero never sprawls wider than the
shelves beneath it. */}
<div className="relative max-w-5xl mx-auto px-4 sm:px-6 py-12 sm:py-16 lg:py-20 min-h-[560px] sm:min-h-[640px] lg:min-h-[680px] flex flex-col items-center text-center">
<div className="relative space-y-3 max-w-3xl">
<p className="text-xs sm:text-sm font-semibold uppercase tracking-[0.18em] text-white/80 drop-shadow">
Discover
</p>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.05] text-white drop-shadow-[0_2px_12px_rgb(0_0_0/0.55)]">
The world,
<br className="sm:hidden" /> gathering.
</h1>
<p className="text-base sm:text-lg text-white/85 max-w-2xl mx-auto drop-shadow-[0_1px_6px_rgb(0_0_0/0.5)]">
Campaigns, communities, and conversations from every corner of the
globe. Backed by Bitcoin, broadcast on Nostr, owned by no one.
</p>
</div>
{/* Spacer so the next block lands beneath the sphere. */}
<div className="flex-1 min-h-[180px] sm:min-h-[220px]" aria-hidden="true" />
{/* Rotating stat ticker. The fixed min-height stops the layout
from jumping as labels swap; the keyed inner span re-mounts on
every change to trigger the fade-in transition. */}
<div
className="relative w-full max-w-md mx-auto rounded-full bg-background/55 backdrop-blur-xl backdrop-saturate-150 border border-white/20 dark:border-white/10 px-5 py-3 shadow-lg shadow-amber-500/10"
aria-live="polite"
>
{currentStat ? (
<div
key={currentStat.id}
className="flex items-center justify-center gap-3 motion-safe:animate-in motion-safe:fade-in motion-safe:duration-500"
>
<span className="text-primary shrink-0">{currentStat.icon}</span>
<span className="text-sm sm:text-base font-semibold tracking-tight">
{currentStat.value}
</span>
<span className="text-xs sm:text-sm text-muted-foreground line-clamp-1">
{currentStat.label}
</span>
</div>
) : (
<div className="flex items-center justify-center gap-3">
{donationsLoading ? (
<>
<Skeleton className="size-5 rounded-full" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-3 w-32" />
</>
) : (
<span className="text-xs text-muted-foreground">
Connecting to relays
</span>
)}
</div>
)}
</div>
{/* CTAs — clean glass pills, same vocabulary as `/`. Two clear
actions: start something (campaign creation), or browse the
world map for inspiration. */}
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
<Button
size="lg"
asChild
className={cn(
'relative rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px]',
'bg-gradient-to-br from-white/14 via-amber-100/10 to-rose-100/10 hover:from-white/20 hover:via-amber-100/14 hover:to-rose-100/14',
'backdrop-blur-xl backdrop-saturate-150',
'border border-white/25 hover:border-white/35',
'shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08),0_10px_28px_-12px_hsl(24_85%_45%/0.4)]',
'hover:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.12),0_12px_32px_-10px_hsl(24_85%_45%/0.5)]',
'motion-safe:transition-colors motion-safe:duration-200',
)}
>
<Link to="/campaigns/new">
<PlusCircle className="mr-2" />
Start a campaign
</Link>
</Button>
<Button
variant="outline"
size="lg"
asChild
className="rounded-full bg-background/60 backdrop-blur h-12 px-6 text-base"
>
<Link to="/world">
<Globe2 className="size-4 mr-2" />
Browse the world
</Link>
</Button>
</div>
</div>
</section>
);
}
-2
View File
@@ -208,8 +208,6 @@ export interface AppConfig {
clientName?: string;
/** NIP-19 `naddr1…` identifying this client's kind 31990 handler event. Decoded at publish time to produce the `31990:<pubkey>:<d-tag>` addr and relay hint for the "client" tag per NIP-89. */
client?: string;
/** Enable Magic Mouse mode: cursor/finger emanates magical fire in the primary color */
magicMouse: boolean;
/** Current theme */
theme: Theme;
/** Custom theme config (colors, fonts, background). Only used when theme === "custom". */
+74 -27
View File
@@ -10,16 +10,16 @@ import { isEventMuted } from '@/lib/muteHelpers';
const AGORA_PAGE_SIZE = 25;
const PLEDGE_KIND = 36639;
const COMMUNITY_KIND = 34550;
const POLL_KIND = 1068;
const COMMENT_KIND = 1111;
const NOTE_KIND = 1;
const ONCHAIN_ZAP_KIND = 8333;
const LIGHTNING_ZAP_KIND = 9735;
const AGORA_ENTITY_KINDS = [CAMPAIGN_KIND, PLEDGE_KIND, ONCHAIN_ZAP_KIND];
const COMMENT_ROOT_KINDS = [String(CAMPAIGN_KIND), String(PLEDGE_KIND)];
const AGORA_ENTITY_KINDS = [CAMPAIGN_KIND, PLEDGE_KIND, COMMUNITY_KIND, ONCHAIN_ZAP_KIND];
const COMMENT_ROOT_KINDS = [String(CAMPAIGN_KIND), String(PLEDGE_KIND), String(COMMUNITY_KIND)];
const WORLD_K_TAGS = ['iso3166', 'geo'];
const PLEDGE_T_ALIASES = ['agora-action', 'pathos-challenge', 'agora-challenge'];
const AGORA_T_TAGS = ['agora', 'Agora'];
const IGNORED_AGORA_NOTE_AUTHORS = new Set([
'4fe14ef28934b4093d71d43a8c9e9ec42ab4243febfff38470bfef05f51992ec',
@@ -40,26 +40,47 @@ function hasTagValue(event: NostrEvent, name: string, values: readonly string[])
return tagValues(event, name).some((value) => accepted.has(value.toLowerCase()));
}
function hasAgoraTag(event: NostrEvent): boolean {
return hasTagValue(event, 't', AGORA_T_TAGS);
}
function isWorldComment(event: NostrEvent): boolean {
return event.kind === COMMENT_KIND && hasTagValue(event, 'k', WORLD_K_TAGS);
}
function isWorldPoll(event: NostrEvent): boolean {
return event.kind === POLL_KIND && hasTagValue(event, 'k', WORLD_K_TAGS);
}
/**
* Strict Agora filter — accepts an event only if it is genuinely Agora-created
* content (carries the `t:agora` marker) OR is a world-layer event (country-
* rooted comment / poll), which is intentionally surfaced cross-client.
*
* See `src/lib/agoraNoteTags.ts` and `NIP.md` (§ Agora Content Marker).
*/
function isRelevantAgoraEvent(event: NostrEvent): boolean {
if (shouldHideFeedEvent(event)) return false;
if (event.kind === CAMPAIGN_KIND) return true;
// World-layer posts are kept regardless of the Agora marker.
if (isWorldComment(event) || isWorldPoll(event)) return true;
if (event.kind === PLEDGE_KIND) {
return hasTagValue(event, 't', PLEDGE_T_ALIASES);
}
// Everything else must carry the Agora content marker.
if (!hasAgoraTag(event)) return false;
if (event.kind === CAMPAIGN_KIND) return true;
if (event.kind === COMMUNITY_KIND) return true;
if (event.kind === PLEDGE_KIND) return true;
if (event.kind === COMMENT_KIND) {
return hasTagValue(event, 'K', COMMENT_ROOT_KINDS) || hasTagValue(event, 'k', WORLD_K_TAGS);
}
if (event.kind === POLL_KIND) {
return hasTagValue(event, 'k', WORLD_K_TAGS);
// Comment must reference an Agora entity root (campaign / pledge / community).
return hasTagValue(event, 'K', COMMENT_ROOT_KINDS)
|| tagValues(event, 'A').some((value) => value.startsWith(`${COMMUNITY_KIND}:`));
}
if (event.kind === NOTE_KIND) {
if (IGNORED_AGORA_NOTE_AUTHORS.has(event.pubkey)) return false;
return hasTagValue(event, 't', AGORA_T_TAGS);
return true; // already verified `t:agora` above
}
if (event.kind === ONCHAIN_ZAP_KIND || event.kind === LIGHTNING_ZAP_KIND) {
@@ -71,7 +92,7 @@ function isRelevantAgoraEvent(event: NostrEvent): boolean {
function isAgoraAddress(value: string): boolean {
const kind = value.split(':')[0];
return kind === String(CAMPAIGN_KIND) || kind === String(PLEDGE_KIND);
return kind === String(CAMPAIGN_KIND) || kind === String(PLEDGE_KIND) || kind === String(COMMUNITY_KIND);
}
function getAddressableCoordinate(event: NostrEvent): string | undefined {
@@ -102,35 +123,61 @@ function extractDonationTargets(events: NostrEvent[]): { coordinates: string[];
};
}
/** Mixed Agora activity feed: campaigns, pledges, world posts, #Agora notes, and donation receipts. */
export function useAgoraFeed(enabled: boolean) {
export interface UseAgoraFeedOptions {
/**
* Restrict the feed to events authored by these pubkeys. Applied as an
* `authors:` filter on every relay query (server-side filtering). Empty
* array disables the query — used for "Following" mode when the user
* follows nobody.
*/
authors?: string[];
}
/** Strict Agora activity feed: campaigns, pledges, communities, world posts, #Agora notes, and donations. */
export function useAgoraFeed(enabled: boolean, options?: UseAgoraFeedOptions) {
const { nostr } = useNostr();
const { muteItems } = useMuteList();
const { shouldFilterEvent } = useContentFilters();
const authors = options?.authors;
const authorsKey = authors ? [...authors].sort().join(',') : '';
// If `authors` is provided but empty, the feed is intentionally empty
// (e.g. the user follows nobody) — skip the query entirely.
const authorsEmpty = authors !== undefined && authors.length === 0;
const queryEnabled = enabled && !authorsEmpty;
const query = useInfiniteQuery<AgoraFeedPage, Error>({
queryKey: ['agora-feed'],
queryKey: ['agora-feed', authorsKey],
queryFn: async ({ pageParam, signal: querySignal }) => {
const signal = AbortSignal.any([querySignal, AbortSignal.timeout(8_000)]);
const until = pageParam as number | undefined;
const authorsFilter = authors && authors.length > 0 ? { authors } : {};
const filters: NostrFilter[] = [
{ kinds: AGORA_ENTITY_KINDS, limit: AGORA_PAGE_SIZE, ...(until && { until }) },
{ kinds: [COMMENT_KIND], '#K': COMMENT_ROOT_KINDS, limit: AGORA_PAGE_SIZE, ...(until && { until }) },
{ kinds: [COMMENT_KIND, POLL_KIND], '#k': WORLD_K_TAGS, limit: AGORA_PAGE_SIZE, ...(until && { until }) },
{ kinds: [NOTE_KIND], '#t': AGORA_T_TAGS, limit: Math.ceil(AGORA_PAGE_SIZE / 2), ...(until && { until }) },
// Agora entity kinds — strict `t:agora` required.
{ kinds: AGORA_ENTITY_KINDS, '#t': AGORA_T_TAGS, limit: AGORA_PAGE_SIZE, ...authorsFilter, ...(until && { until }) },
// Comments on Agora entities — strict `t:agora` required.
{ kinds: [COMMENT_KIND], '#t': AGORA_T_TAGS, '#K': COMMENT_ROOT_KINDS, limit: AGORA_PAGE_SIZE, ...authorsFilter, ...(until && { until }) },
// World layer — country/geo-rooted comments and polls. Intentionally
// cross-client; the `#k=iso3166|geo` filter is the entire gate.
{ kinds: [COMMENT_KIND, POLL_KIND], '#k': WORLD_K_TAGS, limit: AGORA_PAGE_SIZE, ...authorsFilter, ...(until && { until }) },
// `#Agora`-tagged kind 1 notes — accepts any author opting in via the tag.
{ kinds: [NOTE_KIND], '#t': AGORA_T_TAGS, limit: Math.ceil(AGORA_PAGE_SIZE / 2), ...authorsFilter, ...(until && { until }) },
];
const raw = await nostr.query(filters, { signal });
const filtered = raw.filter(isRelevantAgoraEvent);
const { coordinates, eventIds } = extractDonationTargets(filtered);
// Donation enrichment: pull lightning + onchain zaps that reference
// the Agora entities visible on this page. Donation events must also
// carry the Agora marker to be included (per `isRelevantAgoraEvent`).
const donationFilters: NostrFilter[] = [];
if (coordinates.length > 0) {
donationFilters.push({ kinds: [LIGHTNING_ZAP_KIND, ONCHAIN_ZAP_KIND], '#a': coordinates, limit: coordinates.length * 10 });
donationFilters.push({ kinds: [LIGHTNING_ZAP_KIND, ONCHAIN_ZAP_KIND], '#t': AGORA_T_TAGS, '#a': coordinates, limit: coordinates.length * 10 });
}
if (eventIds.length > 0) {
donationFilters.push({ kinds: [LIGHTNING_ZAP_KIND, ONCHAIN_ZAP_KIND], '#e': eventIds, limit: eventIds.length * 10 });
donationFilters.push({ kinds: [LIGHTNING_ZAP_KIND, ONCHAIN_ZAP_KIND], '#t': AGORA_T_TAGS, '#e': eventIds, limit: eventIds.length * 10 });
}
const donationEvents = donationFilters.length > 0
@@ -141,7 +188,7 @@ export function useAgoraFeed(enabled: boolean) {
const combined = [
...filtered,
// Donation enrichment is already scoped by exact #a/#e targets from this page.
...donationEvents.filter((event) => !shouldHideFeedEvent(event)),
...donationEvents.filter((event) => !shouldHideFeedEvent(event) && hasAgoraTag(event)),
]
.filter((event) => {
if (seen.has(event.id)) return false;
@@ -166,7 +213,7 @@ export function useAgoraFeed(enabled: boolean) {
if (lastPage.totalFetched < AGORA_PAGE_SIZE || !lastPage.oldestTimestamp) return undefined;
return lastPage.oldestTimestamp - 1;
},
enabled,
enabled: queryEnabled,
staleTime: 30_000,
placeholderData: (prev) => prev,
});
@@ -183,9 +230,9 @@ export function useAgoraFeed(enabled: boolean) {
return {
events,
isLoading: query.isPending,
isLoading: queryEnabled ? query.isPending : false,
isFetchingNextPage: query.isFetchingNextPage,
hasNextPage: query.hasNextPage,
hasNextPage: !authorsEmpty && query.hasNextPage,
fetchNextPage: query.fetchNextPage,
pageCount: query.data?.pages.length,
};
+3 -7
View File
@@ -30,7 +30,7 @@ interface CampaignScore {
const EMPTY_SCORE: CampaignScore = { totalSats: 0, donorCount: 0 };
/**
* Loads kind 30223 campaigns with optional Top ranking (most-zapped first)
* Loads kind 33863 campaigns with optional Top ranking (most-zapped first)
* and free-text search applied client-side.
*
* **Why client-side rather than NIP-50?** Ditto's `sort:top` / `sort:hot`
@@ -46,7 +46,7 @@ const EMPTY_SCORE: CampaignScore = { totalSats: 0, donorCount: 0 };
* - **Full relay coverage** — we fetch from the user's default pool, not
* just Ditto, so campaigns published anywhere are discoverable.
* - **Search that actually matches** — substring across title, summary,
* story, location, and category tags.
* and story.
*
* Tradeoff: we fetch up to `limit` (default 200) campaigns regardless of
* search, then filter in JavaScript. At current campaign volume this is
@@ -70,7 +70,7 @@ export function useAllCampaigns({
[{ kinds: [CAMPAIGN_KIND], limit }],
{ signal: AbortSignal.any([c.signal, AbortSignal.timeout(10_000)]) },
);
return parseCampaignEvents(events, { includeArchived: false, sortByCreatedAt: true });
return parseCampaignEvents(events, { sortByCreatedAt: true });
},
staleTime: 30_000,
});
@@ -185,9 +185,5 @@ function matchesQuery(campaign: ParsedCampaign, lowerQuery: string): boolean {
if (campaign.title.toLowerCase().includes(lowerQuery)) return true;
if (campaign.summary.toLowerCase().includes(lowerQuery)) return true;
if (campaign.story.toLowerCase().includes(lowerQuery)) return true;
// Location and `t` tags are short but worth matching so users can type
// "kenya" or "mutual aid" and get useful results.
if (campaign.location?.toLowerCase().includes(lowerQuery)) return true;
if (campaign.tags.some((t) => t.toLowerCase().includes(lowerQuery))) return true;
return false;
}
-82
View File
@@ -1,82 +0,0 @@
import { useNostr } from '@nostrify/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import {
CAMPAIGN_KIND,
parseCampaign,
type ParsedCampaign,
} from '@/lib/campaign';
interface ArchiveCampaignArgs {
campaign: ParsedCampaign;
/** `true` to mark archived, `false` to reopen by removing the status tag. */
archived: boolean;
}
/**
* Archive (or reopen) a fundraising campaign without deleting it.
*
* Archiving republishes the campaign with `["status", "archived"]` so the
* UI can hide it from the main fundraisers feed while still loading it by
* direct link. Past donations remain intact because the addressable
* coordinate (kind, pubkey, d) is unchanged.
*
* Unarchive removes the status tag (or any other status value), bringing
* the campaign back into the main list.
*
* Only the campaign author can archive — the relay would reject anyone
* else's republish under the same coordinate, but we also guard at the
* UI layer.
*/
export function useArchiveCampaign() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ campaign, archived }: ArchiveCampaignArgs) => {
if (!user) throw new Error('You must be logged in.');
if (user.pubkey !== campaign.pubkey) {
throw new Error('Only the campaign author can change its status.');
}
// Read-modify-write: never trust the local cache for addressable mutations.
const prev = await fetchFreshEvent(nostr, {
kinds: [CAMPAIGN_KIND],
authors: [user.pubkey],
'#d': [campaign.identifier],
});
if (!prev || !parseCampaign(prev)) {
throw new Error('Could not load the latest version of this campaign.');
}
// Carry over every tag except any existing `status` tag, which we own here.
const nextTags = prev.tags.filter(([name]) => name !== 'status');
if (archived) nextTags.push(['status', 'archived']);
const published = await publishEvent({
kind: CAMPAIGN_KIND,
content: prev.content,
tags: nextTags,
prev,
});
const parsed = parseCampaign(published);
if (!parsed) {
throw new Error('Updated campaign failed validation.');
}
return parsed;
},
onSuccess: (campaign) => {
void queryClient.invalidateQueries({
queryKey: ['campaign', campaign.pubkey, campaign.identifier],
});
void queryClient.invalidateQueries({ queryKey: ['campaigns'] });
void queryClient.invalidateQueries({ queryKey: ['campaign-featured'] });
},
});
}
+108 -51
View File
@@ -1,76 +1,133 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useQueries, useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useAppContext } from '@/hooks/useAppContext';
import { verifyOnchainZap, extractOnchainZapTxid, type OnchainZapEntry } from '@/hooks/useOnchainZaps';
import type { ParsedCampaign } from '@/lib/campaign';
export interface CampaignDonationStats {
/** Total satoshis pledged across all kind 8333 receipts (self-reported sum). */
/** Total satoshis pledged across all verified kind 8333 receipts. */
totalSats: number;
/** Number of unique on-chain transactions counted. */
txCount: number;
/** Number of unique donor pubkeys. */
donorCount: number;
/** All kind 8333 receipts for the campaign, newest first. */
/** All raw kind 8333 receipts for the campaign, newest first. */
receipts: NostrEvent[];
/** Verified entries (one per unique txid). */
verified: OnchainZapEntry[];
/**
* True while underlying verification queries are still in flight.
* Callers may use this to defer rendering "0 sats raised" until
* the verifier has had a chance to validate the receipts.
*/
isVerifying: boolean;
}
const EMPTY_RECEIPTS: NostrEvent[] = [];
/**
* Aggregates donation receipts (kind 8333 events) for a campaign by its
* addressable coordinate.
* Aggregates donation receipts (kind 8333 events) for a campaign and
* **verifies each one on-chain** before counting it toward the campaign
* total.
*
* Each kind 8333 event's `amount` tag is the total sats paid to the
* recipients listed in that event (see `NIP.md`). New donations publish a
* single event per tx covering every recipient; legacy donations published
* one event per recipient. In either case, summing `amount` across all
* events that tag the campaign yields the campaign's total — the legacy
* per-recipient amounts sum to the full donation, and the new per-tx
* amount IS the full donation.
* Per NIP.md §Kind 33863, each receipt:
*
* The returned `totalSats` is **self-reported**. Per the NIP.md spec a
* strict client would verify each receipt against the on-chain transaction
* before counting it; that's left to a future iteration (see TODO inline).
* - Targets the campaign via an `a` tag (`33863:<pubkey>:<d>`).
* - Carries an `i bitcoin:tx:<txid>` tag.
* - Carries an `amount <sats>` tag (self-reported, capped at verified).
* - Carries **no `p` tags** — campaigns are not Nostr-identity recipients.
*
* Verification re-fetches the tx from the configured Esplora endpoint and
* sums the outputs paying the campaign's `w` address. The self-reported
* `amount` is capped at the verified amount.
*
* Silent-payment campaigns (`w` starts with `sp1…`) short-circuit to
* zeros — donations to SP campaigns are unlinkable by design and clients
* MUST NOT publish receipts.
*/
export function useCampaignDonations(aTag: string | undefined) {
export function useCampaignDonations(campaign: ParsedCampaign | undefined): {
data: CampaignDonationStats;
isLoading: boolean;
} {
const { nostr } = useNostr();
const { config } = useAppContext();
const { esploraApis } = config;
return useQuery({
queryKey: ['campaign-donations', aTag ?? ''],
queryFn: async (c): Promise<CampaignDonationStats> => {
if (!aTag) {
return { totalSats: 0, txCount: 0, donorCount: 0, receipts: [] };
}
const aTag = campaign?.aTag;
const wallet = campaign?.wallet;
const isSilentPayment = wallet?.mode === 'sp';
// Step 1: fetch raw receipts. Disabled for SP campaigns.
const receiptsQuery = useQuery({
queryKey: ['campaign-donations', 'events', aTag ?? ''],
queryFn: async ({ signal }): Promise<NostrEvent[]> => {
if (!aTag) return EMPTY_RECEIPTS;
const events = await nostr.query(
[{ kinds: [8333], '#a': [aTag], limit: 500 }],
{ signal: c.signal },
{ signal },
);
let totalSats = 0;
const txids = new Set<string>();
const donors = new Set<string>();
for (const event of events) {
const txid = event.tags.find(([n]) => n === 'i')?.[1]?.replace(/^bitcoin:tx:/, '');
const amountTag = event.tags.find(([n]) => n === 'amount')?.[1];
const amount = amountTag ? Number(amountTag) : NaN;
if (!txid || !Number.isFinite(amount) || amount <= 0) continue;
totalSats += amount;
txids.add(txid);
donors.add(event.pubkey);
}
// TODO: verify each txid against mempool.space and sum only the outputs
// that pay listed recipients' derived Taproot addresses. Until then the
// total is best-effort and trivially spoofable.
const receipts = [...events].sort((a, b) => b.created_at - a.created_at);
return {
totalSats,
txCount: txids.size,
donorCount: donors.size,
receipts,
};
return events;
},
enabled: !!aTag,
enabled: !!aTag && !isSilentPayment,
staleTime: 15_000,
});
// Dedupe by txid; prefer the earliest receipt per tx (first to claim).
const receipts = receiptsQuery.data ?? EMPTY_RECEIPTS;
const dedupedByTxid = (() => {
const byTxid = new Map<string, NostrEvent>();
for (const event of receipts) {
const txid = extractOnchainZapTxid(event);
if (!txid) continue;
const existing = byTxid.get(txid);
if (!existing || event.created_at < existing.created_at) {
byTxid.set(txid, event);
}
}
return Array.from(byTxid.values());
})();
// Step 2: verify each unique-txid receipt against the campaign's `w`
// wallet address. SP campaigns are short-circuited above so the
// wallet here is always `onchain` mode when present.
const walletValue = wallet?.value;
const verifications = useQueries({
queries: dedupedByTxid.map((event) => ({
queryKey: ['onchain-zaps', 'verify', esploraApis, event.id, walletValue ?? ''],
queryFn: ({ signal }: { signal: AbortSignal }) =>
verifyOnchainZap(event, esploraApis, walletValue, signal),
staleTime: 60_000,
enabled: !!walletValue && !isSilentPayment,
})),
});
const verified: OnchainZapEntry[] = verifications
.map((v) => v.data)
.filter((v): v is OnchainZapEntry => !!v);
const totalSats = verified.reduce((sum, v) => sum + v.amountSats, 0);
const txids = new Set<string>();
const donors = new Set<string>();
for (const v of verified) {
txids.add(v.txid);
donors.add(v.senderPubkey);
}
const sortedReceipts = [...receipts].sort((a, b) => b.created_at - a.created_at);
const isVerifying = !isSilentPayment && (receiptsQuery.isLoading || verifications.some((v) => v.isLoading));
return {
data: {
totalSats,
txCount: txids.size,
donorCount: donors.size,
receipts: sortedReceipts,
verified,
isVerifying,
},
isLoading: isVerifying,
};
}
+23 -121
View File
@@ -1,130 +1,31 @@
import { useNostr } from '@nostrify/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostrPublish } from './useNostrPublish';
import { useCampaignModerators } from './useCampaignModerators';
import { CAMPAIGN_KIND } from '@/lib/campaign';
import {
AGORA_MODERATION_NAMESPACE,
EMPTY_MODERATION_DATA,
LABEL_KIND,
type ModerationData,
type ModerationLabel,
type ModerationState,
foldModerationLabels,
} from '@/lib/agoraModeration';
/** NIP-32 label kind. */
const LABEL_KIND = 1985;
/** Label namespace for Agora's moderation labels. */
export const AGORA_MODERATION_NAMESPACE = 'agora.moderation';
/** The six possible label values in the moderation namespace. */
export type ModerationLabel =
| 'approved'
| 'unapproved'
| 'hidden'
| 'unhidden'
| 'featured'
| 'unfeatured';
/** A single label event narrowed to its decision axis. */
interface AxisDecision {
/** Latest label observed on this axis. */
label: ModerationLabel;
/** Author of the latest label. */
pubkey: string;
/** Created-at of the latest label. */
createdAt: number;
}
// Re-exports for existing import sites. The namespace constant and the
// `ModerationLabel` type are imported from this module by the campaign
// moderation menu and other surfaces; keep those exports stable so the
// shared-module refactor stays a no-op for callers.
export { AGORA_MODERATION_NAMESPACE };
export type { ModerationLabel };
/** Per-campaign rollup of approval + hide + featured state. */
export interface CampaignModerationState {
approval?: AxisDecision; // `approved` or `unapproved`
hide?: AxisDecision; // `hidden` or `unhidden`
featured?: AxisDecision; // `featured` or `unfeatured`
}
export type CampaignModerationState = ModerationState;
export interface CampaignModerationData {
/** Map of `30223:<pubkey>:<d>` -> rollup. */
byCoord: Map<string, CampaignModerationState>;
/** Coordinates where the latest approval label is `approved`. */
approvedCoords: Set<string>;
/** Coordinates where the latest hide label is `hidden`. */
hiddenCoords: Set<string>;
/** Coordinates where the latest featured label is `featured`. */
featuredCoords: Set<string>;
/**
* Map of `coord` -> `created_at` of the latest `featured` label.
* Used to sort the home-page featured row newest-first.
*/
featuredOrder: Map<string, number>;
/** Pubkeys that were considered moderators when the query ran. */
moderators: string[];
}
const EMPTY_DATA: CampaignModerationData = {
byCoord: new Map(),
approvedCoords: new Set(),
hiddenCoords: new Set(),
featuredCoords: new Set(),
featuredOrder: new Map(),
moderators: [],
};
/** True if a label value belongs to the approval axis. */
function isApprovalLabel(value: string): value is 'approved' | 'unapproved' {
return value === 'approved' || value === 'unapproved';
}
/** True if a label value belongs to the hide axis. */
function isHideLabel(value: string): value is 'hidden' | 'unhidden' {
return value === 'hidden' || value === 'unhidden';
}
/** True if a label value belongs to the featured axis. */
function isFeaturedLabel(value: string): value is 'featured' | 'unfeatured' {
return value === 'featured' || value === 'unfeatured';
}
/**
* Fold a flat list of label events into per-coordinate rollups by axis.
* The newest event per `(coord, axis)` wins. Events not addressing a
* campaign coordinate or carrying a value outside the namespace are dropped.
*/
function foldLabelEvents(events: NostrEvent[], moderators: string[]): CampaignModerationData {
const byCoord = new Map<string, CampaignModerationState>();
for (const event of events) {
const value = event.tags.find(([n, , ns]) => n === 'l' && ns === AGORA_MODERATION_NAMESPACE)?.[1];
if (!value) continue;
const aTag = event.tags.find(([n, v]) => n === 'a' && typeof v === 'string' && v.startsWith(`${CAMPAIGN_KIND}:`))?.[1];
if (!aTag) continue;
const state = byCoord.get(aTag) ?? {};
if (isApprovalLabel(value)) {
if (!state.approval || event.created_at > state.approval.createdAt) {
state.approval = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
}
} else if (isHideLabel(value)) {
if (!state.hide || event.created_at > state.hide.createdAt) {
state.hide = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
}
} else if (isFeaturedLabel(value)) {
if (!state.featured || event.created_at > state.featured.createdAt) {
state.featured = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
}
}
byCoord.set(aTag, state);
}
const approvedCoords = new Set<string>();
const hiddenCoords = new Set<string>();
const featuredCoords = new Set<string>();
const featuredOrder = new Map<string, number>();
for (const [coord, state] of byCoord) {
if (state.approval?.label === 'approved') approvedCoords.add(coord);
if (state.hide?.label === 'hidden') hiddenCoords.add(coord);
if (state.featured?.label === 'featured') {
featuredCoords.add(coord);
featuredOrder.set(coord, state.featured.createdAt);
}
}
return { byCoord, approvedCoords, hiddenCoords, featuredCoords, featuredOrder, moderators };
}
/** Surface-scoped alias so existing callers keep working. */
export type CampaignModerationData = ModerationData;
/**
* Fetches and folds campaign-moderation label events authored by Team
@@ -154,7 +55,8 @@ export function useCampaignModeration() {
// an empty `authors:` filter (which would return everything matching the
// namespace from any author and break our trust model — see AGENTS.md).
// Once moderators arrives empty, the query runs and immediately resolves
// to EMPTY_DATA — no rendering can promote a campaign without a moderator.
// to EMPTY_MODERATION_DATA — no rendering can promote a campaign without
// a moderator.
const moderatorsKey = moderators ? [...moderators].sort().join(',') : '';
const moderationQuery = useQuery({
@@ -162,7 +64,7 @@ export function useCampaignModeration() {
enabled: moderators !== undefined,
queryFn: async ({ signal }): Promise<CampaignModerationData> => {
if (!moderators || moderators.length === 0) {
return { ...EMPTY_DATA, moderators: [] };
return { ...EMPTY_MODERATION_DATA, moderators: [] };
}
const events = await nostr.query(
[
@@ -179,7 +81,7 @@ export function useCampaignModeration() {
],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
);
return foldLabelEvents(events, moderators);
return foldModerationLabels(events, moderators, CAMPAIGN_KIND);
},
staleTime: 30_000,
});
@@ -207,7 +109,7 @@ export function useCampaignModeration() {
});
return {
data: moderationQuery.data ?? EMPTY_DATA,
data: moderationQuery.data ?? EMPTY_MODERATION_DATA,
isPending: moderationQuery.isPending,
isLoading: moderationQuery.isLoading,
isReady: moderationQuery.isSuccess,
+13 -40
View File
@@ -2,16 +2,10 @@ import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { CAMPAIGN_KIND, type CampaignCategory, parseCampaign, type ParsedCampaign } from '@/lib/campaign';
import { CAMPAIGN_KIND, parseCampaign, type ParsedCampaign } from '@/lib/campaign';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
interface ParseCampaignEventsOptions {
/**
* Include campaigns whose latest revision carries `["status", "archived"]`.
* Defaults to `false` so archived campaigns never appear in the main
* fundraisers listing.
*/
includeArchived?: boolean;
/**
* When `true`, sort the parsed campaigns newest-`created_at`-first.
* When `false`, preserve the order in which events were returned —
@@ -23,7 +17,7 @@ interface ParseCampaignEventsOptions {
}
/**
* Deduplicate, parse, and (optionally) reorder a flat list of kind 30223
* Deduplicate, parse, and (optionally) reorder a flat list of kind 33863
* events into `ParsedCampaign` objects.
*
* For each `(pubkey, d)` pair we keep only the latest event — relays may
@@ -35,7 +29,7 @@ interface ParseCampaignEventsOptions {
*/
export function parseCampaignEvents(
events: NostrEvent[],
{ includeArchived = false, sortByCreatedAt = true }: ParseCampaignEventsOptions = {},
{ sortByCreatedAt = true }: ParseCampaignEventsOptions = {},
): ParsedCampaign[] {
// Track insertion order keyed by coord so we can preserve relay-scored
// order when we don't want to re-sort. `Map` iteration order is insertion
@@ -62,7 +56,6 @@ export function parseCampaignEvents(
if (!event) continue;
const campaign = parseCampaign(event);
if (!campaign) continue;
if (!includeArchived && campaign.archived) continue;
parsed.push(campaign);
}
@@ -74,8 +67,6 @@ export function parseCampaignEvents(
}
interface UseCampaignsOptions {
/** Optional category filter (`t` tag). */
category?: CampaignCategory;
/** Optional ISO 3166-1 alpha-2 country filter (`i` tag). */
countryCode?: string;
/** Maximum number of events to fetch from relays. Default: 60. */
@@ -83,12 +74,7 @@ interface UseCampaignsOptions {
/** Authors to fetch from, e.g. for a profile's campaigns. */
authors?: string[];
/**
* Restrict to campaigns whose recipient `p` tags include any of these
* pubkeys. Used by the /claim page to find campaigns set up *for* a user.
*/
recipientPubkeys?: string[];
/**
* Restrict to a specific set of `30223:<pubkey>:<d>` coordinates.
* Restrict to a specific set of `33863:<pubkey>:<d>` coordinates.
*
* Used by moderator-curated surfaces (the home page, Discover) that only
* want to render campaigns labeled `approved` by a Team Soapbox moderator
@@ -102,24 +88,19 @@ interface UseCampaignsOptions {
* the unfiltered behavior while their moderator query loads.
*/
coordinates?: string[];
/**
* Include campaigns that have been archived by their creator
* (`["status", "archived"]`). Defaults to `false` so archived
* campaigns never appear in the main fundraisers listing.
*/
includeArchived?: boolean;
}
/**
* Loads kind 30223 campaign events and returns them as fully-parsed
* Loads kind 33863 campaign events and returns them as fully-parsed
* {@link ParsedCampaign} objects, newest first.
*
* Campaigns that fail validation (missing title, no recipients, etc.) are
* Campaigns that fail validation (missing title, no `w` wallet, etc.) are
* dropped so the UI never has to defensively check for missing fields.
*
* Archived campaigns (`status=archived`) are excluded by default. Pass
* `includeArchived: true` to load them — used by the author's own profile
* view so they can see and reopen their own archives.
* To stop a campaign from appearing the creator publishes a NIP-09 kind 5
* deletion request referencing the campaign's `a` coordinate; well-behaved
* relays honor the deletion and the campaign drops out of result sets
* automatically.
*
* For each `(pubkey, d)` pair we keep only the latest event — relays may
* return older revisions of an addressable event alongside the current one.
@@ -127,13 +108,10 @@ interface UseCampaignsOptions {
export function useCampaigns(options: UseCampaignsOptions = {}) {
const { nostr } = useNostr();
const {
category,
countryCode,
limit = 60,
authors,
recipientPubkeys,
coordinates,
includeArchived = false,
} = options;
// Stable cache key for the coordinates option; sort so order doesn't
@@ -143,7 +121,7 @@ export function useCampaigns(options: UseCampaignsOptions = {}) {
return useQuery({
queryKey: [
'campaigns',
{ category, countryCode, limit, authors, recipientPubkeys, coordinatesKey, includeArchived },
{ countryCode, limit, authors, coordinatesKey },
],
queryFn: async (c) => {
// Sentinel: empty allowlist = empty result. Skip the relay entirely.
@@ -156,7 +134,7 @@ export function useCampaigns(options: UseCampaignsOptions = {}) {
if (coordinates && coordinates.length > 0) {
const byAuthor = new Map<string, string[]>();
for (const coord of coordinates) {
// Expected: `30223:<pubkey>:<d>`
// Expected: `33863:<pubkey>:<d>`
const parts = coord.split(':');
if (parts.length < 3) continue;
const kindPart = Number(parts[0]);
@@ -171,23 +149,18 @@ export function useCampaigns(options: UseCampaignsOptions = {}) {
if (byAuthor.size === 0) return [] as ParsedCampaign[];
filters = Array.from(byAuthor, ([author, dTags]) => {
const f: NostrFilter = { kinds: [CAMPAIGN_KIND], authors: [author], '#d': dTags };
if (category) f['#t'] = [category];
if (countryCode) f['#i'] = [createCountryIdentifier(countryCode)];
return f;
});
} else {
const filter: NostrFilter = { kinds: [CAMPAIGN_KIND], limit };
if (category) filter['#t'] = [category];
if (countryCode) filter['#i'] = [createCountryIdentifier(countryCode)];
if (authors && authors.length > 0) filter.authors = authors;
if (recipientPubkeys && recipientPubkeys.length > 0) {
filter['#p'] = recipientPubkeys;
}
filters = [filter];
}
const events = await nostr.query(filters, { signal: c.signal });
return parseCampaignEvents(events, { includeArchived, sortByCreatedAt: true });
return parseCampaignEvents(events, { sortByCreatedAt: true });
},
staleTime: 30_000,
});
+68
View File
@@ -0,0 +1,68 @@
import { useCallback, useState } from 'react';
import { useAppContext } from '@/hooks/useAppContext';
import { getCountryInfo } from '@/lib/countries';
import { getStorageKey } from '@/lib/storageKey';
/**
* Sentinel value for "post to the global / world feed" (a plain kind 1 note
* with no country root). Matches the value used by ComposeBox's `destination`
* state.
*/
export type PostCountryDestination = 'world' | string;
const WORLD = 'world' as const;
const STORAGE_SUFFIX = 'compose-default-country';
function readStored(key: string): PostCountryDestination {
try {
const raw = localStorage.getItem(key);
if (!raw || raw === WORLD) return WORLD;
// Validate against the country directory so a stale code (or an
// invalid string from a different version) doesn't pin the composer
// to a country that no longer parses.
return getCountryInfo(raw) ? raw : WORLD;
} catch {
return WORLD;
}
}
/**
* The user's preferred default post destination — either `'world'` (plain
* kind 1) or an ISO 3166 country code (NIP-22 country-rooted kind 1111).
*
* Persisted to localStorage so the choice survives reloads. Hydrates
* synchronously from storage on first render, so the composer never flashes
* the wrong default.
*
* The act of selecting a destination in ComposeBox does NOT auto-save the
* default — there is an explicit "Set as default" affordance for that. This
* means a user posting once to a country they don't normally post to does
* not unintentionally change their default.
*/
export function useDefaultPostCountry(): [
PostCountryDestination,
(value: PostCountryDestination) => void,
] {
const { config } = useAppContext();
const key = getStorageKey(config.appId, STORAGE_SUFFIX);
const [value, setValue] = useState<PostCountryDestination>(() => readStored(key));
const setDefault = useCallback(
(next: PostCountryDestination) => {
setValue(next);
try {
if (next === WORLD) {
localStorage.setItem(key, WORLD);
} else if (getCountryInfo(next)) {
localStorage.setItem(key, next);
}
} catch {
// localStorage unavailable — non-critical.
}
},
[key],
);
return [value, setDefault];
}
-148
View File
@@ -1,148 +0,0 @@
import { useNostr } from '@nostrify/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { CAMPAIGN_KIND } from '@/lib/campaign';
import { shouldHideFeedEvent } from '@/lib/feedUtils';
const DISCOVER_PAGE_SIZE = 30;
/**
* Kinds surfaced in the mixed Discover feed:
*
* - **30223** — new campaign creations (addressable). When a campaign is
* minted or revised it bubbles back to the top, the same way a new
* Substack post would.
* - **1111** — NIP-22 comments. We pull two slices: comments scoped to
* countries (`#K = iso3166`) and comments scoped to communities
* (`#K = 34550`). Together these are "posts from the world" + "voices
* inside the communities".
* - **36639** — Agora pledges (challenges / civic calls). Always
* included because they're the most action-oriented funding signal.
*
* We deliberately *exclude* free-form kind 1 notes here — the Discover
* page is the place to see content that's tagged to a real-world thread
* (country, community, campaign), not the global text-note firehose. The
* old plain feed still lives at `/feed`.
*/
/** Tag scopes we accept on kind 1111 comments. */
const COMMENT_K_SCOPES = ['iso3166', 'geo', '34550'];
/** Aliases we accept on kind 36639 pledge `t` tags. */
const ACTION_T_ALIASES = ['agora-action', 'pathos-challenge', 'agora-challenge'];
/**
* Apply Discover-specific filtering after relay fetch. Drops events that
* `shouldHideFeedEvent` flags (mutes, content filters happen later) and
* any 1111 comment that lacks a recognised scope tag, since relays may
* over-return when we union filters.
*/
function filterDiscoverEvents(events: NostrEvent[]): NostrEvent[] {
return events
.filter((event) => {
if (shouldHideFeedEvent(event)) return false;
if (event.kind === 1111) {
const kTags = event.tags
.filter(([n]) => n === 'k' || n === 'K')
.map(([, v]) => v);
return kTags.some((v) => COMMENT_K_SCOPES.includes(v));
}
return true;
})
.sort((a, b) => b.created_at - a.created_at);
}
/**
* Public infinite feed for the Discover page. Streams together new
* campaigns, world-tagged comments, community comments, and Agora
* pledges, paginated by `created_at` cursor.
*
* Each page issues exactly one relay request (the union of all relevant
* filters) to stay inside per-page rate budgets — the same pattern
* `useWorldFeed` uses.
*
* Returns the standard `useInfiniteQuery` surface plus a flattened
* `events` list for convenient consumption.
*/
export function useDiscoverFeed(enabled = true) {
const { nostr } = useNostr();
const query = useInfiniteQuery({
queryKey: ['discover-feed'],
queryFn: async ({ pageParam, signal: querySignal }) => {
const signal = AbortSignal.any([querySignal, AbortSignal.timeout(8_000)]);
const until = pageParam as number | undefined;
const filters: NostrFilter[] = [
// New / revised campaigns — addressable, so we lean on a small
// limit and let the relay's natural newest-first ordering surface
// recent edits. No `#k` scoping needed.
{
kinds: [CAMPAIGN_KIND],
limit: Math.floor(DISCOVER_PAGE_SIZE / 3),
...(until && { until }),
},
// Community + country-scoped comments.
{
kinds: [1111],
'#K': COMMENT_K_SCOPES,
limit: DISCOVER_PAGE_SIZE,
...(until && { until }),
},
// Agora pledges.
{
kinds: [36639],
'#t': ACTION_T_ALIASES,
limit: Math.floor(DISCOVER_PAGE_SIZE / 3),
...(until && { until }),
},
];
const raw = await nostr.query(filters, { signal });
const filtered = filterDiscoverEvents(raw);
const page = filtered.slice(0, DISCOVER_PAGE_SIZE);
const oldestTimestamp = page.length > 0
? page[page.length - 1].created_at
: null;
return {
events: page,
oldestTimestamp,
totalFetched: filtered.length,
};
},
initialPageParam: undefined as number | undefined,
getNextPageParam: (lastPage) => {
if (lastPage.totalFetched < DISCOVER_PAGE_SIZE || !lastPage.oldestTimestamp) {
return undefined;
}
return lastPage.oldestTimestamp - 1;
},
enabled,
staleTime: 30_000,
placeholderData: (prev) => prev,
});
// Flatten + dedupe. Each addressable event may legitimately appear
// across pages if a newer revision lands; we keep the newest version.
const seen = new Set<string>();
const events: NostrEvent[] = [];
for (const page of query.data?.pages ?? []) {
for (const event of page.events) {
if (seen.has(event.id)) continue;
seen.add(event.id);
events.push(event);
}
}
return {
events,
isLoading: query.isPending,
isFetchingNextPage: query.isFetchingNextPage,
hasNextPage: query.hasNextPage,
fetchNextPage: query.fetchNextPage,
pageCount: query.data?.pages.length,
};
}
+49 -61
View File
@@ -5,25 +5,25 @@ import { isSignerCapabilityError, reportSignerUnsupported, useBitcoinSigner } fr
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import {
BITCOIN_DUST_LIMIT,
broadcastTransaction,
buildUnsignedMultiOutputPsbt,
buildUnsignedPsbt,
fetchUTXOs,
finalizePsbt,
getFeeRates,
nostrPubkeyToBitcoinAddress,
} from '@/lib/bitcoin';
import type { FeeRates } from '@/lib/bitcoin';
import { minDonationForSplit, type ParsedCampaign, splitDonation } from '@/lib/campaign';
import { CAMPAIGN_KIND, type ParsedCampaign } from '@/lib/campaign';
import { withAgoraTag } from '@/lib/agoraNoteTags';
/** Supported on-chain fee speeds (mirrors {@link SendBitcoinDialog}). */
export type DonationFeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
export interface DonateCampaignArgs {
campaign: ParsedCampaign;
/** Total donation amount in satoshis. Split across recipients per the campaign weights. */
/** Donation amount in satoshis. */
amountSats: number;
/** Optional public comment included in each kind 8333 receipt. */
/** Optional public comment included in the kind 8333 receipt. */
comment?: string;
/** Fee speed for the on-chain tx. Default: `halfHour`. */
feeSpeed?: DonationFeeSpeed;
@@ -34,9 +34,7 @@ export interface DonateCampaignResult {
txid: string;
/** On-chain fee paid in satoshis. */
fee: number;
/** Number of recipients that received funds in the tx. */
recipientCount: number;
/** Total sent to recipients (donation amount; excludes fee). */
/** Sats paid to the campaign wallet (excludes fee and donor change). */
totalSats: number;
/** Whether the kind 8333 donation receipt published successfully. */
receiptPublished: boolean;
@@ -58,17 +56,29 @@ function errorMessage(error: unknown): string {
}
/**
* Mutation hook that sends a single multi-output Bitcoin transaction to all
* of a campaign's recipients (split per their weights), broadcasts it via
* mempool.space, and then publishes a single kind 8333 onchain-zap receipt
* for the transaction referencing the campaign's addressable coordinate and
* listing every recipient under its own `p` tag.
* Mutation hook that donates to a campaign by paying its declared wallet
* endpoint with a single Bitcoin transaction, then publishing a kind 8333
* donation receipt referencing the campaign's addressable coordinate.
*
* Returns an async function that throws on any pre-broadcast failure
* (insufficient funds, signer not available, dust, etc.). Once the tx is
* broadcast, the function always resolves: a kind 8333 publish failure is
* reported in {@link DonateCampaignResult.receiptPublished} rather than
* thrown, because the donation itself is already final on-chain.
* The campaign's `w` tag drives the destination:
*
* - **on-chain** (`bc1q…` / `bc1p…`) — the donor's client builds a
* single-output PSBT paying the campaign address, broadcasts it, then
* publishes a kind 8333 receipt with no `p` tags (campaigns are not
* Nostr-identity recipients; verification matches tx outputs against
* the campaign's `w` address).
* - **silent payment** (`sp1…`) — this hook refuses the request.
* Donating to a silent-payment campaign requires a BIP-352-aware
* wallet that derives a fresh one-time output from the SP code; the
* in-app Taproot signer does not support that. Donors are directed to
* an external wallet via a copy/QR affordance instead, and no Nostr
* event is ever published.
*
* Throws on any pre-broadcast failure (insufficient funds, signer not
* available, SP mode, etc.). Once the tx is broadcast, the function
* always resolves: a kind 8333 publish failure is reported in
* {@link DonateCampaignResult.receiptPublished} rather than thrown,
* because the donation itself is already final on-chain.
*/
export function useDonateCampaign() {
const { user } = useCurrentUser();
@@ -92,30 +102,21 @@ export function useDonateCampaign() {
throw new Error('Enter a valid donation amount in satoshis.');
}
// Split the donation across the campaign's payable recipients.
const splits = splitDonation(campaign.recipients, amountSats, user.pubkey);
// Dust guard: every output must clear the BIP-141 dust limit for P2TR.
const tooSmall = splits.find((s) => s.amountSats < BITCOIN_DUST_LIMIT);
if (tooSmall) {
const min = minDonationForSplit(campaign.recipients, user.pubkey, BITCOIN_DUST_LIMIT);
if (campaign.wallet.mode === 'sp') {
throw new Error(
`Donation is too small to split: each recipient would get less than the dust limit (${BITCOIN_DUST_LIMIT} sats). Minimum: ${min.toLocaleString()} sats.`,
'This campaign uses silent payments. Donate from an external BIP-352-capable wallet using the QR code.',
);
}
// Build the multi-output PSBT.
// Donor cannot donate to their own campaign (the tx output would just
// pay the donor's own wallet — an obvious foot-gun).
if (campaign.pubkey === user.pubkey) {
throw new Error('You cannot donate to your own campaign.');
}
const senderAddress = nostrPubkeyToBitcoinAddress(user.pubkey);
if (!senderAddress) throw new Error('Failed to derive your Bitcoin address.');
const outputs = splits.map((s) => {
const address = nostrPubkeyToBitcoinAddress(s.pubkey);
if (!address) {
throw new Error(`Failed to derive Bitcoin address for ${s.pubkey.slice(0, 8)}`);
}
return { address, amountSats: s.amountSats };
});
const [utxos, rates] = await Promise.all([fetchUTXOs(senderAddress, esploraApis), getFeeRates(esploraApis)]);
if (utxos.length === 0) {
throw new Error('Your Bitcoin wallet has no spendable funds.');
@@ -124,9 +125,10 @@ export function useDonateCampaign() {
let signedHex: string;
let fee: number;
try {
const unsigned = buildUnsignedMultiOutputPsbt(
const unsigned = buildUnsignedPsbt(
user.pubkey,
outputs,
campaign.wallet.value,
amountSats,
utxos,
feeRateForSpeed(rates, feeSpeed),
);
@@ -142,36 +144,23 @@ export function useDonateCampaign() {
const txHex = finalizePsbt(signedHex);
const txid = await broadcastTransaction(txHex, esploraApis);
// Publish a single kind 8333 receipt covering the whole transaction. The
// event lists every recipient under its own `p` tag; the `amount` tag is
// the combined total paid to all recipients (i.e. the full donation,
// excluding the donor's change). Per-recipient amounts are recomputed
// from the on-chain tx at display time by matching each recipient's
// derived Taproot address against the tx outputs.
//
// The on-chain tx is already final at this point; we record a publish
// failure rather than throwing so the donor sees a successful result
// even if the relay hiccups.
const totalSats = splits.reduce((sum, s) => sum + s.amountSats, 0);
// Publish the kind 8333 receipt. Per NIP.md §Kind 33863 §Donation flow,
// campaign donation receipts MUST NOT carry `p` tags — the recipient is
// the campaign's `w` wallet, not a Nostr identity. Viewers verify by
// matching tx outputs against the campaign's `w` address.
let receiptPublished = false;
let receiptPublishError: string | undefined;
try {
await publishEvent({
kind: 8333,
content: comment,
tags: [
tags: withAgoraTag([
['i', `bitcoin:tx:${txid}`],
...splits.map((s) => ['p', s.pubkey]),
['amount', String(totalSats)],
['amount', String(amountSats)],
['a', campaign.aTag],
['K', String(campaign.event.kind)],
[
'alt',
splits.length === 1
? `Donation to ${campaign.title}: ${totalSats.toLocaleString()} sats`
: `Donation to ${campaign.title}: ${totalSats.toLocaleString()} sats across ${splits.length} recipients`,
],
],
['K', String(CAMPAIGN_KIND)],
['alt', `Donation to ${campaign.title}: ${amountSats.toLocaleString()} sats`],
]),
});
receiptPublished = true;
} catch (error) {
@@ -188,8 +177,7 @@ export function useDonateCampaign() {
return {
txid,
fee,
recipientCount: splits.length,
totalSats,
totalSats: amountSats,
receiptPublished,
receiptPublishError,
};
+99 -28
View File
@@ -1,7 +1,8 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
import {
COMMUNITY_DEFINITION_KIND,
parseCommunityEvent,
@@ -9,56 +10,126 @@ import {
} from '@/lib/communityUtils';
import { dedupeAddressableLatest } from '@/lib/addressableEvents';
/**
* Hand-curated list of featured organization authors.
*
* We query all organizations authored by these accounts in one relay request,
* then latest-wins dedupe addressable revisions client-side.
*/
export const FEATURED_ORGANIZATION_AUTHORS = [
'932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
] as const;
export interface FeaturedOrganization {
community: ParsedCommunity;
event: NostrEvent;
}
/**
* Fetch featured organizations by author.
* Parse a kind 34550 coordinate string (`34550:<pubkey>:<d>`) into its
* pubkey and d-tag components. Returns `null` for malformed coords so the
* caller can skip them without crashing the query.
*/
function parseCoord(coord: string): { pubkey: string; dTag: string } | null {
const colon1 = coord.indexOf(':');
if (colon1 < 0) return null;
const colon2 = coord.indexOf(':', colon1 + 1);
if (colon2 < 0) return null;
const pubkey = coord.slice(colon1 + 1, colon2);
const dTag = coord.slice(colon2 + 1);
if (!pubkey || !dTag) return null;
return { pubkey, dTag };
}
/**
* Fetch the featured organizations selected by Agora moderators.
*
* One author-filtered query is more reliable than pinning individual event IDs
* because kind 34550 definitions are addressable and can be revised.
* Featured selection rides the shared `agora.moderation` namespace (kind
* 1985 NIP-32 labels): a moderator publishes a `featured` label tagging
* the organization's `34550:<pubkey>:<d>` coordinate, and the home/communities
* page surfaces it here. A `hidden` label on the same coord always wins —
* even if `featured` is set, a hidden org never reaches this list.
*
* Coords are grouped by author into one filter per unique author, then OR'd
* into a single `nostr.query` call. For the typical case (a small handful
* of featured orgs across a few authors) this stays one relay round-trip.
* Results are sorted by the `created_at` of the latest `featured` label so
* moderators control ordering by simply re-publishing the label to bump an
* org to the top.
*/
export function useFeaturedOrganizations() {
const { nostr } = useNostr();
const { data: moderation, isReady: moderationReady } = useOrganizationModeration();
// Derive the curated coord set: featured minus hidden, sorted by the
// recency of the `featured` label. No cap — the moderator pack controls
// how many orgs surface.
const featuredCoords = moderationReady
? Array.from(moderation.featuredCoords)
.filter((coord) => !moderation.hiddenCoords.has(coord))
.sort(
(a, b) =>
(moderation.featuredOrder.get(b) ?? 0) -
(moderation.featuredOrder.get(a) ?? 0),
)
: [];
// Include the coord set in the query key so the cache busts whenever the
// curation changes (mutations invalidate `featured-organizations` too, so
// there's no one-tick gap).
const featuredCoordsKey = featuredCoords.join('|');
return useQuery<FeaturedOrganization[]>({
queryKey: ['featured-organizations', FEATURED_ORGANIZATION_AUTHORS.join(',')],
queryKey: ['featured-organizations', featuredCoordsKey],
enabled: moderationReady,
queryFn: async ({ signal }) => {
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
if (featuredCoords.length === 0) return [];
const events = await nostr.query(
[{
// Group coord d-tags by author so we can issue one filter per author
// instead of one per coord. Most featured orgs cluster around a few
// founders, so this typically collapses to a single-digit number of
// filters in one round-trip.
const dTagsByAuthor = new Map<string, string[]>();
for (const coord of featuredCoords) {
const parsed = parseCoord(coord);
if (!parsed) continue;
const bucket = dTagsByAuthor.get(parsed.pubkey);
if (bucket) {
bucket.push(parsed.dTag);
} else {
dTagsByAuthor.set(parsed.pubkey, [parsed.dTag]);
}
}
if (dTagsByAuthor.size === 0) return [];
const filters: NostrFilter[] = Array.from(dTagsByAuthor.entries()).map(
([pubkey, dTags]) => ({
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [...FEATURED_ORGANIZATION_AUTHORS],
limit: 60,
}],
{ signal: combinedSignal },
authors: [pubkey],
'#d': dTags,
}),
);
const entries: FeaturedOrganization[] = [];
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
const events = await nostr.query(filters, { signal: combinedSignal });
// Latest-wins dedupe of addressable revisions, then index by coord so
// we can return them in the moderator-controlled `featuredOrder`.
const byCoord = new Map<string, FeaturedOrganization>();
for (const event of dedupeAddressableLatest(events)) {
const community = parseCommunityEvent(event);
if (!community) continue;
entries.push({ community, event });
byCoord.set(community.aTag, { community, event });
}
entries.sort((a, b) => b.event.created_at - a.event.created_at);
return entries;
// Preserve the moderator's chosen ordering by walking `featuredCoords`
// (already sorted newest-label-first) and emitting entries in that
// order. Drops coords whose underlying 34550 event we couldn't fetch
// (e.g. it was deleted or never reached the queried relays).
const ordered: FeaturedOrganization[] = [];
for (const coord of featuredCoords) {
const entry = byCoord.get(coord);
if (entry) ordered.push(entry);
}
return ordered;
},
// 5 minutes — featured list is hand-curated, doesn't churn.
// Featured org definitions don't change often — orgs publish a
// new revision when their banner or description changes, not minute
// to minute — so a generous staleTime makes back-navigation to
// /communities feel instant. The moderation hook explicitly
// invalidates this key on mutation, so moderator-driven churn is
// still visible immediately.
staleTime: 5 * 60_000,
gcTime: 60 * 60_000,
});
}
+5 -6
View File
@@ -5,8 +5,7 @@ import { getStorageKey } from '@/lib/storageKey';
/**
* Manages the active feed tab for a specific feed page, persisting
* the selection in sessionStorage so it survives navigation within
* the same browser session.
* the selection in localStorage so it survives reloads and new sessions.
*
* Each feed page should pass a unique `feedId` (e.g. 'home', 'vines', 'videos').
*
@@ -24,17 +23,17 @@ export function useFeedTab<T extends string = string>(
const [activeTab, setActiveTab] = useState<T>(() => {
const defaultTab = (user ? 'follows' : 'world') as T;
try {
const stored = sessionStorage.getItem(key);
const stored = localStorage.getItem(key);
if (stored) {
if (feedId === 'home' && stored === 'network') {
sessionStorage.setItem(key, defaultTab);
localStorage.setItem(key, defaultTab);
return defaultTab;
}
if (!validTabs || validTabs.includes(stored as T)) {
return stored as T;
}
}
} catch { /* sessionStorage unavailable */ }
} catch { /* localStorage unavailable */ }
// Validate the default tab against validTabs. If it's not in the list,
// fall back to the last valid tab (typically 'global').
if (validTabs && !validTabs.includes(defaultTab)) {
@@ -45,7 +44,7 @@ export function useFeedTab<T extends string = string>(
const setTab = useCallback((tab: T) => {
setActiveTab(tab);
try { sessionStorage.setItem(key, tab); } catch { /* ignore */ }
try { localStorage.setItem(key, tab); } catch { /* ignore */ }
}, [key]);
return [activeTab, setTab];
+254
View File
@@ -0,0 +1,254 @@
import { useCallback, useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useAgoraFeed } from '@/hooks/useAgoraFeed';
import { type FeedItem } from '@/hooks/useFeed';
import { useFollowList } from '@/hooks/useFollowActions';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useMuteList } from '@/hooks/useMuteList';
import { getPaginationCursor, shouldHideFeedEvent, isRepostKind } from '@/lib/feedUtils';
import { isEventMuted } from '@/lib/muteHelpers';
import { parseRepostContent } from '@/lib/feedUtils';
/**
* The three feed modes available on the home `/feed` page.
*
* - `agora` — Agora content only (campaigns, pledges, donations, comments
* on Agora entities, communities, `#Agora`-tagged notes).
* - `all-nostr` — global kind 1 stream from across the network plus the
* full Agora content mix, interleaved chronologically.
* - `following` — same as `all-nostr` but every layer is filtered to authors
* you follow.
*/
export type FeedMode = 'agora' | 'all-nostr' | 'following';
const NOSTR_PAGE_SIZE = 30;
/** Kinds included in the "all Nostr" / "following" kind 1 layer. */
const NOSTR_LAYER_KINDS = [1, 6, 16];
interface NostrLayerPage {
items: FeedItem[];
oldestTimestamp: number | null;
rawCount: number;
}
/**
* A deliberately broad kind 1 + reposts query for the home feed's
* non-Agora layers. Unlike `useFeed('global')`, which uses Ditto's
* curated `sort:hot` NIP-50 extension and is filtered by user feed
* settings, this query is a straight chronological pull of recent
* kind 1 notes (plus reposts) so "All Nostr" actually lives up to
* its name.
*/
function useNostrLayer({
enabled,
authors,
}: {
enabled: boolean;
/** When provided, restrict to these authors (Following mode). */
authors?: string[];
}) {
const { nostr } = useNostr();
const authorsKey = authors ? [...authors].sort().join(',') : '';
// If `authors` is provided but empty, the query is intentionally empty
// (e.g. the user follows nobody).
const authorsEmpty = authors !== undefined && authors.length === 0;
const queryEnabled = enabled && !authorsEmpty;
return useInfiniteQuery<NostrLayerPage, Error>({
queryKey: ['nostr-layer', authorsKey],
queryFn: async ({ pageParam, signal: querySignal }) => {
const signal = AbortSignal.any([querySignal, AbortSignal.timeout(8_000)]);
const until = pageParam as number | undefined;
const now = Math.floor(Date.now() / 1000);
const filter: NostrFilter = {
kinds: NOSTR_LAYER_KINDS,
limit: NOSTR_PAGE_SIZE,
...(authors && authors.length > 0 ? { authors } : {}),
...(until ? { until } : {}),
};
const raw = await nostr.query([filter], { signal });
const valid = raw.filter((ev) => ev.created_at <= now);
const items: FeedItem[] = [];
for (const ev of valid) {
if (isRepostKind(ev.kind)) {
const embedded = parseRepostContent(ev);
if (embedded && embedded.created_at <= now) {
items.push({ event: embedded, repostedBy: ev.pubkey, sortTimestamp: ev.created_at });
}
} else {
items.push({ event: ev, sortTimestamp: ev.created_at });
}
}
const oldestTimestamp = valid.length > 0 ? getPaginationCursor(valid) : null;
return { items, oldestTimestamp, rawCount: valid.length };
},
initialPageParam: undefined as number | undefined,
getNextPageParam: (lastPage) => {
if (lastPage.rawCount === 0 || lastPage.oldestTimestamp === null) return undefined;
return lastPage.oldestTimestamp - 1;
},
enabled: queryEnabled,
staleTime: 30_000,
refetchOnWindowFocus: false,
placeholderData: (prev) => prev,
});
}
/**
* Orchestrates the three-mode home feed. Internally wires the appropriate
* combination of {@link useNostrLayer} (a broad chronological kind 1 +
* reposts query) and {@link useAgoraFeed} (Agora activity mix),
* interleaving their results chronologically and exposing a single
* pagination interface.
*/
export function useMixedFeed(mode: FeedMode, enabled: boolean) {
const { user } = useCurrentUser();
const { data: followData } = useFollowList();
const { muteItems } = useMuteList();
// In Following mode, both layers are filtered to authors the user
// follows (plus themselves, matching the network feed convention).
// Passing `authors: []` intentionally disables queries until the
// follow list has loaded — without this guard a logged-in user
// would briefly see the global mix before their follows arrive.
const followAuthors = useMemo<string[] | undefined>(() => {
if (mode !== 'following') return undefined;
if (!user) return [];
const follows = followData?.pubkeys;
if (follows === undefined) return undefined; // still loading
return follows.length > 0 ? [...follows, user.pubkey] : [user.pubkey];
}, [mode, user, followData?.pubkeys]);
// Following mode is gated on the follow list being loaded.
const layersReady = enabled && (mode !== 'following' || followAuthors !== undefined);
const agoraOptions = useMemo(
() => (followAuthors !== undefined ? { authors: followAuthors } : undefined),
[followAuthors],
);
const agoraFeed = useAgoraFeed(layersReady, agoraOptions);
// Kind 1 layer: only used by 'all-nostr' and 'following'. 'agora' mode skips it.
const useNostr = mode === 'all-nostr' || mode === 'following';
const nostrLayer = useNostrLayer({
enabled: layersReady && useNostr,
authors: followAuthors,
});
// Flatten kind 1 layer pages → FeedItem[].
const nostrItems = useMemo<FeedItem[]>(() => {
if (!useNostr) return [];
return nostrLayer.data?.pages.flatMap((page) => page.items) ?? [];
}, [useNostr, nostrLayer.data?.pages]);
// Flatten Agora layer events → FeedItem[].
const agoraItems = useMemo<FeedItem[]>(
() => agoraFeed.events.map((event: NostrEvent) => ({ event, sortTimestamp: event.created_at })),
[agoraFeed.events],
);
// When the Nostr layer is active, clip the Agora layer to the recency
// window the Nostr layer has already loaded. Without this, the Nostr
// firehose advances its `until` cursor by minutes per page while the
// (sparser) Agora layer covers weeks per page — so at the bottom of
// the merged feed only Agora items remain, giving the impression that
// the "All Nostr" feed has degenerated back into the Agora-only feed.
//
// The clip floor is the oldest Nostr item we've loaded. Agora items
// older than that are held back until the Nostr layer paginates far
// enough to keep them company.
const nostrFloor = useMemo<number | null>(() => {
if (!useNostr || nostrItems.length === 0) return null;
let oldest = nostrItems[0].sortTimestamp;
for (const item of nostrItems) {
if (item.sortTimestamp < oldest) oldest = item.sortTimestamp;
}
return oldest;
}, [useNostr, nostrItems]);
const visibleAgoraItems = useMemo<FeedItem[]>(() => {
if (!useNostr) return agoraItems;
if (nostrFloor === null) return [];
return agoraItems.filter((item) => item.sortTimestamp >= nostrFloor);
}, [useNostr, agoraItems, nostrFloor]);
// Merge both layers, dedupe by event id, apply mute filter, sort newest-first.
const items = useMemo<FeedItem[]>(() => {
const seen = new Map<string, FeedItem>();
const consider = (item: FeedItem) => {
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
if (!key) return;
if (shouldHideFeedEvent(item.event)) return;
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return;
const existing = seen.get(key);
if (!existing || item.sortTimestamp > existing.sortTimestamp) {
seen.set(key, item);
}
};
for (const item of visibleAgoraItems) consider(item);
for (const item of nostrItems) consider(item);
return Array.from(seen.values()).sort((a, b) => b.sortTimestamp - a.sortTimestamp);
}, [visibleAgoraItems, nostrItems, muteItems]);
// Unified pagination. The Agora layer is sparser than Nostr, so it
// exhausts its pages first. Once that happens, only Nostr keeps
// advancing — which is fine because we already clip Agora to the
// Nostr recency window. We only fetch Agora when the Nostr floor is
// about to dip below the Agora floor (i.e. we're about to scroll into
// a region where the Agora buffer is empty).
const agoraHasNext = agoraFeed.hasNextPage;
const agoraFetchNext = agoraFeed.fetchNextPage;
const nostrHasNext = nostrLayer.hasNextPage;
const nostrFetchNext = nostrLayer.fetchNextPage;
// Oldest Agora item currently loaded — used to decide whether we need
// to fetch more Agora when the Nostr cursor advances past it.
const agoraFloor = useMemo<number | null>(() => {
if (agoraItems.length === 0) return null;
let oldest = agoraItems[0].sortTimestamp;
for (const item of agoraItems) {
if (item.sortTimestamp < oldest) oldest = item.sortTimestamp;
}
return oldest;
}, [agoraItems]);
const fetchNextPage = useCallback(async () => {
if (!useNostr) {
// Pure Agora mode — just advance Agora.
if (agoraHasNext) await agoraFetchNext();
return;
}
// Mixed mode: always advance Nostr (it's the dense layer driving the
// scroll). Only advance Agora if its floor is at or above the Nostr
// floor — i.e. the Agora buffer is on the verge of being uncovered
// by further Nostr pagination. Otherwise we'd fetch Agora pages we
// can't display yet because they fall below the visible window.
const advanceAgora =
agoraHasNext && agoraFloor !== null && nostrFloor !== null && agoraFloor >= nostrFloor;
await Promise.all([
nostrHasNext ? nostrFetchNext() : Promise.resolve(),
advanceAgora ? agoraFetchNext() : Promise.resolve(),
]);
}, [useNostr, agoraHasNext, agoraFetchNext, agoraFloor, nostrHasNext, nostrFetchNext, nostrFloor]);
const hasNextPage = useNostr
? !!nostrHasNext || !!agoraHasNext
: !!agoraHasNext;
const isFetchingNextPage = agoraFeed.isFetchingNextPage
|| (useNostr && nostrLayer.isFetchingNextPage);
const isLoading = agoraFeed.isLoading || (useNostr && nostrLayer.isPending);
return {
items,
isLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
};
}
+2 -1
View File
@@ -8,6 +8,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useAppContext } from '@/hooks/useAppContext';
import { notificationSuccess } from '@/lib/haptics';
import { withAgoraTag } from '@/lib/agoraNoteTags';
import {
nostrPubkeyToBitcoinAddress,
fetchUTXOs,
@@ -162,7 +163,7 @@ export function useOnchainZap(
const event = await publishEvent({
kind: 8333,
content: comment,
tags,
tags: withAgoraTag(tags),
});
return { txid, amountSats, fee, event };
+120 -42
View File
@@ -4,6 +4,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { fetchTxDetail, nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin';
import { useAppContext } from '@/hooks/useAppContext';
import { CAMPAIGN_KIND, parseCampaign } from '@/lib/campaign';
/** A single verified on-chain zap, with the amount that actually paid the recipient(s) on-chain. */
export interface OnchainZapEntry {
/** The kind 8333 event. */
@@ -13,15 +14,18 @@ export interface OnchainZapEntry {
/** Pubkey of the sender (the 8333 event author). */
senderPubkey: string;
/**
* Pubkeys of the recipients (one per `p` tag). For legacy single-recipient
* events this has length 1; for multi-output events (campaign donations,
* community batch zaps) it has one entry per recipient.
* Pubkeys of the recipients (one per `p` tag). For identity zaps this has
* length 1 (or more for batch community zaps). For campaign donations
* (kind 33863 targets) this is always empty — campaigns are not
* Nostr-identity recipients and the receipt carries no `p` tags.
*/
recipientPubkeys: string[];
/**
* Verified total in sats — sum of tx outputs that pay any of the listed
* recipients' derived Taproot addresses. Excludes the sender's change
* output even if some helpful soul tagged the sender as a recipient.
* Verified total in sats — sum of tx outputs paying any expected
* destination. In identity-recipient mode this is "any of the listed
* recipients' derived Taproot addresses." In campaign-wallet mode
* (target is a kind 33863 campaign) it is "outputs paying the campaign's
* `w` address." Excludes the sender's change output in either case.
*/
amountSats: number;
/** Sender's self-reported amount tag (may differ from verified). */
@@ -54,9 +58,9 @@ export function extractOnchainZapClaimedAmount(event: NostrEvent): number {
/**
* Parse the recipient pubkey(s) from a kind 8333 event.
*
* Legacy single-recipient events have exactly one `p` tag; multi-output
* events (campaigns, community batch zaps) list every recipient under its
* own `p` tag. Returns the pubkeys in `p`-tag order, deduplicated.
* Identity-recipient events carry one or more `p` tags; campaign-donation
* events carry none (a campaign is not a Nostr identity). Returns the
* pubkeys in `p`-tag order, deduplicated.
*/
export function extractOnchainZapRecipients(event: NostrEvent): string[] {
const seen = new Set<string>();
@@ -82,43 +86,84 @@ export function extractOnchainZapRecipient(event: NostrEvent): string {
return extractOnchainZapRecipients(event)[0] ?? '';
}
/**
* Returns the addressable coordinate from the receipt's `a` tag if it
* points at a kind 33863 campaign, or null otherwise.
*/
function extractCampaignTarget(event: NostrEvent): string | null {
const aTag = event.tags.find(([n]) => n === 'a')?.[1];
if (typeof aTag !== 'string') return null;
if (!aTag.startsWith(`${CAMPAIGN_KIND}:`)) return null;
return aTag;
}
/**
* Verify a kind 8333 on-chain zap event against the Bitcoin blockchain.
*
* Returns the verified amount (sum of tx outputs paying any listed
* recipient's derived Taproot address) and confirmation status. Returns
* `null` if the event is malformed or the transaction cannot be verified.
* Returns the verified amount (sum of tx outputs paying the expected
* destination) and confirmation status. Returns `null` if the event is
* malformed or the transaction cannot be verified.
*
* A verified amount of 0 means the transaction exists but does not pay
* any listed recipient — callers should discard such events.
* Verification has two modes depending on the event shape:
*
* @param event The kind 8333 event to verify.
* @param esploraApis Ordered list of Esplora REST roots tried with failover.
* @param signal Optional abort signal (e.g. from TanStack Query).
* - **Identity-recipient mode** — event has one or more `p` tags and no
* campaign `a` tag. The expected destinations are the recipients'
* derived Taproot addresses (`nostrPubkeyToBitcoinAddress`).
* - **Campaign-wallet mode** — event has an `a` tag pointing at a kind
* 33863 campaign and no `p` tags. The expected destination is the
* campaign's declared `w` bech32(m) address. Silent-payment campaigns
* (`w` starts with `sp1…`) cannot be verified on-chain by definition
* and are rejected.
*
* @param event The kind 8333 event to verify.
* @param esploraApis Ordered list of Esplora REST roots tried with failover.
* @param campaignWallet When the receipt targets a kind 33863 campaign,
* the campaign's `w` value. Required for campaign-wallet
* mode; ignored otherwise.
* @param signal Optional abort signal (e.g. from TanStack Query).
*/
export async function verifyOnchainZap(
event: NostrEvent,
esploraApis: string[],
campaignWallet?: string,
signal?: AbortSignal,
): Promise<OnchainZapEntry | null> {
const txid = extractOnchainZapTxid(event);
const recipientPubkeys = extractOnchainZapRecipients(event);
if (!txid || recipientPubkeys.length === 0) return null;
if (!txid) return null;
// Reject self-zaps: the sender already controls each derived destination
// address, so any output paying the sender is change. We strip the sender
// from the recipient set rather than discarding the whole event so a tx
// that pays the sender plus legitimate recipients still verifies for the
// others.
const externalRecipients = recipientPubkeys.filter((p) => p !== event.pubkey);
if (externalRecipients.length === 0) return null;
const campaignTarget = extractCampaignTarget(event);
const recipientAddresses = new Set<string>();
for (const pubkey of externalRecipients) {
const address = nostrPubkeyToBitcoinAddress(pubkey);
if (address) recipientAddresses.add(address);
// Determine the set of expected destination addresses for verification.
const expectedAddresses = new Set<string>();
let recipientPubkeys: string[] = [];
if (campaignTarget) {
// Campaign-wallet mode: match outputs against the campaign's declared
// `w` address. Silent-payment campaigns publish no receipts, so a
// receipt referencing an `sp1…` campaign is malformed.
if (!campaignWallet) return null;
if (campaignWallet.startsWith('sp1')) return null;
expectedAddresses.add(campaignWallet);
// No identity recipients in this mode.
} else {
// Identity-recipient mode.
const recipients = extractOnchainZapRecipients(event);
if (recipients.length === 0) return null;
// Reject self-zaps: the sender already controls each derived destination
// address, so any output paying the sender is change. Strip the sender
// from the recipient set rather than discarding the whole event so a tx
// that pays the sender plus legitimate recipients still verifies for
// the others.
recipientPubkeys = recipients.filter((p) => p !== event.pubkey);
if (recipientPubkeys.length === 0) return null;
for (const pubkey of recipientPubkeys) {
const address = nostrPubkeyToBitcoinAddress(pubkey);
if (address) expectedAddresses.add(address);
}
if (expectedAddresses.size === 0) return null;
}
if (recipientAddresses.size === 0) return null;
let detail;
try {
@@ -128,7 +173,7 @@ export async function verifyOnchainZap(
}
const amountSats = detail.outputs
.filter((o) => o.address && recipientAddresses.has(o.address))
.filter((o) => o.address && expectedAddresses.has(o.address))
.reduce((sum, o) => sum + o.value, 0);
if (amountSats === 0) return null;
@@ -141,7 +186,7 @@ export async function verifyOnchainZap(
event,
txid,
senderPubkey: event.pubkey,
recipientPubkeys: externalRecipients,
recipientPubkeys,
amountSats: effectiveClaim,
claimedAmountSats: claimed,
comment: event.content,
@@ -153,6 +198,11 @@ export async function verifyOnchainZap(
/**
* Query all kind 8333 on-chain zaps targeting a specific event, then verify
* each one on-chain. Returns only verified entries (deduped by txid).
*
* When the target is a kind 33863 campaign, verification matches against
* the campaign's `w` wallet address rather than derived recipient
* addresses. Silent-payment campaigns (`w` starts with `sp1…`) return an
* empty list — donations to those campaigns are unlinkable by design.
*/
export function useOnchainZaps(target: NostrEvent | undefined) {
const { nostr } = useNostr();
@@ -164,6 +214,14 @@ export function useOnchainZaps(target: NostrEvent | undefined) {
: '';
const aCoord = isAddressable && target ? `${target.kind}:${target.pubkey}:${dTag}` : '';
// If the target is a campaign, parse its `w` wallet for campaign-wallet
// mode verification. Silent-payment campaigns short-circuit to "no
// verifiable donations" — we don't issue any verifier queries.
const campaignWallet = target && target.kind === CAMPAIGN_KIND
? parseCampaign(target)?.wallet
: undefined;
const isSilentPayment = campaignWallet?.mode === 'sp';
// Step 1: fetch the raw kind 8333 events for this target
const eventsQuery = useQuery({
queryKey: ['onchain-zaps', 'events', target?.id ?? '', aCoord],
@@ -198,16 +256,18 @@ export function useOnchainZaps(target: NostrEvent | undefined) {
return Array.from(byTxid.values());
},
enabled: !!target,
enabled: !!target && !isSilentPayment,
staleTime: 30_000,
});
// Step 2: verify each event on-chain (parallel, cached per event)
const events = eventsQuery.data ?? [];
const walletValue = campaignWallet?.value;
const verifications = useQueries({
queries: events.map((event) => ({
queryKey: ['onchain-zaps', 'verify', esploraApis, event.id],
queryFn: ({ signal }: { signal: AbortSignal }) => verifyOnchainZap(event, esploraApis, signal),
queryKey: ['onchain-zaps', 'verify', esploraApis, event.id, walletValue ?? ''],
queryFn: ({ signal }: { signal: AbortSignal }) =>
verifyOnchainZap(event, esploraApis, walletValue, signal),
staleTime: 60_000,
})),
});
@@ -220,7 +280,7 @@ export function useOnchainZaps(target: NostrEvent | undefined) {
verified.sort((a, b) => b.amountSats - a.amountSats);
const totalSats = verified.reduce((s, v) => s + v.amountSats, 0);
const isLoading = eventsQuery.isLoading || verifications.some((v) => v.isLoading);
const isLoading = !isSilentPayment && (eventsQuery.isLoading || verifications.some((v) => v.isLoading));
return {
zaps: verified,
@@ -238,17 +298,35 @@ export function useOnchainZaps(target: NostrEvent | undefined) {
*
* Returns `undefined` while loading, `null` if the event fails verification
* (invalid tx, wrong recipient, self-zap, etc.), or the entry.
*
* If the receipt targets a kind 33863 campaign, pass the campaign's `w`
* value as `campaignWallet` so the verifier can match outputs against the
* campaign address. Without it, campaign-targeted receipts always fail
* verification (no `p` tags, no fallback).
*/
export function useVerifiedOnchainZap(event: NostrEvent | undefined): OnchainZapEntry | null | undefined {
export function useVerifiedOnchainZap(
event: NostrEvent | undefined,
campaignWallet?: string,
): OnchainZapEntry | null | undefined {
const { config } = useAppContext();
const { esploraApis } = config;
const txid = event ? extractOnchainZapTxid(event) : null;
const hasRecipient = event ? extractOnchainZapRecipients(event).length > 0 : false;
const targetsCampaign = event ? !!extractCampaignTarget(event) : false;
const hasIdentityRecipient = event ? extractOnchainZapRecipients(event).length > 0 : false;
// Enable verification when:
// - we have a txid AND
// - either the receipt targets a campaign AND we know the wallet, OR
// - the receipt has an identity recipient (`p` tag).
const enabled = !!event && !!txid && (
(targetsCampaign && !!campaignWallet)
|| (!targetsCampaign && hasIdentityRecipient)
);
const { data } = useQuery({
queryKey: ['onchain-zaps', 'verify', esploraApis, event?.id ?? ''],
queryFn: ({ signal }) => verifyOnchainZap(event!, esploraApis, signal),
enabled: !!event && !!txid && hasRecipient,
queryKey: ['onchain-zaps', 'verify', esploraApis, event?.id ?? '', campaignWallet ?? ''],
queryFn: ({ signal }) => verifyOnchainZap(event!, esploraApis, campaignWallet, signal),
enabled,
staleTime: 60_000,
});
@@ -1,203 +0,0 @@
import { useMemo } from 'react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import type { UserOrganization } from '@/hooks/useUserOrganizations';
import { CAMPAIGN_KIND } from '@/lib/campaign';
import { getPaginationCursor } from '@/lib/feedUtils';
const PLEDGE_KIND = 36639;
const CALENDAR_EVENT_KINDS = [31922, 31923];
const OFFICIAL_ACTIVITY_KINDS = [CAMPAIGN_KIND, PLEDGE_KIND, ...CALENDAR_EVENT_KINDS];
const OFFICIAL_ACTIVITY_KIND_SET = new Set(OFFICIAL_ACTIVITY_KINDS);
const COMMENTS_PAGE_SIZE = 60;
const OFFICIAL_PAGE_SIZE = 40;
interface OrganizationFeedPage {
events: NostrEvent[];
commentsRawCount: number;
officialRawCount: number;
commentsOldestTimestamp: number | null;
officialOldestTimestamp: number | null;
}
interface OrganizationFeedPageParam {
commentsUntil?: number;
officialUntil?: number;
commentsDone?: boolean;
officialDone?: boolean;
}
function getEventOrganizationATag(event: NostrEvent): string | undefined {
return event.tags.find(([name]) => name === 'A')?.[1];
}
function buildLeadershipByATag(organizations: UserOrganization[]) {
const leadershipByATag = new Map<string, Set<string>>();
for (const entry of organizations) {
leadershipByATag.set(
entry.community.aTag,
new Set([entry.community.founderPubkey, ...entry.community.moderatorPubkeys]),
);
}
return leadershipByATag;
}
/** Feed of comments and official activity from the user's founded/moderated/followed organizations. */
export function useOrganizationHomeActivityFeed(
organizations: UserOrganization[] | undefined,
membersOnly: boolean,
enabled = true,
) {
const { nostr } = useNostr();
const queryClient = useQueryClient();
const aTags = useMemo(
() => organizations?.map((entry) => entry.community.aTag) ?? [],
[organizations],
);
const aTagsKey = aTags.join(',');
const leadershipByATag = useMemo(
() => buildLeadershipByATag(organizations ?? []),
[organizations],
);
const officialAuthors = useMemo(() => {
const authors = new Set<string>();
for (const leaders of leadershipByATag.values()) {
for (const pubkey of leaders) authors.add(pubkey);
}
return [...authors];
}, [leadershipByATag]);
const query = useInfiniteQuery<OrganizationFeedPage, Error>({
queryKey: ['organization-home-activity-feed', aTagsKey, membersOnly],
queryFn: async ({ pageParam, signal }) => {
if (aTags.length === 0) {
return {
events: [],
commentsRawCount: 0,
officialRawCount: 0,
commentsOldestTimestamp: null,
officialOldestTimestamp: null,
};
}
const cursor = pageParam as OrganizationFeedPageParam | undefined;
const filters: NostrFilter[] = [];
if (!cursor?.commentsDone) {
filters.push({
kinds: [1111],
'#A': aTags,
...(membersOnly && officialAuthors.length > 0 ? { authors: officialAuthors } : {}),
limit: COMMENTS_PAGE_SIZE,
...(cursor?.commentsUntil ? { until: cursor.commentsUntil } : {}),
});
}
if (!cursor?.officialDone && officialAuthors.length > 0) {
filters.push({
kinds: OFFICIAL_ACTIVITY_KINDS,
authors: officialAuthors,
'#A': aTags,
limit: OFFICIAL_PAGE_SIZE,
...(cursor?.officialUntil ? { until: cursor.officialUntil } : {}),
});
}
if (filters.length === 0) {
return {
events: [],
commentsRawCount: 0,
officialRawCount: 0,
commentsOldestTimestamp: null,
officialOldestTimestamp: null,
};
}
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
const raw = await nostr.query(filters, { signal: combinedSignal });
const rawComments = raw.filter((event) => event.kind === 1111);
const rawOfficial = raw.filter((event) => OFFICIAL_ACTIVITY_KIND_SET.has(event.kind));
const seen = new Set<string>();
const events: NostrEvent[] = [];
for (const event of raw.sort((a, b) => b.created_at - a.created_at)) {
if (seen.has(event.id)) continue;
seen.add(event.id);
const aTag = getEventOrganizationATag(event);
if (!aTag) continue;
const leaders = leadershipByATag.get(aTag);
if (!leaders) continue;
if (event.kind === 1111) {
if (membersOnly && !leaders.has(event.pubkey)) continue;
events.push(event);
continue;
}
if (!leaders.has(event.pubkey)) continue;
events.push(event);
}
for (const event of events) {
if (!queryClient.getQueryData(['event', event.id])) {
queryClient.setQueryData(['event', event.id], event);
}
}
return {
events,
commentsRawCount: rawComments.length,
officialRawCount: rawOfficial.length,
commentsOldestTimestamp: rawComments.length > 0 ? getPaginationCursor(rawComments) : null,
officialOldestTimestamp: rawOfficial.length > 0 ? getPaginationCursor(rawOfficial) : null,
};
},
getNextPageParam: (lastPage) => {
const commentsCursor = lastPage.commentsOldestTimestamp;
const officialCursor = lastPage.officialOldestTimestamp;
const commentsDone = lastPage.commentsRawCount < COMMENTS_PAGE_SIZE || commentsCursor === null;
const officialDone = lastPage.officialRawCount < OFFICIAL_PAGE_SIZE || officialCursor === null;
if (commentsDone && officialDone) return undefined;
return {
commentsDone,
officialDone,
commentsUntil: commentsDone ? undefined : commentsCursor - 1,
officialUntil: officialDone ? undefined : officialCursor - 1,
} satisfies OrganizationFeedPageParam;
},
initialPageParam: undefined as OrganizationFeedPageParam | undefined,
enabled: enabled && aTags.length > 0,
staleTime: 60_000,
placeholderData: (prev) => prev,
refetchOnWindowFocus: false,
});
return useMemo(() => {
const seen = new Set<string>();
const events = (query.data?.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);
return {
events,
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
hasNextPage: query.hasNextPage,
isFetchingNextPage: query.isFetchingNextPage,
fetchNextPage: query.fetchNextPage,
pageCount: query.data?.pages.length,
};
}, [query.data, query.error, query.fetchNextPage, query.hasNextPage, query.isError, query.isFetchingNextPage, query.isLoading]);
}
@@ -1,58 +0,0 @@
import { useCallback, useSyncExternalStore } from 'react';
const STORAGE_KEY = 'organization-feed:members-only';
function readFromStorage(): boolean {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return false;
return JSON.parse(raw) === true;
} catch {
return false;
}
}
let cached = typeof localStorage !== 'undefined' ? readFromStorage() : false;
const subscribers = new Set<() => void>();
function notify() {
for (const cb of subscribers) cb();
}
function setMembersOnly(next: boolean) {
if (cached === next) return;
cached = next;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
// Keep the in-memory preference even when storage is unavailable.
}
notify();
}
if (typeof window !== 'undefined') {
window.addEventListener('storage', (event) => {
if (event.key !== STORAGE_KEY) return;
const next = readFromStorage();
if (next === cached) return;
cached = next;
notify();
});
}
function subscribe(cb: () => void) {
subscribers.add(cb);
return () => subscribers.delete(cb);
}
function getSnapshot() {
return cached;
}
/** Global preference for showing only founder/moderator comments in org feeds. */
export function useOrganizationMembersOnlyFilter() {
const membersOnly = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const toggle = useCallback(() => setMembersOnly(!cached), []);
return { membersOnly, setMembersOnly, toggle };
}
+133
View File
@@ -0,0 +1,133 @@
import { useNostr } from '@nostrify/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostrPublish } from './useNostrPublish';
import { useCampaignModerators } from './useCampaignModerators';
import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
import {
AGORA_MODERATION_NAMESPACE,
EMPTY_MODERATION_DATA,
LABEL_KIND,
type ModerationData,
type ModerationLabel,
foldModerationLabels,
} from '@/lib/agoraModeration';
/** Surface-scoped alias so call sites read naturally. */
export type OrganizationModerationData = ModerationData;
/**
* Fetches and folds organization-moderation label events authored by Team
* Soapbox members. Returns hide / featured rollups per community
* coordinate (`34550:<pubkey>:<d>`).
*
* Organizations ride the same `agora.moderation` namespace and the same
* moderator pack as campaigns; we just narrow the fold to labels whose `a`
* tag points at a kind 34550 coordinate. The relay-side query is identical
* to the campaign side (we fetch every namespace-tagged label authored by
* moderators) — the surface separation is purely client-side.
*
* **Two-axis model.** Unlike campaigns, organizations don't have an
* `approved` axis. Every Agora-tagged organization is publicly visible
* by default; moderation reduces to `featured` (lift into the curated
* shelf) and `hidden` (suppress from public discovery). The shared
* fold helper still tracks `approvedCoords` for type symmetry with the
* campaign hook, but the org UI never emits or reads it — moderators
* SHOULD NOT publish `approved` / `unapproved` labels against kind
* 34550 coordinates.
*
* **Display rule** consumers should follow:
* - Featured shelf on `/communities` iff
* `featuredCoords.has(coord) && !hiddenCoords.has(coord)`.
* - Future "All organizations" / discovery surfaces iff
* `!hiddenCoords.has(coord)` for non-moderators; moderators may see hidden
* orgs with a dimmed treatment.
* - "My organizations" intentionally ignores moderation — a user's own
* founded / moderated / followed orgs always render regardless of label.
* - Hide always wins over featured.
*
* The mutation `moderate({ coord, action })` publishes a single kind 1985
* event labeling one organization in the `agora.moderation` namespace.
* Callers MUST be in the moderator set or the relay-side `authors:` filter
* on read will silently ignore the new event.
*/
export function useOrganizationModeration() {
const { nostr } = useNostr();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
const { data: moderators } = useCampaignModerators();
// Same gating as the campaign hook: never fire with an empty `authors:`
// filter, since that would return labels from any author and break the
// trust model.
const moderatorsKey = moderators ? [...moderators].sort().join(',') : '';
const moderationQuery = useQuery({
queryKey: ['organization-moderation', moderatorsKey],
enabled: moderators !== undefined,
queryFn: async ({ signal }): Promise<OrganizationModerationData> => {
if (!moderators || moderators.length === 0) {
return { ...EMPTY_MODERATION_DATA, moderators: [] };
}
const events = await nostr.query(
[
{
kinds: [LABEL_KIND],
authors: moderators,
'#L': [AGORA_MODERATION_NAMESPACE],
limit: 2000,
},
],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
);
return foldModerationLabels(events, moderators, COMMUNITY_DEFINITION_KIND);
},
// Moderation labels change slowly — moderators feature or hide on
// human timescales, not seconds. A generous staleTime keeps repeat
// visits to /communities instant (no relay round-trip on remount),
// and explicit invalidation in the `moderate` mutation below catches
// local changes immediately. The hour-long gcTime survives tab
// switches and back-button navigation without refetching.
staleTime: 5 * 60_000,
gcTime: 60 * 60_000,
});
const moderate = useMutation({
mutationFn: async ({ coord, action }: { coord: string; action: ModerationLabel }) => {
if (!coord.startsWith(`${COMMUNITY_DEFINITION_KIND}:`)) {
throw new Error(`Coordinate must start with ${COMMUNITY_DEFINITION_KIND}:`);
}
// Organizations use a two-axis model — only `featured` / `unfeatured`
// / `hidden` / `unhidden` are valid here. Reject `approved` /
// `unapproved` defensively so a stray UI bug can't poison the
// label stream with axis-mixed events.
if (action === 'approved' || action === 'unapproved') {
throw new Error(`Organizations do not support the ${action} label`);
}
return publishEvent({
kind: LABEL_KIND,
content: '',
tags: [
['L', AGORA_MODERATION_NAMESPACE],
['l', action, AGORA_MODERATION_NAMESPACE],
['a', coord],
['alt', `Organization moderation: ${action}`],
],
});
},
onSuccess: () => {
// Invalidate both the moderation rollup and the derived featured
// query so the grid reflects the new state immediately.
queryClient.invalidateQueries({ queryKey: ['organization-moderation'] });
queryClient.invalidateQueries({ queryKey: ['featured-organizations'] });
},
});
return {
data: moderationQuery.data ?? EMPTY_MODERATION_DATA,
isPending: moderationQuery.isPending,
isLoading: moderationQuery.isLoading,
isReady: moderationQuery.isSuccess,
moderate,
};
}
+109
View File
@@ -0,0 +1,109 @@
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
const PIN_LIST_KIND = 30078;
const PIN_D_TAG_PREFIX = 'agora-pinned-comments:';
function pinDTag(rootATag: string): string {
return `${PIN_D_TAG_PREFIX}${rootATag}`;
}
function parsePinnedIds(event: NostrEvent | null | undefined): string[] {
if (!event) return [];
try {
const parsed = JSON.parse(event.content) as { pinnedEvents?: unknown };
if (!Array.isArray(parsed.pinnedEvents)) return [];
return parsed.pinnedEvents.filter((id): id is string => typeof id === 'string');
} catch {
return [];
}
}
export function usePinnedEventComments(rootATag: string | undefined, ownerPubkey: string | undefined) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
const dTag = rootATag ? pinDTag(rootATag) : undefined;
const canManagePins = !!user && !!ownerPubkey && user.pubkey === ownerPubkey;
const pinnedListQuery = useQuery({
queryKey: ['pinned-event-comments-list', rootATag, ownerPubkey],
queryFn: async ({ signal }) => {
if (!rootATag || !ownerPubkey || !dTag) return null;
const events = await nostr.query(
[{ kinds: [PIN_LIST_KIND], authors: [ownerPubkey], '#d': [dTag], limit: 1 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
return events[0] ?? null;
},
enabled: !!rootATag && !!ownerPubkey && !!dTag,
staleTime: 30_000,
});
const pinnedIds = parsePinnedIds(pinnedListQuery.data);
const pinnedEventsQuery = useQuery({
queryKey: ['pinned-event-comments', rootATag, pinnedIds],
queryFn: async ({ signal }) => {
if (pinnedIds.length === 0) return [];
const events = await nostr.query(
[{ ids: pinnedIds, limit: pinnedIds.length }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
return events.sort((a, b) => pinnedIds.indexOf(a.id) - pinnedIds.indexOf(b.id));
},
enabled: !!rootATag && pinnedIds.length > 0,
staleTime: 30_000,
});
const togglePin = useMutation({
mutationFn: async (eventId: string) => {
if (!user) throw new Error('User is not logged in');
if (!rootATag || !ownerPubkey || !dTag) throw new Error('Missing pin context.');
if (user.pubkey !== ownerPubkey) throw new Error('Only the event owner can pin comments.');
const prev = await fetchFreshEvent(nostr, {
kinds: [PIN_LIST_KIND],
authors: [ownerPubkey],
'#d': [dTag],
});
const current = parsePinnedIds(prev);
const next = current.includes(eventId)
? current.filter((id) => id !== eventId)
: [eventId, ...current.filter((id) => id !== eventId)];
await publishEvent({
kind: PIN_LIST_KIND,
content: JSON.stringify({ pinnedEvents: next }),
tags: [
['d', dTag],
['a', rootATag],
['k', rootATag.split(':')[0] ?? ''],
['alt', 'Pinned event comments'],
],
prev: prev ?? undefined,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pinned-event-comments-list', rootATag, ownerPubkey] });
queryClient.invalidateQueries({ queryKey: ['pinned-event-comments', rootATag] });
},
});
return {
pinnedIds,
pinnedEvents: pinnedEventsQuery.data ?? [],
isLoading: pinnedListQuery.isLoading || pinnedEventsQuery.isLoading,
isPinned: (eventId: string) => pinnedIds.includes(eventId),
canManagePins,
togglePin,
};
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { useMutation, useQueryClient, type InfiniteData } from '@tanstack/react-query';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { withAgoraTag } from '@/lib/agoraNoteTags';
import { NKinds, type NostrEvent } from '@nostrify/nostrify';
interface PaginatedFeedPage {
@@ -45,7 +46,7 @@ export function usePostComment() {
const event = await publishEvent({
kind: 1111,
content,
tags,
tags: withAgoraTag(tags),
});
return event;
+15 -1
View File
@@ -27,6 +27,13 @@ interface StreamPostsOptions {
* Each entry accepts raw hex or npub-encoded pubkeys.
*/
authorPubkeys?: string[];
/**
* When set, narrows results to events whose NIP-89 `client` tag's first
* value matches this string. Relays that index the multi-letter `client`
* tag (e.g. Ditto's relay) honor this server-side; on relays that don't,
* the filter is silently dropped on their end and results are unfiltered.
*/
clientName?: string;
/** NIP-50 sort preference. 'recent' = default (no sort: term). */
sort?: 'recent' | 'hot' | 'trending';
}
@@ -295,9 +302,16 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
streamFilter.authors = resolvedAuthorPubkeys;
}
// NIP-89 client tag filter (Ditto relay indexes the multi-letter `client`
// tag; other relays silently ignore the constraint).
if (options.clientName) {
searchFilter['#client'] = [options.clientName];
streamFilter['#client'] = [options.clientName];
}
return { searchFilter, streamFilter };
// eslint-disable-next-line react-hooks/exhaustive-deps -- enabledKinds is stabilized via kindsKey; options.protocols via protocolsKey; kindsOverride via kindsOverrideKey; authorPubkeys via authorPubkeysKey
}, [query, isDedicatedKindQuery, kindsKey, options.language, options.mediaType, protocolsKey, kindsOverrideKey, authorPubkeysKey, options.sort]);
}, [query, isDedicatedKindQuery, kindsKey, options.language, options.mediaType, protocolsKey, kindsOverrideKey, authorPubkeysKey, options.sort, options.clientName]);
// Shared ref for the event map and known IDs — persists across initial fetch + pagination
const eventMapRef = useRef(new Map<string, NostrEvent>());
-140
View File
@@ -1,140 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { getCoordinates } from '@/lib/coordinates';
/** WMO weather interpretation codes → descriptions and icons. */
const WMO_CODES: Record<number, { description: string; icon: string }> = {
0: { description: 'Clear sky', icon: '☀️' },
1: { description: 'Mainly clear', icon: '🌤️' },
2: { description: 'Partly cloudy', icon: '⛅' },
3: { description: 'Overcast', icon: '☁️' },
45: { description: 'Foggy', icon: '🌫️' },
48: { description: 'Depositing rime fog', icon: '🌫️' },
51: { description: 'Light drizzle', icon: '🌦️' },
53: { description: 'Moderate drizzle', icon: '🌦️' },
55: { description: 'Dense drizzle', icon: '🌧️' },
56: { description: 'Light freezing drizzle', icon: '🌧️' },
57: { description: 'Dense freezing drizzle', icon: '🌧️' },
61: { description: 'Slight rain', icon: '🌦️' },
63: { description: 'Moderate rain', icon: '🌧️' },
65: { description: 'Heavy rain', icon: '🌧️' },
66: { description: 'Light freezing rain', icon: '🌧️' },
67: { description: 'Heavy freezing rain', icon: '🌧️' },
71: { description: 'Slight snowfall', icon: '🌨️' },
73: { description: 'Moderate snowfall', icon: '🌨️' },
75: { description: 'Heavy snowfall', icon: '❄️' },
77: { description: 'Snow grains', icon: '❄️' },
80: { description: 'Slight rain showers', icon: '🌦️' },
81: { description: 'Moderate rain showers', icon: '🌧️' },
82: { description: 'Violent rain showers', icon: '🌧️' },
85: { description: 'Slight snow showers', icon: '🌨️' },
86: { description: 'Heavy snow showers', icon: '❄️' },
95: { description: 'Thunderstorm', icon: '⛈️' },
96: { description: 'Thunderstorm with slight hail', icon: '⛈️' },
99: { description: 'Thunderstorm with heavy hail', icon: '⛈️' },
};
export type PrecipitationType = 'rain' | 'snow' | null;
/** Intensity: 'light' | 'moderate' | 'heavy'. */
export type PrecipitationIntensity = 'light' | 'moderate' | 'heavy';
/** Determine the precipitation type and intensity from a WMO code. */
export function getPrecipitation(code: number): { type: PrecipitationType; intensity: PrecipitationIntensity } {
// Snow codes
if ([71, 85].includes(code)) return { type: 'snow', intensity: 'light' };
if ([73].includes(code)) return { type: 'snow', intensity: 'moderate' };
if ([75, 77, 86].includes(code)) return { type: 'snow', intensity: 'heavy' };
// Rain codes (including drizzle, freezing rain, showers, thunderstorms)
if ([51, 56, 61, 80].includes(code)) return { type: 'rain', intensity: 'light' };
if ([53, 63, 66, 81, 95].includes(code)) return { type: 'rain', intensity: 'moderate' };
if ([55, 57, 65, 67, 82, 96, 99].includes(code)) return { type: 'rain', intensity: 'heavy' };
return { type: null, intensity: 'light' };
}
export interface WeatherData {
/** Current temperature in °C. */
temperature: number;
/** Apparent (feels like) temperature in °C. */
apparentTemperature: number;
/** Relative humidity percentage. */
humidity: number;
/** Wind speed in km/h. */
windSpeed: number;
/** WMO weather code. */
weatherCode: number;
/** Human-readable weather description. */
description: string;
/** Weather emoji icon. */
icon: string;
/** Whether it's currently daytime. */
isDay: boolean;
/** City name for display. */
city: string;
}
async function fetchWeather(
latitude: number,
longitude: number,
signal?: AbortSignal,
): Promise<Omit<WeatherData, 'city'> | null> {
try {
const params = new URLSearchParams({
latitude: latitude.toString(),
longitude: longitude.toString(),
current: 'temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,is_day',
timezone: 'auto',
});
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?${params}`,
{ signal, headers: { Accept: 'application/json' } },
);
if (!response.ok) return null;
const data = await response.json();
const current = data.current;
if (!current) return null;
const code = current.weather_code ?? 0;
const wmo = WMO_CODES[code] ?? { description: 'Unknown', icon: '🌡️' };
return {
temperature: Math.round(current.temperature_2m),
apparentTemperature: Math.round(current.apparent_temperature),
humidity: Math.round(current.relative_humidity_2m),
windSpeed: Math.round(current.wind_speed_10m),
weatherCode: code,
description: wmo.description,
icon: wmo.icon,
isDay: current.is_day === 1,
};
} catch {
return null;
}
}
/**
* Fetch current weather for an ISO 3166 code (country or subdivision).
* Uses the free Open-Meteo API (no API key required).
*/
export function useWeather(code: string | null) {
const coords = code ? getCoordinates(code) : null;
return useQuery({
queryKey: ['weather', code],
queryFn: async ({ signal }): Promise<WeatherData | null> => {
if (!coords) return null;
const weather = await fetchWeather(coords.latitude, coords.longitude, signal);
if (!weather) return null;
return { ...weather, city: coords.city };
},
enabled: !!coords,
staleTime: 1000 * 60 * 15, // 15 minutes
gcTime: 1000 * 60 * 60, // 1 hour
retry: 1,
});
}
-20
View File
@@ -242,26 +242,6 @@
animation: ping-3 4s ease-out forwards;
}
/* Slow continuous pulse for nagging buttons */
@keyframes pulse-slow {
0%, 100% { transform: scale(1); opacity: 0.5; }
50% { transform: scale(1.8); opacity: 0; }
}
.animate-pulse-slow {
animation: pulse-slow 2.5s ease-in-out infinite;
}
/* Eerie sigil glow pulse */
@keyframes sigil-glow {
0%, 100% { opacity: 0.12; filter: drop-shadow(0 0 4px hsl(var(--primary) / 0.3)); }
50% { opacity: 0.28; filter: drop-shadow(0 0 12px hsl(var(--primary) / 0.6)) drop-shadow(0 0 24px hsl(var(--primary) / 0.25)); }
}
.animate-sigil-glow {
animation: sigil-glow 4s ease-in-out infinite;
}
/* Eyeball blink animation - eyelid sweeps down then back up */
@keyframes eyelid-blink {
0% { transform: translateY(-100%); }
+152
View File
@@ -0,0 +1,152 @@
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Shared building blocks for Agora's moderation labels (NIP-32 kind 1985 in
* the `agora.moderation` namespace). Both campaigns (kind 33863) and
* organizations (kind 34550) ride the same label stream and the same
* moderator pack (Team Soapbox); the only thing that varies between them is
* the kind prefix on the `a` tag.
*
* Centralizing the constants, types, and folding logic here keeps the two
* per-surface hooks (`useCampaignModeration`, `useOrganizationModeration`)
* from drifting apart on namespace strings, axis semantics, or the
* surfacing-rule contract documented in NIP.md.
*/
/** NIP-32 label kind. */
export const LABEL_KIND = 1985;
/** Label namespace for Agora's moderation labels. */
export const AGORA_MODERATION_NAMESPACE = 'agora.moderation';
/** The six possible label values in the moderation namespace. */
export type ModerationLabel =
| 'approved'
| 'unapproved'
| 'hidden'
| 'unhidden'
| 'featured'
| 'unfeatured';
/** A single label event narrowed to its decision axis. */
export interface AxisDecision {
/** Latest label observed on this axis. */
label: ModerationLabel;
/** Author of the latest label. */
pubkey: string;
/** Created-at of the latest label. */
createdAt: number;
}
/** Per-coordinate rollup of approval + hide + featured state. */
export interface ModerationState {
approval?: AxisDecision; // `approved` or `unapproved`
hide?: AxisDecision; // `hidden` or `unhidden`
featured?: AxisDecision; // `featured` or `unfeatured`
}
/**
* Per-surface rollup. Keys are the addressable coordinates we filtered for
* (e.g. `33863:<pubkey>:<d>` for campaigns or `34550:<pubkey>:<d>` for
* organizations).
*/
export interface ModerationData {
/** Map of `<kind>:<pubkey>:<d>` -> rollup. */
byCoord: Map<string, ModerationState>;
/** Coordinates where the latest approval label is `approved`. */
approvedCoords: Set<string>;
/** Coordinates where the latest hide label is `hidden`. */
hiddenCoords: Set<string>;
/** Coordinates where the latest featured label is `featured`. */
featuredCoords: Set<string>;
/**
* Map of `coord` -> `created_at` of the latest `featured` label. Used to
* sort featured rows newest-first.
*/
featuredOrder: Map<string, number>;
/** Pubkeys that were considered moderators when the query ran. */
moderators: string[];
}
export const EMPTY_MODERATION_DATA: ModerationData = {
byCoord: new Map(),
approvedCoords: new Set(),
hiddenCoords: new Set(),
featuredCoords: new Set(),
featuredOrder: new Map(),
moderators: [],
};
function isApprovalLabel(value: string): value is 'approved' | 'unapproved' {
return value === 'approved' || value === 'unapproved';
}
function isHideLabel(value: string): value is 'hidden' | 'unhidden' {
return value === 'hidden' || value === 'unhidden';
}
function isFeaturedLabel(value: string): value is 'featured' | 'unfeatured' {
return value === 'featured' || value === 'unfeatured';
}
/**
* Fold a flat list of label events into per-coordinate rollups by axis.
* The newest event per `(coord, axis)` wins.
*
* Events are filtered to only those carrying an `a` tag that starts with
* `<coordKind>:` so the campaign and organization label streams never bleed
* into each other even though they share a namespace and signer set.
*
* Events with a value outside the moderation namespace, or with no `l` tag
* in that namespace, are dropped.
*/
export function foldModerationLabels(
events: NostrEvent[],
moderators: string[],
coordKind: number,
): ModerationData {
const coordPrefix = `${coordKind}:`;
const byCoord = new Map<string, ModerationState>();
for (const event of events) {
const value = event.tags.find(
([n, , ns]) => n === 'l' && ns === AGORA_MODERATION_NAMESPACE,
)?.[1];
if (!value) continue;
const aTag = event.tags.find(
([n, v]) => n === 'a' && typeof v === 'string' && v.startsWith(coordPrefix),
)?.[1];
if (!aTag) continue;
const state = byCoord.get(aTag) ?? {};
if (isApprovalLabel(value)) {
if (!state.approval || event.created_at > state.approval.createdAt) {
state.approval = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
}
} else if (isHideLabel(value)) {
if (!state.hide || event.created_at > state.hide.createdAt) {
state.hide = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
}
} else if (isFeaturedLabel(value)) {
if (!state.featured || event.created_at > state.featured.createdAt) {
state.featured = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
}
}
byCoord.set(aTag, state);
}
const approvedCoords = new Set<string>();
const hiddenCoords = new Set<string>();
const featuredCoords = new Set<string>();
const featuredOrder = new Map<string, number>();
for (const [coord, state] of byCoord) {
if (state.approval?.label === 'approved') approvedCoords.add(coord);
if (state.hide?.label === 'hidden') hiddenCoords.add(coord);
if (state.featured?.label === 'featured') {
featuredCoords.add(coord);
featuredOrder.set(coord, state.featured.createdAt);
}
}
return { byCoord, approvedCoords, hiddenCoords, featuredCoords, featuredOrder, moderators };
}
+53
View File
@@ -0,0 +1,53 @@
/**
* The single-letter `t` tag that marks an event as first-class Agora content.
*
* Every event Agora creates carries `["t", "agora"]` so the Agora activity
* feed can filter strictly server-side via the relay-indexed `#t` filter
* (multi-letter tags like `client` are not indexed by relays, so NIP-89
* cannot serve this purpose).
*
* Tagged kinds: 1 (notes, replies, quotes), 1111 (comments via usePostComment),
* 8333 (onchain zaps), 9041 (zap goals), 30223 (campaigns), 31922 / 31923
* (calendar events), 34550 (communities), 36639 (pledges).
*
* Country-rooted world posts (kind 1111 / 1068 with `#k=iso3166` or `geo`)
* are surfaced regardless of this tag — the world layer is intentionally
* cross-client.
*
* Untagged kinds (intentional): 0, 3, 6, 7, 8, 16, 62, 1311, 30009,
* 10000-series, 30078, encrypted DMs. Reactions, reposts, follows, profile
* metadata, lists, settings, badges, and chat are user-state or response
* events — not first-class Agora content.
*
* See `NIP.md` (§ Agora Content Marker) for the canonical protocol doc.
*/
export const AGORA_DEFAULT_NOTE_TAGS: string[][] = [['t', 'agora']];
/**
* Append `["t", "agora"]` to a tag array if it is not already present.
*
* Use at every Agora publish site so the feed can strictly filter by
* `#t=agora`. Dedupes user-supplied `t` tags (e.g. campaigns let users
* enter free-text hashtags — typing "agora" must not yield two tags).
*/
export function withAgoraTag(tags: string[][]): string[][] {
const hasAgora = tags.some(
([name, value]) => name === 't' && value?.toLowerCase() === 'agora',
);
return hasAgora ? tags : [...tags, ['t', 'agora']];
}
/**
* Returns `true` when an event carries Agora's `["t", "agora"]` marker
* (case-insensitive on the value).
*
* Use to client-side filter discovery surfaces — e.g. the moderator
* "Pending review" list on `/communities` only surfaces orgs minted
* through Agora's create flow, so reviewers aren't drowning in every
* kind 34550 community on the network.
*/
export function hasAgoraTag(tags: string[][]): boolean {
return tags.some(
([name, value]) => name === 't' && value?.toLowerCase() === 'agora',
);
}
+144 -241
View File
@@ -1,79 +1,67 @@
import type { NostrEvent } from '@nostrify/nostrify';
import * as bitcoin from 'bitcoinjs-lib';
import { nip19 } from 'nostr-tools';
import { COUNTRIES } from '@/lib/countries';
import { parseCountryIdentifier } from '@/lib/countryIdentifiers';
/** Addressable kind number for fundraising campaigns (see NIP.md). */
export const CAMPAIGN_KIND = 30223;
/**
* Addressable kind number for fundraising campaigns (see NIP.md, Kind 33863).
*
* Campaigns are self-authored — the event author owns the wallet declared
* in the `w` tag and is the sole beneficiary of donations. There is no
* recipient list, no split logic, and no on-behalf-of authorship.
*/
export const CAMPAIGN_KIND = 33863;
/** Canonical category slugs shown in the create-campaign form. */
export const CAMPAIGN_CATEGORIES = [
'human-rights',
'civil-liberties',
'democracy',
'political-prisoners',
'legal-defense',
'independent-media',
'humanitarian-aid',
'emergency-relief',
'education',
'community',
'medical-aid',
'other',
] as const;
/**
* Two ways a campaign can accept donations, distinguished by the `w` tag's
* bech32(m) prefix:
*
* - **`onchain`** — the wallet is a public mainnet on-chain bech32(m)
* address (`bc1q…` segwit v0 or `bc1p…` Taproot). Donations are
* traceable; clients show progress, totals, and recent donations.
* - **`sp`** — the wallet is a BIP-352 silent-payment code (`sp1…`).
* Donations are unlinkable by design; clients MUST hide all aggregate
* UI and MUST NOT publish donation receipts.
*/
export type CampaignWalletMode = 'onchain' | 'sp';
export type CampaignCategory = typeof CAMPAIGN_CATEGORIES[number];
/** Parsed wallet endpoint declared by a campaign's `w` tag. */
export interface CampaignWallet {
/** Raw bech32(m) string as it appears in the `w` tag. */
value: string;
/** Mode derived from the prefix. */
mode: CampaignWalletMode;
}
/** Human-readable labels for category slugs. */
export const CAMPAIGN_CATEGORY_LABELS: Record<CampaignCategory, string> = {
'human-rights': 'Human Rights',
'civil-liberties': 'Civil Liberties',
democracy: 'Democracy & Free Elections',
'political-prisoners': 'Political Prisoners',
'legal-defense': 'Legal Defense',
'independent-media': 'Independent Media',
'humanitarian-aid': 'Humanitarian Aid',
'emergency-relief': 'Emergency Relief',
education: 'Education & Training',
community: 'Community Organizing',
'medical-aid': 'Medical Aid',
other: 'Other',
};
const LEGACY_CAMPAIGN_CATEGORY_ALIASES: Record<string, CampaignCategory> = {
medical: 'medical-aid',
emergency: 'emergency-relief',
animals: 'humanitarian-aid',
sports: 'community',
creative: 'community',
business: 'community',
faith: 'community',
memorial: 'other',
};
/** A 64-character lowercase hex string (Nostr pubkey or event id). */
const HEX_64_RE = /^[0-9a-f]{64}$/;
/** A campaign recipient parsed from a single `p` tag. */
export interface CampaignRecipient {
/** Lowercase hex pubkey. */
pubkey: string;
/** Optional relay hint provided in the `p` tag. */
relay?: string;
/** Positive split weight. Defaults to 1 when the `p` tag does not supply one. */
weight: number;
/**
* NIP-92 imeta block parsed from a campaign event. Pairs with the
* `banner` tag (`url` MUST match the banner URL — clients ignore an
* imeta whose URL does not match).
*/
export interface CampaignBannerImeta {
url: string;
/** MIME type, e.g. `image/jpeg`. */
m?: string;
/** SHA-256 of the file (lowercase hex). */
x?: string;
/** `WIDTHxHEIGHT` as published, kept verbatim. */
dim?: string;
blurhash?: string;
/** Accessibility alt text for the banner (distinct from event-level NIP-31 alt). */
alt?: string;
}
/** A fully-parsed campaign with everything the UI needs. */
export interface ParsedCampaign {
/** The original event. */
event: NostrEvent;
/** Campaign creator's hex pubkey. */
/** Campaign creator's hex pubkey (the beneficiary). */
pubkey: string;
/** The campaign's `d` tag (slug). */
identifier: string;
/** Addressable coordinate `30223:<pubkey>:<d>`. */
/** Addressable coordinate `33863:<pubkey>:<d>`. */
aTag: string;
/** Campaign title. */
title: string;
@@ -81,31 +69,20 @@ export interface ParsedCampaign {
summary: string;
/** Markdown story (the event content). */
story: string;
/** Sanitized HTTPS cover image URL, or `undefined` if missing/invalid. */
image?: string;
/** Category slug from the first `t` tag matching a known category, or `undefined`. */
category?: CampaignCategory;
/** Campaign tags parsed from all `t` tags, in event order. */
tags: string[];
/** Goal in satoshis, or `undefined` if not set. */
goalSats?: number;
/** Sanitized HTTPS banner URL, or `undefined` if missing/invalid. */
banner?: string;
/** NIP-92 imeta for the banner. Only present when the imeta's `url` matches the banner. */
bannerImeta?: CampaignBannerImeta;
/** Bitcoin wallet endpoint (required). */
wallet: CampaignWallet;
/** Fundraising goal in **integer US Dollars**, or `undefined` if not set. */
goalUsd?: number;
/** Deadline (Unix seconds), or `undefined` if not set. */
deadline?: number;
/** Human-readable location string. */
location?: string;
/** ISO 3166-1 alpha-2 country code parsed from a NIP-73 `i` tag. */
countryCode?: string;
/** Validated recipient list (always at least one). */
recipients: CampaignRecipient[];
/** Created-at from the event. */
createdAt: number;
/**
* True when the creator has marked the campaign closed via
* `["status", "archived"]`. Archived campaigns are hidden from main
* listings but still load by direct link so existing donors can find
* them and donation history is preserved.
*/
archived: boolean;
}
/** Returns the first value of a tag, or undefined. */
@@ -130,29 +107,85 @@ function getCountryCode(event: NostrEvent): string | undefined {
return undefined;
}
function getCampaignTags(event: NostrEvent): string[] {
const seen = new Set<string>();
const tags: string[] = [];
for (const [name, value] of event.tags) {
if (name !== 't' || typeof value !== 'string') continue;
const tag = value.trim();
if (!tag || seen.has(tag)) continue;
seen.add(tag);
tags.push(tag);
/**
* Parse a campaign wallet endpoint, returning the parsed wallet on success
* or `null` if the string is missing, malformed, or for a network we
* don't accept (testnet, regtest, etc.).
*
* Mode is inferred from the bech32 HRP/prefix:
*
* - `bc1q…` / `bc1p…` → mainnet on-chain (validated via bitcoinjs-lib).
* - `sp1…` → BIP-352 silent-payment code (validated via prefix + bech32m
* checksum from `@scure/base`-style parsing in `bitcoinjs-lib`).
*
* Any other prefix (`tb1…`, `bcrt1…`, `tsp1…`, `lnbc…`, etc.) is rejected.
*/
export function parseCampaignWallet(value: string | undefined): CampaignWallet | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
// On-chain mainnet: bc1q (segwit v0) or bc1p (Taproot v1).
if (/^bc1[qp]/i.test(trimmed)) {
try {
bitcoin.address.toOutputScript(trimmed, bitcoin.networks.bitcoin);
} catch {
return null;
}
return { value: trimmed, mode: 'onchain' };
}
return tags;
// Silent payments: sp1 followed by bech32m payload (mainnet only).
if (/^sp1[02-9ac-hj-np-z]+$/i.test(trimmed)) {
// bitcoinjs-lib doesn't currently parse BIP-352 codes, so we accept any
// bech32m-shaped sp1 string. The checksum is verified by the donor's
// wallet when it derives the payment output; an invalid code there
// simply fails the donation flow.
return { value: trimmed, mode: 'sp' };
}
return null;
}
/**
* Parses a kind 30223 event into a strongly-typed campaign, or returns
* `null` if the event is missing required fields (title, `d` tag, or at
* least one valid recipient).
*
* `p` tag rules:
* - 2nd element MUST be a 64-char lowercase hex pubkey.
* - 3rd element is treated as a relay hint when it looks like a relay URL.
* - 4th element is a positive decimal weight; missing/invalid -> 1.
* - Duplicate pubkeys collapse to the first occurrence.
* Parse the NIP-92 `imeta` tag whose `url` matches the campaign's banner.
* Returns `undefined` if no matching imeta is found.
*/
function getBannerImeta(event: NostrEvent, bannerUrl: string | undefined): CampaignBannerImeta | undefined {
if (!bannerUrl) return undefined;
for (const tag of event.tags) {
if (tag[0] !== 'imeta') continue;
// Each entry after the tag name is a space-separated `key value` pair.
const fields: Record<string, string> = {};
for (let i = 1; i < tag.length; i++) {
const entry = tag[i];
if (typeof entry !== 'string') continue;
const spaceIdx = entry.indexOf(' ');
if (spaceIdx <= 0) continue;
const key = entry.slice(0, spaceIdx);
const val = entry.slice(spaceIdx + 1);
fields[key] = val;
}
if (fields.url !== bannerUrl) continue;
return {
url: fields.url,
m: fields.m,
x: fields.x,
dim: fields.dim,
blurhash: fields.blurhash,
alt: fields.alt,
};
}
return undefined;
}
/**
* Parses a kind 33863 event into a strongly-typed campaign, or returns
* `null` if the event is missing a required field (kind, `d`, `title`, or
* a valid `w` wallet endpoint).
*/
export function parseCampaign(event: NostrEvent): ParsedCampaign | null {
if (event.kind !== CAMPAIGN_KIND) return null;
@@ -161,49 +194,15 @@ export function parseCampaign(event: NostrEvent): ParsedCampaign | null {
const title = getTag(event, 'title');
if (!identifier || !title) return null;
const seen = new Set<string>();
const recipients: CampaignRecipient[] = [];
for (const tag of event.tags) {
if (tag[0] !== 'p') continue;
const pubkey = tag[1];
if (typeof pubkey !== 'string' || !HEX_64_RE.test(pubkey)) continue;
if (seen.has(pubkey)) continue;
seen.add(pubkey);
const wallet = parseCampaignWallet(getTag(event, 'w'));
if (!wallet) return null;
const maybeRelay = typeof tag[2] === 'string' && tag[2].startsWith('ws') ? tag[2] : undefined;
const rawWeight = typeof tag[3] === 'string' ? Number(tag[3]) : NaN;
const weight = Number.isFinite(rawWeight) && rawWeight > 0 ? rawWeight : 1;
recipients.push({ pubkey, relay: maybeRelay, weight });
}
if (recipients.length === 0) return null;
// Category from the first `t` tag whose value is a recognized slug.
let category: CampaignCategory | undefined;
for (const [name, value] of event.tags) {
if (name !== 't' || typeof value !== 'string') continue;
if ((CAMPAIGN_CATEGORIES as readonly string[]).includes(value)) {
category = value as CampaignCategory;
break;
}
const legacyCategory = LEGACY_CAMPAIGN_CATEGORY_ALIASES[value];
if (legacyCategory) {
category = legacyCategory;
break;
}
}
// Image — only accept https URLs. We do the formal sanitizeUrl pass at the
// render site (since this lib runs in tests without DOM); strip non-https here.
const rawImage = getTag(event, 'image');
const image = rawImage && /^https:\/\//i.test(rawImage) ? rawImage : undefined;
// Status tag. We only recognize `archived` today; any other value is
// ignored so future statuses (e.g. `paused`, `funded`) don't accidentally
// get treated as archived.
const archived = getTag(event, 'status') === 'archived';
const tags = getCampaignTags(event);
// Banner — only accept https URLs. Formal sanitizeUrl pass happens at
// the render site (this lib runs in tests without DOM); strip non-https
// here so the parsed value is safe to interpolate into a fetch().
const rawBanner = getTag(event, 'banner');
const banner = rawBanner && /^https:\/\//i.test(rawBanner) ? rawBanner : undefined;
const bannerImeta = getBannerImeta(event, banner);
return {
event,
@@ -213,117 +212,21 @@ export function parseCampaign(event: NostrEvent): ParsedCampaign | null {
title: title.trim(),
summary: getTag(event, 'summary')?.trim() ?? '',
story: event.content,
image,
category,
tags,
goalSats: parsePositiveInt(getTag(event, 'goal')),
banner,
bannerImeta,
wallet,
goalUsd: parsePositiveInt(getTag(event, 'goal')),
deadline: parsePositiveInt(getTag(event, 'deadline')),
location: getTag(event, 'location')?.trim() || undefined,
countryCode: getCountryCode(event),
recipients,
createdAt: event.created_at,
archived,
};
}
/** Human display for a campaign's structured country, falling back to legacy location text. */
/** Human display for a campaign's country code, including the flag emoji. */
export function getCampaignCountryLabel(campaign: ParsedCampaign): string | undefined {
const country = campaign.countryCode ? COUNTRIES[campaign.countryCode] : undefined;
if (country) return `${country.flag} ${country.name}`;
return campaign.location;
}
export function getCampaignPrimaryTagLabel(campaign: ParsedCampaign): string | undefined {
const firstTag = campaign.tags[0] ?? campaign.category;
if (!firstTag) return undefined;
if ((CAMPAIGN_CATEGORIES as readonly string[]).includes(firstTag)) {
return CAMPAIGN_CATEGORY_LABELS[firstTag as CampaignCategory];
}
const legacyCategory = LEGACY_CAMPAIGN_CATEGORY_ALIASES[firstTag];
if (legacyCategory) return CAMPAIGN_CATEGORY_LABELS[legacyCategory];
return firstTag.replace(/-/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
}
/** Output of {@link splitDonation}: per-recipient amounts in sats. */
export interface DonationSplit {
pubkey: string;
weight: number;
/** Whole satoshis allocated to this recipient. */
amountSats: number;
}
/**
* Splits a donation across the campaign's recipients according to each
* recipient's weight (defaulting to equal shares when weights are uniform).
*
* Rules:
* 1. Each recipient's share is `floor(totalSats * weight / sumOfWeights)`.
* 2. Any rounding remainder is appended to the recipient with the largest
* weight (ties broken by original `p` tag order) so the entire donation
* reaches the campaign.
* 3. Self-donations (recipient pubkey equals donor pubkey) are dropped from
* the output. The donor's share would be a no-op output paying their own
* Taproot address and only inflates the on-chain fee.
*
* Throws if `totalSats` is not a positive finite integer, or if there are no
* non-self recipients.
*/
export function splitDonation(
recipients: CampaignRecipient[],
totalSats: number,
donorPubkey: string | undefined,
): DonationSplit[] {
if (!Number.isFinite(totalSats) || !Number.isInteger(totalSats) || totalSats <= 0) {
throw new Error('Donation amount must be a positive integer (satoshis).');
}
const payable = recipients.filter((r) => r.pubkey !== donorPubkey);
if (payable.length === 0) {
throw new Error('No eligible recipients (donor cannot donate to themselves).');
}
const totalWeight = payable.reduce((sum, r) => sum + r.weight, 0);
if (totalWeight <= 0) {
throw new Error('Recipient weights must sum to a positive number.');
}
const splits: DonationSplit[] = payable.map((r) => ({
pubkey: r.pubkey,
weight: r.weight,
amountSats: Math.floor((totalSats * r.weight) / totalWeight),
}));
const allocated = splits.reduce((sum, s) => sum + s.amountSats, 0);
const remainder = totalSats - allocated;
if (remainder > 0) {
let largestIdx = 0;
for (let i = 1; i < splits.length; i++) {
if (splits[i].weight > splits[largestIdx].weight) largestIdx = i;
}
splits[largestIdx].amountSats += remainder;
}
return splits;
}
/**
* Computes the smallest donation total (in sats) where every recipient
* receives at least `dustLimit` sats. Used to surface a helpful minimum
* before the donor tries to sign a PSBT that would throw on dust.
*/
export function minDonationForSplit(
recipients: CampaignRecipient[],
donorPubkey: string | undefined,
dustLimit: number,
): number {
const payable = recipients.filter((r) => r.pubkey !== donorPubkey);
if (payable.length === 0) return dustLimit;
const totalWeight = payable.reduce((sum, r) => sum + r.weight, 0);
const smallestShare = Math.min(...payable.map((r) => r.weight));
// Need: floor(T * smallestShare / totalWeight) >= dustLimit
// -> T >= ceil(dustLimit * totalWeight / smallestShare)
return Math.ceil((dustLimit * totalWeight) / smallestShare);
if (!country) return undefined;
return `${country.flag} ${country.name}`;
}
/** Encodes a campaign's addressable coordinate as a NIP-19 `naddr1...` string. */
+8 -1
View File
@@ -63,6 +63,12 @@ export interface ParsedCommunity {
relays: string[];
/** The `a` tag coordinate for the community: `34550:<pubkey>:<d-tag>`. */
aTag: string;
/**
* Raw tag array from the underlying kind 34550 event. Carried through so
* non-essential, non-indexed markers (e.g. Agora's `["t", "agora"]`
* content marker) can be inspected without re-fetching the event.
*/
tags: string[][];
}
/**
@@ -101,6 +107,7 @@ export function parseCommunityEvent(event: NostrEvent): ParsedCommunity | null {
moderatorPubkeys,
relays,
aTag: `${COMMUNITY_DEFINITION_KIND}:${event.pubkey}:${dTag}`,
tags: event.tags,
};
}
@@ -486,7 +493,7 @@ export function canModerateOrganization(
*
* Agora restricts creation of organization-tagged campaigns / pledges /
* events in the client to this set, but anyone could technically publish
* a kind 30223 / 36639 / 31922 / 31923 with the organization's uppercase
* a kind 33863 / 36639 / 31922 / 31923 with the organization's uppercase
* `A` root-scope tag outside the client. Queries that surface "official"
* organization activity MUST pass this list as the `authors` filter so
* forged events from non-moderators never reach the UI.
+34 -27
View File
@@ -74,6 +74,8 @@ export interface ExtraKindDef {
blurb?: string;
/** External sites where users can create or participate in this kind of content. */
sites?: ExtraKindSite[];
/** If true, this kind is part of the Agora-curated content set and surfaces in the settings UI. */
agora?: boolean;
}
/** All supported extra content kinds, ordered by section (feed → media → social → whimsy). */
@@ -88,46 +90,41 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
addressable: false,
section: 'feed',
feedOnly: true,
},
{
kind: 1111,
id: 'comments',
feedKey: 'feedIncludeComments',
label: 'Comments',
description: 'NIP-22 comments on posts and external content',
addressable: false,
section: 'feed',
feedOnly: true,
agora: true,
},
{
kind: 6,
id: 'reposts',
feedKey: 'feedIncludeReposts',
label: 'Reposted Notes',
description: 'Shared posts from others',
extraFeedKinds: [16],
label: 'Reposts',
description: 'Posts and media reshared by others',
addressable: false,
section: 'feed',
feedOnly: true,
agora: true,
},
{
kind: 16,
id: 'generic-reposts',
feedKey: 'feedIncludeGenericReposts',
label: 'Reposted Other Content',
description: 'Shared non-text-note posts from others',
kind: 1111,
id: 'comments',
feedKey: 'feedIncludeComments',
label: 'Replies',
description: 'Threaded replies to posts',
addressable: false,
section: 'feed',
feedOnly: true,
agora: true,
},
{
kind: 7,
id: 'reactions',
feedKey: 'feedIncludeReactions',
label: 'Reactions',
description: 'People reacting to posts (likes and emoji reactions). Disabled by default.',
description: 'Likes and emoji reactions on posts',
addressable: false,
section: 'feed',
feedOnly: true,
agora: true,
},
{
kind: 9735,
@@ -137,10 +134,11 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
// toggle so users don't have to think about which rail was used.
extraFeedKinds: [8333],
label: 'Zaps',
description: 'People zapping posts (Lightning and on-chain Bitcoin). Disabled by default.',
description: 'Bitcoin tips sent to posts',
addressable: false,
section: 'feed',
feedOnly: true,
agora: true,
},
{
kind: 30023,
@@ -153,6 +151,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
addressable: true,
section: 'feed',
blurb: 'Blog posts, essays, and guides. Write and publish long-form articles.',
agora: true,
},
// Media
{
@@ -161,24 +160,26 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
showKey: 'showPhotos',
feedKey: 'feedIncludePhotos',
label: 'Photos',
description: 'Picture-first posts (NIP-68)',
description: 'Picture-first posts',
route: 'photos',
addressable: false,
section: 'media',
blurb: 'Instagram-style photo posts. Share images with captions and tags.',
sites: [{ url: 'https://nostr.build', name: 'nostr.build' }],
agora: true,
},
{
kind: 21,
id: 'videos',
showKey: 'showVideos',
label: 'Videos',
description: 'Video posts (NIP-71 kinds 21 & 22) and live streams',
description: 'Recorded videos and live streams',
route: 'videos',
addressable: false,
section: 'media',
blurb: 'Watch and discover videos and live streams in a YouTube/Twitch-style interface.',
sites: [{ url: 'https://zap.stream', name: 'zap.stream' }, { url: 'https://vidstr.shakespeare.wtf', name: 'Vidstr' }],
agora: true,
subKinds: [
{
kind: 21,
@@ -203,11 +204,12 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
id: 'voice',
feedKey: 'feedIncludeVoiceMessages',
label: 'Voice Messages',
description: 'Short audio voice messages (NIP-A0)',
description: 'Short audio recordings',
addressable: false,
section: 'media',
feedOnly: true,
blurb: 'Record and share short voice messages, up to 60 seconds long.',
agora: true,
},
{
kind: 34236,
@@ -300,12 +302,13 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
feedKey: 'feedIncludeEvents',
extraFeedKinds: [31922],
label: 'Events',
description: 'Calendar events and meetups (NIP-52)',
description: 'Calendar events and meetups',
route: 'events',
addressable: true,
section: 'social',
blurb: 'Events and meetups on Nostr. RSVP and see who else is going. Create and manage events on Plektos.',
sites: [{ url: 'https://plektos.app', name: 'Plektos' }],
agora: true,
},
{
kind: 1063,
@@ -332,6 +335,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
section: 'social',
blurb: 'Ask a question, let people vote. Create polls from a polling app.',
sites: [{ url: 'https://pollerama.fun' }],
agora: true,
},
{
kind: 39089,
@@ -356,11 +360,12 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
feedKey: 'feedIncludeCommunities',
extraFeedKinds: [9041],
label: 'Organizations',
description: 'Agora organizations with founder + moderator trust model (NIP-72)',
description: 'Coalitions and groups with founders and moderators',
route: 'communities',
addressable: true,
section: 'social',
blurb: 'Organizations on Nostr with an explicit founder, listed moderators, and event-level moderation.',
agora: true,
},
{
kind: 62,
@@ -417,11 +422,12 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
id: 'badges',
showKey: 'showBadges',
label: 'Badges',
description: 'Badges and awards (NIP-58)',
description: 'Awards and recognition issued to people',
route: 'badges',
addressable: true,
section: 'whimsy',
section: 'social',
blurb: 'Discover badges created on Nostr. Badge issuers award them for recognition, participation, or appreciation.',
agora: true,
subKinds: [
{
kind: 30009,
@@ -485,11 +491,12 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
showKey: 'showHighlights',
feedKey: 'feedIncludeHighlights',
label: 'Highlights',
description: 'Noteworthy excerpts from articles, posts, and the web (NIP-84)',
description: 'Noteworthy excerpts from articles, posts, and the web',
route: 'highlights',
addressable: false,
section: 'social',
blurb: "Highlights are excerpts people find valuable — a paragraph from an article, a passage from a blog post, or a quote from anywhere on the web. Browse what people are reading and what's resonating.",
agora: true,
},
// Birdstar (feed-only — external app, no Ditto page)
{
+46 -1
View File
@@ -10,9 +10,54 @@ type KindOption = {
icon: React.ComponentType<{ className?: string }> | undefined;
};
/** Build the kind options from EXTRA_KINDS definitions. */
/**
* Agora-native kinds that are not modeled in EXTRA_KINDS (which drives the
* sidebar / feed-settings UI). We surface them in the search Kind picker
* because they are first-class searchable content on Agora.
*/
const AGORA_NATIVE_KIND_OPTIONS: KindOption[] = [
{
value: '33863',
label: 'Campaigns (33863)',
description: 'Fundraising campaigns',
parentId: 'campaigns',
icon: CONTENT_KIND_ICONS['campaigns'],
},
{
value: '36639',
label: 'Pledges (36639)',
description: 'Donor pledges for concrete actions',
parentId: 'actions',
icon: CONTENT_KIND_ICONS['actions'],
},
];
/**
* Agora's curated "main content" kinds, surfaced as a preset section at the
* top of the KindPicker. Order is the order they appear in the picker.
*
* Includes Agora-native kinds plus the existing-NIP kinds Agora foregrounds
* (campaigns, pledges, communities, posts, articles, events, polls, photos,
* videos). Excludes social-signal kinds (reactions, reposts, zaps) and
* stats snapshots, which users rarely filter on directly.
*/
export const AGORA_PRESET_KIND_VALUES: readonly string[] = [
'33863', // Campaigns
'36639', // Pledges
'34550', // Communities
'1', // Posts
'30023', // Articles
'31923', // Events (time)
'1068', // Polls
'20', // Photos
'21', // Videos
] as const;
/** Build the kind options from EXTRA_KINDS definitions plus Agora-native kinds. */
export function buildKindOptions(): KindOption[] {
const options: KindOption[] = [];
// Agora-native kinds appear first so they're easy to find.
for (const opt of AGORA_NATIVE_KIND_OPTIONS) options.push(opt);
for (const def of EXTRA_KINDS) {
if (def.subKinds) {
for (const sub of def.subKinds) {
+20
View File
@@ -25,3 +25,23 @@ export function formatCampaignAmount(sats: number, btcPrice: number | undefined)
if (btcPrice) return satsToUSDWhole(sats, btcPrice);
return formatSatsShort(sats);
}
/**
* Formats an integer USD amount (the campaign goal unit per NIP.md Kind
* 33863). Uses thousands separators and a leading `$`. Negative or
* non-finite values render as `$0`.
*/
export function formatUsdGoal(usd: number): string {
if (!Number.isFinite(usd) || usd <= 0) return '$0';
return `$${Math.floor(usd).toLocaleString()}`;
}
/**
* Convert sats to USD using a live BTC/USD price. Returns `undefined` if
* the price isn't available — callers should fall back to the sats
* representation in that case.
*/
export function satsToUsd(sats: number, btcPrice: number | undefined): number | undefined {
if (!btcPrice || !Number.isFinite(btcPrice) || btcPrice <= 0) return undefined;
return (sats / 100_000_000) * btcPrice;
}
+2 -1
View File
@@ -295,7 +295,6 @@ export const KIND_LABELS: Record<number, string> = {
// NIP-23
30023: 'Article',
30024: 'Draft long-form content',
30223: 'Campaign',
// NIP-30
30030: 'Emoji set',
// NKBIP-01
@@ -360,6 +359,8 @@ export const KIND_LABELS: Record<number, string> = {
// Corny Chat
32388: 'User room favorites',
33388: 'High scores',
// Agora
33863: 'Campaign',
// NIP-71
34235: 'Addressable video',
34236: 'Addressable short video',
-1
View File
@@ -221,7 +221,6 @@ export const AppConfigSchema = z.object({
clientName: z.string().optional(),
/** NIP-19 naddr1 string for the kind 31990 handler event. */
client: z.string().startsWith('naddr1').optional(),
magicMouse: z.boolean().optional(),
theme: ThemeSchema,
customTheme: ThemeConfigCompatSchema.optional(),
themes: ThemesConfigSchema.optional(),
+1 -1
View File
@@ -193,7 +193,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
{ id: "emojis", label: "Emojis", path: "/emojis", icon: SmilePlus },
{ id: "development", label: "Development", path: "/development", icon: Code },
{ id: "badges", label: "Badges", path: "/badges", icon: Award },
{ id: "communities", label: "Organize", path: "/communities", icon: Users },
{ id: "communities", label: "Groups", path: "/communities", icon: Users },
{ id: "world", label: "World", path: "/world", icon: Earth },
];
+4
View File
@@ -1,5 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { AGORA_PRESET_KIND_VALUES } from "./feedFilterUtils"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -9,12 +10,15 @@ export function cn(...inputs: ClassValue[]) {
* Parse a kindFilter string into an array of kind numbers.
* Supports:
* - 'all' → undefined (no override)
* - 'agora' → the AGORA_PRESET_KIND_VALUES set (Campaigns, Pledges,
* Communities, Posts, Articles, Events, Polls, Photos, Videos)
* - 'custom' → parse customKindText as comma/space-separated numbers
* - Single kind number (e.g. '1') → [1]
* - Comma-separated kind numbers (e.g. '1,30023,20') → [1, 30023, 20]
*/
export function parseKindFilter(kindFilter: string, customKindText?: string): number[] | undefined {
if (kindFilter === 'all' || kindFilter === '') return undefined;
if (kindFilter === 'agora') return AGORA_PRESET_KIND_VALUES.map(Number);
if (kindFilter === 'custom') {
if (!customKindText) return undefined;
const parsed = customKindText.trim().split(/[\s,]+/).map(Number).filter((n) => Number.isInteger(n) && n > 0);
+1 -1
View File
@@ -106,7 +106,7 @@ export function getTargetEventId(event: NostrEvent): string | undefined {
* returns undefined for those — callers that need the kind in that case must
* fetch the target event themselves.
*
* Used to distinguish on-chain donations to campaigns (kind 30223) from
* Used to distinguish on-chain donations to campaigns (kind 33863) from
* other zap targets so the UI can label them as "donated" rather than the
* generic "sent / zapped".
*/
+97 -60
View File
@@ -17,7 +17,6 @@ import { useAction, type Action } from '@/hooks/useActions';
import { useAuthor } from '@/hooks/useAuthor';
import { useBitcoinWallet } from '@/hooks/useBitcoinWallet';
import { useComments } from '@/hooks/useComments';
import { useEventStats } from '@/hooks/useTrending';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useSubmissionZapTotals } from '@/hooks/useSubmissionZapTotals';
import { useToast } from '@/hooks/useToast';
@@ -33,14 +32,13 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import { DetailCommentComposer } from '@/components/DetailCommentComposer';
import { PostActionBar } from '@/components/PostActionBar';
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import {
InteractionsModal,
type InteractionTab,
} from '@/components/InteractionsModal';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
import NotFound from '@/pages/NotFound';
function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } {
@@ -81,13 +79,17 @@ function PledgeDetailContent({ action }: { action: Action }) {
const author = useAuthor(action.pubkey);
const navigate = useNavigate();
const { toast } = useToast();
const { data: engagementStats } = useEventStats(action.event.id, action.event);
const { data: commentsData, isLoading: commentsLoading } = useComments(action.event, 500);
const rootATag = `36639:${action.pubkey}:${action.id}`;
const {
pinnedEvents,
isPinned,
canManagePins,
togglePin,
} = usePinnedEventComments(rootATag, action.pubkey);
const [replyOpen, setReplyOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [interactionsOpen, setInteractionsOpen] = useState(false);
const [interactionsTab, setInteractionsTab] = useState<InteractionTab>('reposts');
const topLevel = useMemo(
() => commentsData?.topLevelComments ?? [],
@@ -130,17 +132,17 @@ function PledgeDetailContent({ action }: { action: Action }) {
.map((c) => buildNode(c));
}, [commentsData, topLevel, zapTotals]);
const pinnedNodes = useMemo(
() => pinnedEvents.map((event): ReplyNode => ({ event, children: [] })),
[pinnedEvents],
);
const metadata: NostrMetadata | undefined = author.data?.metadata;
const creatorName = getDisplayName(metadata, action.pubkey);
const creatorProfileUrl = useProfileUrl(action.pubkey, metadata);
const deadline = action.deadline ? formatDeadline(action.deadline) : null;
const cover = sanitizeUrl(action.image);
const progressValue = action.bounty > 0 ? Math.min(100, Math.round((fundedSats / action.bounty) * 100)) : 0;
const hasStats =
!!engagementStats?.replies ||
!!engagementStats?.reposts ||
!!engagementStats?.quotes ||
!!engagementStats?.reactions;
const naddr = nip19.naddrEncode({
kind: 36639,
@@ -156,11 +158,6 @@ function PledgeDetailContent({ action }: { action: Action }) {
[action.event],
);
const openInteractions = (tab: InteractionTab) => {
setInteractionsTab(tab);
setInteractionsOpen(true);
};
const handleShare = async () => {
const url = `${window.location.origin}/${naddr}`;
try {
@@ -187,6 +184,35 @@ function PledgeDetailContent({ action }: { action: Action }) {
onBack={() => navigate(-1)}
/>
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="rounded-b-xl rounded-t-none bg-card border border-t-0 border-border/60 shadow-sm px-4 sm:px-5 py-3">
<PostActionBar
event={action.event}
replyLabel="Submit"
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
/>
</div>
</div>
{pinnedNodes.length > 0 && (
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-6">
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
<ThreadedReplyList
roots={pinnedNodes}
renderItemHeader={(event) => (
<PledgePinHeader
isPinned={isPinned(event.id)}
canManagePins={canManagePins}
pinPending={togglePin.isPending}
onTogglePin={() => handleTogglePin(event)}
/>
)}
/>
</div>
</div>
)}
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 lg:py-10">
<div className="lg:flex lg:gap-8 lg:items-start">
<div className="lg:hidden mb-6">
@@ -205,39 +231,7 @@ function PledgeDetailContent({ action }: { action: Action }) {
<PledgeStory storyEvent={storyEvent} hasContent={action.description.trim().length > 0} />
<div id="pledge-activity" className="scroll-mt-20">
<div className="rounded-2xl bg-card border border-border/60 shadow-sm px-4 sm:px-5 py-4 sm:py-5">
{hasStats && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs sm:text-sm text-muted-foreground">
{engagementStats?.reposts ? (
<button onClick={() => openInteractions('reposts')} className="hover:underline transition-colors">
<span className="font-bold text-foreground">{engagementStats.reposts.toLocaleString()}</span>{' '}
Repost{engagementStats.reposts !== 1 ? 's' : ''}
</button>
) : null}
{engagementStats?.quotes ? (
<button onClick={() => openInteractions('quotes')} className="hover:underline transition-colors">
<span className="font-bold text-foreground">{engagementStats.quotes.toLocaleString()}</span>{' '}
Quote{engagementStats.quotes !== 1 ? 's' : ''}
</button>
) : null}
{engagementStats?.reactions ? (
<button onClick={() => openInteractions('reactions')} className="hover:underline transition-colors">
<span className="font-bold text-foreground">{engagementStats.reactions.toLocaleString()}</span>{' '}
Like{engagementStats.reactions !== 1 ? 's' : ''}
</button>
) : null}
</div>
)}
<PostActionBar
event={action.event}
replyLabel="Submit"
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
/>
</div>
<div className="mt-6">
<div>
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
<h2 className="text-lg font-semibold tracking-tight">Submissions</h2>
{topLevel.length > 0 ? (
@@ -247,13 +241,29 @@ function PledgeDetailContent({ action }: { action: Action }) {
) : null}
</div>
<DetailCommentComposer
event={action.event}
placeholder="Share proof, evidence, or completed work..."
className="mb-3"
/>
{commentsLoading && replyTree.length === 0 ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => <PledgeReplySkeleton key={i} />)}
</div>
) : replyTree.length > 0 ? (
<div className="-mx-2 sm:-mx-4 rounded-2xl bg-card border border-border/60 overflow-hidden">
<ThreadedReplyList roots={replyTree} />
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
<ThreadedReplyList
roots={replyTree}
renderItemHeader={(event) => (
<PledgePinHeader
isPinned={isPinned(event.id)}
canManagePins={canManagePins}
pinPending={togglePin.isPending}
onTogglePin={() => handleTogglePin(event)}
/>
)}
/>
</div>
) : (
<button
@@ -289,14 +299,41 @@ function PledgeDetailContent({ action }: { action: Action }) {
<ReplyComposeModal event={action.event} open={replyOpen} onOpenChange={setReplyOpen} />
<NoteMoreMenu event={action.event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<InteractionsModal
eventId={action.event.id}
open={interactionsOpen}
onOpenChange={setInteractionsOpen}
initialTab={interactionsTab}
/>
</main>
);
function handleTogglePin(event: NostrEvent) {
const wasPinned = isPinned(event.id);
togglePin.mutate(event.id, {
onSuccess: () => {
toast({ title: wasPinned ? 'Unpinned from pledge' : 'Pinned to pledge' });
},
onError: () => {
toast({ title: 'Failed to update pledge pins', variant: 'destructive' });
},
});
}
}
function PledgePinHeader({
isPinned,
canManagePins,
pinPending,
onTogglePin,
}: {
isPinned: boolean;
canManagePins: boolean;
pinPending: boolean;
onTogglePin: () => void;
}) {
return (
<PinnedCommentHeader
isPinned={isPinned}
canManagePins={canManagePins}
pinPending={pinPending}
onTogglePin={onTogglePin}
/>
);
}
interface PledgeHeroProps {
@@ -315,7 +352,7 @@ function PledgeHero({ action, cover, creatorName, creatorProfileUrl, deadline, o
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-4">
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-xl overflow-hidden bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-t-xl rounded-b-none overflow-hidden bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
<img
src={coverImage}
alt=""
+5 -9
View File
@@ -1,6 +1,5 @@
import { useSeoMeta } from '@unhead/react';
import { PageHeader } from '@/components/PageHeader';
import { IntroImage } from '@/components/IntroImage';
import { AdvancedSettings } from '@/components/AdvancedSettings';
import { WalletSettings } from '@/components/WalletSettings';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -38,14 +37,11 @@ export function AdvancedSettingsPage() {
<div className="p-4">
{/* Intro */}
<div className="flex items-center gap-4 px-3 pt-2 pb-4">
<IntroImage src="/advanced-intro.png" />
<div className="min-w-0">
<h2 className="text-sm font-semibold">Power User Settings</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Wallet connections, system configuration, and other advanced options.
</p>
</div>
<div className="px-3 pt-2 pb-4">
<h2 className="text-sm font-semibold">Power User Settings</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Wallet connections, system configuration, and other advanced options.
</p>
</div>
{/* Wallet collapsible — only when logged in */}
+13 -5
View File
@@ -44,7 +44,12 @@ function parseSort(value: string | null): CampaignSort {
* canonical URL stays clean. Useful for sharing search results.
*/
export function AllCampaignsPage() {
useLayoutOptions({ rightSidebar: null });
// `noMaxWidth: true` drops MainLayout's default `sidebar:max-w-[600px]`
// cap so the campaign grid can spread to 3-4 columns on desktop, the
// same width budget the Pledge index gets. The inner `max-w-7xl`
// wrapper still keeps the content from sprawling on ultrawide
// monitors.
useLayoutOptions({ noMaxWidth: true, rightSidebar: null });
const { config } = useAppContext();
// URL state — sort and query live in the URL so results are shareable.
@@ -216,10 +221,13 @@ export function AllCampaignsPage() {
</div>
</div>
{/* Grid — capped at 2 columns per row regardless of viewport. */}
{/* Grid — widens to 3 columns at lg and 4 at xl so desktop users
can scan more campaigns at once, matching the Pledge index's
card density. Mobile and small tablets stay single / double
column so the cards keep their tappable size. */}
{showSkeleton ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{Array.from({ length: 4 }).map((_, i) => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => (
<CampaignCardSkeleton key={i} />
))}
</div>
@@ -265,7 +273,7 @@ export function AllCampaignsPage() {
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{visible.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
+5 -9
View File
@@ -1,7 +1,6 @@
import { useSeoMeta } from '@unhead/react';
import { Monitor, Moon, Sun } from 'lucide-react';
import { PageHeader } from '@/components/PageHeader';
import { IntroImage } from '@/components/IntroImage';
import { useAppContext } from '@/hooks/useAppContext';
import { useTheme } from '@/hooks/useTheme';
import { cn } from '@/lib/utils';
@@ -64,14 +63,11 @@ export function AppearanceSettingsPage() {
<div className="p-4">
{/* Intro */}
<div className="flex items-center gap-4 px-3 pt-2 pb-6">
<IntroImage src="/theme-intro.png" />
<div className="min-w-0">
<h2 className="text-sm font-semibold">Color Mode</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Pick your preferred color mode. System will automatically match your device's light or dark setting.
</p>
</div>
<div className="px-3 pt-2 pb-6">
<h2 className="text-sm font-semibold">Color Mode</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Pick your preferred color mode. System will automatically match your device's light or dark setting.
</p>
</div>
{/* Theme options */}
File diff suppressed because it is too large Load Diff
+8 -10
View File
@@ -24,7 +24,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
import { type ParsedCampaign } from '@/lib/campaign';
import { searchCountry } from '@/lib/countries';
import { getCoordinates } from '@/lib/coordinates';
/** Cap on how many featured campaigns we render in the home-page row. */
@@ -67,7 +67,7 @@ export function CampaignsPage() {
if (!moderation || !featuredCampaigns) return [];
const order = moderation.featuredOrder;
return [...featuredCampaigns]
.filter((c) => featuredCoords.includes(c.aTag) && !c.archived)
.filter((c) => featuredCoords.includes(c.aTag))
.sort((a, b) => (order.get(b.aTag) ?? 0) - (order.get(a.aTag) ?? 0))
.slice(0, MAX_FEATURED);
}, [featuredCampaigns, featuredCoords, moderation]);
@@ -150,12 +150,10 @@ export function CampaignsPage() {
);
}, [isMod, user, moderation, ownCampaigns]);
// Build the spotlight pool from the featured campaigns only.
// New events use NIP-73 `i` country tags; legacy campaigns can still
// resolve from the old free-form `location` field. A featured campaign
// without a resolvable country is silently dropped — the globe needs
// coordinates to pin it, and the banner cycles in lockstep with the
// globe so the two stay in sync.
// Build the spotlight pool from the featured campaigns only. A featured
// campaign without a resolvable country is silently dropped — the globe
// needs coordinates to pin it, and the banner cycles in lockstep with
// the globe so the two stay in sync.
const spotlightables = useMemo(() => {
type Entry = {
key: string;
@@ -169,7 +167,7 @@ export function CampaignsPage() {
const add = (c: ParsedCampaign) => {
if (seenAtag.has(c.aTag)) return;
const countryCode = c.countryCode ?? (c.location ? searchCountry(c.location)?.country.code : undefined);
const countryCode = c.countryCode;
if (!countryCode) return;
const coords = getCoordinates(countryCode);
if (!coords) return;
@@ -257,7 +255,7 @@ export function CampaignsPage() {
Inspired by the Treasures HeroGallery pattern: the photo IS the
background, everything else floats over it. */}
<section className="relative overflow-hidden border-b border-border bg-secondary/40">
<CampaignHeroBackground imageUrl={spotlightCampaign?.image} />
<CampaignHeroBackground imageUrl={spotlightCampaign?.banner} />
{/* Per-campaign hopeful color atmosphere. Sits on top of the
photo and below the globe so its mix-blend-mode: screen layers
-275
View File
@@ -1,275 +0,0 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import { Check, Copy, HandHeart, LogIn, Send, Sparkles } from 'lucide-react';
import { AgoraLogo } from '@/components/AgoraLogo';
import AuthDialog from '@/components/auth/AuthDialog';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useAppContext } from '@/hooks/useAppContext';
import { useCampaigns } from '@/hooks/useCampaigns';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
/**
* Landing page for someone who already has a Nostr account and was told a
* campaign was started on their behalf. The page splits cleanly:
*
* - Logged out: hero + a single "Sign in" button that opens the auth dialog.
* - Logged in: query for campaigns whose `p` tags include `user.pubkey`
* and surface them as a list of CampaignCards, so the user can click
* through to view, edit metadata (if they ever take ownership), or just
* watch donations arrive.
*
* "Claim" is a UI metaphor — there's nothing on-chain to claim, since
* donations already land in the recipient's Taproot address derived from
* their pubkey. Filling in a profile is the only real action needed.
*/
export function ClaimPage() {
const { user } = useCurrentUser();
const { config } = useAppContext();
const [loginOpen, setLoginOpen] = useState(false);
useSeoMeta({
title: `Claim your campaign | ${config.appName}`,
description:
'Sign in to see fundraising campaigns started for you on Agora and start receiving Bitcoin donations.',
});
return (
<main className="min-h-dvh bg-gradient-to-br from-primary/10 via-background to-secondary/40">
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-10 lg:py-16">
<header className="flex items-center justify-between mb-10">
<Link to="/" className="flex items-center gap-2">
<AgoraLogo size={36} />
<span className="font-bold text-lg">{config.appName}</span>
</Link>
<Button variant="ghost" asChild>
<Link to="/receive">Need an account?</Link>
</Button>
</header>
{user ? <ClaimedCampaigns pubkey={user.pubkey} /> : <ClaimLoggedOut onLogin={() => setLoginOpen(true)} />}
<AuthDialog
isOpen={loginOpen}
onClose={() => setLoginOpen(false)}
/>
</div>
</main>
);
}
function ClaimLoggedOut({ onLogin }: { onLogin: () => void }) {
return (
<section className="space-y-6 text-center sm:text-left">
<div className="inline-flex items-center gap-2 rounded-full bg-background/70 backdrop-blur px-3 py-1 border border-border text-xs font-medium">
<Sparkles className="size-3.5 text-primary" />
Someone started a fundraiser for you
</div>
<h1 className="text-3xl sm:text-5xl font-bold tracking-tight leading-[1.1]">
Sign in to claim your campaign.
</h1>
<p className="text-base sm:text-lg text-muted-foreground max-w-2xl">
Sign in with the Nostr account the fundraiser was set up for, and you'll see every
campaign that lists you as a beneficiary. Donations are sent directly to your wallet — no
balance to withdraw, no platform holding your funds.
</p>
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<Button size="lg" onClick={onLogin} className="rounded-full">
<LogIn className="size-4 mr-2" />
Sign in to claim
</Button>
<Button size="lg" variant="outline" asChild className="rounded-full">
<Link to="/receive">I don't have an account yet</Link>
</Button>
</div>
<Card className="mt-4">
<CardContent className="py-6 px-6 space-y-3">
<h2 className="text-base font-semibold flex items-center gap-2">
<HandHeart className="size-4 text-primary" />
What "claiming" means
</h2>
<p className="text-sm text-muted-foreground">
There's nothing on-chain to redeem — donations are sent straight to the Bitcoin
address derived from your Nostr key, whether you've signed in to Agora yet or not.
Signing in just lets you see the campaign, update your public profile, and confirm
what's been donated so far.
</p>
</CardContent>
</Card>
</section>
);
}
function ClaimedCampaigns({ pubkey }: { pubkey: string }) {
// includeArchived so that even an archived campaign the user is the
// recipient of still shows up here — they may want to ask the creator
// to reopen it.
const { data: campaigns, isLoading } = useCampaigns({
recipientPubkeys: [pubkey],
includeArchived: true,
limit: 60,
});
return (
<section className="space-y-6">
<div className="space-y-2">
<div className="inline-flex items-center gap-2 rounded-full bg-background/70 backdrop-blur px-3 py-1 border border-border text-xs font-medium">
<Sparkles className="size-3.5 text-primary" />
You're signed in
</div>
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight leading-[1.1]">
Campaigns started for you
</h1>
<p className="text-base text-muted-foreground max-w-2xl">
Any campaign that lists your account as a beneficiary appears here. Donations land
directly in your Bitcoin wallet no action required to start receiving.
</p>
</div>
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<CampaignCardSkeleton />
<CampaignCardSkeleton />
</div>
) : campaigns && campaigns.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{campaigns.map((c) => (
<CampaignCard key={c.aTag} campaign={c} />
))}
</div>
) : (
// Empty state. The primary action a freshly-signed-up invitee needs
// here is sending their new npub back to whoever invited them — once
// the inviter adds them as a recipient, the campaign will show up on
// a future visit. The "no campaigns matched" detail copy stays, but
// demoted below the send-npub card.
<div className="space-y-5">
<SendNpubCard pubkey={pubkey} />
<Card className="border-dashed">
<CardContent className="py-8 px-6 text-center space-y-3">
<HandHeart className="size-8 text-muted-foreground/60 mx-auto" />
<h2 className="text-base font-semibold">Expecting a campaign already?</h2>
<p className="text-muted-foreground max-w-md mx-auto text-sm">
We didn't find a campaign that lists this account as a beneficiary. If you were
told one was set up for you, double-check with the organizer that they used the
same Nostr public key (npub) you just signed in with.
</p>
<div className="pt-1">
<Button variant="outline" size="sm" asChild>
<Link to="/settings/profile">Edit my profile</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
)}
<div className="text-sm text-muted-foreground border-t pt-6">
Next step: complete your{' '}
<Link to="/settings/profile" className="text-primary hover:underline">
public profile
</Link>{' '}
so donors recognize you.
</div>
</section>
);
}
export default ClaimPage;
/**
* Surfaced in the empty-state path of {@link ClaimedCampaigns}.
*
* When a freshly-signed-up invitee lands here and no campaign yet lists them
* as a beneficiary, the most likely explanation is that the inviter is still
* waiting on the invitee's npub to add them. This card hands them a
* one-tap copy of their own npub so they can paste it back into whatever
* channel the original invite came from.
*
* Pure UX no event publishing, no analytics ping back to the inviter. The
* invitee still has to send the message manually; we just remove the friction
* of hunting for their npub in settings.
*/
function SendNpubCard({ pubkey }: { pubkey: string }) {
const { toast } = useToast();
const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
const [copiedNpub, setCopiedNpub] = useState(false);
const writeClipboard = async () => {
try {
await navigator.clipboard.writeText(npub);
setCopiedNpub(true);
setTimeout(() => setCopiedNpub(false), 1500);
toast({ title: 'Npub copied' });
} catch {
toast({
title: 'Copy failed',
description: 'Your browser blocked clipboard access. Select and copy the npub manually.',
variant: 'destructive',
});
}
};
return (
<Card className="border-2 border-primary/50 bg-primary/5">
<CardContent className="py-6 px-6 space-y-4">
<div className="flex items-start gap-3">
<div className="rounded-full bg-primary/15 p-2 shrink-0">
<Send className="size-5 text-primary" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<h2 className="text-lg font-semibold">
Finished setting up? Send your npub to whoever invited you.
</h2>
<p className="text-sm text-muted-foreground">
They need your npub to add you as a beneficiary on the campaign. Once they do, it
will show up here.
</p>
</div>
</div>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Your npub
</span>
<button
type="button"
onClick={writeClipboard}
className="w-full flex items-center justify-between gap-2 rounded-lg border bg-background px-3 py-2.5 font-mono text-xs text-left hover:bg-muted/60 motion-safe:transition-colors"
aria-label="Copy npub"
>
<span className="break-all">{npub}</span>
{copiedNpub ? (
<Check className="size-4 text-green-500 shrink-0" />
) : (
<Copy className="size-4 text-muted-foreground shrink-0" />
)}
</button>
</div>
<Button type="button" onClick={writeClipboard} className="w-full">
{copiedNpub ? (
<Check className="size-4 mr-1.5 text-green-200" />
) : (
<Copy className="size-4 mr-1.5" />
)}
Copy my npub
</Button>
<p className="text-xs text-muted-foreground">
Paste it into the same conversation where they invited you Telegram, iMessage, email,
or wherever.
</p>
</CardContent>
</Card>
);
}
+239 -163
View File
@@ -1,36 +1,38 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Globe2, HandHeart, Loader2, PlusCircle, Shield, ShieldCheck, Users } from 'lucide-react';
import { ChevronDown, ChevronUp, EyeOff, Globe2, HandHeart, Hourglass, PlusCircle, Users } from 'lucide-react';
import { CampaignCard } from '@/components/CampaignCard';
import { FeedCard } from '@/components/FeedCard';
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
import { HeroBanner } from '@/components/HeroBanner';
import { LoginArea } from '@/components/auth/LoginArea';
import { NoteCard } from '@/components/NoteCard';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Skeleton } from '@/components/ui/skeleton';
import { HorizontalScroll } from '@/components/discovery/HorizontalScroll';
import { CommunityGrid } from '@/components/discovery/CommunityGrid';
import { CommunityMiniCard, CommunityMiniCardSkeleton } from '@/components/discovery/CommunityMiniCard';
import { SectionHeader } from '@/components/discovery/SectionHeader';
import { COOL_PALETTE } from '@/lib/hopePalette';
import { cn } from '@/lib/utils';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDiscoverCommunities } from '@/hooks/useDiscoverCommunities';
import { useFeaturedOrganizations } from '@/hooks/useFeaturedOrganizations';
import { useGlobalActivity } from '@/hooks/useGlobalActivity';
import { useGlobalDonations } from '@/hooks/useGlobalDonations';
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
import { useOrganizationHomeActivityFeed } from '@/hooks/useOrganizationHomeActivityFeed';
import { useOrganizationMembersOnlyFilter } from '@/hooks/useOrganizationMembersOnlyFilter';
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
import { useToast } from '@/hooks/useToast';
import { useUserOrganizations } from '@/hooks/useUserOrganizations';
import { CAMPAIGN_KIND, parseCampaign } from '@/lib/campaign';
import { hasAgoraTag } from '@/lib/agoraNoteTags';
import { formatSatsShort } from '@/lib/formatCampaignAmount';
import type { ParsedCommunity } from '@/lib/communityUtils';
// ─── Page ──────────────────────────────────────────────────────────────────────
@@ -41,6 +43,17 @@ export function CommunitiesPage() {
const navigate = useNavigate();
const { toast } = useToast();
// Moderator gate. Reuses the campaign moderator pack (Team Soapbox) —
// see useOrganizationModeration for why the same pack governs both
// surfaces. The `isMod` boolean drives the visibility of the two
// collapsible review sections at the bottom of the page; the heavy
// discovery + moderation queries that power them are themselves
// mounted INSIDE the moderator-only subtree so non-mod viewers don't
// pay the cost of fetching 200 orgs and folding labels just to never
// render anything.
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
useLayoutOptions({
noMaxWidth: true,
rightSidebar: null,
@@ -76,7 +89,7 @@ export function CommunitiesPage() {
/>
</section>
<section className="pt-4">
<section className="pt-4 pb-8">
<SectionHeader
title="Featured organizations"
className="pb-3 sm:px-6"
@@ -84,14 +97,170 @@ export function CommunitiesPage() {
<FeaturedOrganizationsShelf />
</section>
<section className="pt-4 pb-8">
<OrganizationActivityFeed userOrganizations={userOrganizations} />
</section>
{/* Moderator-only review sections: "Needs review" and "Hidden".
Organizations have a two-axis moderation model (featured /
hidden — no approval gate), so anything not yet labelled
simply lives in the public Featured-or-not space. The Needs
review queue surfaces unlabelled Agora-tagged orgs so
moderators can pick what to feature or hide. */}
{isMod && <ModeratorReviewSections />}
</div>
</main>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Moderator review sections
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Renders the "Needs review" and "Hidden" rails for moderators only.
*
* Organizations don't have an `approved` axis (unlike campaigns) — every
* Agora-tagged org is publicly visible by default. Moderation reduces to:
*
* - **Needs review** — orgs minted through Agora (carry `t:agora`) that
* have no featured or hidden label yet. These are candidates for
* either lifting into Featured or suppressing with Hidden.
* - **Hidden** — orgs whose latest hide-axis label is `hidden`.
*
* The component owns its own data fetches (discovery pool + moderation
* rollup) so the page can mount it conditionally on `isMod` and skip
* both queries entirely for the overwhelmingly common non-moderator
* case. Mirrors the campaign side's `ModeratorSection` pattern
* (collapsible, defaults to open when the list is short).
*/
function ModeratorReviewSections() {
// Wider pull than the public discovery shelf so reviewers see deeper
// history. Bumping the limit further would just add network cost —
// anything truly old can be reviewed by visiting it directly.
const { data: allOrgs, isLoading } = useDiscoverCommunities({ limit: 200 });
const { data: moderation, isReady } = useOrganizationModeration();
const needsReviewOrgs = useMemo(() => {
if (!moderation || !allOrgs) return [] as ParsedCommunity[];
return allOrgs.filter(
(org) =>
// Restrict the review queue to orgs minted through Agora's create
// flow. Without this gate, every kind 34550 community on the
// network would appear here — including badge-gated NIP-72
// communities, music scenes, etc. — none of which Agora moderators
// should be expected to triage.
hasAgoraTag(org.tags) &&
!moderation.featuredCoords.has(org.aTag) &&
!moderation.hiddenCoords.has(org.aTag),
);
}, [moderation, allOrgs]);
const hiddenOrgs = useMemo(() => {
if (!moderation || !allOrgs) return [] as ParsedCommunity[];
return allOrgs.filter((org) => moderation.hiddenCoords.has(org.aTag));
}, [moderation, allOrgs]);
const sectionsLoading = isLoading || !isReady;
return (
<>
<ModeratorOrgSection
icon={<Hourglass className="size-4" />}
title="Needs review"
description="Agora organizations that haven't been featured or hidden yet. Lift one into the Featured shelf or suppress it with Hide."
count={needsReviewOrgs.length}
orgs={needsReviewOrgs}
isLoading={sectionsLoading}
emptyText="Nothing awaiting review."
/>
<ModeratorOrgSection
icon={<EyeOff className="size-4" />}
title="Hidden"
description="Organizations suppressed from public discovery. Use the kebab menu on a card to unhide."
count={hiddenOrgs.length}
orgs={hiddenOrgs}
isLoading={sectionsLoading}
emptyText="No organizations are currently hidden."
/>
</>
);
}
/**
* Collapsible moderator-only section listing organizations in a particular
* moderation state (pending / hidden). Defaults to expanded when the list
* is short (≤ 6 items), collapsed otherwise — same heuristic as the
* campaign version.
*/
function ModeratorOrgSection({
icon,
title,
description,
count,
orgs,
isLoading,
emptyText,
}: {
icon: React.ReactNode;
title: string;
description: string;
count: number;
orgs: ParsedCommunity[];
isLoading: boolean;
emptyText: string;
}) {
const [open, setOpen] = useState(count <= 6);
return (
<Collapsible open={open} onOpenChange={setOpen} asChild>
<section className="pt-4 pb-2">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-end justify-between gap-4 rounded-lg text-left px-4 sm:px-6 pb-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
>
<div>
<h2 className="text-xl sm:text-2xl font-bold tracking-tight inline-flex items-center gap-2">
<span className="text-muted-foreground">{icon}</span>
{title}
<span className="text-sm font-medium text-muted-foreground">({count})</span>
</h2>
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">{description}</p>
</div>
<ChevronDown
className={cn(
'size-5 text-muted-foreground motion-safe:transition-transform shrink-0',
open && 'rotate-180',
)}
aria-hidden
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
{isLoading && orgs.length === 0 ? (
<CommunityGrid>
{Array.from({ length: 4 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
) : orgs.length === 0 ? (
<div className="px-4 sm:px-6">
<Card className="border-dashed">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{emptyText}
</CardContent>
</Card>
</div>
) : (
<CommunityGrid>
{orgs.map((org) => (
<CommunityMiniCard key={org.aTag} community={org} className="w-full" />
))}
</CommunityGrid>
)}
</CollapsibleContent>
</section>
</Collapsible>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Hero
// ═══════════════════════════════════════════════════════════════════════════════
@@ -194,7 +363,7 @@ function CommunitiesHero({ onCreateCommunity }: CommunitiesHeroProps) {
<div className="relative max-w-5xl mx-auto px-4 sm:px-6 py-10 sm:py-12 lg:py-14 min-h-[380px] sm:min-h-[420px] lg:min-h-[460px] flex flex-col items-center text-center">
<div className="relative space-y-3 max-w-3xl">
<p className="text-xs sm:text-sm font-semibold uppercase tracking-[0.18em] text-white/85 drop-shadow">
Organize
Groups
</p>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.05] text-white drop-shadow-[0_2px_12px_rgb(0_0_0/0.55)]">
Strength
@@ -310,14 +479,15 @@ function MyCommunitiesShelfContent({
// Sorting is founder first, moderator second, followed-only last, with
// newest community definition revisions first inside each bucket.
const { data: organizations, isLoading } = userOrganizations;
const [expanded, setExpanded] = useState(false);
if (isLoading) {
return (
<HorizontalScroll className="sm:px-6">
<CommunityGrid>
{Array.from({ length: 4 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} />
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</HorizontalScroll>
</CommunityGrid>
);
}
@@ -337,12 +507,45 @@ function MyCommunitiesShelfContent({
);
}
const COLLAPSED_COUNT = 4;
const visible = expanded ? organizations : organizations.slice(0, COLLAPSED_COUNT);
const canExpand = organizations.length > COLLAPSED_COUNT;
return (
<HorizontalScroll className="sm:px-6">
{organizations.slice(0, 18).map((entry) => (
<CommunityMiniCard key={entry.community.aTag} community={entry.community} />
))}
</HorizontalScroll>
<div className="space-y-4">
<CommunityGrid>
{visible.map((entry) => (
<CommunityMiniCard
key={entry.community.aTag}
community={entry.community}
className="w-full"
/>
))}
</CommunityGrid>
{canExpand && (
<div className="flex justify-center px-4 sm:px-6">
<Button
type="button"
variant="ghost"
onClick={() => setExpanded((v) => !v)}
className="rounded-full text-sm"
aria-expanded={expanded}
>
{expanded ? (
<>
<ChevronUp className="size-4 mr-1.5" />
Show less
</>
) : (
<>
<ChevronDown className="size-4 mr-1.5" />
Show {organizations.length - COLLAPSED_COUNT} more
</>
)}
</Button>
</div>
)}
</div>
);
}
@@ -352,11 +555,11 @@ function FeaturedOrganizationsShelf() {
if (isLoading && !hasFeatured) {
return (
<HorizontalScroll className="sm:px-6">
{Array.from({ length: 4 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} />
<CommunityGrid>
{Array.from({ length: 8 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</HorizontalScroll>
</CommunityGrid>
);
}
@@ -364,150 +567,23 @@ function FeaturedOrganizationsShelf() {
return (
<EmptyShelf
icon={<Users className="size-7 text-primary/70" />}
title="No featured organizations available"
body="The curated list is currently unreachable. Try refreshing in a moment."
title="No featured organizations yet"
body="Agora moderators feature standout organizations here. Check back soon."
action={null}
/>
);
}
return (
<HorizontalScroll className="sm:px-6">
<CommunityGrid>
{featured.map((entry) => (
<CommunityMiniCard key={entry.community.aTag} community={entry.community} />
<CommunityMiniCard
key={entry.community.aTag}
community={entry.community}
className="w-full"
/>
))}
</HorizontalScroll>
);
}
function OrganizationActivityFeed({ userOrganizations }: { userOrganizations: UserOrganizationsResult }) {
const { user } = useCurrentUser();
const { data: organizations, isLoading: organizationsLoading } = userOrganizations;
const { membersOnly, toggle } = useOrganizationMembersOnlyFilter();
const feed = useOrganizationHomeActivityFeed(organizations, membersOnly, !!user && !organizationsLoading);
const { scrollRef } = useInfiniteScroll({
hasNextPage: feed.hasNextPage,
isFetchingNextPage: feed.isFetchingNextPage,
fetchNextPage: feed.fetchNextPage,
pageCount: feed.pageCount,
});
if (!user) {
return (
<EmptyShelf
icon={<Shield className="size-7 text-primary/70" />}
title="Log in to see organization activity"
body="Comments and official activity from organizations you follow, founded, or moderate will appear here."
action={<LoginArea className="max-w-60" />}
/>
);
}
const isLoading = organizationsLoading || feed.isLoading;
const hasOrganizations = !!organizations && organizations.length > 0;
return (
<div className="max-w-2xl mx-auto">
<div className="px-4 sm:px-6 mb-3 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
Organization activity
</h2>
<p className="text-sm text-muted-foreground mt-1 max-w-xl">
Comments, campaigns, pledges, and events from organizations you follow,
founded, or moderate.
</p>
</div>
<Button
type="button"
variant={membersOnly ? 'default' : 'ghost'}
size="sm"
onClick={toggle}
className="rounded-full self-start sm:self-auto shrink-0"
>
{membersOnly ? <ShieldCheck className="size-4 mr-2" /> : <Shield className="size-4 mr-2" />}
Members only
</Button>
</div>
{!hasOrganizations && !isLoading ? (
<Card className="border-dashed mx-4 sm:mx-6">
<CardContent className="py-12 px-8 text-center space-y-2">
<p className="text-base font-semibold">No organization activity yet</p>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
Follow, found, or moderate an organization to build a feed from its comments and official activity.
</p>
</CardContent>
</Card>
) : isLoading && feed.events.length === 0 ? (
<FeedCard className="divide-y divide-border/60">
{Array.from({ length: 3 }).map((_, i) => <FeedRowSkeleton key={i} />)}
</FeedCard>
) : feed.events.length > 0 ? (
<>
<FeedCard>
{feed.events.map((event) => (
<OrganizationFeedRow key={event.id} event={event} />
))}
</FeedCard>
{feed.hasNextPage && (
<div ref={scrollRef} className="flex items-center justify-center py-8 text-muted-foreground">
{feed.isFetchingNextPage && <Loader2 className="size-5 animate-spin" aria-hidden />}
</div>
)}
{!feed.hasNextPage && (
<div className="py-8 text-center text-xs text-muted-foreground">
You're caught up. Check back soon for new organization activity.
</div>
)}
</>
) : (
<Card className="border-dashed mx-4 sm:mx-6">
<CardContent className="py-12 px-8 text-center space-y-2">
<p className="text-base font-semibold">No matching activity</p>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
{membersOnly
? 'No member-only activity was found. Turn off Members only to see all organization comments.'
: 'No comments or official activity were found for these organizations yet.'}
</p>
</CardContent>
</Card>
)}
</div>
);
}
function OrganizationFeedRow({ event }: { event: NostrEvent }) {
if (event.kind === CAMPAIGN_KIND) {
const campaign = parseCampaign(event);
if (!campaign || campaign.archived) return null;
return (
<div className="p-4 sm:p-5 border-b border-border">
<CampaignCard campaign={campaign} />
</div>
);
}
return <NoteCard event={event} />;
}
function FeedRowSkeleton() {
return (
<div className="px-4 py-3">
<div className="flex items-center gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="min-w-0 space-y-1.5 flex-1">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-36" />
</div>
</div>
<div className="mt-3 space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</div>
</CommunityGrid>
);
}
-95
View File
@@ -1,95 +0,0 @@
import { useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { RotateCcw } from 'lucide-react';
import { MuteSettingsInternals, SensitiveContentSection } from '@/components/ContentSettings';
import { MuteListRecoveryDialog } from '@/components/MuteListRecoveryDialog';
import { PageHeader } from '@/components/PageHeader';
import { IntroImage } from '@/components/IntroImage';
import { HelpTip } from '@/components/HelpTip';
import { Button } from '@/components/ui/button';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
export function ContentPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const [recoveryOpen, setRecoveryOpen] = useState(false);
useSeoMeta({
title: `Content | Settings | ${config.appName}`,
description: 'Muted users, hashtags, and sensitive content settings',
});
return (
<main className="">
{/* Header with back link */}
<PageHeader
backTo="/settings"
alwaysShowBack
titleContent={
<div className="flex-1 min-w-0">
<h1 className="text-xl font-bold">Content</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Control what you see. Mute users, hashtags, or words, and choose how content warnings are handled. Mutes are encrypted and private.
</p>
</div>
}
/>
{/* Lead image — Muted Content */}
<div className="flex items-center gap-4 px-7 py-5">
<IntroImage src="/mute-intro.png" size="w-28" />
<div className="min-w-0">
<h2 className="text-base font-semibold">Content Control</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Hide posts from specific users, hashtags, words, or entire threads. All mutes are encrypted and private.
</p>
</div>
</div>
<div className="p-4 space-y-0">
{/* Muted Content Section */}
<div>
<div className="relative px-3 py-3.5 flex items-center justify-between">
<h2 className="text-base font-semibold flex items-center gap-1.5">Muted Content <HelpTip faqId="report-content" /></h2>
{user && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1.5 text-muted-foreground hover:text-foreground"
onClick={() => setRecoveryOpen(true)}
>
<RotateCcw className="size-3.5" />
Recovery
</Button>
)}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</div>
<div className="pb-4">
<MuteSettingsInternals />
</div>
</div>
{user && (
<MuteListRecoveryDialog
open={recoveryOpen}
onOpenChange={setRecoveryOpen}
/>
)}
{/* Sensitive Content Section */}
<div>
<div className="relative px-3 py-3.5">
<h2 className="text-base font-semibold flex items-center gap-1.5">Sensitive Content <HelpTip faqId="report-content" /></h2>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</div>
<div className="pb-4">
<SensitiveContentSection />
</div>
</div>
</div>
</main>
);
}
+3 -5
View File
@@ -1,7 +1,6 @@
import { useSeoMeta } from '@unhead/react';
import { ContentSettings } from '@/components/ContentSettings';
import { PageHeader } from '@/components/PageHeader';
import { HelpTip } from '@/components/HelpTip';
import { useAppContext } from '@/hooks/useAppContext';
export function ContentSettingsPage() {
@@ -13,16 +12,15 @@ export function ContentSettingsPage() {
});
return (
<main className="">
{/* Header with back link */}
<main>
<PageHeader
backTo="/settings"
alwaysShowBack
titleContent={
<div className="flex-1 min-w-0">
<h1 className="text-xl font-bold flex items-center gap-1.5">Home Feed <HelpTip faqId="fyp" /></h1>
<h1 className="text-xl font-bold">Home Feed</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Nostr supports many content types beyond text posts. Customize which appear in your home feed.
Choose what appears in your feed, manage saved searches, and hide content you don't want to see.
</p>
</div>
}
+2 -1
View File
@@ -36,6 +36,7 @@ import { createOrganizationAssociationTags, decodeOrganizationParam } from '@/li
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { unixSecondsInTimezone } from '@/lib/timezone';
import { cn } from '@/lib/utils';
import { withAgoraTag } from '@/lib/agoraNoteTags';
function normalizePledgeTag(value: string): string {
return value.trim().replace(/^#+/, '').toLowerCase().replace(/\s+/g, '-');
@@ -197,7 +198,7 @@ export function CreateActionPage() {
]);
}
await createEvent({ kind: 36639, content: trimmedDescription, tags });
await createEvent({ kind: 36639, content: trimmedDescription, tags: withAgoraTag(tags) });
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['agora-actions'] });
+176 -437
View File
@@ -1,10 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { Capacitor } from '@capacitor/core';
import type { NostrEvent } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import {
AlertTriangle,
@@ -12,45 +11,38 @@ import {
HandHeart,
Loader2,
MapPin,
MessageCircle,
Share2,
UserPlus,
ShieldCheck,
Wallet,
X,
} from 'lucide-react';
import { PersonSearch } from '@/components/PersonSearch';
import { CoverImageField } from '@/components/CoverImageField';
import { FormSection } from '@/components/FormSection';
import { OrganizationContextChip } from '@/components/OrganizationContextChip';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { parseAuthorEvent } from '@/hooks/useAuthor';
import { useBitcoinWallet } from '@/hooks/useBitcoinWallet';
import { useCampaign } from '@/hooks/useCampaign';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useManageableOrganizations } from '@/hooks/useManageableOrganizations';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import type { SearchProfile } from '@/hooks/useSearchProfiles';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { formatSats, satsToUSDWhole, usdToSats } from '@/lib/bitcoin';
import {
CAMPAIGN_KIND,
encodeCampaignNaddr,
parseCampaign,
parseCampaignWallet,
slugifyCampaignIdentifier,
} from '@/lib/campaign';
import { getTodayDateInput } from '@/lib/dateInput';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { genUserName } from '@/lib/genUserName';
import { createOrganizationAssociationTags, decodeOrganizationParam } from '@/lib/organizationContext';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { COUNTRIES, searchCountries, searchCountry, type CountryEntry } from '@/lib/countries';
import { withAgoraTag } from '@/lib/agoraNoteTags';
import { COUNTRIES, searchCountries, type CountryEntry } from '@/lib/countries';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
import { cn } from '@/lib/utils';
@@ -60,57 +52,6 @@ interface EditTarget {
relays?: string[];
}
/** Canonical origin used in shareable invite / notify links. */
function getShareOrigin(): string {
return 'https://agora.spot';
}
/**
* Copy text to clipboard with a uniform toast reaction. Returns true on
* success so the caller can update transient UI state.
*/
async function copyShareText(
text: string,
toast: ReturnType<typeof useToast>['toast'],
successTitle: string,
): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
toast({ title: successTitle, description: 'Paste it into a DM, email, or text message.' });
return true;
} catch {
toast({
title: 'Copy failed',
description: 'Your browser blocked clipboard access. Select and copy the text manually.',
variant: 'destructive',
});
return false;
}
}
async function shareTextOrCopy(
text: string,
toast: ReturnType<typeof useToast>['toast'],
fallbackSuccessTitle: string,
): Promise<void> {
try {
if (Capacitor.isNativePlatform()) {
const { Share } = await import('@capacitor/share');
await Share.share({ text });
return;
}
if (navigator.share) {
await navigator.share({ text });
return;
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') return;
}
await copyShareText(text, toast, fallbackSuccessTitle);
}
function getEditTarget(value: string | null): EditTarget | null {
if (!value) return null;
@@ -127,73 +68,39 @@ function getEditTarget(value: string | null): EditTarget | null {
}
}
function makeRecipientProfile(pubkey: string): SearchProfile {
return {
pubkey,
metadata: {},
event: {
id: '',
pubkey,
created_at: 0,
kind: 0,
tags: [],
content: '{}',
sig: '',
},
};
}
function makeRecipientProfileFromAuthor(
pubkey: string,
author: { event?: NostrEvent; metadata?: NostrMetadata } | undefined,
): SearchProfile {
if (!author?.event) return makeRecipientProfile(pubkey);
return {
pubkey,
metadata: author.metadata ?? {},
event: author.event,
};
}
function formatGoalUsd(goalSats: number | undefined, btcPrice: number | undefined): string {
if (!goalSats || !btcPrice) return '';
const usd = (goalSats / 100_000_000) * btcPrice;
if (!Number.isFinite(usd) || usd <= 0) return '';
return usd.toFixed(usd >= 100 ? 0 : 2);
}
function normalizeCampaignTag(value: string): string {
return value.trim().replace(/^#+/, '').toLowerCase().replace(/\s+/g, '-');
}
function parseCampaignTagInput(value: string): string[] {
const seen = new Set<string>();
const tags: string[] = [];
for (const part of value.split(',')) {
const tag = normalizeCampaignTag(part);
if (!tag || seen.has(tag)) continue;
seen.add(tag);
tags.push(tag);
}
return tags;
}
function getExactCountryCode(query: string): string | undefined {
const match = searchCountry(query);
return match?.exact ? match.country.code : undefined;
}
function formatDateInput(unixSeconds: number | undefined): string {
if (!unixSeconds) return '';
return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
}
/**
* Build a NIP-92 `imeta` tag from a Blossom upload's NIP-94 tag array.
*
* NIP-94 returns pairs like `[["url", "<url>"], ["x", "<sha256>"], ["m", "image/jpeg"], ...]`.
* NIP-92 packs the same key/value pairs into a single space-separated tag:
* `["imeta", "url <url>", "x <sha256>", "m image/jpeg", ...]`.
*
* The `url` entry MUST come first (NIP-92 convention). Other keys are
* emitted in their original order.
*/
function buildImetaFromNip94(nip94Tags: string[][]): string[] {
const result: string[] = ['imeta'];
// url first
const urlPair = nip94Tags.find((t) => t[0] === 'url');
if (urlPair && urlPair[1]) result.push(`url ${urlPair[1]}`);
for (const [key, value] of nip94Tags) {
if (key === 'url') continue;
if (typeof key !== 'string' || typeof value !== 'string') continue;
if (key.includes(' ')) continue;
result.push(`${key} ${value}`);
}
return result;
}
export function CreateCampaignPage() {
useLayoutOptions({ noMaxWidth: true, rightSidebar: null });
const { user } = useCurrentUser();
const { btcPrice } = useBitcoinWallet();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
@@ -204,39 +111,28 @@ export function CreateCampaignPage() {
const [title, setTitle] = useState('');
const [summary, setSummary] = useState('');
const [story, setStory] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [bannerUrl, setBannerUrl] = useState('');
/** NIP-94-format tag pairs from the most recent banner upload, used to build the NIP-92 imeta tag on publish. */
const [bannerNip94Tags, setBannerNip94Tags] = useState<string[][] | null>(null);
const [coverUploading, setCoverUploading] = useState(false);
const [tagInput, setTagInput] = useState('');
const [walletInput, setWalletInput] = useState('');
const [goalUsd, setGoalUsd] = useState('');
const [goalTouched, setGoalTouched] = useState(false);
const [deadline, setDeadline] = useState('');
const [countryQuery, setCountryQuery] = useState('');
const [countryCode, setCountryCode] = useState('');
const [recipients, setRecipients] = useState<SearchProfile[]>([]);
const [organizationATag, setOrganizationATag] = useState('');
const [formError, setFormError] = useState('');
const [prepopulatedEventId, setPrepopulatedEventId] = useState<string | null>(null);
const [prepopulatedGoalEventId, setPrepopulatedGoalEventId] = useState<string | null>(null);
const editNaddr = searchParams.get('edit');
const editTarget = useMemo(() => getEditTarget(editNaddr), [editNaddr]);
const isEditMode = !!editNaddr;
// ── Organization context (implicit) ────────────────────────────────────
// `?org=` carries the org coordinate from the entry point — typically
// an org detail page CTA. We accept either an `naddr1...` (preferred,
// canonical) or a raw `34550:<pubkey>:<d-tag>` coordinate. The form
// never exposes a user-editable selector — the campaign is "under the
// user" by default, and "under the org" when the user started from
// inside that org's page.
const orgParam = searchParams.get('org');
const orgFromParam = useMemo(() => decodeOrganizationParam(orgParam), [orgParam]);
const { data: manageableOrgs, isLoading: manageableOrgsLoading } = useManageableOrganizations();
// The org we'll actually attach to the published event. We only honor
// the param when the current user is the founder or a moderator of
// that org — otherwise drop it silently so a stale link can't forge
// an org association.
const authorizedOrgFromParam = useMemo(() => {
if (!orgFromParam || !manageableOrgs) return null;
return manageableOrgs.find((entry) => entry.community.aTag === orgFromParam.aTag) ?? null;
@@ -257,68 +153,15 @@ export function CreateCampaignPage() {
...(editTarget?.relays ? { relays: editTarget.relays } : {}),
});
const editCampaign = isEditMode ? editCampaignQuery.data : null;
const editRecipientPubkeys = useMemo(
() => editCampaign?.recipients.map((recipient) => recipient.pubkey) ?? [],
[editCampaign],
);
const editRecipientProfiles = useQuery({
queryKey: ['campaign-edit-recipients', editRecipientPubkeys],
queryFn: async ({ signal }): Promise<SearchProfile[]> => {
const cachedProfiles = new Map<string, SearchProfile>();
const missingPubkeys: string[] = [];
for (const pubkey of editRecipientPubkeys) {
const cachedAuthor = queryClient.getQueryData<{ event?: NostrEvent; metadata?: NostrMetadata }>([
'author',
pubkey,
]);
if (cachedAuthor?.event) {
cachedProfiles.set(pubkey, makeRecipientProfileFromAuthor(pubkey, cachedAuthor));
} else {
missingPubkeys.push(pubkey);
}
}
if (missingPubkeys.length > 0) {
const events = await nostr.query(
[{ kinds: [0], authors: missingPubkeys, limit: missingPubkeys.length }],
{ signal },
);
const latestByPubkey = new Map<string, NostrEvent>();
for (const event of events) {
const existing = latestByPubkey.get(event.pubkey);
if (!existing || event.created_at > existing.created_at) {
latestByPubkey.set(event.pubkey, event);
}
}
for (const pubkey of missingPubkeys) {
const event = latestByPubkey.get(pubkey);
if (!event) continue;
const parsed = parseAuthorEvent(event);
queryClient.setQueryData(['author', pubkey], parsed);
cachedProfiles.set(pubkey, makeRecipientProfileFromAuthor(pubkey, parsed));
}
}
return editRecipientPubkeys.map((pubkey) => cachedProfiles.get(pubkey) ?? makeRecipientProfile(pubkey));
},
enabled: isEditMode && editRecipientPubkeys.length > 0,
staleTime: 5 * 60 * 1000,
});
// The slug is protocol plumbing: derive it from the title instead of asking
// fundraisers to understand Nostr d-tags.
const derivedIdentifier = useMemo(() => slugifyCampaignIdentifier(title), [title]);
const activeIdentifier = editCampaign?.identifier ?? derivedIdentifier;
const goalSatsPreview = useMemo(() => {
const n = Number(goalUsd.replace(/[, $]/g, ''));
return usdToSats(n, btcPrice);
}, [btcPrice, goalUsd]);
const minDeadline = useMemo(() => getTodayDateInput(), []);
const parsedWallet = useMemo(() => parseCampaignWallet(walletInput), [walletInput]);
useSeoMeta({
title: isEditMode ? 'Edit campaign | Agora' : 'Start a campaign | Agora',
description: isEditMode ? 'Update your fundraising campaign on Agora.' : 'Launch a fundraising campaign on Agora.',
@@ -330,17 +173,17 @@ export function CreateCampaignPage() {
setTitle(editCampaign.title);
setSummary(editCampaign.summary);
setStory(editCampaign.story);
setImageUrl(editCampaign.image ?? '');
setTagInput(editCampaign.tags.join(', '));
setBannerUrl(editCampaign.banner ?? '');
// We don't have NIP-94 tags for an existing event — the imeta is
// already on the event. We'll re-emit it from the original event
// tags below if the URL is unchanged.
setBannerNip94Tags(null);
setWalletInput(editCampaign.wallet.value);
setGoalUsd(editCampaign.goalUsd !== undefined ? String(editCampaign.goalUsd) : '');
setDeadline(formatDateInput(editCampaign.deadline));
const editCountryCode = editCampaign.countryCode ?? getExactCountryCode(editCampaign.location ?? '') ?? '';
const editCountryCode = editCampaign.countryCode ?? '';
setCountryCode(editCountryCode);
setCountryQuery(editCountryCode ? COUNTRIES[editCountryCode]?.name ?? editCountryCode : '');
setRecipients(editCampaign.recipients.map((recipient) => makeRecipientProfile(recipient.pubkey)));
// Restore the organization root-scope tag (uppercase `A`) so the
// selector hydrates with the same org the campaign was originally
// attached to. We accept the tag as-is; the publish branch verifies
// the current user is still authorized to publish under that org.
const existingOrgATag = editCampaign.event.tags.find(
([n, v]) => n === 'A' && typeof v === 'string' && v.startsWith('34550:'),
)?.[1] ?? '';
@@ -348,47 +191,6 @@ export function CreateCampaignPage() {
setPrepopulatedEventId(editCampaign.event.id);
}, [editCampaign, prepopulatedEventId]);
useEffect(() => {
const profiles = editRecipientProfiles.data;
if (!profiles || profiles.length === 0) return;
setRecipients((prev) => prev.map((recipient) => {
const profile = profiles.find((item) => item.pubkey === recipient.pubkey);
return profile ?? recipient;
}));
}, [editRecipientProfiles.data]);
useEffect(() => {
if (!editCampaign || prepopulatedGoalEventId === editCampaign.event.id || goalUsd.trim()) return;
const formattedGoal = formatGoalUsd(editCampaign.goalSats, btcPrice);
if (!formattedGoal) return;
setGoalUsd(formattedGoal);
setPrepopulatedGoalEventId(editCampaign.event.id);
}, [btcPrice, editCampaign, goalUsd, prepopulatedGoalEventId]);
const addRecipient = (profile: SearchProfile) => {
setRecipients((prev) => prev.some((r) => r.pubkey === profile.pubkey) ? prev : [...prev, profile]);
};
const addRecipients = (profiles: SearchProfile[]) => {
setRecipients((prev) => {
const seen = new Set(prev.map((r) => r.pubkey));
const next = [...prev];
for (const profile of profiles) {
if (seen.has(profile.pubkey)) continue;
seen.add(profile.pubkey);
next.push(profile);
}
return next;
});
};
const removeRecipient = (pubkey: string) => {
setRecipients((prev) => prev.filter((r) => r.pubkey !== pubkey));
};
const submitMutation = useMutation({
mutationFn: async () => {
if (!user) throw new Error('You must be logged in to create a campaign.');
@@ -405,34 +207,23 @@ export function CreateCampaignPage() {
throw new Error('Identifier must be lowercase letters, numbers, and hyphens.');
}
// Validate recipients.
const parsedRecipients: { pubkey: string }[] = [];
const seen = new Set<string>();
for (const r of recipients) {
const pubkey = r.pubkey;
if (seen.has(pubkey)) continue;
seen.add(pubkey);
parsedRecipients.push({ pubkey });
// Validate wallet — required.
const wallet = parseCampaignWallet(walletInput);
if (!wallet) {
throw new Error(
'Wallet endpoint is required. Provide a Bitcoin mainnet address (bc1q… / bc1p…) or a silent-payment code (sp1…).',
);
}
if (parsedRecipients.length === 0) {
throw new Error('Add at least one recipient.');
}
// Goal / deadline.
// In edit mode, preserve the exact stored sats unless the user changed the field.
// Goal — integer USD (no unit, no currency conversion).
let goalNum: number | undefined;
if (isEditMode && !goalTouched) {
goalNum = editCampaign?.goalSats;
} else if (goalUsd.trim()) {
const n = Number(goalUsd.replace(/[, $]/g, ''));
if (!Number.isFinite(n) || n <= 0) {
throw new Error('Goal must be a positive USD amount.');
}
goalNum = usdToSats(n, btcPrice);
if (goalNum <= 0) {
throw new Error('Bitcoin price is unavailable. Try again before setting a USD goal.');
const trimmedGoal = goalUsd.replace(/[, $]/g, '').trim();
if (trimmedGoal) {
const n = Number(trimmedGoal);
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
throw new Error('Goal must be a positive whole-dollar amount.');
}
goalNum = n;
}
let deadlineNum: number | undefined;
@@ -447,8 +238,7 @@ export function CreateCampaignPage() {
deadlineNum = ts;
}
const resolvedCountryCode = countryCode || getExactCountryCode(countryQuery);
const campaignTags = parseCampaignTagInput(tagInput);
const resolvedCountryCode = countryCode;
let prev: NostrEvent | null = null;
if (isEditMode) {
@@ -474,46 +264,62 @@ export function CreateCampaignPage() {
}
}
// Validate image URL (must be https).
const trimmedImageUrl = imageUrl.trim();
const sanitizedImage = trimmedImageUrl ? sanitizeUrl(trimmedImageUrl) : undefined;
if (trimmedImageUrl && !sanitizedImage) {
throw new Error('Cover image must be a valid https:// URL.');
// Validate banner URL (must be https).
const trimmedBanner = bannerUrl.trim();
const sanitizedBanner = trimmedBanner ? sanitizeUrl(trimmedBanner) : undefined;
if (trimmedBanner && !sanitizedBanner) {
throw new Error('Banner must be a valid https:// URL.');
}
const tags: string[][] = [
['d', slug],
['title', trimmedTitle],
['alt', `Fundraising campaign: ${trimmedTitle}`],
];
if (summary.trim()) tags.splice(2, 0, ['summary', summary.trim()]);
for (const tag of campaignTags) tags.push(['t', tag]);
if (sanitizedImage) tags.push(['image', sanitizedImage]);
if (summary.trim()) tags.push(['summary', summary.trim()]);
if (sanitizedBanner) {
tags.push(['banner', sanitizedBanner]);
// NIP-92 imeta pairs with the banner. Two sources, in priority order:
// 1. A fresh upload during this session — convert the NIP-94 tag
// array from the uploader into the NIP-92 space-separated form.
// 2. The existing event's imeta tag — re-emit it verbatim when the
// URL hasn't changed during an edit.
const imeta = (() => {
if (bannerNip94Tags) {
const url = bannerNip94Tags.find((t) => t[0] === 'url')?.[1];
if (url === sanitizedBanner) {
return buildImetaFromNip94(bannerNip94Tags);
}
}
if (isEditMode && editCampaign?.banner === sanitizedBanner) {
const existing = editCampaign.event.tags.find(([n]) => n === 'imeta');
if (existing) return existing;
}
return null;
})();
if (imeta) tags.push(imeta);
}
tags.push(['alt', `Fundraising campaign: ${trimmedTitle}`]);
tags.push(['w', wallet.value]);
if (goalNum !== undefined) tags.push(['goal', String(goalNum)]);
if (deadlineNum !== undefined) tags.push(['deadline', String(deadlineNum)]);
if (resolvedCountryCode) {
tags.push(['i', createCountryIdentifier(resolvedCountryCode)]);
tags.push(['k', 'iso3166']);
tags.push(['k', 'iso3166-1']);
}
// Organization association (NIP-22 root-scope convention): an
// uppercase `A` tag points at the NIP-72 community definition so
// the campaign surfaces as official activity on that org's page.
// The `K` companion tag records the referenced kind, and `P` hints
// at the org founder for clients that batch-resolve authors.
const publishOrganizationATag = isEditMode
? authorizedOrgForAttachedATag?.community.aTag ?? ''
: organizationATag;
if (publishOrganizationATag) {
tags.push(...createOrganizationAssociationTags(publishOrganizationATag));
}
for (const r of parsedRecipients) {
tags.push(['p', r.pubkey]);
}
const published = await publishEvent({
kind: CAMPAIGN_KIND,
content: story,
tags,
tags: withAgoraTag(tags),
prev: prev ?? undefined,
});
@@ -661,7 +467,7 @@ export function CreateCampaignPage() {
</div>
<div className="rounded-2xl bg-card/50 p-2">
{/* Title & identifier */}
{/* Title */}
<FormSection title="Title" requirement="Required">
<Input
value={title}
@@ -670,13 +476,24 @@ export function CreateCampaignPage() {
maxLength={200}
required
/>
<p className="text-xs text-muted-foreground flex items-baseline gap-1 min-w-0">
<span className="shrink-0">URL preview:</span>
<span className="font-mono text-foreground truncate min-w-0">
/{activeIdentifier || 'your-campaign-title'}{!isEditMode && derivedIdentifier.length >= 64 && '...'}
</span>
{isEditMode && <span className="shrink-0">(kept from original)</span>}
</p>
</FormSection>
{/* Wallet (required) */}
<FormSection title="Bitcoin wallet" requirement="Required">
<div className="relative">
<Wallet className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="campaign-wallet"
value={walletInput}
onChange={(e) => setWalletInput(e.target.value.trim())}
placeholder="bc1p… or sp1…"
className="pl-9 font-mono text-xs"
spellCheck={false}
autoComplete="off"
autoCorrect="off"
/>
</div>
<WalletHint walletInput={walletInput} parsed={parsedWallet} />
</FormSection>
{/* Country */}
@@ -702,76 +519,27 @@ export function CreateCampaignPage() {
/>
</FormSection>
{/* Tags */}
<FormSection title="Tags" requirement="Recommended">
<Input
id="campaign-tags"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="human rights, legal defense, independent media"
/>
</FormSection>
{/* Recipients */}
<FormSection
title="Beneficiaries"
requirement="Required"
>
<div className="space-y-3">
<PersonSearch
onAdd={addRecipient}
onAddMany={addRecipients}
excludePubkeys={recipients.map((r) => r.pubkey)}
/>
{/* "Recipient not here yet?" invite affordance. Always visible
because even campaigns with existing recipients may have one
beneficiary who's still off-Nostr. */}
<button
type="button"
onClick={() => {
const url = `${getShareOrigin()}/receive`;
const message = `I want to create a fundraiser for you on Agora! Sign up to create your account and start receiving donations directly to your Bitcoin wallet: ${url}`;
void shareTextOrCopy(message, toast, 'Invite copied');
}}
className="w-full flex items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-background px-3 py-2.5 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/40 motion-safe:transition-colors"
>
<UserPlus className="size-4" />
Recipient not here yet? Invite them
<Share2 className="size-3.5 opacity-70" />
</button>
{recipients.length > 0 && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Beneficiaries ({recipients.length})
</Label>
<div className="space-y-1.5">
{recipients.map((recipient) => (
<RecipientRow
key={recipient.pubkey}
profile={recipient}
campaignTitle={title}
onRemove={() => removeRecipient(recipient.pubkey)}
/>
))}
</div>
</div>
)}
</div>
</FormSection>
{/* Cover image */}
<FormSection title="Cover image" requirement="Optional">
{/* Banner image */}
<FormSection title="Banner image" requirement="Recommended">
<CoverImageField
value={imageUrl}
onChange={setImageUrl}
value={bannerUrl}
onChange={(url) => {
setBannerUrl(url);
// Discard stale NIP-94 tags whenever the URL changes — a manual
// paste or template pick won't carry matching metadata.
setBannerNip94Tags(null);
}}
onUploadingChange={setCoverUploading}
onUploadComplete={(nip94Tags) => {
// Capture the NIP-94 tag array so we can convert it into a
// NIP-92 imeta tag at publish time.
setBannerNip94Tags(nip94Tags);
}}
/>
</FormSection>
{/* Summary */}
<FormSection title="Summary" requirement="Optional">
<FormSection title="Summary" requirement="Recommended">
<Textarea
id="campaign-summary"
value={summary}
@@ -795,7 +563,7 @@ export function CreateCampaignPage() {
</FormSection>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{/* Goal */}
{/* Goal — integer USD */}
<FormSection title="Goal" requirement="Optional">
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
@@ -804,35 +572,19 @@ export function CreateCampaignPage() {
<Input
id="campaign-goal"
type="text"
inputMode="decimal"
placeholder="100,000"
inputMode="numeric"
placeholder="25,000"
value={goalUsd}
onChange={(e) => {
setGoalUsd(e.target.value);
setGoalTouched(true);
}}
onChange={(e) => setGoalUsd(e.target.value)}
className="pl-7 pr-14"
/>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-muted-foreground">
USD
</span>
</div>
{isEditMode && editCampaign?.goalSats && !goalTouched && (
<p className="text-xs text-muted-foreground">
Current saved goal: {formatSats(editCampaign.goalSats)} sats
{btcPrice
? <> &mdash; about {satsToUSDWhole(editCampaign.goalSats, btcPrice)} today</>
: null
}. Only edit this field if you want to change the goal.
</p>
)}
{(!isEditMode || goalTouched) && goalSatsPreview > 0 && btcPrice && (
<p className="text-xs text-muted-foreground">
Your goal will be saved as {formatSats(goalSatsPreview)} sats &mdash; about{' '}
{satsToUSDWhole(goalSatsPreview, btcPrice)} today.
The dollar estimate may change with Bitcoin's price.
</p>
)}
<p className="text-xs text-muted-foreground">
Whole US Dollars. Donors pay in Bitcoin; clients estimate the USD-equivalent at view time.
</p>
</FormSection>
{/* Deadline */}
@@ -866,7 +618,7 @@ export function CreateCampaignPage() {
) : coverUploading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Uploading cover…
Uploading banner
</>
) : (
<>
@@ -883,6 +635,48 @@ export function CreateCampaignPage() {
// ─── Layout helpers ──────────────────────────────────────────────────────────
function WalletHint({
walletInput,
parsed,
}: {
walletInput: string;
parsed: ReturnType<typeof parseCampaignWallet>;
}) {
const trimmed = walletInput.trim();
if (!trimmed) {
return (
<p className="text-xs text-muted-foreground">
Use a mainnet bech32 address (<span className="font-mono">bc1q</span> /{' '}
<span className="font-mono">bc1p</span>) for a public, traceable campaign,
or a BIP-352 silent-payment code (<span className="font-mono">sp1</span>)
for a private campaign where totals are hidden.
</p>
);
}
if (!parsed) {
return (
<p className="text-xs text-destructive">
Not a recognized mainnet wallet endpoint. Provide a <span className="font-mono">bc1q</span>,{' '}
<span className="font-mono">bc1p</span>, or <span className="font-mono">sp1</span> string.
</p>
);
}
if (parsed.mode === 'onchain') {
return (
<p className="text-xs text-muted-foreground inline-flex items-center gap-1.5">
<Wallet className="size-3.5" />
Public on-chain address. Donations are traceable; the campaign page shows progress and totals.
</p>
);
}
return (
<p className="text-xs text-muted-foreground inline-flex items-center gap-1.5">
<ShieldCheck className="size-3.5" />
Silent payment. Donations are unlinkable by design; totals will not be shown to anyone.
</p>
);
}
function CountrySelect({
query,
selectedCode,
@@ -989,66 +783,11 @@ function CountrySelect({
{selectedCountry && (
<p className="text-xs text-muted-foreground">
Publishes <span className="font-mono text-foreground">i: iso3166:{selectedCode}</span> for country sorting.
Publishes <span className="font-mono text-foreground">i: iso3166-1:{selectedCode}</span> for country sorting.
</p>
)}
</div>
);
}
function RecipientRow({
profile,
campaignTitle,
onRemove,
}: {
profile: SearchProfile;
campaignTitle: string;
onRemove: () => void;
}) {
const { toast } = useToast();
const displayName = profile.metadata.display_name || profile.metadata.name || genUserName(profile.pubkey);
const picture = sanitizeUrl(profile.metadata.picture);
const handleNotify = () => {
const url = `${getShareOrigin()}/claim`;
const titleClause = campaignTitle.trim() ? ` called "${campaignTitle.trim()}"` : '';
const message = `I just started a fundraiser for you on Agora${titleClause}! Sign in here for more info and to claim your donations: ${url}`;
void shareTextOrCopy(message, toast, `Message for ${displayName} copied`);
};
return (
<div className="rounded-lg bg-secondary/30 p-2.5">
<div className="flex items-center gap-3">
<Avatar className="size-8 shrink-0">
{picture && <AvatarImage src={picture} alt="" />}
<AvatarFallback className="text-xs">{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{displayName}</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleNotify}
className="h-8 shrink-0 gap-1.5 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<MessageCircle className="size-3.5" />
Notify
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
aria-label={`Remove ${displayName}`}
className="shrink-0"
>
<X className="size-4" />
</Button>
</div>
</div>
);
}
export default CreateCampaignPage;
+2 -1
View File
@@ -37,6 +37,7 @@ import {
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { withAgoraTag } from '@/lib/agoraNoteTags';
/**
* Convert text into a URL-safe slug for the NIP-72 community's d-tag.
@@ -401,7 +402,7 @@ export function CreateCommunityPage() {
const created = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: '',
tags,
tags: withAgoraTag(tags),
});
return { event: created, slug, edited: false };
+2 -1
View File
@@ -26,6 +26,7 @@ import { getTodayDateInput } from '@/lib/dateInput';
import { createOrganizationAssociationTags, decodeOrganizationParam } from '@/lib/organizationContext';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { unixSecondsInTimezone } from '@/lib/timezone';
import { withAgoraTag } from '@/lib/agoraNoteTags';
function slugify(text: string): string {
return text
@@ -164,7 +165,7 @@ export function CreateEventPage() {
const publishedEvent = await publishEvent({
kind,
content: trimmedDescription,
tags,
tags: withAgoraTag(tags),
});
const eventCoord = `${kind}:${user.pubkey}:${dTag}`;
-23
View File
@@ -1,23 +0,0 @@
import { useSeoMeta } from '@unhead/react';
import { Feed } from '@/components/Feed';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
/** `/discover` — the Agora activity feed. */
export function DiscoverPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
useLayoutOptions({ showFAB: true, fabKind: 1, hasSubHeader: !!user });
useSeoMeta({
title: `Discover | ${config.appName}`,
description: 'Campaigns, pledges, donations, and conversations happening on Agora.',
});
return <Feed />;
}
export default DiscoverPage;
-12
View File
@@ -23,12 +23,10 @@ import {
BookContentHeader,
CountryContentHeader,
} from '@/components/ExternalContentHeader';
import { PrecipitationEffect } from '@/components/PrecipitationEffect';
import { parseExternalUri, headerLabel, seoTitle, type ExternalContent } from '@/lib/externalContent';
import { ratingToStars } from '@/lib/bookstr';
import { formatNumber } from '@/lib/formatNumber';
import { useAppContext } from '@/hooks/useAppContext';
import { useWeather, getPrecipitation } from '@/hooks/useWeather';
import { useComments } from '@/hooks/useComments';
import { usePaginatedFeed } from '@/hooks/usePaginatedFeed';
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
@@ -361,11 +359,6 @@ export function ExternalContentPage() {
// FAB opens the comment compose dialog
const [composeOpen, setComposeOpen] = useState(false);
const openCompose = useCallback(() => setComposeOpen(true), []);
const { data: weather } = useWeather(countryCode);
const precipitation = useMemo(() => {
if (!weather) return null;
return getPrecipitation(weather.weatherCode);
}, [weather]);
useLayoutOptions({
showFAB: true,
@@ -378,11 +371,6 @@ export function ExternalContentPage() {
return (
<main className="">
{/* Precipitation overlay for country pages */}
{precipitation?.type && (
<PrecipitationEffect type={precipitation.type} intensity={precipitation.intensity} />
)}
{/* Non-sticky transparent header skipped on country pages because
the country hero carries its own back arrow overlaid on the
photo, which lets the cinematic banner reach all the way to the
-85
View File
@@ -1,85 +0,0 @@
import { useSeoMeta } from '@unhead/react';
import { PageHeader } from '@/components/PageHeader';
import { IntroImage } from '@/components/IntroImage';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { useAppContext } from '@/hooks/useAppContext';
export function MagicSettingsPage() {
const { config, updateConfig } = useAppContext();
useSeoMeta({
title: `Magic | Settings | ${config.appName}`,
description: 'Magical cursor effects and enchanted interface settings',
});
function toggleMagicMouse(checked: boolean) {
updateConfig((c) => ({ ...c, magicMouse: checked }));
}
return (
<main className="">
{/* Header with back link */}
<PageHeader
backTo="/settings"
alwaysShowBack
titleContent={
<div className="flex-1 min-w-0">
<h1 className="text-xl font-bold">Magic</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Harness the mystical energies of your device. Imbue your cursor with elemental fire.
</p>
</div>
}
/>
<div className="p-4">
{/* Intro */}
<div className="flex items-center gap-4 px-3 pt-2 pb-6">
<IntroImage src="/magic-intro.png" />
<div className="min-w-0">
<h2 className="text-sm font-semibold">Arcane Configuration</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Harness the mystical energies of your device. Imbue your cursor with elemental fire and make every interaction feel enchanted.
</p>
</div>
</div>
{/* Ornament */}
<div className="flex items-center gap-3 px-2 pb-5">
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-primary/40 to-primary/60" />
<span className="text-primary/50 text-xs tracking-[0.3em] select-none"></span>
<div className="h-px flex-1 bg-gradient-to-l from-transparent via-primary/40 to-primary/60" />
</div>
{/* Magic Mouse toggle */}
<div
className="flex items-start gap-4 rounded-xl px-4 py-4 transition-colors hover:bg-muted/40"
style={{ background: config.magicMouse ? 'radial-gradient(ellipse 120% 80% at 50% 50%, hsl(var(--primary) / 0.07), transparent)' : undefined }}
>
<div className="flex-1 min-w-0">
<Label htmlFor="magic-mouse-toggle" className="text-sm font-semibold cursor-pointer flex items-center gap-1.5">
Magic Mouse
</Label>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Your cursor (or finger on touch devices) will emanate magical fire in the glow of your primary color. Move with purpose every path you trace becomes a trail of flame.
</p>
</div>
<Switch
id="magic-mouse-toggle"
checked={config.magicMouse}
onCheckedChange={toggleMagicMouse}
className="mt-0.5 shrink-0"
/>
</div>
{/* Bottom ornament */}
<div className="flex items-center gap-3 px-2 pt-6 pb-4">
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-primary/20 to-primary/30" />
<span className="text-primary/30 text-[10px] tracking-[0.4em] select-none"></span>
<div className="h-px flex-1 bg-gradient-to-l from-transparent via-primary/20 to-primary/30" />
</div>
</div>
</main>
);
}
+5 -9
View File
@@ -3,7 +3,6 @@ import { Navigate } from 'react-router-dom';
import { PageHeader } from '@/components/PageHeader';
import { RelayListManager } from '@/components/RelayListManager';
import { BlossomSettings } from '@/components/BlossomSettings';
import { IntroImage } from '@/components/IntroImage';
import { HelpTip } from '@/components/HelpTip';
import { Label } from '@/components/ui/label';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -41,14 +40,11 @@ export function NetworkSettingsPage() {
<div className="p-4">
{/* Intro */}
<div className="flex items-center gap-4 px-3 pt-2 pb-4">
<IntroImage src="/relay-intro.png" />
<div className="min-w-0">
<h2 className="text-sm font-semibold">Network Connections</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Manage your relay connections. Relays are servers that store and distribute Nostr events across the network.
</p>
</div>
<div className="px-3 pt-2 pb-4">
<h2 className="text-sm font-semibold">Network Connections</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Manage your relay connections. Relays are servers that store and distribute Nostr events across the network.
</p>
</div>
{/* Relays */}
+51 -28
View File
@@ -37,6 +37,7 @@ const CustomNipCard = lazy(() => import("@/components/CustomNipCard").then(m =>
import { FileMetadataContent } from "@/components/FileMetadataContent";
import { FollowPackContent } from "@/components/FollowPackContent";
import { GoalCard } from "@/components/GoalCard";
import { DetailCommentComposer } from "@/components/DetailCommentComposer";
import { FollowPackDetailContent } from "@/components/FollowPackDetailContent";
import { FoundLogContent } from "@/components/FoundLogContent";
import { GeocacheContent } from "@/components/GeocacheContent";
@@ -51,7 +52,6 @@ import { LiveStreamPage } from "@/components/LiveStreamPage";
import { MagicDeckContent } from "@/components/MagicDeckContent";
import { MusicDetailContent } from "@/components/MusicDetailContent";
import { ActivityCard, EventActionHeader, NoteCard } from "@/components/NoteCard";
import { FeedCard } from "@/components/FeedCard";
import { publishedAtAction } from "@/lib/publishedAtAction";
import { NoteContent } from "@/components/NoteContent";
import { NsiteCard } from "@/components/NsiteCard";
@@ -437,20 +437,18 @@ function ProfileBadgesDetailView({ event }: { event: NostrEvent }) {
return (
<div>
<FeedCard>
<NoteCard event={event} />
</FeedCard>
<NoteCard event={event} />
<div className="pb-16 sidebar:pb-0">
{commentsLoading ? (
<FeedCard className="mt-4 divide-y divide-border">
<div className="mt-4 divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</FeedCard>
</div>
) : orderedReplies.length > 0 ? (
<FeedCard className="mt-4">
<div className="mt-4">
<FlatThreadedReplyList replies={orderedReplies} />
</FeedCard>
</div>
) : (
<div className="py-12 text-center text-muted-foreground text-sm">
No replies yet. Be the first to reply!
@@ -1398,12 +1396,9 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
return (
<CommunityModerationContext.Provider value={communityModContext}>
<div>
{/* Focused post card ancestor previews, ancestor thread, and the
focused event itself share one rounded surface so the page
reads as "thread context → this post" instead of an
edge-to-edge Twitter timeline. Replies sit in their own
FeedCard below. */}
<FeedCard>
{/* Focused post ancestor previews, ancestor thread, and the
focused event render edge-to-edge to match the Activity feed
and the Search results. */}
{/* Content preview for kind 1111 comments: external content, profile, or community */}
{externalIdentifier && (
<ExternalContentPreview identifier={externalIdentifier} />
@@ -2072,25 +2067,53 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
/>
</article>
)}
</FeedCard>
{/* Replies */}
<div className="pb-16 sidebar:pb-0">
<div className="mt-6 pb-16 sidebar:pb-0">
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
<h2 className="text-lg font-semibold tracking-tight">
{isKind1 ? 'Replies' : 'Comments'}
</h2>
{replyTree.length > 0 ? (
<span className="text-sm text-muted-foreground tabular-nums">
{formatNumber(replyTree.length)}{' '}
{replyTree.length === 1
? isKind1 ? 'reply' : 'comment'
: isKind1 ? 'replies' : 'comments'}
</span>
) : null}
</div>
<DetailCommentComposer
event={event}
className="mb-3"
placeholder={isKind1 ? 'Write a reply...' : "What's on your mind?"}
/>
{repliesLoading ? (
<FeedCard className="mt-4 divide-y divide-border">
<div className="rounded-2xl bg-card border border-border/60 shadow-sm overflow-hidden divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</FeedCard>
) : replyTree.length > 0 ? (
<FeedCard className="mt-4">
<ThreadedReplyList roots={replyTree} />
</FeedCard>
) : !parentEventId ? (
<div className="py-12 text-center text-muted-foreground text-sm">
No replies yet. Be the first to reply!
</div>
) : null}
) : replyTree.length > 0 ? (
<div className="rounded-2xl bg-card border border-border/60 shadow-sm overflow-hidden">
<ThreadedReplyList roots={replyTree} />
</div>
) : (
<button
type="button"
onClick={() => setReplyOpen(true)}
className="block w-full rounded-2xl border border-dashed border-border/80 bg-card/50 px-6 py-10 text-center hover:bg-card hover:border-primary/40 transition-colors"
>
<p className="text-base font-medium text-foreground">
No {isKind1 ? 'replies' : 'comments'} yet
</p>
<p className="mt-1 text-sm text-muted-foreground">
Be the first to {isKind1 ? 'reply' : 'start the discussion'}.
</p>
</button>
)}
</div>
</div>
</CommunityModerationContext.Provider>
@@ -2272,11 +2295,11 @@ export function PostDetailSkeleton() {
</div>
{/* Replies skeleton */}
<FeedCard className="mt-4 divide-y divide-border">
<div className="mt-4 divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</FeedCard>
</div>
</div>
);
}
+5 -9
View File
@@ -20,7 +20,6 @@ import { useQueryClient } from '@tanstack/react-query';
import { ProfileCard } from '@/components/ProfileCard';
import { ProfileRightSidebar } from '@/components/ProfileRightSidebar';
import { PageHeader } from '@/components/PageHeader';
import { IntroImage } from '@/components/IntroImage';
import { HelpTip } from '@/components/HelpTip';
import { ImageCropDialog } from '@/components/ImageCropDialog';
import { SortableList, SortableItem } from '@/components/SortableList';
@@ -701,14 +700,11 @@ export function ProfileSettings() {
<form id="profile-settings-form" onSubmit={form.handleSubmit(onSubmit)} className="max-w-xl mx-auto px-4 pb-10 space-y-6">
{/* Intro */}
<div className="flex items-center gap-4 px-3 pt-2 pb-2">
<IntroImage src="/profile-intro.png" />
<div className="min-w-0">
<h2 className="text-sm font-semibold">Your Identity</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Tap any field on the card to edit. Click your avatar or banner to upload and crop a new image.
</p>
</div>
<div className="px-3 pt-2 pb-2">
<h2 className="text-sm font-semibold">Your Identity</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Tap any field on the card to edit. Click your avatar or banner to upload and crop a new image.
</p>
</div>
{/* Interactive profile card */}
+63 -202
View File
@@ -4,11 +4,7 @@ import {
SlidersHorizontal,
Search as SearchIcon,
UserRoundCheck,
User,
RotateCcw,
BookmarkPlus,
Check,
Loader2,
Globe, Users, UserSearch,
Clock, Flame, TrendingUp,
} from 'lucide-react';
@@ -16,7 +12,6 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useInView } from 'react-intersection-observer';
import { Link, useSearchParams } from 'react-router-dom';
import { NoteCard } from '@/components/NoteCard';
import { FeedCard } from '@/components/FeedCard';
import { PullToRefresh } from '@/components/PullToRefresh';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
@@ -35,10 +30,6 @@ import { KindPicker, AuthorChip, AuthorFilterDropdown } from '@/components/Saved
import { useSearchProfiles } from '@/hooks/useSearchProfiles';
import { useAuthor } from '@/hooks/useAuthor';
import { useStreamPosts } from '@/hooks/useStreamPosts';
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useProfileTabs } from '@/hooks/useProfileTabs';
import { usePublishProfileTabs } from '@/hooks/usePublishProfileTabs';
import { useFollowList } from '@/hooks/useFollowActions';
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
import { useFollowPacks } from '@/hooks/useFollowPacks';
@@ -50,18 +41,20 @@ import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { cn, parseKindFilter } from '@/lib/utils';
import type { TabFilter } from '@/contexts/AppContext';
import { useLayoutOptions, useNavHidden } from '@/contexts/LayoutContext';
import { PageHeader } from '@/components/PageHeader';
import { isRepostKind, parseRepostContent } from '@/lib/feedUtils';
import { nip19 } from 'nostr-tools';
type TabType = 'communities' | 'posts' | 'accounts';
type TabType = 'agora' | 'posts' | 'accounts';
const VALID_TABS: TabType[] = ['communities', 'posts', 'accounts'];
const VALID_TABS: TabType[] = ['agora', 'posts', 'accounts'];
function parseTab(value: string | null): TabType {
return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'communities';
// Back-compat: ?tab=communities and ?tab=activity used to be the default tab;
// alias them both to agora.
if (value === 'communities' || value === 'activity') return 'agora';
return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'agora';
}
const VALID_AUTHOR_SCOPES = ['anyone', 'follows', 'people'] as const;
@@ -75,7 +68,7 @@ const DEFAULT_FILTERS = {
mediaType: 'all' as const,
language: 'global',
platform: 'nostr' as const,
kindFilter: 'all',
kindFilter: 'agora',
customKindText: '',
authorScope: 'anyone' as AuthorScope,
sort: 'recent' as SortPref,
@@ -198,7 +191,7 @@ export function SearchPage() {
const setActiveTab = useCallback((tab: TabType) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (tab === 'communities') {
if (tab === 'agora') {
next.delete('tab');
} else {
next.set('tab', tab);
@@ -262,15 +255,18 @@ export function SearchPage() {
const allKindNumbers = useMemo(() => kindOptions.map((o) => Number(o.value)), [kindOptions]);
// Resolve kindsOverride from the current kind filter state.
// "all" means every kind in the picker list, not undefined (which would let
// useStreamPosts fall back to only the user's enabled feed-settings kinds).
// "all" means every kind in the picker list, not undefined (which would
// let useStreamPosts fall back to only the user's enabled feed-settings
// kinds). "agora" expands to the curated Agora preset set.
const kindsOverride = useMemo<number[]>(
() => kindFilter === 'all' ? allKindNumbers : (parseKindFilter(kindFilter, customKindText) ?? allKindNumbers),
[kindFilter, customKindText, allKindNumbers],
);
// Detect kind + media type conflict: a specific kind is selected AND a media type is set
const hasKindMediaConflict = kindFilter !== 'all' && kindsOverride.length > 0 && mediaType !== 'all';
// Detect kind + media type conflict: a non-broad kind is selected AND a
// media type is set. "all" and "agora" are both broad selections that
// don't conflict with media filters.
const hasKindMediaConflict = kindFilter !== 'all' && kindFilter !== 'agora' && kindsOverride.length > 0 && mediaType !== 'all';
// Determine if any filter differs from the default
const hasActiveFilters = !includeReplies || mediaType !== DEFAULT_FILTERS.mediaType ||
@@ -302,7 +298,7 @@ export function SearchPage() {
: ['protocol:nostr'];
if (debouncedSearchQuery.trim()) parts.push(debouncedSearchQuery.trim());
if (language !== 'global') parts.push(`language:${language}`);
const isDedicatedKindQuery = kindFilter === 'all' && (mediaType === 'vines' || mediaType === 'images' || mediaType === 'videos');
const isDedicatedKindQuery = (kindFilter === 'all' || kindFilter === 'agora') && (mediaType === 'vines' || mediaType === 'images' || mediaType === 'videos');
if (!isDedicatedKindQuery && !hasKindMediaConflict) {
if (mediaType === 'images') { parts.push('media:true'); parts.push('video:false'); }
else if (mediaType === 'videos') parts.push('video:true');
@@ -321,7 +317,13 @@ export function SearchPage() {
if (language !== 'global') labels.push(language.toUpperCase());
if (platform !== 'nostr') labels.push({ activitypub: 'Mastodon', atproto: 'Bluesky' }[platform] ?? platform);
if (sort !== 'recent') labels.push(sort === 'hot' ? 'Hot' : 'Trending');
if (kindFilter !== 'all' && kindFilter !== 'custom') {
if (kindFilter === 'agora') {
// 'agora' is the default — no chip needed.
} else if (kindFilter === 'all') {
labels.push('All kinds');
} else if (kindFilter === 'custom') {
if (customKindText) labels.push(`Kind: ${customKindText}`);
} else {
const kindValues = kindFilter.split(',').filter(Boolean);
if (kindValues.length === 1) {
const opt = kindOptions.find(o => o.value === kindValues[0]);
@@ -330,8 +332,6 @@ export function SearchPage() {
} else if (kindValues.length > 1) {
labels.push(`${kindValues.length} kinds`);
}
} else if (kindFilter === 'custom' && customKindText) {
labels.push(`Kind: ${customKindText}`);
}
if (authorScope === 'follows') labels.push('My follows');
if (authorScope === 'people' && authorPubkeys.length > 0) labels.push(`${authorPubkeys.length} author${authorPubkeys.length > 1 ? 's' : ''}`);
@@ -339,61 +339,13 @@ export function SearchPage() {
}, [includeReplies, mediaType, language, platform, sort, kindFilter, customKindText, authorScope, authorPubkeys, kindOptions]);
// Hooks
const { user } = useCurrentUser();
const { data: followData } = useFollowList();
const followPubkeys = useMemo(() => followData?.pubkeys ?? [], [followData?.pubkeys]);
const { lists } = useUserLists();
const { data: followPacks = [] } = useFollowPacks();
const { savedFeeds, addSavedFeed, isPending: isSavingFeed } = useSavedFeeds();
const profileTabsQuery = useProfileTabs(user?.pubkey);
const { publishProfileTabs, isPending: isPublishingTabs } = usePublishProfileTabs();
const [savePopoverOpen, setSavePopoverOpen] = useState(false);
const [saveFeedLabel, setSaveFeedLabel] = useState('');
const [savedJustNow, setSavedJustNow] = useState(false);
const listPickerValue = useMatchedListId(authorPubkeys);
// 'people' scope with explicit authors = user-specific; not eligible for profile tab
const isAuthorSpecific = authorScope === 'people' && authorPubkeys.length > 0;
// Build a standard NIP-01 TabFilter from the current search state
const currentFilter = useMemo<TabFilter>(() => {
const filter: TabFilter = {};
if (debouncedSearchQuery.trim()) filter.search = debouncedSearchQuery.trim();
if (kindsOverride && kindsOverride.length > 0) filter.kinds = kindsOverride;
if (authorScope === 'people' && authorPubkeys.length > 0) filter.authors = authorPubkeys;
return filter;
}, [debouncedSearchQuery, kindsOverride, authorScope, authorPubkeys]);
const alreadySaved = savedFeeds.some(
(f) => JSON.stringify(f.filter) === JSON.stringify(currentFilter),
);
const handleSaveFeed = async () => {
if (!saveFeedLabel.trim() || isSavingFeed) return;
const varsToSave = authorScope === 'follows' && user
? [{ name: '$follows', tagName: 'p', pointer: `a:3:${user.pubkey}:` }]
: [];
await addSavedFeed(saveFeedLabel, currentFilter, varsToSave);
setSavePopoverOpen(false);
setSaveFeedLabel('');
setSavedJustNow(true);
setTimeout(() => setSavedJustNow(false), 2000);
};
const handleSaveProfileTab = async () => {
if (!saveFeedLabel.trim() || isPublishingTabs || !user) return;
const existing = profileTabsQuery.data ?? { tabs: [], vars: [] };
await publishProfileTabs({
tabs: [...existing.tabs, { label: saveFeedLabel.trim(), filter: currentFilter }],
vars: existing.vars,
});
setSavePopoverOpen(false);
setSaveFeedLabel('');
setSavedJustNow(true);
setTimeout(() => setSavedJustNow(false), 2000);
};
// Resolve author pubkeys for the stream
const streamAuthorPubkeys = authorScope === 'follows'
? followPubkeys
@@ -421,8 +373,8 @@ export function SearchPage() {
<main className="flex-1 min-w-0">
<PageHeader title="Search" icon={<SearchIcon className="size-5" />} />
<SubHeaderBar>
<TabButton label="Communities" active={activeTab === 'communities'} onClick={() => setActiveTab('communities')} />
<TabButton label="Posts" active={activeTab === 'posts'} onClick={() => setActiveTab('posts')} />
<TabButton label="Agora" active={activeTab === 'agora'} onClick={() => setActiveTab('agora')} />
<TabButton label="Nostr" active={activeTab === 'posts'} onClick={() => setActiveTab('posts')} />
<TabButton label="Accounts" active={activeTab === 'accounts'} onClick={() => setActiveTab('accounts')} />
</SubHeaderBar>
@@ -434,81 +386,8 @@ export function SearchPage() {
onDebouncedChange={setDebouncedSearchQuery}
/>
{/* Add to feed button (posts & communities tabs) */}
{(activeTab === 'posts' || activeTab === 'communities') && user && (
<div className={cn(!debouncedSearchQuery.trim() && !hasActiveFilters ? 'hidden' : undefined)}>
<Popover open={savePopoverOpen} onOpenChange={(o) => {
setSavePopoverOpen(o);
if (o && !saveFeedLabel) {
if (debouncedSearchQuery.trim()) {
setSaveFeedLabel(debouncedSearchQuery.trim());
} else if (listPickerValue) {
const matched =
listPickerValue.startsWith('set:')
? lists.find((l) => l.id === listPickerValue.slice(4))?.title
: followPacks.find((p) => p.id === listPickerValue.slice(5))?.title;
if (matched) setSaveFeedLabel(matched);
}
}
}}>
<PopoverTrigger asChild>
<button
className={cn(
'shrink-0 h-10 w-10 rounded-lg border flex items-center justify-center transition-colors',
alreadySaved || savedJustNow
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-secondary/50 hover:bg-secondary text-muted-foreground hover:text-foreground',
)}
style={{ outline: 'none' }}
aria-label="Add to feed"
>
{savedJustNow ? <Check className="size-4" /> : <BookmarkPlus className="size-4" />}
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-64 p-3 space-y-3">
<p className="font-semibold text-sm">Save as tab</p>
{alreadySaved ? (
<p className="text-sm text-muted-foreground">Already saved.</p>
) : (
<>
<Input
placeholder="Tab name…"
value={saveFeedLabel}
onChange={(e) => setSaveFeedLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveFeed(); }}
className="bg-secondary/50 border-border focus-visible:ring-1 text-base md:text-sm"
autoFocus
/>
<div className="space-y-1">
<SaveDestinationRow
icon={<BookmarkPlus className="size-4 text-muted-foreground" />}
label="Home feed"
description="Tab on your home page"
onClick={() => handleSaveFeed()}
disabled={!saveFeedLabel.trim() || isSavingFeed || isPublishingTabs}
loading={isSavingFeed}
/>
{!isAuthorSpecific && (
<SaveDestinationRow
icon={<User className="size-4 text-muted-foreground" />}
label="Profile tab"
description="Your posts matching this search"
onClick={() => handleSaveProfileTab()}
disabled={!saveFeedLabel.trim() || isSavingFeed || isPublishingTabs}
loading={isPublishingTabs}
/>
)}
</div>
</>
)}
</PopoverContent>
</Popover>
</div>
)}
{/* Filter popover (posts & communities tabs) */}
{(activeTab === 'posts' || activeTab === 'communities') && (
{/* Filter popover (posts & agora tabs) */}
{(activeTab === 'posts' || activeTab === 'agora') && (
<Popover open={filtersOpen} onOpenChange={setFiltersOpen}>
<PopoverTrigger asChild>
<button
@@ -629,7 +508,7 @@ export function SearchPage() {
</div>
</div>
{/* Posts-only filters (hidden on communities tab) */}
{/* Posts-only filters (hidden on agora tab) */}
{activeTab === 'posts' && (
<>
<Separator />
@@ -715,8 +594,8 @@ export function SearchPage() {
)}
</div>
{/* Active filter summary chips (posts & communities tabs) */}
{(activeTab === 'posts' || activeTab === 'communities') && activeFilterLabels.length > 0 && (
{/* Active filter summary chips (posts & agora tabs) */}
{(activeTab === 'posts' || activeTab === 'agora') && activeFilterLabels.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{activeFilterLabels.map((label) => (
<Badge key={label} variant="secondary" className="text-xs font-normal">
@@ -732,8 +611,8 @@ export function SearchPage() {
</div>
)}
{/* NIP-50 search query debug block (posts & communities tabs) */}
{(activeTab === 'posts' || activeTab === 'communities') && debouncedSearchQuery.trim() && (
{/* NIP-50 search query debug block (posts & agora tabs) */}
{(activeTab === 'posts' || activeTab === 'agora') && debouncedSearchQuery.trim() && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -753,9 +632,9 @@ export function SearchPage() {
</div>
<PullToRefresh onRefresh={handleRefresh}>
{/* ─── Communities Tab ─── */}
{activeTab === 'communities' && (
<CommunitiesSearchTab
{/* ─── Agora Tab ─── */}
{activeTab === 'agora' && (
<AgoraSearchTab
searchQuery={debouncedSearchQuery}
includeReplies={includeReplies}
mediaType={mediaType}
@@ -797,13 +676,13 @@ export function SearchPage() {
)}
{/* Post results — stream */}
{postsLoading && posts.length === 0 ? (
<FeedCard className="mt-2 divide-y divide-border">
<div className="mt-2 divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<PostSkeleton key={i} />
))}
</FeedCard>
</div>
) : posts.length > 0 ? (
<FeedCard className="mt-2">
<div className="mt-2">
{posts.map((event) => {
const isNew = flushedIds.has(event.id);
if (isRepostKind(event.kind)) {
@@ -815,15 +694,15 @@ export function SearchPage() {
}
return <NoteCard key={event.id} event={event} highlight={isNew} />;
})}
</FeedCard>
</div>
) : debouncedSearchQuery.trim() ? (
<EmptyState
message="No posts found matching your search."
message="No results found matching your search."
activeFilters={activeFilterLabels}
onResetFilters={hasActiveFilters ? resetFilters : undefined}
/>
) : (
<EmptyState message="Enter a search query to find posts." />
<EmptyState message="Enter a search query to find Nostr content." />
)}
</>
)}
@@ -834,17 +713,17 @@ export function SearchPage() {
<div>
{debouncedSearchQuery.trim() ? (
profilesLoading ? (
<FeedCard className="mt-2 divide-y divide-border">
<div className="mt-2 divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<AccountSkeleton key={i} />
))}
</FeedCard>
</div>
) : profiles && profiles.length > 0 ? (
<FeedCard className="mt-2 divide-y divide-border">
<div className="mt-2 divide-y divide-border">
{profiles.map((profile) => (
<AccountItem key={profile.pubkey} profile={profile} isFollowed={followedPubkeys.has(profile.pubkey)} />
))}
</FeedCard>
</div>
) : (
<EmptyState message="No accounts found matching your search." />
)
@@ -930,7 +809,7 @@ function FollowsList() {
}
return (
<FeedCard className="mt-2 divide-y divide-border">
<div className="mt-2 divide-y divide-border">
{visiblePubkeys.map((pubkey) => (
<FollowItem key={pubkey} pubkey={pubkey} />
))}
@@ -941,7 +820,7 @@ function FollowsList() {
))}
</div>
)}
</FeedCard>
</div>
);
}
@@ -1112,8 +991,12 @@ function SearchInput({
);
}
/** Communities tab — isolated component so useStreamPosts only subscribes when active. */
function CommunitiesSearchTab({
/** Agora tab isolated component so useStreamPosts only subscribes when active.
* Pins kinds to [33863 Campaigns, 36639 Pledges, 34550 Groups/Communities]
* the non-kind-1 Agora content stream and narrows to events whose NIP-89
* `client` tag is the running app name (e.g. "Agora"). Kind 1 posts and the
* unconstrained Nostr firehose live on the Nostr tab instead. */
function AgoraSearchTab({
searchQuery,
includeReplies,
mediaType,
@@ -1136,71 +1019,49 @@ function CommunitiesSearchTab({
hasActiveFilters: boolean;
resetFilters: () => void;
}) {
const { config } = useAppContext();
const clientName = config.clientName ?? config.appName;
const { posts, isLoading: postsLoading } = useStreamPosts(searchQuery, {
includeReplies,
mediaType,
language,
protocols,
kindsOverride: [34550],
kindsOverride: [33863, 36639, 34550],
authorPubkeys,
sort,
clientName,
});
if (postsLoading && posts.length === 0) {
return (
<FeedCard className="mt-2 divide-y divide-border">
<div className="mt-2 divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<PostSkeleton key={i} />
))}
</FeedCard>
</div>
);
}
if (posts.length > 0) {
return (
<FeedCard className="mt-2">
<div className="mt-2">
{posts.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</FeedCard>
</div>
);
}
if (searchQuery.trim()) {
return (
<EmptyState
message="No communities found matching your search."
message="No Agora campaigns, pledges, or groups found matching your search."
activeFilters={activeFilterLabels}
onResetFilters={hasActiveFilters ? resetFilters : undefined}
/>
);
}
return <EmptyState message="Search for communities or browse the latest." />;
}
function SaveDestinationRow({
icon, label, description, onClick, disabled, loading,
}: {
icon: React.ReactNode;
label: string;
description: string;
onClick: () => void;
disabled: boolean;
loading: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-secondary/60 disabled:opacity-40 disabled:pointer-events-none transition-colors text-left"
>
<span className="shrink-0">{loading ? <Loader2 className="size-4 animate-spin text-muted-foreground" /> : icon}</span>
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium">{label}</span>
<span className="block text-xs text-muted-foreground">{description}</span>
</span>
</button>
);
return <EmptyState message="Search Agora campaigns, pledges, and groups, or browse the latest." />;
}
+33 -142
View File
@@ -1,13 +1,11 @@
import { useSeoMeta } from '@unhead/react';
import { lazy, Suspense, useState, useEffect, useRef } from 'react';
import { lazy, Suspense, useState } from 'react';
import { ChevronRight, Settings } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { PageHeader } from '@/components/PageHeader';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { IntroImage } from '@/components/IntroImage';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { toast } from '@/hooks/useToast';
import { isAdmin } from '@/lib/admins';
const RequestToVanishDialog = lazy(() => import('@/components/RequestToVanishDialog').then(m => ({ default: m.RequestToVanishDialog })));
@@ -16,7 +14,6 @@ interface SettingsSection {
id: string;
label: string;
description: string;
illustration?: string;
path: string;
requiresAuth?: boolean;
/** When true, only shown to platform admins (see `isAdmin` in `@/lib/admins`). */
@@ -27,67 +24,46 @@ const settingsSections: SettingsSection[] = [
{
id: 'profile',
label: 'Profile',
description: 'Edit your display name, bio, and avatar',
illustration: '/profile-intro.png',
description: 'Display name, bio, avatar, and verification.',
path: '/settings/profile',
requiresAuth: true,
},
{
id: 'appearance',
label: 'Appearance',
description: 'Switch between system, light, and dark mode',
illustration: '/theme-intro.png',
description: 'System, light, or dark mode.',
path: '/settings/appearance',
},
{
id: 'feed',
label: 'Home Feed',
description: 'Choose what types of posts appear in your home feed',
illustration: '/community-intro.png',
description: 'Choose which post types appear in your home feed, and manage muted users, hashtags, and sensitive content.',
path: '/settings/feed',
},
{
id: 'content',
label: 'Content',
description: 'Muted users, hashtags, and sensitive content settings',
illustration: '/mute-intro.png',
path: '/settings/content',
},
{
id: 'network',
label: 'Network',
description: 'Relays and file upload servers',
illustration: '/relay-intro.png',
description: 'Relays and file upload servers.',
path: '/settings/network',
requiresAuth: true,
},
{
id: 'notifications',
label: 'Notifications',
description: 'Configure push notification preferences',
illustration: '/notification-intro.png',
description: 'Push notification preferences.',
path: '/settings/notifications',
requiresAuth: true,
},
{
id: 'advanced',
label: 'Advanced',
description: 'Wallet, system, and power user settings',
illustration: '/advanced-intro.png',
description: 'Wallet, system, and power-user options.',
path: '/settings/advanced',
},
{
id: 'magic',
label: 'Magic',
description: 'Enchanted cursor effects and mystical interface powers',
illustration: '/magic-intro.png',
path: '/settings/magic',
},
{
id: 'organizers',
label: 'Organizers',
description: 'Appoint country organizers who can pin posts to country feeds',
illustration: '/community-intro.png',
description: 'Appoint country organizers who can pin posts to country feeds.',
path: '/organizers',
requiresAuth: true,
requiresAdmin: true,
@@ -96,20 +72,10 @@ const settingsSections: SettingsSection[] = [
export function SettingsPage() {
const { user } = useCurrentUser();
const { config, updateConfig } = useAppContext();
const { config } = useAppContext();
const navigate = useNavigate();
const [sigilFlash, setSigilFlash] = useState(false);
const [sigilVisible, setSigilVisible] = useState(false);
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
const inactivityTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (config.magicMouse) return;
inactivityTimer.current = setTimeout(() => setSigilVisible(true), 2 * 60 * 1000);
return () => {
if (inactivityTimer.current) clearTimeout(inactivityTimer.current);
};
}, [config.magicMouse]);
useLayoutOptions({});
useSeoMeta({
@@ -117,86 +83,48 @@ export function SettingsPage() {
description: `Manage your ${config.appName} settings`,
});
// Magic section only appears once unlocked; admin-only sections gate by isAdmin.
const visibleSections = settingsSections.filter((section) => {
if (section.requiresAuth && !user) return false;
if (section.requiresAdmin && !isAdmin(user?.pubkey)) return false;
if (section.id === 'magic' && !config.magicMouse) return false;
return true;
});
function unlockMagic() {
if (config.magicMouse) {
navigate('/settings/magic');
return;
}
setSigilFlash(true);
setTimeout(() => setSigilFlash(false), 1000);
updateConfig((c) => ({ ...c, magicMouse: true }));
toast({
title: '✨ Magical potential unlocked',
description: 'You have awakened the arcane. Your cursor now burns with enchanted fire.',
});
}
return (
<main className="relative min-h-screen pb-16 sidebar:pb-0">
{/* Page header */}
<main className="min-h-screen pb-16 sidebar:pb-0">
<PageHeader title="Settings" icon={<Settings className="size-5" />} backTo="/" />
{/* Codex heading + exposition */}
<div className="px-7 pb-4 pt-4 text-center space-y-2.5">
<p className="text-xs text-muted-foreground leading-relaxed select-none">
Shape your identity, tune your feed, and manage how you connect to the Nostr network.<br />Everything you need to make this place feel like yours.
</p>
<p className="text-[10px] tracking-[0.5em] uppercase text-primary/60 select-none pt-6">Codex of Configuration</p>
</div>
{/* Tome ornament */}
<div className="flex items-center gap-3 px-6 pb-5">
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-primary/40 to-primary/60" />
<span className="text-primary/50 text-xs tracking-[0.3em] select-none"></span>
<div className="h-px flex-1 bg-gradient-to-l from-transparent via-primary/40 to-primary/60" />
</div>
{/* Settings menu */}
<div className="px-4">
{visibleSections.map((section, i) => {
return (
<div key={section.id}>
<div
className="flex items-center gap-4 px-3 py-2 my-1 cursor-pointer rounded-xl transition-colors hover:bg-muted/60 active:bg-muted/80 group"
{/* Settings list */}
<nav aria-label="Settings" className="px-4 sm:px-6 pt-2">
<ul className="divide-y divide-border">
{visibleSections.map((section) => (
<li key={section.id}>
<button
type="button"
onClick={() => navigate(section.path)}
className="flex w-full items-center gap-4 px-2 py-4 text-left transition-colors hover:bg-muted/40 focus-visible:bg-muted/40 focus-visible:outline-none"
>
<div className="flex items-center justify-center size-20 shrink-0">
{section.illustration && (
<IntroImage src={section.illustration} size="w-22" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold">{section.label}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{section.description}
</p>
</div>
<ChevronRight className="size-4 text-primary/40 shrink-0 group-hover:text-primary/70 transition-colors" strokeWidth={4} />
</div>
{i < visibleSections.length - 1 && (
<div className="mx-6 h-px bg-primary/10" />
)}
</div>
);
})}
</div>
<ChevronRight className="size-4 text-muted-foreground shrink-0" aria-hidden="true" />
</button>
</li>
))}
</ul>
</nav>
{/* Delete Account */}
{/* Delete account */}
{user && (
<div className="flex justify-center pt-4 pb-1">
<div className="flex justify-center pt-8 pb-4">
<button
type="button"
onClick={() => setDeleteAccountOpen(true)}
className="text-xs text-destructive-foreground bg-destructive/80 hover:bg-destructive rounded-full px-4 py-1.5 transition-colors"
className="text-xs font-medium text-destructive hover:underline focus-visible:underline focus-visible:outline-none"
>
Delete Account
Delete account
</button>
</div>
)}
@@ -207,50 +135,13 @@ export function SettingsPage() {
</Suspense>
)}
{/* Bottom ornament */}
<div className="flex items-center gap-3 px-6 pt-4 pb-2">
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-primary/20 to-primary/30" />
<span className="text-primary/30 text-[10px] tracking-[0.4em] select-none"></span>
<div className="h-px flex-1 bg-gradient-to-l from-transparent via-primary/20 to-primary/30" />
</div>
{/* Version footer */}
<Link to="/changelog" className="block text-center text-[11px] text-muted-foreground/50 hover:text-muted-foreground transition-colors select-none pt-1 pb-2">
<Link
to="/changelog"
className="block text-center text-[11px] text-muted-foreground/70 hover:text-muted-foreground transition-colors pt-2 pb-4"
>
v{import.meta.env.VERSION}{import.meta.env.COMMIT_TAG ? '' : '+'} ({new Date(import.meta.env.BUILD_DATE).toLocaleDateString()})
</Link>
{/* Magic sigil — appears after 2 min inactivity, only when magic is locked */}
{!config.magicMouse && sigilVisible && (<div className="flex justify-center pt-16 pb-12">
<button
onClick={unlockMagic}
className="relative group focus:outline-none"
aria-label={config.magicMouse ? 'Open Magic settings' : 'Unlock magical potential'}
>
{/* Ambient radial glow pool — tight, close to the image */}
<div
className="absolute inset-0 rounded-full blur-xl animate-pulse-slow"
style={{
background: 'radial-gradient(circle, hsl(var(--primary) / 0.2), transparent 60%)',
opacity: sigilFlash ? 1 : undefined,
transform: sigilFlash ? 'scale(1.5)' : undefined,
transition: 'opacity 0.8s, transform 0.8s',
}}
/>
<div
className={!sigilFlash && !config.magicMouse ? 'animate-sigil-glow' : undefined}
style={sigilFlash || config.magicMouse ? {
opacity: sigilFlash ? 1 : 0.55,
filter: sigilFlash
? 'drop-shadow(0 0 20px hsl(var(--primary))) drop-shadow(0 0 40px hsl(var(--primary) / 0.4))'
: 'drop-shadow(0 0 8px hsl(var(--primary) / 0.7))',
transform: sigilFlash ? 'scale(1.12)' : 'scale(1)',
transition: 'opacity 0.8s, filter 0.8s, transform 0.5s',
} : undefined}
>
<IntroImage src="/magic-intro.png" size="w-72" />
</div>
</button>
</div>)}
</main>
);
}
+5 -9
View File
@@ -24,7 +24,6 @@ import {
import {
Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger,
} from '@/components/ui/dialog';
import { IntroImage } from '@/components/IntroImage';
import { PageHeader } from '@/components/PageHeader';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -234,14 +233,11 @@ export function UserListsPage() {
<div className="p-4">
{/* Intro block */}
<div className="flex items-center gap-4 px-3 pt-2 pb-4">
<IntroImage src="/lists-intro.png" />
<div className="min-w-0">
<h2 className="text-sm font-semibold">Lists</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Group people into named lists. Use any list as the source for a custom feed, or filter searches by list members.
</p>
</div>
<div className="px-3 pt-2 pb-4">
<h2 className="text-sm font-semibold">Lists</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Group people into named lists. Use any list as the source for a custom feed, or filter searches by list members.
</p>
</div>
{/* Create new list */}
-1
View File
@@ -107,7 +107,6 @@ export function TestApp({ children }: TestAppProps) {
faviconUrl: "https://ditto.pub/api/favicon/{hostname}",
linkPreviewUrl: "https://ditto.pub/api/link-preview/{url}",
corsProxy: "https://proxy.shakespeare.diy/?url={href}",
magicMouse: false,
contentWarningPolicy: "blur",
sentryDsn: "",
sentryEnabled: false,