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
-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>
);
}