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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+361
-840
File diff suppressed because it is too large
Load Diff
@@ -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 */}
|
||||
|
||||
@@ -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
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>© {new Date().getFullYear()} Agora. Fundraisers on Nostr.</span>
|
||||
<nav className="flex items-center gap-5">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
|
||||
@@ -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
@@ -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" }),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())) && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user