mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 20:45:01 +01:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
165c427e5f | ||
|
|
a0e30aa197 | ||
|
|
3a8203d26e | ||
|
|
ffe848883e | ||
|
|
078a13c45d | ||
|
|
8a69d5bc6b | ||
|
|
6783ff23f9 | ||
|
|
72a264a01e |
6
.cursor/rules/mobile-first-ui-ux.mdc
Normal file
6
.cursor/rules/mobile-first-ui-ux.mdc
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: anything related to UI/UX
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
@@ -59,7 +59,8 @@
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"react-refresh"
|
||||
"react-refresh",
|
||||
"react-hooks"
|
||||
],
|
||||
"rules": {
|
||||
"react-refresh/only-export-components": [
|
||||
@@ -68,6 +69,7 @@
|
||||
"allowConstantExport": true
|
||||
}
|
||||
],
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -139,7 +139,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
||||
clearTimeout(fetchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [url]) // Only depend on url
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url]) // Only depend on url - title, description, tagsInput are intentionally checked but not dependencies
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -90,6 +90,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setHighlightVisibility
|
||||
} = useBookmarksUI({ settings })
|
||||
|
||||
// Close sidebar on mobile when route changes (e.g., clicking on blog posts in Explore)
|
||||
useEffect(() => {
|
||||
if (isMobile && isSidebarOpen) {
|
||||
toggleSidebar()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.pathname])
|
||||
|
||||
const {
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
|
||||
@@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { readingTime } from 'reading-time-estimator'
|
||||
import { hexToRgb } from '../utils/colorHelpers'
|
||||
@@ -32,6 +33,7 @@ interface ContentPanelProps {
|
||||
currentUserPubkey?: string
|
||||
followedPubkeys?: Set<string>
|
||||
settings?: UserSettings
|
||||
relayPool?: RelayPool | null
|
||||
// For highlight creation
|
||||
onTextSelection?: (text: string) => void
|
||||
onClearSelection?: () => void
|
||||
@@ -51,6 +53,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
highlightStyle = 'marker',
|
||||
highlightColor = '#ffff00',
|
||||
settings,
|
||||
relayPool,
|
||||
onHighlightClick,
|
||||
selectedHighlightId,
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||
@@ -59,7 +62,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
onTextSelection,
|
||||
onClearSelection
|
||||
}) => {
|
||||
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef } = useMarkdownToHTML(markdown)
|
||||
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
|
||||
|
||||
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
||||
html,
|
||||
@@ -116,7 +119,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
{markdown && (
|
||||
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{markdown}
|
||||
{processedMarkdown || markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -52,7 +52,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
|
||||
hasRemoteRelay,
|
||||
isConnecting
|
||||
})
|
||||
}, [offlineMode, localOnlyMode, connectedUrls.length, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
|
||||
}, [offlineMode, localOnlyMode, connectedUrls, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
|
||||
|
||||
// Don't show indicator when fully connected (but show when connecting)
|
||||
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
||||
|
||||
@@ -19,6 +19,7 @@ import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { HighlightButtonRef } from './HighlightButton'
|
||||
import { BookmarkReference } from '../utils/contentLoader'
|
||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||
import { useScrollDirection } from '../hooks/useScrollDirection'
|
||||
|
||||
interface ThreePaneLayoutProps {
|
||||
// Layout state
|
||||
@@ -86,6 +87,16 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
const isMobile = useIsMobile()
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
const highlightsRef = useRef<HTMLDivElement>(null)
|
||||
const mainPaneRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Detect scroll direction to hide/show mobile buttons
|
||||
// On mobile, scroll happens in the main pane, not on window
|
||||
const scrollDirection = useScrollDirection({
|
||||
threshold: 10,
|
||||
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed,
|
||||
elementRef: mainPaneRef
|
||||
})
|
||||
const showMobileButtons = scrollDirection !== 'down'
|
||||
|
||||
// Lock body scroll when mobile sidebar or highlights is open
|
||||
useEffect(() => {
|
||||
@@ -102,22 +113,24 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
|
||||
// Handle ESC key to close sidebar or highlights
|
||||
useEffect(() => {
|
||||
const { isSidebarOpen, isHighlightsCollapsed, onToggleSidebar, onToggleHighlightsPanel } = props
|
||||
|
||||
if (!isMobile) return
|
||||
if (!props.isSidebarOpen && props.isHighlightsCollapsed) return
|
||||
if (!isSidebarOpen && isHighlightsCollapsed) return
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (props.isSidebarOpen) {
|
||||
props.onToggleSidebar()
|
||||
} else if (!props.isHighlightsCollapsed) {
|
||||
props.onToggleHighlightsPanel()
|
||||
if (isSidebarOpen) {
|
||||
onToggleSidebar()
|
||||
} else if (!isHighlightsCollapsed) {
|
||||
onToggleHighlightsPanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed, props.onToggleSidebar, props.onToggleHighlightsPanel])
|
||||
}, [isMobile, props])
|
||||
|
||||
// Trap focus in sidebar when open on mobile
|
||||
useEffect(() => {
|
||||
@@ -204,7 +217,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
{/* Mobile bookmark button - only show when viewing article */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
|
||||
<button
|
||||
className="mobile-hamburger-btn"
|
||||
className={`mobile-hamburger-btn ${showMobileButtons ? 'visible' : 'hidden'}`}
|
||||
onClick={props.onToggleSidebar}
|
||||
aria-label="Open bookmarks"
|
||||
aria-expanded={props.isSidebarOpen}
|
||||
@@ -216,7 +229,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
{/* Mobile highlights button - only show when viewing article */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
|
||||
<button
|
||||
className="mobile-highlights-btn"
|
||||
className={`mobile-highlights-btn ${showMobileButtons ? 'visible' : 'hidden'}`}
|
||||
onClick={props.onToggleHighlightsPanel}
|
||||
aria-label="Open highlights"
|
||||
aria-expanded={!props.isHighlightsCollapsed}
|
||||
@@ -259,7 +272,10 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
<div className={`pane main ${isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'mobile-hidden' : ''}`}>
|
||||
<div
|
||||
ref={mainPaneRef}
|
||||
className={`pane main ${isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'mobile-hidden' : ''}`}
|
||||
>
|
||||
{props.showSettings ? (
|
||||
<Settings
|
||||
settings={props.settings}
|
||||
@@ -294,6 +310,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
currentUserPubkey={props.currentUserPubkey}
|
||||
followedPubkeys={props.followedPubkeys}
|
||||
settings={props.settings}
|
||||
relayPool={props.relayPool}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -110,7 +110,7 @@ export const useBookmarksData = ({
|
||||
handleFetchHighlights()
|
||||
}
|
||||
handleFetchContacts()
|
||||
}, [relayPool, activeAccount?.pubkey, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
||||
}, [relayPool, activeAccount, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
|
||||
@@ -1,34 +1,86 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { extractNaddrUris, replaceNostrUrisInMarkdown, replaceNostrUrisInMarkdownWithTitles } from '../utils/nostrUriResolver'
|
||||
import { fetchArticleTitles } from '../services/articleTitleResolver'
|
||||
|
||||
/**
|
||||
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
|
||||
* Also processes nostr: URIs in the markdown and resolves article titles
|
||||
*/
|
||||
export const useMarkdownToHTML = (markdown?: string): { renderedHtml: string, previewRef: React.RefObject<HTMLDivElement> } => {
|
||||
export const useMarkdownToHTML = (
|
||||
markdown?: string,
|
||||
relayPool?: RelayPool | null
|
||||
): {
|
||||
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
|
||||
}
|
||||
|
||||
console.log('📝 Converting markdown to HTML...')
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (previewRef.current) {
|
||||
const html = previewRef.current.innerHTML
|
||||
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
||||
setRenderedHtml(html)
|
||||
let isCancelled = false
|
||||
|
||||
const processMarkdown = async () => {
|
||||
// Extract all naddr references
|
||||
const naddrs = extractNaddrUris(markdown)
|
||||
|
||||
let processed: string
|
||||
|
||||
if (naddrs.length > 0 && relayPool) {
|
||||
// Fetch article titles for all naddrs
|
||||
try {
|
||||
const articleTitles = await fetchArticleTitles(relayPool, naddrs)
|
||||
|
||||
if (isCancelled) return
|
||||
|
||||
// Replace nostr URIs with resolved titles
|
||||
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
|
||||
console.log(`📚 Resolved ${articleTitles.size} article titles`)
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch article titles:', error)
|
||||
// Fall back to basic replacement
|
||||
processed = replaceNostrUrisInMarkdown(markdown)
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ markdownPreviewRef.current is null')
|
||||
// No articles to resolve, use basic replacement
|
||||
processed = replaceNostrUrisInMarkdown(markdown)
|
||||
}
|
||||
})
|
||||
|
||||
if (isCancelled) return
|
||||
|
||||
setProcessedMarkdown(processed)
|
||||
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [markdown])
|
||||
console.log('📝 Converting markdown to HTML...')
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (previewRef.current && !isCancelled) {
|
||||
const html = previewRef.current.innerHTML
|
||||
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
||||
setRenderedHtml(html)
|
||||
} else if (!isCancelled) {
|
||||
console.warn('⚠️ markdownPreviewRef.current is null')
|
||||
}
|
||||
})
|
||||
|
||||
return { renderedHtml, previewRef }
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}
|
||||
|
||||
processMarkdown()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [markdown, relayPool])
|
||||
|
||||
return { renderedHtml, previewRef, processedMarkdown }
|
||||
}
|
||||
|
||||
// Removed separate useMarkdownPreviewRef; use useMarkdownToHTML to obtain previewRef
|
||||
|
||||
70
src/hooks/useScrollDirection.ts
Normal file
70
src/hooks/useScrollDirection.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState, useEffect, RefObject } from 'react'
|
||||
|
||||
export type ScrollDirection = 'up' | 'down' | 'none'
|
||||
|
||||
interface UseScrollDirectionOptions {
|
||||
threshold?: number
|
||||
enabled?: boolean
|
||||
elementRef?: RefObject<HTMLElement>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect scroll direction on window or a specific element
|
||||
* @param options Configuration options
|
||||
* @param options.threshold Minimum scroll distance to trigger direction change (default: 10)
|
||||
* @param options.enabled Whether scroll detection is enabled (default: true)
|
||||
* @param options.elementRef Optional ref to a scrollable element (uses window if not provided)
|
||||
* @returns Current scroll direction ('up', 'down', or 'none')
|
||||
*/
|
||||
export function useScrollDirection({
|
||||
threshold = 10,
|
||||
enabled = true,
|
||||
elementRef
|
||||
}: UseScrollDirectionOptions = {}): ScrollDirection {
|
||||
const [scrollDirection, setScrollDirection] = useState<ScrollDirection>('none')
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const scrollElement = elementRef?.current || window
|
||||
const getScrollY = () => {
|
||||
if (elementRef?.current) {
|
||||
return elementRef.current.scrollTop
|
||||
}
|
||||
return window.scrollY
|
||||
}
|
||||
|
||||
let lastScrollY = getScrollY()
|
||||
let ticking = false
|
||||
|
||||
const updateScrollDirection = () => {
|
||||
const scrollY = getScrollY()
|
||||
|
||||
// Only update if scroll distance exceeds threshold
|
||||
if (Math.abs(scrollY - lastScrollY) < threshold) {
|
||||
ticking = false
|
||||
return
|
||||
}
|
||||
|
||||
setScrollDirection(scrollY > lastScrollY ? 'down' : 'up')
|
||||
lastScrollY = scrollY > 0 ? scrollY : 0
|
||||
ticking = false
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(updateScrollDirection)
|
||||
ticking = true
|
||||
}
|
||||
}
|
||||
|
||||
scrollElement.addEventListener('scroll', onScroll)
|
||||
|
||||
return () => {
|
||||
scrollElement.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [threshold, enabled, elementRef])
|
||||
|
||||
return scrollDirection
|
||||
}
|
||||
|
||||
@@ -156,7 +156,18 @@ body.mobile-sidebar-open {
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
transition: transform 0.2s ease, opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
.mobile-hamburger-btn.hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mobile-hamburger-btn.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.mobile-hamburger-btn:active {
|
||||
@@ -468,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;
|
||||
@@ -717,7 +745,18 @@ body.mobile-sidebar-open {
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
transition: transform 0.2s ease, opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
.mobile-highlights-btn.hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mobile-highlights-btn.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.mobile-highlights-btn:active {
|
||||
|
||||
87
src/services/articleTitleResolver.ts
Normal file
87
src/services/articleTitleResolver.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
const { getArticleTitle } = Helpers
|
||||
|
||||
/**
|
||||
* Fetch article title for a single naddr
|
||||
* Returns the title or null if not found/error
|
||||
*/
|
||||
export async function fetchArticleTitle(
|
||||
relayPool: RelayPool,
|
||||
naddr: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const decoded = nip19.decode(naddr)
|
||||
|
||||
if (decoded.type !== 'naddr') {
|
||||
return null
|
||||
}
|
||||
|
||||
const pointer = decoded.data as AddressPointer
|
||||
|
||||
// Define relays to query
|
||||
const relays = pointer.relays && pointer.relays.length > 0
|
||||
? pointer.relays
|
||||
: RELAYS
|
||||
|
||||
// Fetch the article event
|
||||
const filter = {
|
||||
kinds: [pointer.kind],
|
||||
authors: [pointer.pubkey],
|
||||
'#d': [pointer.identifier]
|
||||
}
|
||||
|
||||
const events = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relays, filter)
|
||||
.pipe(completeOnEose(), takeUntil(timer(5000)), toArray())
|
||||
)
|
||||
|
||||
if (events.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Sort by created_at and take the most recent
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
const article = events[0]
|
||||
|
||||
return getArticleTitle(article) || null
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch article title for', naddr, err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch titles for multiple naddrs in parallel
|
||||
* Returns a map of naddr -> title
|
||||
*/
|
||||
export async function fetchArticleTitles(
|
||||
relayPool: RelayPool,
|
||||
naddrs: string[]
|
||||
): Promise<Map<string, string>> {
|
||||
const titleMap = new Map<string, string>()
|
||||
|
||||
// Fetch all titles in parallel
|
||||
const results = await Promise.allSettled(
|
||||
naddrs.map(async (naddr) => {
|
||||
const title = await fetchArticleTitle(relayPool, naddr)
|
||||
return { naddr, title }
|
||||
})
|
||||
)
|
||||
|
||||
// Process results
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled' && result.value.title) {
|
||||
titleMap.set(result.value.naddr, result.value.title)
|
||||
}
|
||||
})
|
||||
|
||||
return titleMap
|
||||
}
|
||||
|
||||
188
src/utils/nostrUriResolver.tsx
Normal file
188
src/utils/nostrUriResolver.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { decode, npubEncode, noteEncode } 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
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all naddr (article) identifiers from text
|
||||
*/
|
||||
export function extractNaddrUris(text: string): string[] {
|
||||
const allUris = extractNostrUris(text)
|
||||
return allUris.filter(uri => {
|
||||
try {
|
||||
const decoded = decode(uri)
|
||||
return decoded.type === 'naddr'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a NIP-19 identifier and return a human-readable link
|
||||
* For articles (naddr), returns an internal app link
|
||||
* For other types, returns an external njump.me link
|
||||
*/
|
||||
export function createNostrLink(encoded: string): string {
|
||||
try {
|
||||
const decoded = decode(encoded)
|
||||
|
||||
switch (decoded.type) {
|
||||
case 'naddr':
|
||||
// For articles, link to our internal app route
|
||||
return `/a/${encoded}`
|
||||
case 'npub':
|
||||
case 'nprofile':
|
||||
case 'note':
|
||||
case 'nevent':
|
||||
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': {
|
||||
// For articles, show the identifier if available
|
||||
const identifier = decoded.data.identifier
|
||||
if (identifier && identifier.length > 0) {
|
||||
// Truncate long identifiers but keep them readable
|
||||
return identifier.length > 40 ? `${identifier.slice(0, 37)}...` : identifier
|
||||
}
|
||||
return 'nostr article'
|
||||
}
|
||||
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 markdown with proper markdown links, using resolved titles for articles
|
||||
* This converts: nostr:naddr1... to [Article Title](link)
|
||||
* @param markdown The markdown content to process
|
||||
* @param articleTitles Map of naddr -> title for resolved articles
|
||||
*/
|
||||
export function replaceNostrUrisInMarkdownWithTitles(
|
||||
markdown: string,
|
||||
articleTitles: Map<string, 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)
|
||||
|
||||
// For articles, use the resolved title if available
|
||||
try {
|
||||
const decoded = decode(encoded)
|
||||
if (decoded.type === 'naddr' && articleTitles.has(encoded)) {
|
||||
const title = articleTitles.get(encoded)!
|
||||
return `[${title}](${link})`
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore decode errors, fall through to default label
|
||||
}
|
||||
|
||||
// For other types or if title not resolved, use default label
|
||||
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: ReturnType<typeof decode> | null
|
||||
link: string
|
||||
label: string
|
||||
} {
|
||||
let decoded: ReturnType<typeof decode> | null = null
|
||||
try {
|
||||
decoded = decode(encoded)
|
||||
} catch (error) {
|
||||
// ignore decoding errors
|
||||
}
|
||||
|
||||
return {
|
||||
type: decoded?.type || 'unknown',
|
||||
decoded,
|
||||
link: createNostrLink(encoded),
|
||||
label: getNostrUriLabel(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user