diff --git a/NIP.md b/NIP.md index 300653f1..177f952d 100644 --- a/NIP.md +++ b/NIP.md @@ -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": "", + "content": "{\"pinnedEvents\":[\"\",\"\"]}", + "tags": [ + ["d", "agora-pinned-comments:::"], + ["a", "::"], + ["k", ""], + ["alt", "Pinned event comments"] + ] +} +``` + +Clients SHOULD query the pin list with: + +```json +{ "kinds": [30078], "authors": [""], "#d": ["agora-pinned-comments:::"], "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. diff --git a/src/components/CalendarEventDetailPage.tsx b/src/components/CalendarEventDetailPage.tsx index 9512a197..370b54b0 100644 --- a/src/components/CalendarEventDetailPage.tsx +++ b/src/components/CalendarEventDetailPage.tsx @@ -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 ( +
+
{icon}
+
{children}
+
+ ); +} + // --- 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 ( -
- {/* ── Standard top bar ── */} -
- -

Event Details

- {canEdit && ( - + const eventDetailsCard = ( + + +
+
Hosted by
+ +
+ + {(event.content || summary) && ( +
+
Description
+ {event.content ? ( + + ) : ( +

{summary}

+ )} +
)} -
- {/* ── Cover image ── */} - {image ? ( -
- {title} -
- ) : ( -
- -
- )} - - {/* ── Content ── */} -
- {/* Title */} -

{title}

- {/* Organizer row */} -
-
- -
-
- - {/* Date & Location — sidebar-style pills */} -
-
- - {dateStr} -
+
+ }> + {dateStr} + {location && ( -
- - {location} -
+ }> + {location} + )}
- {/* Hashtags */} - {hashtags.length > 0 && ( -
- {hashtags.map((tag) => ( - - - #{tag} - - - ))} + {showRSVP && ( +
+
+
RSVP
+ {rsvpStatusLabel} +
+
+ + + +
)} - {/* Description */} - {(event.content || summary) && ( - <> - -
-

About

- {event.content ? ( - - ) : ( -

{summary}

+ {rsvps.total > 0 && ( +
+
Attendees
+
+ {([ + ['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 && ( +
+ {label} ({pks.length}) + +
+ ))} +
+
+ )} + + {links.length > 0 && ( +
+
Links
+
+ {links.map((url) => ( + + ))} +
+
+ )} + + + ); + + const participantsCard = participantsByRole.length > 0 ? ( + + +
Participants
+
+ {participantsByRole.map(([role, pubkeys]) => + pubkeys.map((pk) => ), + )} +
+
+
+ ) : null; + + return ( +
+
+
+ {image ? ( + + ) : ( +
+ +
+ )} +
+ +
+ + {canEdit && ( + + )} +
+ +
+
+

+ {title} +

+
+
+ {heroDate && ( + + + {heroDate} + + )} + {location && ( + + + {location} + + )} + {attendingCount > 0 && ( + + + {attendingCount} attending + + )} + {interestedCount > 0 && ( + + + {interestedCount} interested + + )} +
+ {summary && ( +

+ {summary} +

+ )} +
+
+
+ +
+
+ setReplyOpen(true)} + onMore={() => setMoreMenuOpen(true)} + /> +
+
+ + {pinnedNodes.length > 0 && ( +
+
+ ( + handleTogglePin(event)} + /> + )} + /> +
+
+ )} + +
+
+ {eventDetailsCard} + {participantsCard} +
+ +
+
+
+ {hashtags.length > 0 && ( +
+ {hashtags.map((tag) => ( + + + #{tag} + + + ))} +
+ )} + + {(event.content || summary) && ( +
+ {event.content ? ( + + ) : ( +

{summary}

+ )} +
)}
- - )} - {/* External links */} - {links.length > 0 && ( -
- {links.map((url) => ( - - - {url.replace(/^https?:\/\//, '')} - - - ))} +
+
+

Comments

+ {replyTree.length > 0 ? ( + + {replyTree.length.toLocaleString()} {replyTree.length === 1 ? 'comment' : 'comments'} + + ) : null} +
+ + + + {commentsLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ +
+ + + +
+
+
+ ))} +
+ ) : replyTree.length > 0 ? ( +
+ ( + handleTogglePin(event)} + /> + )} + /> +
+ ) : ( + + )} +
- )} - {/* Participants */} - {participantsByRole.length > 0 && ( - <> - -
-

- Participants -

-
- {participantsByRole.map(([role, pubkeys]) => - pubkeys.map((pk) => ), - )} -
-
- - )} - - {/* Attendees */} - {rsvps.total > 0 && ( - <> - -
-

- Attendees -

-
- {([ - ['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 && ( -
- {label} ({pks.length}) - -
- ))} -
-
- - )} - - {/* RSVP section */} - {showRSVP && ( - <> - -
-

- RSVP -

-
- - - -
-
- - )} - - setReplyOpen(true)} - onMore={() => setMoreMenuOpen(true)} - className="-mx-5 px-5" - /> + +
@@ -446,32 +628,40 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) { event={event} /> )} - -
- {commentsLoading ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
- -
- - - -
-
- ))} -
- ) : replyTree.length > 0 ? ( -
- -
- ) : ( -
- No comments yet. Be the first to comment! -
- )} -
-
+
+ ); + + 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 ( + ); } diff --git a/src/components/CampaignCard.tsx b/src/components/CampaignCard.tsx index e3feb639..b43e6c66 100644 --- a/src/components/CampaignCard.tsx +++ b/src/components/CampaignCard.tsx @@ -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 * `` 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 )}
-
- by {creatorName} +
+
+ by {creatorName} +
+ {footerBadge &&
{footerBadge}
}
diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index b02698a4..3ede5332 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -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 ( -
+
{icon} {label}
@@ -243,11 +245,8 @@ function PledgeShelfCard({ pledge }: { pledge: Action }) { return ( -
- } label="Pledge" /> -
-
- by {displayName} +
+
+ by {displayName} +
+ } label="Pledge" />
@@ -336,11 +338,8 @@ function CalendarEventShelfCard({ event }: { event: NostrEvent }) { return ( -
- } label="Event" /> -
{coverImage ? ( @@ -405,8 +404,11 @@ function CalendarEventShelfCard({ event }: { event: NostrEvent }) { ) : null}
-
- by {displayName} +
+
+ by {displayName} +
+ } label="Event" />
@@ -438,7 +440,7 @@ function OfficialShelf({ title, count, isLoading, isEmpty, children }: OfficialS )}
-
+
{children}
@@ -529,11 +531,12 @@ function OfficialActivityShelves({ {mixedActivity.map((item) => { if (item.type === 'campaign') { return ( -
-
- } label="Campaign" /> -
- +
+ } label="Campaign" />} + />
); } @@ -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('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 }) {
{/* ── Hero ─────────────────────────────────────────────────────── */} -
+
{cover ? ( ) : ( @@ -977,6 +976,34 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
+
+ setReplyOpen(true)} + onMore={() => setMoreMenuOpen(true)} + /> +
+ + {pinnedNodes.length > 0 && ( +
+
+ ( + handleTogglePin(event)} + /> + )} + /> +
+
+ )} + {/* ── Body — single column, pledge-detail-style ─────────────────── */}
{/* 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. */}
-
- {hasStats && ( -
- {engagementStats?.reposts ? ( - - ) : null} - {engagementStats?.quotes ? ( - - ) : null} - {engagementStats?.reactions ? ( - - ) : null} -
- )} - - setReplyOpen(true)} - onMore={() => setMoreMenuOpen(true)} - className={hasStats ? 'pt-3 border-t border-border/60' : undefined} - /> -
- - {/* Comments — NIP-22 thread on the community event itself. - Member-filter aware (see replyTree) and routed through - CommunityModerationContext so per-reply ban actions work. */} -
+

Comments

{engagementStats?.replies ? ( @@ -1074,6 +1062,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { ) : null}
+ + {commentsLoading && statsLoading && replyTree.length === 0 ? (
{Array.from({ length: 3 }).map((_, i) => ( @@ -1081,8 +1071,18 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { ))}
) : replyTree.length > 0 ? ( -
- +
+ ( + handleTogglePin(event)} + /> + )} + />
) : (
); + + 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 ( + + ); } diff --git a/src/components/DetailCommentComposer.tsx b/src/components/DetailCommentComposer.tsx new file mode 100644 index 00000000..45654081 --- /dev/null +++ b/src/components/DetailCommentComposer.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/components/PinnedCommentHeader.tsx b/src/components/PinnedCommentHeader.tsx new file mode 100644 index 00000000..436d7968 --- /dev/null +++ b/src/components/PinnedCommentHeader.tsx @@ -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 ( +
+
+ {isPinned && ( + + + Pinned + + )} + {children} +
+ {canManagePins && ( + + )} +
+ ); +} diff --git a/src/components/RepostMenu.tsx b/src/components/RepostMenu.tsx index 53e75bdb..35931fab 100644 --- a/src/components/RepostMenu.tsx +++ b/src/components/RepostMenu.tsx @@ -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" > - + Boost
diff --git a/src/components/ThreadedReplyList.tsx b/src/components/ThreadedReplyList.tsx index 9a1beb01..b97bf476 100644 --- a/src/components/ThreadedReplyList.tsx +++ b/src/components/ThreadedReplyList.tsx @@ -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 (
{roots.map((node) => ( - + ))}
); } -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 (
+ {renderItemHeader?.(node.event)} setExpanded(true)} isLast />
@@ -41,7 +43,12 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe } if (!hasChildren) { - return ; + return ( +
+ {renderItemHeader?.(node.event)} + +
+ ); } // 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 (
+ {renderItemHeader?.(node.event)} {/* 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) => ( - +
+ {renderItemHeader?.(child.event)} + +
))} {node.children.map((child) => ( - + ))}
); diff --git a/src/hooks/usePinnedEventComments.ts b/src/hooks/usePinnedEventComments.ts new file mode 100644 index 00000000..50a29985 --- /dev/null +++ b/src/hooks/usePinnedEventComments.ts @@ -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, + }; +} diff --git a/src/pages/ActionDetailPage.tsx b/src/pages/ActionDetailPage.tsx index 98327f5e..15abae84 100644 --- a/src/pages/ActionDetailPage.tsx +++ b/src/pages/ActionDetailPage.tsx @@ -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('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)} /> +
+
+ setReplyOpen(true)} + onMore={() => setMoreMenuOpen(true)} + /> +
+
+ + {pinnedNodes.length > 0 && ( +
+
+ ( + handleTogglePin(event)} + /> + )} + /> +
+
+ )} +
@@ -205,39 +231,7 @@ function PledgeDetailContent({ action }: { action: Action }) { 0} />
-
- {hasStats && ( -
- {engagementStats?.reposts ? ( - - ) : null} - {engagementStats?.quotes ? ( - - ) : null} - {engagementStats?.reactions ? ( - - ) : null} -
- )} - - setReplyOpen(true)} - onMore={() => setMoreMenuOpen(true)} - /> -
- -
+

Submissions

{topLevel.length > 0 ? ( @@ -247,13 +241,29 @@ function PledgeDetailContent({ action }: { action: Action }) { ) : null}
+ + {commentsLoading && replyTree.length === 0 ? (
{Array.from({ length: 3 }).map((_, i) => )}
) : replyTree.length > 0 ? ( -
- +
+ ( + handleTogglePin(event)} + /> + )} + />
) : ( - ) : null} - {engagementStats?.quotes ? ( - - ) : null} - {engagementStats?.reactions ? ( - - ) : null} -
- )} - - setReplyOpen(true)} - onMore={() => setMoreMenuOpen(true)} - className={hasStats ? 'pt-3 border-t border-border/60' : undefined} - /> -
- -
+

Comments & donations @@ -423,6 +414,12 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { ) : null}

+ queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] })} + /> + {commentsLoading && statsLoading && replyTree.length === 0 ? (
{Array.from({ length: 3 }).map((_, i) => ( @@ -430,8 +427,19 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { ))}
) : replyTree.length > 0 ? ( -
- +
+ ( + handleTogglePin(event)} + /> + )} + />
) : ( + )}