mirror of
https://github.com/dergigi/boris.git
synced 2025-12-18 15:14:20 +01:00
feat: add image caching for offline mode
- Add imageCacheService with localStorage-based image caching and LRU eviction - Create useImageCache hook for React components to fetch and cache images - Integrate image caching with article service to cache cover images on load - Add image cache settings (enable/disable, size limit) to user settings - Update ReaderHeader to use cached images for article covers - Update BookmarkViews (CardView, LargeView) to use cached images - Add image cache configuration UI in OfflineModeSettings with: - Toggle to enable/disable image caching - Slider to set cache size limit (10-200 MB) - Display current cache stats (size and image count) - Clear cache button Images are cached in localStorage for offline viewing, with a configurable size limit (default 50MB). LRU eviction ensures cache stays within limits.
This commit is contained in:
@@ -11,15 +11,17 @@ import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
|
|||||||
import { CompactView } from './BookmarkViews/CompactView'
|
import { CompactView } from './BookmarkViews/CompactView'
|
||||||
import { LargeView } from './BookmarkViews/LargeView'
|
import { LargeView } from './BookmarkViews/LargeView'
|
||||||
import { CardView } from './BookmarkViews/CardView'
|
import { CardView } from './BookmarkViews/CardView'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
|
||||||
interface BookmarkItemProps {
|
interface BookmarkItemProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
index: number
|
index: number
|
||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
viewMode?: ViewMode
|
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 [ogImage, setOgImage] = useState<string | null>(null)
|
||||||
|
|
||||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
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,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
articleSummary
|
articleSummary,
|
||||||
|
settings
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === 'compact') {
|
if (viewMode === 'compact') {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import SidebarHeader from './SidebarHeader'
|
|||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { ViewMode } from './Bookmarks'
|
import { ViewMode } from './Bookmarks'
|
||||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
|
||||||
interface BookmarkListProps {
|
interface BookmarkListProps {
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
@@ -23,6 +24,7 @@ interface BookmarkListProps {
|
|||||||
isRefreshing?: boolean
|
isRefreshing?: boolean
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
|
settings?: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||||
@@ -38,7 +40,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
loading = false,
|
loading = false,
|
||||||
relayPool
|
relayPool,
|
||||||
|
settings
|
||||||
}) => {
|
}) => {
|
||||||
// Helper to check if a bookmark has either content or a URL
|
// Helper to check if a bookmark has either content or a URL
|
||||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||||
@@ -123,6 +126,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
index={index}
|
index={index}
|
||||||
onSelectUrl={onSelectUrl}
|
onSelectUrl={onSelectUrl}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
|
settings={settings}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
|||||||
import IconButton from '../IconButton'
|
import IconButton from '../IconButton'
|
||||||
import { classifyUrl } from '../../utils/helpers'
|
import { classifyUrl } from '../../utils/helpers'
|
||||||
import { IconGetter } from './shared'
|
import { IconGetter } from './shared'
|
||||||
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
|
||||||
interface CardViewProps {
|
interface CardViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -22,6 +24,7 @@ interface CardViewProps {
|
|||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleImage?: string
|
articleImage?: string
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
|
settings?: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CardView: React.FC<CardViewProps> = ({
|
export const CardView: React.FC<CardViewProps> = ({
|
||||||
@@ -37,8 +40,10 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
articleSummary
|
articleSummary,
|
||||||
|
settings
|
||||||
}) => {
|
}) => {
|
||||||
|
const cachedImage = useImageCache(articleImage, settings)
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
||||||
const contentLength = (bookmark.content || '').length
|
const contentLength = (bookmark.content || '').length
|
||||||
@@ -48,10 +53,10 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||||
{isArticle && articleImage && (
|
{isArticle && cachedImage && (
|
||||||
<div
|
<div
|
||||||
className="article-hero-image"
|
className="article-hero-image"
|
||||||
style={{ backgroundImage: `url(${articleImage})` }}
|
style={{ backgroundImage: `url(${cachedImage})` }}
|
||||||
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
|
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { IndividualBookmark } from '../../types/bookmarks'
|
|||||||
import { formatDate } from '../../utils/bookmarkUtils'
|
import { formatDate } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||||
import { IconGetter } from './shared'
|
import { IconGetter } from './shared'
|
||||||
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
|
||||||
interface LargeViewProps {
|
interface LargeViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -19,6 +21,7 @@ interface LargeViewProps {
|
|||||||
getAuthorDisplayName: () => string
|
getAuthorDisplayName: () => string
|
||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
|
settings?: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LargeView: React.FC<LargeViewProps> = ({
|
export const LargeView: React.FC<LargeViewProps> = ({
|
||||||
@@ -34,13 +37,15 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
eventNevent,
|
eventNevent,
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleSummary
|
articleSummary,
|
||||||
|
settings
|
||||||
}) => {
|
}) => {
|
||||||
|
const cachedImage = useImageCache(previewImage || undefined, settings)
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||||
{(hasUrls || (isArticle && previewImage)) && (
|
{(hasUrls || (isArticle && cachedImage)) && (
|
||||||
<div
|
<div
|
||||||
className="large-preview-image"
|
className="large-preview-image"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -50,7 +55,7 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
onSelectUrl?.(extractedUrls[0])
|
onSelectUrl?.(extractedUrls[0])
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={previewImage ? { backgroundImage: `url(${previewImage})` } : undefined}
|
style={cachedImage ? { backgroundImage: `url(${cachedImage})` } : undefined}
|
||||||
>
|
>
|
||||||
{!previewImage && hasUrls && (
|
{!previewImage && hasUrls && (
|
||||||
<div className="preview-placeholder">
|
<div className="preview-placeholder">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { HighlightVisibility } from './HighlightsPanel'
|
|||||||
import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
|
import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
|
||||||
import { useHighlightedContent } from '../hooks/useHighlightedContent'
|
import { useHighlightedContent } from '../hooks/useHighlightedContent'
|
||||||
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
|
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
|
||||||
interface ContentPanelProps {
|
interface ContentPanelProps {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -30,6 +31,7 @@ interface ContentPanelProps {
|
|||||||
highlightVisibility?: HighlightVisibility
|
highlightVisibility?: HighlightVisibility
|
||||||
currentUserPubkey?: string
|
currentUserPubkey?: string
|
||||||
followedPubkeys?: Set<string>
|
followedPubkeys?: Set<string>
|
||||||
|
settings?: UserSettings
|
||||||
// For highlight creation
|
// For highlight creation
|
||||||
onTextSelection?: (text: string) => void
|
onTextSelection?: (text: string) => void
|
||||||
onClearSelection?: () => void
|
onClearSelection?: () => void
|
||||||
@@ -48,6 +50,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
showHighlights = true,
|
showHighlights = true,
|
||||||
highlightStyle = 'marker',
|
highlightStyle = 'marker',
|
||||||
highlightColor = '#ffff00',
|
highlightColor = '#ffff00',
|
||||||
|
settings,
|
||||||
onHighlightClick,
|
onHighlightClick,
|
||||||
selectedHighlightId,
|
selectedHighlightId,
|
||||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||||
@@ -126,6 +129,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
readingTimeText={readingStats ? readingStats.text : null}
|
readingTimeText={readingStats ? readingStats.text : null}
|
||||||
hasHighlights={hasHighlights}
|
hasHighlights={hasHighlights}
|
||||||
highlightCount={relevantHighlights.length}
|
highlightCount={relevantHighlights.length}
|
||||||
|
settings={settings}
|
||||||
/>
|
/>
|
||||||
{markdown || html ? (
|
{markdown || html ? (
|
||||||
markdown ? (
|
markdown ? (
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React from 'react'
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
|
import { useImageCache } from '../hooks/useImageCache'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
|
||||||
interface ReaderHeaderProps {
|
interface ReaderHeaderProps {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -11,6 +13,7 @@ interface ReaderHeaderProps {
|
|||||||
readingTimeText?: string | null
|
readingTimeText?: string | null
|
||||||
hasHighlights: boolean
|
hasHighlights: boolean
|
||||||
highlightCount: number
|
highlightCount: number
|
||||||
|
settings?: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||||
@@ -20,13 +23,15 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
published,
|
published,
|
||||||
readingTimeText,
|
readingTimeText,
|
||||||
hasHighlights,
|
hasHighlights,
|
||||||
highlightCount
|
highlightCount,
|
||||||
|
settings
|
||||||
}) => {
|
}) => {
|
||||||
|
const cachedImage = useImageCache(image, settings)
|
||||||
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
||||||
if (image) {
|
if (cachedImage) {
|
||||||
return (
|
return (
|
||||||
<div className="reader-hero-image">
|
<div className="reader-hero-image">
|
||||||
<img src={image} alt={title || 'Article image'} />
|
<img src={cachedImage} alt={title || 'Article image'} />
|
||||||
{formattedDate && (
|
{formattedDate && (
|
||||||
<div className="publish-date-topright">
|
<div className="publish-date-topright">
|
||||||
{formattedDate}
|
{formattedDate}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { UserSettings } from '../../services/settingsService'
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
import { getImageCacheStats, clearImageCache } from '../../services/imageCacheService'
|
||||||
|
|
||||||
interface OfflineModeSettingsProps {
|
interface OfflineModeSettingsProps {
|
||||||
settings: UserSettings
|
settings: UserSettings
|
||||||
@@ -10,12 +11,27 @@ interface OfflineModeSettingsProps {
|
|||||||
|
|
||||||
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
|
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [cacheStats, setCacheStats] = useState(getImageCacheStats())
|
||||||
|
|
||||||
const handleLinkClick = (url: string) => {
|
const handleLinkClick = (url: string) => {
|
||||||
if (onClose) onClose()
|
if (onClose) onClose()
|
||||||
navigate(`/r/${encodeURIComponent(url)}`)
|
navigate(`/r/${encodeURIComponent(url)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleClearCache = () => {
|
||||||
|
if (confirm('Are you sure you want to clear all cached images?')) {
|
||||||
|
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 (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3 className="section-title">Flight Mode</h3>
|
<h3 className="section-title">Flight Mode</h3>
|
||||||
@@ -46,6 +62,92 @@ const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onU
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 className="section-title" style={{ marginTop: '2rem' }}>Image Cache</h3>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="enableImageCache" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="enableImageCache"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.enableImageCache ?? true}
|
||||||
|
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Cache images for offline viewing</span>
|
||||||
|
</label>
|
||||||
|
<p style={{
|
||||||
|
margin: '0.5rem 0 0 1.75rem',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'var(--text-secondary)'
|
||||||
|
}}>
|
||||||
|
Images will be stored in browser localStorage
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(settings.enableImageCache ?? true) && (
|
||||||
|
<>
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="imageCacheSizeMB">
|
||||||
|
<span>Max cache size: {settings.imageCacheSizeMB ?? 50} MB</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="imageCacheSizeMB"
|
||||||
|
type="range"
|
||||||
|
min="10"
|
||||||
|
max="200"
|
||||||
|
step="10"
|
||||||
|
value={settings.imageCacheSizeMB ?? 50}
|
||||||
|
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) })}
|
||||||
|
className="setting-slider"
|
||||||
|
style={{ width: '100%', marginTop: '0.5rem' }}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
marginTop: '0.25rem'
|
||||||
|
}}>
|
||||||
|
<span>10 MB</span>
|
||||||
|
<span>200 MB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '1rem',
|
||||||
|
background: 'var(--surface-secondary)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '0.9rem'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '0.5rem'
|
||||||
|
}}>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Current cache: {cacheStats.totalSizeMB.toFixed(2)} MB ({cacheStats.itemCount} images)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleClearCache}
|
||||||
|
style={{
|
||||||
|
padding: '0.25rem 0.75rem',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
background: 'var(--danger, #ef4444)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear Cache
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
marginTop: '1.5rem',
|
marginTop: '1.5rem',
|
||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
isRefreshing={props.isRefreshing}
|
isRefreshing={props.isRefreshing}
|
||||||
loading={props.bookmarksLoading}
|
loading={props.bookmarksLoading}
|
||||||
relayPool={props.relayPool}
|
relayPool={props.relayPool}
|
||||||
|
settings={props.settings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pane main">
|
<div className="pane main">
|
||||||
@@ -123,6 +124,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
onClearSelection={props.onClearSelection}
|
onClearSelection={props.onClearSelection}
|
||||||
currentUserPubkey={props.currentUserPubkey}
|
currentUserPubkey={props.currentUserPubkey}
|
||||||
followedPubkeys={props.followedPubkeys}
|
followedPubkeys={props.followedPubkeys}
|
||||||
|
settings={props.settings}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
90
src/hooks/useImageCache.ts
Normal file
90
src/hooks/useImageCache.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { cacheImage, getCachedImage } from '../services/imageCacheService'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to cache and retrieve images from localStorage
|
||||||
|
*
|
||||||
|
* @param imageUrl - The URL of the image to cache
|
||||||
|
* @param settings - User settings to determine if caching is enabled
|
||||||
|
* @returns The cached data 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already cached
|
||||||
|
const cached = getCachedImage(imageUrl)
|
||||||
|
if (cached) {
|
||||||
|
console.log('📦 Using cached image:', imageUrl.substring(0, 50))
|
||||||
|
setCachedUrl(cached)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, show original URL while caching in background
|
||||||
|
setCachedUrl(imageUrl)
|
||||||
|
|
||||||
|
// Cache image in background
|
||||||
|
if (!isLoading) {
|
||||||
|
setIsLoading(true)
|
||||||
|
const maxSize = settings?.imageCacheSizeMB ?? 50
|
||||||
|
|
||||||
|
cacheImage(imageUrl, maxSize)
|
||||||
|
.then(dataUrl => {
|
||||||
|
setCachedUrl(dataUrl)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to cache image:', err)
|
||||||
|
// Keep using original URL on error
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [imageUrl, settings?.enableImageCache, settings?.imageCacheSizeMB])
|
||||||
|
|
||||||
|
return cachedUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simpler hook variant that just caches on mount if enabled
|
||||||
|
* Useful for 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
|
||||||
|
const cached = getCachedImage(imageUrl)
|
||||||
|
if (cached) return
|
||||||
|
|
||||||
|
// Cache in background
|
||||||
|
const maxSize = settings?.imageCacheSizeMB ?? 50
|
||||||
|
cacheImage(imageUrl, maxSize).catch(err => {
|
||||||
|
console.error('Failed to cache image:', err)
|
||||||
|
})
|
||||||
|
}, [imageUrl, settings?.enableImageCache, settings?.imageCacheSizeMB])
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import { Helpers } from 'applesauce-core'
|
|||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { UserSettings } from './settingsService'
|
import { UserSettings } from './settingsService'
|
||||||
import { rebroadcastEvents } from './rebroadcastService'
|
import { rebroadcastEvents } from './rebroadcastService'
|
||||||
|
import { cacheImage } from './imageCacheService'
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
@@ -145,6 +146,14 @@ export async function fetchArticleByNaddr(
|
|||||||
// Save to cache before returning
|
// Save to cache before returning
|
||||||
saveToCache(naddr, content)
|
saveToCache(naddr, content)
|
||||||
|
|
||||||
|
// Cache cover image if enabled and present
|
||||||
|
if (image && settings?.enableImageCache !== false) {
|
||||||
|
const maxSize = settings?.imageCacheSizeMB ?? 50
|
||||||
|
cacheImage(image, maxSize).catch(err => {
|
||||||
|
console.warn('Failed to cache article cover image:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return content
|
return content
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch article:', err)
|
console.error('Failed to fetch article:', err)
|
||||||
|
|||||||
278
src/services/imageCacheService.ts
Normal file
278
src/services/imageCacheService.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* Image Cache Service
|
||||||
|
*
|
||||||
|
* Caches images in localStorage for offline access.
|
||||||
|
* Uses LRU (Least Recently Used) eviction when cache size limit is exceeded.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CACHE_PREFIX = 'img_cache_'
|
||||||
|
const CACHE_METADATA_KEY = 'img_cache_metadata'
|
||||||
|
|
||||||
|
interface CacheMetadata {
|
||||||
|
[url: string]: {
|
||||||
|
key: string
|
||||||
|
size: number
|
||||||
|
lastAccessed: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache metadata
|
||||||
|
*/
|
||||||
|
function getMetadata(): CacheMetadata {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(CACHE_METADATA_KEY)
|
||||||
|
return data ? JSON.parse(data) : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save cache metadata
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cache key for URL
|
||||||
|
*/
|
||||||
|
function getCacheKey(url: string): string {
|
||||||
|
// Use a simple hash of the URL
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < url.length; i++) {
|
||||||
|
const char = url.charCodeAt(i)
|
||||||
|
hash = ((hash << 5) - hash) + char
|
||||||
|
hash = hash & hash
|
||||||
|
}
|
||||||
|
return `${CACHE_PREFIX}${Math.abs(hash)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict least recently used images until cache is under limit
|
||||||
|
*/
|
||||||
|
function evictLRU(maxSizeBytes: number): 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()
|
||||||
|
|
||||||
|
for (const [url, item] of entries) {
|
||||||
|
if (currentSize <= maxSizeBytes) break
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(item.key)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch image and convert to data URL
|
||||||
|
*/
|
||||||
|
async function fetchImageAsDataUrl(url: string): Promise<string> {
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch image: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onloadend = () => {
|
||||||
|
if (typeof reader.result === 'string') {
|
||||||
|
resolve(reader.result)
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to convert image to data URL'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache an image
|
||||||
|
*/
|
||||||
|
export async function cacheImage(
|
||||||
|
url: string,
|
||||||
|
maxCacheSizeMB: number = 50
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
// Check if already cached
|
||||||
|
const cached = getCachedImage(url)
|
||||||
|
if (cached) {
|
||||||
|
console.log('✅ Image already cached:', url.substring(0, 50))
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch and convert to data URL
|
||||||
|
console.log('📥 Caching image:', url.substring(0, 50))
|
||||||
|
const dataUrl = await fetchImageAsDataUrl(url)
|
||||||
|
const size = dataUrl.length
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
evictLRU(maxSizeBytes - size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store image
|
||||||
|
const key = getCacheKey(url)
|
||||||
|
const metadata = getMetadata()
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, dataUrl)
|
||||||
|
metadata[url] = {
|
||||||
|
key,
|
||||||
|
size,
|
||||||
|
lastAccessed: Date.now()
|
||||||
|
}
|
||||||
|
saveMetadata(metadata)
|
||||||
|
|
||||||
|
console.log(`💾 Cached image (${bytesToMB(size).toFixed(2)}MB). Total cache: ${bytesToMB(getTotalCacheSize()).toFixed(2)}MB`)
|
||||||
|
return dataUrl
|
||||||
|
} catch (err) {
|
||||||
|
// If storage fails, try evicting more and retry once
|
||||||
|
console.warn('Storage full, evicting more items...')
|
||||||
|
evictLRU(maxSizeBytes / 2) // Free up half the cache
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, dataUrl)
|
||||||
|
metadata[url] = {
|
||||||
|
key,
|
||||||
|
size,
|
||||||
|
lastAccessed: Date.now()
|
||||||
|
}
|
||||||
|
saveMetadata(metadata)
|
||||||
|
return dataUrl
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to cache image after eviction')
|
||||||
|
return url // Return original URL on failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to cache image:', err)
|
||||||
|
return url // Return original URL on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached image
|
||||||
|
*/
|
||||||
|
export function getCachedImage(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const metadata = getMetadata()
|
||||||
|
const item = metadata[url]
|
||||||
|
|
||||||
|
if (!item) return null
|
||||||
|
|
||||||
|
const dataUrl = localStorage.getItem(item.key)
|
||||||
|
if (!dataUrl) {
|
||||||
|
// Clean up stale metadata
|
||||||
|
delete metadata[url]
|
||||||
|
saveMetadata(metadata)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last accessed time
|
||||||
|
item.lastAccessed = Date.now()
|
||||||
|
metadata[url] = item
|
||||||
|
saveMetadata(metadata)
|
||||||
|
|
||||||
|
return dataUrl
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached images
|
||||||
|
*/
|
||||||
|
export function clearImageCache(): void {
|
||||||
|
try {
|
||||||
|
const metadata = getMetadata()
|
||||||
|
|
||||||
|
for (const item of Object.values(metadata)) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(item.key)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to remove cached image:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -42,6 +42,9 @@ export interface UserSettings {
|
|||||||
// Relay rebroadcast settings
|
// Relay rebroadcast settings
|
||||||
useLocalRelayAsCache?: boolean // Rebroadcast events to local relays
|
useLocalRelayAsCache?: boolean // Rebroadcast events to local relays
|
||||||
rebroadcastToAllRelays?: boolean // Rebroadcast events to all 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: 50MB)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSettings(
|
export async function loadSettings(
|
||||||
|
|||||||
Reference in New Issue
Block a user