mirror of
https://github.com/dergigi/boris.git
synced 2026-02-17 13:04:59 +01:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00da638e81 | ||
|
|
f04c0a401e | ||
|
|
f5e9f164f5 | ||
|
|
589ac17114 | ||
|
|
8d3510947c | ||
|
|
08a8f5623a | ||
|
|
e85ccdc7da | ||
|
|
d0e7f146fb | ||
|
|
efdb33eb31 | ||
|
|
0abbe62515 | ||
|
|
ab0972dd29 | ||
|
|
83fbb26e03 | ||
|
|
e8ce928ec6 | ||
|
|
1a01e14702 | ||
|
|
aab8176987 | ||
|
|
5a8b885d25 | ||
|
|
c129b24352 | ||
|
|
d98d750268 | ||
|
|
8262b2bf24 | ||
|
|
b99f36c0c5 | ||
|
|
dfe37a260e | ||
|
|
2a42f1de53 | ||
|
|
cea2d0eda2 | ||
|
|
ef05974a72 | ||
|
|
5a6ac628d2 | ||
|
|
826f07544e | ||
|
|
911215c0fb | ||
|
|
43ed41bfae | ||
|
|
81597fbb6d | ||
|
|
cc722c2599 | ||
|
|
c20682fbe8 | ||
|
|
cfa6dc4400 | ||
|
|
851cecf18c | ||
|
|
d4c67485a2 | ||
|
|
381fd05c90 | ||
|
|
60c4ef55c0 | ||
|
|
0b7891419b | ||
|
|
aeedc622b1 | ||
|
|
6f5b87136b | ||
|
|
1ac0c719a2 | ||
|
|
c9ce5442e0 | ||
|
|
c28052720e | ||
|
|
d0f942c495 | ||
|
|
907ef82efb | ||
|
|
415ff04345 | ||
|
|
683ea27526 |
144
CHANGELOG.md
144
CHANGELOG.md
@@ -7,6 +7,150 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.10.27] - 2025-10-31
|
||||
|
||||
### Added
|
||||
|
||||
- Refresh button to highlights sidebar header
|
||||
- Users can manually refresh highlights panel
|
||||
- Better control over highlights data updates
|
||||
- Image preloading in BlogPostCard for better caching
|
||||
- Images are preloaded when blog posts are displayed
|
||||
- Improved offline access to article images
|
||||
- Preload logged-in user profile image for offline access
|
||||
- User profile picture is cached for offline viewing
|
||||
- Better user experience when network is unavailable
|
||||
- Development Service Worker for testing image caching
|
||||
- Service Worker enabled in development mode
|
||||
- Improved testing capabilities for offline functionality
|
||||
|
||||
### Fixed
|
||||
|
||||
- Service Worker registration error handling
|
||||
- Better error handling for Service Worker registration failures
|
||||
- More robust development mode Service Worker support
|
||||
- Proper error handling for fetch requests in Service Worker
|
||||
- Article loading race conditions
|
||||
- Resolved race condition when loading articles from cache
|
||||
- Cache is checked synchronously before setting loading state
|
||||
- Articles are populated in cache from explore view
|
||||
- Image caching issues
|
||||
- Images are properly preloaded when loading articles from cache
|
||||
- Removed bulk image preloading to prevent resource exhaustion errors
|
||||
- Avoid redundant image preload when using preview data
|
||||
- Scroll position management
|
||||
- Scroll position is reset when switching articles
|
||||
- Save suppression added when resetting scroll position
|
||||
- Reader content is cleared immediately when article changes
|
||||
- React hook ordering issues
|
||||
- useEffect moved before early return in BlogPostCard
|
||||
- Prevents React hooks dependency violations
|
||||
- TypeScript and linting issues
|
||||
- Cache save logic simplified to avoid TypeScript errors
|
||||
- Unused settings parameter marked as intentionally unused
|
||||
- Articles are saved to localStorage cache after loading from relays
|
||||
- Cache is saved immediately when first event is received
|
||||
|
||||
### Performance
|
||||
|
||||
- Avoid redundant image preload when using preview data
|
||||
- Prevents unnecessary image loading operations
|
||||
- Improved resource utilization
|
||||
|
||||
### Removed
|
||||
|
||||
- Debug console.log statements
|
||||
- Removed all debug console output from article cache and service worker
|
||||
- Removed debug logging from useImageCache hook
|
||||
- Cleaner console output in production
|
||||
- Unused refresh button from highlights panel header
|
||||
- Cleaned up unused UI component
|
||||
|
||||
## [0.10.26] - 2025-10-31
|
||||
|
||||
### Added
|
||||
|
||||
- Persist highlight metadata and offline events to localStorage
|
||||
- Highlights created offline are now preserved across sessions
|
||||
- Better data persistence for flight mode highlights
|
||||
- Proper relay response tracking for flight mode
|
||||
- Accurate detection of which relays have received highlight events
|
||||
- Improved flight mode indicator accuracy
|
||||
|
||||
### Changed
|
||||
|
||||
- Implemented proper flight mode detection for highlights
|
||||
- More reliable identification of highlights created offline
|
||||
- Better tracking of highlight publication status
|
||||
- Refactored to use isLocalOnly flag instead of isOfflineCreated
|
||||
- More consistent naming and clearer intent
|
||||
- Improved code maintainability
|
||||
|
||||
### Fixed
|
||||
|
||||
- Show airplane icon for flight mode highlights
|
||||
- Visual indicator now correctly displays for offline-created highlights
|
||||
- Better user feedback for highlights pending publication
|
||||
- Prioritize isLocalOnly check to show airplane icon
|
||||
- Icon display logic now correctly identifies flight mode highlights
|
||||
- Preserve isLocalOnly and publishedRelays in eventToHighlight conversion
|
||||
- Highlight metadata is maintained during event transformations
|
||||
- Flight mode status preserved across data conversions
|
||||
- Use metadata cache to preserve highlight properties across EventStore
|
||||
- Highlights maintain their properties when stored in EventStore
|
||||
- Prevents loss of flight mode metadata
|
||||
- Add fallback logic for detecting flight mode highlights
|
||||
- More robust detection when primary methods fail
|
||||
- Better handling of edge cases
|
||||
- Determine isLocalOnly before publishing, not after
|
||||
- Correct timing for flight mode detection
|
||||
- Prevents incorrect status during highlight creation
|
||||
- Store event in EventStore after updating properties
|
||||
- Ensures all highlight properties are saved correctly
|
||||
- Prevents data loss during highlight creation
|
||||
- Manually set highlight properties after eventToHighlight conversion
|
||||
- Ensures all metadata is properly assigned
|
||||
- Correct property mapping during conversions
|
||||
- Prevent duplicate highlights
|
||||
- Eliminates duplicate highlight entries
|
||||
- Cleaner highlight list display
|
||||
- Publish only to connected relays to avoid long timeouts
|
||||
- Faster highlight publishing
|
||||
- Better user experience when some relays are unavailable
|
||||
- Prevent unnecessary relay queries when article content is cached
|
||||
- Improved performance by avoiding redundant network requests
|
||||
- Better resource utilization
|
||||
- Remove relayPool dependency from content loaders
|
||||
- Cleaner architecture and reduced coupling
|
||||
- Better separation of concerns
|
||||
- Check EventStore before setting loading state
|
||||
- Prevents unnecessary loading states
|
||||
- Better state management
|
||||
- Remove eventStore and setter functions from useEffect dependencies
|
||||
- Fixes React hook dependency issues
|
||||
- Prevents unnecessary re-renders
|
||||
- Replace require() with ES module imports
|
||||
- Modern JavaScript module system
|
||||
- Better compatibility with build tools
|
||||
- Resolve all linting errors and type issues
|
||||
- Improved code quality
|
||||
- Better type safety
|
||||
- Remove unused variables to resolve lint errors
|
||||
- Cleaner codebase
|
||||
- Eliminates lint warnings
|
||||
|
||||
### Performance
|
||||
|
||||
- Remove excessive debug logging for better performance
|
||||
- Reduced overhead from logging operations
|
||||
- Improved application performance
|
||||
|
||||
### Removed
|
||||
|
||||
- Debug console.log statements
|
||||
- Cleaner console output
|
||||
- Production-ready code
|
||||
|
||||
## [0.10.25] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.10.26",
|
||||
"version": "0.10.28",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
47
public/sw-dev.js
Normal file
47
public/sw-dev.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// Development Service Worker - simplified version for testing image caching
|
||||
// This is served in dev mode when vite-plugin-pwa doesn't serve the injectManifest SW
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(clients.claim())
|
||||
})
|
||||
|
||||
// Image caching - simple version for dev testing
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
const isImage = event.request.destination === 'image' ||
|
||||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
|
||||
|
||||
if (isImage) {
|
||||
event.respondWith(
|
||||
caches.open('boris-images-dev').then((cache) => {
|
||||
return cache.match(event.request).then((cachedResponse) => {
|
||||
// Try to fetch from network
|
||||
return fetch(event.request).then((response) => {
|
||||
// If fetch succeeds, cache it and return
|
||||
if (response.ok) {
|
||||
cache.put(event.request, response.clone()).catch(() => {
|
||||
// Ignore cache put errors
|
||||
})
|
||||
}
|
||||
return response
|
||||
}).catch((error) => {
|
||||
// If fetch fails (network error, CORS, etc.), return cached response if available
|
||||
if (cachedResponse) {
|
||||
return cachedResponse
|
||||
}
|
||||
// No cache available, reject the promise so browser handles it
|
||||
return Promise.reject(error)
|
||||
})
|
||||
})
|
||||
}).catch(() => {
|
||||
// If cache operations fail, try to fetch directly without caching
|
||||
return fetch(event.request)
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -18,6 +18,12 @@ interface BlogPostCardProps {
|
||||
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress, hideBotByName = true }) => {
|
||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||
|
||||
// Note: Images are lazy-loaded (loading="lazy" below), so they'll be fetched
|
||||
// when they come into view. The Service Worker will cache them automatically.
|
||||
// No need to preload all images at once - this causes ERR_INSUFFICIENT_RESOURCES
|
||||
// when there are many blog posts.
|
||||
|
||||
const displayName = profile?.name || profile?.display_name ||
|
||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||
const rawName = (profile?.name || profile?.display_name || '').toLowerCase()
|
||||
@@ -41,7 +47,7 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
|
||||
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||
}
|
||||
|
||||
|
||||
// Debug log - reading progress shown as visual indicator
|
||||
if (readingProgress !== undefined) {
|
||||
// Reading progress display
|
||||
|
||||
@@ -263,6 +263,23 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
const restoreKey = `${articleIdentifier}-${isTrackingEnabled}`
|
||||
const hasAttemptedRestoreRef = useRef<string | null>(null)
|
||||
|
||||
// Reset scroll position and restore ref when article changes
|
||||
useEffect(() => {
|
||||
if (!articleIdentifier) return
|
||||
|
||||
// Suppress saves during navigation to prevent saving 0% position
|
||||
// The 500ms suppression covers the scroll reset and initial render
|
||||
if (suppressSavesForRef.current) {
|
||||
suppressSavesForRef.current(500)
|
||||
}
|
||||
|
||||
// Reset scroll to top when article identifier changes
|
||||
// This prevents showing wrong scroll position from previous article
|
||||
window.scrollTo({ top: 0, behavior: 'instant' })
|
||||
// Reset restore attempt tracking for new article
|
||||
hasAttemptedRestoreRef.current = null
|
||||
}, [articleIdentifier])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTextContent || !activeAccount || !articleIdentifier) {
|
||||
return
|
||||
|
||||
@@ -80,7 +80,13 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
<>
|
||||
<div className="reader-hero-image">
|
||||
{cachedImage ? (
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
<img
|
||||
src={cachedImage}
|
||||
alt={title || 'Article image'}
|
||||
onError={(e) => {
|
||||
console.error('[reader-header] Image failed to load:', cachedImage, e)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="reader-hero-placeholder">
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import IconButton from './IconButton'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { preloadImage } from '../hooks/useImageCache'
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
onToggleCollapse: () => void
|
||||
@@ -36,6 +37,13 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
|
||||
const profileImage = getProfileImage()
|
||||
|
||||
// Preload profile image for offline access
|
||||
useEffect(() => {
|
||||
if (profileImage) {
|
||||
preloadImage(profileImage)
|
||||
}
|
||||
}, [profileImage])
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
|
||||
@@ -6,8 +6,9 @@ import { nip19 } from 'nostr-tools'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { queryEvents } from '../services/dataFetch'
|
||||
import { fetchArticleByNaddr } from '../services/articleService'
|
||||
import { fetchArticleByNaddr, getFromCache, saveToCache } from '../services/articleService'
|
||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { preloadImage } from './useImageCache'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
@@ -72,11 +73,201 @@ export function useArticleLoader({
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
|
||||
if (!relayPool || !naddr) return
|
||||
// First check: naddr is required
|
||||
if (!naddr) {
|
||||
setReaderContent(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear readerContent immediately to prevent showing stale content from previous article
|
||||
// This ensures images from previous articles don't flash briefly
|
||||
setReaderContent(undefined)
|
||||
|
||||
// Synchronously check cache sources BEFORE checking relayPool
|
||||
// This prevents showing loading skeletons when content is immediately available
|
||||
// and fixes the race condition where relayPool isn't ready yet
|
||||
let foundInCache = false
|
||||
try {
|
||||
// Check localStorage cache first (synchronous, doesn't need relayPool)
|
||||
const cachedArticle = getFromCache(naddr)
|
||||
if (cachedArticle) {
|
||||
foundInCache = true
|
||||
const title = cachedArticle.title || 'Untitled Article'
|
||||
setCurrentTitle(title)
|
||||
setReaderContent({
|
||||
title,
|
||||
markdown: cachedArticle.markdown,
|
||||
image: cachedArticle.image,
|
||||
summary: cachedArticle.summary,
|
||||
published: cachedArticle.published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
const dTag = cachedArticle.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}`
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(cachedArticle.event.id)
|
||||
setCurrentArticle?.(cachedArticle.event)
|
||||
setReaderLoading(false)
|
||||
setSelectedUrl(`nostr:${naddr}`)
|
||||
setIsCollapsed(true)
|
||||
|
||||
// Preload image if available to ensure it's cached by Service Worker
|
||||
// This ensures images are available when offline
|
||||
if (cachedArticle.image) {
|
||||
preloadImage(cachedArticle.image)
|
||||
}
|
||||
|
||||
// Store in EventStore for future lookups
|
||||
if (eventStore) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
eventStore.add?.(cachedArticle.event as unknown as any)
|
||||
} catch {
|
||||
// Silently ignore store errors
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch highlights in background (don't block UI)
|
||||
// Only fetch highlights if relayPool is available
|
||||
if (mountedRef.current && relayPool) {
|
||||
const dTag = cachedArticle.event.tags.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coord = dTag ? `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}` : undefined
|
||||
const eventId = cachedArticle.event.id
|
||||
|
||||
if (coord && eventId) {
|
||||
setHighlightsLoading(true)
|
||||
fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
coord,
|
||||
eventId,
|
||||
(highlight) => {
|
||||
if (!mountedRef.current) return
|
||||
setHighlights((prev: Highlight[]) => {
|
||||
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||
const next = [highlight, ...prev]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
},
|
||||
settings,
|
||||
false,
|
||||
eventStore || undefined
|
||||
).then(() => {
|
||||
if (mountedRef.current) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}).catch(() => {
|
||||
if (mountedRef.current) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Return early - we have cached content, no need to query relays
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
// If cache check fails, fall through to async loading
|
||||
console.warn('[article-loader] Cache check failed:', err)
|
||||
}
|
||||
|
||||
// Check EventStore synchronously (also doesn't need relayPool)
|
||||
let foundInEventStore = false
|
||||
if (eventStore && !foundInCache) {
|
||||
try {
|
||||
// Decode naddr to get the coordinate
|
||||
const decoded = nip19.decode(naddr)
|
||||
if (decoded.type === 'naddr') {
|
||||
const pointer = decoded.data as AddressPointer
|
||||
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
|
||||
const storedEvent = eventStore.getEvent?.(coordinate)
|
||||
if (storedEvent) {
|
||||
foundInEventStore = true
|
||||
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
|
||||
setCurrentTitle(title)
|
||||
const image = Helpers.getArticleImage(storedEvent)
|
||||
const summary = Helpers.getArticleSummary(storedEvent)
|
||||
const published = Helpers.getArticlePublished(storedEvent)
|
||||
setReaderContent({
|
||||
title,
|
||||
markdown: storedEvent.content,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(storedEvent.id)
|
||||
setCurrentArticle?.(storedEvent)
|
||||
setReaderLoading(false)
|
||||
setSelectedUrl(`nostr:${naddr}`)
|
||||
setIsCollapsed(true)
|
||||
|
||||
// Fetch highlights in background if relayPool is available
|
||||
if (relayPool) {
|
||||
const coord = dTag ? `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}` : undefined
|
||||
const eventId = storedEvent.id
|
||||
|
||||
if (coord && eventId) {
|
||||
setHighlightsLoading(true)
|
||||
fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
coord,
|
||||
eventId,
|
||||
(highlight) => {
|
||||
if (!mountedRef.current) return
|
||||
setHighlights((prev: Highlight[]) => {
|
||||
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||
const next = [highlight, ...prev]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
},
|
||||
settings,
|
||||
false,
|
||||
eventStore || undefined
|
||||
).then(() => {
|
||||
if (mountedRef.current) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}).catch(() => {
|
||||
if (mountedRef.current) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Return early - we have EventStore content, no need to query relays yet
|
||||
// But we might want to fetch from relays in background if relayPool becomes available
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore store errors, fall through to relay query
|
||||
console.warn('[article-loader] EventStore check failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Only return early if we have no content AND no relayPool to fetch from
|
||||
if (!relayPool && !foundInCache && !foundInEventStore) {
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
// If we have relayPool, proceed with async loading
|
||||
if (!relayPool) {
|
||||
return
|
||||
}
|
||||
|
||||
const loadArticle = async () => {
|
||||
const requestId = ++currentRequestIdRef.current
|
||||
if (!mountedRef.current) return
|
||||
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedUrl(`nostr:${naddr}`)
|
||||
setIsCollapsed(true)
|
||||
@@ -85,62 +276,28 @@ export function useArticleLoader({
|
||||
// when we know the article coordinate
|
||||
setHighlightsLoading(false) // Don't show loading yet
|
||||
|
||||
// Check eventStore first for instant load (from bookmark cards, explore, etc.)
|
||||
let foundInStore = false
|
||||
if (eventStore) {
|
||||
try {
|
||||
// Decode naddr to get the coordinate
|
||||
const decoded = nip19.decode(naddr)
|
||||
if (decoded.type === 'naddr') {
|
||||
const pointer = decoded.data as AddressPointer
|
||||
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
|
||||
const storedEvent = eventStore.getEvent?.(coordinate)
|
||||
if (storedEvent) {
|
||||
foundInStore = true
|
||||
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
|
||||
setCurrentTitle(title)
|
||||
const image = Helpers.getArticleImage(storedEvent)
|
||||
const summary = Helpers.getArticleSummary(storedEvent)
|
||||
const published = Helpers.getArticlePublished(storedEvent)
|
||||
setReaderContent({
|
||||
title,
|
||||
markdown: storedEvent.content,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(storedEvent.id)
|
||||
setCurrentArticle?.(storedEvent)
|
||||
setReaderLoading(false)
|
||||
|
||||
// If we found the content in EventStore, we can return early
|
||||
// This prevents unnecessary relay queries when offline
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore store errors, fall through to relay query
|
||||
}
|
||||
}
|
||||
// Note: Cache and EventStore were already checked synchronously above
|
||||
// This async function only runs if we need to fetch from relays
|
||||
|
||||
// If we have preview data from navigation, show it immediately (no skeleton!)
|
||||
// At this point, we've checked EventStore and cache - neither had content
|
||||
// Only show loading skeleton if we also don't have preview data
|
||||
if (previewData) {
|
||||
// If we have preview data from navigation, show it immediately (no skeleton!)
|
||||
setCurrentTitle(previewData.title)
|
||||
setReaderContent({
|
||||
title: previewData.title,
|
||||
markdown: '', // Will be loaded from store or relay
|
||||
markdown: '', // Will be loaded from relay
|
||||
image: previewData.image,
|
||||
summary: previewData.summary,
|
||||
published: previewData.published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
setReaderLoading(false) // Turn off loading immediately - we have the preview!
|
||||
} else if (!foundInStore) {
|
||||
// Only show loading if we didn't find content in store and no preview data
|
||||
|
||||
// Don't preload image here - it should already be cached from BlogPostCard
|
||||
// Preloading again would be redundant and could cause unnecessary network requests
|
||||
} else {
|
||||
// No cache, no EventStore, no preview data - need to load from relays
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
}
|
||||
@@ -164,8 +321,12 @@ export function useArticleLoader({
|
||||
// Stream local-first via queryEvents; rely on EOSE (no timeouts)
|
||||
const events = await queryEvents(relayPool, filter, {
|
||||
onEvent: (evt) => {
|
||||
if (!mountedRef.current) return
|
||||
if (currentRequestIdRef.current !== requestId) return
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
if (currentRequestIdRef.current !== requestId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Store in event store for future local reads
|
||||
try {
|
||||
@@ -184,10 +345,11 @@ export function useArticleLoader({
|
||||
if (!firstEmitted) {
|
||||
firstEmitted = true
|
||||
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
|
||||
setCurrentTitle(title)
|
||||
const image = Helpers.getArticleImage(evt)
|
||||
const summary = Helpers.getArticleSummary(evt)
|
||||
const published = Helpers.getArticlePublished(evt)
|
||||
|
||||
setCurrentTitle(title)
|
||||
setReaderContent({
|
||||
title,
|
||||
markdown: evt.content,
|
||||
@@ -202,20 +364,41 @@ export function useArticleLoader({
|
||||
setCurrentArticleEventId(evt.id)
|
||||
setCurrentArticle?.(evt)
|
||||
setReaderLoading(false)
|
||||
|
||||
// Save to cache immediately when we get the first event
|
||||
// Don't wait for queryEvents to complete in case it hangs
|
||||
const articleContent = {
|
||||
title,
|
||||
markdown: evt.content,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
author: evt.pubkey,
|
||||
event: evt
|
||||
}
|
||||
saveToCache(naddr, articleContent, settings)
|
||||
|
||||
// Preload image to ensure it's cached by Service Worker
|
||||
if (image) {
|
||||
preloadImage(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
|
||||
if (!mountedRef.current || currentRequestIdRef.current !== requestId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Finalize with newest version if it's newer than what we first rendered
|
||||
const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent
|
||||
if (finalEvent) {
|
||||
const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article'
|
||||
setCurrentTitle(title)
|
||||
const image = Helpers.getArticleImage(finalEvent)
|
||||
const summary = Helpers.getArticleSummary(finalEvent)
|
||||
const published = Helpers.getArticlePublished(finalEvent)
|
||||
|
||||
setCurrentTitle(title)
|
||||
setReaderContent({
|
||||
title,
|
||||
markdown: finalEvent.content,
|
||||
@@ -230,6 +413,23 @@ export function useArticleLoader({
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(finalEvent.id)
|
||||
setCurrentArticle?.(finalEvent)
|
||||
|
||||
// Save to cache for future loads (if we haven't already saved from first event)
|
||||
// Only save if this is a different/newer event than what we first rendered
|
||||
// Note: We already saved from first event, so only save if this is different
|
||||
if (!firstEmitted) {
|
||||
// First event wasn't emitted, so save now
|
||||
const articleContent = {
|
||||
title,
|
||||
markdown: finalEvent.content,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
author: finalEvent.pubkey,
|
||||
event: finalEvent
|
||||
}
|
||||
saveToCache(naddr, articleContent)
|
||||
}
|
||||
} else {
|
||||
// As a last resort, fall back to the legacy helper (which includes cache)
|
||||
const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current)
|
||||
@@ -315,11 +515,13 @@ export function useArticleLoader({
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
// Dependencies intentionally excluded to prevent re-renders when relay/eventStore state changes
|
||||
// This fixes the loading skeleton appearing when going offline (flight mode)
|
||||
// Include relayPool in dependencies so effect re-runs when it becomes available
|
||||
// This fixes the race condition where articles don't load on direct navigation
|
||||
// We guard against unnecessary re-renders by checking cache/EventStore first
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
naddr,
|
||||
previewData
|
||||
previewData,
|
||||
relayPool
|
||||
])
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export function useImageCache(
|
||||
imageUrl: string | undefined
|
||||
): string | undefined {
|
||||
// Service Worker handles everything - just return the URL as-is
|
||||
// The Service Worker will intercept fetch requests and cache them
|
||||
// Make sure images use standard <img src> tags for SW interception
|
||||
return imageUrl
|
||||
}
|
||||
|
||||
@@ -26,3 +28,26 @@ export function useCacheImageOnLoad(
|
||||
void imageUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload an image URL to ensure it's cached by the Service Worker
|
||||
* This is useful when loading content from cache - we want to ensure
|
||||
* images are cached before going offline
|
||||
*/
|
||||
export function preloadImage(imageUrl: string | undefined): void {
|
||||
if (!imageUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a link element with rel=prefetch or use Image object to trigger fetch
|
||||
// Service Worker will intercept and cache the request
|
||||
const img = new Image()
|
||||
img.src = imageUrl
|
||||
|
||||
// Also try using fetch to explicitly trigger Service Worker
|
||||
// This ensures the image is cached even if <img> tag hasn't rendered yet
|
||||
fetch(imageUrl, { mode: 'no-cors' }).catch(() => {
|
||||
// Ignore errors - image might not be CORS-enabled, but SW will still cache it
|
||||
// The Image() approach above will work for most cases
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
75
src/main.tsx
75
src/main.tsx
@@ -5,16 +5,60 @@ import './styles/tailwind.css'
|
||||
import './index.css'
|
||||
import 'react-loading-skeleton/dist/skeleton.css'
|
||||
|
||||
// Register Service Worker for PWA functionality (production only)
|
||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||
// Register Service Worker for PWA functionality
|
||||
// With injectRegister: null, we need to register manually
|
||||
// With devOptions.enabled: true, vite-plugin-pwa serves SW in dev mode too
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
const swPath = '/sw.js'
|
||||
|
||||
// Check if already registered/active first
|
||||
navigator.serviceWorker.getRegistrations().then(async (registrations) => {
|
||||
if (registrations.length > 0) {
|
||||
return registrations[0]
|
||||
}
|
||||
|
||||
// Not registered yet, try to register
|
||||
// In dev mode, use the dev Service Worker for testing
|
||||
if (import.meta.env.DEV) {
|
||||
const devSwPath = '/sw-dev.js'
|
||||
try {
|
||||
// Check if dev SW exists
|
||||
const response = await fetch(devSwPath)
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
const isJavaScript = contentType.includes('javascript') || contentType.includes('application/javascript')
|
||||
|
||||
if (response.ok && isJavaScript) {
|
||||
return await navigator.serviceWorker.register(devSwPath, { scope: '/' })
|
||||
} else {
|
||||
console.warn('[sw-registration] Development Service Worker not available')
|
||||
return null
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[sw-registration] Could not load development Service Worker:', err)
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
// In production, just register directly
|
||||
return await navigator.serviceWorker.register(swPath)
|
||||
}
|
||||
})
|
||||
.then(registration => {
|
||||
// Check for updates periodically
|
||||
setInterval(() => {
|
||||
registration.update()
|
||||
}, 60 * 60 * 1000) // Check every hour
|
||||
if (!registration) return
|
||||
|
||||
// Wait for Service Worker to activate
|
||||
if (registration.installing) {
|
||||
registration.installing.addEventListener('statechange', () => {
|
||||
// Service Worker state changed
|
||||
})
|
||||
}
|
||||
|
||||
// Check for updates periodically (production only)
|
||||
if (import.meta.env.PROD) {
|
||||
setInterval(() => {
|
||||
registration.update()
|
||||
}, 60 * 60 * 1000) // Check every hour
|
||||
}
|
||||
|
||||
// Handle service worker updates
|
||||
registration.addEventListener('updatefound', () => {
|
||||
@@ -31,9 +75,22 @@ if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('❌ Service Worker registration failed:', error)
|
||||
console.error('[sw-registration] ❌ Service Worker registration failed:', error)
|
||||
console.error('[sw-registration] Error details:', {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
stack: error.stack
|
||||
})
|
||||
|
||||
// In dev mode, this is expected if vite-plugin-pwa isn't serving the SW
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[sw-registration] ⚠️ This is expected in dev mode if vite-plugin-pwa is not serving the SW file')
|
||||
console.warn('[sw-registration] Image caching will not work in dev mode - test in production build')
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
console.warn('[sw-registration] ⚠️ Service Workers not supported in this browser')
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -34,11 +34,13 @@ function getCacheKey(naddr: string): string {
|
||||
return `${CACHE_PREFIX}${naddr}`
|
||||
}
|
||||
|
||||
function getFromCache(naddr: string): ArticleContent | null {
|
||||
export function getFromCache(naddr: string): ArticleContent | null {
|
||||
try {
|
||||
const cacheKey = getCacheKey(naddr)
|
||||
const cached = localStorage.getItem(cacheKey)
|
||||
if (!cached) return null
|
||||
if (!cached) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { content, timestamp }: CachedArticle = JSON.parse(cached)
|
||||
const age = Date.now() - timestamp
|
||||
@@ -49,12 +51,51 @@ function getFromCache(naddr: string): ArticleContent | null {
|
||||
}
|
||||
|
||||
return content
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Silently handle cache read errors
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveToCache(naddr: string, content: ArticleContent): void {
|
||||
/**
|
||||
* Caches an article event to localStorage for offline access
|
||||
* @param event - The Nostr event to cache
|
||||
* @param settings - Optional user settings
|
||||
*/
|
||||
export function cacheArticleEvent(event: NostrEvent, settings?: UserSettings): void {
|
||||
try {
|
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
if (!dTag || event.kind !== 30023) return
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: event.pubkey,
|
||||
identifier: dTag
|
||||
})
|
||||
|
||||
const articleContent: ArticleContent = {
|
||||
title: getArticleTitle(event) || 'Untitled Article',
|
||||
markdown: event.content,
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
summary: getArticleSummary(event),
|
||||
author: event.pubkey,
|
||||
event
|
||||
}
|
||||
|
||||
saveToCache(naddr, articleContent, settings)
|
||||
} catch (err) {
|
||||
// Silently fail cache saves - quota exceeded, invalid data, etc.
|
||||
}
|
||||
}
|
||||
|
||||
export function saveToCache(naddr: string, content: ArticleContent, settings?: UserSettings): void {
|
||||
// Respect user settings: if image caching is disabled, we could skip article caching too
|
||||
// However, for offline-first design, we default to caching unless explicitly disabled
|
||||
// Future: could add explicit enableArticleCache setting
|
||||
// For now, we cache aggressively but handle errors gracefully
|
||||
// Note: settings parameter reserved for future use
|
||||
void settings // Mark as intentionally unused for now
|
||||
try {
|
||||
const cacheKey = getCacheKey(naddr)
|
||||
const cached: CachedArticle = {
|
||||
@@ -63,8 +104,8 @@ function saveToCache(naddr: string, content: ArticleContent): void {
|
||||
}
|
||||
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
||||
} catch (err) {
|
||||
console.warn('Failed to cache article:', err)
|
||||
// Silently fail if storage is full or unavailable
|
||||
// Silently fail - don't block the UI if caching fails
|
||||
// Handles quota exceeded, invalid data, and other errors gracefully
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +205,7 @@ export async function fetchArticleByNaddr(
|
||||
}
|
||||
|
||||
// Save to cache before returning
|
||||
saveToCache(naddr, content)
|
||||
saveToCache(naddr, content, settings)
|
||||
|
||||
// Image caching is handled automatically by Service Worker
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers, IEventStore } from 'applesauce-core'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { cacheArticleEvent } from './articleService'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
@@ -75,6 +76,9 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
}
|
||||
onPost(post)
|
||||
}
|
||||
|
||||
// Cache article content in localStorage for offline access
|
||||
cacheArticleEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,7 +109,6 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
return timeB - timeA // Most recent first
|
||||
})
|
||||
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch blog posts:', error)
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* Service Worker automatically caches images on fetch
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'boris-image-cache-v1'
|
||||
// Must match the cache name in src/sw.ts
|
||||
const CACHE_NAME = 'boris-images'
|
||||
|
||||
/**
|
||||
* Clear all cached images
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BlogPostPreview } from './exploreService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { cacheArticleEvent } from './articleService'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
@@ -57,6 +58,9 @@ export const fetchNostrverseBlogPosts = async (
|
||||
}
|
||||
onPost(post)
|
||||
}
|
||||
|
||||
// Cache article content in localStorage for offline access
|
||||
cacheArticleEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,7 +83,6 @@ export const fetchNostrverseBlogPosts = async (
|
||||
return timeB - timeA // Most recent first
|
||||
})
|
||||
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch nostrverse blog posts:', error)
|
||||
|
||||
@@ -65,6 +65,10 @@ export const fetchProfiles = async (
|
||||
|
||||
const profiles = Array.from(profilesByPubkey.values())
|
||||
|
||||
// Note: We don't preload all profile images here to avoid ERR_INSUFFICIENT_RESOURCES
|
||||
// Profile images will be cached by Service Worker when they're actually displayed.
|
||||
// Only the logged-in user's profile image is preloaded (in SidebarHeader).
|
||||
|
||||
// Rebroadcast profiles to local/all relays based on settings
|
||||
if (profiles.length > 0) {
|
||||
await rebroadcastEvents(profiles, relayPool, settings)
|
||||
|
||||
13
src/sw.ts
13
src/sw.ts
@@ -23,13 +23,15 @@ sw.skipWaiting()
|
||||
clientsClaim()
|
||||
|
||||
|
||||
// Runtime cache: Cross-origin images
|
||||
// This preserves the existing image caching behavior
|
||||
// Runtime cache: All images (cross-origin and same-origin)
|
||||
// Cache both external images and any internal image assets
|
||||
registerRoute(
|
||||
({ request, url }) => {
|
||||
const isImage = request.destination === 'image' ||
|
||||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
|
||||
return isImage && url.origin !== sw.location.origin
|
||||
// Cache all images, not just cross-origin ones
|
||||
// This ensures article images from any source get cached
|
||||
return isImage
|
||||
},
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'boris-images',
|
||||
@@ -41,6 +43,11 @@ registerRoute(
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
{
|
||||
cacheWillUpdate: async ({ response }) => {
|
||||
return response.ok ? response : null
|
||||
}
|
||||
}
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
@@ -107,17 +107,123 @@ export function getNostrUriLabel(encoded: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process markdown to replace nostr URIs while skipping those inside markdown links
|
||||
* This prevents nested markdown link issues when nostr identifiers appear in URLs
|
||||
*/
|
||||
function replaceNostrUrisSafely(
|
||||
markdown: string,
|
||||
getReplacement: (encoded: string) => string
|
||||
): string {
|
||||
// Track positions where we're inside a markdown link URL
|
||||
// Use a parser approach to correctly handle URLs with brackets/parentheses
|
||||
const linkRanges: Array<{ start: number, end: number }> = []
|
||||
|
||||
// Find all markdown link URLs by looking for ]( pattern and tracking to matching )
|
||||
let i = 0
|
||||
while (i < markdown.length) {
|
||||
// Look for ]( pattern that starts a markdown link URL
|
||||
const urlStartMatch = markdown.indexOf('](', i)
|
||||
if (urlStartMatch === -1) break
|
||||
|
||||
const urlStart = urlStartMatch + 2 // Position after "]("
|
||||
|
||||
// Now find the matching closing parenthesis
|
||||
// We need to account for nested parentheses and escaped characters
|
||||
let pos = urlStart
|
||||
let depth = 1 // We're inside one set of parentheses
|
||||
let urlEnd = -1
|
||||
|
||||
while (pos < markdown.length && depth > 0) {
|
||||
const char = markdown[pos]
|
||||
const nextChar = pos + 1 < markdown.length ? markdown[pos + 1] : ''
|
||||
|
||||
// Check for escaped characters
|
||||
if (char === '\\' && nextChar) {
|
||||
pos += 2 // Skip escaped character
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '(') {
|
||||
depth++
|
||||
} else if (char === ')') {
|
||||
depth--
|
||||
if (depth === 0) {
|
||||
urlEnd = pos
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
pos++
|
||||
}
|
||||
|
||||
if (urlEnd !== -1) {
|
||||
linkRanges.push({
|
||||
start: urlStart,
|
||||
end: urlEnd
|
||||
})
|
||||
|
||||
i = urlEnd + 1
|
||||
} else {
|
||||
// No matching closing paren found, skip this one
|
||||
i = urlStart + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a position is inside any markdown link URL
|
||||
const isInsideLinkUrl = (pos: number): boolean => {
|
||||
return linkRanges.some(range => pos >= range.start && pos < range.end)
|
||||
}
|
||||
|
||||
// Replace nostr URIs, but skip those inside link URLs
|
||||
// Also check if nostr URI is part of any URL pattern (http/https URLs)
|
||||
// Callback params: (match, encoded, type, offset, string)
|
||||
const result = markdown.replace(NOSTR_URI_REGEX, (match, encoded, _type, offset, fullString) => {
|
||||
const matchEnd = offset + match.length
|
||||
|
||||
// Check if this match is inside a markdown link URL
|
||||
// Check both start and end positions to ensure we catch the whole match
|
||||
const startInside = isInsideLinkUrl(offset)
|
||||
const endInside = isInsideLinkUrl(matchEnd - 1) // Check end position
|
||||
|
||||
if (startInside || endInside) {
|
||||
// Don't replace - return original match
|
||||
return match
|
||||
}
|
||||
|
||||
// Also check if the nostr URI is part of an HTTP/HTTPS URL pattern
|
||||
// This catches cases where the source markdown has URLs like https://example.com/naddr1...
|
||||
// before they're formatted as markdown links
|
||||
const contextBefore = fullString.slice(Math.max(0, offset - 200), offset)
|
||||
const contextAfter = fullString.slice(matchEnd, Math.min(fullString.length, matchEnd + 10))
|
||||
|
||||
// Check if we're inside an http/https URL (looking for https?:// pattern before the match)
|
||||
// and the match is followed by valid URL characters (not whitespace or closing paren)
|
||||
const urlPatternBefore = /https?:\/\/[^\s)]*$/i
|
||||
const isInHttpUrl = urlPatternBefore.test(contextBefore)
|
||||
const isValidUrlContinuation = !contextAfter.match(/^[\s)]/) // Not followed by space or closing paren
|
||||
|
||||
if (isInHttpUrl && isValidUrlContinuation) {
|
||||
// Don't replace - return original match
|
||||
return match
|
||||
}
|
||||
|
||||
// encoded is already the NIP-19 identifier without nostr: prefix (from capture group)
|
||||
const replacement = getReplacement(encoded)
|
||||
return replacement
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:/, '')
|
||||
return replaceNostrUrisSafely(markdown, (encoded) => {
|
||||
const link = createNostrLink(encoded)
|
||||
const label = getNostrUriLabel(encoded)
|
||||
|
||||
return `[${label}](${link})`
|
||||
})
|
||||
}
|
||||
@@ -132,9 +238,7 @@ 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:/, '')
|
||||
return replaceNostrUrisSafely(markdown, (encoded) => {
|
||||
const link = createNostrLink(encoded)
|
||||
|
||||
// For articles, use the resolved title if available
|
||||
|
||||
@@ -139,7 +139,10 @@ export default defineConfig({
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: 'module'
|
||||
type: 'module',
|
||||
// Use generateSW strategy for dev mode to enable SW testing
|
||||
// This creates a working SW in dev mode, while injectManifest is used in production
|
||||
navigateFallback: 'index.html'
|
||||
}
|
||||
})
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user