NostrNoteView.tsx (4071B)
1 import { cn } from "@/lib/utils"; 2 import { useEffect, useMemo, useState } from "react"; 3 import Image from "next/image"; 4 5 export interface ParsedNote { 6 note: Note 7 parsed_content: ParsedContentBlock[]; 8 profile: Profile; 9 } 10 11 // This is a note, with the following constraints: 12 // Kind is always 0 13 // Content is a JSON string that can be parsed into a ProfileContent 14 export type Profile = Note; 15 16 export interface ProfileContent { 17 name: string; 18 about: string; 19 deleted: boolean; 20 display_name: string; 21 picture: string; // URL 22 banner: string; // URL 23 nip05: string; // Email-like address 24 lud16: string; // Email-like address 25 displayName: string; 26 // There are more fields, but we don't really care about them in this context 27 } 28 29 export interface ParsedContentBlock { 30 text?: string; 31 mention?: string; 32 hashtag?: string; 33 url?: string; 34 indexed_mention?: string; 35 invoice?: string; 36 } 37 38 export interface Note { 39 id: string; 40 pubkey: string; 41 created_at: number; 42 kind: number; 43 content: string; 44 tags: string[][]; 45 sig: string; 46 } 47 48 export interface NostrNoteViewProps { 49 note: ParsedNote; 50 className?: string; 51 style?: React.CSSProperties; 52 } 53 54 export function NostrNoteView(props: NostrNoteViewProps) { 55 const profileContent = useMemo(() => { // JSON parsing is expensive, so we memoize it 56 if (!props.note?.profile) 57 return {name: "nostrich", displayName: "nostrich", picture: "https://damus.io/img/no-profile.svg"} 58 return JSON.parse(props.note.profile.content) as ProfileContent; 59 }, [props.note?.profile?.content]); 60 const [timestamp, setTimestamp] = useState<string | null>(null); 61 const displayName = profileContent.displayName || profileContent.name; 62 63 useEffect(() => { 64 let created_at = props.note?.note?.created_at || 0; 65 setTimestamp(new Date(created_at * 1000).toLocaleDateString()); 66 }, [props.note?.note?.created_at]); 67 68 return ( 69 <div className={cn("p-6 bg-white rounded-3xl shadow-lg border border-black/20 text-left", props.className)} style={props.style}> 70 <div className="flex flex-col gap-y-3"> 71 <div className="flex items-center gap-x-3 text-xl"> 72 <Image 73 src={profileContent.picture} 74 className="w-12 h-12 rounded-full" 75 width={48} 76 height={48} 77 alt={displayName} 78 /> 79 <div className="flex flex-col"> 80 <div className="font-bold text-2xl text-gray-700"> 81 {displayName} 82 </div> 83 <div className="text-gray-400 text-sm"> 84 {timestamp} 85 </div> 86 </div> 87 </div> 88 <div className="text-gray-700 whitespace-pre-wrap break-words"> 89 {props.note?.parsed_content?.map((block, i) => { 90 return <NoteBlock key={i} block={block} /> 91 }) || null} 92 </div> 93 </div> 94 </div> 95 ) 96 } 97 98 export function NoteBlock({ block }: { block: ParsedContentBlock }): JSX.Element | null { 99 if (block.text) { 100 return <span>{block.text}</span>; 101 } else if (block.mention) { 102 return <span className="text-damuspink-600 hover:underline"><a target="_blank" href={mentionLinkAddress(block.mention)}>@{shortenMention(block.mention)}</a></span>; 103 } else if (block.url) { 104 return ( 105 /\.(jpg|jpeg|png|gif)$/.test(block.url) ? 106 <div className="my-2 flex items-center justify-center w-full h-auto max-h-96 overflow-hidden"> 107 <img src={block.url} className="w-full"/> 108 </div> 109 : 110 <a href={block.url} target="_blank" rel="noopener noreferrer">{block.url}</a> 111 ); 112 } else if (block.hashtag) { 113 return <span className="text-damuspink-600 hover:underline"><a href={"damus:t:" + block.hashtag}>#{block.hashtag}</a></span>; 114 } else { 115 return null; 116 } 117 } 118 119 function mentionLinkAddress(mention: string) { 120 if (mention.startsWith("note")) { 121 return "https://damus.io/" + mention; 122 } 123 else { 124 return "https://njump.me/" + mention; 125 } 126 } 127 128 function shortenMention(npub: string) { 129 return npub.substring(0, 8) + ":" + npub.substring(npub.length - 8); 130 }