Compare commits

..

73 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
Gigi
035d4d3bd0 chore: bump version to 0.3.1 2025-10-09 17:36:37 +01:00
Gigi
43d5554c0c feat: change default image cache size to 210MB
Increase default cache size from 50MB to 210MB for better offline experience
2025-10-09 17:31:53 +01:00
Gigi
724a3e5cfa refactor: move 'Rebroadcast events' setting to Startup & Behavior section
Move the rebroadcast setting from Flight Mode to Startup & Behavior as it's more about behavior than offline mode
2025-10-09 17:31:07 +01:00
Gigi
0c49988d36 refactor: rename 'Startup Preferences' to 'Startup & Behavior' 2025-10-09 17:30:40 +01:00
Gigi
70de68848b refactor: move image cache setting to top of Flight Mode section
Reorder settings so 'Use local image cache' appears before 'Use local relays as cache'
2025-10-09 17:29:37 +01:00
Gigi
8a12ae72cb fix: ensure cache size input uses same font and size as surrounding text
Use inherit for fontSize, fontFamily, and color to match parent styling
2025-10-09 17:29:03 +01:00
Gigi
f8d5d19a9f refactor: inline textbox in cache stats display
Move max cache size input inline with stats text: '( X MB / [input] MB used )'
2025-10-09 17:28:20 +01:00
Gigi
dbd20e676f refactor: use IconButton component for cache clear button
Replace inline styled button with existing IconButton component to keep code DRY
2025-10-09 17:27:53 +01:00
Gigi
bbdf47fb94 refactor: update cache stats display format
Change from '(X.X MB, N images)' to '( X.X MB / [ max ] MB used )'
2025-10-09 17:27:21 +01:00
Gigi
1b754e02dc refactor: update cache setting label to 'Use local image cache' 2025-10-09 17:26:52 +01:00
Gigi
a2e410252a refactor: condense cache settings to single line
- Combine all cache settings into one horizontal line
- Shorten 'Cache images for offline viewing' to 'Cache images'
- Shorten 'Max cache size (MB):' to 'Max (MB):'
- Simplify current stats display with parentheses
- Use flexbox with wrap for responsive layout
2025-10-09 17:26:13 +01:00
Gigi
c9a14d151d refactor: simplify image cache settings UI
- Remove 'Image Cache' heading
- Remove explanatory text about localStorage
- Replace slider with simple number input for cache size
- Replace 'Clear Cache' button text with trash icon
- Make cache stats display more compact
2025-10-09 17:25:18 +01:00
Gigi
b286562e86 fix: extend article hero image to pane edges
Remove padding/margins from article hero images so they extend all the way
to the top, left, and right edges of the article pane. Uses negative margins
to counteract the reader container's padding.
2025-10-09 17:24:24 +01:00
Gigi
507288f51c 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.
2025-10-09 17:23:31 +01:00
Gigi
e08bc54f15 refactor(relay): adjust offline indicator polling to 5s 2025-10-09 17:01:20 +01:00
Gigi
4306069191 fix(relay): make offline indicator poll every 3s for better responsiveness 2025-10-09 17:00:53 +01:00
Gigi
56e56af8ec docs: update CHANGELOG for version 0.3.0 2025-10-09 16:59:05 +01:00
36 changed files with 1175 additions and 123 deletions

4
.gitignore vendored
View File

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

View File

@@ -5,6 +5,118 @@ 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
- Flight Mode with offline highlight creation and local relay support
- Automatic offline sync - rebroadcast local events when back online
- Relay indicator icon on highlight items showing sync status
- Click-to-rebroadcast functionality for highlights
- Flight mode indicator (plane icon) on offline-created highlights
- Relay rebroadcast settings for caching and propagation
- Local relay status indicator for local-only/offline mode
- Second local relay support (localhost:4869)
- Relay connection status tracking and display
- 6th font size option for better UI scaling
### Fixed
- Highlight creation resilient to offline/flight mode
- TypeScript type errors in offline sync
- Relay indicator tooltip accuracy and reliability
- Always show relay indicator icon on highlights
- Show remote relay list for fetched highlights
- Publish highlights to all connected relays instead of just one
- Keep all relay connections alive, not just local ones
- Check actual relay connection status instead of pool membership
- Skip rebroadcasting when in flight mode
- Update relay info after automatic sync completes
- Only show successfully reachable relays in flight mode
- Include local relays in relay indicator tooltip
### Changed
- Rename 'Offline Mode' to 'Flight Mode' throughout UI
- Move publication date to top-right corner with subtle border styling
- Consolidate relay/status indicators into single unified icon
- Simplify relay indicator tooltip to show relay list
- Move rebroadcast settings to dedicated Flight Mode section
- Place Reading Font and Font Size settings side-by-side
- Improve font size scale and default value
- Use wifi icon for disconnected remote relays
- Use airplane icons for local relay indicators
- Make Relays heading same level as Flight Mode in settings
- Simplify rebroadcast settings UI with consistent checkbox style
### Performance
- Make highlight creation instant with non-blocking relay publish
- Reduce relay status polling interval to 20 seconds
- Show sync progress and hide indicator after successful sync
## [0.2.10] - 2025-10-09
### Added
@@ -426,6 +538,13 @@ 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
[0.2.8]: https://github.com/dergigi/boris/compare/v0.2.7...v0.2.8

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.0",
"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

@@ -11,15 +11,17 @@ import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
import { CompactView } from './BookmarkViews/CompactView'
import { LargeView } from './BookmarkViews/LargeView'
import { CardView } from './BookmarkViews/CardView'
import { UserSettings } from '../services/settingsService'
interface BookmarkItemProps {
bookmark: IndividualBookmark
index: number
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
viewMode?: ViewMode
settings?: UserSettings
}
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', settings }) => {
const [ogImage, setOgImage] = useState<string | null>(null)
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
@@ -115,7 +117,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
getAuthorDisplayName,
handleReadNow,
articleImage,
articleSummary
articleSummary,
settings
}
if (viewMode === 'compact') {

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'
@@ -8,6 +9,7 @@ import SidebarHeader from './SidebarHeader'
import IconButton from './IconButton'
import { ViewMode } from './Bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { UserSettings } from '../services/settingsService'
interface BookmarkListProps {
bookmarks: Bookmark[]
@@ -21,8 +23,10 @@ interface BookmarkListProps {
onOpenSettings: () => void
onRefresh?: () => void
isRefreshing?: boolean
lastFetchTime?: number | null
loading?: boolean
relayPool: RelayPool | null
settings?: UserSettings
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
@@ -37,8 +41,10 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onOpenSettings,
onRefresh,
isRefreshing,
lastFetchTime,
loading = false,
relayPool
relayPool,
settings
}) => {
// Helper to check if a bookmark has either content or a URL
const hasContentOrUrl = (ib: IndividualBookmark) => {
@@ -99,8 +105,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onToggleCollapse={onToggleCollapse}
onLogout={onLogout}
onOpenSettings={onOpenSettings}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
relayPool={relayPool}
/>
@@ -123,9 +127,38 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
index={index}
onSelectUrl={onSelectUrl}
viewMode={viewMode}
settings={settings}
/>
)}
</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

@@ -7,6 +7,8 @@ import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import IconButton from '../IconButton'
import { classifyUrl } from '../../utils/helpers'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { UserSettings } from '../../services/settingsService'
interface CardViewProps {
bookmark: IndividualBookmark
@@ -22,6 +24,7 @@ interface CardViewProps {
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleImage?: string
articleSummary?: string
settings?: UserSettings
}
export const CardView: React.FC<CardViewProps> = ({
@@ -37,8 +40,10 @@ export const CardView: React.FC<CardViewProps> = ({
getAuthorDisplayName,
handleReadNow,
articleImage,
articleSummary
articleSummary,
settings
}) => {
const cachedImage = useImageCache(articleImage, settings)
const [expanded, setExpanded] = useState(false)
const [urlsExpanded, setUrlsExpanded] = useState(false)
const contentLength = (bookmark.content || '').length
@@ -48,10 +53,10 @@ export const CardView: React.FC<CardViewProps> = ({
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
{isArticle && articleImage && (
{isArticle && cachedImage && (
<div
className="article-hero-image"
style={{ backgroundImage: `url(${articleImage})` }}
style={{ backgroundImage: `url(${cachedImage})` }}
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
/>
)}

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

@@ -4,6 +4,8 @@ import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { UserSettings } from '../../services/settingsService'
interface LargeViewProps {
bookmark: IndividualBookmark
@@ -19,6 +21,7 @@ interface LargeViewProps {
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleSummary?: string
settings?: UserSettings
}
export const LargeView: React.FC<LargeViewProps> = ({
@@ -34,13 +37,15 @@ export const LargeView: React.FC<LargeViewProps> = ({
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleSummary
articleSummary,
settings
}) => {
const cachedImage = useImageCache(previewImage || undefined, settings)
const isArticle = bookmark.kind === 30023
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
{(hasUrls || (isArticle && previewImage)) && (
{(hasUrls || (isArticle && cachedImage)) && (
<div
className="large-preview-image"
onClick={() => {
@@ -50,7 +55,7 @@ export const LargeView: React.FC<LargeViewProps> = ({
onSelectUrl?.(extractedUrls[0])
}
}}
style={previewImage ? { backgroundImage: `url(${previewImage})` } : undefined}
style={cachedImage ? { backgroundImage: `url(${cachedImage})` } : undefined}
>
{!previewImage && hasUrls && (
<div className="preview-placeholder">

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}

View File

@@ -11,6 +11,7 @@ import { HighlightVisibility } from './HighlightsPanel'
import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
import { useHighlightedContent } from '../hooks/useHighlightedContent'
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
import { UserSettings } from '../services/settingsService'
interface ContentPanelProps {
loading: boolean
@@ -30,6 +31,7 @@ interface ContentPanelProps {
highlightVisibility?: HighlightVisibility
currentUserPubkey?: string
followedPubkeys?: Set<string>
settings?: UserSettings
// For highlight creation
onTextSelection?: (text: string) => void
onClearSelection?: () => void
@@ -48,6 +50,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
showHighlights = true,
highlightStyle = 'marker',
highlightColor = '#ffff00',
settings,
onHighlightClick,
selectedHighlightId,
highlightVisibility = { nostrverse: true, friends: true, mine: true },
@@ -126,6 +129,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
readingTimeText={readingStats ? readingStats.text : null}
hasHighlights={hasHighlights}
highlightCount={relevantHighlights.length}
settings={settings}
/>
{markdown || html ? (
markdown ? (

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

@@ -2,6 +2,8 @@ import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
import { format } from 'date-fns'
import { useImageCache } from '../hooks/useImageCache'
import { UserSettings } from '../services/settingsService'
interface ReaderHeaderProps {
title?: string
@@ -11,6 +13,7 @@ interface ReaderHeaderProps {
readingTimeText?: string | null
hasHighlights: boolean
highlightCount: number
settings?: UserSettings
}
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
@@ -20,13 +23,15 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
published,
readingTimeText,
hasHighlights,
highlightCount
highlightCount,
settings
}) => {
const cachedImage = useImageCache(image, settings)
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
if (image) {
if (cachedImage) {
return (
<div className="reader-hero-image">
<img src={image} alt={title || 'Article image'} />
<img src={cachedImage} alt={title || 'Article image'} />
{formattedDate && (
<div className="publish-date-topright">
{formattedDate}

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'
@@ -10,8 +10,9 @@ interface RelayStatusIndicatorProps {
}
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ relayPool }) => {
// Poll for relay status updates
const relayStatuses = useRelayStatus({ relayPool })
// Poll frequently for responsive offline indicator (5s instead of default 20s)
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
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
// Debug logging
React.useEffect(() => {
if (localOnlyMode || offlineMode) {
console.log('✈️ Relay Status Indicator:', {
mode: offlineMode ? 'OFFLINE' : 'LOCAL_ONLY',
connectedUrls,
hasLocalRelay,
hasRemoteRelay
})
// 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)
}
}, [localOnlyMode, offlineMode, connectedUrls.length])
}, [connectedUrls.length])
// Don't show indicator when fully connected
if (!localOnlyMode && !offlineMode) return null
// Debug logging
useEffect(() => {
console.log('🔌 Relay Status Indicator:', {
mode: isConnecting ? 'CONNECTING' : offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
totalStatuses: relayStatuses.length,
connectedCount: connectedUrls.length,
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
hasLocalRelay,
hasRemoteRelay,
isConnecting
})
}, [offlineMode, localOnlyMode, connectedUrls.length, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
// 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

@@ -1,6 +1,9 @@
import React from 'react'
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { faTrash } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
import IconButton from '../IconButton'
interface OfflineModeSettingsProps {
settings: UserSettings
@@ -10,16 +13,95 @@ interface OfflineModeSettingsProps {
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
const navigate = useNavigate()
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 = async () => {
if (confirm('Are you sure you want to clear all cached images?')) {
await clearImageCache()
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
}
// Update cache stats periodically
useEffect(() => {
const updateStats = async () => {
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
updateStats() // Initial load
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
return () => clearInterval(interval)
}, [])
return (
<div className="settings-section">
<h3 className="section-title">Flight Mode</h3>
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
<input
id="enableImageCache"
type="checkbox"
checked={settings.enableImageCache ?? true}
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
className="setting-checkbox"
/>
<span>Use local image cache</span>
</label>
{(settings.enableImageCache ?? true) && (
<div style={{
fontSize: '0.85rem',
color: 'var(--text-secondary)',
display: 'flex',
alignItems: 'center',
gap: '0.5rem'
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
( {cacheStats.totalSizeMB.toFixed(1)} MB /
<input
id="imageCacheSizeMB"
type="number"
min="10"
max="500"
value={settings.imageCacheSizeMB ?? 210}
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
style={{
width: '50px',
padding: '0.15rem 0.35rem',
background: 'var(--surface-secondary)',
border: '1px solid var(--border-color, #333)',
borderRadius: '4px',
color: 'inherit',
fontSize: 'inherit',
fontFamily: 'inherit',
textAlign: 'center'
}}
/>
MB used )
</span>
<IconButton
icon={faTrash}
onClick={handleClearCache}
title="Clear cache"
variant="ghost"
size={28}
/>
</div>
)}
</div>
<div className="setting-group">
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
<input
@@ -33,19 +115,6 @@ const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onU
</label>
</div>
<div className="setting-group">
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
<input
id="rebroadcastToAllRelays"
type="checkbox"
checked={settings.rebroadcastToAllRelays ?? false}
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
className="setting-checkbox"
/>
<span>Rebroadcast events while browsing</span>
</label>
</div>
<div style={{
marginTop: '1.5rem',
padding: '1rem',

View File

@@ -9,7 +9,7 @@ interface StartupPreferencesSettingsProps {
const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({ settings, onUpdate }) => {
return (
<div className="settings-section">
<h3 className="section-title">Startup Preferences</h3>
<h3 className="section-title">Startup & Behavior</h3>
<div className="setting-group">
<label htmlFor="sidebarCollapsed" className="checkbox-label">
@@ -36,6 +36,19 @@ const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({
<span>Start with highlights panel collapsed</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
<input
id="rebroadcastToAllRelays"
type="checkbox"
checked={settings.rebroadcastToAllRelays ?? false}
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
className="setting-checkbox"
/>
<span>Rebroadcast events while browsing</span>
</label>
</div>
</div>
)
}

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,8 +95,10 @@ 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}
/>
</div>
<div className="pane main">
@@ -102,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}
@@ -123,6 +135,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
onClearSelection={props.onClearSelection}
currentUserPubkey={props.currentUserPubkey}
followedPubkeys={props.followedPubkeys}
settings={props.settings}
/>
)}
</div>

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

@@ -0,0 +1,34 @@
import { UserSettings } from '../services/settingsService'
/**
* 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 display
* @returns The image URL (Service Worker handles caching)
*/
export function useImageCache(
imageUrl: string | undefined,
// eslint-disable-next-line no-unused-vars
_settings?: UserSettings
): string | undefined {
// Service Worker handles everything - just return the URL as-is
return imageUrl
}
/**
* 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,
// eslint-disable-next-line no-unused-vars
_settings?: UserSettings
): void {
// 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;
}
@@ -1095,9 +1092,9 @@ body {
/* Hero image in reader view */
.reader-hero-image {
width: 100%;
margin: 0 0 2rem 0;
border-radius: 8px;
width: calc(100% + 1.5rem);
margin: -0.75rem -0.75rem 2rem -0.75rem;
border-radius: 0;
overflow: hidden;
position: relative;
min-height: 300px;
@@ -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

@@ -145,6 +145,8 @@ export async function fetchArticleByNaddr(
// Save to cache before returning
saveToCache(naddr, content)
// Image caching is handled automatically by Service Worker
return content
} catch (err) {
console.error('Failed to fetch article:', 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

@@ -0,0 +1,74 @@
/**
* Image Cache Service
*
* Utility functions for managing the Service Worker's image cache
* Service Worker automatically caches images on fetch
*/
const CACHE_NAME = 'boris-image-cache-v1'
/**
* Clear all cached images
*/
export async function clearImageCache(): Promise<void> {
try {
await caches.delete(CACHE_NAME)
console.log('🗑️ Cleared all cached images')
} catch (err) {
console.error('Failed to clear image cache:', err)
}
}
/**
* 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 }>
} {
// Return placeholder - actual stats require async Cache API access
// Component should use getImageCacheStatsAsync for real values
return {
totalSizeMB: 0,
itemCount: 0,
items: []
}
}

View File

@@ -42,6 +42,9 @@ export interface UserSettings {
// Relay rebroadcast settings
useLocalRelayAsCache?: boolean // Rebroadcast events to local relays
rebroadcastToAllRelays?: boolean // Rebroadcast events to all relays
// Image cache settings
enableImageCache?: boolean // Enable caching images in localStorage
imageCacheSizeMB?: number // Maximum cache size in megabytes (default: 210MB)
}
export async function loadSettings(

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"
}
]
}