diff --git a/src/components/NoteContent.test.tsx b/src/components/NoteContent.test.tsx index 4da125d..9feaaf3 100644 --- a/src/components/NoteContent.test.tsx +++ b/src/components/NoteContent.test.tsx @@ -75,4 +75,30 @@ describe('NoteContent', () => { expect(screen.getByText('This is just plain text without any links.')).toBeInTheDocument(); expect(screen.queryByRole('link')).not.toBeInTheDocument(); }); + + it('renders hashtags as links', () => { + const event: NostrEvent = { + id: 'test-id', + pubkey: 'test-pubkey', + created_at: Math.floor(Date.now() / 1000), + kind: 1, + tags: [], + content: 'This is a post about #nostr and #bitcoin development.', + sig: 'test-sig', + }; + + render( + + + + ); + + const nostrHashtag = screen.getByRole('link', { name: '#nostr' }); + const bitcoinHashtag = screen.getByRole('link', { name: '#bitcoin' }); + + expect(nostrHashtag).toBeInTheDocument(); + expect(bitcoinHashtag).toBeInTheDocument(); + expect(nostrHashtag).toHaveAttribute('href', '/t/nostr'); + expect(bitcoinHashtag).toHaveAttribute('href', '/t/bitcoin'); + }); }); \ No newline at end of file diff --git a/src/components/NoteContent.tsx b/src/components/NoteContent.tsx index 0372f54..2a19343 100644 --- a/src/components/NoteContent.tsx +++ b/src/components/NoteContent.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useMemo } from 'react'; import { type NostrEvent } from '@nostrify/nostrify'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; @@ -14,138 +14,98 @@ interface NoteContentProps { export function NoteContent({ event, className, -}: NoteContentProps) { - const [content, setContent] = useState([]); - +}: NoteContentProps) { // Process the content to render mentions, links, etc. - useEffect(() => { - const processContent = async () => { - const text = event.content; - - // Regular expressions for different patterns - const urlRegex = /(https?:\/\/[^\s]+)/g; - const nostrRegex = /nostr:(npub1|note1|nprofile1|nevent1)([a-z0-9]+)/g; - const hashtagRegex = /#(\w+)/g; - - // Split the content by these patterns - let lastIndex = 0; - const parts: React.ReactNode[] = []; - - // Process URLs - const processUrls = () => { - text.replace(urlRegex, (match, url, index) => { - if (index > lastIndex) { - parts.push(text.substring(lastIndex, index)); - } - - parts.push( - - {url} - - ); - - lastIndex = index + match.length; - return match; - }); - }; - - // Process Nostr references - const processNostrRefs = () => { - text.replace(nostrRegex, (match, prefix, datastring, index) => { - if (index > lastIndex) { - parts.push(text.substring(lastIndex, index)); - } - - try { - const nostrId = `${prefix}${datastring}`; - const decoded = nip19.decode(nostrId); - - if (decoded.type === 'npub') { - const pubkey = decoded.data as string; - parts.push( - - ); - } else if (decoded.type === 'note') { - parts.push( - - note - - ); - } else { - // For other types, just show as a link - parts.push( - - {match} - - ); - } - } catch (e) { - // If decoding fails, just render as text - parts.push(match); - } - - lastIndex = index + match.length; - return match; - }); - }; - - // Process hashtags - const processHashtags = () => { - text.replace(hashtagRegex, (match, tag, index) => { - if (index > lastIndex) { - parts.push(text.substring(lastIndex, index)); - } - - parts.push( - - #{tag} - - ); - - lastIndex = index + match.length; - return match; - }); - }; - - // Run all processors - processUrls(); - processNostrRefs(); - processHashtags(); - - // Add any remaining text - if (lastIndex < text.length) { - parts.push(text.substring(lastIndex)); - } - - // If no special content was found, just use the plain text - if (parts.length === 0) { - parts.push(text); - } - - setContent(parts); - }; + const content = useMemo(() => { + const text = event.content; - processContent(); + // Regex to find URLs, Nostr references, and hashtags + const regex = /(https?:\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1)([a-z0-9]+)|(#\w+)/g; + + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + let keyCounter = 0; + + while ((match = regex.exec(text)) !== null) { + const [fullMatch, url, nostrPrefix, nostrData, hashtag] = match; + const index = match.index; + + // Add text before this match + if (index > lastIndex) { + parts.push(text.substring(lastIndex, index)); + } + + if (url) { + // Handle URLs + parts.push( + + {url} + + ); + } else if (nostrPrefix && nostrData) { + // Handle Nostr references + try { + const nostrId = `${nostrPrefix}${nostrData}`; + const decoded = nip19.decode(nostrId); + + if (decoded.type === 'npub') { + const pubkey = decoded.data; + parts.push( + + ); + } else { + // For other types, just show as a link + parts.push( + + {fullMatch} + + ); + } + } catch (e) { + // If decoding fails, just render as text + parts.push(fullMatch); + } + } else if (hashtag) { + // Handle hashtags + const tag = hashtag.slice(1); // Remove the # + parts.push( + + {hashtag} + + ); + } + + lastIndex = index + fullMatch.length; + } + + // Add any remaining text + if (lastIndex < text.length) { + parts.push(text.substring(lastIndex)); + } + + // If no special content was found, just use the plain text + if (parts.length === 0) { + parts.push(text); + } + + return parts; }, [event]); - + return (
{content.length > 0 ? content : event.content} @@ -156,11 +116,12 @@ export function NoteContent({ // Helper component to display user mentions function NostrMention({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); - const displayName = author.data?.metadata?.name || pubkey.slice(0, 8); - + const npub = nip19.npubEncode(pubkey); + const displayName = author.data?.metadata?.name ?? npub.slice(0, 8); + return ( @{displayName}