import { RefreshButton } from '@/components/RefreshButton' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { useSecondaryPage, useSmartNoteNavigation } from '@/PageManager' import { ExtendedKind } from '@/constants' import ContentPreview from '@/components/ContentPreview' import client from '@/services/client.service' import Note from '@/components/Note' import NoteBoostBadges from '@/components/NoteBoostBadges' import NoteInteractions from '@/components/NoteInteractions' import NoteStats from '@/components/NoteStats' import UserAvatar from '@/components/UserAvatar' import { Card } from '@/components/ui/card' import { Separator } from '@/components/ui/separator' import { Skeleton } from '@/components/ui/skeleton' import { useFetchEvent, useFetchProfile } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { getParentBech32Id, getParentETag, getParentEventHexId, getRootBech32Id, getRootEventHexId } from '@/lib/event' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList } from '@/lib/link' import { tagNameEquals } from '@/lib/tag' import { cn } from '@/lib/utils' import { Ellipsis } from 'lucide-react' import type { Event } from 'nostr-tools' import { kinds, nip19 } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { NOSTR_URI_NADDR_REGEX } from '@/lib/content-patterns' import { applyDefaultSiteSocialMeta, avatarProxyUrl, defaultOgImageAbsoluteUrl, getSiteOrigin, removeMetaByProperty, SITE_NAME, updateMetaTag } from '@/lib/document-meta' import NotFound from './NotFound' // Helper function to get event type name (matching WebPreview) function getEventTypeName(kind: number): string { switch (kind) { case kinds.ShortTextNote: return 'Text Post' case kinds.LongFormArticle: return 'Longform Article' case ExtendedKind.PICTURE: return 'Picture' case ExtendedKind.VIDEO: return 'Video' case ExtendedKind.SHORT_VIDEO: return 'Short Video' case ExtendedKind.POLL: return 'Poll' case ExtendedKind.COMMENT: return 'Comment' case ExtendedKind.VOICE: return 'Voice Post' case ExtendedKind.VOICE_COMMENT: return 'Voice Comment' case kinds.Highlights: return 'Highlight' case ExtendedKind.PUBLICATION: return 'Publication' case ExtendedKind.PUBLICATION_CONTENT: return 'Publication Content' case ExtendedKind.WIKI_ARTICLE: return 'Wiki Article' case ExtendedKind.WIKI_ARTICLE_MARKDOWN: return 'Wiki Article' case ExtendedKind.DISCUSSION: return 'Discussion' case ExtendedKind.CALENDAR_EVENT_TIME: case ExtendedKind.CALENDAR_EVENT_DATE: return 'Calendar Event' default: return `Event (kind ${kind})` } } // Helper function to extract and strip markdown/asciidoc for preview (matching WebPreview) function stripMarkdown(content: string): string { let text = content // Remove markdown headers text = text.replace(/^#{1,6}\s+/gm, '') // Remove markdown bold/italic text = text.replace(/\*\*([^*]+)\*\*/g, '$1') text = text.replace(/\*([^*]+)\*/g, '$1') // Remove markdown links text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove asciidoc headers text = text.replace(/^=+\s+/gm, '') // Remove asciidoc bold/italic text = text.replace(/\*\*([^*]+)\*\*/g, '$1') text = text.replace(/_([^_]+)_/g, '$1') // Remove code blocks text = text.replace(/```[\s\S]*?```/g, '') text = text.replace(/`([^`]+)`/g, '$1') // Remove HTML tags text = text.replace(/<[^>]+>/g, '') // Clean up whitespace text = text.replace(/\n{3,}/g, '\n\n') return text.trim() } const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: { id?: string; index?: number; hideTitlebar?: boolean; initialEvent?: Event }, ref) => { const { t } = useTranslation() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { event, isFetching, refetch: refetchMain } = useFetchEvent(id, initialEvent) const [externalEvent, setExternalEvent] = useState(undefined) const finalEvent = event || externalEvent const parentEventId = useMemo(() => { if (!finalEvent) return undefined const parentHex = getParentEventHexId(finalEvent)?.toLowerCase() if (parentHex && parentHex === finalEvent.id.toLowerCase()) return undefined return getParentBech32Id(finalEvent) }, [finalEvent]) const rootEventId = useMemo(() => { if (!finalEvent) return undefined const rootHex = getRootEventHexId(finalEvent)?.toLowerCase() if (rootHex && rootHex === finalEvent.id.toLowerCase()) return undefined return getRootBech32Id(finalEvent) }, [finalEvent]) const rootITag = useMemo( () => (finalEvent?.kind === ExtendedKind.COMMENT ? finalEvent.tags.find(tagNameEquals('I')) : undefined), [finalEvent] ) const { isFetching: isFetchingRootEvent, event: rootEvent, refetch: refetchRoot } = useFetchEvent(rootEventId) const { isFetching: isFetchingParentEvent, event: parentEvent, refetch: refetchParent } = useFetchEvent(parentEventId) const selfHex = finalEvent?.id?.toLowerCase() const rootEventForStrip = rootEvent && selfHex && rootEvent.id.toLowerCase() !== selfHex ? rootEvent : undefined const parentEventForStrip = parentEvent && selfHex && parentEvent.id.toLowerCase() !== selfHex ? parentEvent : undefined // When viewing a kind-24 invite (e.g. from notifications), extract calendar event naddr from content and show full calendar card with RSVP const calendarInviteNaddr = useMemo(() => { if (finalEvent?.kind !== ExtendedKind.PUBLIC_MESSAGE || !finalEvent.content?.trim()) return undefined const match = NOSTR_URI_NADDR_REGEX.exec(finalEvent.content) NOSTR_URI_NADDR_REGEX.lastIndex = 0 const naddr = match?.[1] if (!naddr) return undefined try { const decoded = nip19.decode(naddr) if (decoded.type === 'naddr' && (decoded.data.kind === ExtendedKind.CALENDAR_EVENT_DATE || decoded.data.kind === ExtendedKind.CALENDAR_EVENT_TIME)) { return naddr } } catch { // ignore decode errors } return undefined }, [finalEvent?.kind, finalEvent?.content]) const { event: calendarInviteEvent, refetch: refetchCalendarInvite } = useFetchEvent(calendarInviteNaddr) const refreshNoteData = useCallback(() => { refetchMain() refetchRoot() refetchParent() refetchCalendarInvite() }, [refetchMain, refetchRoot, refetchParent, refetchCalendarInvite]) useEffect(() => { if (!hideTitlebar) { registerPrimaryPanelRefresh(null) return } registerPrimaryPanelRefresh(refreshNoteData) return () => registerPrimaryPanelRefresh(null) }, [hideTitlebar, registerPrimaryPanelRefresh, refreshNoteData]) const titlebarRefreshControls = hideTitlebar ? undefined : // Fetch profile for author (for OpenGraph metadata) const { profile: authorProfile } = useFetchProfile(finalEvent?.pubkey) useEffect(() => { const pk = finalEvent?.pubkey?.trim().toLowerCase() if (!pk || !/^[0-9a-f]{64}$/.test(pk)) return void client.fetchProfilesForPubkeys([pk]) }, [finalEvent?.id, finalEvent?.pubkey]) const getNoteTypeTitle = (kind: number): string => { switch (kind) { case 1: // kinds.ShortTextNote return 'Note: Text Post' case 30023: // kinds.LongFormArticle return 'Note: Longform Article' case 30040: // ExtendedKind.PUBLICATION return 'Note: Publication' case 30041: // ExtendedKind.PUBLICATION_CONTENT return 'Note: Publication Content' case 30817: // ExtendedKind.WIKI_ARTICLE_MARKDOWN return 'Note: Wiki Article' case 30818: // ExtendedKind.WIKI_ARTICLE return 'Note: Wiki Article' case 20: // ExtendedKind.PICTURE return 'Note: Picture' case 21: // ExtendedKind.VIDEO return 'Note: Video' case 22: // ExtendedKind.SHORT_VIDEO return 'Note: Short Video' case 11: // ExtendedKind.DISCUSSION return 'Discussions' case 9802: // kinds.Highlights return 'Note: Highlight' case 1068: // ExtendedKind.POLL return 'Note: Poll' case 6969: // ExtendedKind.ZAP_POLL return 'Note: Zap Poll' case 31987: // ExtendedKind.RELAY_REVIEW return 'Note: Relay Review' case 31922: // ExtendedKind.CALENDAR_EVENT_DATE case 31923: // ExtendedKind.CALENDAR_EVENT_TIME return 'Note: Calendar Event' case 9735: // ExtendedKind.ZAP_RECEIPT return 'Note: Zap Receipt' case 6: // kinds.Repost (Nostr boost) case 16: // ExtendedKind.GENERIC_REPOST (NIP-18) return 'Note: Boost' case 7: // kinds.Reaction return 'Note: Reaction' case 17: // ExtendedKind.EXTERNAL_REACTION (NIP-25 external) return 'Note: Reaction' case 1111: // ExtendedKind.COMMENT return 'Note: Comment' case 1222: // ExtendedKind.VOICE return 'Note: Voice Post' case 1244: // ExtendedKind.VOICE_COMMENT return 'Note: Voice Comment' default: return 'Note' } } // Get article metadata for OpenGraph tags const articleMetadata = useMemo(() => { if (!finalEvent) return null const articleKinds = [ kinds.LongFormArticle, // 30023 ExtendedKind.PUBLICATION, // 30040 ExtendedKind.PUBLICATION_CONTENT, // 30041 ExtendedKind.WIKI_ARTICLE_MARKDOWN, // 30817 ExtendedKind.WIKI_ARTICLE // 30818 ] if (articleKinds.includes(finalEvent.kind)) { return getLongFormArticleMetadataFromEvent(finalEvent) } return null }, [finalEvent]) // Store title in sessionStorage for primary note view when hideTitlebar is true // This must be called before any early returns to follow Rules of Hooks useEffect(() => { if (hideTitlebar && finalEvent) { const title = getNoteTypeTitle(finalEvent.kind) sessionStorage.setItem('notePageTitle', title) // Trigger a re-render of the primary view title by dispatching a custom event window.dispatchEvent(new Event('notePageTitleUpdated')) } }, [hideTitlebar, finalEvent]) // Update OpenGraph metadata to match in-app preview cards and site branding useEffect(() => { if (!finalEvent) { applyDefaultSiteSocialMeta() removeMetaByProperty('article:tag') return } // Get event metadata matching fallback card format const eventMetadata = getLongFormArticleMetadataFromEvent(finalEvent) const eventTypeName = getEventTypeName(finalEvent.kind) const eventTitle = eventMetadata?.title || eventTypeName const eventSummary = eventMetadata?.summary || '' // Generate content preview (matching fallback card) let contentPreview = '' if (finalEvent.content) { const stripped = stripMarkdown(finalEvent.content) contentPreview = stripped.length > 500 ? stripped.substring(0, 500) + '...' : stripped } // Build description matching fallback card: username • event type, title, summary, content preview, URL // Always show note-specific info, even if profile isn't loaded yet const authorName = authorProfile?.username || '' const parts: string[] = [] // Always include event type (this is note-specific) if (eventTypeName) { parts.push(eventTypeName) } if (authorName) { parts.push(`@${authorName}`) } let ogDescription = '' if (parts.length > 0) { ogDescription = parts.join(' • ') } else { // Fallback if nothing available yet ogDescription = 'Event' } // Always show title if available (note-specific) if (eventTitle && eventTitle !== eventTypeName) { ogDescription += (ogDescription ? ' | ' : '') + eventTitle } // Show summary if available (note-specific) if (eventSummary) { ogDescription += (ogDescription ? ' - ' : '') + eventSummary } // Truncate URL to 150 chars before adding it const fullUrl = window.location.href const truncatedUrl = fullUrl.length > 150 ? fullUrl.substring(0, 147) + '...' : fullUrl // Calculate remaining space for content preview (max 300 chars total, leave room for URL) const maxDescLength = 300 const urlPart = ` | ${truncatedUrl}` const remainingLength = maxDescLength - (ogDescription.length + urlPart.length) // Always try to include content preview if available (this is note-specific!) if (contentPreview && remainingLength > 20) { const truncatedContent = contentPreview.length > remainingLength ? contentPreview.substring(0, remainingLength - 3) + '...' : contentPreview ogDescription += (ogDescription ? ' ' : '') + truncatedContent } // Add truncated URL at the end ogDescription += (ogDescription ? urlPart : truncatedUrl) // Ensure we have note-specific content - if description is still too generic, add more event info if (!authorName && !eventSummary && !contentPreview && ogDescription.includes('Event') && !ogDescription.includes('|')) { // Add at least the event kind or some identifier to make it note-specific ogDescription = ogDescription.replace('Event', `${eventTypeName} (kind ${finalEvent.kind})`) } let image = eventMetadata?.image if (!image && authorProfile?.pubkey) { image = avatarProxyUrl(authorProfile.pubkey) } if (!image) { image = defaultOgImageAbsoluteUrl() } const tags = eventMetadata?.tags || [] // For articles, use article type; for other events, use website type const isArticle = articleMetadata !== null const ogType = isArticle ? 'article' : 'website' // Enhanced title with profile info const ogTitle = authorName ? `${eventTitle} · @${authorName} · ${SITE_NAME}` : `${eventTitle} · ${SITE_NAME}` updateMetaTag('og:title', ogTitle) updateMetaTag('og:description', ogDescription) updateMetaTag('og:image', image) updateMetaTag('og:image:width', '1200') updateMetaTag('og:image:height', '630') updateMetaTag('og:image:alt', `${eventTitle}${authorName ? ` by @${authorName}` : ''} on ${SITE_NAME}`) updateMetaTag('og:type', ogType) updateMetaTag('og:url', window.location.href) updateMetaTag('og:site_name', SITE_NAME) // Add profile data - always include if available if (authorProfile) { if (authorProfile.username) { updateMetaTag('profile:username', authorProfile.username) } if (authorProfile.nip05) { updateMetaTag('profile:username', authorProfile.nip05) } } // Add author for articles if (isArticle && authorName) { updateMetaTag('article:author', authorName) const authorUrl = `${getSiteOrigin()}/users/${nip19.npubEncode(finalEvent.pubkey)}` updateMetaTag('article:author:url', authorUrl) } // Twitter card meta tags updateMetaTag('twitter:card', 'summary_large_image') updateMetaTag('twitter:title', ogTitle) updateMetaTag('twitter:description', ogDescription.length > 200 ? ogDescription.substring(0, 197) + '...' : ogDescription) updateMetaTag('twitter:image', image) updateMetaTag('twitter:image:alt', `${eventTitle}${authorName ? ` by @${authorName}` : ''} on ${SITE_NAME}`) removeMetaByProperty('article:tag') // Add article-specific tags (one meta tag per tag) if (isArticle) { tags.forEach(tag => { const tagMeta = document.createElement('meta') tagMeta.setAttribute('property', 'article:tag') tagMeta.setAttribute('content', tag) document.head.appendChild(tagMeta) }) } document.title = ogTitle return () => { applyDefaultSiteSocialMeta() removeMetaByProperty('article:tag') removeMetaByProperty('article:author') document.querySelector('meta[property="article:author:url"]')?.remove() document.title = SITE_NAME } }, [finalEvent, articleMetadata, authorProfile]) if (!event && isFetching) { return (
) } if (!finalEvent) { return ( ) } return (
{rootITag && } {rootEventId && rootEventId !== parentEventId && (isFetchingRootEvent || rootEventForStrip) && ( )} {parentEventId && (isFetchingParentEvent || parentEventForStrip) && ( )}
) }) NotePage.displayName = 'NotePage' export default NotePage function ExternalRoot({ value }: { value: string }) { const { push } = useSecondaryPage() return (
{ // For external content, we still use secondary page navigation push(toNoteList({ externalContentId: value })) }} >
{value}
) } function ParentNote({ event, eventBech32Id, isFetching, isConsecutive = true }: { event?: Event eventBech32Id: string isFetching: boolean isConsecutive?: boolean }) { const { navigateToNote } = useSmartNoteNavigation() if (isFetching) { return (
) } return (
{ e.stopPropagation() if (event) client.addEventToCache(event) navigateToNote(toNote(event ?? eventBech32Id)) }} > {event && ( )}
{ e.stopPropagation() if (event) client.addEventToCache(event) navigateToNote(toNote(event ?? eventBech32Id)) }} >
{isConsecutive ? (
) : ( )}
) } function isConsecutive(rootEvent?: Event, parentEvent?: Event) { const eTag = getParentETag(parentEvent) if (!eTag) return false return rootEvent?.id === eTag[1] }