7 changed files with 544 additions and 275 deletions
@ -0,0 +1,511 @@ |
|||||||
|
/** |
||||||
|
* Nostr address parser that converts nostr: addresses to embedded content |
||||||
|
*/ |
||||||
|
|
||||||
|
import { nip19 } from 'nostr-tools' |
||||||
|
import { EmbeddedMention, EmbeddedNote } from '@/components/Embedded' |
||||||
|
import ImageGallery from '@/components/ImageGallery' |
||||||
|
import { cleanUrl, isImage, isMedia } from '@/lib/url' |
||||||
|
import { getImetaInfosFromEvent } from '@/lib/event' |
||||||
|
import { TImetaInfo } from '@/types' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
export interface ParsedNostrContent { |
||||||
|
elements: Array<{ |
||||||
|
type: 'text' | 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'gallery' | 'url' | 'jumble-note' |
||||||
|
content: string |
||||||
|
bech32Id?: string |
||||||
|
nostrType?: 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note' |
||||||
|
mediaUrl?: string |
||||||
|
hashtag?: string |
||||||
|
wikilink?: string |
||||||
|
displayText?: string |
||||||
|
images?: TImetaInfo[] |
||||||
|
url?: string |
||||||
|
noteId?: string |
||||||
|
}> |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse content and convert nostr: addresses and media URLs to embedded components |
||||||
|
*/ |
||||||
|
export function parseNostrContent(content: string, event?: Event): ParsedNostrContent { |
||||||
|
const elements: ParsedNostrContent['elements'] = [] |
||||||
|
|
||||||
|
// Regex to match nostr: addresses that are not inside URLs or other contexts
|
||||||
|
const nostrRegex = /(?:^|\s|>|\[)nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)(?=\s|$|>|\]|,|\.|!|\?|;|:)/g |
||||||
|
|
||||||
|
// Regex to match all URLs (we'll filter by type later)
|
||||||
|
const urlRegex = /(https?:\/\/[^\s]+)/gi |
||||||
|
|
||||||
|
|
||||||
|
// Regex to match hashtags
|
||||||
|
const hashtagRegex = /#([a-zA-Z0-9_]+)/g |
||||||
|
|
||||||
|
// Regex to match wikilinks: [[target]] or [[target|display text]] or [[book:...]]
|
||||||
|
const wikilinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g |
||||||
|
|
||||||
|
// Regex to match Jumble note URLs: https://jumble.imwald.eu/notes/noteId
|
||||||
|
const jumbleNoteRegex = /(https:\/\/jumble\.imwald\.eu\/notes\/([a-zA-Z0-9]+))/g |
||||||
|
|
||||||
|
// Collect all matches (nostr, URLs, hashtags, wikilinks, and jumble notes) and sort by position
|
||||||
|
const allMatches: Array<{ |
||||||
|
type: 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'url' | 'jumble-note' |
||||||
|
match: RegExpExecArray |
||||||
|
start: number |
||||||
|
end: number |
||||||
|
url?: string |
||||||
|
hashtag?: string |
||||||
|
wikilink?: string |
||||||
|
displayText?: string |
||||||
|
noteId?: string |
||||||
|
}> = [] |
||||||
|
|
||||||
|
// Find nostr matches
|
||||||
|
let nostrMatch |
||||||
|
while ((nostrMatch = nostrRegex.exec(content)) !== null) { |
||||||
|
if (isNostrAddressInValidContext(content, nostrMatch.index, nostrMatch.index + nostrMatch[0].length)) { |
||||||
|
allMatches.push({ |
||||||
|
type: 'nostr', |
||||||
|
match: nostrMatch, |
||||||
|
start: nostrMatch.index, |
||||||
|
end: nostrMatch.index + nostrMatch[0].length |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Find URL matches and categorize them
|
||||||
|
let urlMatch |
||||||
|
while ((urlMatch = urlRegex.exec(content)) !== null) { |
||||||
|
const url = urlMatch[1] |
||||||
|
const cleanedUrl = cleanUrl(url) |
||||||
|
|
||||||
|
// Check if it's an image
|
||||||
|
if (isImage(cleanedUrl)) { |
||||||
|
allMatches.push({ |
||||||
|
type: 'image', |
||||||
|
match: urlMatch, |
||||||
|
start: urlMatch.index, |
||||||
|
end: urlMatch.index + urlMatch[0].length, |
||||||
|
url: cleanedUrl |
||||||
|
}) |
||||||
|
} |
||||||
|
// Check if it's media (video/audio)
|
||||||
|
else if (isMedia(cleanedUrl)) { |
||||||
|
// Determine if it's video or audio based on extension
|
||||||
|
const isVideo = /\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv|m4v)$/i.test(cleanedUrl) |
||||||
|
allMatches.push({ |
||||||
|
type: isVideo ? 'video' : 'audio', |
||||||
|
match: urlMatch, |
||||||
|
start: urlMatch.index, |
||||||
|
end: urlMatch.index + urlMatch[0].length, |
||||||
|
url: cleanedUrl |
||||||
|
}) |
||||||
|
} |
||||||
|
// Regular URL (not media)
|
||||||
|
else { |
||||||
|
allMatches.push({ |
||||||
|
type: 'url', |
||||||
|
match: urlMatch, |
||||||
|
start: urlMatch.index, |
||||||
|
end: urlMatch.index + urlMatch[0].length, |
||||||
|
url: cleanedUrl |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Find hashtag matches
|
||||||
|
let hashtagMatch |
||||||
|
while ((hashtagMatch = hashtagRegex.exec(content)) !== null) { |
||||||
|
allMatches.push({ |
||||||
|
type: 'hashtag', |
||||||
|
match: hashtagMatch, |
||||||
|
start: hashtagMatch.index, |
||||||
|
end: hashtagMatch.index + hashtagMatch[0].length, |
||||||
|
hashtag: hashtagMatch[1] |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// Find wikilink matches
|
||||||
|
let wikilinkMatch |
||||||
|
while ((wikilinkMatch = wikilinkRegex.exec(content)) !== null) { |
||||||
|
allMatches.push({ |
||||||
|
type: 'wikilink', |
||||||
|
match: wikilinkMatch, |
||||||
|
start: wikilinkMatch.index, |
||||||
|
end: wikilinkMatch.index + wikilinkMatch[0].length, |
||||||
|
wikilink: wikilinkMatch[1], |
||||||
|
displayText: wikilinkMatch[2] || wikilinkMatch[1] |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// Find Jumble note URL matches
|
||||||
|
let jumbleNoteMatch |
||||||
|
while ((jumbleNoteMatch = jumbleNoteRegex.exec(content)) !== null) { |
||||||
|
allMatches.push({ |
||||||
|
type: 'jumble-note', |
||||||
|
match: jumbleNoteMatch, |
||||||
|
start: jumbleNoteMatch.index, |
||||||
|
end: jumbleNoteMatch.index + jumbleNoteMatch[0].length, |
||||||
|
url: jumbleNoteMatch[1], |
||||||
|
noteId: jumbleNoteMatch[2] |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// Sort matches by position
|
||||||
|
allMatches.sort((a, b) => a.start - b.start) |
||||||
|
|
||||||
|
let lastIndex = 0 |
||||||
|
|
||||||
|
for (const { type, match, start, end, url, hashtag, wikilink, displayText, noteId } of allMatches) { |
||||||
|
// Add text before the match
|
||||||
|
if (start > lastIndex) { |
||||||
|
const textContent = content.slice(lastIndex, start) |
||||||
|
if (textContent) { |
||||||
|
elements.push({ |
||||||
|
type: 'text', |
||||||
|
content: textContent |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (type === 'nostr') { |
||||||
|
const bech32Id = match[1] |
||||||
|
const nostrType = getNostrType(bech32Id) |
||||||
|
|
||||||
|
elements.push({ |
||||||
|
type: 'nostr', |
||||||
|
content: match[0], |
||||||
|
bech32Id, |
||||||
|
nostrType: nostrType || undefined |
||||||
|
}) |
||||||
|
} else if (['image', 'video', 'audio'].includes(type) && url) { |
||||||
|
elements.push({ |
||||||
|
type: type as 'image' | 'video' | 'audio', |
||||||
|
content: match[0], |
||||||
|
mediaUrl: url |
||||||
|
}) |
||||||
|
} else if (type === 'hashtag' && hashtag) { |
||||||
|
elements.push({ |
||||||
|
type: 'hashtag', |
||||||
|
content: match[0], |
||||||
|
hashtag: hashtag |
||||||
|
}) |
||||||
|
} else if (type === 'wikilink' && wikilink) { |
||||||
|
elements.push({ |
||||||
|
type: 'wikilink', |
||||||
|
content: match[0], |
||||||
|
wikilink: wikilink, |
||||||
|
displayText: displayText |
||||||
|
}) |
||||||
|
} else if (type === 'url' && url) { |
||||||
|
elements.push({ |
||||||
|
type: 'url', |
||||||
|
content: match[0], |
||||||
|
url: url |
||||||
|
}) |
||||||
|
} else if (type === 'jumble-note' && url && noteId) { |
||||||
|
elements.push({ |
||||||
|
type: 'jumble-note', |
||||||
|
content: match[0], |
||||||
|
url: url, |
||||||
|
noteId: noteId |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
lastIndex = end |
||||||
|
} |
||||||
|
|
||||||
|
// Add remaining text after the last match
|
||||||
|
if (lastIndex < content.length) { |
||||||
|
const textContent = content.slice(lastIndex) |
||||||
|
if (textContent) { |
||||||
|
elements.push({ |
||||||
|
type: 'text', |
||||||
|
content: textContent |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Collect all images from content and imeta tags
|
||||||
|
const allImages: TImetaInfo[] = [] |
||||||
|
const processedUrls = new Set<string>() |
||||||
|
|
||||||
|
// Add imeta images first (they have priority) - only actual images, not videos
|
||||||
|
if (event) { |
||||||
|
const imetaInfos = getImetaInfosFromEvent(event) |
||||||
|
imetaInfos.forEach(imageInfo => { |
||||||
|
// Only add if it's actually an image (not video/audio)
|
||||||
|
if (!processedUrls.has(imageInfo.url) && isImage(imageInfo.url)) { |
||||||
|
allImages.push(imageInfo) |
||||||
|
processedUrls.add(imageInfo.url) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// Add content images that aren't already in imeta
|
||||||
|
elements.forEach(element => { |
||||||
|
if (element.type === 'image' && element.mediaUrl) { |
||||||
|
if (!processedUrls.has(element.mediaUrl)) { |
||||||
|
allImages.push({ url: element.mediaUrl, pubkey: event?.pubkey }) |
||||||
|
processedUrls.add(element.mediaUrl) |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
// Process imeta videos separately
|
||||||
|
if (event) { |
||||||
|
const imetaInfos = getImetaInfosFromEvent(event) |
||||||
|
imetaInfos.forEach(imetaInfo => { |
||||||
|
// Check if it's a video that hasn't been processed yet
|
||||||
|
if (isMedia(imetaInfo.url) && !isImage(imetaInfo.url)) { |
||||||
|
// Check if this video is already in elements
|
||||||
|
const alreadyProcessed = elements.some(element =>
|
||||||
|
element.type === 'video' && element.mediaUrl === imetaInfo.url |
||||||
|
) |
||||||
|
|
||||||
|
if (!alreadyProcessed) { |
||||||
|
// Determine if it's video or audio based on extension
|
||||||
|
const isVideo = /\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv|m4v)$/i.test(imetaInfo.url) |
||||||
|
elements.push({ |
||||||
|
type: isVideo ? 'video' : 'audio', |
||||||
|
content: imetaInfo.url, |
||||||
|
mediaUrl: imetaInfo.url |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// If we have images, add a gallery element and remove individual image elements
|
||||||
|
if (allImages.length > 0) { |
||||||
|
// Remove individual image elements
|
||||||
|
const filteredElements = elements.filter(element => element.type !== 'image') |
||||||
|
|
||||||
|
// Add gallery element at the end
|
||||||
|
filteredElements.push({ |
||||||
|
type: 'gallery', |
||||||
|
content: '', |
||||||
|
images: allImages |
||||||
|
}) |
||||||
|
|
||||||
|
return { elements: filteredElements } |
||||||
|
} |
||||||
|
|
||||||
|
// If no special content found, return the whole content as text
|
||||||
|
if (elements.length === 0) { |
||||||
|
elements.push({ |
||||||
|
type: 'text', |
||||||
|
content |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
return { elements } |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a nostr address is in a valid context (not inside URLs, etc.) |
||||||
|
*/ |
||||||
|
function isNostrAddressInValidContext(content: string, start: number, _end: number): boolean { |
||||||
|
// Don't parse if it's inside a URL (preceded by http://, https://, or www.)
|
||||||
|
const beforeContext = content.slice(Math.max(0, start - 20), start) |
||||||
|
if (beforeContext.match(/(https?:\/\/|www\.)[^\s]*$/)) { |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
// Don't parse if it's inside markdown links [text](url) or images 
|
||||||
|
const beforeMatch = content.slice(Math.max(0, start - 10), start) |
||||||
|
if (beforeMatch.match(/[!]?\[[^\]]*\]\([^)]*$/)) { |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
// Don't parse if it's inside HTML tags
|
||||||
|
const beforeTag = content.slice(Math.max(0, start - 50), start) |
||||||
|
if (beforeTag.match(/<[^>]*$/)) { |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
// Don't parse if it's inside code blocks or inline code
|
||||||
|
const beforeCode = content.slice(Math.max(0, start - 10), start) |
||||||
|
if (beforeCode.match(/`[^`]*$/)) { |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
// Don't parse if it's inside a code block (```)
|
||||||
|
const beforeCodeBlock = content.slice(0, start) |
||||||
|
const codeBlockMatches = beforeCodeBlock.match(/```/g) |
||||||
|
if (codeBlockMatches && codeBlockMatches.length % 2 === 1) { |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get the nostr type from a bech32 ID |
||||||
|
*/ |
||||||
|
function getNostrType(bech32Id: string): 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note' | null { |
||||||
|
try { |
||||||
|
const { type } = nip19.decode(bech32Id) |
||||||
|
if (['npub', 'nprofile', 'nevent', 'naddr', 'note'].includes(type)) { |
||||||
|
return type as 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note' |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Invalid bech32 ID:', bech32Id, error) |
||||||
|
} |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Render parsed nostr content as React elements |
||||||
|
*/ |
||||||
|
export function renderNostrContent(parsedContent: ParsedNostrContent, className?: string): JSX.Element { |
||||||
|
return ( |
||||||
|
<div className={className}> |
||||||
|
{parsedContent.elements.map((element, index) => { |
||||||
|
if (element.type === 'text') { |
||||||
|
return ( |
||||||
|
<span key={index} className="whitespace-pre-wrap break-words"> |
||||||
|
{element.content} |
||||||
|
</span> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (element.type === 'gallery' && element.images) { |
||||||
|
return ( |
||||||
|
<div key={index} className="my-2"> |
||||||
|
<ImageGallery |
||||||
|
images={element.images} |
||||||
|
className="max-w-[400px]" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (element.type === 'video' && element.mediaUrl) { |
||||||
|
return ( |
||||||
|
<video |
||||||
|
key={index} |
||||||
|
src={element.mediaUrl} |
||||||
|
controls |
||||||
|
className="max-w-[400px] w-full h-auto rounded-lg my-2 block" |
||||||
|
preload="metadata" |
||||||
|
onError={(e) => { |
||||||
|
// Fallback to text if video fails to load
|
||||||
|
const target = e.target as HTMLVideoElement |
||||||
|
target.style.display = 'none' |
||||||
|
const textSpan = document.createElement('span') |
||||||
|
textSpan.className = 'whitespace-pre-wrap break-words text-primary hover:underline' |
||||||
|
textSpan.textContent = element.content |
||||||
|
target.parentNode?.insertBefore(textSpan, target.nextSibling) |
||||||
|
}} |
||||||
|
> |
||||||
|
Your browser does not support the video tag. |
||||||
|
</video> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (element.type === 'audio' && element.mediaUrl) { |
||||||
|
return ( |
||||||
|
<audio |
||||||
|
key={index} |
||||||
|
src={element.mediaUrl} |
||||||
|
controls |
||||||
|
className="w-full my-2 block" |
||||||
|
preload="metadata" |
||||||
|
onError={(e) => { |
||||||
|
// Fallback to text if audio fails to load
|
||||||
|
const target = e.target as HTMLAudioElement |
||||||
|
target.style.display = 'none' |
||||||
|
const textSpan = document.createElement('span') |
||||||
|
textSpan.className = 'whitespace-pre-wrap break-words text-primary hover:underline' |
||||||
|
textSpan.textContent = element.content |
||||||
|
target.parentNode?.insertBefore(textSpan, target.nextSibling) |
||||||
|
}} |
||||||
|
> |
||||||
|
Your browser does not support the audio tag. |
||||||
|
</audio> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (element.type === 'hashtag' && element.hashtag) { |
||||||
|
const normalizedHashtag = element.hashtag.toLowerCase() |
||||||
|
return ( |
||||||
|
<a |
||||||
|
key={index} |
||||||
|
href={`/notes?t=${normalizedHashtag}`} |
||||||
|
className="text-primary hover:text-primary/80 hover:underline break-words" |
||||||
|
> |
||||||
|
#{element.hashtag} |
||||||
|
</a> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (element.type === 'wikilink' && element.wikilink && element.displayText) { |
||||||
|
const normalizedWikilink = element.wikilink.toLowerCase() |
||||||
|
return ( |
||||||
|
<a |
||||||
|
key={index} |
||||||
|
href={`/wiki/${encodeURIComponent(normalizedWikilink)}`} |
||||||
|
className="text-primary hover:text-primary/80 hover:underline break-words" |
||||||
|
> |
||||||
|
{element.displayText} |
||||||
|
</a> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (element.type === 'url' && element.url) { |
||||||
|
return ( |
||||||
|
<a |
||||||
|
key={index} |
||||||
|
href={element.url} |
||||||
|
target="_blank" |
||||||
|
rel="noopener noreferrer" |
||||||
|
className="text-primary hover:text-primary/80 hover:underline break-words" |
||||||
|
> |
||||||
|
{element.content} |
||||||
|
</a> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (element.type === 'jumble-note' && element.noteId) { |
||||||
|
return ( |
||||||
|
<EmbeddedNote |
||||||
|
key={index} |
||||||
|
noteId={element.noteId} |
||||||
|
className="not-prose inline-block" |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (element.type === 'nostr' && element.bech32Id && element.nostrType) { |
||||||
|
// Render as embedded content
|
||||||
|
if (element.nostrType === 'npub' || element.nostrType === 'nprofile') { |
||||||
|
return ( |
||||||
|
<EmbeddedMention
|
||||||
|
key={index}
|
||||||
|
userId={element.bech32Id}
|
||||||
|
className="not-prose inline-block"
|
||||||
|
/> |
||||||
|
) |
||||||
|
} else if (['nevent', 'naddr', 'note'].includes(element.nostrType)) { |
||||||
|
return ( |
||||||
|
<EmbeddedNote
|
||||||
|
key={index}
|
||||||
|
noteId={element.bech32Id}
|
||||||
|
className="not-prose inline-block"
|
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Fallback to text if something goes wrong
|
||||||
|
return ( |
||||||
|
<span key={index} className="whitespace-pre-wrap break-words"> |
||||||
|
{element.content} |
||||||
|
</span> |
||||||
|
) |
||||||
|
})} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
Loading…
Reference in new issue