diff --git a/src/components/MarkdownContent/index.tsx b/src/components/MarkdownContent/index.tsx index c5bf8dc..ebbe57e 100644 --- a/src/components/MarkdownContent/index.tsx +++ b/src/components/MarkdownContent/index.tsx @@ -1,13 +1,29 @@ import { SecondaryPageLink } from '@/PageManager' +import { X_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants' import { toNote, toProfile } from '@/lib/link' +import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { Event } from 'nostr-tools' import { useMemo } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' +import { EmbeddedHashtag, EmbeddedLNInvoice } from '../Embedded' +import Emoji from '../Emoji' +import ExternalLink from '../ExternalLink' import ImageWithLightbox from '../ImageWithLightbox' import NostrNode from '../Note/LongFormArticle/NostrNode' import { remarkNostr } from '../Note/LongFormArticle/remarkNostr' -import { Components } from '../Note/LongFormArticle/types' +import { Components as BaseComponents } from '../Note/LongFormArticle/types' + +type InlineComponent = React.ComponentType<{ value: string }> + +interface Components extends BaseComponents { + hashtag: InlineComponent + emoji: InlineComponent + invoice: InlineComponent +} +import XEmbeddedPost from '../XEmbeddedPost' +import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer' +import { remarkInlineContent } from './remarkInlineContent' export default function MarkdownContent({ content, @@ -16,10 +32,20 @@ export default function MarkdownContent({ content: string event?: Event }) { + const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event?.tags), [event?.tags]) + const components = useMemo( () => ({ nostr: ({ rawText, bech32Id }) => , + hashtag: ({ value }) => , + emoji: ({ value }) => { + const shortcode = value.slice(1, -1) + const emojiInfo = emojiInfos.find((e) => e.shortcode === shortcode) + if (!emojiInfo) return value + return + }, + invoice: ({ value }) => , a: ({ href, children }) => { if (!href) return {children} if (href.startsWith('note1') || href.startsWith('nevent1') || href.startsWith('naddr1')) { @@ -36,16 +62,14 @@ export default function MarkdownContent({ ) } + if (YOUTUBE_URL_REGEX.test(href)) { + return + } + if (X_URL_REGEX.test(href)) { + return + } return ( - e.stopPropagation()} - > - {children} - + ) }, h1: ({ children }) =>

{children}

, @@ -66,7 +90,6 @@ export default function MarkdownContent({
{children}
), code: ({ children, className }) => { - // If inside a
, render as block code (className contains language info)
           if (className) {
             return {children}
           }
@@ -95,13 +118,13 @@ export default function MarkdownContent({
         ),
         hr: () => 
}) as Components, - [event?.pubkey] + [event?.pubkey, emojiInfos] ) return (
{ if (url.startsWith('nostr:')) { return url.slice(6) diff --git a/src/components/MarkdownContent/remarkInlineContent.ts b/src/components/MarkdownContent/remarkInlineContent.ts new file mode 100644 index 0000000..98275ae --- /dev/null +++ b/src/components/MarkdownContent/remarkInlineContent.ts @@ -0,0 +1,74 @@ +import { EMOJI_SHORT_CODE_REGEX, HASHTAG_REGEX, LN_INVOICE_REGEX } from '@/constants' +import type { PhrasingContent, Root, Text } from 'mdast' +import type { Plugin } from 'unified' +import type { Node } from 'unist' +import { visit } from 'unist-util-visit' + +interface InlineContentNode extends Node { + type: 'hashtag' | 'emoji' | 'invoice' + data: { + hName: string + hProperties: { value: string } + } +} + +const PATTERNS: { type: InlineContentNode['type']; regex: RegExp }[] = [ + { type: 'invoice', regex: LN_INVOICE_REGEX }, + { type: 'hashtag', regex: HASHTAG_REGEX }, + { type: 'emoji', regex: EMOJI_SHORT_CODE_REGEX } +] + +export const remarkInlineContent: Plugin<[], Root> = () => { + return (tree) => { + visit(tree, 'text', (node: Text, index, parent) => { + if (!parent || typeof index !== 'number') return + + let segments: (Text | InlineContentNode)[] = [{ type: 'text', value: node.value }] + + for (const { type, regex } of PATTERNS) { + const nextSegments: (Text | InlineContentNode)[] = [] + + for (const segment of segments) { + if (segment.type !== 'text') { + nextSegments.push(segment) + continue + } + + const text = (segment as Text).value + const localRegex = new RegExp(regex.source, regex.flags) + const matches = Array.from(text.matchAll(localRegex)) + + if (matches.length === 0) { + nextSegments.push(segment) + continue + } + + let lastIndex = 0 + for (const match of matches) { + const matchStart = match.index! + if (matchStart > lastIndex) { + nextSegments.push({ type: 'text', value: text.slice(lastIndex, matchStart) }) + } + nextSegments.push({ + type, + data: { + hName: type, + hProperties: { value: match[0] } + } + } as InlineContentNode) + lastIndex = matchStart + match[0].length + } + if (lastIndex < text.length) { + nextSegments.push({ type: 'text', value: text.slice(lastIndex) }) + } + } + + segments = nextSegments + } + + if (segments.length > 1 || (segments.length === 1 && segments[0].type !== 'text')) { + parent.children.splice(index, 1, ...(segments as PhrasingContent[])) + } + }) + } +}