From 8a69d5bc6b7a129b8bfb92ca0acd82285166e793 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 11 Oct 2025 01:42:03 +0100 Subject: [PATCH] feat: resolve NIP-19 identifiers in article content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/ContentPanel.tsx | 4 +- src/hooks/useMarkdownToHTML.ts | 16 +++- src/index.css | 17 ++++ src/utils/nostrUriResolver.tsx | 134 ++++++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 src/utils/nostrUriResolver.tsx diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index c07cf8e7..7fb12347 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -59,7 +59,7 @@ const ContentPanel: React.FC = ({ 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 = ({ {markdown && (
- {markdown} + {processedMarkdown || markdown}
)} diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index 39afa8f0..8d97dd3c 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -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 } => { +export const useMarkdownToHTML = (markdown?: string): { + renderedHtml: string + previewRef: React.RefObject + processedMarkdown: string +} => { const previewRef = useRef(null) const [renderedHtml, setRenderedHtml] = useState('') + const [processedMarkdown, setProcessedMarkdown] = useState('') 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 diff --git a/src/index.css b/src/index.css index a339b94c..9249ea83 100644 --- a/src/index.css +++ b/src/index.css @@ -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; diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx new file mode 100644 index 00000000..30a74f45 --- /dev/null +++ b/src/utils/nostrUriResolver.tsx @@ -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 `${label}` + }) +} + +/** + * 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) + } +} +