From 9c83a8fee2ac5ab7b37b8c260a92953137576950 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 31 Mar 2026 13:29:19 +0200 Subject: [PATCH] switch to using Marked as the markdown editor --- src/PageManager.tsx | 2 +- src/components/AboutInfoDialog/index.tsx | 4 +- .../Note/MarkdownArticle/MarkdownArticle.tsx | 729 +++++++++++++++++- src/components/Note/index.tsx | 100 ++- src/pages/secondary/NoteListPage/index.tsx | 6 +- src/providers/InterestListProvider.tsx | 6 + 6 files changed, 805 insertions(+), 42 deletions(-) diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 21ee1ff9..490d0439 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -921,7 +921,7 @@ function MainContentArea({ -
+
{primaryNoteView}
diff --git a/src/components/AboutInfoDialog/index.tsx b/src/components/AboutInfoDialog/index.tsx index 8c84cff4..0fe8033d 100644 --- a/src/components/AboutInfoDialog/index.tsx +++ b/src/components/AboutInfoDialog/index.tsx @@ -2,6 +2,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, DrawerTrigger } from '@/components/ui/drawer' import { Button } from '@/components/ui/button' import { SILBERENGEL_PUBKEY } from '@/constants' +import { useSmartProfileNavigationOptional } from '@/PageManager' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useState, useEffect } from 'react' import { replaceableEventService } from '@/services/client.service' @@ -11,6 +12,7 @@ import { toProfile } from '@/lib/link' export default function AboutInfoDialog({ children }: { children: React.ReactNode }) { const { isSmallScreen } = useScreenSize() + const { navigateToProfile } = useSmartProfileNavigationOptional() const [open, setOpen] = useState(false) const [silberengelLightning, setSilberengelLightning] = useState(null) @@ -31,7 +33,7 @@ export default function AboutInfoDialog({ children }: { children: React.ReactNod const openSilberengelProfile = () => { setOpen(false) - window.location.assign(toProfile(SILBERENGEL_PUBKEY)) + navigateToProfile(toProfile(SILBERENGEL_PUBKEY)) } const openGithubFork = () => { diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 91b35815..05d2a5f9 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -37,6 +37,7 @@ import EmbeddedCitation from '@/components/EmbeddedCitation' import { preprocessMarkdownMediaLinks } from './preprocessMarkup' import { PAYTO_URI_REGEX, parsePaytoUri } from '@/lib/payto' import PaytoLink from '@/components/PaytoLink' +import { marked } from 'marked' import katex from 'katex' import '@/styles/katex-bundle.css' import { isContentSpacingDebug, reprString } from '@/lib/content-spacing-debug' @@ -430,7 +431,7 @@ function normalizeSetextHeaders(content: string): string { * - wss:// and ws:// URLs -> hyperlinks to /relays/{url} * Returns both rendered nodes and a set of hashtags found in content (for deduplication) */ -function parseMarkdownContent( +export function parseMarkdownContent( content: string, options: { eventPubkey: string @@ -2740,6 +2741,553 @@ function parseMarkdownContent( return { nodes: wrappedParts, hashtagsInContent, footnotes, citations } } +/** + * Marked-driven markdown renderer (standard markdown blocks/inline), while keeping + * Nostr-specific enrichments (embeds, wikilinks, relay/profile navigation) custom. + */ +function parseMarkdownContentMarked( + content: string, + options: { + eventPubkey: string + imageIndexMap: Map + openLightbox: (index: number) => void + navigateToHashtag: (href: string) => void + navigateToRelay: (url: string) => void + videoPosterMap?: Map + imageThumbnailMap?: Map + getImageIdentifier?: (url: string) => string | null + emojiInfos?: TEmoji[] + fullCalendarInvite?: { naddr: string; event: Event } + suppressStandaloneWebPreviewCleanedUrls?: ReadonlySet + containingEvent?: Event + } +): { nodes: React.ReactNode[]; hashtagsInContent: Set; footnotes: Map; citations: Array<{ id: string; type: string; citationId: string }> } { + const { + eventPubkey, + imageIndexMap, + openLightbox, + navigateToHashtag, + navigateToRelay, + videoPosterMap, + imageThumbnailMap, + getImageIdentifier, + emojiInfos = [], + fullCalendarInvite, + suppressStandaloneWebPreviewCleanedUrls, + containingEvent + } = options + + const hashtagsInContent = new Set() + const footnotes = new Map() + const citations: Array<{ id: string; type: string; citationId: string }> = [] + const blockTokens = marked.lexer(content, { gfm: true, breaks: true }) as any[] + let codeBlockIdx = 0 + + const collectHashtags = (text: string) => { + const re = /#([a-zA-Z0-9_]+)/g + let m: RegExpExecArray | null + while ((m = re.exec(text)) !== null) { + hashtagsInContent.add(m[1].toLowerCase()) + } + } + + const renderInlineTokens = (tokens: any[], keyPrefix: string): React.ReactNode[] => { + const out: React.ReactNode[] = [] + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] + const key = `${keyPrefix}-${i}` + switch (token.type) { + case 'text': + case 'escape': { + const txt = String(token.text ?? token.raw ?? '') + collectHashtags(txt) + out.push( + ...parseInlineMarkdownLegacy(txt, `${key}-text`, footnotes, emojiInfos, navigateToHashtag) + ) + break + } + case 'strong': + out.push( + + {renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? '' }], `${key}-strong`)} + + ) + break + case 'em': + out.push( + + {renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? '' }], `${key}-em`)} + + ) + break + case 'del': + out.push( + + {renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? '' }], `${key}-del`)} + + ) + break + case 'codespan': + out.push( + + ) + break + case 'link': { + const href = String(token.href ?? '') + const children = renderInlineTokens( + token.tokens ?? [{ type: 'text', text: token.text ?? href }], + `${key}-link` + ) + if (href.startsWith('payto://')) { + out.push( + + {children} + + ) + } else { + out.push( + + {children} + + ) + } + break + } + case 'br': + out.push(
) + break + case 'image': { + const src = String(token.href ?? '') + const cleaned = cleanUrl(src) + if (!cleaned) break + if (isVideo(cleaned) || isAudio(cleaned)) { + // Inline context: do NOT mount block media players inside paragraph flow. + out.push( + + {src} + + ) + break + } + const identifier = getImageIdentifier?.(cleaned) + const thumbnail = + imageThumbnailMap?.get(cleaned) ?? + (identifier ? imageThumbnailMap?.get(`__img_id:${identifier}`) : undefined) + const imageUrl = thumbnail || src + const imageIdx = imageIndexMap.get(cleaned) + out.push( + {token.text { + e.stopPropagation() + if (typeof imageIdx === 'number') openLightbox(imageIdx) + }} + /> + ) + break + } + default: { + const txt = String(token.raw ?? token.text ?? '') + if (txt) { + collectHashtags(txt) + out.push( + ...parseInlineMarkdownLegacy(txt, `${key}-fallback`, footnotes, emojiInfos, navigateToHashtag) + ) + } + } + } + } + return out + } + + const renderParagraph = (token: any, key: string): React.ReactNode => { + const paragraphText = String(token.text ?? '').trim() + const standaloneNostr = paragraphText.match(/^nostr:([a-z0-9]{8,})$/i) + if (standaloneNostr) { + const bech32Id = standaloneNostr[1] + if (bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')) { + return ( + + + + ) + } + if (bech32Id.startsWith('note') || bech32Id.startsWith('nevent') || bech32Id.startsWith('naddr')) { + if (fullCalendarInvite && bech32Id === fullCalendarInvite.naddr) { + return ( +
+ +
+ ) + } + return ( +
+ +
+ ) + } + } + + const wiki = paragraphText.match(/^\[\[([^\]]+)\]\]$/) + if (wiki) { + const linkContent = wiki[1].trim() + if (linkContent.startsWith('book::')) { + return + } + const target = linkContent.includes('|') ? linkContent.split('|')[0].trim() : linkContent + const displayText = linkContent.includes('|') ? linkContent.split('|')[1].trim() : linkContent + const dTag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') + return + } + + if (/^wss?:\/\/\S+$/i.test(paragraphText)) { + return ( + { + e.preventDefault() + navigateToRelay(paragraphText) + }} + > + {paragraphText} + + ) + } + + // Mixed paragraphs can contain normal text plus one or more standalone nostr lines. + // Render event references as embedded cards even when they are not the entire paragraph. + const rawParagraphText = String(token.text ?? '') + if (rawParagraphText.includes('\n')) { + const lines = rawParagraphText.split('\n').map((line) => line.trim()).filter((line) => line.length > 0) + const hasStandaloneNostrLine = lines.some((line) => /^nostr:([a-z0-9]{8,})$/i.test(line)) + if (hasStandaloneNostrLine) { + const lineNodes = lines.map((line, lineIdx) => { + const nostrMatch = line.match(/^nostr:([a-z0-9]{8,})$/i) + if (!nostrMatch) { + if (/^wss?:\/\/\S+$/i.test(line)) { + return ( + { + e.preventDefault() + navigateToRelay(line) + }} + > + {line} + + ) + } + + if (/^https?:\/\/\S+$/i.test(line)) { + const cleaned = cleanUrl(line) + if (cleaned) { + if (isPseudoNostrHttpsUrl(cleaned)) { + return ( +
+ +
+ ) + } + if (suppressStandaloneWebPreviewCleanedUrls?.has(cleaned)) { + return ( +

+ + {cleaned} + +

+ ) + } + return + } + } + + return ( +

+ {renderInlineTokens(marked.Lexer.lexInline(line) as any[], `${key}-line-inline-${lineIdx}`)} +

+ ) + } + + const bech32Id = nostrMatch[1] + if (bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')) { + return ( + + + + ) + } + + if (bech32Id.startsWith('note') || bech32Id.startsWith('nevent') || bech32Id.startsWith('naddr')) { + if (fullCalendarInvite && bech32Id === fullCalendarInvite.naddr) { + return ( +
+ +
+ ) + } + return ( +
+ +
+ ) + } + + return ( +

+ {renderInlineTokens(marked.Lexer.lexInline(line) as any[], `${key}-line-fallback-inline-${lineIdx}`)} +

+ ) + }) + + return
{lineNodes}
+ } + } + + if (/^https?:\/\/\S+$/i.test(paragraphText)) { + const cleaned = cleanUrl(paragraphText) + if (cleaned) { + if (isPseudoNostrHttpsUrl(cleaned)) { + return ( +
+ +
+ ) + } + if (suppressStandaloneWebPreviewCleanedUrls?.has(cleaned)) { + return ( +

+ + {cleaned} + +

+ ) + } + return + } + } + + // If the paragraph is a single markdown image token, render it as block media/image + // instead of wrapping in

(avoids invalid DOM nesting for media players). + const paragraphTokens = token.tokens ?? marked.Lexer.lexInline(token.text ?? '') + if (Array.isArray(paragraphTokens) && paragraphTokens.length === 1 && paragraphTokens[0]?.type === 'image') { + const imageToken = paragraphTokens[0] + const src = String(imageToken.href ?? '') + const cleaned = cleanUrl(src) + if (cleaned) { + if (isVideo(cleaned) || isAudio(cleaned)) { + const poster = videoPosterMap?.get(cleaned) + return ( +

+ +
+ ) + } + const identifier = getImageIdentifier?.(cleaned) + const thumbnail = + imageThumbnailMap?.get(cleaned) ?? + (identifier ? imageThumbnailMap?.get(`__img_id:${identifier}`) : undefined) + const imageUrl = thumbnail || src + const imageIdx = imageIndexMap.get(cleaned) + return ( + {imageToken.text { + e.stopPropagation() + if (typeof imageIdx === 'number') openLightbox(imageIdx) + }} + /> + ) + } + } + + const inlineNodes = renderInlineTokens(paragraphTokens, `${key}-inline`) + return

{inlineNodes}

+ } + + const renderBlockTokens = (tokens: any[], keyPrefix: string): React.ReactNode[] => { + const nodes: React.ReactNode[] = [] + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] + const key = `${keyPrefix}-${i}` + switch (token.type) { + case 'space': + break + case 'paragraph': + nodes.push(renderParagraph(token, key)) + break + case 'heading': { + const level = Number(token.depth || 1) + const headingClass = + level === 1 + ? 'text-3xl' + : level === 2 + ? 'text-2xl' + : level === 3 + ? 'text-xl' + : level === 4 + ? 'text-lg' + : 'text-base' + nodes.push( + React.createElement( + `h${Math.min(Math.max(level, 1), 6)}`, + { key: `${key}-h`, className: `font-bold break-words block mt-4 mb-2 ${headingClass}` }, + renderInlineTokens(token.tokens ?? marked.Lexer.lexInline(token.text ?? ''), `${key}-h-inline`) + ) + ) + break + } + case 'hr': + nodes.push(
) + break + case 'code': + nodes.push( + + ) + break + case 'blockquote': { + const rawLines = String(token.raw ?? '') + .split('\n') + .filter((line) => line.trim().length > 0) + const isGreentext = + rawLines.length > 0 && rawLines.every((line) => /^>([^\s>].*)$/.test(line.trim())) + if (isGreentext) { + const lines = rawLines.map((line) => line.replace(/^>\s?/, '')) + nodes.push( +
+ {lines.map((line, idx) => ( + + {renderInlineTokens(marked.Lexer.lexInline(line) as any[], `${key}-gt-inline-${idx}`)} + {idx < lines.length - 1 ?
: null} +
+ ))} +
+ ) + } else { + nodes.push( +
+ {renderBlockTokens(token.tokens ?? [], `${key}-bq-inner`)} +
+ ) + } + break + } + case 'list': { + const ListTag = token.ordered ? 'ol' : 'ul' + const listClass = token.ordered ? 'list-decimal list-outside my-2 ml-6' : 'list-disc list-inside my-2 space-y-1' + nodes.push( + React.createElement( + ListTag, + { key: `${key}-list`, className: listClass }, + (token.items ?? []).map((item: any, itemIdx: number) => ( +
  • + {renderBlockTokens(item.tokens ?? [{ type: 'text', text: item.text ?? '' }], `${key}-li-${itemIdx}`)} +
  • + )) + ) + ) + break + } + case 'table': { + nodes.push( +
    + + + + {(token.header ?? []).map((cell: any, cIdx: number) => ( + + ))} + + + + {(token.rows ?? []).map((row: any[], rIdx: number) => ( + + {row.map((cell: any, cIdx: number) => ( + + ))} + + ))} + +
    + {renderInlineTokens(cell.tokens ?? marked.Lexer.lexInline(cell.text ?? ''), `${key}-th-inline-${cIdx}`)} +
    + {renderInlineTokens( + cell.tokens ?? marked.Lexer.lexInline(cell.text ?? ''), + `${key}-td-inline-${rIdx}-${cIdx}` + )} +
    +
    + ) + break + } + default: { + if (Array.isArray(token.tokens) && token.tokens.length > 0) { + nodes.push(...renderBlockTokens(token.tokens, `${key}-nested`)) + } else if (typeof token.text === 'string' && token.text.trim()) { + nodes.push( +

    + {renderInlineTokens(marked.Lexer.lexInline(token.text) as any[], `${key}-fallback-inline`)} +

    + ) + } + } + } + } + return nodes + } + + const nodes = renderBlockTokens(blockTokens, 'marked-root') + return { nodes, hashtagsInContent, footnotes, citations } +} + /** * Parse inline markdown formatting (bold, italic, strikethrough, inline code, footnote references) * Returns an array of React nodes @@ -2751,7 +3299,143 @@ function parseMarkdownContent( * - Inline code: ``code`` (double backtick) or `code` (single backtick) * - Footnote references: [^1] (handled at block level, but parsed here for inline context) */ -function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map = new Map(), emojiInfos: TEmoji[] = []): React.ReactNode[] { +function parseInlineMarkdown( + text: string, + keyPrefix: string, + _footnotes: Map = new Map(), + emojiInfos: TEmoji[] = [], + navigateToHashtag?: (href: string) => void +): React.ReactNode[] { + const normalized = text.replace(/\n/g, ' ').replace(/[ \t]{2,}/g, ' ') + const tokens = marked.Lexer.lexInline(normalized) as any[] + const hasMarkdownSyntax = tokens.some((token) => token.type !== 'text' && token.type !== 'escape') + + // Fast path: keep old behavior when there is no markdown syntax. + if (!hasMarkdownSyntax) { + return parseInlineMarkdownLegacy(normalized, keyPrefix, _footnotes, emojiInfos, navigateToHashtag) + } + + const renderTokens = (list: any[], path: string): React.ReactNode[] => { + const out: React.ReactNode[] = [] + for (let i = 0; i < list.length; i++) { + const token = list[i] + const tokenKey = `${path}-${i}` + + if (token.type === 'text' || token.type === 'escape') { + out.push( + ...parseInlineMarkdownLegacy( + String(token.text ?? token.raw ?? ''), + `${keyPrefix}-${tokenKey}-text`, + _footnotes, + emojiInfos, + navigateToHashtag + ) + ) + continue + } + + if (token.type === 'strong') { + out.push( + + {renderTokens(token.tokens ?? [{ type: 'text', text: token.text ?? '' }], `${tokenKey}-strong`)} + + ) + continue + } + + if (token.type === 'em') { + out.push( + + {renderTokens(token.tokens ?? [{ type: 'text', text: token.text ?? '' }], `${tokenKey}-em`)} + + ) + continue + } + + if (token.type === 'del') { + out.push( + + {renderTokens(token.tokens ?? [{ type: 'text', text: token.text ?? '' }], `${tokenKey}-del`)} + + ) + continue + } + + if (token.type === 'codespan') { + out.push( + + ) + continue + } + + if (token.type === 'link') { + const href = String(token.href ?? '') + const children = renderTokens( + token.tokens ?? [{ type: 'text', text: token.text ?? href }], + `${tokenKey}-link` + ) + if (href.startsWith('payto://')) { + out.push( + + {children} + + ) + } else { + out.push( + + {children} + + ) + } + continue + } + + if (token.type === 'br') { + out.push(
    ) + continue + } + + // Unknown/HTML token: treat as text to avoid unsafe HTML injection. + out.push( + ...parseInlineMarkdownLegacy( + String(token.raw ?? token.text ?? ''), + `${keyPrefix}-${tokenKey}-fallback`, + _footnotes, + emojiInfos, + navigateToHashtag + ) + ) + } + return out + } + + const rendered = renderTokens(tokens, `${keyPrefix}-md`) + return rendered.length > 0 + ? rendered + : parseInlineMarkdownLegacy(normalized, keyPrefix, _footnotes, emojiInfos, navigateToHashtag) +} + +function parseInlineMarkdownLegacy( + text: string, + keyPrefix: string, + _footnotes: Map = new Map(), + emojiInfos: TEmoji[] = [], + navigateToHashtag?: (href: string) => void +): React.ReactNode[] { if (isContentSpacingDebug() && text.includes('nostr:')) { // eslint-disable-next-line no-console console.log('[jumble content-spacing] parseInlineMarkdown:before-normalize', { @@ -3145,11 +3829,16 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map - {parseInlineMarkdown(text, `${keyPrefix}-link-${i}`, _footnotes, emojiInfos)} + {parseInlineMarkdownLegacy(text, `${keyPrefix}-link-${i}`, _footnotes, emojiInfos)} ) } else { - const linkContent = parseInlineMarkdown(text, `${keyPrefix}-link-${i}`, _footnotes, emojiInfos) + const linkContent = parseInlineMarkdownLegacy( + text, + `${keyPrefix}-link-${i}`, + _footnotes, + emojiInfos + ) parts.push( { + if (!navigateToHashtag) return + e.stopPropagation() + e.preventDefault() + navigateToHashtag(`/notes?t=${tagLower}`) + }} > #{tag} @@ -3537,10 +4232,12 @@ export default function MarkdownArticle({ }, [event.content]) // Image gallery state - const [lightboxIndex, setLightboxIndex] = useState(-1) + const [lightboxOpen, setLightboxOpen] = useState(false) + const [lightboxIndex, setLightboxIndex] = useState(0) const openLightbox = useCallback((index: number) => { setLightboxIndex(index) + setLightboxOpen(true) }, []) // Filter tag media to only show what's not in content @@ -3651,7 +4348,7 @@ export default function MarkdownArticle({ // Parse markdown content with post-processing for nostr: links and hashtags const { nodes: parsedContent, hashtagsInContent } = useMemo(() => { - const result = parseMarkdownContent(preprocessedContent, { + const parseOptions = { eventPubkey: event.pubkey, imageIndexMap, openLightbox, @@ -3665,7 +4362,8 @@ export default function MarkdownArticle({ containingEvent: event, suppressStandaloneWebPreviewCleanedUrls: webPreviewSuppressCleanedSet.size > 0 ? webPreviewSuppressCleanedSet : undefined - }) + } + const result = parseMarkdownContentMarked(preprocessedContent, parseOptions) // Return nodes and hashtags (footnotes are already included in nodes) return { nodes: result.nodes, hashtagsInContent: result.hashtagsInContent } }, [ @@ -3941,8 +4639,14 @@ export default function MarkdownArticle({ {/* Image gallery lightbox */} - {allImages.length > 0 && lightboxIndex >= 0 && createPortal( -
    e.stopPropagation()}> + {allImages.length > 0 && lightboxOpen && createPortal( +
    e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + > ({ @@ -3950,8 +4654,11 @@ export default function MarkdownArticle({ alt: alt || url }))} plugins={[Zoom]} - open={lightboxIndex >= 0} - close={() => setLightboxIndex(-1)} + open={lightboxOpen} + close={() => setLightboxOpen(false)} + on={{ + view: ({ index }) => setLightboxIndex(index) + }} controller={{ closeOnBackdropClick: false, closeOnPullUp: true, diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index d7befb1e..cd26f2f6 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -52,7 +52,6 @@ import Highlight from './Highlight' import IValue from './IValue' import LiveEvent from './LiveEvent' -import LongFormArticlePreview from './LongFormArticlePreview' import MarkdownArticle from './MarkdownArticle/MarkdownArticle' import AsciidocArticle from './AsciidocArticle/AsciidocArticle' import PublicationCard from './PublicationCard' @@ -76,6 +75,27 @@ 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 + } +} + export default function Note({ event, originalNoteId, @@ -159,6 +179,47 @@ export default function Note({ event.kind === ExtendedKind.CALENDAR_EVENT_DATE || event.kind === ExtendedKind.COMMENT + const renderEventContent = useCallback( + ({ + hideMetadata = false, + className = 'mt-2' + }: { + hideMetadata?: boolean + className?: string + } = {}) => { + if (isStringifiedJsonContent(event.content)) { + return ( +
    +            {event.content}
    +          
    + ) + } + if (ASCIIDOC_CONTENT_KINDS.has(event.kind)) { + return ( + + ) + } + return ( + + ) + }, + [event, fullCalendarInvite] + ) + let content: React.ReactNode if (!isRenderableNoteKind(event.kind)) { @@ -206,20 +267,18 @@ export default function Note({
    ) : null} - {event.content?.trim() ? ( -

    {event.content}

    - ) : null} + {event.content?.trim() ? renderEventContent({ hideMetadata: true }) : null} ) } else if (event.kind === ExtendedKind.WIKI_ARTICLE) { content = showFull ? ( - + renderEventContent() ) : ( ) } else if (event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { content = showFull ? ( - + renderEventContent() ) : ( ) @@ -231,16 +290,12 @@ export default function Note({ ) } else if (event.kind === ExtendedKind.PUBLICATION_CONTENT) { content = showFull ? ( - + renderEventContent() ) : ( ) } else if (event.kind === kinds.LongFormArticle) { - content = showFull ? ( - - ) : ( - - ) + content = renderEventContent({ hideMetadata: true }) } else if (event.kind === kinds.LiveEvent) { content = } else if (event.kind === ExtendedKind.GROUP_METADATA) { @@ -253,7 +308,7 @@ export default function Note({ content = ( <>

    {title}

    - + {renderEventContent({ hideMetadata: true })} ) } else if ( @@ -266,14 +321,14 @@ export default function Note({ } else if (event.kind === ExtendedKind.POLL) { content = ( <> - + {renderEventContent({ hideMetadata: true })} ) } else if (event.kind === ExtendedKind.ZAP_POLL) { content = ( <> - + {renderEventContent({ hideMetadata: true })} } else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { - content = ( - - ) + content = renderEventContent({ hideMetadata: true }) } else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) { content = } else if (event.kind === ExtendedKind.FOLLOW_PACK) { @@ -323,11 +371,9 @@ export default function Note({ ) { content = } else if (event.kind === kinds.ShortTextNote || event.kind === ExtendedKind.COMMENT) { - // Plain text notes use MarkdownArticle for proper markdown rendering - content = + content = renderEventContent({ hideMetadata: true }) } else { - // Use MarkdownArticle for all other kinds - content = + content = renderEventContent() } const isSyntheticRssParent = isRssThreadSyntheticParentEvent(event) diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx index b02ba59c..6ce8d8d7 100644 --- a/src/pages/secondary/NoteListPage/index.tsx +++ b/src/pages/secondary/NoteListPage/index.tsx @@ -15,7 +15,7 @@ import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { useSecondaryPage } from '@/PageManager' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' -import { useInterestList } from '@/providers/InterestListProvider' +import { useInterestListOptional } from '@/providers/InterestListProvider' import client from '@/services/client.service' import { TFeedSubRequest } from '@/types' import { UserRound, Plus } from 'lucide-react' @@ -35,7 +35,9 @@ const NoteListPage = forwardRef(({ index, hid const { push } = useSecondaryPage() const { relayList, pubkey } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() - const { isSubscribed, subscribe } = useInterestList() + const interestList = useInterestListOptional() + const isSubscribed = interestList?.isSubscribed ?? (() => false) + const subscribe = interestList?.subscribe ?? (async () => {}) const [title, setTitle] = useState(null) const [controls, setControls] = useState(null) const [data, setData] = useState< diff --git a/src/providers/InterestListProvider.tsx b/src/providers/InterestListProvider.tsx index c1b7c5fc..83d82c16 100644 --- a/src/providers/InterestListProvider.tsx +++ b/src/providers/InterestListProvider.tsx @@ -29,6 +29,12 @@ export const useInterestList = () => { return context } +/** + * Optional variant for routes/components that can be mounted + * during transient navigation/HMR paths before providers settle. + */ +export const useInterestListOptional = () => useContext(InterestListContext) + export function InterestListProvider({ children }: { children: React.ReactNode }) { const { t } = useTranslation() const { pubkey: accountPubkey, interestListEvent, publish, updateInterestListEvent } = useNostr()