import { useSmartNoteNavigationOptional } from '@/PageManager' import { ExtendedKind, isMusicTrackKind, isNip71StyleVideoKind, publicAssetUrl } from '@/constants' import { isRenderableNoteKind } from '@/lib/note-renderable-kinds' import { getHttpUrlFromITags, getParentBech32Id, isNip18RepostKind, isNip25ReactionKind, isNsfwEvent } from '@/lib/event' import { mergeNip84MarkedIntervals, renderPlaintextWithNip84MergedMarks } from '@/lib/nip84-op-body-marks' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { relayHintsFromEventTags } from '@/lib/relay-list-builder' import { encodeArticleLikePublicationNaddr, openAlexandriaPublicationFromNaddr, toNote } from '@/lib/link' import { cn } from '@/lib/utils' import { DISCUSSION_DOWNVOTE_DISPLAY, DISCUSSION_UPVOTE_DISPLAY } from '@/lib/discussion-votes' import { notificationReactionSummaryKey, useNotificationReactionDisplay } from '@/hooks/useNotificationReactionDisplay' import logger from '@/lib/logger' import client from '@/services/client.service' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useMuteListOptional } from '@/contexts/mute-list-context' import { muteSetHas } from '@/lib/mute-set' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import type { HighlightData } from '@/components/PostEditor/HighlightEditor' import { Event, kinds } from 'nostr-tools' import { isCalendarEventKind } from '@/lib/calendar-event' import { mergeTranslatedNote, useNoteTranslation } from '@/lib/note-translation-display' import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { getWebBookmarkArticleUrl, getWebExternalReactionTargetUrl, isRssThreadSyntheticParentEvent } from '@/lib/rss-article' import { findTrailingStringifiedNostrEvent, type StringifiedNostrEventMatch } from '@/lib/nostr-event-json' import { CreateHighlightContext } from './CreateHighlightContext' import SelectionHighlightTrigger from './SelectionHighlightTrigger' import AudioPlayer from '../AudioPlayer' import WebPreview from '../WebPreview' import NoteAuthorMetaLine from '../NoteAuthorMetaLine' import { FormattedTimestamp } from '../FormattedTimestamp' import NoteOptions from '../NoteOptions' import EventPowLabel from '../EventPowLabel' import ParentNotePreview from '../ParentNotePreview' import UserAvatar from '../UserAvatar' import Username from '../Username' import { MessageSquare, Repeat2 } from 'lucide-react' import CommunityDefinition from './CommunityDefinition' import GroupMetadata from './GroupMetadata' import Highlight from './Highlight' import ContentPreview from '../ContentPreview' import IValue from './IValue' import LiveEvent from './LiveEvent' import MarkdownArticle from './MarkdownArticle/MarkdownArticle' import AsciidocArticle from './AsciidocArticle/AsciidocArticle' import PublicationCard from './PublicationCard' import NostrSpecCard from './NostrSpecCard' import WikiCard from './WikiCard' import LongFormCard from './LongFormCard' import MutedNote from './MutedNote' import NsfwNote from './NsfwNote' import PictureNote from './PictureNote' import Poll from './Poll' import NotificationEventCard from './NotificationEventCard' import ReactionEmojiDisplay from './ReactionEmojiDisplay' import UnknownNote from './UnknownNote' import { Button } from '@/components/ui/button' import VideoNote from './VideoNote' import MusicTrackNote from './MusicTrackNote' import RelayReview from './RelayReview' import Superchat from './Superchat' import Zap from './Zap' import MoneroTip from './MoneroTip' import CitationCard from '@/components/CitationCard' import FollowPackPreview from '../ContentPreview/FollowPackPreview' import CalendarEventContent from '../CalendarEventContent' import GitRepublicEventCard from './GitRepublicEventCard' const ASCIIDOC_CONTENT_KINDS = new Set([ ExtendedKind.PUBLICATION_CONTENT, ExtendedKind.WIKI_ARTICLE ]) function isStringifiedJsonContent(content?: string): boolean { if (!content) return false const trimmed = content.trim() if (!trimmed) return false const looksLikeJson = (trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']')) if (!looksLikeJson) return false try { const parsed = JSON.parse(trimmed) return parsed !== null && typeof parsed === 'object' } catch { return false } } function cacheEmbeddedRepostTarget(hostEvent: Event, targetEvent: Event) { client.addEventToCache(targetEvent) const targetSeenOn = client.getSeenEventRelays(targetEvent.id) if (targetSeenOn.length > 0) return client.getSeenEventRelays(hostEvent.id).forEach((relay) => { client.trackEventSeenOn(targetEvent.id, relay) }) } function StringifiedNostrEventPreviewCard({ hostEvent, targetEvent, className, deferAuthorAvatar = false }: { hostEvent: Event targetEvent: Event className?: string deferAuthorAvatar?: boolean }) { const { t } = useTranslation() useEffect(() => { cacheEmbeddedRepostTarget(hostEvent, targetEvent) }, [hostEvent.id, targetEvent]) return (
{t('Boost')}
) } function StringifiedNostrEventContent({ hostEvent, match, className, hideMetadata, autoLoadMedia, fullCalendarInvite, deferAuthorAvatar = false }: { hostEvent: Event match: StringifiedNostrEventMatch className?: string hideMetadata?: boolean autoLoadMedia: boolean fullCalendarInvite?: { event: Event; naddr: string } deferAuthorAvatar?: boolean }) { const textEvent = match.textBefore.trim() ? { ...hostEvent, content: match.textBefore } : undefined return (
{textEvent ? ( ) : null}
) } function RepostEventContent({ event, className }: { event: Event; className?: string }) { const embeddedEvent = findTrailingStringifiedNostrEvent(event.content) if (embeddedEvent) { return ( ) } return } export default function Note({ event, originalNoteId, size = 'normal', className, hideParentNotePreview = false, showFull = false, disableClick = false, /** From {@link MainNoteCard}: embedded cards need eager poll results (viewport IO often misses nested scrollers). */ embedded, fullCalendarInvite, nip84HighlightEvents, deferAuthorAvatar = false, /** When true, parent list already prefetches embeds — skip per-row duplicate fetches. */ skipEmbedPrefetch = false, showPaymentAttestationAction = false, pinned = false, seenOnAllowlist }: { event: Event originalNoteId?: string size?: 'normal' | 'small' className?: string hideParentNotePreview?: boolean showFull?: boolean disableClick?: boolean embedded?: boolean /** Passed to note menu when this row is already shown as pinned. */ pinned?: boolean /** When viewing a kind-24 invite, use this to replace the embedded calendar with the full card (RSVP) in content */ fullCalendarInvite?: { event: Event; naddr: string } /** Kind-9802 events that cite this note; when spans match {@link displayEvent.content}, render green marks (note page OP). */ nip84HighlightEvents?: Event[] /** When true, defer remote profile avatars until near-viewport (dense lists e.g. merged NIP-50 search). */ deferAuthorAvatar?: boolean /** Skip embedded-note prefetch when the feed list handles it in batch. */ skipEmbedPrefetch?: boolean /** Notifications feed: show attest-superchat action on incoming payments. */ showPaymentAttestationAction?: boolean /** When set (home favorites feed), relay list in ⋯ menu matches the feed allowlist. */ seenOnAllowlist?: readonly string[] }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigationOptional() const screenSize = useScreenSizeOptional() const isSmallScreen = screenSize?.isSmallScreen ?? false const parentEventId = useMemo(() => { if (hideParentNotePreview) return undefined if ( event.kind === ExtendedKind.PAYMENT_NOTIFICATION || event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE || event.kind === ExtendedKind.MONERO_TIP_RECEIPT ) { return undefined } return getParentBech32Id(event) }, [event, hideParentNotePreview]) const parentFetchRelayHints = useMemo(() => relayHintsFromEventTags(event), [event]) const contentPolicy = useContentPolicyOptional() const defaultShowNsfw = contentPolicy?.defaultShowNsfw ?? true const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true const [showNsfw, setShowNsfw] = useState(false) const muteList = useMuteListOptional() const mutePubkeySet = muteList?.mutePubkeySet ?? new Set() const [showMuted, setShowMuted] = useState(false) const [highlightData, setHighlightData] = useState(undefined) const [highlightDefaultContent, setHighlightDefaultContent] = useState('') const [postEditorOpen, setPostEditorOpen] = useState(false) const [publicMessageTo, setPublicMessageTo] = useState(null) const [callInviteContent, setCallInviteContent] = useState(null) const noteTranslation = useNoteTranslation(event.id) const displayEvent = useMemo(() => mergeTranslatedNote(event, noteTranslation), [event, noteTranslation]) useLayoutEffect(() => { if (skipEmbedPrefetch) return client.prefetchEmbeddedEventsForParents([event]) }, [event.id, skipEmbedPrefetch]) const reactionDisplay = useNotificationReactionDisplay(event) const webReactionParentUrl = useMemo( () => event.kind === ExtendedKind.EXTERNAL_REACTION ? getWebExternalReactionTargetUrl(event) : undefined, [event] ) const openHighlight = useCallback((data: HighlightData, eventContent?: string) => { setHighlightData(data) setHighlightDefaultContent(eventContent ?? '') setPublicMessageTo(null) setCallInviteContent(null) setPostEditorOpen(true) }, []) const openPublicMessage = useCallback((pubkey: string) => { setPublicMessageTo(pubkey) setCallInviteContent(null) setPostEditorOpen(true) }, []) const openCallInvite = useCallback((url: string) => { setCallInviteContent(url) setPublicMessageTo(null) setHighlightData(undefined) setHighlightDefaultContent('') setPostEditorOpen(true) }, []) const isHighlightableKind = event.kind === kinds.ShortTextNote || event.kind === kinds.LongFormArticle || event.kind === ExtendedKind.WIKI_ARTICLE || event.kind === ExtendedKind.NOSTR_SPECIFICATION || event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT || event.kind === ExtendedKind.DISCUSSION || isCalendarEventKind(event.kind) || event.kind === ExtendedKind.COMMENT const renderEventContent = useCallback( ({ hideMetadata = false, className = 'mt-2' }: { hideMetadata?: boolean className?: string } = {}) => { if (isNip18RepostKind(displayEvent.kind)) { return } const embeddedEvent = findTrailingStringifiedNostrEvent(displayEvent.content ?? '') if (embeddedEvent) { return ( ) } if (isStringifiedJsonContent(displayEvent.content)) { if (isNip18RepostKind(displayEvent.kind)) { return } return (
            {displayEvent.content}
          
) } if (ASCIIDOC_CONTENT_KINDS.has(displayEvent.kind)) { return ( ) } if ( nip84HighlightEvents?.length && displayEvent.kind === kinds.ShortTextNote ) { const merged = mergeNip84MarkedIntervals( displayEvent.content ?? '', nip84HighlightEvents, displayEvent.id ) if (merged.length > 0) { return (
{renderPlaintextWithNip84MergedMarks(displayEvent.content ?? '', merged)}
) } } return ( ) }, [displayEvent, fullCalendarInvite, autoLoadMedia, nip84HighlightEvents, deferAuthorAvatar] ) let content: React.ReactNode if (!isRenderableNoteKind(event.kind)) { content = } else if (muteSetHas(mutePubkeySet, event.pubkey) && !showMuted) { content = setShowMuted(true)} /> } else if (!defaultShowNsfw && isNsfwEvent(event) && !showNsfw) { content = setShowNsfw(true)} /> } else if (isNip25ReactionKind(event.kind)) { content = null } else if (isNip18RepostKind(displayEvent.kind)) { content = } else if (event.kind === ExtendedKind.POLL_RESPONSE) { content = } else if (event.kind === kinds.Highlights) { // Try to render the Highlight component with error boundary try { content = } catch (error) { logger.error('Note component - Error rendering Highlight component:', error) content =
HIGHLIGHT ERROR:
Error: {String(error)}
Content: {event.content}
Context: {event.tags.find(tag => tag[0] === 'context')?.[1] || 'No context found'}
} } else if (event.kind === ExtendedKind.WEB_BOOKMARK) { const href = getWebBookmarkArticleUrl(displayEvent) const title = displayEvent.tags.find((tag) => tag[0] === 'title')?.[1]?.trim() content = ( <> {title ? (

{title}

) : null} {href ? ( ) : null} {displayEvent.content?.trim() ? renderEventContent({ hideMetadata: true }) : null} ) } else if (event.kind === ExtendedKind.WIKI_ARTICLE) { content = showFull ? ( renderEventContent() ) : ( ) } else if (event.kind === ExtendedKind.NOSTR_SPECIFICATION) { content = showFull ? ( renderEventContent() ) : ( ) } else if (event.kind === ExtendedKind.PUBLICATION) { if (showFull) { const naddrFull = encodeArticleLikePublicationNaddr(displayEvent) content = (
{naddrFull ? ( ) : null}
) } else { content = } } else if (event.kind === ExtendedKind.PUBLICATION_CONTENT) { content = showFull ? ( renderEventContent() ) : ( ) } else if (event.kind === kinds.LongFormArticle) { content = showFull ? ( renderEventContent({ hideMetadata: true }) ) : ( ) } else if (event.kind === kinds.LiveEvent || event.kind === 30312 || event.kind === 30313) { content = } else if (event.kind === ExtendedKind.GROUP_METADATA) { content = } else if (event.kind === kinds.CommunityDefinition) { content = } else if (event.kind === ExtendedKind.DISCUSSION) { const titleTag = displayEvent.tags.find(tag => tag[0] === 'title') const title = titleTag?.[1] || 'Untitled Discussion' content = ( <>

{title}

{renderEventContent({ hideMetadata: true })} ) } else if ( event.kind === ExtendedKind.CITATION_INTERNAL || event.kind === ExtendedKind.CITATION_EXTERNAL || event.kind === ExtendedKind.CITATION_HARDCOPY || event.kind === ExtendedKind.CITATION_PROMPT ) { content = } else if (event.kind === ExtendedKind.POLL) { content = ( <> {renderEventContent({ hideMetadata: true })} ) } else if (event.kind === ExtendedKind.VOICE) { content = } else if (event.kind === ExtendedKind.VOICE_COMMENT) { const voiceArticleUrl = getHttpUrlFromITags(event) content = ( <> {voiceArticleUrl && (
)} ) } else if (event.kind === ExtendedKind.PICTURE) { content = } else if (isMusicTrackKind(event.kind)) { content = } else if (isNip71StyleVideoKind(event.kind)) { content = } else if (event.kind === ExtendedKind.RELAY_REVIEW) { content = } else if (isCalendarEventKind(event.kind)) { content = ( ) } else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { content = renderEventContent({ hideMetadata: true }) } else if ( event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === kinds.Zap ) { content = ( ) } else if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) { content = ( ) } else if ( event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE || event.kind === ExtendedKind.MONERO_TIP_RECEIPT ) { content = ( ) } else if (event.kind === ExtendedKind.FOLLOW_PACK) { content = } else if ( event.kind === ExtendedKind.GIT_REPO_ANNOUNCEMENT || event.kind === ExtendedKind.GIT_ISSUE || event.kind === ExtendedKind.GIT_RELEASE ) { content = } else if (event.kind === kinds.ShortTextNote || event.kind === ExtendedKind.COMMENT) { content = renderEventContent({ hideMetadata: true }) } else { content = renderEventContent() } const isSyntheticRssParent = isRssThreadSyntheticParentEvent(event) const wrappedContent = isHighlightableKind ? ( {content} ) : ( content ) return (
{ // Don't navigate if clicking on interactive elements const target = e.target as HTMLElement if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-embedded-note]') || target.closest('[data-parent-note-preview]') || target.closest('[data-user-avatar]') || target.closest('[data-username]')) { return } e.stopPropagation() client.addEventToCache(event) navigateToNote(toNote(event), event, getCachedThreadContextEvents(event)) }} >
{isNip25ReactionKind(event.kind) ? (
{reactionDisplay.status === 'vote_up' ? ( {DISCUSSION_UPVOTE_DISPLAY} ) : reactionDisplay.status === 'vote_down' ? ( {DISCUSSION_DOWNVOTE_DISPLAY} ) : ( )}
{t(notificationReactionSummaryKey(reactionDisplay))}
) : isSyntheticRssParent ? ( <>
{t('Imwald synthetic event')}
) : ( <> )}
{event.kind === ExtendedKind.DISCUSSION && ( )} {(size === 'normal' || event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) && ( { setPostEditorOpen(false) setHighlightData(undefined) setHighlightDefaultContent('') setPublicMessageTo(null) setCallInviteContent(null) }} onOpenPublicMessage={openPublicMessage} initialPublicMessageTo={publicMessageTo} onOpenCallInvite={openCallInvite} initialDefaultContent={callInviteContent} /> )}
{webReactionParentUrl ? (
) : parentEventId ? ( { e.stopPropagation() const parentEv = client.peekSessionCachedEvent(parentEventId) navigateToNote( toNote(parentEventId), parentEv, parentEv ? getCachedThreadContextEvents(parentEv) : undefined ) }} /> ) : null} {wrappedContent}
) }