Merge branch 'feat/pin-updates' into 'main'

Feat/pin updates

See merge request soapbox-pub/agora!33
This commit is contained in:
Lemon
2026-05-21 14:12:47 -07:00
12 changed files with 1026 additions and 465 deletions
+26
View File
@@ -322,6 +322,32 @@ This mirrors the community batch-zap pattern documented in the kind 8333 section
Clients MUST verify each kind 8333 event on-chain before counting it toward the campaign total, per the verification rules in the kind 8333 section.
**Fetch pinned event comments:**
Event owners MAY pin important comments or activity feed events with a NIP-78 app-specific data event (`kind: 30078`) authored by the root event owner. The `d` tag is scoped to the root event coordinate. Agora uses this for campaigns (`30223`), pledges (`36639`), organizations (`34550`), and calendar events (`31922` / `31923`).
```json
{
"kind": 30078,
"pubkey": "<root-event-author-pubkey>",
"content": "{\"pinnedEvents\":[\"<event-id-2>\",\"<event-id-1>\"]}",
"tags": [
["d", "agora-pinned-comments:<kind>:<root-event-author-pubkey>:<d-tag>"],
["a", "<kind>:<root-event-author-pubkey>:<d-tag>"],
["k", "<kind>"],
["alt", "Pinned event comments"]
]
}
```
Clients SHOULD query the pin list with:
```json
{ "kinds": [30078], "authors": ["<root-event-author-pubkey>"], "#d": ["agora-pinned-comments:<kind>:<root-event-author-pubkey>:<d-tag>"], "limit": 1 }
```
The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned event removes it. Clients SHOULD ignore pin lists not authored by the root event owner.
### Client Behavior
- **Recipient validity:** clients SHOULD reject `p` tag entries whose pubkey is not 64 hex characters and SHOULD ignore weights that are not positive finite decimals.
+400 -210
View File
@@ -1,8 +1,8 @@
import { useMemo, useCallback, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
ArrowLeft,
CalendarDays,
ChevronLeft,
MapPin,
Clock,
Users,
@@ -18,10 +18,12 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Card, CardContent } from '@/components/ui/card';
import { DetailCommentComposer } from '@/components/DetailCommentComposer';
import { NoteContent } from '@/components/NoteContent';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { PostActionBar } from '@/components/PostActionBar';
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog';
import { RSVPAvatars } from '@/components/RSVPAvatars';
@@ -33,9 +35,11 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
import { useMyRSVP } from '@/hooks/useMyRSVP';
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { openUrl } from '@/lib/downloadFile';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
@@ -126,6 +130,26 @@ function formatDetailDate(event: NostrEvent): string {
return startStr;
}
function formatCalendarHeroDate(event: NostrEvent): string | null {
const startRaw = getTag(event.tags, 'start');
if (!startRaw) return null;
if (event.kind === 31922) {
const [year, month, day] = startRaw.split('-').map(Number);
const date = new Date(year, month - 1, day);
if (isNaN(date.getTime())) return startRaw;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
const timestamp = Number(startRaw);
if (!Number.isFinite(timestamp)) return startRaw;
const startTzid = getTag(event.tags, 'start_tzid');
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
...(startTzid ? { timeZone: startTzid } : {}),
});
}
const ROLE_ORDER = ['host', 'speaker', 'moderator', 'participant'];
function roleSort(a: string, b: string): number {
const ai = ROLE_ORDER.indexOf(a.toLowerCase());
@@ -162,6 +186,15 @@ function PersonRow({ pubkey, label, size = 'md' }: { pubkey: string; label?: str
);
}
function EventDetailRow({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3 rounded-xl bg-muted/40 px-3 py-3">
<div className="mt-0.5 text-primary shrink-0">{icon}</div>
<div className="min-w-0 text-sm leading-relaxed">{children}</div>
</div>
);
}
// --- Main Component ---
export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
@@ -170,7 +203,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const { toast } = useToast();
const title = getTag(event.tags, 'title') ?? 'Untitled Event';
const image = getTag(event.tags, 'image');
const image = sanitizeUrl(getTag(event.tags, 'image'));
const locationRaw = getTag(event.tags, 'location');
const location = locationRaw ? parseLocation(locationRaw) : undefined;
const summary = getTag(event.tags, 'summary');
@@ -179,6 +212,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const eventCoord = useMemo(() => getEventCoord(event), [event]);
const dateStr = useMemo(() => formatDetailDate(event), [event]);
const heroDate = useMemo(() => formatCalendarHeroDate(event), [event]);
// Participants grouped by role
const participantsByRole = useMemo(() => {
@@ -200,6 +234,12 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const myRsvp = useMyRSVP(eventCoord);
const publishRSVP = usePublishRSVP();
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
const {
pinnedEvents,
isPinned,
canManagePins,
togglePin,
} = usePinnedEventComments(eventCoord, event.pubkey);
const [replyOpen, setReplyOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
@@ -225,6 +265,11 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
.map((comment) => buildNode(comment));
}, [commentsData]);
const pinnedNodes = useMemo(
() => pinnedEvents.map((event): ReplyNode => ({ event, children: [] })),
[pinnedEvents],
);
const handleRSVP = useCallback(async (status: 'accepted' | 'declined' | 'tentative') => {
if (status === myRsvp.status) return;
try {
@@ -240,202 +285,339 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
}, [eventCoord, event.pubkey, myRsvp.status, publishRSVP, toast]);
const showRSVP = !!user;
const attendingCount = rsvps.accepted.length;
const interestedCount = rsvps.tentative.length;
const rsvpStatusLabel = myRsvp.status === 'accepted'
? 'You are going'
: myRsvp.status === 'tentative'
? 'You are interested'
: myRsvp.status === 'declined'
? "You can't go"
: 'Choose your RSVP';
return (
<div className="max-w-2xl mx-auto pb-16">
{/* ── Standard top bar ── */}
<div className="flex items-center gap-4 px-4 pt-4 pb-5">
<button
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
aria-label="Go back"
>
<ArrowLeft className="size-5" />
</button>
<h1 className="text-xl font-bold flex-1">Event Details</h1>
{canEdit && (
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={() => setEditOpen(true)}
aria-label="Edit event"
>
<Pencil className="size-5" />
</button>
const eventDetailsCard = (
<Card className="overflow-hidden">
<CardContent className="p-5 space-y-5">
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Hosted by</div>
<PersonRow pubkey={event.pubkey} size="sm" />
</div>
{(event.content || summary) && (
<div className="space-y-2 border-t border-border/60 pt-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Description</div>
{event.content ? (
<NoteContent event={event} className="text-sm leading-relaxed text-foreground" hideEmbedImages={!!image} disableEmbeds disableNoteEmbeds />
) : (
<p className="text-sm leading-relaxed text-muted-foreground">{summary}</p>
)}
</div>
)}
</div>
{/* ── Cover image ── */}
{image ? (
<div className="aspect-[2/1] w-full overflow-hidden">
<img src={image} alt={title} className="w-full h-full object-cover" />
</div>
) : (
<div className="aspect-[3/1] w-full bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
<CalendarDays className="size-20 text-primary/20" />
</div>
)}
{/* ── Content ── */}
<div className="px-5 mt-5 space-y-5">
{/* Title */}
<h2 className="text-2xl font-bold leading-tight tracking-tight">{title}</h2>
{/* Organizer row */}
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<PersonRow pubkey={event.pubkey} />
</div>
</div>
{/* Date & Location — sidebar-style pills */}
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85">
<Clock className="size-5 text-primary shrink-0" />
<span className="text-sm">{dateStr}</span>
</div>
<div className="space-y-3">
<EventDetailRow icon={<Clock className="size-5" />}>
{dateStr}
</EventDetailRow>
{location && (
<div className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85">
<MapPin className="size-5 text-primary shrink-0" />
<span className="text-sm">{location}</span>
</div>
<EventDetailRow icon={<MapPin className="size-5" />}>
{location}
</EventDetailRow>
)}
</div>
{/* Hashtags */}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-2">
{hashtags.map((tag) => (
<Link key={tag} to={`/t/${tag}`}>
<Badge variant="secondary" className="cursor-pointer hover:bg-secondary/80 text-xs px-2.5 py-0.5">
#{tag}
</Badge>
</Link>
))}
{showRSVP && (
<div className="space-y-3 border-t border-border/60 pt-4">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">RSVP</div>
<span className="text-xs font-medium text-muted-foreground">{rsvpStatusLabel}</span>
</div>
<div className="grid grid-cols-3 gap-2">
<Button
size="sm"
variant={myRsvp.status === 'accepted' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('rounded-full px-2', myRsvp.status === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
onClick={() => handleRSVP('accepted')}
>
<Check className="size-3.5 mr-1" />
Going
</Button>
<Button
size="sm"
variant={myRsvp.status === 'tentative' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('rounded-full px-2', myRsvp.status === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
onClick={() => handleRSVP('tentative')}
>
<Star className="size-3.5 mr-1" />
Interested
</Button>
<Button
size="sm"
variant={myRsvp.status === 'declined' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('rounded-full px-2', myRsvp.status === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
onClick={() => handleRSVP('declined')}
>
<XIcon className="size-3.5 mr-1" />
Can't Go
</Button>
</div>
</div>
)}
{/* Description */}
{(event.content || summary) && (
<>
<Separator />
<section className="space-y-2">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">About</h2>
{event.content ? (
<NoteContent event={event} className="text-sm leading-relaxed text-foreground" hideEmbedImages={!!image} />
) : (
<p className="text-sm leading-relaxed text-muted-foreground">{summary}</p>
{rsvps.total > 0 && (
<div className="space-y-3 border-t border-border/60 pt-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Attendees</div>
<div className="space-y-3">
{([
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
] as const).map(([label, pks, cls]) => pks.length > 0 && (
<div key={label} className="space-y-2">
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
</div>
))}
</div>
</div>
)}
{links.length > 0 && (
<div className="space-y-2 border-t border-border/60 pt-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Links</div>
<div className="space-y-1">
{links.map((url) => (
<button
key={url}
type="button"
onClick={() => void openUrl(url)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted/50 transition-colors"
>
<LinkIcon className="size-4 text-primary shrink-0" />
<span className="truncate flex-1">{url.replace(/^https?:\/\//, '')}</span>
<ExternalLink className="size-3.5 text-muted-foreground shrink-0" />
</button>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
const participantsCard = participantsByRole.length > 0 ? (
<Card>
<CardContent className="p-5 space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Participants</div>
<div className="space-y-2">
{participantsByRole.map(([role, pubkeys]) =>
pubkeys.map((pk) => <PersonRow key={`${role}-${pk}`} pubkey={pk} label={role} size="sm" />),
)}
</div>
</CardContent>
</Card>
) : null;
return (
<main className="min-h-screen pb-16">
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-4">
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-t-xl rounded-b-none overflow-hidden bg-gradient-to-br from-primary/30 via-primary/15 to-secondary">
{image ? (
<img src={image} alt="" className="absolute inset-0 size-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center">
<CalendarDays className="size-16 sm:size-20 text-primary/40" />
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-black/45" />
<div className="absolute left-0 right-0 top-0 z-10 flex items-center justify-between gap-3 px-4 pt-4">
<button
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
className="p-2.5 -ml-2 rounded-full text-white/90 hover:bg-white/15 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 motion-safe:transition-colors"
aria-label="Go back"
>
<ChevronLeft className="size-6 drop-shadow-[0_1px_2px_rgba(0,0,0,0.85)]" />
</button>
{canEdit && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setEditOpen(true)}
className="rounded-full bg-transparent text-white/90 shadow-none hover:bg-white/15 hover:text-white focus-visible:ring-white/80"
>
<Pencil className="size-4 mr-2" />
Edit
</Button>
)}
</div>
<div className="absolute inset-x-0 bottom-0 z-10 space-y-2 p-5 sm:p-6 [text-shadow:0_1px_4px_rgba(0,0,0,0.75),0_2px_10px_rgba(0,0,0,0.45)]">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h1 className="text-3xl sm:text-4xl font-bold leading-tight tracking-tight text-white">
{title}
</h1>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs sm:text-sm font-medium text-white/85">
{heroDate && (
<span className="inline-flex items-center gap-1.5">
<CalendarDays className="size-3.5 sm:size-4" />
{heroDate}
</span>
)}
{location && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5 sm:size-4" />
{location}
</span>
)}
{attendingCount > 0 && (
<span className="inline-flex items-center gap-1.5">
<Users className="size-3.5 sm:size-4" />
{attendingCount} attending
</span>
)}
{interestedCount > 0 && (
<span className="inline-flex items-center gap-1.5">
<Users className="size-3.5 sm:size-4" />
{interestedCount} interested
</span>
)}
</div>
{summary && (
<p className="max-w-2xl text-base sm:text-lg text-white/90 line-clamp-3">
{summary}
</p>
)}
</div>
</div>
</div>
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="rounded-b-xl rounded-t-none bg-card border border-t-0 border-border/60 shadow-sm px-4 sm:px-5 py-3">
<PostActionBar
event={event}
replyLabel="Comment"
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
/>
</div>
</div>
{pinnedNodes.length > 0 && (
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-6">
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
<ThreadedReplyList
roots={pinnedNodes}
renderItemHeader={(event) => (
<EventPinHeader
isPinned={isPinned(event.id)}
canManagePins={canManagePins}
pinPending={togglePin.isPending}
onTogglePin={() => handleTogglePin(event)}
/>
)}
/>
</div>
</div>
)}
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 lg:py-10">
<div className="lg:hidden mb-6 space-y-4">
{eventDetailsCard}
{participantsCard}
</div>
<div className="lg:flex lg:gap-8 lg:items-start">
<div className="flex-1 min-w-0 space-y-8">
<section className="space-y-5">
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-2">
{hashtags.map((tag) => (
<Link key={tag} to={`/t/${tag}`}>
<Badge variant="secondary" className="cursor-pointer hover:bg-secondary/80 text-xs px-2.5 py-0.5">
#{tag}
</Badge>
</Link>
))}
</div>
)}
{(event.content || summary) && (
<article className="prose prose-neutral dark:prose-invert max-w-none">
{event.content ? (
<NoteContent event={event} hideEmbedImages={!!image} />
) : (
<p className="text-muted-foreground">{summary}</p>
)}
</article>
)}
</section>
</>
)}
{/* External links */}
{links.length > 0 && (
<div className="flex flex-col gap-0.5">
{links.map((url) => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85 hover:bg-secondary/60 transition-colors"
>
<LinkIcon className="size-5 text-primary shrink-0" />
<span className="text-sm truncate flex-1">{url.replace(/^https?:\/\//, '')}</span>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</a>
))}
<section id="event-comments" className="scroll-mt-20">
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
<h2 className="text-lg font-semibold tracking-tight">Comments</h2>
{replyTree.length > 0 ? (
<span className="text-sm text-muted-foreground tabular-nums">
{replyTree.length.toLocaleString()} {replyTree.length === 1 ? 'comment' : 'comments'}
</span>
) : null}
</div>
<DetailCommentComposer event={event} className="mb-3" />
{commentsLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-2xl bg-card border border-border/60 px-4 py-3">
<div className="flex gap-3">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
</div>
))}
</div>
) : replyTree.length > 0 ? (
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
<ThreadedReplyList
roots={replyTree}
renderItemHeader={(event) => (
<EventPinHeader
isPinned={isPinned(event.id)}
canManagePins={canManagePins}
pinPending={togglePin.isPending}
onTogglePin={() => handleTogglePin(event)}
/>
)}
/>
</div>
) : (
<button
type="button"
onClick={() => setReplyOpen(true)}
className="block w-full rounded-2xl border border-dashed border-border/80 bg-card/50 px-6 py-10 text-center hover:bg-card hover:border-primary/40 transition-colors"
>
<p className="text-base font-medium text-foreground">No comments yet</p>
<p className="mt-1 text-sm text-muted-foreground">Be the first to comment.</p>
</button>
)}
</section>
</div>
)}
{/* Participants */}
{participantsByRole.length > 0 && (
<>
<Separator />
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Users className="size-4" /> Participants
</h2>
<div className="space-y-2">
{participantsByRole.map(([role, pubkeys]) =>
pubkeys.map((pk) => <PersonRow key={pk} pubkey={pk} label={role} size="sm" />),
)}
</div>
</section>
</>
)}
{/* Attendees */}
{rsvps.total > 0 && (
<>
<Separator />
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Users className="size-4" /> Attendees
</h2>
<div className="space-y-2.5">
{([
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
] as const).map(([label, pks, cls]) => pks.length > 0 && (
<div key={label} className="flex items-center gap-3">
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
</div>
))}
</div>
</section>
</>
)}
{/* RSVP section */}
{showRSVP && (
<>
<Separator />
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Check className="size-4" /> RSVP
</h2>
<div className="flex gap-2">
<Button
size="sm"
variant={myRsvp.status === 'accepted' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
onClick={() => handleRSVP('accepted')}
>
<Check className="size-3.5 mr-1.5" /> Going
</Button>
<Button
size="sm"
variant={myRsvp.status === 'tentative' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
onClick={() => handleRSVP('tentative')}
>
<Star className="size-3.5 mr-1.5" /> Interested
</Button>
<Button
size="sm"
variant={myRsvp.status === 'declined' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
onClick={() => handleRSVP('declined')}
>
<XIcon className="size-3.5 mr-1.5" /> Can't Go
</Button>
</div>
</section>
</>
)}
<PostActionBar
event={event}
replyLabel="Comments"
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
className="-mx-5 px-5"
/>
<aside className="hidden lg:block lg:w-[360px] lg:shrink-0 lg:self-start">
<div className="lg:sticky lg:top-4 space-y-4">
{eventDetailsCard}
{participantsCard}
</div>
</aside>
</div>
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
@@ -446,32 +628,40 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
event={event}
/>
)}
<section>
{commentsLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
))}
</div>
) : replyTree.length > 0 ? (
<div className="-mx-5">
<ThreadedReplyList roots={replyTree} />
</div>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
No comments yet. Be the first to comment!
</div>
)}
</section>
</div>
</div>
</main>
);
function handleTogglePin(event: NostrEvent) {
const wasPinned = isPinned(event.id);
togglePin.mutate(event.id, {
onSuccess: () => {
toast({ title: wasPinned ? 'Unpinned from event' : 'Pinned to event' });
},
onError: () => {
toast({ title: 'Failed to update event pins', variant: 'destructive' });
},
});
}
}
function EventPinHeader({
isPinned,
canManagePins,
pinPending,
onTogglePin,
}: {
isPinned: boolean;
canManagePins: boolean;
pinPending: boolean;
onTogglePin: () => void;
}) {
return (
<PinnedCommentHeader
isPinned={isPinned}
canManagePins={canManagePins}
pinPending={pinPending}
onTogglePin={onTogglePin}
/>
);
}
+9 -3
View File
@@ -1,4 +1,5 @@
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';
@@ -68,13 +69,15 @@ interface CampaignCardProps {
/** 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: btcPrice } = useBtcPrice();
@@ -224,8 +227,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>
+113 -89
View File
@@ -25,6 +25,8 @@ 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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -35,7 +37,6 @@ 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';
@@ -49,6 +50,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
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 +219,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 +245,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 +301,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 +338,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 +404,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 +440,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>
@@ -529,11 +531,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>
);
}
@@ -618,8 +621,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
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');
// Parse community definition
const community = useMemo(() => parseCommunityEvent(event), [event]);
@@ -742,6 +743,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 +772,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 +805,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] ?? '';
@@ -848,7 +847,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" />
) : (
@@ -977,6 +976,34 @@ 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
@@ -1022,48 +1049,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 +1062,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 +1071,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
@@ -1195,16 +1195,40 @@ 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}
/>
</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}
/>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { ComposeBox } from '@/components/ComposeBox';
interface DetailCommentComposerProps {
event: NostrEvent;
placeholder?: string;
onSuccess?: () => void;
className?: string;
}
export function DetailCommentComposer({
event,
placeholder = "What's on your mind?",
onSuccess,
className,
}: DetailCommentComposerProps) {
return (
<div className={className}>
<ComposeBox
compact
defaultExpanded
hideBorder
replyTo={event}
placeholder={placeholder}
onSuccess={onSuccess}
className="bg-transparent"
/>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { Pin } from 'lucide-react';
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface PinnedCommentHeaderProps {
isPinned: boolean;
canManagePins: boolean;
pinPending: boolean;
onTogglePin: () => void;
children?: ReactNode;
}
export function PinnedCommentHeader({
isPinned,
canManagePins,
pinPending,
onTogglePin,
children,
}: PinnedCommentHeaderProps) {
if (!isPinned && !canManagePins && !children) return null;
return (
<div className="flex items-center justify-between gap-3 px-4 pt-3 pb-0 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
{isPinned && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary">
<Pin className="size-3 rotate-45 fill-current" />
Pinned
</span>
)}
{children}
</div>
{canManagePins && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onTogglePin();
}}
disabled={pinPending}
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-medium transition-colors hover:bg-primary/10 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60',
isPinned && 'text-primary',
)}
>
<Pin className={cn('size-3 rotate-45', isPinned && 'fill-current')} />
{isPinned ? 'Unpin' : 'Pin'}
</button>
)}
</div>
);
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Quote, Rocket, Undo2 } from 'lucide-react';
import { Megaphone, Quote, Undo2 } from 'lucide-react';
import { RepostIcon } from '@/components/icons/RepostIcon';
import { useState } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -195,7 +195,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
}}
className="flex items-center gap-3 w-full px-4 py-3 text-[15px] text-foreground hover:bg-secondary/60 transition-colors"
>
<Rocket className="size-5" />
<Megaphone className="size-5" />
<span>Boost</span>
</button>
</div>
+17 -6
View File
@@ -1,4 +1,5 @@
import type { NostrEvent } from '@nostrify/nostrify';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { NoteCard } from '@/components/NoteCard';
import { cn } from '@/lib/utils';
@@ -14,17 +15,17 @@ 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>
{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 +35,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 +43,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 +56,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 +64,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>
);
+109
View File
@@ -0,0 +1,109 @@
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
const PIN_LIST_KIND = 30078;
const PIN_D_TAG_PREFIX = 'agora-pinned-comments:';
function pinDTag(rootATag: string): string {
return `${PIN_D_TAG_PREFIX}${rootATag}`;
}
function parsePinnedIds(event: NostrEvent | null | undefined): string[] {
if (!event) return [];
try {
const parsed = JSON.parse(event.content) as { pinnedEvents?: unknown };
if (!Array.isArray(parsed.pinnedEvents)) return [];
return parsed.pinnedEvents.filter((id): id is string => typeof id === 'string');
} catch {
return [];
}
}
export function usePinnedEventComments(rootATag: string | undefined, ownerPubkey: string | undefined) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
const dTag = rootATag ? pinDTag(rootATag) : undefined;
const canManagePins = !!user && !!ownerPubkey && user.pubkey === ownerPubkey;
const pinnedListQuery = useQuery({
queryKey: ['pinned-event-comments-list', rootATag, ownerPubkey],
queryFn: async ({ signal }) => {
if (!rootATag || !ownerPubkey || !dTag) return null;
const events = await nostr.query(
[{ kinds: [PIN_LIST_KIND], authors: [ownerPubkey], '#d': [dTag], limit: 1 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
return events[0] ?? null;
},
enabled: !!rootATag && !!ownerPubkey && !!dTag,
staleTime: 30_000,
});
const pinnedIds = parsePinnedIds(pinnedListQuery.data);
const pinnedEventsQuery = useQuery({
queryKey: ['pinned-event-comments', rootATag, pinnedIds],
queryFn: async ({ signal }) => {
if (pinnedIds.length === 0) return [];
const events = await nostr.query(
[{ ids: pinnedIds, limit: pinnedIds.length }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
return events.sort((a, b) => pinnedIds.indexOf(a.id) - pinnedIds.indexOf(b.id));
},
enabled: !!rootATag && pinnedIds.length > 0,
staleTime: 30_000,
});
const togglePin = useMutation({
mutationFn: async (eventId: string) => {
if (!user) throw new Error('User is not logged in');
if (!rootATag || !ownerPubkey || !dTag) throw new Error('Missing pin context.');
if (user.pubkey !== ownerPubkey) throw new Error('Only the event owner can pin comments.');
const prev = await fetchFreshEvent(nostr, {
kinds: [PIN_LIST_KIND],
authors: [ownerPubkey],
'#d': [dTag],
});
const current = parsePinnedIds(prev);
const next = current.includes(eventId)
? current.filter((id) => id !== eventId)
: [eventId, ...current.filter((id) => id !== eventId)];
await publishEvent({
kind: PIN_LIST_KIND,
content: JSON.stringify({ pinnedEvents: next }),
tags: [
['d', dTag],
['a', rootATag],
['k', rootATag.split(':')[0] ?? ''],
['alt', 'Pinned event comments'],
],
prev: prev ?? undefined,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pinned-event-comments-list', rootATag, ownerPubkey] });
queryClient.invalidateQueries({ queryKey: ['pinned-event-comments', rootATag] });
},
});
return {
pinnedIds,
pinnedEvents: pinnedEventsQuery.data ?? [],
isLoading: pinnedListQuery.isLoading || pinnedEventsQuery.isLoading,
isPinned: (eventId: string) => pinnedIds.includes(eventId),
canManagePins,
togglePin,
};
}
+97 -60
View File
@@ -17,7 +17,6 @@ import { useAction, type Action } from '@/hooks/useActions';
import { useAuthor } from '@/hooks/useAuthor';
import { useBitcoinWallet } from '@/hooks/useBitcoinWallet';
import { useComments } from '@/hooks/useComments';
import { useEventStats } from '@/hooks/useTrending';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useSubmissionZapTotals } from '@/hooks/useSubmissionZapTotals';
import { useToast } from '@/hooks/useToast';
@@ -33,14 +32,13 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import { DetailCommentComposer } from '@/components/DetailCommentComposer';
import { PostActionBar } from '@/components/PostActionBar';
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import {
InteractionsModal,
type InteractionTab,
} from '@/components/InteractionsModal';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
import NotFound from '@/pages/NotFound';
function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } {
@@ -81,13 +79,17 @@ function PledgeDetailContent({ action }: { action: Action }) {
const author = useAuthor(action.pubkey);
const navigate = useNavigate();
const { toast } = useToast();
const { data: engagementStats } = useEventStats(action.event.id, action.event);
const { data: commentsData, isLoading: commentsLoading } = useComments(action.event, 500);
const rootATag = `36639:${action.pubkey}:${action.id}`;
const {
pinnedEvents,
isPinned,
canManagePins,
togglePin,
} = usePinnedEventComments(rootATag, action.pubkey);
const [replyOpen, setReplyOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [interactionsOpen, setInteractionsOpen] = useState(false);
const [interactionsTab, setInteractionsTab] = useState<InteractionTab>('reposts');
const topLevel = useMemo(
() => commentsData?.topLevelComments ?? [],
@@ -130,17 +132,17 @@ function PledgeDetailContent({ action }: { action: Action }) {
.map((c) => buildNode(c));
}, [commentsData, topLevel, zapTotals]);
const pinnedNodes = useMemo(
() => pinnedEvents.map((event): ReplyNode => ({ event, children: [] })),
[pinnedEvents],
);
const metadata: NostrMetadata | undefined = author.data?.metadata;
const creatorName = getDisplayName(metadata, action.pubkey);
const creatorProfileUrl = useProfileUrl(action.pubkey, metadata);
const deadline = action.deadline ? formatDeadline(action.deadline) : null;
const cover = sanitizeUrl(action.image);
const progressValue = action.bounty > 0 ? Math.min(100, Math.round((fundedSats / action.bounty) * 100)) : 0;
const hasStats =
!!engagementStats?.replies ||
!!engagementStats?.reposts ||
!!engagementStats?.quotes ||
!!engagementStats?.reactions;
const naddr = nip19.naddrEncode({
kind: 36639,
@@ -156,11 +158,6 @@ function PledgeDetailContent({ action }: { action: Action }) {
[action.event],
);
const openInteractions = (tab: InteractionTab) => {
setInteractionsTab(tab);
setInteractionsOpen(true);
};
const handleShare = async () => {
const url = `${window.location.origin}/${naddr}`;
try {
@@ -187,6 +184,35 @@ function PledgeDetailContent({ action }: { action: Action }) {
onBack={() => navigate(-1)}
/>
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="rounded-b-xl rounded-t-none bg-card border border-t-0 border-border/60 shadow-sm px-4 sm:px-5 py-3">
<PostActionBar
event={action.event}
replyLabel="Submit"
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
/>
</div>
</div>
{pinnedNodes.length > 0 && (
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-6">
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
<ThreadedReplyList
roots={pinnedNodes}
renderItemHeader={(event) => (
<PledgePinHeader
isPinned={isPinned(event.id)}
canManagePins={canManagePins}
pinPending={togglePin.isPending}
onTogglePin={() => handleTogglePin(event)}
/>
)}
/>
</div>
</div>
)}
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 lg:py-10">
<div className="lg:flex lg:gap-8 lg:items-start">
<div className="lg:hidden mb-6">
@@ -205,39 +231,7 @@ function PledgeDetailContent({ action }: { action: Action }) {
<PledgeStory storyEvent={storyEvent} hasContent={action.description.trim().length > 0} />
<div id="pledge-activity" className="scroll-mt-20">
<div className="rounded-2xl bg-card border border-border/60 shadow-sm px-4 sm:px-5 py-4 sm:py-5">
{hasStats && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs sm:text-sm text-muted-foreground">
{engagementStats?.reposts ? (
<button onClick={() => openInteractions('reposts')} className="hover:underline transition-colors">
<span className="font-bold text-foreground">{engagementStats.reposts.toLocaleString()}</span>{' '}
Repost{engagementStats.reposts !== 1 ? 's' : ''}
</button>
) : null}
{engagementStats?.quotes ? (
<button onClick={() => openInteractions('quotes')} className="hover:underline transition-colors">
<span className="font-bold text-foreground">{engagementStats.quotes.toLocaleString()}</span>{' '}
Quote{engagementStats.quotes !== 1 ? 's' : ''}
</button>
) : null}
{engagementStats?.reactions ? (
<button onClick={() => openInteractions('reactions')} className="hover:underline transition-colors">
<span className="font-bold text-foreground">{engagementStats.reactions.toLocaleString()}</span>{' '}
Like{engagementStats.reactions !== 1 ? 's' : ''}
</button>
) : null}
</div>
)}
<PostActionBar
event={action.event}
replyLabel="Submit"
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
/>
</div>
<div className="mt-6">
<div>
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
<h2 className="text-lg font-semibold tracking-tight">Submissions</h2>
{topLevel.length > 0 ? (
@@ -247,13 +241,29 @@ function PledgeDetailContent({ action }: { action: Action }) {
) : null}
</div>
<DetailCommentComposer
event={action.event}
placeholder="Share proof, evidence, or completed work..."
className="mb-3"
/>
{commentsLoading && replyTree.length === 0 ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => <PledgeReplySkeleton key={i} />)}
</div>
) : replyTree.length > 0 ? (
<div className="-mx-2 sm:-mx-4 rounded-2xl bg-card border border-border/60 overflow-hidden">
<ThreadedReplyList roots={replyTree} />
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
<ThreadedReplyList
roots={replyTree}
renderItemHeader={(event) => (
<PledgePinHeader
isPinned={isPinned(event.id)}
canManagePins={canManagePins}
pinPending={togglePin.isPending}
onTogglePin={() => handleTogglePin(event)}
/>
)}
/>
</div>
) : (
<button
@@ -289,14 +299,41 @@ function PledgeDetailContent({ action }: { action: Action }) {
<ReplyComposeModal event={action.event} open={replyOpen} onOpenChange={setReplyOpen} />
<NoteMoreMenu event={action.event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<InteractionsModal
eventId={action.event.id}
open={interactionsOpen}
onOpenChange={setInteractionsOpen}
initialTab={interactionsTab}
/>
</main>
);
function handleTogglePin(event: NostrEvent) {
const wasPinned = isPinned(event.id);
togglePin.mutate(event.id, {
onSuccess: () => {
toast({ title: wasPinned ? 'Unpinned from pledge' : 'Pinned to pledge' });
},
onError: () => {
toast({ title: 'Failed to update pledge pins', variant: 'destructive' });
},
});
}
}
function PledgePinHeader({
isPinned,
canManagePins,
pinPending,
onTogglePin,
}: {
isPinned: boolean;
canManagePins: boolean;
pinPending: boolean;
onTogglePin: () => void;
}) {
return (
<PinnedCommentHeader
isPinned={isPinned}
canManagePins={canManagePins}
pinPending={pinPending}
onTogglePin={onTogglePin}
/>
);
}
interface PledgeHeroProps {
@@ -315,7 +352,7 @@ function PledgeHero({ action, cover, creatorName, creatorProfileUrl, deadline, o
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-4">
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-xl overflow-hidden bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-t-xl rounded-b-none overflow-hidden bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
<img
src={coverImage}
alt=""
+124 -80
View File
@@ -13,6 +13,7 @@ import {
MapPin,
Pencil,
Share2,
ShieldCheck,
Users,
} from 'lucide-react';
@@ -37,14 +38,12 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DonateDialog } from '@/components/DonateDialog';
import { DetailCommentComposer } from '@/components/DetailCommentComposer';
import { PostActionBar } from '@/components/PostActionBar';
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { Progress } from '@/components/ui/progress';
import {
InteractionsModal,
type InteractionTab,
} from '@/components/InteractionsModal';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
import { useArchiveCampaign } from '@/hooks/useArchiveCampaign';
import { useAuthor } from '@/hooks/useAuthor';
@@ -54,6 +53,7 @@ import { useCampaignDonations } from '@/hooks/useCampaignDonations';
import { useComments } from '@/hooks/useComments';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEventStats } from '@/hooks/useTrending';
import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { useLayoutOptions } from '@/contexts/LayoutContext';
@@ -98,6 +98,15 @@ function formatDeadline(unixSeconds: number): { label: string; isPast: boolean }
return { label: `Ends ${new Date(unixSeconds * 1000).toLocaleDateString()}`, isPast: false };
}
function collectReplyEvents(nodes: ReplyNode[], out = new Map<string, NostrEvent>()): Map<string, NostrEvent> {
for (const node of nodes) {
out.set(node.event.id, node.event);
collectReplyEvents(node.children, out);
if (node.hiddenChildren) collectReplyEvents(node.hiddenChildren, out);
}
return out;
}
export function CampaignDetailPage({ pubkey, identifier, relays }: CampaignDetailPageProps) {
// Drop the default 600px column cap and the default right widget sidebar
// — this page renders its own GoFundMe-style 2-column layout (article on
@@ -128,24 +137,24 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
const [donateOpen, setDonateOpen] = useState(false);
const [replyOpen, setReplyOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [interactionsOpen, setInteractionsOpen] = useState(false);
const [storyExpanded, setStoryExpanded] = useState(false);
const [interactionsTab, setInteractionsTab] = useState<InteractionTab>('reposts');
const [archiveConfirmOpen, setArchiveConfirmOpen] = useState(false);
const archiveMutation = useArchiveCampaign();
const openInteractions = (tab: InteractionTab) => {
setInteractionsTab(tab);
setInteractionsOpen(true);
};
const { data: engagementStats } = useEventStats(campaign.event.id, campaign.event);
const { data: commentsData, isLoading: commentsLoading } = useComments(
campaign.event,
500,
);
const {
pinnedIds,
pinnedEvents,
isPinned,
canManagePins,
togglePin,
} = usePinnedEventComments(campaign.aTag, campaign.pubkey);
// Aggregate kind 8333 donation receipts by `(txid, donor)` so each
// donation surfaces as a single event in the donor list and the inline
@@ -208,14 +217,15 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
);
}, [commentsData, donationReceipts]);
// Engagement counters above the action bar. Zaps are intentionally excluded
// for campaigns — donations are on-chain (kind 8333), so showing a zap
// count here would suggest the wrong CTA.
const hasStats =
!!engagementStats?.replies ||
!!engagementStats?.reposts ||
!!engagementStats?.quotes ||
!!engagementStats?.reactions;
const feedEventsById = useMemo(() => collectReplyEvents(replyTree), [replyTree]);
const pinnedNodes = useMemo((): ReplyNode[] => {
return pinnedIds
.map((id) => feedEventsById.get(id) ?? pinnedEvents.find((event) => event.id === id))
.filter((event): event is NostrEvent => !!event)
.map((event): ReplyNode => ({ event, children: [] }));
}, [feedEventsById, pinnedEvents, pinnedIds]);
const cover = sanitizeUrl(campaign.image);
const creatorMetadata = author.data?.metadata;
const creatorName =
@@ -340,6 +350,37 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
onReopen={handleToggleArchive}
/>
<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={campaign.event}
replyLabel="Comment"
hideZap
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) => (
<CampaignPinHeader
isCampaignAuthor={event.pubkey === campaign.pubkey}
canManagePins={canManagePins}
isPinned={isPinned(event.id)}
pinPending={togglePin.isPending}
onTogglePin={() => handleTogglePin(event)}
/>
)}
/>
</div>
</div>
)}
{/* Two-column body. On mobile the right column collapses inline
immediately below the hero so the donate CTA stays above the
fold. On lg+ the right column sticks to the viewport edge of
@@ -358,59 +399,9 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
onToggle={() => setStoryExpanded((v) => !v)}
/>
{/* Engagement: stats counters, action bar, threaded replies
+ donation receipts interleaved. */}
{/* Activity: threaded replies + donation receipts interleaved. */}
<div id="campaign-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={campaign.event}
replyLabel="Comment"
hideZap
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
className={hasStats ? 'pt-3 border-t border-border/60' : undefined}
/>
</div>
<div className="mt-6">
<div>
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
<h2 className="text-lg font-semibold tracking-tight">
Comments &amp; donations
@@ -423,6 +414,12 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
) : null}
</div>
<DetailCommentComposer
event={campaign.event}
className="mb-3"
onSuccess={() => queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] })}
/>
{commentsLoading && statsLoading && replyTree.length === 0 ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
@@ -430,8 +427,19 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
))}
</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) => (
<CampaignPinHeader
isCampaignAuthor={event.pubkey === campaign.pubkey}
canManagePins={canManagePins}
isPinned={isPinned(event.id)}
pinPending={togglePin.isPending}
onTogglePin={() => handleTogglePin(event)}
/>
)}
/>
</div>
) : (
<button
@@ -489,12 +497,6 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
/>
<InteractionsModal
eventId={campaign.event.id}
open={interactionsOpen}
onOpenChange={setInteractionsOpen}
initialTab={interactionsTab}
/>
<AlertDialog open={archiveConfirmOpen} onOpenChange={setArchiveConfirmOpen}>
<AlertDialogContent>
@@ -522,6 +524,48 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
</AlertDialog>
</main>
);
function handleTogglePin(event: NostrEvent) {
const wasPinned = isPinned(event.id);
togglePin.mutate(event.id, {
onSuccess: () => {
toast({ title: wasPinned ? 'Unpinned from campaign' : 'Pinned to campaign' });
},
onError: () => {
toast({ title: 'Failed to update campaign pins', variant: 'destructive' });
},
});
}
}
function CampaignPinHeader({
isCampaignAuthor,
canManagePins,
isPinned,
pinPending,
onTogglePin,
}: {
isCampaignAuthor: boolean;
canManagePins: boolean;
isPinned: boolean;
pinPending: boolean;
onTogglePin: () => void;
}) {
return (
<PinnedCommentHeader
isPinned={isPinned}
canManagePins={canManagePins}
pinPending={pinPending}
onTogglePin={onTogglePin}
>
{isCampaignAuthor && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary">
<ShieldCheck className="size-3" />
Campaigner
</span>
)}
</PinnedCommentHeader>
);
}
// ─────────────────────────────────────────────────────────────────────
@@ -561,7 +605,7 @@ function CampaignHero({
}: CampaignHeroProps) {
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-4">
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-xl overflow-hidden bg-gradient-to-br from-primary/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" />
) : (
+45 -15
View File
@@ -37,6 +37,7 @@ const CustomNipCard = lazy(() => import("@/components/CustomNipCard").then(m =>
import { FileMetadataContent } from "@/components/FileMetadataContent";
import { FollowPackContent } from "@/components/FollowPackContent";
import { GoalCard } from "@/components/GoalCard";
import { DetailCommentComposer } from "@/components/DetailCommentComposer";
import { FollowPackDetailContent } from "@/components/FollowPackDetailContent";
import { FoundLogContent } from "@/components/FoundLogContent";
import { GeocacheContent } from "@/components/GeocacheContent";
@@ -436,8 +437,8 @@ function ProfileBadgesDetailView({ event }: { event: NostrEvent }) {
}, [commentsData, muteItems]);
return (
<div>
<FeedCard>
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<FeedCard className="mx-0">
<NoteCard event={event} />
</FeedCard>
<div className="pb-16 sidebar:pb-0">
@@ -1397,13 +1398,13 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
return (
<CommunityModerationContext.Provider value={communityModContext}>
<div>
<div className="max-w-6xl mx-auto px-4 sm:px-6">
{/* Focused post card — ancestor previews, ancestor thread, and the
focused event itself share one rounded surface so the page
reads as "thread context → this post" instead of an
edge-to-edge Twitter timeline. Replies sit in their own
FeedCard below. */}
<FeedCard>
<FeedCard className="mx-0">
{/* Content preview for kind 1111 comments: external content, profile, or community */}
{externalIdentifier && (
<ExternalContentPreview identifier={externalIdentifier} />
@@ -2075,22 +2076,51 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
</FeedCard>
{/* Replies */}
<div className="pb-16 sidebar:pb-0">
<div className="mt-6 pb-16 sidebar:pb-0">
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
<h2 className="text-lg font-semibold tracking-tight">
{isKind1 ? 'Replies' : 'Comments'}
</h2>
{replyTree.length > 0 ? (
<span className="text-sm text-muted-foreground tabular-nums">
{formatNumber(replyTree.length)}{' '}
{replyTree.length === 1
? isKind1 ? 'reply' : 'comment'
: isKind1 ? 'replies' : 'comments'}
</span>
) : null}
</div>
<DetailCommentComposer
event={event}
className="mb-3"
placeholder={isKind1 ? 'Write a reply...' : "What's on your mind?"}
/>
{repliesLoading ? (
<FeedCard className="mt-4 divide-y divide-border">
<div className="rounded-2xl bg-card border border-border/60 shadow-sm overflow-hidden divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</FeedCard>
) : replyTree.length > 0 ? (
<FeedCard className="mt-4">
<ThreadedReplyList roots={replyTree} />
</FeedCard>
) : !parentEventId ? (
<div className="py-12 text-center text-muted-foreground text-sm">
No replies yet. Be the first to reply!
</div>
) : null}
) : replyTree.length > 0 ? (
<div className="rounded-2xl bg-card border border-border/60 shadow-sm overflow-hidden">
<ThreadedReplyList roots={replyTree} />
</div>
) : (
<button
type="button"
onClick={() => setReplyOpen(true)}
className="block w-full rounded-2xl border border-dashed border-border/80 bg-card/50 px-6 py-10 text-center hover:bg-card hover:border-primary/40 transition-colors"
>
<p className="text-base font-medium text-foreground">
No {isKind1 ? 'replies' : 'comments'} yet
</p>
<p className="mt-1 text-sm text-muted-foreground">
Be the first to {isKind1 ? 'reply' : 'start the discussion'}.
</p>
</button>
)}
</div>
</div>
</CommunityModerationContext.Provider>