Compare commits

...

56 Commits

Author SHA1 Message Date
Gigi
7d5be8d6aa chore: bump version to 0.3.8 2025-10-10 16:30:21 +01:00
Gigi
133e4756b2 fix: add vercel.json to handle SPA routing on Vercel
Without this configuration, page refreshes result in 404 errors because
Vercel tries to serve non-existent files instead of routing through
index.html for client-side routing.
2025-10-10 16:22:33 +01:00
Gigi
39ada734d5 docs: update CHANGELOG for v0.3.7 2025-10-10 13:25:18 +01:00
Gigi
19d88c5fba chore: bump version to 0.3.7 2025-10-10 13:24:31 +01:00
Gigi
461b0936e2 fix: use clearActive() method for logout instead of setActive(null)
Changed logout to use the proper clearActive() method from AccountManager instead of setActive(null), which was causing TypeScript type errors. This is the correct way to clear the active account according to the applesauce-accounts API.
2025-10-10 13:22:50 +01:00
Gigi
e9ee5e87be chore: add applesauce reference directory to gitignore
Added the applesauce directory to .gitignore to exclude the local reference copy of the applesauce monorepo from being committed to the project repository.
2025-10-10 13:21:25 +01:00
Gigi
5e66c5ef76 fix: correct logout functionality by using null instead of undefined
The logout button wasn't working because setActive was being called with 'undefined as never', which is an incorrect type hack. Changed to use null instead, which properly clears the active account. Also removed redundant localStorage.removeItem('active') call since the active$ subscription already handles localStorage cleanup.
2025-10-10 13:19:34 +01:00
Gigi
307dc3d726 docs: update CHANGELOG for v0.3.6 2025-10-10 13:16:05 +01:00
Gigi
e514a5f063 chore: bump version to 0.3.6 2025-10-10 13:14:41 +01:00
Gigi
880b7974f4 style: make connecting notification more subtle with muted blue background 2025-10-10 13:12:03 +01:00
Gigi
47048f435f Revert "fix(ui): prevent highlight panel UI breaks with long content or formatting"
This reverts commit a31f05d498.
2025-10-10 06:04:57 +01:00
Gigi
53ad492729 fix(ui): remove incorrect padding-right from highlights container 2025-10-09 21:31:17 +01:00
Gigi
eb4da419ae chore: update Boris pubkey for zap splits to npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x 2025-10-09 21:30:43 +01:00
Gigi
c66dfc9e2e feat(ui): use compact date format for highlights (now, 5m, 3h, 2d, 1mo, 1y) 2025-10-09 21:28:01 +01:00
Gigi
a31f05d498 fix(ui): prevent highlight panel UI breaks with long content or formatting 2025-10-09 21:27:08 +01:00
Gigi
6548e89c54 fix(ui): reduce font size of highlight metadata for cleaner look 2025-10-09 21:25:54 +01:00
Gigi
8a21b46ebd fix(ui): position highlight FAB button relative to article pane, not viewport 2025-10-09 21:23:21 +01:00
Gigi
bc5fe1ae30 fix(ui): adjust relay indicator position for better visual alignment 2025-10-09 21:22:02 +01:00
Gigi
b57ea3f640 fix(ui): ensure highlight metadata elements align on single visual line with consistent line-height 2025-10-09 21:18:14 +01:00
Gigi
3b55d64468 feat(ui): ultra-compact date format for bookmarks sidebar (now, 5m, 3h, 2d, 1mo, 1y) 2025-10-09 21:17:14 +01:00
Gigi
4caf1f0b22 fix(ui): prevent bookmark icons from being cut off in compact view 2025-10-09 21:16:20 +01:00
Gigi
1eb9911645 feat(highlights): encode event links as nevent/naddr per NIP-19 2025-10-09 21:15:03 +01:00
Gigi
38268c453c fix(ui): clean up nested borders in bookmark items for cleaner look 2025-10-09 21:13:47 +01:00
Gigi
9686b80b09 fix(ui): clean up nested borders in bookmarks sidebar view mode controls 2025-10-09 21:12:50 +01:00
Gigi
f32dec16fb fix(ui): align highlight metadata elements on single line in sidebar 2025-10-09 21:12:06 +01:00
Gigi
cb444b532f fix(explore): change header icon from compass to newspaper 2025-10-09 21:11:17 +01:00
Gigi
962062130a feat(routing): render /explore via Bookmarks to keep side panels 2025-10-09 21:10:51 +01:00
Gigi
e429931139 feat(layout): render Explore within ThreePaneLayout so side panels remain 2025-10-09 21:10:33 +01:00
Gigi
e56d28f82a chore: update highlight alt tag domain to read.withboris.com 2025-10-09 21:08:45 +01:00
Gigi
13a30d35c4 docs: update README app link to https://read.withboris.com/ 2025-10-09 21:08:31 +01:00
Gigi
e3174d8777 chore(seo): update robots.txt sitemap to https://read.withboris.com/ 2025-10-09 21:08:22 +01:00
Gigi
829a8d5dca chore(seo): update canonical and social URLs to https://read.withboris.com/ 2025-10-09 21:08:13 +01:00
Gigi
00978e2e64 chore: commit pending RelayStatusIndicator changes before URL update 2025-10-09 21:08:00 +01:00
Gigi
a5fcf36e83 docs: update CHANGELOG for v0.3.5 2025-10-09 20:28:50 +01:00
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
Gigi
8f2ecd5fe1 chore: bump version to 0.3.2 2025-10-09 17:49:08 +01:00
Gigi
d6be6f364b refactor: migrate image cache from localStorage to Cache API
BREAKING CHANGE: Image cache now uses Cache API instead of localStorage

Benefits:
- Support for actual 210MB cache size (localStorage limited to 5-10MB)
- Store native Response objects (no base64 overhead)
- Asynchronous, non-blocking operations
- Better suited for large binary blobs like images
- Can handle hundreds of MB to several GB

Changes:
- Rewrite imageCacheService to use Cache API for image storage
- Keep metadata in localStorage for LRU tracking (small footprint)
- Update useImageCache hook to handle async Cache API
- Add blob URL cleanup to prevent memory leaks
- Update clearImageCache to async function

The cache now works as advertised and won't hit quota limits.
2025-10-09 17:48:59 +01:00
29 changed files with 951 additions and 426 deletions

4
.gitignore vendored
View File

@@ -7,3 +7,7 @@ dist
# Misc
*.log
.DS_Store
# Applesauce Reference
applesauce

View File

@@ -5,6 +5,72 @@ 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.7] - 2025-10-10
### Fixed
- Logout button functionality - now properly clears active account using clearActive() method
## [0.3.6] - 2025-10-10
### Added
- Compact date format for highlights (now, 5m, 3h, 2d, 1mo, 1y)
- Ultra-compact date format for bookmarks sidebar
- Encode event links as nevent/naddr per NIP-19 for better client compatibility
- Render /explore within ThreePaneLayout to keep side panels visible
### Fixed
- Remove incorrect padding-right from highlights container
- Reduce font size of highlight metadata for cleaner look
- Position highlight FAB button relative to article pane instead of viewport
- Adjust relay indicator position for better visual alignment
- Ensure highlight metadata elements align on single visual line with consistent line-height
- Prevent bookmark icons from being cut off in compact view
- Clean up nested borders in bookmark items and sidebar view mode controls
- Align highlight metadata elements on single line in sidebar
- Change explore header icon from compass to newspaper
### Changed
- Make connecting notification more subtle with muted blue background
- Update Boris pubkey for zap splits to npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
- Update domain references to read.withboris.com (URLs, SEO metadata, and documentation)
## [0.3.5] - 2025-10-09
### Fixed
- Ensure connecting state shows for minimum 15 seconds to prevent premature offline display
- Add Cloudflare Pages routing config for SPA paths
### Changed
- Extend connecting state duration and remove subtitle text for cleaner UI
## [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 +538,12 @@ 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.6]: https://github.com/dergigi/boris/compare/v0.3.5...v0.3.6
[0.3.5]: https://github.com/dergigi/boris/compare/v0.3.4...v0.3.5
[0.3.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

@@ -6,7 +6,7 @@ Boris turns your Nostr bookmarks into a calm, fast, and focused reading experien
## Live
- App: [https://xn--bris-v0b.com/](https://xn--bris-v0b.com/)
- App: [https://read.withboris.com/](https://read.withboris.com/)
## The Vision

View File

@@ -6,18 +6,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Boris - Nostr Bookmarks</title>
<meta name="description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<link rel="canonical" href="https://xn--bris-v0b.com/" />
<link rel="canonical" href="https://read.withboris.com/" />
<!-- Open Graph / Social Media -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://xn--bris-v0b.com/" />
<meta property="og:url" content="https://read.withboris.com/" />
<meta property="og:title" content="Boris - Nostr Bookmarks" />
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta property="og:site_name" content="Boris" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://xn--bris-v0b.com/" />
<meta name="twitter:url" content="https://read.withboris.com/" />
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
</head>

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.3.1",
"version": "0.3.8",
"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"]
}

View File

@@ -1,5 +1,5 @@
User-agent: *
Allow: /
Sitemap: https://xn--bris-v0b.com/sitemap.xml
Sitemap: https://read.withboris.com/sitemap.xml

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

@@ -27,8 +27,7 @@ function AppRoutes({
const accountManager = Hooks.useAccountManager()
const handleLogout = () => {
accountManager.setActive(undefined as never)
localStorage.removeItem('active')
accountManager.clearActive()
showToast('Logged out successfully')
}
@@ -61,6 +60,15 @@ function AppRoutes({
/>
}
/>
<Route
path="/explore"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<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

@@ -2,7 +2,7 @@ import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils'
import { formatDateCompact } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import { IconGetter } from './shared'
@@ -75,7 +75,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
<ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} />
</div>
)}
<span className="bookmark-date-compact">{formatDate(bookmark.created_at)}</span>
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
{isClickable && (
<button
className="compact-read-btn"

View File

@@ -13,6 +13,7 @@ import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useRelayStatus } from '../hooks/useRelayStatus'
import { useOfflineSync } from '../hooks/useOfflineSync'
import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore'
import { classifyHighlights } from '../utils/highlightClassification'
export type ViewMode = 'compact' | 'cards' | 'large'
@@ -33,6 +34,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
: undefined
const showSettings = location.pathname === '/settings'
const showExplore = location.pathname === '/explore'
// Track previous location for going back from settings
useEffect(() => {
@@ -94,6 +96,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setHighlightsLoading,
followedPubkeys,
isRefreshing,
lastFetchTime,
handleFetchHighlights,
handleRefreshAll
} = useBookmarksData({
@@ -178,10 +181,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
isCollapsed={isCollapsed}
isHighlightsCollapsed={isHighlightsCollapsed}
showSettings={showSettings}
showExplore={showExplore}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
viewMode={viewMode}
isRefreshing={isRefreshing}
lastFetchTime={lastFetchTime}
onToggleSidebar={() => setIsCollapsed(!isCollapsed)}
onLogout={onLogout}
onViewModeChange={setViewMode}
@@ -225,6 +230,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
highlightButtonRef={highlightButtonRef}
onCreateHighlight={handleCreateHighlight}
hasActiveAccount={!!(activeAccount && relayPool)}
explore={showExplore ? (
relayPool ? <Explore relayPool={relayPool} /> : null
) : undefined}
toastMessage={toastMessage ?? undefined}
toastType={toastType}
onClearToast={clearToast}

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, faNewspaper } 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={faNewspaper} />
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

@@ -2,13 +2,14 @@ import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { formatDistanceToNow } from 'date-fns'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
import { RELAYS } from '../config/relays'
import { areAllRelaysLocal } from '../utils/helpers'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
interface HighlightWithLevel extends Highlight {
level?: 'mine' | 'friends' | 'nostrverse'
@@ -102,7 +103,41 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const getSourceLink = () => {
if (highlight.eventReference) {
return `https://search.dergigi.com/e/${highlight.eventReference}`
// Check if it's a coordinate string (kind:pubkey:identifier) or a simple event ID
if (highlight.eventReference.includes(':')) {
// It's an addressable event coordinate, encode as naddr
const parts = highlight.eventReference.split(':')
if (parts.length === 3) {
const [kindStr, pubkey, identifier] = parts
const kind = parseInt(kindStr, 10)
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
const naddr = nip19.naddrEncode({
kind,
pubkey,
identifier,
relays: relayHints
})
return `https://njump.me/${naddr}`
}
} else {
// It's a simple event ID, encode as nevent
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
const nevent = nip19.neventEncode({
id: highlight.eventReference,
relays: relayHints,
author: highlight.author
})
return `https://njump.me/${nevent}`
}
}
return highlight.urlReference
}
@@ -248,7 +283,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
</span>
<span className="highlight-meta-separator"></span>
<span className="highlight-time">
{formatDistanceToNow(new Date(highlight.created_at * 1000), { addSuffix: true })}
{formatDateCompact(highlight.created_at)}
</span>
{sourceLink && (

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,7 @@ 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)
if (!relayPool) return null
@@ -25,32 +26,52 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
const localOnlyMode = hasLocalRelay && !hasRemoteRelay
const offlineMode = connectedUrls.length === 0
// Show "Connecting" for first few seconds or until relays connect
useEffect(() => {
if (connectedUrls.length > 0) {
// Connected! Stop showing connecting state
setIsConnecting(false)
} else {
// No connections yet - show connecting for 8 seconds
setIsConnecting(true)
const timeout = setTimeout(() => {
setIsConnecting(false)
}, 8000)
return () => clearTimeout(timeout)
}
}, [connectedUrls.length])
// 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'
<div className={`relay-status-indicator ${isConnecting ? 'connecting' : ''}`} title={
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 +83,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,24 +13,34 @@ 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()
navigate(`/r/${encodeURIComponent(url)}`)
}
const handleClearCache = () => {
const handleClearCache = async () => {
if (confirm('Are you sure you want to clear all cached images?')) {
clearImageCache()
setCacheStats(getImageCacheStats())
await clearImageCache()
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

@@ -22,12 +22,14 @@ interface ThreePaneLayoutProps {
isCollapsed: boolean
isHighlightsCollapsed: boolean
showSettings: boolean
showExplore?: boolean
// Bookmarks pane
bookmarks: Bookmark[]
bookmarksLoading: boolean
viewMode: ViewMode
isRefreshing: boolean
lastFetchTime?: number | null
onToggleSidebar: () => void
onLogout: () => void
onViewModeChange: (mode: ViewMode) => void
@@ -71,6 +73,9 @@ interface ThreePaneLayoutProps {
toastMessage?: string
toastType?: 'success' | 'error'
onClearToast: () => void
// Optional Explore content
explore?: React.ReactNode
}
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
@@ -90,6 +95,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}
@@ -103,6 +109,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
onClose={props.onCloseSettings}
relayPool={props.relayPool}
/>
) : props.showExplore && props.explore ? (
// Render Explore inside the main pane to keep side panels
<>
{props.explore}
</>
) : (
<ContentPanel
loading={props.readerLoading}

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,90 +1,34 @@
import { useState, useEffect } from 'react'
import { cacheImage, getCachedImage } from '../services/imageCacheService'
import { UserSettings } from '../services/settingsService'
/**
* Hook to cache and retrieve images from localStorage
* 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 data 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
}
// 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 ?? 210
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
// Service Worker handles everything - just return the URL as-is
return imageUrl
}
/**
* Simpler hook variant that just caches on mount if enabled
* Useful for 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
const cached = getCachedImage(imageUrl)
if (cached) 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

@@ -71,15 +71,16 @@ body {
.bookmarks-container .view-mode-controls {
margin-top: auto;
padding: 0.75rem 1rem;
padding: 1rem;
border-top: 1px solid #333;
background: #1a1a1a;
border-radius: 0 0 12px 12px;
background: transparent;
border-radius: 0;
}
.bookmarks-container .bookmarks-list {
padding: 0.25rem;
padding: 0.5rem;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
width: 100%;
max-width: 100%;
@@ -108,13 +109,8 @@ body {
.view-mode-controls {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
margin-bottom: 1rem;
justify-content: center;
gap: 0.5rem;
}
.profile-avatar {
@@ -747,11 +743,11 @@ body {
}
.individual-bookmark {
background: #2a2a2a;
background: transparent;
padding: 1rem;
border-radius: 8px;
transition: all 0.2s ease;
border: 1px solid #333;
border: 1px solid transparent;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
@@ -759,23 +755,26 @@ body {
}
.individual-bookmark:hover {
border-color: #444;
background: #2d2d2d;
border-color: transparent;
background: #2a2a2a;
}
/* Compact view styles */
.individual-bookmark.compact {
padding: 0.3rem 0.25rem;
padding: 0.5rem 0.5rem;
background: transparent;
border-bottom: 1px solid #333;
border: none;
border-bottom: 1px solid #2a2a2a;
border-radius: 0;
box-shadow: none;
width: 100%;
max-width: 100%;
overflow: hidden;
}
.individual-bookmark.compact:hover {
background: #2a2a2a;
background: #252525;
border-bottom-color: #333;
transform: none;
box-shadow: none;
}
@@ -783,11 +782,11 @@ body {
.compact-row {
display: flex;
align-items: center;
gap: 0.75rem;
gap: 0.5rem;
height: 28px;
justify-content: space-between;
width: 100%;
min-width: 0;
overflow: hidden;
}
.compact-row.clickable {
@@ -808,7 +807,7 @@ body {
}
.compact-text {
flex: 1 1 0;
flex: 1;
min-width: 0;
color: #ccc;
font-size: 0.85rem;
@@ -816,7 +815,6 @@ body {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.bookmark-date-compact {
@@ -837,10 +835,9 @@ body {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
width: 24px;
height: 22px;
flex-shrink: 0;
margin-left: auto;
transition: color 0.2s ease;
}
@@ -1208,12 +1205,22 @@ body {
}
.individual-bookmark {
background: #f5f5f5;
border-color: #ddd;
background: transparent;
border-color: transparent;
}
.individual-bookmark:hover {
border-color: #646cff;
background: #f5f5f5;
border-color: transparent;
}
.individual-bookmark.compact {
border-bottom-color: #e5e5e5;
}
.individual-bookmark.compact:hover {
background: #fafafa;
border-bottom-color: #ddd;
}
.individual-bookmarks h4 {
@@ -1279,7 +1286,6 @@ body {
flex-direction: column;
height: 100%;
overflow: hidden;
padding-right: 1rem;
}
.highlights-container.collapsed {
@@ -1570,7 +1576,7 @@ body {
.highlight-relay-indicator {
position: absolute;
bottom: -4px;
bottom: -2px;
left: 0;
font-size: 0.7rem;
color: #888;
@@ -1635,22 +1641,33 @@ body {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-size: 0.8rem;
color: #888;
flex-wrap: wrap;
flex-wrap: nowrap;
min-height: 20px;
}
.highlight-author {
color: #aaa;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
line-height: 1;
}
.highlight-meta-separator {
color: #666;
flex-shrink: 0;
line-height: 1;
}
.highlight-time {
color: #888;
white-space: nowrap;
flex-shrink: 0;
line-height: 1;
}
.highlight-source {
@@ -1660,6 +1677,9 @@ body {
color: #646cff;
text-decoration: none;
transition: color 0.2s ease;
flex-shrink: 0;
margin-left: auto;
line-height: 1;
}
.highlight-source:hover {
@@ -2494,6 +2514,23 @@ body {
cursor: default;
}
.relay-status-indicator.connecting {
background: rgba(100, 108, 255, 0.15);
border: 1px solid rgba(100, 108, 255, 0.25);
}
.relay-status-indicator.connecting:hover {
background: rgba(100, 108, 255, 0.25);
}
.relay-status-indicator.connecting .relay-status-icon {
color: rgba(100, 108, 255, 0.9);
}
.relay-status-indicator.connecting .relay-status-title {
color: rgba(100, 108, 255, 1);
}
.relay-status-indicator:hover {
background: rgba(245, 158, 11, 1);
transform: translateY(-2px);
@@ -2555,3 +2592,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

@@ -11,7 +11,8 @@ import { areAllRelaysLocal } from '../utils/helpers'
import { markEventAsOfflineCreated } from './offlineSyncService'
// Boris pubkey for zap splits
const BORIS_PUBKEY = '6e468422dfb74a5738702a8823b9b28168fc6cfb119d613e49ca0ec5a0bbd0c3'
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
const {
getHighlightText,
@@ -75,9 +76,19 @@ export async function createHighlight(
// Update the alt tag to identify Boris as the creator
const altTagIndex = highlightEvent.tags.findIndex(tag => tag[0] === 'alt')
if (altTagIndex !== -1) {
highlightEvent.tags[altTagIndex] = ['alt', 'Highlight created by Boris. readwithboris.com']
highlightEvent.tags[altTagIndex] = ['alt', 'Highlight created by Boris. read.withboris.com']
} else {
highlightEvent.tags.push(['alt', 'Highlight created by Boris. readwithboris.com'])
highlightEvent.tags.push(['alt', 'Highlight created by Boris. read.withboris.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)

View File

@@ -1,253 +1,18 @@
/**
* Image Cache Service
*
* Caches images in localStorage 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_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
}
}
const CACHE_NAME = 'boris-image-cache-v1'
/**
* Clear all cached images
*/
export function clearImageCache(): void {
export async function clearImageCache(): Promise<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)
await caches.delete(CACHE_NAME)
console.log('🗑️ Cleared all cached images')
} catch (err) {
console.error('Failed to clear image cache:', err)
@@ -255,24 +20,55 @@ export function clearImageCache(): 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: []
}
}

View File

@@ -1,5 +1,5 @@
import React from 'react'
import { formatDistanceToNow } from 'date-fns'
import { formatDistanceToNow, differenceInSeconds, differenceInMinutes, differenceInHours, differenceInDays, differenceInMonths, differenceInYears } from 'date-fns'
import { ParsedContent, ParsedNode } from '../types/bookmarks'
import ResolvedMention from '../components/ResolvedMention'
// Note: ContentWithResolvedProfiles is imported by components directly to keep this file component-only for fast refresh
@@ -9,6 +9,26 @@ export const formatDate = (timestamp: number) => {
return formatDistanceToNow(date, { addSuffix: true })
}
// Ultra-compact date format for tight spaces (e.g., compact view)
export const formatDateCompact = (timestamp: number) => {
const date = new Date(timestamp * 1000)
const now = new Date()
const seconds = differenceInSeconds(now, date)
const minutes = differenceInMinutes(now, date)
const hours = differenceInHours(now, date)
const days = differenceInDays(now, date)
const months = differenceInMonths(now, date)
const years = differenceInYears(now, date)
if (seconds < 60) return 'now'
if (minutes < 60) return `${minutes}m`
if (hours < 24) return `${hours}h`
if (days < 30) return `${days}d`
if (months < 12) return `${months}mo`
return `${years}y`
}
// Component to render content with resolved nprofile names
// Intentionally no exports except components and render helpers

9
vercel.json Normal file
View File

@@ -0,0 +1,9 @@
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}