Compare commits

..

72 Commits

Author SHA1 Message Date
Gigi
fa8eed4f4e chore: bump version to 0.10.13 2025-10-22 15:36:42 +02:00
Gigi
3ff57c4b67 fix(lint): add previewData to useArticleLoader effect dependencies 2025-10-22 15:36:03 +02:00
Gigi
51c364ea53 feat(article): instant preview from blog cards - show title, image, summary, date immediately via navigation state while content loads 2025-10-22 15:33:37 +02:00
Gigi
4d032372dc fix(explore): show blog post skeletons instead of spinner when loading writings tab 2025-10-22 15:31:19 +02:00
Gigi
48b5aa3a30 feat(article): instant load from eventStore when clicking bookmark cards - check store by coordinate before relay query 2025-10-22 15:29:08 +02:00
Gigi
d4483a2f91 fix(lint): resolve eslint warnings in useArticleLoader - add comment for empty catch, use settingsRef consistently 2025-10-22 15:26:16 +02:00
Gigi
c62cb21962 fix(article): wire eventStore to useArticleLoader for instant local-first loads; keep SW enabled in prod for PWA 2025-10-22 15:24:24 +02:00
Gigi
3f7d726ae6 feat(article): local-first streaming loader using eventStore + queryEvents in useArticleLoader; emit immediately on first store/relay hit; finalize on EOSE 2025-10-22 15:22:39 +02:00
Gigi
ac0e5eb585 fix(article): add reliable-relay fallback (nostr.band, primal, damus, nos.lol) when first parallel query returns no events 2025-10-22 14:03:47 +02:00
Gigi
5a0dd49e4e fix(sw): disable Service Worker in dev and register non-module SW only in production to avoid stale cached HTML causing mismatched content 2025-10-22 14:01:53 +02:00
Gigi
d067193f21 fix(reader): force re-mount of markdown preview and rendered HTML per-content to eliminate stale display when switching articles 2025-10-22 13:46:57 +02:00
Gigi
774e2ba67c fix(reader): clear markdown render on change and add request guards to external URL loader to prevent stale content 2025-10-22 13:45:41 +02:00
Gigi
6f1c31058f fix(reader): guard against stale article fetches overwriting current content/highlights via requestId in useArticleLoader 2025-10-22 13:41:46 +02:00
Gigi
7551a05aee fix(article): prevent re-fetch on settings change by memoizing via ref in useArticleLoader 2025-10-22 13:38:24 +02:00
Gigi
df485b883d fix(article): query union of naddr relay hints and configured relays to prevent post-load ‘Article not found’ refresh 2025-10-22 13:35:59 +02:00
Gigi
6f428af1bc docs: update CHANGELOG for v0.10.12 2025-10-22 13:32:05 +02:00
Gigi
e821aaf058 chore: bump version to 0.10.12 2025-10-22 13:30:37 +02:00
Gigi
a84d439489 fix: properly deduplicate web bookmarks by d-tag
- Web bookmarks (kind:39701) are replaceable events and should be deduplicated by d-tag
- Update dedupeNip51Events to include kind:39701 in d-tag deduplication logic
- Use coordinate format (kind:pubkey:d-tag) for web bookmark IDs instead of event IDs
- Ensures same URL bookmarked multiple times only appears once
- Keeps newest version when duplicates exist
2025-10-22 13:28:36 +02:00
Gigi
67bf7e017d fix: make profile avatar button same size as other icon buttons on mobile
- Add mobile media query to profile-avatar-button for consistent sizing
- Use --min-touch-target (44px) on mobile to match IconButton components
- Ensures consistent touch target size across all sidebar buttons
2025-10-22 13:26:20 +02:00
Gigi
e47419a0b8 feat: update explore icon to fa-person-hiking and reorder sidebar buttons
- Change explore icon from fa-newspaper to fa-person-hiking in SidebarHeader and Explore components
- Switch positions of settings and explore buttons in sidebar navigation
- Remove all console.log statements from bookmarkController and bookmarkProcessing
- Update CHANGELOG.md with v0.10.11 changes
2025-10-22 13:25:34 +02:00
Gigi
2dda52c30f chore: bump version to 0.10.11 2025-10-22 13:19:36 +02:00
Gigi
2e0a493243 fix(bookmarks): sort by display time (created_at || listUpdatedAt) desc; nulls last 2025-10-22 13:07:37 +02:00
Gigi
2e955e9bed refactor(bookmarks): never default timestamps to now; allow nulls and sort nulls last; render empty when missing 2025-10-22 13:04:24 +02:00
Gigi
538cbd2296 fix(bookmarks): show sane dates using created_at fallback to listUpdatedAt; guard formatters 2025-10-22 12:54:26 +02:00
Gigi
c17eab5a47 fix(router): route /me/reading-list -> /me/bookmarks to render Bookmarks view 2025-10-22 12:49:23 +02:00
Gigi
b3c61ba635 fix: update Me.tsx bookmarks tab to use dynamic filter titles and chronological sorting 2025-10-22 12:46:16 +02:00
Gigi
3bfa750a0c fix: update Me.tsx to use faClock icon instead of faBars 2025-10-22 12:42:42 +02:00
Gigi
d1f7e549c2 fix: change bookmark URL from /me/reading-list to /me/bookmarks 2025-10-22 12:33:05 +02:00
Gigi
0fec120410 debug: add targeted logging to diagnose listUpdatedAt timestamp issue 2025-10-22 12:31:53 +02:00
Gigi
9b21075a9b refactor: remove excessive debug logging 2025-10-22 12:29:09 +02:00
Gigi
4f78ee4794 fix: preserve content created_at, add listUpdatedAt for sorting by when bookmarked 2025-10-22 12:26:01 +02:00
Gigi
8bb871913b refactor: remove synthetic added_at field, use created_at from bookmark list event 2025-10-22 12:18:43 +02:00
Gigi
49eb6855ca debug: add console logging for bookmark timestamp and sorting analysis 2025-10-22 12:14:36 +02:00
Gigi
748b2e1631 fix: correct added_at timestamp to use bookmark list creation time, not content creation time 2025-10-22 12:12:44 +02:00
Gigi
9fa83a2a1c fix: ensure robust sorting of merged bookmarks with fallback timestamps 2025-10-22 12:07:32 +02:00
Gigi
d45705e8e4 feat: use clock icon (regular style) for chronological bookmark view 2025-10-22 12:05:57 +02:00
Gigi
83c170b4e2 fix: ensure bookmarks are consistently sorted chronologically with useMemo 2025-10-22 12:04:41 +02:00
Gigi
8459853c43 refactor: remove bookmark count from section headings 2025-10-22 12:02:24 +02:00
Gigi
f7eeb080e1 feat: update bookmark heading based on selected filter 2025-10-22 12:01:09 +02:00
Gigi
2769b2dba7 fix: remove unused faTimes import 2025-10-22 11:51:17 +02:00
Gigi
46636b8e6a feat: move profile picture to first position (left-aligned) with consistent sizing 2025-10-22 11:50:16 +02:00
Gigi
92a85761ef feat: make highlight count clickable to open highlights sidebar 2025-10-22 11:48:41 +02:00
Gigi
f6a325f7e9 feat: hide close/collapse sidebar buttons on mobile 2025-10-22 11:45:51 +02:00
Gigi
a501fa816f feat: sort bookmarks chronologically by displayed date (newest first) 2025-10-22 11:43:30 +02:00
Gigi
5ece80b8e9 feat: change default bookmark view to flat chronological list 2025-10-22 11:42:12 +02:00
Gigi
87c017b2c2 docs: update CHANGELOG for v0.10.10 2025-10-22 11:38:09 +02:00
Gigi
550ee415f0 chore: bump version to 0.10.10 2025-10-22 11:37:08 +02:00
Gigi
aaaf226623 Merge pull request #24 from dergigi/controllers-and-fetching
Replace timeouts with streaming controllers and fix bookmark hydration
2025-10-22 11:36:35 +02:00
Gigi
23ce0c9d4c chore: remove debug logging from bookmark controller 2025-10-22 11:33:41 +02:00
Gigi
dddf8575c4 fix: resolve TypeScript type errors in bookmark hydration promises
Add .then() handlers to convert Promise<NostrEvent[]> to Promise<void>
for compatibility with Promise<void>[] array type.
2025-10-22 11:30:53 +02:00
Gigi
3ab0610e1e fix: prevent cascading hydration loops in bookmark controller
Run all coordinate queries in parallel with Promise.all instead of
sequential awaits. This prevents each query from triggering a rebuild
that causes another hydration cycle, which was creating infinite loops.

The issue was that awaiting each query sequentially would:
1. Fetch articles for author A
2. Call onProgress, rebuild bookmarks
3. Trigger new hydration because coordinates changed
4. Repeat indefinitely

Now all queries start at once and stream results as they arrive,
matching the original loader behavior.
2025-10-22 11:27:12 +02:00
Gigi
e40f820fdc fix: handle empty d-tags separately in bookmark hydration
Separate fetching of articles with empty vs non-empty d-tags to work
around relay filter handling issues. For empty d-tags, fetch all events
of that kind/author and filter client-side.
2025-10-22 11:25:30 +02:00
Gigi
3f82bc7873 debug: add logging for bookmark coordinate hydration 2025-10-22 11:23:26 +02:00
Gigi
b913cc4d7f fix: hide 'Open Original' button for nostr-native events
Only external URLs (/r/ paths) have original sources.
Nostr-native events don't need this option in the three-dot menu.
2025-10-22 11:21:08 +02:00
Gigi
bc1aed30b4 fix: open nostr events directly on ants.sh instead of as search query
When clicking search in the three-dot menu for a nostr event,
now opens https://ants.sh/e/<eventId> directly instead of
https://ants.sh/?q=nostr-event:<eventId>
2025-10-22 11:20:12 +02:00
Gigi
9a801975aa fix(bookmarks): replace applesauce loaders with local-first queryEvents
Replace EventLoader and AddressLoader with queryEvents for bookmark
hydration to properly prioritize local relays. The applesauce loaders
were not using local-first fetching strategy, causing bookmarked events
to not be hydrated from local relay cache.

- Remove createEventLoader and createAddressLoader usage
- Replace with queryEvents which handles local-first fetching
- Properly streams events from local relays before remote relays
- Follows the controller pattern used by other services (writings, etc)

This fixes the issue where bookmarks would only show event IDs instead
of full content, while blog posts (kind:30023) worked correctly.
2025-10-22 11:16:21 +02:00
Gigi
f3e44edd51 fix: remove unnecessary key prop causing lag on tab switching in Explore 2025-10-22 11:09:05 +02:00
Gigi
0be6aa81ce fix: add comments to empty catch blocks to satisfy linter 2025-10-22 09:00:01 +02:00
Gigi
c7b885cfcd refactor(reader): use startReadingPositionStream in ContentPanel 2025-10-22 08:55:50 +02:00
Gigi
11041df1fb refactor(reading-position): add startReadingPositionStream and remove timeouts 2025-10-22 08:55:18 +02:00
Gigi
89273e2a03 refactor(settings): use startSettingsStream in useSettings hook 2025-10-22 08:54:45 +02:00
Gigi
0610454e74 feat(settings): add startSettingsStream and remove timeout-based blocking 2025-10-22 08:54:17 +02:00
Gigi
a02413a7cb fix(reading-progress): load and display progress on fresh sessions; include external URL keys and avoid double-encoding; add debug guard 2025-10-22 02:02:39 +02:00
Gigi
0bc84e7c6c chore: update package-lock.json for v0.10.9 2025-10-22 01:41:46 +02:00
Gigi
a1e28c6bc9 docs: update CHANGELOG for v0.10.9 2025-10-22 01:41:34 +02:00
Gigi
a1a7f0e4a4 chore: bump version to 0.10.9 2025-10-22 01:41:14 +02:00
Gigi
cde8e30ab2 fix(events): improve /e/ reliability with retry + backoff in eventManager
- Add multi-attempt fetch with backoff
- Retry on not-found, errors, and timeouts before failing
- Keep deduplication and cache-first behavior
2025-10-22 01:40:26 +02:00
Gigi
aa7e532950 fix(bookmarks): use per-item added_at/created_at when available
- Read / from applesauce pointers for notes/articles
- Fallback to eventStore event  during enrichment
- Keeps sorting by  then  consistent
2025-10-22 01:35:06 +02:00
Gigi
c9208cfff2 chore: remove all debug console logs
- Remove console.log from bookmark hydration
- Remove console.log from relay initialization
- Remove all console.debug calls from TTS hook and controls
- Remove debug logging from RouteDebug component
- Fix useCallback dependency warning in speak function
2025-10-22 01:26:42 +02:00
Gigi
2fb4132342 docs: update CHANGELOG for v0.10.8 2025-10-22 01:25:41 +02:00
Gigi
81180c8ba8 chore: bump version to 0.10.8 2025-10-22 01:23:13 +02:00
Gigi
1c48adf44e Merge pull request #23 from dergigi/e-path
feat: add /e/:eventId path for individual event rendering
2025-10-22 01:22:52 +02:00
46 changed files with 1042 additions and 593 deletions

View File

@@ -2,4 +2,4 @@
alwaysApply: true
---
Keep files below 210 lines.
Keep files below 420 lines.

View File

@@ -0,0 +1,18 @@
---
description: fetching data from relays
alwaysApply: false
---
We fetch data from relays using controllers:
- Start controllers immediatly; dont await.
- Stream via onEvent; dedupe replaceables; emit immediately.
- Parallel local/remote queries; complete on EOSE.
- Finalize and persist since after completion.
- Guard with generations to cancel stale runs.
- UI flips off loading on first streamed result.
We always include and prefer local relays for reads; optionally rebroadcast fetched content to local relays (depending on setting); and tolerate localonly mode for writes (queueing for later).
Since we are streaming results, we should NEVER use timeouts for fetching data. We should always rely on EOSE.
In short: Local-first hydration, background network fetch, reactive updates, and replaceable lookups provide instant UI with eventual consistency. Use local relays as local data store for everything we fetch from remote relays.

View File

@@ -7,6 +7,117 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.10.12] - 2025-01-27
### Added
- Person hiking icon (fa-person-hiking) for explore navigation
### Changed
- Explore icon changed from newspaper to person hiking for better semantic meaning
- Settings button moved before explore button in sidebar navigation
- Profile avatar button now uses 44px touch target on mobile (matches other icon buttons)
### Fixed
- Web bookmarks (kind:39701) now properly deduplicate by d-tag
- Same URL bookmarked multiple times now only appears once
- Web bookmark IDs use coordinate format (kind:pubkey:d-tag) for consistent deduplication
- Profile avatar button sizing on mobile now matches other IconButton components
- Removed all console.log statements from bookmarkController and bookmarkProcessing
## [0.10.11] - 2025-01-27
### Added
- Clock icon for chronological bookmark view
- Clickable highlight count to open highlights sidebar
- Dynamic bookmark filter titles based on selected filter
- Profile picture moved to first position (left-aligned) with consistent sizing
### Changed
- Default bookmark view changed to flat chronological list (newest first)
- Bookmark URL changed from `/me/reading-list` to `/me/bookmarks`
- Router updated to handle `/me/reading-list``/me/bookmarks` redirect
- Me.tsx bookmarks tab now uses dynamic filter titles and chronological sorting
- Me.tsx updated to use faClock icon instead of faBars
- Removed bookmark count from section headings for cleaner display
- Hide close/collapse sidebar buttons on mobile for better UX
### Fixed
- Bookmark sorting now uses proper display time (created_at || listUpdatedAt) with nulls last
- Robust sorting of merged bookmarks with fallback timestamps
- Corrected bookmark timestamp to use bookmark list creation time, not content creation time
- Preserved content created_at while adding listUpdatedAt for proper sorting
- Removed synthetic added_at field, now uses created_at from bookmark list event
- Consistent chronological sorting with useMemo optimization
- Removed unused faTimes import
- Bookmark timestamps now show sane dates using created_at fallback to listUpdatedAt
- Guarded formatters to prevent timestamp display errors
### Refactored
- Removed excessive debug logging for cleaner console output
- Bookmark timestamp handling never defaults to "now", allows nulls and sorts nulls last
- Renders empty when timestamp is missing instead of showing invalid dates
## [0.10.10] - 2025-10-22
### Changed
- Version bump for consistency (no user-facing changes)
## [0.10.9] - 2025-10-21
### Fixed
- Event fetching reliability with exponential backoff in eventManager
- Improved retry logic with incremental backoff delays
- Better handling of concurrent event requests
- More robust event retrieval from relay pool
- Bookmark timestamp handling
- Use per-item `added_at`/`created_at` timestamps when available
- Improves accuracy of bookmark date tracking
### Changed
- Removed all debug console logs
- Cleaner console output in development and production
- Improved performance by eliminating debugging statements
## [0.10.8] - 2025-10-21
### Added
- Individual event rendering via `/e/:eventId` path
- Display `kind:1` notes and other events with article-like presentation
- Publication date displayed in top-right corner like articles
- Author attribution with "Note by @author" titles
- Direct event loading with intelligent caching from eventStore
- Centralized event fetching via new `eventManager` singleton
- Request deduplication for concurrent fetches
- Automatic retry logic when relay pool becomes available
- Non-blocking background fetching with 12-second timeout
- Seamless integration with eventStore for instant cached event display
### Fixed
- Bookmark hydration efficiency
- Only request content for bookmarks missing data (not all bookmarks)
- Use eventStore fallback for instant display of cached profiles
- Prevents over-fetching and improves initial load performance
- Search button behavior for notes
- Opens `kind:1` notes directly via `/e/{eventId}` instead of search portal
- Articles continue to use search portal with proper naddr encoding
- Removes unwanted `nostr-event:` prefix from URLs
- Author profile resolution
- Fetch author profiles from eventStore cache first before relay requests
- Instant title updates if profile already loaded
- Graceful fallback to short pubkey display if profile unavailable
## [0.10.7] - 2025-10-21
### Fixed
@@ -2388,7 +2499,15 @@ 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
[Unreleased]: https://github.com/dergigi/boris/compare/v0.10.4...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.10.12...HEAD
[0.10.12]: https://github.com/dergigi/boris/compare/v0.10.11...v0.10.12
[0.10.11]: https://github.com/dergigi/boris/compare/v0.10.10...v0.10.11
[0.10.10]: https://github.com/dergigi/boris/compare/v0.10.9...v0.10.10
[0.10.9]: https://github.com/dergigi/boris/compare/v0.10.8...v0.10.9
[0.10.8]: https://github.com/dergigi/boris/compare/v0.10.7...v0.10.8
[0.10.7]: https://github.com/dergigi/boris/compare/v0.10.6...v0.10.7
[0.10.6]: https://github.com/dergigi/boris/compare/v0.10.5...v0.10.6
[0.10.5]: https://github.com/dergigi/boris/compare/v0.10.4...v0.10.5
[0.10.4]: https://github.com/dergigi/boris/compare/v0.10.3...v0.10.4
[0.10.3]: https://github.com/dergigi/boris/compare/v0.10.2...v0.10.3
[0.10.2]: https://github.com/dergigi/boris/compare/v0.10.1...v0.10.2

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "boris",
"version": "0.10.5",
"version": "0.10.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boris",
"version": "0.10.5",
"version": "0.10.9",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",

View File

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

View File

@@ -253,7 +253,7 @@ function AppRoutes({
}
/>
<Route
path="/me/reading-list"
path="/me/bookmarks"
element={
<Bookmarks
relayPool={relayPool}
@@ -578,8 +578,6 @@ function App() {
// Handle user relay list and blocked relays when account changes
const userRelaysSub = accounts.active$.subscribe((account) => {
console.log('[relay-init] userRelaysSub fired, account:', account ? 'logged in' : 'logged out')
console.log('[relay-init] Pool has', Array.from(pool.relays.keys()).length, 'relays before applying changes')
if (account) {
// User logged in - start with hardcoded relays immediately, then stream user relay list updates
const pubkey = account.pubkey

View File

@@ -50,6 +50,14 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
return (
<Link
to={href}
state={{
previewData: {
title: post.title,
image: post.image,
summary: post.summary,
published: post.published
}
}}
className={`blog-post-card ${level ? `level-${level}` : ''}`}
style={{ textDecoration: 'none', color: 'inherit' }}
>

View File

@@ -1,7 +1,8 @@
import React, { useRef, useState } from 'react'
import React, { useRef, useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
import { faClock } from '@fortawesome/free-regular-svg-icons'
import { formatDistanceToNow } from 'date-fns'
import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
@@ -13,7 +14,7 @@ import { ViewMode } from './Bookmarks'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { BookmarkSkeleton } from './Skeletons'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate } from '../utils/bookmarkUtils'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
import { UserSettings } from '../services/settingsService'
import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService'
@@ -71,7 +72,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
const saved = localStorage.getItem('bookmarkGroupingMode')
return saved === 'flat' ? 'flat' : 'grouped'
return saved === 'grouped' ? 'grouped' : 'flat'
})
const activeAccount = Hooks.useActiveAccount()
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
@@ -120,6 +121,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
localStorage.setItem('bookmarkGroupingMode', newMode)
}
const getFilterTitle = (filter: BookmarkFilterType): string => {
const titles: Record<BookmarkFilterType, string> = {
'all': 'All Bookmarks',
'article': 'Bookmarked Reads',
'external': 'Bookmarked Links',
'video': 'Bookmarked Videos',
'note': 'Bookmarked Notes',
'web': 'Web Bookmarks'
}
return titles[filter]
}
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
if (!activeAccount || !relayPool) {
throw new Error('Please login to create bookmarks')
@@ -140,39 +153,58 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
isDisabled: !onRefresh
})
// Merge and flatten all individual bookmarks from all lists
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
// Merge and flatten all individual bookmarks from all lists - memoized to ensure consistent sorting
const sections = useMemo(() => {
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
// Separate bookmarks with setName (kind 30003) from regular bookmarks
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
const bookmarkSets = getBookmarkSets(filteredBookmarks)
// Group non-set bookmarks by source or flatten based on mode
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
const sectionsArray: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: getFilterTitle(selectedFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
: [
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
]
// Add bookmark sets as additional sections (only in grouped mode)
if (groupingMode === 'grouped') {
bookmarkSets.forEach(set => {
sectionsArray.push({
key: `set-${set.name}`,
title: set.title || set.name,
items: set.bookmarks
})
})
}
return sectionsArray
}, [bookmarks, selectedFilter, groupingMode, settings?.hideBookmarksWithoutCreationDate])
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
// Get all filtered bookmarks for empty state checks
const allIndividualBookmarks = useMemo(() =>
bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b)),
[bookmarks, settings?.hideBookmarksWithoutCreationDate]
)
// Separate bookmarks with setName (kind 30003) from regular bookmarks
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
const bookmarkSets = getBookmarkSets(filteredBookmarks)
// Group non-set bookmarks by source or flatten based on mode
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: `All Bookmarks (${bookmarksWithoutSet.length})`, items: bookmarksWithoutSet }]
: [
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
]
// Add bookmark sets as additional sections
bookmarkSets.forEach(set => {
sections.push({
key: `set-${set.name}`,
title: set.title || set.name,
items: set.bookmarks
})
})
const filteredBookmarks = useMemo(() =>
filterBookmarksByType(allIndividualBookmarks, selectedFilter),
[allIndividualBookmarks, selectedFilter]
)
if (isCollapsed) {
// Check if the selected URL is in bookmarks
@@ -286,7 +318,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
{activeAccount && (
<div className="view-mode-right">
<IconButton
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
onClick={toggleGroupingMode}
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}

View File

@@ -112,10 +112,10 @@ export const CardView: React.FC<CardViewProps> = ({
title="Open event in search"
onClick={(e) => e.stopPropagation()}
>
{formatDate(bookmark.created_at)}
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
</a>
) : (
<span className="bookmark-date">{formatDate(bookmark.created_at)}</span>
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
)}
</div>

View File

@@ -73,7 +73,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
<code>{bookmark.id.slice(0, 12)}...</code>
</div>
)}
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
{/* CTA removed */}
</div>

View File

@@ -144,7 +144,7 @@ export const LargeView: React.FC<LargeViewProps> = ({
className="bookmark-date-link"
onClick={(e) => e.stopPropagation()}
>
{formatDate(bookmark.created_at)}
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
</a>
)}

View File

@@ -64,7 +64,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
// Extract tab from me routes
const meTab = location.pathname === '/me' ? 'highlights' :
location.pathname === '/me/highlights' ? 'highlights' :
location.pathname === '/me/reading-list' ? 'reading-list' :
location.pathname === '/me/bookmarks' ? 'bookmarks' :
location.pathname.startsWith('/me/reads') ? 'reads' :
location.pathname.startsWith('/me/links') ? 'links' :
location.pathname === '/me/writings' ? 'writings' : 'highlights'
@@ -230,6 +230,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
useArticleLoader({
naddr,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,

View File

@@ -43,8 +43,8 @@ import { EventFactory } from 'applesauce-factory'
import { Hooks } from 'applesauce-react'
import {
generateArticleIdentifier,
loadReadingPosition,
saveReadingPosition
saveReadingPosition,
startReadingPositionStream
} from '../services/readingPositionService'
import TTSControls from './TTSControls'
@@ -76,6 +76,7 @@ interface ContentPanelProps {
// For reading progress indicator positioning
isSidebarCollapsed?: boolean
isHighlightsCollapsed?: boolean
onOpenHighlights?: () => void
}
const ContentPanel: React.FC<ContentPanelProps> = ({
@@ -103,7 +104,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onTextSelection,
onClearSelection,
isSidebarCollapsed = false,
isHighlightsCollapsed = false
isHighlightsCollapsed = false,
onOpenHighlights
}) => {
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
@@ -132,6 +134,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
currentUserPubkey,
followedPubkeys
})
// Key used to force re-mount of markdown preview/render when content changes
const contentKey = useMemo(() => {
// Prefer selectedUrl as a stable per-article key; fallback to title+length
return selectedUrl || `${title || ''}:${(markdown || html || '').length}`
}, [selectedUrl, title, markdown, html])
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
onHighlightClick,
@@ -207,7 +214,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
useEffect(() => {
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
// Load saved reading position when article loads
// Load saved reading position when article loads (non-blocking, EOSE-driven)
useEffect(() => {
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
return
@@ -216,15 +223,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
return
}
const loadPosition = async () => {
try {
const savedPosition = await loadReadingPosition(
relayPool,
eventStore,
activeAccount.pubkey,
articleIdentifier
)
const stop = startReadingPositionStream(
relayPool,
eventStore,
activeAccount.pubkey,
articleIdentifier,
(savedPosition) => {
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
// Wait for content to be fully rendered before scrolling
setTimeout(() => {
@@ -237,19 +241,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
behavior: 'smooth'
})
}, 500) // Give content time to render
} else if (savedPosition) {
if (savedPosition.position === 1) {
// Article was completed, start from top
} else {
// Position was too early, skip restore
}
}
} catch (error) {
console.error('❌ [ContentPanel] Failed to load reading position:', error)
}
}
)
loadPosition()
return () => stop()
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
// Save position before unmounting or changing article
@@ -577,7 +573,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const handleSearchExternalUrl = () => {
if (selectedUrl) {
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
// If it's a nostr event sentinel, open the event directly on ants.sh
if (selectedUrl.startsWith('nostr-event:')) {
const eventId = selectedUrl.replace('nostr-event:', '')
window.open(`https://ants.sh/e/${eventId}`, '_blank', 'noopener,noreferrer')
} else {
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
}
}
setShowExternalMenu(false)
}
@@ -754,7 +756,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
{/* Hidden markdown preview to convert markdown to HTML */}
{markdown && (
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
<div ref={markdownPreviewRef} key={`preview:${contentKey}`} style={{ display: 'none' }}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypePrism]}
@@ -783,6 +785,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
settings={settings}
highlights={relevantHighlights}
highlightVisibility={highlightVisibility}
onHighlightCountClick={onOpenHighlights}
/>
{isTextContent && articleText && (
<div style={{ padding: '0 0.75rem 0.5rem 0.75rem' }}>
@@ -874,6 +877,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
{markdown ? (
renderedMarkdownHtml && finalHtml ? (
<VideoEmbedProcessor
key={`content:${contentKey}`}
ref={contentRef}
html={finalHtml}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
@@ -890,6 +894,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
)
) : (
<VideoEmbedProcessor
key={`content:${contentKey}`}
ref={contentRef}
html={finalHtml || html || ''}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
@@ -927,13 +932,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<FontAwesomeIcon icon={faCopy} />
<span>Copy URL</span>
</button>
<button
className="article-menu-item"
onClick={handleOpenExternalUrl}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Original</span>
</button>
{/* Only show "Open Original" for actual external URLs, not nostr events */}
{!selectedUrl?.startsWith('nostr-event:') && (
<button
className="article-menu-item"
onClick={handleOpenExternalUrl}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Original</span>
</button>
)}
<button
className="article-menu-item"
onClick={handleSearchExternalUrl}

View File

@@ -781,9 +781,16 @@ const Debug: React.FC<DebugProps> = ({
}
})
// Load deduplicated results via controller
// Load deduplicated results via controller (includes articles and external URLs)
const unsubProgress = readingProgressController.onProgress((progressMap) => {
setDeduplicatedProgressMap(new Map(progressMap))
// Regression guard: ensure keys include both naddr and raw URL forms when present
try {
const keys = Array.from(progressMap.keys())
const sample = keys.slice(0, 5).join(', ')
DebugBus.info('debug', `Progress keys sample: ${sample}`)
} catch { /* ignore */ }
})
// Run both in parallel

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
import { faPersonHiking, faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { Hooks } from 'applesauce-react'
@@ -523,8 +523,10 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
)
}
return filteredBlogPosts.length === 0 ? (
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))}
</div>
) : (
<div className="explore-grid">
@@ -584,7 +586,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/>
<div className="explore-header">
<h1>
<FontAwesomeIcon icon={faNewspaper} />
<FontAwesomeIcon icon={faPersonHiking} />
Explore
</h1>
@@ -656,7 +658,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
</div>
</div>
<div key={activeTab}>
<div>
{renderTabContent()}
</div>
</div>

View File

@@ -37,6 +37,7 @@ interface HighlightsPanelProps {
relayPool?: RelayPool | null
eventStore?: IEventStore | null
settings?: UserSettings
isMobile?: boolean
}
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
@@ -56,7 +57,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
followedPubkeys = new Set(),
relayPool,
eventStore,
settings
settings,
isMobile = false
}) => {
const [showHighlights, setShowHighlights] = useState(true)
const [localHighlights, setLocalHighlights] = useState(highlights)
@@ -125,6 +127,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onRefresh={onRefresh}
onToggleCollapse={onToggleCollapse}
onHighlightVisibilityChange={onHighlightVisibilityChange}
isMobile={isMobile}
/>
{loading && filteredHighlights.length === 0 ? (

View File

@@ -13,6 +13,7 @@ interface HighlightsPanelHeaderProps {
onRefresh?: () => void
onToggleCollapse: () => void
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
isMobile?: boolean
}
const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
@@ -24,7 +25,8 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
onToggleHighlights,
onRefresh,
onToggleCollapse,
onHighlightVisibilityChange
onHighlightVisibilityChange,
isMobile = false
}) => {
return (
<div className="highlights-header">
@@ -101,14 +103,16 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
/>
)}
</div>
<IconButton
icon={faChevronRight}
onClick={onToggleCollapse}
title="Collapse highlights panel"
ariaLabel="Collapse highlights panel"
variant="ghost"
style={{ transform: 'rotate(180deg)' }}
/>
{!isMobile && (
<IconButton
icon={faChevronRight}
onClick={onToggleCollapse}
title="Collapse highlights panel"
ariaLabel="Collapse highlights panel"
variant="ghost"
style={{ transform: 'rotate(180deg)' }}
/>
)}
</div>
</div>
)

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
import { faClock } from '@fortawesome/free-regular-svg-icons'
import { Hooks } from 'applesauce-react'
import { IEventStore } from 'applesauce-core'
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
@@ -23,7 +24,7 @@ import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { groupIndividualBookmarks, hasContent, hasCreationDate } from '../utils/bookmarkUtils'
import { groupIndividualBookmarks, hasContent, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
@@ -42,7 +43,7 @@ interface MeProps {
settings: UserSettings
}
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
type TabType = 'highlights' | 'bookmarks' | 'reads' | 'links' | 'writings'
// Valid reading progress filters
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted', 'archive']
@@ -229,9 +230,9 @@ const Me: React.FC<MeProps> = ({
if (!viewingPubkey || !activeAccount) return
setLoadedTabs(prev => {
const hasBeenLoaded = prev.has('reading-list')
const hasBeenLoaded = prev.has('bookmarks')
if (!hasBeenLoaded) setLoading(true)
return new Set(prev).add('reading-list')
return new Set(prev).add('bookmarks')
})
// Always turn off loading after a tick
@@ -334,7 +335,7 @@ const Me: React.FC<MeProps> = ({
case 'writings':
loadWritingsTab()
break
case 'reading-list':
case 'bookmarks':
loadReadingListTab()
break
case 'reads':
@@ -418,7 +419,7 @@ const Me: React.FC<MeProps> = ({
const mockEvent = {
id: item.id,
pubkey: item.author || '',
created_at: item.readingTimestamp || Math.floor(Date.now() / 1000),
created_at: item.readingTimestamp || 0,
kind: 1,
tags: [] as string[][],
content: item.title || item.url || 'Untitled',
@@ -565,9 +566,21 @@ const Me: React.FC<MeProps> = ({
? buildArchiveOnly(linksWithProgress, { kind: 'external' })
: []
const getFilterTitle = (filter: BookmarkFilterType): string => {
const titles: Record<BookmarkFilterType, string> = {
'all': 'All Bookmarks',
'article': 'Bookmarked Reads',
'external': 'Bookmarked Links',
'video': 'Bookmarked Videos',
'note': 'Bookmarked Notes',
'web': 'Web Bookmarks'
}
return titles[filter]
}
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
? [{ key: 'all', title: getFilterTitle(bookmarkFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
: [
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
@@ -609,7 +622,7 @@ const Me: React.FC<MeProps> = ({
</div>
)
case 'reading-list':
case 'bookmarks':
if (showSkeletons) {
return (
<div className="bookmarks-list">
@@ -664,7 +677,7 @@ const Me: React.FC<MeProps> = ({
borderTop: '1px solid var(--border-color)'
}}>
<IconButton
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
onClick={toggleGroupingMode}
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
@@ -860,9 +873,9 @@ const Me: React.FC<MeProps> = ({
<span className="tab-label">Highlights</span>
</button>
<button
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
data-tab="reading-list"
onClick={() => navigate('/me/reading-list')}
className={`me-tab ${activeTab === 'bookmarks' ? 'active' : ''}`}
data-tab="bookmarks"
onClick={() => navigate('/me/bookmarks')}
>
<FontAwesomeIcon icon={faBookmark} />
<span className="tab-label">Bookmarks</span>

View File

@@ -20,6 +20,7 @@ interface ReaderHeaderProps {
settings?: UserSettings
highlights?: Highlight[]
highlightVisibility?: HighlightVisibility
onHighlightCountClick?: () => void
}
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
@@ -32,7 +33,8 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
highlightCount,
settings,
highlights = [],
highlightVisibility = { nostrverse: true, friends: true, mine: true }
highlightVisibility = { nostrverse: true, friends: true, mine: true },
onHighlightCountClick
}) => {
const cachedImage = useImageCache(image)
const { textColor } = useAdaptiveTextColor(cachedImage)
@@ -107,8 +109,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
)}
{hasHighlights && (
<div
className="highlight-indicator"
className="highlight-indicator clickable"
style={getHighlightIndicatorStyles(true)}
onClick={onHighlightCountClick}
title="Open highlights sidebar"
>
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
@@ -152,8 +156,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
)}
{hasHighlights && (
<div
className="highlight-indicator"
className="highlight-indicator clickable"
style={getHighlightIndicatorStyles(false)}
onClick={onHighlightCountClick}
title="Open highlights sidebar"
>
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>

View File

@@ -20,7 +20,7 @@ export default function RouteDebug() {
// Unexpected during deep-link refresh tests
console.warn('[RouteDebug] unexpected root redirect', info)
} else {
console.debug('[RouteDebug]', info)
// silent
}
}, [location, matchArticle])

View File

@@ -1,7 +1,7 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faPersonHiking } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
@@ -36,70 +36,61 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
return (
<>
<div className="sidebar-header-bar">
{isMobile ? (
<IconButton
icon={faTimes}
onClick={onToggleCollapse}
title="Close sidebar"
ariaLabel="Close sidebar"
variant="ghost"
className="mobile-close-btn"
/>
) : (
<button
onClick={onToggleCollapse}
className="toggle-sidebar-btn"
title="Collapse bookmarks sidebar"
aria-label="Collapse bookmarks sidebar"
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
)}
<div className="sidebar-header-right">
{activeAccount && (
<div
className="profile-avatar"
<button
className="profile-avatar-button"
title={getUserDisplayName()}
onClick={() => navigate('/me')}
style={{ cursor: 'pointer' }}
aria-label={`Profile: ${getUserDisplayName()}`}
>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
)}
</div>
</button>
)}
<IconButton
icon={faHome}
onClick={() => navigate('/')}
title="Home"
ariaLabel="Home"
variant="ghost"
/>
<IconButton
icon={faNewspaper}
onClick={() => navigate('/explore')}
title="Explore"
ariaLabel="Explore"
variant="ghost"
/>
<IconButton
icon={faGear}
onClick={onOpenSettings}
title="Settings"
ariaLabel="Settings"
variant="ghost"
/>
{activeAccount && (
<div className="sidebar-header-right">
<IconButton
icon={faRightFromBracket}
onClick={onLogout}
title="Logout"
ariaLabel="Logout"
icon={faHome}
onClick={() => navigate('/')}
title="Home"
ariaLabel="Home"
variant="ghost"
/>
)}
<IconButton
icon={faGear}
onClick={onOpenSettings}
title="Settings"
ariaLabel="Settings"
variant="ghost"
/>
<IconButton
icon={faPersonHiking}
onClick={() => navigate('/explore')}
title="Explore"
ariaLabel="Explore"
variant="ghost"
/>
{activeAccount && (
<IconButton
icon={faRightFromBracket}
onClick={onLogout}
title="Logout"
ariaLabel="Logout"
variant="ghost"
/>
)}
{!isMobile && (
<button
onClick={onToggleCollapse}
className="toggle-sidebar-btn"
title="Collapse bookmarks sidebar"
aria-label="Collapse bookmarks sidebar"
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
)}
</div>
</div>
</>

View File

@@ -60,7 +60,7 @@ const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }
const lang = detect(text)
if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2)
} catch (err) {
console.debug('[tts][detect] failed', err)
// ignore detection errors
}
}
if (!langOverride && resolvedSystemLang) {
@@ -78,7 +78,6 @@ const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }
const currentIndex = SPEED_OPTIONS.indexOf(rate)
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
const next = SPEED_OPTIONS[nextIndex]
console.debug('[tts][ui] cycle speed', { from: rate, to: next, speaking, paused })
setRate(next)
}

View File

@@ -387,6 +387,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
currentArticle={props.currentArticle}
isSidebarCollapsed={props.isCollapsed}
isHighlightsCollapsed={props.isHighlightsCollapsed}
onOpenHighlights={() => {
if (props.isHighlightsCollapsed) {
props.onToggleHighlightsPanel()
}
}}
/>
)}
</div>
@@ -413,6 +418,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
relayPool={props.relayPool}
eventStore={props.eventStore}
settings={props.settings}
isMobile={isMobile}
/>
</div>
</div>

View File

@@ -1,5 +1,11 @@
import { useEffect, useRef, Dispatch, SetStateAction } from 'react'
import { useLocation } from 'react-router-dom'
import { RelayPool } from 'applesauce-relay'
import type { IEventStore } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { Helpers } from 'applesauce-core'
import { queryEvents } from '../services/dataFetch'
import { fetchArticleByNaddr } from '../services/articleService'
import { fetchHighlightsForArticle } from '../services/highlightService'
import { ReadableContent } from '../services/readerService'
@@ -7,9 +13,17 @@ import { Highlight } from '../types/highlights'
import { NostrEvent } from 'nostr-tools'
import { UserSettings } from '../services/settingsService'
interface PreviewData {
title: string
image?: string
summary?: string
published?: number
}
interface UseArticleLoaderProps {
naddr: string | undefined
relayPool: RelayPool | null
eventStore?: IEventStore | null
setSelectedUrl: (url: string) => void
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
@@ -25,6 +39,7 @@ interface UseArticleLoaderProps {
export function useArticleLoader({
naddr,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,
@@ -36,7 +51,18 @@ export function useArticleLoader({
setCurrentArticle,
settings
}: UseArticleLoaderProps) {
const location = useLocation()
const mountedRef = useRef(true)
// Hold latest settings without retriggering effect
const settingsRef = useRef<UserSettings | undefined>(settings)
useEffect(() => {
settingsRef.current = settings
}, [settings])
// Track in-flight request to prevent stale updates from previous naddr
const currentRequestIdRef = useRef(0)
// Extract preview data from navigation state (from blog post cards)
const previewData = (location.state as { previewData?: PreviewData })?.previewData
useEffect(() => {
mountedRef.current = true
@@ -44,67 +70,198 @@ export function useArticleLoader({
if (!relayPool || !naddr) return
const loadArticle = async () => {
const requestId = ++currentRequestIdRef.current
if (!mountedRef.current) return
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
try {
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
if (!mountedRef.current) return
// If we have preview data from navigation, show it immediately (no skeleton!)
if (previewData) {
setReaderContent({
title: article.title,
markdown: article.markdown,
image: article.image,
summary: article.summary,
published: article.published,
title: previewData.title,
markdown: '', // Will be loaded from store or relay
image: previewData.image,
summary: previewData.summary,
published: previewData.published,
url: `nostr:${naddr}`
})
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(article.event.id)
setCurrentArticle?.(article.event)
setReaderLoading(false)
// Fetch highlights asynchronously without blocking article display
setReaderLoading(false) // Turn off loading immediately - we have the preview!
} else {
setReaderLoading(true)
setReaderContent(undefined)
}
try {
// Decode naddr to filter
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
throw new Error('Invalid naddr format')
}
const pointer = decoded.data as AddressPointer
const filter = {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier]
}
let firstEmitted = false
let latestEvent: NostrEvent | null = null
// Check eventStore first for instant load (from bookmark cards, explore, etc.)
if (eventStore) {
try {
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
const storedEvent = eventStore.getEvent?.(coordinate)
if (storedEvent) {
latestEvent = storedEvent as NostrEvent
firstEmitted = true
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
const image = Helpers.getArticleImage(storedEvent)
const summary = Helpers.getArticleSummary(storedEvent)
const published = Helpers.getArticlePublished(storedEvent)
setReaderContent({
title,
markdown: storedEvent.content,
image,
summary,
published,
url: `nostr:${naddr}`
})
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(storedEvent.id)
setCurrentArticle?.(storedEvent)
setReaderLoading(false)
}
} catch (err) {
// Ignore store errors, fall through to relay query
}
}
// Stream local-first via queryEvents; rely on EOSE (no timeouts)
const events = await queryEvents(relayPool, filter, {
onEvent: (evt) => {
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
// Store in event store for future local reads
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eventStore?.add?.(evt as unknown as any)
} catch {
// Silently ignore store errors
}
// Keep latest by created_at
if (!latestEvent || evt.created_at > latestEvent.created_at) {
latestEvent = evt
}
// Emit immediately on first event
if (!firstEmitted) {
firstEmitted = true
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
const image = Helpers.getArticleImage(evt)
const summary = Helpers.getArticleSummary(evt)
const published = Helpers.getArticlePublished(evt)
setReaderContent({
title,
markdown: evt.content,
image,
summary,
published,
url: `nostr:${naddr}`
})
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${evt.kind}:${evt.pubkey}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(evt.id)
setCurrentArticle?.(evt)
setReaderLoading(false)
}
}
})
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
// Finalize with newest version if it's newer than what we first rendered
const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent
if (finalEvent) {
const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article'
const image = Helpers.getArticleImage(finalEvent)
const summary = Helpers.getArticleSummary(finalEvent)
const published = Helpers.getArticlePublished(finalEvent)
setReaderContent({
title,
markdown: finalEvent.content,
image,
summary,
published,
url: `nostr:${naddr}`
})
const dTag = finalEvent.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${finalEvent.kind}:${finalEvent.pubkey}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(finalEvent.id)
setCurrentArticle?.(finalEvent)
} else {
// As a last resort, fall back to the legacy helper (which includes cache)
const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current)
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
setReaderContent({
title: article.title,
markdown: article.markdown,
image: article.image,
summary: article.summary,
published: article.published,
url: `nostr:${naddr}`
})
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(article.event.id)
setCurrentArticle?.(article.event)
}
// Fetch highlights after content is shown
try {
if (!mountedRef.current) return
setHighlightsLoading(true)
setHighlights([])
await fetchHighlightsForArticle(
relayPool,
articleCoordinate,
article.event.id,
(highlight) => {
if (!mountedRef.current) return
setHighlights((prev: Highlight[]) => {
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
const next = [highlight, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
},
settings
)
const le = latestEvent as NostrEvent | null
const dTag = le ? (le.tags.find((t: string[]) => t[0] === 'd')?.[1] || '') : ''
const coord = le && dTag ? `${le.kind}:${le.pubkey}:${dTag}` : undefined
const eventId = le ? le.id : undefined
if (coord && eventId) {
await fetchHighlightsForArticle(
relayPool,
coord,
eventId,
(highlight) => {
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
setHighlights((prev: Highlight[]) => {
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
const next = [highlight, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
},
settingsRef.current
)
}
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
if (mountedRef.current) {
if (mountedRef.current && currentRequestIdRef.current === requestId) {
setHighlightsLoading(false)
}
}
} catch (err) {
console.error('Failed to load article:', err)
if (mountedRef.current) {
if (mountedRef.current && currentRequestIdRef.current === requestId) {
setReaderContent({
title: 'Error Loading Article',
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
@@ -123,7 +280,8 @@ export function useArticleLoader({
}, [
naddr,
relayPool,
settings,
eventStore,
previewData,
setSelectedUrl,
setReaderContent,
setReaderLoading,

View File

@@ -49,6 +49,8 @@ export function useExternalUrlLoader({
setCurrentArticleEventId
}: UseExternalUrlLoaderProps) {
const mountedRef = useRef(true)
// Track in-flight request to prevent stale updates when switching quickly
const currentRequestIdRef = useRef(0)
// Load cached URL-specific highlights from event store
const urlFilter = useMemo(() => {
@@ -70,6 +72,7 @@ export function useExternalUrlLoader({
if (!relayPool || !url) return
const loadExternalUrl = async () => {
const requestId = ++currentRequestIdRef.current
if (!mountedRef.current) return
setReaderLoading(true)
@@ -83,6 +86,7 @@ export function useExternalUrlLoader({
const content = await fetchReadableContent(url)
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
setReaderContent(content)
setReaderLoading(false)
@@ -114,6 +118,7 @@ export function useExternalUrlLoader({
url,
(highlight) => {
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
if (seen.has(highlight.id)) return
seen.add(highlight.id)
@@ -131,13 +136,13 @@ export function useExternalUrlLoader({
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
if (mountedRef.current) {
if (mountedRef.current && currentRequestIdRef.current === requestId) {
setHighlightsLoading(false)
}
}
} catch (err) {
console.error('Failed to load external URL:', err)
if (mountedRef.current) {
if (mountedRef.current && currentRequestIdRef.current === requestId) {
const filename = getFilenameFromUrl(url)
setReaderContent({
title: filename,

View File

@@ -20,9 +20,11 @@ export const useMarkdownToHTML = (
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
useEffect(() => {
// Always clear previous render immediately to avoid showing stale content while processing
setRenderedHtml('')
setProcessedMarkdown('')
if (!markdown) {
setRenderedHtml('')
setProcessedMarkdown('')
return
}

View File

@@ -3,7 +3,7 @@ import { IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { EventFactory } from 'applesauce-factory'
import { AccountManager } from 'applesauce-accounts'
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
import { UserSettings, saveSettings, watchSettings, startSettingsStream } from '../services/settingsService'
import { loadFont, getFontFamily } from '../utils/fontLoader'
import { applyTheme } from '../utils/theme'
import { RELAYS } from '../config/relays'
@@ -20,26 +20,24 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
const [toastMessage, setToastMessage] = useState<string | null>(null)
const [toastType, setToastType] = useState<'success' | 'error'>('success')
// Load settings and set up subscription
// Load settings and set up streaming subscription (non-blocking, EOSE-driven)
useEffect(() => {
if (!relayPool || !pubkey || !eventStore) return
const loadAndWatch = async () => {
try {
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
} catch (err) {
console.error('Failed to load settings:', err)
}
}
loadAndWatch()
// Start settings stream: seed from store, stream updates to store in background
const stopNetwork = startSettingsStream(relayPool, eventStore, pubkey, RELAYS, (loadedSettings) => {
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
})
// Also watch store reactively for any further updates
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
})
return () => subscription.unsubscribe()
return () => {
subscription.unsubscribe()
stopNetwork()
}
}, [relayPool, pubkey, eventStore])
// Apply settings to document

View File

@@ -59,7 +59,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
// Update rate when defaultRate option changes
useEffect(() => {
if (options.defaultRate !== undefined) {
console.debug('[tts] defaultRate changed ->', options.defaultRate)
setRate(options.defaultRate)
}
}, [options.defaultRate])
@@ -73,7 +72,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
if (!voice && v.length) {
const byLang = v.find(x => x.lang?.toLowerCase().startsWith(defaultLang.toLowerCase()))
setVoice(byLang || v[0] || null)
console.debug('[tts] voices loaded', { total: v.length, picked: (byLang || v[0] || null)?.lang })
}
}
load()
@@ -107,44 +105,37 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
u.onstart = () => {
if (utteranceRef.current !== self) return
console.debug('[tts] onstart')
setSpeaking(true)
setPaused(false)
}
u.onpause = () => {
if (utteranceRef.current !== self) return
console.debug('[tts] onpause')
setPaused(true)
}
u.onresume = () => {
if (utteranceRef.current !== self) return
console.debug('[tts] onresume')
setPaused(false)
}
u.onend = () => {
if (utteranceRef.current !== self) return
console.debug('[tts] onend')
// Continue with next chunk if available
const hasMore = chunkIndexRef.current < (chunksRef.current.length - 1)
if (hasMore) {
chunkIndexRef.current += 1
globalOffsetRef.current += self.text.length
const next = chunksRef.current[chunkIndexRef.current] || ''
const nextUtterance = createUtterance(next, langRef.current)
chunkIndexRef.current++
charIndexRef.current += self.text.length
const nextChunk = chunksRef.current[chunkIndexRef.current]
const nextUtterance = createUtterance(nextChunk, langRef.current)
utteranceRef.current = nextUtterance
synth!.speak(nextUtterance)
return
} else {
setSpeaking(false)
setPaused(false)
}
setSpeaking(false)
setPaused(false)
utteranceRef.current = null
}
u.onerror = () => {
if (utteranceRef.current !== self) return
console.debug('[tts] onerror')
setSpeaking(false)
setPaused(false)
utteranceRef.current = null
}
u.onboundary = (ev: SpeechSynthesisEvent) => {
if (utteranceRef.current !== self) return
@@ -197,7 +188,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
const stop = useCallback(() => {
if (!supported) return
console.debug('[tts] stop')
synth!.cancel()
setSpeaking(false)
setPaused(false)
@@ -211,18 +201,16 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
const speak = useCallback((text: string, langOverride?: string) => {
if (!supported || !text?.trim()) return
console.debug('[tts] speak', { len: text.length, rate })
synth!.cancel()
spokenTextRef.current = text
charIndexRef.current = 0
langRef.current = langOverride
startSpeakingChunks(text)
}, [supported, synth, startSpeakingChunks, rate])
}, [supported, synth, startSpeakingChunks])
const pause = useCallback(() => {
if (!supported) return
if (synth!.speaking && !synth!.paused) {
console.debug('[tts] pause')
synth!.pause()
setPaused(true)
}
@@ -231,7 +219,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
const resume = useCallback(() => {
if (!supported) return
if (synth!.speaking && synth!.paused) {
console.debug('[tts] resume')
synth!.resume()
setPaused(false)
}
@@ -242,14 +229,11 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
if (!supported) return
if (!utteranceRef.current) return
console.debug('[tts] rate change', { rate, speaking: synth!.speaking, paused: synth!.paused, charIndex: charIndexRef.current })
if (synth!.speaking && !synth!.paused) {
const fullText = spokenTextRef.current
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length))
const remainingText = fullText.slice(startIndex)
console.debug('[tts] restart at new rate', { startIndex, remainingLen: remainingText.length })
synth!.cancel()
// restart chunked from current global index
spokenTextRef.current = remainingText
@@ -273,7 +257,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
const fullText = spokenTextRef.current
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length - 1))
const remainingText = fullText.slice(startIndex)
console.debug('[tts] updateRate -> restart', { newRate, startIndex, remainingLen: remainingText.length })
synth!.cancel()
const u = createUtterance(remainingText)
// ensure the new rate is applied immediately on the new utterance
@@ -281,7 +264,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
utteranceRef.current = u
synth!.speak(u)
} else if (utteranceRef.current) {
console.debug('[tts] updateRate -> set on utterance', { newRate })
utteranceRef.current.rate = newRate
}
}, [supported, synth, createUtterance])

View File

@@ -5,13 +5,12 @@ import './styles/tailwind.css'
import './index.css'
import 'react-loading-skeleton/dist/skeleton.css'
// Register Service Worker for PWA functionality
if ('serviceWorker' in navigator) {
// Register Service Worker for PWA functionality (production only)
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js', { type: 'module' })
.register('/sw.js')
.then(registration => {
// Check for updates periodically
setInterval(() => {
registration.update()
@@ -24,8 +23,6 @@ if ('serviceWorker' in navigator) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New service worker available
// Optionally show a toast notification
const updateAvailable = new CustomEvent('sw-update-available')
window.dispatchEvent(updateAvailable)
}

View File

@@ -97,10 +97,10 @@ export async function fetchArticleByNaddr(
const pointer = decoded.data as AddressPointer
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local)
const baseRelays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: RELAYS
// Define relays to query - use union of relay hints from naddr and configured relays
// This avoids failures when naddr contains stale/unreachable relay hints
const hintedRelays = (pointer.relays && pointer.relays.length > 0) ? pointer.relays : []
const baseRelays = Array.from(new Set<string>([...hintedRelays, ...RELAYS]))
const orderedRelays = prioritizeLocalRelays(baseRelays)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
@@ -114,7 +114,28 @@ export async function fetchArticleByNaddr(
// Parallel local+remote, stream immediate, collect up to first from each
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000)
const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()))
const events = collected as NostrEvent[]
let events = collected as NostrEvent[]
// Fallback: if nothing found, try a second round against a set of reliable public relays
if (events.length === 0) {
const reliableRelays = Array.from(new Set<string>([
'wss://relay.nostr.band',
'wss://relay.primal.net',
'wss://relay.damus.io',
'wss://nos.lol',
...remoteRelays // keep any configured remote relays
]))
const { remote$: fallback$ } = createParallelReqStreams(
relayPool,
[], // no local
reliableRelays,
filter,
1500,
12000
)
const fallbackCollected = await lastValueFrom(fallback$.pipe(take(1), rxToArray()))
events = fallbackCollected as NostrEvent[]
}
if (events.length === 0) {
throw new Error('Article not found')

View File

@@ -1,13 +1,8 @@
import { RelayPool } from 'applesauce-relay'
import { Helpers, EventStore } from 'applesauce-core'
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
import { NostrEvent } from 'nostr-tools'
import { EventPointer } from 'nostr-tools/nip19'
import { from } from 'rxjs'
import { mergeMap } from 'rxjs/operators'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { collectBookmarksFromEvents } from './bookmarkProcessing'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import {
@@ -65,12 +60,8 @@ class BookmarkController {
}> = new Map()
private isLoading = false
private hydrationGeneration = 0
// Event loaders for efficient batching
private eventStore = new EventStore()
private eventLoader: ReturnType<typeof createEventLoader> | null = null
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
private externalEventStore: EventStore | null = null
private relayPool: RelayPool | null = null
onRawEvent(cb: RawEventCallback): () => void {
this.rawEventListeners.push(cb)
@@ -119,15 +110,15 @@ class BookmarkController {
}
/**
* Hydrate events by IDs using EventLoader (auto-batching, streaming)
* Hydrate events by IDs using queryEvents (local-first, streaming)
*/
private hydrateByIds(
private async hydrateByIds(
ids: string[],
idToEvent: Map<string, NostrEvent>,
onProgress: () => void,
generation: number
): void {
if (!this.eventLoader) {
): Promise<void> {
if (!this.relayPool) {
return
}
@@ -137,86 +128,146 @@ class BookmarkController {
return
}
// Convert IDs to EventPointers
const pointers: EventPointer[] = unique.map(id => ({ id }))
// Use mergeMap with concurrency limit instead of merge to properly batch requests
// This prevents overwhelming relays with 96+ simultaneous requests
from(pointers).pipe(
mergeMap(pointer => this.eventLoader!(pointer), 5)
).subscribe({
next: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
idToEvent.set(event.id, event)
// Also index by coordinate for addressable events
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
idToEvent.set(coordinate, event)
// Fetch events using local-first queryEvents
await queryEvents(
this.relayPool,
{ ids: unique },
{
onEvent: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
idToEvent.set(event.id, event)
// Also index by coordinate for addressable events
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
idToEvent.set(coordinate, event)
}
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
}
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
},
error: () => {
// Silent error - EventLoader handles retries
}
})
)
}
/**
* Hydrate addressable events by coordinates using AddressLoader (auto-batching, streaming)
* Hydrate addressable events by coordinates using queryEvents (local-first, streaming)
*/
private hydrateByCoordinates(
private async hydrateByCoordinates(
coords: Array<{ kind: number; pubkey: string; identifier: string }>,
idToEvent: Map<string, NostrEvent>,
onProgress: () => void,
generation: number
): void {
if (!this.addressLoader) {
): Promise<void> {
if (!this.relayPool) {
return
}
if (coords.length === 0) return
if (coords.length === 0) {
return
}
// Convert coordinates to AddressPointers
const pointers = coords.map(c => ({
kind: c.kind,
pubkey: c.pubkey,
identifier: c.identifier
}))
// Group by kind and pubkey for efficient batching
const filtersByKind = new Map<number, Map<string, string[]>>()
for (const coord of coords) {
if (!filtersByKind.has(coord.kind)) {
filtersByKind.set(coord.kind, new Map())
}
const byPubkey = filtersByKind.get(coord.kind)!
if (!byPubkey.has(coord.pubkey)) {
byPubkey.set(coord.pubkey, [])
}
byPubkey.get(coord.pubkey)!.push(coord.identifier || '')
}
// Use mergeMap with concurrency limit instead of merge to properly batch requests
from(pointers).pipe(
mergeMap(pointer => this.addressLoader!(pointer), 5)
).subscribe({
next: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
idToEvent.set(coordinate, event)
idToEvent.set(event.id, event)
// Kick off all queries in parallel (fire-and-forget)
const promises: Promise<void>[] = []
for (const [kind, byPubkey] of filtersByKind) {
for (const [pubkey, identifiers] of byPubkey) {
// Separate empty and non-empty identifiers
const nonEmptyIdentifiers = identifiers.filter(id => id && id.length > 0)
const hasEmptyIdentifier = identifiers.some(id => !id || id.length === 0)
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
// Fetch events with non-empty d-tags
if (nonEmptyIdentifiers.length > 0) {
promises.push(
queryEvents(
this.relayPool,
{ kinds: [kind], authors: [pubkey], '#d': nonEmptyIdentifiers },
{
onEvent: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
idToEvent.set(coordinate, event)
idToEvent.set(event.id, event)
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
}
}
).then(() => {
// Query completed successfully
}).catch(() => {
// Silent error - individual query failed
})
)
}
onProgress()
},
error: () => {
// Silent error - AddressLoader handles retries
// Fetch events with empty d-tag separately (without '#d' filter)
if (hasEmptyIdentifier) {
promises.push(
queryEvents(
this.relayPool,
{ kinds: [kind], authors: [pubkey] },
{
onEvent: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
// Only process events with empty d-tag
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
if (dTag !== '') return
const coordinate = `${event.kind}:${event.pubkey}:`
idToEvent.set(coordinate, event)
idToEvent.set(event.id, event)
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
}
}
).then(() => {
// Query completed successfully
}).catch(() => {
// Silent error - individual query failed
})
)
}
}
})
}
// Wait for all queries to complete
await Promise.all(promises)
}
private async buildAndEmitBookmarks(
@@ -279,8 +330,6 @@ class BookmarkController {
}
})
console.log(`📋 Requesting hydration for: ${noteIds.length} note IDs, ${coordinates.length} coordinates`)
// Helper to build and emit bookmarks
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
// Now hydrate the ORIGINAL items (which may have duplicates), using the deduplicated results
@@ -293,22 +342,28 @@ class BookmarkController {
const enriched = allBookmarks.map(b => ({
...b,
tags: b.tags || [],
// Prefer hydrated content; fallback to any cached event content in external store
content: b.content && b.content.length > 0
? b.content
: (this.externalEventStore?.getEvent(b.id)?.content || '')
content: b.content || this.externalEventStore?.getEvent(b.id)?.content || '', // Fallback to eventStore content
created_at: (b.created_at ?? this.externalEventStore?.getEvent(b.id)?.created_at ?? null)
}))
const sortedBookmarks = enriched
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
.map(b => ({
...b,
urlReferences: extractUrlsFromContent(b.content)
}))
.sort((a, b) => {
// Sort by display time: created_at, else listUpdatedAt. Newest first. Nulls last.
const aTs = (a.created_at ?? a.listUpdatedAt ?? -Infinity)
const bTs = (b.created_at ?? b.listUpdatedAt ?? -Infinity)
return bTs - aTs
})
const bookmark: Bookmark = {
id: `${activeAccount.pubkey}-bookmarks`,
title: `Bookmarks (${sortedBookmarks.length})`,
url: '',
content: latestContent,
created_at: newestCreatedAt || Math.floor(Date.now() / 1000),
created_at: newestCreatedAt || 0,
tags: allTags,
bookmarkCount: sortedBookmarks.length,
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
@@ -324,7 +379,7 @@ class BookmarkController {
const idToEvent: Map<string, NostrEvent> = new Map()
emitBookmarks(idToEvent)
// Now fetch events progressively in background using batched hydrators
// Now fetch events progressively in background using local-first queries
const generation = this.hydrationGeneration
const onProgress = () => emitBookmarks(idToEvent)
@@ -339,10 +394,14 @@ class BookmarkController {
}
})
// Kick off batched hydration (streaming, non-blocking)
// EventLoader and AddressLoader handle batching and streaming automatically
this.hydrateByIds(noteIds, idToEvent, onProgress, generation)
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation)
// Kick off hydration (streaming, non-blocking, local-first)
// Fire-and-forget - don't await, let it run in background
this.hydrateByIds(noteIds, idToEvent, onProgress, generation).catch(() => {
// Silent error - hydration will retry or show partial results
})
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation).catch(() => {
// Silent error - hydration will retry or show partial results
})
} catch (error) {
console.error('Failed to build bookmarks:', error)
this.bookmarksListeners.forEach(cb => cb([]))
@@ -357,7 +416,8 @@ class BookmarkController {
}): Promise<void> {
const { relayPool, activeAccount, accountManager, eventStore } = options
// Store the external event store reference for adding hydrated events
// Store references for hydration
this.relayPool = relayPool
this.externalEventStore = eventStore || null
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
@@ -369,16 +429,6 @@ class BookmarkController {
// Increment generation to cancel any in-flight hydration
this.hydrationGeneration++
// Initialize loaders for this session
this.eventLoader = createEventLoader(relayPool, {
eventStore: this.eventStore,
extraRelays: RELAYS
})
this.addressLoader = createAddressLoader(relayPool, {
eventStore: this.eventStore,
extraRelays: RELAYS
})
this.setLoading(true)
try {

View File

@@ -15,28 +15,30 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
}
const unique = Array.from(byId.values())
// Separate web bookmarks (kind:39701) from list-based bookmarks
const webBookmarks = unique.filter(e => e.kind === 39701)
const bookmarkLists = unique
.filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd'))
// Deduplicate replaceable events (kind:30003, 30001, 39701) by d-tag
const byD = new Map<string, NostrEvent>()
for (const e of unique) {
if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001) {
if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001 || e.kind === 39701) {
const d = (e.tags || []).find((t: string[]) => t[0] === 'd')?.[1] || ''
const prev = byD.get(d)
if (!prev || (e.created_at || 0) > (prev.created_at || 0)) byD.set(d, e)
}
}
const setsAndNamedLists = Array.from(byD.values())
// Separate web bookmarks from bookmark sets/lists
const allReplaceable = Array.from(byD.values())
const webBookmarks = allReplaceable.filter(e => e.kind === 39701)
const setsAndNamedLists = allReplaceable.filter(e => e.kind !== 39701)
const out: NostrEvent[] = []
if (latestBookmarkList) out.push(latestBookmarkList)
out.push(...setsAndNamedLists)
// Add web bookmarks as individual events
// Add deduplicated web bookmarks as individual events
out.push(...webBookmarks)
return out
}

View File

@@ -21,12 +21,16 @@ export interface AddressPointer {
pubkey: string
identifier: string
relays?: string[]
added_at?: number
created_at?: number
}
export interface EventPointer {
id: string
relays?: string[]
author?: string
added_at?: number
created_at?: number
}
export interface ApplesauceBookmarks {
@@ -77,14 +81,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: note.id,
content: '',
created_at: parentCreatedAt || 0,
created_at: note.created_at ?? null,
pubkey: note.author || activeAccount.pubkey,
kind: 1, // Short note kind
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: parentCreatedAt || 0
listUpdatedAt: parentCreatedAt || 0
})
})
}
@@ -97,14 +101,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: coordinate,
content: '',
created_at: parentCreatedAt || 0,
created_at: article.created_at ?? null,
pubkey: article.pubkey,
kind: article.kind, // Usually 30023 for long-form articles
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: parentCreatedAt || 0
listUpdatedAt: parentCreatedAt ?? null
})
})
}
@@ -115,14 +119,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: `hashtag-${hashtag}`,
content: `#${hashtag}`,
created_at: parentCreatedAt || 0,
created_at: 0, // Hashtags don't have their own creation time
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['t', hashtag]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: parentCreatedAt || 0
listUpdatedAt: parentCreatedAt ?? null
})
})
}
@@ -133,14 +137,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: `url-${url}`,
content: url,
created_at: parentCreatedAt || 0,
created_at: 0, // URLs don't have their own creation time
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['r', url]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: parentCreatedAt || 0
listUpdatedAt: parentCreatedAt || 0
})
})
}
@@ -149,20 +153,24 @@ export const processApplesauceBookmarks = (
}
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
return bookmarkArray
const processed = bookmarkArray
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
.map((bookmark: BookmarkData) => ({
id: bookmark.id!,
content: bookmark.content || '',
created_at: bookmark.created_at || parentCreatedAt || 0,
pubkey: activeAccount.pubkey,
kind: bookmark.kind || 30001,
tags: bookmark.tags || [],
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate,
added_at: bookmark.created_at || parentCreatedAt || 0
}))
.map((bookmark: BookmarkData) => {
return {
id: bookmark.id!,
content: bookmark.content || '',
created_at: bookmark.created_at ?? null,
pubkey: activeAccount.pubkey,
kind: bookmark.kind || 30001,
tags: bookmark.tags || [],
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate,
listUpdatedAt: parentCreatedAt ?? null
}
})
return processed
}
// Types and guards around signer/decryption APIs

View File

@@ -133,29 +133,36 @@ export async function collectBookmarksFromEvents(
// Handle web bookmarks (kind:39701) as individual bookmarks
if (evt.kind === 39701) {
// Use coordinate format for web bookmarks to enable proper deduplication
// Web bookmarks are replaceable events (kind:39701:pubkey:d-tag)
const webBookmarkId = dTag ? `${evt.kind}:${evt.pubkey}:${dTag}` : evt.id
publicItemsAll.push({
id: evt.id,
id: webBookmarkId,
content: evt.content || '',
created_at: evt.created_at || Math.floor(Date.now() / 1000),
created_at: evt.created_at ?? null,
pubkey: evt.pubkey,
kind: evt.kind,
tags: evt.tags || [],
parsedContent: undefined,
type: 'web' as const,
isPrivate: false,
added_at: evt.created_at || Math.floor(Date.now() / 1000),
sourceKind: 39701,
setName: dTag,
setTitle,
setDescription,
setImage
setImage,
listUpdatedAt: evt.created_at ?? null
})
continue
}
const pub = Helpers.getPublicBookmarks(evt)
const processedPub = processApplesauceBookmarks(pub, activeAccount, false, evt.created_at)
publicItemsAll.push(
...processApplesauceBookmarks(pub, activeAccount, false, evt.created_at).map(i => ({
...processedPub.map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,

View File

@@ -22,6 +22,9 @@ class EventManager {
// Safety timeout for event fetches (ms)
private fetchTimeoutMs = 12000
// Retry policy
private maxAttempts = 4
private baseBackoffMs = 700
/**
* Initialize the event manager with event store and relay pool
@@ -70,7 +73,7 @@ class EventManager {
// Start a new fetch request
this.pendingRequests.set(eventId, [{ resolve, reject }])
this.fetchFromRelay(eventId)
this.fetchFromRelayWithRetry(eventId, 1)
})
}
@@ -86,17 +89,14 @@ class EventManager {
requests.forEach(req => req.reject(error))
}
/**
* Actually fetch the event from relay
*/
private fetchFromRelay(eventId: string): void {
private fetchFromRelayWithRetry(eventId: string, attempt: number): void {
// If no loader yet, schedule retry
if (!this.relayPool || !this.eventLoader) {
setTimeout(() => {
if (this.eventLoader && this.pendingRequests.has(eventId)) {
this.fetchFromRelay(eventId)
if (this.pendingRequests.has(eventId)) {
this.fetchFromRelayWithRetry(eventId, attempt)
}
}, 500)
}, this.baseBackoffMs)
return
}
@@ -111,14 +111,23 @@ class EventManager {
error: (err: unknown) => {
clearTimeout(timeoutId)
const error = err instanceof Error ? err : new Error(String(err))
this.rejectPending(eventId, error)
// Retry on error until attempts exhausted
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
setTimeout(() => this.fetchFromRelayWithRetry(eventId, attempt + 1), this.baseBackoffMs * attempt)
} else {
this.rejectPending(eventId, error)
}
subscription.unsubscribe()
},
complete: () => {
// Completed without next - consider not found
// Completed without next - consider not found, but retry a few times
if (!delivered) {
clearTimeout(timeoutId)
this.rejectPending(eventId, new Error('Event not found'))
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
setTimeout(() => this.fetchFromRelayWithRetry(eventId, attempt + 1), this.baseBackoffMs * attempt)
} else {
this.rejectPending(eventId, new Error('Event not found'))
}
}
subscription.unsubscribe()
}
@@ -127,8 +136,13 @@ class EventManager {
// Safety timeout
const timeoutId = setTimeout(() => {
if (!delivered) {
this.rejectPending(eventId, new Error('Timed out fetching event'))
subscription.unsubscribe()
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
subscription.unsubscribe()
this.fetchFromRelayWithRetry(eventId, attempt + 1)
} else {
subscription.unsubscribe()
this.rejectPending(eventId, new Error('Timed out fetching event'))
}
}
}, this.fetchTimeoutMs)
}
@@ -139,7 +153,7 @@ class EventManager {
private retryAllPending(): void {
const pendingIds = Array.from(this.pendingRequests.keys())
pendingIds.forEach(eventId => {
this.fetchFromRelay(eventId)
this.fetchFromRelayWithRetry(eventId, 1)
})
}
}

View File

@@ -75,10 +75,17 @@ export function processReadingProgress(
continue
}
} else if (dTag.startsWith('url:')) {
// It's a URL with base64url encoding
const encoded = dTag.replace('url:', '')
// It's a URL. We support both raw URLs and base64url-encoded URLs.
const value = dTag.slice(4)
const looksBase64Url = /^[A-Za-z0-9_-]+$/.test(value) && (value.includes('-') || value.includes('_'))
try {
itemUrl = atob(encoded.replace(/-/g, '+').replace(/_/g, '/'))
if (looksBase64Url) {
// Decode base64url to raw URL
itemUrl = atob(value.replace(/-/g, '+').replace(/_/g, '/'))
} else {
// Treat as raw URL (already decoded)
itemUrl = value
}
itemId = itemUrl
itemType = 'external'
} catch (e) {

View File

@@ -98,11 +98,8 @@ export function generateArticleIdentifier(naddrOrUrl: string): string {
if (naddrOrUrl.startsWith('nostr:')) {
return naddrOrUrl.replace('nostr:', '')
}
// For URLs, use base64url encoding (URL-safe)
return btoa(naddrOrUrl)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
// For URLs, return the raw URL. Downstream tag generation will encode as needed.
return naddrOrUrl
}
/**
@@ -138,8 +135,53 @@ export async function saveReadingPosition(
await publishEvent(relayPool, eventStore, signed)
}
/**
* Streaming reading position loader (non-blocking, EOSE-driven)
* Seeds from local eventStore, streams relay updates to store in background
* @returns Unsubscribe function to cancel both store watch and network stream
*/
export function startReadingPositionStream(
relayPool: RelayPool,
eventStore: IEventStore,
pubkey: string,
articleIdentifier: string,
onPosition: (pos: ReadingPosition | null) => void
): () => void {
const dTag = generateDTag(articleIdentifier)
// 1) Seed from local replaceable immediately and watch for updates
const storeSub = eventStore
.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
.subscribe((event: NostrEvent | undefined) => {
if (!event) {
onPosition(null)
return
}
const parsed = getReadingProgressContent(event)
onPosition(parsed || null)
})
// 2) Stream from relays in background; pipe into store; no timeout/unsubscribe timer
const networkSub = relayPool
.subscription(RELAYS, {
kinds: [READING_PROGRESS_KIND],
authors: [pubkey],
'#d': [dTag]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
// Caller manages lifecycle
return () => {
try { storeSub.unsubscribe() } catch { /* ignore */ }
try { networkSub.unsubscribe() } catch { /* ignore */ }
}
}
/**
* Load reading position from Nostr (kind 39802)
* @deprecated Use startReadingPositionStream for non-blocking behavior
* Returns current local position immediately (or null) and starts background sync
*/
export async function loadReadingPosition(
relayPool: RelayPool,
@@ -149,101 +191,29 @@ export async function loadReadingPosition(
): Promise<ReadingPosition | null> {
const dTag = generateDTag(articleIdentifier)
// Check local event store first
let initial: ReadingPosition | null = null
try {
const localEvent = await firstValueFrom(
eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
)
if (localEvent) {
const content = getReadingProgressContent(localEvent)
if (content) {
// Fetch from relays in background to get any updates
relayPool
.subscription(RELAYS, {
kinds: [READING_PROGRESS_KIND],
authors: [pubkey],
'#d': [dTag]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
return content
}
if (content) initial = content
}
} catch (err) {
// Ignore errors and fetch from relays
} catch {
// ignore
}
// Fetch from relays
const result = await fetchFromRelays(
relayPool,
eventStore,
pubkey,
READING_PROGRESS_KIND,
dTag,
getReadingProgressContent
)
return result || null
}
// Helper function to fetch from relays with timeout
async function fetchFromRelays(
relayPool: RelayPool,
eventStore: IEventStore,
pubkey: string,
kind: number,
dTag: string,
parser: (event: NostrEvent) => ReadingPosition | undefined
): Promise<ReadingPosition | null> {
return new Promise((resolve) => {
let hasResolved = false
const timeout = setTimeout(() => {
if (!hasResolved) {
hasResolved = true
resolve(null)
}
}, 3000)
const sub = relayPool
.subscription(RELAYS, {
kinds: [kind],
authors: [pubkey],
'#d': [dTag]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
complete: async () => {
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true
try {
const event = await firstValueFrom(
eventStore.replaceable(kind, pubkey, dTag)
)
if (event) {
const content = parser(event)
resolve(content || null)
} else {
resolve(null)
}
} catch (err) {
resolve(null)
}
}
},
error: () => {
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true
resolve(null)
}
}
})
setTimeout(() => {
sub.unsubscribe()
}, 3000)
})
// Start background sync (fire-and-forget; no timeout)
relayPool
.subscription(RELAYS, {
kinds: [READING_PROGRESS_KIND],
authors: [pubkey],
'#d': [dTag]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
return initial
}

View File

@@ -276,10 +276,10 @@ class ReadingProgressController {
// Process new events
processReadingProgress(events, readsMap)
// Convert back to progress map (naddr -> progress)
// Convert back to progress map (id -> progress). Include both articles and external URLs.
const newProgressMap = new Map<string, number>()
for (const [id, item] of readsMap.entries()) {
if (item.readingProgress !== undefined && item.type === 'article') {
if (item.readingProgress !== undefined) {
newProgressMap.set(id, item.readingProgress)
}
}

View File

@@ -76,7 +76,7 @@ export async function fetchAllReads(
source: 'bookmark',
type: 'article',
readingProgress: 0,
readingTimestamp: bookmark.added_at || bookmark.created_at
readingTimestamp: bookmark.created_at ?? undefined
}
readsMap.set(coordinate, item)
if (onItem) emitItem(item)

View File

@@ -75,90 +75,82 @@ export interface UserSettings {
ttsDefaultSpeed?: number // default: 2.1
}
/**
* Streaming settings loader (non-blocking, EOSE-driven)
* Seeds from local eventStore, streams relay updates to store in background
* @returns Unsubscribe function to cancel both store watch and network stream
*/
export function startSettingsStream(
relayPool: RelayPool,
eventStore: IEventStore,
pubkey: string,
relays: string[],
onSettings: (settings: UserSettings | null) => void
): () => void {
// 1) Seed from local replaceable immediately and watch for updates
const storeSub = eventStore
.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
.subscribe((event: NostrEvent | undefined) => {
if (!event) {
onSettings(null)
return
}
const content = getAppDataContent<UserSettings>(event)
onSettings(content || null)
})
// 2) Stream from relays in background; pipe into store; no timeout/unsubscribe timer
const networkSub = relayPool
.subscription(relays, {
kinds: [APP_DATA_KIND],
authors: [pubkey],
'#d': [SETTINGS_IDENTIFIER]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
// Caller manages lifecycle
return () => {
try { storeSub.unsubscribe() } catch { /* ignore */ }
try { networkSub.unsubscribe() } catch { /* ignore */ }
}
}
/**
* @deprecated Use startSettingsStream + watchSettings for non-blocking behavior.
* Returns current local settings immediately (or null if not present) and starts background sync.
*/
export async function loadSettings(
relayPool: RelayPool,
eventStore: IEventStore,
pubkey: string,
relays: string[]
): Promise<UserSettings | null> {
// First, check if we already have settings in the local event store
let initial: UserSettings | null = null
try {
const localEvent = await firstValueFrom(
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
)
if (localEvent) {
const content = getAppDataContent<UserSettings>(localEvent)
// Still fetch from relays in the background to get any updates
relayPool
.subscription(relays, {
kinds: [APP_DATA_KIND],
authors: [pubkey],
'#d': [SETTINGS_IDENTIFIER]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
return content || null
initial = content || null
}
} catch (_err) {
// Ignore local store errors
} catch {
// ignore
}
// If not in local store, fetch from relays
return new Promise((resolve) => {
let hasResolved = false
const timeout = setTimeout(() => {
if (!hasResolved) {
console.warn('⚠️ Settings load timeout - no settings event found')
hasResolved = true
resolve(null)
}
}, 5000)
const sub = relayPool
.subscription(relays, {
kinds: [APP_DATA_KIND],
authors: [pubkey],
'#d': [SETTINGS_IDENTIFIER]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
complete: async () => {
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true
try {
const event = await firstValueFrom(
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
)
if (event) {
const content = getAppDataContent<UserSettings>(event)
resolve(content || null)
} else {
resolve(null)
}
} catch (err) {
console.error('❌ Error loading settings:', err)
resolve(null)
}
}
},
error: (err) => {
console.error('❌ Settings subscription error:', err)
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true
resolve(null)
}
}
})
// Start background sync (fire-and-forget; no timeout)
relayPool
.subscription(relays, {
kinds: [APP_DATA_KIND],
authors: [pubkey],
'#d': [SETTINGS_IDENTIFIER]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
setTimeout(() => {
sub.unsubscribe()
}, 5000)
})
return initial
}
export async function saveSettings(

View File

@@ -35,6 +35,8 @@
.reading-time svg { font-size: 0.875rem; }
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); border-radius: 6px; font-size: 0.875rem; color: var(--color-text); }
.highlight-indicator svg { font-size: 0.875rem; }
.highlight-indicator.clickable { cursor: pointer; transition: all 0.2s ease; }
.highlight-indicator.clickable:hover { background: rgba(99, 102, 241, 0.15); border-color: rgba(99, 102, 241, 0.5); transform: translateY(-1px); }
.reader-html { color: var(--color-text); line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-family: var(--reading-font); font-size: var(--reading-font-size); }
.reader-markdown { color: var(--color-text); line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
/* Ensure font inheritance */

View File

@@ -59,6 +59,8 @@
align-items: center;
gap: 0.5rem;
margin-left: auto;
flex: 1;
justify-content: flex-end;
}
/* Mobile hamburger button now uses Tailwind utilities in ThreePaneLayout */
@@ -92,7 +94,7 @@
gap: 0.5rem;
}
.profile-avatar {
.profile-avatar-button {
min-width: 33px;
min-height: 33px;
width: 33px;
@@ -108,10 +110,27 @@
color: var(--color-text);
box-sizing: border-box;
padding: 0;
cursor: pointer;
transition: all 0.2s ease;
}
.profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
.profile-avatar svg { font-size: 1rem; }
/* Mobile touch target improvements */
@media (max-width: 768px) {
.profile-avatar-button {
min-width: var(--min-touch-target);
min-height: var(--min-touch-target);
width: var(--min-touch-target);
height: var(--min-touch-target);
}
}
.profile-avatar-button:hover {
background: var(--color-bg-hover);
border-color: var(--color-border);
}
.profile-avatar-button img { width: 100%; height: 100%; object-fit: cover; }
.profile-avatar-button svg { font-size: 1rem; }
.sidebar-header-bar .toggle-sidebar-btn {
background: transparent;

View File

@@ -31,7 +31,8 @@ export interface Bookmark {
export interface IndividualBookmark {
id: string
content: string
created_at: number
// Timestamp when the content was created (from the content event itself)
created_at: number | null
pubkey: string
kind: number
tags: string[][]
@@ -40,8 +41,6 @@ export interface IndividualBookmark {
type: 'event' | 'article' | 'web'
isPrivate?: boolean
encryptedContent?: string
// When the item was added to the bookmark list (synthetic, for sorting)
added_at?: number
// The kind of the source list/set that produced this bookmark (e.g., 10003, 30003, 30001, or 39701 for web)
sourceKind?: number
// The 'd' tag value from kind 30003 bookmark sets
@@ -50,6 +49,9 @@ export interface IndividualBookmark {
setTitle?: string
setDescription?: string
setImage?: string
// Timestamp of the bookmark list event (best proxy for "when bookmarked")
// Note: This is imperfect - it's when the list was last updated, not necessarily when this item was added
listUpdatedAt?: number | null
}
export interface ActiveAccount {

View File

@@ -4,13 +4,15 @@ import { ParsedContent, ParsedNode, IndividualBookmark } from '../types/bookmark
import ResolvedMention from '../components/ResolvedMention'
// Note: RichContent is imported by components directly to keep this file component-only for fast refresh
export const formatDate = (timestamp: number) => {
export const formatDate = (timestamp: number | null | undefined) => {
if (!timestamp || !isFinite(timestamp) || timestamp <= 0) return ''
const date = new Date(timestamp * 1000)
return formatDistanceToNow(date, { addSuffix: true })
}
// Ultra-compact date format for tight spaces (e.g., compact view)
export const formatDateCompact = (timestamp: number) => {
export const formatDateCompact = (timestamp: number | null | undefined) => {
if (!timestamp || !isFinite(timestamp) || timestamp <= 0) return ''
const date = new Date(timestamp * 1000)
const now = new Date()
@@ -85,9 +87,8 @@ export const renderParsedContent = (parsedContent: ParsedContent) => {
// Sorting and grouping for bookmarks
export const sortIndividualBookmarks = (items: IndividualBookmark[]) => {
return items
.slice()
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
const getSortTime = (b: IndividualBookmark) => b.created_at ?? b.listUpdatedAt ?? -Infinity
return items.slice().sort((a, b) => getSortTime(b) - getSortTime(a))
}
export function groupIndividualBookmarks(items: IndividualBookmark[]) {
@@ -122,10 +123,7 @@ export function hasContent(bookmark: IndividualBookmark): boolean {
export function hasCreationDate(bookmark: IndividualBookmark): boolean {
if (!bookmark.created_at) return false
// If timestamp is missing or equals current time (within 1 second), consider it invalid
const now = Math.floor(Date.now() / 1000)
const createdAt = Math.floor(bookmark.created_at)
// If created_at is within 1 second of now, it's likely missing/placeholder
return Math.abs(createdAt - now) > 1
return true
}
// Bookmark sets helpers (kind 30003)

View File

@@ -51,7 +51,7 @@ export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
summary,
image,
readingProgress: 0,
readingTimestamp: bookmark.added_at || bookmark.created_at
readingTimestamp: bookmark.created_at ?? undefined
}
linksMap.set(url, item)

View File

@@ -49,7 +49,7 @@ export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
source: 'bookmark',
type: 'article',
readingProgress: 0,
readingTimestamp: bookmark.added_at || bookmark.created_at,
readingTimestamp: bookmark.created_at ?? undefined,
title,
summary,
image,