feat: resolve NIP-19 identifiers in article content

- Add nostrUriResolver utility to detect and replace nostr: URIs
- Support npub, note, nprofile, nevent, and naddr identifiers
- Convert nostr: URIs to clickable njump.me links
- Process markdown before rendering to handle nostr mentions
- Add CSS styling for nostr-uri-link class
- Implements NIP-19 and NIP-27 (nostr: URI scheme)

Nostr-native articles can now contain references like:
- nostr:npub1... → @npub1abc...
- nostr:note1... → note:note1abc...
- nostr:naddr1... → article:identifier

All identifiers become clickable links to njump.me
This commit is contained in:
Gigi
2025-10-11 01:42:03 +01:00
parent 6783ff23f9
commit 8a69d5bc6b
4 changed files with 167 additions and 4 deletions

View File

@@ -59,7 +59,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onTextSelection,
onClearSelection
}) => {
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef } = useMarkdownToHTML(markdown)
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown)
const { finalHtml, relevantHighlights } = useHighlightedContent({
html,
@@ -116,7 +116,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
{markdown && (
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{markdown}
{processedMarkdown || markdown}
</ReactMarkdown>
</div>
)}

View File

@@ -1,18 +1,30 @@
import React, { useState, useEffect, useRef } from 'react'
import { replaceNostrUrisInMarkdown } from '../utils/nostrUriResolver'
/**
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
* Also processes nostr: URIs in the markdown
*/
export const useMarkdownToHTML = (markdown?: string): { renderedHtml: string, previewRef: React.RefObject<HTMLDivElement> } => {
export const useMarkdownToHTML = (markdown?: string): {
renderedHtml: string
previewRef: React.RefObject<HTMLDivElement>
processedMarkdown: string
} => {
const previewRef = useRef<HTMLDivElement>(null)
const [renderedHtml, setRenderedHtml] = useState<string>('')
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
useEffect(() => {
if (!markdown) {
setRenderedHtml('')
setProcessedMarkdown('')
return
}
// Process nostr: URIs in markdown before rendering
const processed = replaceNostrUrisInMarkdown(markdown)
setProcessedMarkdown(processed)
console.log('📝 Converting markdown to HTML...')
const rafId = requestAnimationFrame(() => {
@@ -28,7 +40,7 @@ export const useMarkdownToHTML = (markdown?: string): { renderedHtml: string, pr
return () => cancelAnimationFrame(rafId)
}, [markdown])
return { renderedHtml, previewRef }
return { renderedHtml, previewRef, processedMarkdown }
}
// Removed separate useMarkdownPreviewRef; use useMarkdownToHTML to obtain previewRef

View File

@@ -479,6 +479,23 @@ body.mobile-sidebar-open {
text-decoration: underline;
}
.nostr-uri-link {
color: #007bff;
text-decoration: none;
font-family: monospace;
background: #f8f9fa;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.9em;
word-wrap: break-word;
overflow-wrap: break-word;
}
.nostr-uri-link:hover {
background: #e9ecef;
text-decoration: underline;
}
.logout-button {
background: #dc3545;
color: white;

View File

@@ -0,0 +1,134 @@
import React from 'react'
import { decode, npubEncode, noteEncode } from 'nostr-tools/nip19'
import { DecodeResult } from 'nostr-tools/nip19'
/**
* Regular expression to match nostr: URIs and bare NIP-19 identifiers
* Matches: nostr:npub1..., nostr:note1..., nostr:nprofile1..., nostr:nevent1..., nostr:naddr1...
* Also matches bare identifiers without the nostr: prefix
*/
const NOSTR_URI_REGEX = /(?:nostr:)?((npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi
/**
* Extract all nostr URIs from text
*/
export function extractNostrUris(text: string): string[] {
const matches = text.match(NOSTR_URI_REGEX)
if (!matches) return []
// Extract just the NIP-19 identifier (without nostr: prefix)
return matches.map(match => {
const cleanMatch = match.replace(/^nostr:/, '')
return cleanMatch
})
}
/**
* Decode a NIP-19 identifier and return a human-readable link
*/
export function createNostrLink(encoded: string): string {
try {
const decoded = decode(encoded)
switch (decoded.type) {
case 'npub':
return `https://njump.me/${encoded}`
case 'nprofile':
return `https://njump.me/${encoded}`
case 'note':
return `https://njump.me/${encoded}`
case 'nevent':
return `https://njump.me/${encoded}`
case 'naddr':
return `https://njump.me/${encoded}`
default:
return `https://njump.me/${encoded}`
}
} catch (error) {
console.warn('Failed to decode nostr URI:', encoded, error)
return `https://njump.me/${encoded}`
}
}
/**
* Get a display label for a nostr URI
*/
export function getNostrUriLabel(encoded: string): string {
try {
const decoded = decode(encoded)
switch (decoded.type) {
case 'npub':
return `@${encoded.slice(0, 12)}...`
case 'nprofile':
const npub = npubEncode(decoded.data.pubkey)
return `@${npub.slice(0, 12)}...`
case 'note':
return `note:${encoded.slice(5, 12)}...`
case 'nevent':
const note = noteEncode(decoded.data.id)
return `note:${note.slice(5, 12)}...`
case 'naddr':
return `article:${decoded.data.identifier || 'untitled'}`
default:
return encoded.slice(0, 16) + '...'
}
} catch (error) {
return encoded.slice(0, 16) + '...'
}
}
/**
* Replace nostr: URIs in markdown with proper markdown links
* This converts: nostr:npub1... to [label](link)
*/
export function replaceNostrUrisInMarkdown(markdown: string): string {
return markdown.replace(NOSTR_URI_REGEX, (match) => {
// Extract just the NIP-19 identifier (without nostr: prefix)
const encoded = match.replace(/^nostr:/, '')
const link = createNostrLink(encoded)
const label = getNostrUriLabel(encoded)
return `[${label}](${link})`
})
}
/**
* Replace nostr: URIs in HTML with clickable links
* This is used when processing HTML content directly
*/
export function replaceNostrUrisInHTML(html: string): string {
return html.replace(NOSTR_URI_REGEX, (match) => {
// Extract just the NIP-19 identifier (without nostr: prefix)
const encoded = match.replace(/^nostr:/, '')
const link = createNostrLink(encoded)
const label = getNostrUriLabel(encoded)
return `<a href="${link}" class="nostr-uri-link" target="_blank" rel="noopener noreferrer">${label}</a>`
})
}
/**
* Get decoded information from a nostr URI for detailed display
*/
export function getNostrUriInfo(encoded: string): {
type: string
decoded: DecodeResult | null
link: string
label: string
} {
let decoded: DecodeResult | null = null
try {
decoded = decode(encoded)
} catch (error) {
// ignore decoding errors
}
return {
type: decoded?.type || 'unknown',
decoded,
link: createNostrLink(encoded),
label: getNostrUriLabel(encoded)
}
}