diff --git a/CONTEXT.md b/CONTEXT.md index ddcba64..f3b2b54 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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" ``` +### 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 ( + +
+ +
+
+ ); +} +``` + ## Development Practices - Uses React Query for data fetching and caching diff --git a/package-lock.json b/package-lock.json index 15137ed..f8e7f18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "input-otp": "^1.2.4", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", + "nostr-tools": "^2.13.0", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -4881,9 +4882,9 @@ } }, "node_modules/nostr-tools": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.12.0.tgz", - "integrity": "sha512-pUWEb020gTvt1XZvTa8AKNIHWFapjsv2NKyk43Ez2nnvz6WSXsrTFE0XtkNLSRBjPn6EpxumKeNiVzLz74jNSA==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.13.0.tgz", + "integrity": "sha512-A1arGsvpULqVK0NmZQqK1imwaCiPm8gcG/lo+cTax2NbNqBEYsuplbqAFdVqcGHEopmkByYbTwF76x25+oEbew==", "license": "Unlicense", "dependencies": { "@noble/ciphers": "^0.5.1", diff --git a/package.json b/package.json index ce14dfc..1ef453c 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "input-otp": "^1.2.4", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", + "nostr-tools": "^2.13.0", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", diff --git a/src/components/NoteContent.tsx b/src/components/NoteContent.tsx new file mode 100644 index 0000000..c56cc2f --- /dev/null +++ b/src/components/NoteContent.tsx @@ -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([]); + + // 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( + + {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); + }; + + processContent(); + }, [event]); + + return ( +
+ {content.length > 0 ? content : event.content} +
+ ); +} + +// 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 ( + + @{displayName} + + ); +} \ No newline at end of file