mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f2ecd5fe1 | ||
|
|
d6be6f364b | ||
|
|
035d4d3bd0 | ||
|
|
43d5554c0c | ||
|
|
724a3e5cfa | ||
|
|
0c49988d36 | ||
|
|
70de68848b | ||
|
|
8a12ae72cb | ||
|
|
f8d5d19a9f | ||
|
|
dbd20e676f | ||
|
|
bbdf47fb94 | ||
|
|
1b754e02dc | ||
|
|
a2e410252a | ||
|
|
c9a14d151d | ||
|
|
b286562e86 | ||
|
|
507288f51c | ||
|
|
e08bc54f15 | ||
|
|
4306069191 | ||
|
|
56e56af8ec |
47
CHANGELOG.md
47
CHANGELOG.md
@@ -5,6 +5,52 @@ 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.0] - 2025-10-09
|
||||
|
||||
### Added
|
||||
- Flight Mode with offline highlight creation and local relay support
|
||||
- Automatic offline sync - rebroadcast local events when back online
|
||||
- Relay indicator icon on highlight items showing sync status
|
||||
- Click-to-rebroadcast functionality for highlights
|
||||
- Flight mode indicator (plane icon) on offline-created highlights
|
||||
- Relay rebroadcast settings for caching and propagation
|
||||
- Local relay status indicator for local-only/offline mode
|
||||
- Second local relay support (localhost:4869)
|
||||
- Relay connection status tracking and display
|
||||
- 6th font size option for better UI scaling
|
||||
|
||||
### Fixed
|
||||
- Highlight creation resilient to offline/flight mode
|
||||
- TypeScript type errors in offline sync
|
||||
- Relay indicator tooltip accuracy and reliability
|
||||
- Always show relay indicator icon on highlights
|
||||
- Show remote relay list for fetched highlights
|
||||
- Publish highlights to all connected relays instead of just one
|
||||
- Keep all relay connections alive, not just local ones
|
||||
- Check actual relay connection status instead of pool membership
|
||||
- Skip rebroadcasting when in flight mode
|
||||
- Update relay info after automatic sync completes
|
||||
- Only show successfully reachable relays in flight mode
|
||||
- Include local relays in relay indicator tooltip
|
||||
|
||||
### Changed
|
||||
- Rename 'Offline Mode' to 'Flight Mode' throughout UI
|
||||
- Move publication date to top-right corner with subtle border styling
|
||||
- Consolidate relay/status indicators into single unified icon
|
||||
- Simplify relay indicator tooltip to show relay list
|
||||
- Move rebroadcast settings to dedicated Flight Mode section
|
||||
- Place Reading Font and Font Size settings side-by-side
|
||||
- Improve font size scale and default value
|
||||
- Use wifi icon for disconnected remote relays
|
||||
- Use airplane icons for local relay indicators
|
||||
- Make Relays heading same level as Flight Mode in settings
|
||||
- Simplify rebroadcast settings UI with consistent checkbox style
|
||||
|
||||
### Performance
|
||||
- Make highlight creation instant with non-blocking relay publish
|
||||
- Reduce relay status polling interval to 20 seconds
|
||||
- Show sync progress and hide indicator after successful sync
|
||||
|
||||
## [0.2.10] - 2025-10-09
|
||||
|
||||
### Added
|
||||
@@ -426,6 +472,7 @@ 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.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
|
||||
[0.2.8]: https://github.com/dergigi/boris/compare/v0.2.7...v0.2.8
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.2",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
@@ -11,15 +11,17 @@ import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
|
||||
import { CompactView } from './BookmarkViews/CompactView'
|
||||
import { LargeView } from './BookmarkViews/LargeView'
|
||||
import { CardView } from './BookmarkViews/CardView'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface BookmarkItemProps {
|
||||
bookmark: IndividualBookmark
|
||||
index: number
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
viewMode?: ViewMode
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', settings }) => {
|
||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||
|
||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||
@@ -115,7 +117,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary
|
||||
articleSummary,
|
||||
settings
|
||||
}
|
||||
|
||||
if (viewMode === 'compact') {
|
||||
|
||||
@@ -8,6 +8,7 @@ import SidebarHeader from './SidebarHeader'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface BookmarkListProps {
|
||||
bookmarks: Bookmark[]
|
||||
@@ -23,6 +24,7 @@ interface BookmarkListProps {
|
||||
isRefreshing?: boolean
|
||||
loading?: boolean
|
||||
relayPool: RelayPool | null
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
@@ -38,7 +40,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
loading = false,
|
||||
relayPool
|
||||
relayPool,
|
||||
settings
|
||||
}) => {
|
||||
// Helper to check if a bookmark has either content or a URL
|
||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||
@@ -123,6 +126,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
settings={settings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import IconButton from '../IconButton'
|
||||
import { classifyUrl } from '../../utils/helpers'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface CardViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -22,6 +24,7 @@ interface CardViewProps {
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const CardView: React.FC<CardViewProps> = ({
|
||||
@@ -37,8 +40,10 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary
|
||||
articleSummary,
|
||||
settings
|
||||
}) => {
|
||||
const cachedImage = useImageCache(articleImage, settings)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
||||
const contentLength = (bookmark.content || '').length
|
||||
@@ -48,10 +53,10 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
{isArticle && articleImage && (
|
||||
{isArticle && cachedImage && (
|
||||
<div
|
||||
className="article-hero-image"
|
||||
style={{ backgroundImage: `url(${articleImage})` }}
|
||||
style={{ backgroundImage: `url(${cachedImage})` }}
|
||||
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface LargeViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -19,6 +21,7 @@ interface LargeViewProps {
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const LargeView: React.FC<LargeViewProps> = ({
|
||||
@@ -34,13 +37,15 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary
|
||||
articleSummary,
|
||||
settings
|
||||
}) => {
|
||||
const cachedImage = useImageCache(previewImage || undefined, settings)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
{(hasUrls || (isArticle && previewImage)) && (
|
||||
{(hasUrls || (isArticle && cachedImage)) && (
|
||||
<div
|
||||
className="large-preview-image"
|
||||
onClick={() => {
|
||||
@@ -50,7 +55,7 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
onSelectUrl?.(extractedUrls[0])
|
||||
}
|
||||
}}
|
||||
style={previewImage ? { backgroundImage: `url(${previewImage})` } : undefined}
|
||||
style={cachedImage ? { backgroundImage: `url(${cachedImage})` } : undefined}
|
||||
>
|
||||
{!previewImage && hasUrls && (
|
||||
<div className="preview-placeholder">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
|
||||
import { useHighlightedContent } from '../hooks/useHighlightedContent'
|
||||
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface ContentPanelProps {
|
||||
loading: boolean
|
||||
@@ -30,6 +31,7 @@ interface ContentPanelProps {
|
||||
highlightVisibility?: HighlightVisibility
|
||||
currentUserPubkey?: string
|
||||
followedPubkeys?: Set<string>
|
||||
settings?: UserSettings
|
||||
// For highlight creation
|
||||
onTextSelection?: (text: string) => void
|
||||
onClearSelection?: () => void
|
||||
@@ -48,6 +50,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
showHighlights = true,
|
||||
highlightStyle = 'marker',
|
||||
highlightColor = '#ffff00',
|
||||
settings,
|
||||
onHighlightClick,
|
||||
selectedHighlightId,
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||
@@ -126,6 +129,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
readingTimeText={readingStats ? readingStats.text : null}
|
||||
hasHighlights={hasHighlights}
|
||||
highlightCount={relevantHighlights.length}
|
||||
settings={settings}
|
||||
/>
|
||||
{markdown || html ? (
|
||||
markdown ? (
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { format } from 'date-fns'
|
||||
import { useImageCache } from '../hooks/useImageCache'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface ReaderHeaderProps {
|
||||
title?: string
|
||||
@@ -11,6 +13,7 @@ interface ReaderHeaderProps {
|
||||
readingTimeText?: string | null
|
||||
hasHighlights: boolean
|
||||
highlightCount: number
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
@@ -20,13 +23,15 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
published,
|
||||
readingTimeText,
|
||||
hasHighlights,
|
||||
highlightCount
|
||||
highlightCount,
|
||||
settings
|
||||
}) => {
|
||||
const cachedImage = useImageCache(image, settings)
|
||||
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
||||
if (image) {
|
||||
if (cachedImage) {
|
||||
return (
|
||||
<div className="reader-hero-image">
|
||||
<img src={image} alt={title || 'Article image'} />
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
{formattedDate && (
|
||||
<div className="publish-date-topright">
|
||||
{formattedDate}
|
||||
|
||||
@@ -10,8 +10,8 @@ interface RelayStatusIndicatorProps {
|
||||
}
|
||||
|
||||
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ relayPool }) => {
|
||||
// Poll for relay status updates
|
||||
const relayStatuses = useRelayStatus({ relayPool })
|
||||
// Poll frequently for responsive offline indicator (5s instead of default 20s)
|
||||
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
|
||||
|
||||
if (!relayPool) return null
|
||||
|
||||
@@ -27,15 +27,15 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
|
||||
|
||||
// Debug logging
|
||||
React.useEffect(() => {
|
||||
if (localOnlyMode || offlineMode) {
|
||||
console.log('✈️ Relay Status Indicator:', {
|
||||
mode: offlineMode ? 'OFFLINE' : 'LOCAL_ONLY',
|
||||
connectedUrls,
|
||||
hasLocalRelay,
|
||||
hasRemoteRelay
|
||||
})
|
||||
}
|
||||
}, [localOnlyMode, offlineMode, connectedUrls.length])
|
||||
console.log('🔌 Relay Status Indicator:', {
|
||||
mode: offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
|
||||
totalStatuses: relayStatuses.length,
|
||||
connectedCount: connectedUrls.length,
|
||||
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
|
||||
hasLocalRelay,
|
||||
hasRemoteRelay
|
||||
})
|
||||
}, [offlineMode, localOnlyMode, connectedUrls.length, relayStatuses.length, hasLocalRelay, hasRemoteRelay])
|
||||
|
||||
// Don't show indicator when fully connected
|
||||
if (!localOnlyMode && !offlineMode) return null
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from 'react'
|
||||
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 IconButton from '../IconButton'
|
||||
|
||||
interface OfflineModeSettingsProps {
|
||||
settings: UserSettings
|
||||
@@ -10,16 +13,85 @@ interface OfflineModeSettingsProps {
|
||||
|
||||
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
|
||||
const navigate = useNavigate()
|
||||
const [cacheStats, setCacheStats] = useState(getImageCacheStats())
|
||||
|
||||
const handleLinkClick = (url: string) => {
|
||||
if (onClose) onClose()
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
}
|
||||
|
||||
const handleClearCache = async () => {
|
||||
if (confirm('Are you sure you want to clear all cached images?')) {
|
||||
await clearImageCache()
|
||||
setCacheStats(getImageCacheStats())
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache stats when settings change
|
||||
useEffect(() => {
|
||||
const updateStats = () => setCacheStats(getImageCacheStats())
|
||||
const interval = setInterval(updateStats, 2000) // Update every 2 seconds
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Flight Mode</h3>
|
||||
|
||||
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
|
||||
<input
|
||||
id="enableImageCache"
|
||||
type="checkbox"
|
||||
checked={settings.enableImageCache ?? true}
|
||||
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Use local image cache</span>
|
||||
</label>
|
||||
|
||||
{(settings.enableImageCache ?? true) && (
|
||||
<div style={{
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem'
|
||||
}}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
( {cacheStats.totalSizeMB.toFixed(1)} MB /
|
||||
<input
|
||||
id="imageCacheSizeMB"
|
||||
type="number"
|
||||
min="10"
|
||||
max="500"
|
||||
value={settings.imageCacheSizeMB ?? 210}
|
||||
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
|
||||
style={{
|
||||
width: '50px',
|
||||
padding: '0.15rem 0.35rem',
|
||||
background: 'var(--surface-secondary)',
|
||||
border: '1px solid var(--border-color, #333)',
|
||||
borderRadius: '4px',
|
||||
color: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
/>
|
||||
MB used )
|
||||
</span>
|
||||
<IconButton
|
||||
icon={faTrash}
|
||||
onClick={handleClearCache}
|
||||
title="Clear cache"
|
||||
variant="ghost"
|
||||
size={28}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
|
||||
<input
|
||||
@@ -33,19 +105,6 @@ const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onU
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
||||
<input
|
||||
id="rebroadcastToAllRelays"
|
||||
type="checkbox"
|
||||
checked={settings.rebroadcastToAllRelays ?? false}
|
||||
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Rebroadcast events while browsing</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '1.5rem',
|
||||
padding: '1rem',
|
||||
|
||||
@@ -9,7 +9,7 @@ interface StartupPreferencesSettingsProps {
|
||||
const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({ settings, onUpdate }) => {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Startup Preferences</h3>
|
||||
<h3 className="section-title">Startup & Behavior</h3>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="sidebarCollapsed" className="checkbox-label">
|
||||
@@ -36,6 +36,19 @@ const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({
|
||||
<span>Start with highlights panel collapsed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
||||
<input
|
||||
id="rebroadcastToAllRelays"
|
||||
type="checkbox"
|
||||
checked={settings.rebroadcastToAllRelays ?? false}
|
||||
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Rebroadcast events while browsing</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
isRefreshing={props.isRefreshing}
|
||||
loading={props.bookmarksLoading}
|
||||
relayPool={props.relayPool}
|
||||
settings={props.settings}
|
||||
/>
|
||||
</div>
|
||||
<div className="pane main">
|
||||
@@ -123,6 +124,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
onClearSelection={props.onClearSelection}
|
||||
currentUserPubkey={props.currentUserPubkey}
|
||||
followedPubkeys={props.followedPubkeys}
|
||||
settings={props.settings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
116
src/hooks/useImageCache.ts
Normal file
116
src/hooks/useImageCache.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
export function useImageCache(
|
||||
imageUrl: string | undefined,
|
||||
settings: UserSettings | undefined
|
||||
): 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpler hook variant that just caches on mount if enabled
|
||||
* Useful for preloading article cover images
|
||||
*/
|
||||
export function useCacheImageOnLoad(
|
||||
imageUrl: string | undefined,
|
||||
settings: UserSettings | undefined
|
||||
): 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])
|
||||
}
|
||||
|
||||
@@ -1095,9 +1095,9 @@ body {
|
||||
|
||||
/* Hero image in reader view */
|
||||
.reader-hero-image {
|
||||
width: 100%;
|
||||
margin: 0 0 2rem 0;
|
||||
border-radius: 8px;
|
||||
width: calc(100% + 1.5rem);
|
||||
margin: -0.75rem -0.75rem 2rem -0.75rem;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
|
||||
@@ -145,6 +146,14 @@ 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)
|
||||
})
|
||||
}
|
||||
|
||||
return content
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch article:', err)
|
||||
|
||||
237
src/services/imageCacheService.ts
Normal file
237
src/services/imageCacheService.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Image Cache Service
|
||||
*
|
||||
* Caches images using the Cache API for offline access.
|
||||
* Uses LRU (Least Recently Used) eviction when cache size limit is exceeded.
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
export function getImageCacheStats(): {
|
||||
totalSizeMB: number
|
||||
itemCount: number
|
||||
items: Array<{ url: string, sizeMB: number, lastAccessed: Date }>
|
||||
} {
|
||||
const metadata = getMetadata()
|
||||
const entries = Object.entries(metadata)
|
||||
|
||||
return {
|
||||
totalSizeMB: bytesToMB(getTotalCacheSize()),
|
||||
itemCount: entries.length,
|
||||
items: entries.map(([url, item]) => ({
|
||||
url,
|
||||
sizeMB: bytesToMB(item.size),
|
||||
lastAccessed: new Date(item.lastAccessed)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cached image asynchronously (for use in hooks/components)
|
||||
*/
|
||||
export async function loadCachedImage(url: string): Promise<string | null> {
|
||||
return getCachedImageUrl(url)
|
||||
}
|
||||
@@ -42,6 +42,9 @@ export interface UserSettings {
|
||||
// Relay rebroadcast settings
|
||||
useLocalRelayAsCache?: boolean // Rebroadcast events to local relays
|
||||
rebroadcastToAllRelays?: boolean // Rebroadcast events to all relays
|
||||
// Image cache settings
|
||||
enableImageCache?: boolean // Enable caching images in localStorage
|
||||
imageCacheSizeMB?: number // Maximum cache size in megabytes (default: 210MB)
|
||||
}
|
||||
|
||||
export async function loadSettings(
|
||||
|
||||
Reference in New Issue
Block a user