Improve NoteContent and NoteContent tests

This commit is contained in:
Alex Gleason 2025-06-01 09:20:00 -05:00
parent acbe053c97
commit 865ca7039f
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
2 changed files with 120 additions and 133 deletions

View File

@ -75,4 +75,30 @@ describe('NoteContent', () => {
expect(screen.getByText('This is just plain text without any links.')).toBeInTheDocument(); expect(screen.getByText('This is just plain text without any links.')).toBeInTheDocument();
expect(screen.queryByRole('link')).not.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(
<TestApp>
<NoteContent event={event} />
</TestApp>
);
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');
});
}); });

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useMemo } from 'react';
import { type NostrEvent } from '@nostrify/nostrify'; import { type NostrEvent } from '@nostrify/nostrify';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
@ -15,32 +15,32 @@ export function NoteContent({
event, event,
className, className,
}: NoteContentProps) { }: NoteContentProps) {
const [content, setContent] = useState<React.ReactNode[]>([]);
// Process the content to render mentions, links, etc. // Process the content to render mentions, links, etc.
useEffect(() => { const content = useMemo(() => {
const processContent = async () => {
const text = event.content; const text = event.content;
// Regular expressions for different patterns // Regex to find URLs, Nostr references, and hashtags
const urlRegex = /(https?:\/\/[^\s]+)/g; const regex = /(https?:\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1)([a-z0-9]+)|(#\w+)/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[] = []; const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
let keyCounter = 0;
// Process URLs while ((match = regex.exec(text)) !== null) {
const processUrls = () => { const [fullMatch, url, nostrPrefix, nostrData, hashtag] = match;
text.replace(urlRegex, (match, url, index) => { const index = match.index;
// Add text before this match
if (index > lastIndex) { if (index > lastIndex) {
parts.push(text.substring(lastIndex, index)); parts.push(text.substring(lastIndex, index));
} }
if (url) {
// Handle URLs
parts.push( parts.push(
<a <a
key={`url-${index}`} key={`url-${keyCounter++}`}
href={url} href={url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@ -49,86 +49,49 @@ export function NoteContent({
{url} {url}
</a> </a>
); );
} else if (nostrPrefix && nostrData) {
lastIndex = index + match.length; // Handle Nostr references
return match;
});
};
// Process Nostr references
const processNostrRefs = () => {
text.replace(nostrRegex, (match, prefix, datastring, index) => {
if (index > lastIndex) {
parts.push(text.substring(lastIndex, index));
}
try { try {
const nostrId = `${prefix}${datastring}`; const nostrId = `${nostrPrefix}${nostrData}`;
const decoded = nip19.decode(nostrId); const decoded = nip19.decode(nostrId);
if (decoded.type === 'npub') { if (decoded.type === 'npub') {
const pubkey = decoded.data as string; const pubkey = decoded.data;
parts.push( parts.push(
<NostrMention key={`mention-${index}`} pubkey={pubkey} /> <NostrMention key={`mention-${keyCounter++}`} pubkey={pubkey} />
);
} else if (decoded.type === 'note') {
parts.push(
<Link
key={`note-${index}`}
to={`/e/${nostrId}`}
className="text-blue-500 hover:underline"
>
note
</Link>
); );
} else { } else {
// For other types, just show as a link // For other types, just show as a link
parts.push( parts.push(
<Link <Link
key={`nostr-${index}`} key={`nostr-${keyCounter++}`}
to={`/${nostrId}`} to={`/${nostrId}`}
className="text-blue-500 hover:underline" className="text-blue-500 hover:underline"
> >
{match} {fullMatch}
</Link> </Link>
); );
} }
} catch (e) { } catch (e) {
// If decoding fails, just render as text // If decoding fails, just render as text
parts.push(match); parts.push(fullMatch);
} }
} else if (hashtag) {
lastIndex = index + match.length; // Handle hashtags
return match; const tag = hashtag.slice(1); // Remove the #
});
};
// Process hashtags
const processHashtags = () => {
text.replace(hashtagRegex, (match, tag, index) => {
if (index > lastIndex) {
parts.push(text.substring(lastIndex, index));
}
parts.push( parts.push(
<Link <Link
key={`hashtag-${index}`} key={`hashtag-${keyCounter++}`}
to={`/t/${tag}`} to={`/t/${tag}`}
className="text-blue-500 hover:underline" className="text-blue-500 hover:underline"
> >
#{tag} {hashtag}
</Link> </Link>
); );
}
lastIndex = index + match.length; lastIndex = index + fullMatch.length;
return match; }
});
};
// Run all processors
processUrls();
processNostrRefs();
processHashtags();
// Add any remaining text // Add any remaining text
if (lastIndex < text.length) { if (lastIndex < text.length) {
@ -140,10 +103,7 @@ export function NoteContent({
parts.push(text); parts.push(text);
} }
setContent(parts); return parts;
};
processContent();
}, [event]); }, [event]);
return ( return (
@ -156,11 +116,12 @@ export function NoteContent({
// Helper component to display user mentions // Helper component to display user mentions
function NostrMention({ pubkey }: { pubkey: string }) { function NostrMention({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey); 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 ( return (
<Link <Link
to={`/p/${pubkey}`} to={`/${npub}`}
className="font-medium text-blue-500 hover:underline" className="font-medium text-blue-500 hover:underline"
> >
@{displayName} @{displayName}