Kind 1 text rendering

This commit is contained in:
Alex Gleason 2025-05-17 00:37:39 -05:00
parent 7ee419bc6d
commit aa2468b656
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
4 changed files with 195 additions and 3 deletions

View File

@ -338,6 +338,26 @@ const encrypted = await user.signer.nip44.encrypt(user.pubkey, "hello world");
const decrypted = await user.signer.nip44.decrypt(user.pubkey, encrypted) // "hello world" const decrypted = await user.signer.nip44.decrypt(user.pubkey, encrypted) // "hello world"
``` ```
### Rendering Kind 1 Text
If you need to render kind 1 text, use the `NoteContent` component:
```tsx
import { NoteContent } from "@/components/NoteContent";
export function Post(/* ...props */) {
// ...
return (
<CardContent className="pb-2">
<div className="whitespace-pre-wrap break-words">
<NoteContent event={post} className="text-sm" />
</div>
</CardContent>
);
}
```
## Development Practices ## Development Practices
- Uses React Query for data fetching and caching - Uses React Query for data fetching and caching

7
package-lock.json generated
View File

@ -47,6 +47,7 @@
"input-otp": "^1.2.4", "input-otp": "^1.2.4",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"nostr-tools": "^2.13.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@ -4881,9 +4882,9 @@
} }
}, },
"node_modules/nostr-tools": { "node_modules/nostr-tools": {
"version": "2.12.0", "version": "2.13.0",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.12.0.tgz", "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.13.0.tgz",
"integrity": "sha512-pUWEb020gTvt1XZvTa8AKNIHWFapjsv2NKyk43Ez2nnvz6WSXsrTFE0XtkNLSRBjPn6EpxumKeNiVzLz74jNSA==", "integrity": "sha512-A1arGsvpULqVK0NmZQqK1imwaCiPm8gcG/lo+cTax2NbNqBEYsuplbqAFdVqcGHEopmkByYbTwF76x25+oEbew==",
"license": "Unlicense", "license": "Unlicense",
"dependencies": { "dependencies": {
"@noble/ciphers": "^0.5.1", "@noble/ciphers": "^0.5.1",

View File

@ -52,6 +52,7 @@
"input-otp": "^1.2.4", "input-otp": "^1.2.4",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"nostr-tools": "^2.13.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",

View File

@ -0,0 +1,170 @@
import { useState, useEffect } from 'react';
import { type NostrEvent } from '@nostrify/nostrify';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useAuthor } from '@/hooks/useAuthor';
import { cn } from '@/lib/utils';
interface NoteContentProps {
event: NostrEvent;
className?: string;
}
export function NoteContent({
event,
className,
}: NoteContentProps) {
const [content, setContent] = useState<React.ReactNode[]>([]);
// Process the content to render mentions, links, etc.
useEffect(() => {
if (!event || event.kind !== 1) return;
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);
};
processContent();
}, [event]);
return (
<div className={cn("whitespace-pre-wrap break-words", className)}>
{content.length > 0 ? content : event.content}
</div>
);
}
// 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);
return (
<Link
to={`/p/${pubkey}`}
className="font-medium text-blue-500 hover:underline"
>
@{displayName}
</Link>
);
}