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.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 { 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<React.ReactNode[]>([]);
}: 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(
<a
key={`url-${index}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
{url}
</a>
);
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(
<NostrMention key={`mention-${index}`} 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 {
// For other types, just show as a link
parts.push(
<Link
key={`nostr-${index}`}
to={`/${nostrId}`}
className="text-blue-500 hover:underline"
>
{match}
</Link>
);
}
} 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(
<Link
key={`hashtag-${index}`}
to={`/t/${tag}`}
className="text-blue-500 hover:underline"
>
#{tag}
</Link>
);
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(
<a
key={`url-${keyCounter++}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
{url}
</a>
);
} 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(
<NostrMention key={`mention-${keyCounter++}`} pubkey={pubkey} />
);
} else {
// For other types, just show as a link
parts.push(
<Link
key={`nostr-${keyCounter++}`}
to={`/${nostrId}`}
className="text-blue-500 hover:underline"
>
{fullMatch}
</Link>
);
}
} 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(
<Link
key={`hashtag-${keyCounter++}`}
to={`/t/${tag}`}
className="text-blue-500 hover:underline"
>
{hashtag}
</Link>
);
}
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 (
<div className={cn("whitespace-pre-wrap break-words", className)}>
{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 (
<Link
to={`/p/${pubkey}`}
to={`/${npub}`}
className="font-medium text-blue-500 hover:underline"
>
@{displayName}