mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
165c427e5f | ||
|
|
a0e30aa197 | ||
|
|
3a8203d26e | ||
|
|
ffe848883e | ||
|
|
078a13c45d | ||
|
|
8a69d5bc6b | ||
|
|
6783ff23f9 | ||
|
|
72a264a01e | ||
|
|
5a67be8096 | ||
|
|
9a929a6be4 | ||
|
|
e0ca010026 | ||
|
|
8bd5d7aadf | ||
|
|
9115c38cde | ||
|
|
0c7c1d54d9 | ||
|
|
d529d83eb8 | ||
|
|
a3127c7836 | ||
|
|
4d5fe1f425 | ||
|
|
c7a4de9786 |
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.)
|
||||||
29
CHANGELOG.md
29
CHANGELOG.md
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.4.1] - 2025-10-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Long article summaries overlapping with hero image content on mobile devices
|
||||||
|
- Article summary now moves below hero image on mobile when longer than 150 characters
|
||||||
|
- Article summary line clamp reduced from 3 to 2 lines on mobile for better space utilization
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Hero image rendering on mobile now uses zoom-to-fit approach with viewport-based sizing
|
||||||
|
- Hero image height on mobile set to 50vh (constrained between 280px-400px)
|
||||||
|
- Improved image cropping with center positioning for better visual presentation
|
||||||
|
- Optimized reader header overlay padding and title sizing on mobile
|
||||||
|
|
||||||
|
## [0.4.0] - 2025-10-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Mobile-responsive design with overlay sidebar drawer
|
- Mobile-responsive design with overlay sidebar drawer
|
||||||
- Media query hooks for responsive behavior (`useIsMobile`, `useIsTablet`, `useIsCoarsePointer`)
|
- Media query hooks for responsive behavior (`useIsMobile`, `useIsTablet`, `useIsCoarsePointer`)
|
||||||
@@ -19,12 +34,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Mobile-optimized modals (full-screen sheet style)
|
- Mobile-optimized modals (full-screen sheet style)
|
||||||
- Mobile-optimized toast notifications (bottom position)
|
- Mobile-optimized toast notifications (bottom position)
|
||||||
- Dynamic viewport height support (100dvh)
|
- Dynamic viewport height support (100dvh)
|
||||||
|
- Mobile highlights panel as overlay with toggle button
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Sidebar now displays as overlay drawer on mobile (≤768px)
|
- Sidebar now displays as overlay drawer on mobile (≤768px)
|
||||||
- Highlights panel hidden on mobile for better content focus
|
- Highlights panel hidden on mobile for better content focus
|
||||||
- Sidebar auto-closes when selecting content on mobile
|
- Sidebar auto-closes when selecting content on mobile
|
||||||
- Hover effects disabled on touch devices
|
- Hover effects disabled on touch devices
|
||||||
|
- Replace hamburger icon with bookmark icon on mobile
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Ensure bookmarks container fills mobile sidepane properly
|
||||||
|
- Restore desktop grid layout for highlights panel
|
||||||
|
- Improve empty state and loading visibility in mobile sidepanes
|
||||||
|
- Add flex properties to mobile bookmark containers for proper filling
|
||||||
|
- Force bookmarks pane expanded on mobile and ensure highlights pane sits above content on desktop
|
||||||
|
- Reduce mobile backdrop opacity and ensure sidepanes appear above it
|
||||||
|
- Replace any type with proper bookmark interface for linter compliance
|
||||||
|
|
||||||
## [0.3.8] - 2025-10-10
|
## [0.3.8] - 2025-10-10
|
||||||
|
|
||||||
@@ -564,6 +590,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Optimize relay usage following applesauce-relay best practices
|
- Optimize relay usage following applesauce-relay best practices
|
||||||
- Use applesauce-react event models for better profile handling
|
- Use applesauce-react event models for better profile handling
|
||||||
|
|
||||||
|
[0.4.0]: https://github.com/dergigi/boris/compare/v0.3.8...v0.4.0
|
||||||
|
[0.3.8]: https://github.com/dergigi/boris/compare/v0.3.7...v0.3.8
|
||||||
|
[0.3.7]: https://github.com/dergigi/boris/compare/v0.3.6...v0.3.7
|
||||||
[0.3.6]: https://github.com/dergigi/boris/compare/v0.3.5...v0.3.6
|
[0.3.6]: https://github.com/dergigi/boris/compare/v0.3.5...v0.3.6
|
||||||
[0.3.5]: https://github.com/dergigi/boris/compare/v0.3.4...v0.3.5
|
[0.3.5]: https://github.com/dergigi/boris/compare/v0.3.4...v0.3.5
|
||||||
[0.3.4]: https://github.com/dergigi/boris/compare/v0.3.3...v0.3.4
|
[0.3.4]: https://github.com/dergigi/boris/compare/v0.3.3...v0.3.4
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.3.8",
|
"version": "0.4.2",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://read.withboris.com/",
|
"homepage": "https://read.withboris.com/",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -59,7 +59,8 @@
|
|||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@typescript-eslint",
|
"@typescript-eslint",
|
||||||
"react-refresh"
|
"react-refresh",
|
||||||
|
"react-hooks"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"react-refresh/only-export-components": [
|
"react-refresh/only-export-components": [
|
||||||
@@ -68,6 +69,7 @@
|
|||||||
"allowConstantExport": true
|
"allowConstantExport": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -139,7 +139,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
|||||||
clearTimeout(fetchTimeoutRef.current)
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>No bookmarks found.</p>
|
<p>No bookmarks found.</p>
|
||||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||||
|
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bookmarks-list">
|
<div className="bookmarks-list">
|
||||||
|
|||||||
@@ -90,6 +90,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
setHighlightVisibility
|
setHighlightVisibility
|
||||||
} = useBookmarksUI({ settings })
|
} = 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 {
|
const {
|
||||||
bookmarks,
|
bookmarks,
|
||||||
bookmarksLoading,
|
bookmarksLoading,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown'
|
|||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { readingTime } from 'reading-time-estimator'
|
import { readingTime } from 'reading-time-estimator'
|
||||||
import { hexToRgb } from '../utils/colorHelpers'
|
import { hexToRgb } from '../utils/colorHelpers'
|
||||||
@@ -32,6 +33,7 @@ interface ContentPanelProps {
|
|||||||
currentUserPubkey?: string
|
currentUserPubkey?: string
|
||||||
followedPubkeys?: Set<string>
|
followedPubkeys?: Set<string>
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
|
relayPool?: RelayPool | null
|
||||||
// For highlight creation
|
// For highlight creation
|
||||||
onTextSelection?: (text: string) => void
|
onTextSelection?: (text: string) => void
|
||||||
onClearSelection?: () => void
|
onClearSelection?: () => void
|
||||||
@@ -51,6 +53,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
highlightStyle = 'marker',
|
highlightStyle = 'marker',
|
||||||
highlightColor = '#ffff00',
|
highlightColor = '#ffff00',
|
||||||
settings,
|
settings,
|
||||||
|
relayPool,
|
||||||
onHighlightClick,
|
onHighlightClick,
|
||||||
selectedHighlightId,
|
selectedHighlightId,
|
||||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||||
@@ -59,7 +62,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
onTextSelection,
|
onTextSelection,
|
||||||
onClearSelection
|
onClearSelection
|
||||||
}) => {
|
}) => {
|
||||||
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef } = useMarkdownToHTML(markdown)
|
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
|
||||||
|
|
||||||
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
||||||
html,
|
html,
|
||||||
@@ -74,7 +77,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
followedPubkeys
|
followedPubkeys
|
||||||
})
|
})
|
||||||
|
|
||||||
const { contentRef, handleMouseUp } = useHighlightInteractions({
|
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
|
||||||
onHighlightClick,
|
onHighlightClick,
|
||||||
selectedHighlightId,
|
selectedHighlightId,
|
||||||
onTextSelection,
|
onTextSelection,
|
||||||
@@ -116,7 +119,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
{markdown && (
|
{markdown && (
|
||||||
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
{markdown}
|
{processedMarkdown || markdown}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -138,7 +141,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="reader-markdown"
|
className="reader-markdown"
|
||||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleSelectionEnd}
|
||||||
|
onTouchEnd={handleSelectionEnd}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="reader-markdown">
|
<div className="reader-markdown">
|
||||||
@@ -152,7 +156,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="reader-html"
|
className="reader-html"
|
||||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleSelectionEnd}
|
||||||
|
onTouchEnd={handleSelectionEnd}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -28,36 +28,45 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const cachedImage = useImageCache(image, settings)
|
const cachedImage = useImageCache(image, settings)
|
||||||
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
||||||
|
const isLongSummary = summary && summary.length > 150
|
||||||
|
|
||||||
if (cachedImage) {
|
if (cachedImage) {
|
||||||
return (
|
return (
|
||||||
<div className="reader-hero-image">
|
<>
|
||||||
<img src={cachedImage} alt={title || 'Article image'} />
|
<div className="reader-hero-image">
|
||||||
{formattedDate && (
|
<img src={cachedImage} alt={title || 'Article image'} />
|
||||||
<div className="publish-date-topright">
|
{formattedDate && (
|
||||||
{formattedDate}
|
<div className="publish-date-topright">
|
||||||
</div>
|
{formattedDate}
|
||||||
)}
|
|
||||||
{title && (
|
|
||||||
<div className="reader-header-overlay">
|
|
||||||
<h2 className="reader-title">{title}</h2>
|
|
||||||
{summary && <p className="reader-summary">{summary}</p>}
|
|
||||||
<div className="reader-meta">
|
|
||||||
{readingTimeText && (
|
|
||||||
<div className="reading-time">
|
|
||||||
<FontAwesomeIcon icon={faClock} />
|
|
||||||
<span>{readingTimeText}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasHighlights && (
|
|
||||||
<div className="highlight-indicator">
|
|
||||||
<FontAwesomeIcon icon={faHighlighter} />
|
|
||||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{title && (
|
||||||
|
<div className="reader-header-overlay">
|
||||||
|
<h2 className="reader-title">{title}</h2>
|
||||||
|
{summary && <p className={`reader-summary ${isLongSummary ? 'hide-on-mobile' : ''}`}>{summary}</p>}
|
||||||
|
<div className="reader-meta">
|
||||||
|
{readingTimeText && (
|
||||||
|
<div className="reading-time">
|
||||||
|
<FontAwesomeIcon icon={faClock} />
|
||||||
|
<span>{readingTimeText}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasHighlights && (
|
||||||
|
<div className="highlight-indicator">
|
||||||
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
|
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isLongSummary && (
|
||||||
|
<div className="reader-summary-below-image">
|
||||||
|
<p className="reader-summary">{summary}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
|
|||||||
hasRemoteRelay,
|
hasRemoteRelay,
|
||||||
isConnecting
|
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)
|
// Don't show indicator when fully connected (but show when connecting)
|
||||||
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
accountManager.setActive(account)
|
accountManager.setActive(account)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login failed:', error)
|
console.error('Login failed:', error)
|
||||||
alert('Login failed. Please install a nostr browser extension and try again.')
|
alert('Login failed. Please install a nostr browser extension and try again.\n\nIf you aren\'t on nostr yet, start here: https://nstart.me/')
|
||||||
} finally {
|
} finally {
|
||||||
setIsConnecting(false)
|
setIsConnecting(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { HighlightVisibility } from './HighlightsPanel'
|
|||||||
import { HighlightButtonRef } from './HighlightButton'
|
import { HighlightButtonRef } from './HighlightButton'
|
||||||
import { BookmarkReference } from '../utils/contentLoader'
|
import { BookmarkReference } from '../utils/contentLoader'
|
||||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||||
|
import { useScrollDirection } from '../hooks/useScrollDirection'
|
||||||
|
|
||||||
interface ThreePaneLayoutProps {
|
interface ThreePaneLayoutProps {
|
||||||
// Layout state
|
// Layout state
|
||||||
@@ -86,6 +87,16 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||||
const highlightsRef = 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
|
// Lock body scroll when mobile sidebar or highlights is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -102,22 +113,24 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
|
|
||||||
// Handle ESC key to close sidebar or highlights
|
// Handle ESC key to close sidebar or highlights
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const { isSidebarOpen, isHighlightsCollapsed, onToggleSidebar, onToggleHighlightsPanel } = props
|
||||||
|
|
||||||
if (!isMobile) return
|
if (!isMobile) return
|
||||||
if (!props.isSidebarOpen && props.isHighlightsCollapsed) return
|
if (!isSidebarOpen && isHighlightsCollapsed) return
|
||||||
|
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (props.isSidebarOpen) {
|
if (isSidebarOpen) {
|
||||||
props.onToggleSidebar()
|
onToggleSidebar()
|
||||||
} else if (!props.isHighlightsCollapsed) {
|
} else if (!isHighlightsCollapsed) {
|
||||||
props.onToggleHighlightsPanel()
|
onToggleHighlightsPanel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', handleEscape)
|
document.addEventListener('keydown', handleEscape)
|
||||||
return () => document.removeEventListener('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
|
// Trap focus in sidebar when open on mobile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -204,7 +217,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
{/* Mobile bookmark button - only show when viewing article */}
|
{/* Mobile bookmark button - only show when viewing article */}
|
||||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
|
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
|
||||||
<button
|
<button
|
||||||
className="mobile-hamburger-btn"
|
className={`mobile-hamburger-btn ${showMobileButtons ? 'visible' : 'hidden'}`}
|
||||||
onClick={props.onToggleSidebar}
|
onClick={props.onToggleSidebar}
|
||||||
aria-label="Open bookmarks"
|
aria-label="Open bookmarks"
|
||||||
aria-expanded={props.isSidebarOpen}
|
aria-expanded={props.isSidebarOpen}
|
||||||
@@ -216,7 +229,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
{/* Mobile highlights button - only show when viewing article */}
|
{/* Mobile highlights button - only show when viewing article */}
|
||||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
|
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
|
||||||
<button
|
<button
|
||||||
className="mobile-highlights-btn"
|
className={`mobile-highlights-btn ${showMobileButtons ? 'visible' : 'hidden'}`}
|
||||||
onClick={props.onToggleHighlightsPanel}
|
onClick={props.onToggleHighlightsPanel}
|
||||||
aria-label="Open highlights"
|
aria-label="Open highlights"
|
||||||
aria-expanded={!props.isHighlightsCollapsed}
|
aria-expanded={!props.isHighlightsCollapsed}
|
||||||
@@ -259,7 +272,10 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 ? (
|
{props.showSettings ? (
|
||||||
<Settings
|
<Settings
|
||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
@@ -294,6 +310,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
currentUserPubkey={props.currentUserPubkey}
|
currentUserPubkey={props.currentUserPubkey}
|
||||||
followedPubkeys={props.followedPubkeys}
|
followedPubkeys={props.followedPubkeys}
|
||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
|
relayPool={props.relayPool}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const useBookmarksData = ({
|
|||||||
handleFetchHighlights()
|
handleFetchHighlights()
|
||||||
}
|
}
|
||||||
handleFetchContacts()
|
handleFetchContacts()
|
||||||
}, [relayPool, activeAccount?.pubkey, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
}, [relayPool, activeAccount, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookmarks,
|
bookmarks,
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ export const useHighlightInteractions = ({
|
|||||||
}
|
}
|
||||||
}, [selectedHighlightId])
|
}, [selectedHighlightId])
|
||||||
|
|
||||||
// Handle text selection
|
// Handle text selection (works for both mouse and touch)
|
||||||
const handleMouseUp = useCallback(() => {
|
const handleSelectionEnd = useCallback(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (!selection || selection.rangeCount === 0) {
|
if (!selection || selection.rangeCount === 0) {
|
||||||
@@ -76,6 +76,6 @@ export const useHighlightInteractions = ({
|
|||||||
}, 10)
|
}, 10)
|
||||||
}, [onTextSelection, onClearSelection])
|
}, [onTextSelection, onClearSelection])
|
||||||
|
|
||||||
return { contentRef, handleMouseUp }
|
return { contentRef, handleSelectionEnd }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,86 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
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
|
* 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 previewRef = useRef<HTMLDivElement>(null)
|
||||||
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
||||||
|
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!markdown) {
|
if (!markdown) {
|
||||||
setRenderedHtml('')
|
setRenderedHtml('')
|
||||||
|
setProcessedMarkdown('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📝 Converting markdown to HTML...')
|
let isCancelled = false
|
||||||
|
|
||||||
const rafId = requestAnimationFrame(() => {
|
const processMarkdown = async () => {
|
||||||
if (previewRef.current) {
|
// Extract all naddr references
|
||||||
const html = previewRef.current.innerHTML
|
const naddrs = extractNaddrUris(markdown)
|
||||||
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
|
||||||
setRenderedHtml(html)
|
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 {
|
} 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)
|
console.log('📝 Converting markdown to HTML...')
|
||||||
}, [markdown])
|
|
||||||
|
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
|
// 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;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
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 {
|
.mobile-hamburger-btn:active {
|
||||||
@@ -468,6 +479,23 @@ body.mobile-sidebar-open {
|
|||||||
text-decoration: underline;
|
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 {
|
.logout-button {
|
||||||
background: #dc3545;
|
background: #dc3545;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -717,7 +745,18 @@ body.mobile-sidebar-open {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
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 {
|
.mobile-highlights-btn:active {
|
||||||
@@ -1437,6 +1476,51 @@ body.mobile-sidebar-open {
|
|||||||
border: 1px solid rgba(100, 108, 255, 0.4);
|
border: 1px solid rgba(100, 108, 255, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-summary-below-image {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.reader-header-overlay .reader-summary.hide-on-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-summary-below-image {
|
||||||
|
display: block;
|
||||||
|
padding: 0 0 1.5rem 0;
|
||||||
|
margin-top: -1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-summary-below-image .reader-summary {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-hero-image {
|
||||||
|
min-height: 280px;
|
||||||
|
max-height: 400px;
|
||||||
|
height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-hero-image img {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-header-overlay {
|
||||||
|
padding: 1.5rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-header-overlay .reader-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Private Bookmark Styles */
|
/* Private Bookmark Styles */
|
||||||
.private-bookmark {
|
.private-bookmark {
|
||||||
background: #2a2a2a;
|
background: #2a2a2a;
|
||||||
@@ -3062,4 +3146,13 @@ body.mobile-sidebar-open {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-post-card-summary {
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-card-content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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