Merge branch 'feat/pin-updates' into 'main'
Feat/pin updates See merge request soapbox-pub/agora!33
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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=""
|
||||
|
||||
@@ -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 & 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" />
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user