Compare commits

...

20 Commits

Author SHA1 Message Date
Gigi
a92a9ee3a3 chore: bump version to 0.3.5 2025-10-09 20:27:59 +01:00
Gigi
f39e34c699 fix: ensure connecting state shows for minimum 15s to prevent premature offline display 2025-10-09 20:27:20 +01:00
Gigi
b58f34d587 fix: add Cloudflare Pages routing config for SPA paths
Add _routes.json configuration to properly handle direct /r/ and /a/ paths
on Cloudflare Pages deployments. This ensures that client-side routes are
served correctly instead of returning 404 errors.
2025-10-09 20:22:08 +01:00
Gigi
76d1d4544e feat: extend connecting state to 8 seconds and remove subtitle text
- Increase 'Connecting' timeout from 4 to 8 seconds
- Remove explanatory subtitle 'Establishing connections...'
- Cleaner, simpler connecting state display
2025-10-09 20:17:29 +01:00
Gigi
5e56176e2d docs: update CHANGELOG for v0.3.3 and v0.3.4 2025-10-09 18:39:30 +01:00
Gigi
a2a4e7e454 chore: bump version to 0.3.4 2025-10-09 18:38:32 +01:00
Gigi
b266288b0f fix: add p tag (author tag) to highlights of nostr-native content
- Highlights now include p tag referencing original article author
- Allows authors to discover highlights of their work
- Follows NIP-84 best practices for highlight attribution
2025-10-09 18:36:20 +01:00
Gigi
1619e328da chore: bump version to 0.3.3 2025-10-09 18:34:12 +01:00
Gigi
b852dad243 fix: resolve linter errors for unused parameters
- Add eslint-disable comments for intentionally unused _settings parameters
- Parameters kept for API compatibility with existing code
- All linter and type checks now pass
2025-10-09 18:31:08 +01:00
Gigi
1552a5f106 feat: reorganize bookmarks UI - add explore button and move refresh
- Move refresh button from top bar to end of bookmarks list
- Show relative time of last fetch next to refresh button
- Add 'Explore' button (fa-newspaper icon) to top bar that links to /explore
- Track lastFetchTime in useBookmarksData hook
- Better UX with explore more prominent and refresh less intrusive
2025-10-09 18:29:41 +01:00
Gigi
0feaffb21b feat: make explore page article cards proper links
- Replace div+onClick with Link components
- Enable CMD+click to open articles in new tabs
- Preserve SPA navigation for normal clicks
- Better UX with standard browser link behavior
2025-10-09 18:27:18 +01:00
Gigi
9b3a4e20de feat: show 'Connecting' instead of 'Offline' on page load
- Display 'Connecting' with spinner for first 4 seconds after page load
- Give relays time to establish connections before showing 'Offline'
- Immediately switch to normal state once any relay connects
- Better UX - most refreshes aren't actually offline, just connecting
2025-10-09 18:26:01 +01:00
Gigi
c83b972a68 fix: correct TypeScript types for cache stats state 2025-10-09 18:24:49 +01:00
Gigi
2e96f93d81 refactor: simplify image caching to use Service Worker only
- Remove complex Cache API management with blob URLs and metadata
- useImageCache now simply returns the URL (Service Worker handles caching)
- imageCacheService reduced to just stats and clear functions
- Service Worker automatically caches all images on fetch
- Much simpler, DRY code that 'just works' for offline mode
- Stats now read directly from Cache API instead of localStorage metadata
2025-10-09 18:24:22 +01:00
Gigi
1e8182d984 feat: add Service Worker for robust offline image caching
- Implement Service Worker to intercept and cache image requests
- Service Worker persists across hard reloads unlike Cache API alone
- Simplify useImageCache hook to work with Service Worker
- Images now work offline even after hard reload
- Service Worker handles transparent cache-first serving for images
2025-10-09 18:17:27 +01:00
Gigi
b20a67d4d0 fix: improve image cache resilience for offline viewing
- Clean up stale metadata when Cache API doesn't have cached data
- Handle online/offline state properly in image loading
- Show original URL when online, blob URL from cache when offline
- Prevent cache misses when browser clears Cache API on hard reload
2025-10-09 18:15:30 +01:00
Gigi
60975b449d fix: import useEventModel from applesauce-react/hooks for proper type safety 2025-10-09 18:10:00 +01:00
Gigi
704fce4d80 fix: import Models from applesauce-core instead of applesauce-react 2025-10-09 18:07:45 +01:00
Gigi
4d1eb0f9fd fix: use correct useEventModel hook for profile loading in BlogPostCard 2025-10-09 18:03:32 +01:00
Gigi
ceafe277d3 feat: add /explore route to discover blog posts from friends
- Create exploreService to fetch kind:30023 events from followed users
- Add BlogPostCard component for displaying blog post previews
- Add Explore page component with grid layout
- Add /explore route to App.tsx (not linked in navigation yet)
- Add responsive CSS styles for explore page and blog post cards
- Clicking blog post cards navigates to article view
2025-10-09 18:02:07 +01:00
21 changed files with 745 additions and 359 deletions

View File

@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.4] - 2025-10-09
### Fixed
- Add p tag (author tag) to highlights of nostr-native content for proper attribution
## [0.3.3] - 2025-10-09
### Added
- Service Worker for robust offline image caching
- /explore route to discover blog posts from friends on Nostr
- Explore button (newspaper icon) in bookmarks header
- "Connecting" status indicator on page load (instead of immediately showing "Offline")
- Last fetch time display with relative timestamps in bookmarks list
### Changed
- Simplify image caching to use Service Worker transparently
- Move refresh button from top bar to end of bookmarks list
- Make explore page article cards proper links (supports CMD+click to open in new tab)
- Reorganize bookmarks UI for better UX
### Fixed
- Improve image cache resilience for offline viewing and hard reloads
- Correct TypeScript types for cache stats state
- Resolve linter errors for unused parameters
- Import useEventModel from applesauce-react/hooks for proper type safety
- Import Models from applesauce-core instead of applesauce-react
- Use correct useEventModel hook for profile loading in BlogPostCard
## [0.3.0] - 2025-10-09
### Added
@@ -472,6 +500,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling
[0.3.4]: https://github.com/dergigi/boris/compare/v0.3.3...v0.3.4
[0.3.3]: https://github.com/dergigi/boris/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/dergigi/boris/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/dergigi/boris/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/dergigi/boris/compare/v0.2.10...v0.3.0
[0.2.10]: https://github.com/dergigi/boris/compare/v0.2.9...v0.2.10
[0.2.9]: https://github.com/dergigi/boris/compare/v0.2.8...v0.2.9

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.3.2",
"version": "0.3.5",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",

6
public/_routes.json Normal file
View File

@@ -0,0 +1,6 @@
{
"version": 1,
"include": ["/*"],
"exclude": ["/assets/*", "/robots.txt", "/sw.js", "/_headers", "/_redirects"]
}

56
public/sw.js Normal file
View File

@@ -0,0 +1,56 @@
// Service Worker for Boris - handles offline image caching
const CACHE_NAME = 'boris-image-cache-v1'
// Install event - activate immediately
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...')
self.skipWaiting()
})
// Activate event - take control immediately
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...')
event.waitUntil(self.clients.claim())
})
// Fetch event - intercept image requests
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
// Only intercept image requests
const isImage = event.request.destination === 'image' ||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
if (!isImage) {
return // Let other requests pass through
}
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(cachedResponse => {
if (cachedResponse) {
console.log('[SW] Serving cached image:', url.pathname)
return cachedResponse
}
// Not in cache, try to fetch
return fetch(event.request)
.then(response => {
// Only cache successful responses
if (response && response.status === 200) {
// Clone the response before caching
cache.put(event.request, response.clone())
console.log('[SW] Cached new image:', url.pathname)
}
return response
})
.catch(error => {
console.error('[SW] Fetch failed for:', url.pathname, error)
// Return a fallback or let it fail
throw error
})
})
})
)
})

View File

@@ -9,6 +9,7 @@ import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
import { RelayPool } from 'applesauce-relay'
import { createAddressLoader } from 'applesauce-loaders/loaders'
import Bookmarks from './components/Bookmarks'
import Explore from './components/Explore'
import Toast from './components/Toast'
import { useToast } from './hooks/useToast'
import { RELAYS } from './config/relays'
@@ -61,6 +62,12 @@ function AppRoutes({
/>
}
/>
<Route
path="/explore"
element={
<Explore relayPool={relayPool} />
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes>
)

View File

@@ -0,0 +1,61 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons'
import { formatDistance } from 'date-fns'
import { BlogPostPreview } from '../services/exploreService'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
interface BlogPostCardProps {
post: BlogPostPreview
href: string
}
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
const profile = useEventModel(Models.ProfileModel, [post.author])
const displayName = profile?.name || profile?.display_name ||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
const publishedDate = post.published || post.event.created_at
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
addSuffix: true
})
return (
<Link
to={href}
className="blog-post-card"
style={{ textDecoration: 'none', color: 'inherit' }}
>
{post.image && (
<div className="blog-post-card-image">
<img
src={post.image}
alt={post.title}
loading="lazy"
/>
</div>
)}
<div className="blog-post-card-content">
<h3 className="blog-post-card-title">{post.title}</h3>
{post.summary && (
<p className="blog-post-card-summary">{post.summary}</p>
)}
<div className="blog-post-card-meta">
<span className="blog-post-card-author">
<FontAwesomeIcon icon={faUser} />
{displayName}
</span>
<span className="blog-post-card-date">
<FontAwesomeIcon icon={faCalendar} />
{formattedDate}
</span>
</div>
</div>
</Link>
)
}
export default BlogPostCard

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
import { formatDistanceToNow } from 'date-fns'
import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import { BookmarkItem } from './BookmarkItem'
@@ -22,6 +23,7 @@ interface BookmarkListProps {
onOpenSettings: () => void
onRefresh?: () => void
isRefreshing?: boolean
lastFetchTime?: number | null
loading?: boolean
relayPool: RelayPool | null
settings?: UserSettings
@@ -39,6 +41,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onOpenSettings,
onRefresh,
isRefreshing,
lastFetchTime,
loading = false,
relayPool,
settings
@@ -102,8 +105,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onToggleCollapse={onToggleCollapse}
onLogout={onLogout}
onOpenSettings={onOpenSettings}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
relayPool={relayPool}
/>
@@ -130,6 +131,34 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
/>
)}
</div>
{onRefresh && (
<div className="refresh-section" style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
padding: '1rem',
marginTop: '1rem',
borderTop: '1px solid var(--border-color)',
fontSize: '0.85rem',
color: 'var(--text-secondary)'
}}>
<IconButton
icon={faRotate}
onClick={onRefresh}
title="Refresh bookmarks"
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
{lastFetchTime && (
<span>
Updated {formatDistanceToNow(lastFetchTime, { addSuffix: true })}
</span>
)}
</div>
)}
</div>
)}
<div className="view-mode-controls">

View File

@@ -94,6 +94,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setHighlightsLoading,
followedPubkeys,
isRefreshing,
lastFetchTime,
handleFetchHighlights,
handleRefreshAll
} = useBookmarksData({
@@ -182,6 +183,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
bookmarksLoading={bookmarksLoading}
viewMode={viewMode}
isRefreshing={isRefreshing}
lastFetchTime={lastFetchTime}
onToggleSidebar={() => setIsCollapsed(!isCollapsed)}
onLogout={onLogout}
onViewModeChange={setViewMode}

129
src/components/Explore.tsx Normal file
View File

@@ -0,0 +1,129 @@
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faCompass } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { fetchContacts } from '../services/contactService'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import BlogPostCard from './BlogPostCard'
interface ExploreProps {
relayPool: RelayPool
}
const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
const activeAccount = Hooks.useActiveAccount()
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const loadBlogPosts = async () => {
if (!activeAccount) {
setError('Please log in to explore content from your friends')
setLoading(false)
return
}
try {
setLoading(true)
setError(null)
// Fetch the user's contacts (friends)
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
if (contacts.size === 0) {
setError('You are not following anyone yet. Follow some people to see their blog posts!')
setLoading(false)
return
}
// Get relay URLs from pool
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
// Fetch blog posts from friends
const posts = await fetchBlogPostsFromAuthors(
relayPool,
Array.from(contacts),
relayUrls
)
if (posts.length === 0) {
setError('No blog posts found from your friends yet')
}
setBlogPosts(posts)
} catch (err) {
console.error('Failed to load blog posts:', err)
setError('Failed to load blog posts. Please try again.')
} finally {
setLoading(false)
}
}
loadBlogPosts()
}, [relayPool, activeAccount])
const getPostUrl = (post: BlogPostPreview) => {
// Get the d-tag identifier
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
// Create naddr
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
return `/a/${naddr}`
}
if (loading) {
return (
<div className="explore-container">
<div className="explore-loading">
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<p>Loading blog posts from your friends...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="explore-container">
<div className="explore-error">
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
<p>{error}</p>
</div>
</div>
)
}
return (
<div className="explore-container">
<div className="explore-header">
<h1>
<FontAwesomeIcon icon={faCompass} />
Explore
</h1>
<p className="explore-subtitle">
Discover blog posts from your friends on Nostr
</p>
</div>
<div className="explore-grid">
{blogPosts.map((post) => (
<BlogPostCard
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
post={post}
href={getPostUrl(post)}
/>
))}
</div>
</div>
)
}
export default Explore

View File

@@ -1,6 +1,6 @@
import React from 'react'
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPlane, faGlobe, faCircle } from '@fortawesome/free-solid-svg-icons'
import { faPlane, faGlobe, faCircle, faSpinner } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { useRelayStatus } from '../hooks/useRelayStatus'
import { isLocalRelay } from '../utils/helpers'
@@ -12,6 +12,8 @@ interface RelayStatusIndicatorProps {
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ relayPool }) => {
// Poll frequently for responsive offline indicator (5s instead of default 20s)
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
const [isConnecting, setIsConnecting] = useState(true)
const [connectingStartTime] = useState(Date.now())
if (!relayPool) return null
@@ -25,32 +27,59 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
const localOnlyMode = hasLocalRelay && !hasRemoteRelay
const offlineMode = connectedUrls.length === 0
// Show "Connecting" for minimum duration (15s) to avoid flashing states
useEffect(() => {
const MIN_CONNECTING_DURATION = 15000 // 15 seconds minimum
const elapsedTime = Date.now() - connectingStartTime
if (connectedUrls.length > 0 && elapsedTime >= MIN_CONNECTING_DURATION) {
// Connected and minimum time passed - stop showing connecting state
setIsConnecting(false)
} else if (connectedUrls.length > 0) {
// Connected but haven't shown connecting long enough
const remainingTime = MIN_CONNECTING_DURATION - elapsedTime
const timeout = setTimeout(() => {
setIsConnecting(false)
}, remainingTime)
return () => clearTimeout(timeout)
} else if (elapsedTime >= MIN_CONNECTING_DURATION) {
// No connections and minimum time passed - show offline
setIsConnecting(false)
}
// If no connections and time hasn't passed, keep showing connecting
}, [connectedUrls.length, connectingStartTime])
// Debug logging
React.useEffect(() => {
useEffect(() => {
console.log('🔌 Relay Status Indicator:', {
mode: offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
mode: isConnecting ? 'CONNECTING' : offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
totalStatuses: relayStatuses.length,
connectedCount: connectedUrls.length,
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
hasLocalRelay,
hasRemoteRelay
hasRemoteRelay,
isConnecting
})
}, [offlineMode, localOnlyMode, connectedUrls.length, relayStatuses.length, hasLocalRelay, hasRemoteRelay])
}, [offlineMode, localOnlyMode, connectedUrls.length, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
// Don't show indicator when fully connected
if (!localOnlyMode && !offlineMode) return null
// Don't show indicator when fully connected (but show when connecting)
if (!localOnlyMode && !offlineMode && !isConnecting) return null
return (
<div className="relay-status-indicator" title={
offlineMode
? 'Offline - No relays connected'
: 'Local Relays Only - Highlights will be marked as local'
isConnecting
? 'Connecting to relays...'
: offlineMode
? 'Offline - No relays connected'
: 'Local Relays Only - Highlights will be marked as local'
}>
<div className="relay-status-icon">
<FontAwesomeIcon icon={offlineMode ? faCircle : faPlane} />
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
</div>
<div className="relay-status-text">
{offlineMode ? (
{isConnecting ? (
<span className="relay-status-title">Connecting</span>
) : offlineMode ? (
<>
<span className="relay-status-title">Offline</span>
<span className="relay-status-subtitle">No relays connected</span>
@@ -62,7 +91,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
</>
)}
</div>
{!offlineMode && (
{!offlineMode && !isConnecting && (
<div className="relay-status-pulse">
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
</div>

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { faTrash } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import { getImageCacheStats, clearImageCache } from '../../services/imageCacheService'
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
import IconButton from '../IconButton'
interface OfflineModeSettingsProps {
@@ -13,7 +13,11 @@ interface OfflineModeSettingsProps {
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
const navigate = useNavigate()
const [cacheStats, setCacheStats] = useState(getImageCacheStats())
const [cacheStats, setCacheStats] = useState<{
totalSizeMB: number
itemCount: number
items: Array<{ url: string, sizeMB: number }>
}>({ totalSizeMB: 0, itemCount: 0, items: [] })
const handleLinkClick = (url: string) => {
if (onClose) onClose()
@@ -23,14 +27,20 @@ const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onU
const handleClearCache = async () => {
if (confirm('Are you sure you want to clear all cached images?')) {
await clearImageCache()
setCacheStats(getImageCacheStats())
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
}
// Update cache stats when settings change
// Update cache stats periodically
useEffect(() => {
const updateStats = () => setCacheStats(getImageCacheStats())
const interval = setInterval(updateStats, 2000) // Update every 2 seconds
const updateStats = async () => {
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
updateStats() // Initial load
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
return () => clearInterval(interval)
}, [])

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate, faHome, faPlus } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
@@ -16,12 +16,10 @@ interface SidebarHeaderProps {
onToggleCollapse: () => void
onLogout: () => void
onOpenSettings: () => void
onRefresh?: () => void
isRefreshing?: boolean
relayPool: RelayPool | null
}
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, onRefresh, isRefreshing, relayPool }) => {
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool }) => {
const [isConnecting, setIsConnecting] = useState(false)
const [showAddModal, setShowAddModal] = useState(false)
const navigate = useNavigate()
@@ -61,11 +59,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
}
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
// Refresh bookmarks after creating
if (onRefresh) {
onRefresh()
}
}
const profileImage = getProfileImage()
@@ -101,6 +94,13 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Home"
variant="ghost"
/>
<IconButton
icon={faNewspaper}
onClick={() => navigate('/explore')}
title="Explore"
ariaLabel="Explore"
variant="ghost"
/>
<IconButton
icon={faGear}
onClick={onOpenSettings}
@@ -108,17 +108,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Settings"
variant="ghost"
/>
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title="Refresh bookmarks"
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
)}
{activeAccount && (
<IconButton
icon={faPlus}

View File

@@ -28,6 +28,7 @@ interface ThreePaneLayoutProps {
bookmarksLoading: boolean
viewMode: ViewMode
isRefreshing: boolean
lastFetchTime?: number | null
onToggleSidebar: () => void
onLogout: () => void
onViewModeChange: (mode: ViewMode) => void
@@ -90,6 +91,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
onOpenSettings={props.onOpenSettings}
onRefresh={props.onRefresh}
isRefreshing={props.isRefreshing}
lastFetchTime={props.lastFetchTime}
loading={props.bookmarksLoading}
relayPool={props.relayPool}
settings={props.settings}

View File

@@ -34,6 +34,7 @@ export const useBookmarksData = ({
const [highlightsLoading, setHighlightsLoading] = useState(true)
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [isRefreshing, setIsRefreshing] = useState(false)
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
const handleFetchContacts = useCallback(async () => {
if (!relayPool || !activeAccount) return
@@ -93,6 +94,7 @@ export const useBookmarksData = ({
await handleFetchBookmarks()
await handleFetchHighlights()
await handleFetchContacts()
setLastFetchTime(Date.now())
} catch (err) {
console.error('Failed to refresh data:', err)
} finally {
@@ -119,6 +121,7 @@ export const useBookmarksData = ({
setHighlightsLoading,
followedPubkeys,
isRefreshing,
lastFetchTime,
handleFetchBookmarks,
handleFetchHighlights,
handleRefreshAll

View File

@@ -1,116 +1,34 @@
import { useState, useEffect } from 'react'
import { cacheImage, getCachedImage, loadCachedImage } from '../services/imageCacheService'
import { UserSettings } from '../services/settingsService'
/**
* Hook to cache and retrieve images using Cache API
* Hook to return image URL for display
* Service Worker handles all caching transparently
* Images are cached on first load and available offline automatically
*
* @param imageUrl - The URL of the image to cache
* @param settings - User settings to determine if caching is enabled
* @returns The cached blob URL or the original URL
* @param imageUrl - The URL of the image to display
* @returns The image URL (Service Worker handles caching)
*/
export function useImageCache(
imageUrl: string | undefined,
settings: UserSettings | undefined
// eslint-disable-next-line no-unused-vars
_settings?: UserSettings
): string | undefined {
const [cachedUrl, setCachedUrl] = useState<string | undefined>(imageUrl)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
if (!imageUrl) {
setCachedUrl(undefined)
return
}
// If caching is disabled, just use the original URL
const enableCache = settings?.enableImageCache ?? true // Default to enabled
if (!enableCache) {
setCachedUrl(imageUrl)
return
}
// Store imageUrl in local variable for closure
const urlToCache = imageUrl
// Check if image is in cache metadata (fast synchronous check)
const isCached = getCachedImage(urlToCache)
if (isCached) {
// Load the cached image asynchronously
loadCachedImage(urlToCache)
.then(blobUrl => {
if (blobUrl) {
console.log('📦 Using cached image:', urlToCache.substring(0, 50))
setCachedUrl(blobUrl)
} else {
// Not actually cached, fall through to caching logic
setCachedUrl(urlToCache)
cacheInBackground()
}
})
.catch(err => {
console.error('Failed to load cached image:', err)
setCachedUrl(urlToCache)
})
} else {
// Not cached, show original and cache in background
setCachedUrl(urlToCache)
cacheInBackground()
}
function cacheInBackground() {
if (!isLoading) {
setIsLoading(true)
const maxSize = settings?.imageCacheSizeMB ?? 210
cacheImage(urlToCache, maxSize)
.then(blobUrl => {
setCachedUrl(blobUrl)
})
.catch(err => {
console.error('Failed to cache image:', err)
// Keep using original URL on error
})
.finally(() => {
setIsLoading(false)
})
}
}
// Cleanup: revoke blob URLs when component unmounts or URL changes
return () => {
if (cachedUrl && cachedUrl.startsWith('blob:')) {
URL.revokeObjectURL(cachedUrl)
}
}
}, [imageUrl, settings?.enableImageCache, settings?.imageCacheSizeMB])
return cachedUrl
// Service Worker handles everything - just return the URL as-is
return imageUrl
}
/**
* Simpler hook variant that just caches on mount if enabled
* Useful for preloading article cover images
* Pre-load image to ensure it's cached by Service Worker
* Triggers a fetch so the SW can cache it even if not visible yet
*/
export function useCacheImageOnLoad(
imageUrl: string | undefined,
settings: UserSettings | undefined
// eslint-disable-next-line no-unused-vars
_settings?: UserSettings
): void {
useEffect(() => {
if (!imageUrl) return
const enableCache = settings?.enableImageCache ?? true
if (!enableCache) return
// Check if already cached (fast metadata check)
const isCached = getCachedImage(imageUrl)
if (isCached) return
// Cache in background
const maxSize = settings?.imageCacheSizeMB ?? 210
cacheImage(imageUrl, maxSize).catch(err => {
console.error('Failed to cache image:', err)
})
}, [imageUrl, settings?.enableImageCache, settings?.imageCacheSizeMB])
// Service Worker will cache on first fetch
// This hook is now a no-op, kept for API compatibility
// The browser will automatically fetch and cache images when they're used in <img> tags
void imageUrl
}

View File

@@ -2555,3 +2555,163 @@ body {
.three-pane.sidebar-collapsed .relay-status-indicator {
left: calc(var(--sidebar-collapsed-width) + 1.5rem);
}
/* Explore Page Styles */
.explore-container {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
min-height: 100vh;
}
.explore-header {
text-align: center;
margin-bottom: 3rem;
}
.explore-header h1 {
font-size: 2.5rem;
margin: 0 0 1rem 0;
color: #646cff;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.explore-subtitle {
font-size: 1.125rem;
color: rgba(255, 255, 255, 0.7);
margin: 0;
}
.explore-loading,
.explore-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
min-height: 50vh;
color: rgba(255, 255, 255, 0.7);
}
.explore-error {
color: #ff6b6b;
}
.explore-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.blog-post-card {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
overflow: hidden;
transition: all 0.3s ease;
cursor: pointer;
display: flex;
flex-direction: column;
height: 100%;
}
.blog-post-card:hover {
border-color: #646cff;
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(100, 108, 255, 0.15);
}
.blog-post-card-image {
width: 100%;
height: 200px;
overflow: hidden;
background: #0f0f0f;
}
.blog-post-card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.blog-post-card:hover .blog-post-card-image img {
transform: scale(1.05);
}
.blog-post-card-content {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
}
.blog-post-card-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: rgba(255, 255, 255, 0.95);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.blog-post-card-summary {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.6);
margin: 0;
line-height: 1.6;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
flex: 1;
}
.blog-post-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-top: 0.75rem;
border-top: 1px solid #333;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
flex-wrap: wrap;
}
.blog-post-card-author,
.blog-post-card-date {
display: flex;
align-items: center;
gap: 0.5rem;
}
.blog-post-card-author svg,
.blog-post-card-date svg {
opacity: 0.7;
}
@media (max-width: 768px) {
.explore-container {
padding: 1rem;
}
.explore-header h1 {
font-size: 2rem;
}
.explore-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
}

View File

@@ -3,6 +3,32 @@ import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
// Register Service Worker for offline image caching
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then(registration => {
console.log('✅ Service Worker registered:', registration.scope)
// Update service worker when a new version is available
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
console.log('🔄 Service Worker updated, page may need reload')
}
})
}
})
})
.catch(error => {
console.error('❌ Service Worker registration failed:', error)
})
})
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />

View File

@@ -7,7 +7,6 @@ import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
import { cacheImage } from './imageCacheService'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -146,13 +145,7 @@ export async function fetchArticleByNaddr(
// Save to cache before returning
saveToCache(naddr, content)
// Cache cover image if enabled and present
if (image && settings?.enableImageCache !== false) {
const maxSize = settings?.imageCacheSizeMB ?? 210
cacheImage(image, maxSize).catch(err => {
console.warn('Failed to cache article cover image:', err)
})
}
// Image caching is handled automatically by Service Worker
return content
} catch (err) {

View File

@@ -0,0 +1,87 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
export interface BlogPostPreview {
event: NostrEvent
title: string
summary?: string
image?: string
published?: number
author: string
}
/**
* Fetches blog posts (kind:30023) from a list of pubkeys (friends)
* @param relayPool - The relay pool to query
* @param pubkeys - Array of pubkeys to fetch posts from
* @param relayUrls - Array of relay URLs to query
* @returns Array of blog post previews
*/
export const fetchBlogPostsFromAuthors = async (
relayPool: RelayPool,
pubkeys: string[],
relayUrls: string[]
): Promise<BlogPostPreview[]> => {
try {
if (pubkeys.length === 0) {
console.log('⚠️ No pubkeys to fetch blog posts from')
return []
}
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
const events = await lastValueFrom(
relayPool
.req(relayUrls, {
kinds: [30023],
authors: pubkeys,
limit: 100 // Fetch up to 100 recent posts
})
.pipe(completeOnEose(), takeUntil(timer(15000)), toArray())
)
console.log('📊 Blog post events fetched:', events.length)
// Deduplicate replaceable events by keeping the most recent version
// Group by author + d-tag identifier
const uniqueEvents = new Map<string, NostrEvent>()
for (const event of events) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
if (!existing || event.created_at > existing.created_at) {
uniqueEvents.set(key, event)
}
}
// Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
.map(event => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}))
.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA // Most recent first
})
console.log('📰 Processed', blogPosts.length, 'unique blog posts')
return blogPosts
} catch (error) {
console.error('Failed to fetch blog posts:', error)
return []
}
}

View File

@@ -80,6 +80,16 @@ export async function createHighlight(
highlightEvent.tags.push(['alt', 'Highlight created by Boris. readwithboris.com'])
}
// Add p tag (author tag) for nostr-native content
// This tags the original author so they can see highlights of their work
if (typeof source === 'object' && 'kind' in source) {
// Only add p tag if it doesn't already exist
const hasPTag = highlightEvent.tags.some(tag => tag[0] === 'p' && tag[1] === source.pubkey)
if (!hasPTag) {
highlightEvent.tags.push(['p', source.pubkey])
}
}
// Add zap tags for nostr-native content (NIP-57 Appendix G)
if (typeof source === 'object' && 'kind' in source) {
// Migrate old settings format to new weight-based format if needed

View File

@@ -1,206 +1,18 @@
/**
* Image Cache Service
*
* Caches images using the Cache API for offline access.
* Uses LRU (Least Recently Used) eviction when cache size limit is exceeded.
* Utility functions for managing the Service Worker's image cache
* Service Worker automatically caches images on fetch
*/
const CACHE_NAME = 'boris-image-cache-v1'
const CACHE_METADATA_KEY = 'img_cache_metadata'
interface CacheMetadata {
[url: string]: {
size: number
lastAccessed: number
}
}
/**
* Get cache metadata from localStorage
*/
function getMetadata(): CacheMetadata {
try {
const data = localStorage.getItem(CACHE_METADATA_KEY)
return data ? JSON.parse(data) : {}
} catch {
return {}
}
}
/**
* Save cache metadata to localStorage
*/
function saveMetadata(metadata: CacheMetadata): void {
try {
localStorage.setItem(CACHE_METADATA_KEY, JSON.stringify(metadata))
} catch (err) {
console.warn('Failed to save image cache metadata:', err)
}
}
/**
* Calculate total cache size in bytes
*/
function getTotalCacheSize(): number {
const metadata = getMetadata()
return Object.values(metadata).reduce((sum, item) => sum + item.size, 0)
}
/**
* Convert bytes to MB
*/
function bytesToMB(bytes: number): number {
return bytes / (1024 * 1024)
}
/**
* Convert MB to bytes
*/
function mbToBytes(mb: number): number {
return mb * 1024 * 1024
}
/**
* Evict least recently used images until cache is under limit
*/
async function evictLRU(maxSizeBytes: number): Promise<void> {
const metadata = getMetadata()
const entries = Object.entries(metadata)
// Sort by last accessed (oldest first)
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
let currentSize = getTotalCacheSize()
const cache = await caches.open(CACHE_NAME)
for (const [url, item] of entries) {
if (currentSize <= maxSizeBytes) break
try {
await cache.delete(url)
delete metadata[url]
currentSize -= item.size
console.log(`🗑️ Evicted image from cache: ${url.substring(0, 50)}...`)
} catch (err) {
console.warn('Failed to evict image:', err)
}
}
saveMetadata(metadata)
}
/**
* Cache an image using Cache API
*/
export async function cacheImage(
url: string,
maxCacheSizeMB: number = 210
): Promise<string> {
try {
// Check if already cached
const cached = await getCachedImageUrl(url)
if (cached) {
console.log('✅ Image already cached:', url.substring(0, 50))
return cached
}
// Fetch the image
console.log('📥 Caching image:', url.substring(0, 50))
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`)
}
// Clone the response so we can read it twice (once for size, once for cache)
const responseClone = response.clone()
const blob = await response.blob()
const size = blob.size
// Check if image alone exceeds cache limit
if (bytesToMB(size) > maxCacheSizeMB) {
console.warn(`⚠️ Image too large to cache (${bytesToMB(size).toFixed(2)}MB > ${maxCacheSizeMB}MB)`)
return url // Return original URL if too large
}
const maxSizeBytes = mbToBytes(maxCacheSizeMB)
// Evict old images if necessary
const currentSize = getTotalCacheSize()
if (currentSize + size > maxSizeBytes) {
await evictLRU(maxSizeBytes - size)
}
// Store in Cache API
const cache = await caches.open(CACHE_NAME)
await cache.put(url, responseClone)
// Update metadata
const metadata = getMetadata()
metadata[url] = {
size,
lastAccessed: Date.now()
}
saveMetadata(metadata)
console.log(`💾 Cached image (${bytesToMB(size).toFixed(2)}MB). Total cache: ${bytesToMB(getTotalCacheSize()).toFixed(2)}MB`)
// Return blob URL for immediate use
return URL.createObjectURL(blob)
} catch (err) {
console.error('Failed to cache image:', err)
return url // Return original URL on error
}
}
/**
* Get cached image URL (creates blob URL from cached response)
*/
async function getCachedImageUrl(url: string): Promise<string | null> {
try {
const cache = await caches.open(CACHE_NAME)
const response = await cache.match(url)
if (!response) {
return null
}
// Update last accessed time in metadata
const metadata = getMetadata()
if (metadata[url]) {
metadata[url].lastAccessed = Date.now()
saveMetadata(metadata)
}
// Convert response to blob URL
const blob = await response.blob()
return URL.createObjectURL(blob)
} catch {
return null
}
}
/**
* Get cached image (synchronous wrapper that returns null, actual loading happens async)
* This maintains backward compatibility with the hook's synchronous check
*/
export function getCachedImage(url: string): string | null {
// Check if we have metadata for this URL
const metadata = getMetadata()
return metadata[url] ? url : null // Return URL if in metadata, let hook handle async loading
}
/**
* Clear all cached images
*/
export async function clearImageCache(): Promise<void> {
try {
// Clear from Cache API
await caches.delete(CACHE_NAME)
// Clear metadata from localStorage
localStorage.removeItem(CACHE_METADATA_KEY)
console.log('🗑️ Cleared all cached images')
} catch (err) {
console.error('Failed to clear image cache:', err)
@@ -208,30 +20,55 @@ export async function clearImageCache(): Promise<void> {
}
/**
* Get cache statistics
* Get cache statistics by inspecting Cache API directly
*/
export async function getImageCacheStatsAsync(): Promise<{
totalSizeMB: number
itemCount: number
items: Array<{ url: string, sizeMB: number }>
}> {
try {
const cache = await caches.open(CACHE_NAME)
const requests = await cache.keys()
let totalSize = 0
const items: Array<{ url: string, sizeMB: number }> = []
for (const request of requests) {
const response = await cache.match(request)
if (response) {
const blob = await response.blob()
const sizeMB = blob.size / (1024 * 1024)
totalSize += blob.size
items.push({ url: request.url, sizeMB })
}
}
return {
totalSizeMB: totalSize / (1024 * 1024),
itemCount: requests.length,
items
}
} catch (err) {
console.error('Failed to get cache stats:', err)
return { totalSizeMB: 0, itemCount: 0, items: [] }
}
}
/**
* Synchronous wrapper for cache stats (returns approximate values)
* For real-time stats, use getImageCacheStatsAsync
*/
export function getImageCacheStats(): {
totalSizeMB: number
itemCount: number
items: Array<{ url: string, sizeMB: number, lastAccessed: Date }>
} {
const metadata = getMetadata()
const entries = Object.entries(metadata)
// Return placeholder - actual stats require async Cache API access
// Component should use getImageCacheStatsAsync for real values
return {
totalSizeMB: bytesToMB(getTotalCacheSize()),
itemCount: entries.length,
items: entries.map(([url, item]) => ({
url,
sizeMB: bytesToMB(item.size),
lastAccessed: new Date(item.lastAccessed)
}))
totalSizeMB: 0,
itemCount: 0,
items: []
}
}
/**
* Load cached image asynchronously (for use in hooks/components)
*/
export async function loadCachedImage(url: string): Promise<string | null> {
return getCachedImageUrl(url)
}