Compare commits

..

64 Commits

Author SHA1 Message Date
Gigi
9ab6847501 chore: bump version to 0.9.0 2025-10-20 20:13:15 +02:00
Gigi
31afe3792e fix: replace any types with proper NostrEvent types in relayListService
- Import NostrEvent type from nostr-tools
- Replace any[] with NostrEvent[] for events array
- Replace Map<string, any> with Map<string, NostrEvent> for eventsMap
- Resolves ESLint warnings about explicit any usage
2025-10-20 20:13:05 +02:00
Gigi
ebe8ecf63b feat: stream user relay list into pool immediately and finalize after blocked relays
- loadUserRelayList accepts onUpdate callback to stream first user relay list
- App.tsx applies interim relay set on first event, keeps alive, then recomputes with blocked relays
- Keeps startup non-blocking and matches Debug page behavior
2025-10-20 20:10:08 +02:00
Gigi
c418000a0c fix: add streaming callback to relay list service for faster results
- Add onEvent streaming callback to relayListService queryEvents call
- Process events as they arrive instead of waiting for all relays to respond
- Deduplicate events by id and keep most recent version
- Remove artificial delay since streaming provides immediate results
- Should resolve hanging issue where debug works but app query hangs
2025-10-20 20:03:16 +02:00
Gigi
15fd19f6a4 fix: resolve all linting issues
- Remove unused DebugBus import from App.tsx
- Remove unused NostrEvent import from relayListService.ts
- Add comment to empty catch block in ContentPanel.tsx
- Remove unused targetUrlsMap variable from relayManager.ts
- All linting errors resolved, TypeScript type checking passes
2025-10-20 20:01:40 +02:00
Gigi
2a44b4e3c0 cleanup: remove temporary test relays from hardcoded list
- Remove temporary relay additions that were added for debugging
- Restore clean hardcoded relay list now that dynamic relay integration is working
- The non-blocking relay loading implementation handles user relay lists properly
2025-10-20 20:01:02 +02:00
Gigi
aa7807e3d2 fix: make relay list loading non-blocking in App.tsx
- Start with hardcoded relays immediately when user logs in
- Load user relay list and blocked relays in background Promise
- Apply user relay preferences when they become available
- Remove blocking await that was preventing immediate relay setup
- Update keep-alive subscription and address loader when user relays load
- Continue with initial relay set if user relay loading fails
2025-10-20 19:58:55 +02:00
Gigi
359d3d0dd6 feat: add relay list debug section to Debug component
- Add state variables for relay list loading (isLoadingRelayList, relayListEvents, timing)
- Add handleLoadRelayList function to query kind 10002 events
- Add handleClearRelayList function to clear loaded data
- Add UI section with Load/Clear buttons and event display
- Show relay URLs and permissions for each relay list event
- Add loadRelayList to live timing type definition
2025-10-20 19:56:00 +02:00
Gigi
d40b3c0048 debug: add more detailed logging to relay list query including broader query test 2025-10-20 19:54:07 +02:00
Gigi
7b4ca50b16 debug: add timeout to relay list query and temporarily add user's relays to hardcoded set to test relay list loading 2025-10-20 19:52:40 +02:00
Gigi
76e001aba4 debug: add logging to relay list loading to diagnose why user relay list is not found 2025-10-20 19:51:42 +02:00
Gigi
0b42aeb383 refactor: remove non-relay console.log statements
- Remove console.log statements from ContentPanel.tsx (archive/content related)
- Remove console.log statements from readingProgressController.ts (reading progress related)
- Remove console.log statements from reactionService.ts (reaction related)
- Remove debug console.log block from Me.tsx (archive/me related)
- Preserve all relay-related console.log statements in App.tsx and relayManager.ts
2025-10-20 19:46:50 +02:00
Gigi
a4554e5176 chore: remove non-relay debug output
Remove bunker-related debug logs and keep-alive subscription warnings.
Keep only relay-related logs ([relay-init] and [relayManager]) for debugging
relay loading and management.
2025-10-20 19:35:39 +02:00
Gigi
2e844fc26b fix: use user's relay list exclusively when logged in
When logged in:
- If user has relay list (kind:10002): use ONLY user relays + bunker + localhost
- If user has NO relay list: fall back to hardcoded RELAYS

This ensures the relay list changes when you log in based on your NIP-65 relay list.

Added debug logging to show user relay list, blocked relays, and final relay set.
2025-10-20 19:31:21 +02:00
Gigi
8c0a4cac16 config: remove relay.dergigi.com from default relays
Keep only wot.dergigi.com (WoT relay) in the default relay list.
2025-10-20 19:30:00 +02:00
Gigi
c6eccc9589 fix: normalize relay URLs to match applesauce-relay internal format
applesauce-relay adds trailing slashes to relay URLs without paths,
but our RELAYS config doesn't include them. This caused applyRelaySetToPool
to think they were different URLs and remove all relays except the proxy.

Now we normalize URLs before comparison to match the pool's format.
2025-10-20 19:26:43 +02:00
Gigi
2e5536c331 debug: add logging to relay initialization to diagnose single relay issue 2025-10-20 19:18:03 +02:00
Gigi
fc025b9579 feat: integrate user relay lists (NIP-65) and blocked relays (NIP-51)
- Add relayListService to load kind:10002 (user relay list) and kind:10006 (blocked relays)
- Add relayManager to compute active relay set and dynamically manage pool membership
- Update App.tsx to fetch and apply user relays on login, reset on logout
- Replace all hardcoded RELAYS usages with dynamic getActiveRelayUrls() across services and components
- Always preserve localhost relays (ws://localhost:10547, ws://localhost:4869) regardless of user blocks
- Merge bunker relays, user relays, and hardcoded relays while excluding blocked relays
- Update keep-alive subscription and address loaders to use dynamic relay set
- Modified files: App.tsx, relayListService.ts (new), relayManager.ts (new), readsService.ts, readingProgressController.ts, archiveController.ts, libraryService.ts, reactionService.ts, writeService.ts, HighlightItem.tsx, ContentPanel.tsx, BookmarkList.tsx, Profile.tsx
2025-10-20 18:40:23 +02:00
Gigi
88db14c352 docs: update CHANGELOG for v0.8.6 2025-10-20 18:07:46 +02:00
Gigi
49c5f0c3ad chore: bump version to 0.8.6 2025-10-20 18:07:24 +02:00
Gigi
dbed4ad253 fix: revert to inline mount tracking with useRef
- Replace useMountedState custom hook with inline useRef approach
- Set mountedRef.current = true at start of each effect run
- Ensures proper reset when navigating between articles
- Simpler and more reliable than custom hook approach
2025-10-20 18:05:02 +02:00
Gigi
b117b1e6cf fix: remove isMounted from useEffect dependencies
- isMounted is a stable function from useMountedState and shouldn't be in deps
- Including it was preventing effects from running correctly
- Fixes issue where articles wouldn't load (stuck on spinner)
2025-10-20 17:46:41 +02:00
Gigi
627ffd6c5d fix: resolve React Hooks violation in NostrMentionLink component
- Move useEventModel hook call to top level (Rules of Hooks)
- Extract pubkey before calling the hook
- Profile resolution now works correctly for npub and nprofile mentions
- Fixes issue where profiles weren't being fetched and displayed
2025-10-20 16:36:52 +02:00
Gigi
0d53027818 chore: bump version to 0.8.5 2025-10-20 16:34:30 +02:00
Gigi
811d96dee0 refactor: extract common isMounted pattern into reusable useMountedState hook
- Create useMountedState hook to track component mount status
- Refactor useArticleLoader to use shared hook
- Refactor useExternalUrlLoader to use shared hook
- Remove duplicated isMounted pattern across both loaders
- Cleaner, more DRY code with same functionality
2025-10-20 16:33:05 +02:00
Gigi
21335d56dc fix: prevent infinite loading spinner by fixing race conditions in article/URL loaders
- Add isMounted flag to track component lifecycle in useArticleLoader
- Add isMounted flag to track component lifecycle in useExternalUrlLoader
- Remove setter functions from useEffect dependencies to prevent re-triggers
- Add cleanup functions to cancel pending state updates on unmount
- Check isMounted before all state updates in async operations
- Fixes issue where spinner would spin forever when loading articles
2025-10-20 15:00:39 +02:00
Gigi
f7e50023a3 feat: replace ContentWithResolvedProfiles with comprehensive RichContent component
- Create RichContent component to handle ALL nostr URI types
- Support npub, nprofile, note, nevent, naddr with profile resolution
- Handle both 'nostr:npub1...' and plain 'npub1...' formats
- Replace all ContentWithResolvedProfiles usages in CardView, LargeView, and CompactView
- Now all bookmark content properly displays resolved nostr mentions
2025-10-20 14:57:39 +02:00
Gigi
6b09212fe9 feat: resolve user profiles for npub mentions in highlight comments
- Create NostrMentionLink component to fetch and display user names
- Replace truncated pubkey display with resolved profile names
- Fetch profiles in background non-blocking way using useEventModel
- Falls back to truncated pubkey if profile not available
2025-10-20 14:55:00 +02:00
Gigi
cecff6b8d5 fix: filter out bookmark list events from individual bookmarks display
- Bookmark list events (kind:10003, 30003, 30001) are containers, not content
- Add filter in hydrateItems to exclude these kinds after hydration
- Add debug logging to track which items are being filtered
- Prevents bookmark list events from showing as individual bookmarks in UI
2025-10-20 14:45:30 +02:00
Gigi
2b061afa47 debug: add [BOOKMARK_TS] logging to investigate timestamp issues
- Log parentCreatedAt value when processApplesauceBookmarks is called
- Log each bookmark event with its kind and created_at timestamp
- Log count and timestamp for notes, articles, and URLs being processed
- Prefixed with [BOOKMARK_TS] for easy console filtering
2025-10-20 13:56:07 +02:00
Gigi
7516013e67 fix: use parent event timestamp for bookmarks instead of placeholder
- Add parentCreatedAt parameter to processApplesauceBookmarks function
- Replace all Math.floor(Date.now() / 1000) placeholders with parentCreatedAt || 0
- Update all call sites in bookmarkProcessing.ts to pass evt.created_at
- Individual bookmarks now inherit timestamp from their bookmark list event
- Bookmarks without valid parent timestamp will show as 0 (epoch) and be filtered by hideBookmarksWithoutCreationDate setting
- Eliminates 'now' placeholder timestamps in bookmark sidebar
2025-10-20 13:51:26 +02:00
Gigi
567641de77 fix: improve detection of placeholder bookmarks without valid timestamps
- Enhanced hasCreationDate() to better detect unhydrated bookmark references
- Web bookmarks (kind 39701) always have real timestamps, always shown
- Filter out bookmarks with no content (failed hydration)
- Filter out URL-only bookmarks with minimal tags and synthetic IDs
- These are created during NIP-51 processing and show 'now' if not hydrated
- Fixes issue where placeholder timestamps would pass filter after time elapsed
2025-10-20 13:45:00 +02:00
Gigi
4e86907663 fix: apply hideBookmarksWithoutCreationDate setting to Me component
- Import hasCreationDate utility function in Me.tsx
- Add UserSettings to MeProps interface
- Pass settings prop from Bookmarks to Me component
- Filter out bookmarks without creation dates when setting is enabled
- This ensures bookmarks showing 'Now' are hidden by default
2025-10-20 13:41:45 +02:00
Gigi
ec34e00573 docs: update CHANGELOG for v0.8.4 release
- Document progressive article hydration feature for reads tab
- Document React type imports fix in useArticleLoader
2025-10-20 13:36:19 +02:00
Gigi
5e6c8b7516 chore: bump version to 0.8.4 2025-10-20 13:35:13 +02:00
Gigi
e50af42c96 fix: import React types correctly in useArticleLoader
- Import Dispatch and SetStateAction directly from 'react'
- Fixes linting errors about React not being defined
- Resolves eslint no-undef errors
2025-10-20 13:34:48 +02:00
Gigi
73470987be feat: add progressive article hydration for reads tab
- Create readsController service with background article fetching
- Implement progressive hydration pattern similar to bookmarkController
- Use AddressLoader for efficient batched article event retrieval
- Update Me.tsx to use readsController instead of direct readingProgressController
- Articles now show titles, summaries, images as data arrives from relays
- Fixes issue where reads showed 'Untitled' for all articles
- Keep event store integration for caching article events
- Maintain DRY principle by centralizing reads data fetching
2025-10-20 13:33:17 +02:00
Gigi
31e203825d fix(types): correct setHighlights type to accept setState updater functions 2025-10-20 13:19:39 +02:00
Gigi
6f9c0a35e2 fix(reader): trigger archive animation even if already archived on auto-complete 2025-10-20 13:17:35 +02:00
Gigi
96f59a54f3 fix(reading): ensure 2s linger at 100% uses live position ref for auto-archive 2025-10-20 13:14:10 +02:00
Gigi
87c0a0454b refactor(me): DRY archive-only builders into shared helper for reads/links 2025-10-20 13:12:34 +02:00
Gigi
77c2ef1794 feat(links): mirror archive-only vs progress-only behavior in Links tab 2025-10-20 13:02:56 +02:00
Gigi
8d08911bd3 feat(reads): separate archive vs reading-progress filters; archive shows emoji-only, progress filters ignore emoji 2025-10-20 13:00:34 +02:00
Gigi
31b005a989 fix(reads): build archive list exactly like debug loader (streamed union, no overwrite) 2025-10-20 12:56:19 +02:00
Gigi
337bfe5432 fix(reads): union archive marks from readingProgress and archiveController to prevent empty archive view 2025-10-20 12:49:29 +02:00
Gigi
2f275375f7 ui(animation): restore archive success burst on manual archive (animating state) 2025-10-20 12:45:12 +02:00
Gigi
27cbcb56ec ui(reader): keep Archived label and subtle style while remaining clickable 2025-10-20 12:43:28 +02:00
Gigi
7f150003b5 feat(reader): wire unarchive actions to delete matching reactions and clear controller 2025-10-20 12:39:28 +02:00
Gigi
1f50d8e1b6 feat(reader): make Archived button clickable and perform unarchive via NIP-09 2025-10-20 12:39:09 +02:00
Gigi
f53decef16 feat(archive): add unarchive service to delete ARCHIVE_EMOJI reactions (kind 7/17) 2025-10-20 12:38:27 +02:00
Gigi
f272943b64 chore: commit pending working changes before implementing unarchive behavior 2025-10-20 12:36:27 +02:00
Gigi
49745e1b8a refactor(archive): remove direct markedIds mutation; use controller.mark/unmark for DRY updates; fix duplicate import in reactionService 2025-10-20 11:23:45 +02:00
Gigi
470f4fb34e feat(archive): support un-archive toggle; add ArchiveController mark/unmark; prep NIP-09 deletion hook 2025-10-20 11:21:59 +02:00
Gigi
8cde36c08c fix(archive): add 'a' coord tag to mark-as-read reactions for articles; archiveController maps a-tag instantly; add debug 2025-10-20 11:17:30 +02:00
Gigi
c21f96f5bb chore(debug): deepen [archive] mapping with eventStore timeline and logs; add sampleMarked logs in Me 2025-10-20 11:05:59 +02:00
Gigi
c9fef5804b chore(debug): add [archive] debug logs in archiveController, Me, and ContentPanel to trace archive filter behavior 2025-10-20 10:48:44 +02:00
Gigi
8337622a22 feat(archive): introduce archiveController to manage marked-as-read (kind:7/17); wire into App, Me, and ContentPanel for DRY archive state 2025-10-20 10:33:42 +02:00
Gigi
572f0fed6f fix(reads/links): keep DRY filtering but enforce type separation (articles vs external) for /me/reads and /me/links filters 2025-10-20 10:14:20 +02:00
Gigi
27a55ec329 fix(links): keep Links tab active when using /me/links/:filter by recognizing links path prefix in tab detection 2025-10-20 09:50:13 +02:00
Gigi
7ba362a3bb feat(links): add /me/links/:filter routes and mirror Reads filters/state for Links tab 2025-10-20 09:47:31 +02:00
Gigi
dc1844907e feat(settings): enable 'Hide bookmarks missing a creation date' by default 2025-10-20 09:43:51 +02:00
Gigi
28123b5e13 feat(archive): rename 'Mark as Read' UI to 'Move to Archive' and show 'Archived' state; update settings and filters wording 2025-10-20 09:42:34 +02:00
Gigi
d9eb87aa5c feat(reads): rename 'emoji' filter to 'archive' and use fa-books icon; map legacy /me/reads/emoji to /me/reads/archive 2025-10-20 09:39:45 +02:00
Gigi
a0ff0daf9d docs: update CHANGELOG.md for v0.8.3 release 2025-10-20 09:30:30 +02:00
40 changed files with 1969 additions and 542 deletions

View File

@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.8.6] - 2025-10-20
### Fixed
- React Hooks violations in NostrMentionLink component
- Fixed useEffect dependency warnings by removing isMounted from dependencies
- Reverted to inline mount tracking with useRef for safer lifecycle handling
## [0.8.4] - 2024-10-20
### Added
- Progressive article hydration for reads tab
- Articles now load titles, summaries, images, and author information progressively
- Implemented readsController following the same pattern as bookmarkController
- Uses AddressLoader for efficient batched article event retrieval
- Articles rehydrate as data arrives from relays without blocking initial display
- Event store integration for caching article events
- Centralized reads data fetching following DRY principles
### Fixed
- Fixed React type imports in useArticleLoader
- Import `Dispatch` and `SetStateAction` directly from 'react' instead of using `React.` prefix
- Resolves ESLint no-undef errors
## [0.8.3] - 2025-01-19
### Fixed
- Highlight creation now shows immediate UI feedback without page refresh
- Fixed streaming highlight merge logic to preserve newly created highlights
- Decoupled cached highlight sync from content loading to prevent unintended reloads
- Newly created highlights appear instantly in both reader and highlights panel
- Highlights remain visible while remote results stream in and merge properly
### Changed
- Improved highlight creation user experience
- Selection clearing and synchronous rendering for immediate highlight display
- Better error handling for bunker permission issues with user-friendly messages
## [0.8.2] - 2025-10-19
### Added
@@ -2069,7 +2111,8 @@ 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.8.2...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.8.3...HEAD
[0.8.3]: https://github.com/dergigi/boris/compare/v0.8.2...v0.8.3
[0.8.2]: https://github.com/dergigi/boris/compare/v0.8.0...v0.8.2
[0.8.0]: https://github.com/dergigi/boris/compare/v0.7.4...v0.8.0
[0.7.4]: https://github.com/dergigi/boris/compare/v0.7.3...v0.7.4

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "boris",
"version": "0.6.13",
"version": "0.8.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boris",
"version": "0.6.13",
"version": "0.8.4",
"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.8.3",
"version": "0.9.0",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",

View File

@@ -18,7 +18,8 @@ import { useToast } from './hooks/useToast'
import { useOnlineStatus } from './hooks/useOnlineStatus'
import { RELAYS } from './config/relays'
import { SkeletonThemeProvider } from './components/Skeletons'
import { DebugBus } from './utils/debugBus'
import { loadUserRelayList, loadBlockedRelays, computeRelaySet } from './services/relayListService'
import { applyRelaySetToPool, getActiveRelayUrls, ALWAYS_LOCAL_RELAYS } from './services/relayManager'
import { Bookmark } from './types/bookmarks'
import { bookmarkController } from './services/bookmarkController'
import { contactsController } from './services/contactsController'
@@ -28,6 +29,7 @@ import { readingProgressController } from './services/readingProgressController'
// import { fetchNostrverseHighlights } from './services/nostrverseService'
import { nostrverseHighlightsController } from './services/nostrverseHighlightsController'
import { nostrverseWritingsController } from './services/nostrverseWritingsController'
import { archiveController } from './services/archiveController'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
@@ -114,6 +116,11 @@ function AppRoutes({
readingProgressController.start({ relayPool, eventStore, pubkey })
}
// Load archive (marked-as-read) controller
if (pubkey && eventStore && !archiveController.isLoadedFor(pubkey)) {
archiveController.start({ relayPool, eventStore, pubkey })
}
// Start centralized nostrverse highlights controller (non-blocking)
if (eventStore) {
nostrverseHighlightsController.start({ relayPool, eventStore })
@@ -145,6 +152,7 @@ function AppRoutes({
contactsController.reset() // Clear contacts via controller
highlightsController.reset() // Clear highlights via controller
readingProgressController.reset() // Clear reading progress via controller
archiveController.reset() // Clear archive state
showToast('Logged out successfully')
}
@@ -286,6 +294,18 @@ function AppRoutes({
/>
}
/>
<Route
path="/me/links/:filter"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
<Route
path="/me/writings"
element={
@@ -381,6 +401,8 @@ function App() {
// Create a relay group for better event deduplication and management
pool.group(RELAYS)
console.log('[relay-init] Initial pool setup - added RELAYS:', RELAYS.length, 'relays')
console.log('[relay-init] Pool now has:', Array.from(pool.relays.keys()).length, 'relays')
// Load persisted accounts from localStorage
try {
@@ -398,14 +420,10 @@ function App() {
if (account) {
accounts.setActive(activeId)
} else {
console.warn('[bunker] ⚠️ Active ID found but account not in list')
}
} else {
// No active account ID in localStorage
}
} catch (err) {
console.error('[bunker] ❌ Failed to load accounts from storage:', err)
console.error('Failed to load accounts from storage:', err)
}
// Subscribe to accounts changes and persist to localStorage
@@ -474,61 +492,27 @@ function App() {
try {
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
recreatedSigner.relays = mergedRelays
} catch (err) { console.warn('[bunker] failed to merge signer relays', err) }
} catch (err) { /* ignore */ }
// Replace the signer on the account
nostrConnectAccount.signer = recreatedSigner
// Debug: log publish/subscription calls made by signer (decrypt/sign requests)
// Fire-and-forget publish for bunker: trigger but don't wait for completion
// IMPORTANT: bind originals to preserve `this` context used internally by the signer
const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner)
;(recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod = (relays: string[], event: unknown) => {
try {
let method: string | undefined
const content = (event as { content?: unknown })?.content
if (typeof content === 'string') {
try {
const parsed = JSON.parse(content) as { method?: string; id?: unknown }
method = parsed?.method
} catch (err) { console.warn('[bunker] failed to parse event content', err) }
}
const summary = {
relays,
kind: (event as { kind?: number })?.kind,
method,
// include tags array for debugging (NIP-46 expects method tag)
tags: (event as { tags?: unknown })?.tags,
contentLength: typeof content === 'string' ? content.length : undefined
}
try { DebugBus.info('bunker', 'publish', summary) } catch (err) { console.warn('[bunker] failed to log to DebugBus', err) }
} catch (err) { console.warn('[bunker] failed to log publish summary', err) }
// Fire-and-forget publish: trigger the publish but do not return the
// Observable/Promise to upstream to avoid their awaiting of completion.
const result = originalPublish(relays, event)
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
}
// If it's a Promise, simply ignore it (no await) so it resolves in the background.
// Return a benign object so callers that probe for a "subscribe" property
// (e.g., applesauce makeRequest) won't throw on `"subscribe" in result`.
return {} as unknown as never
}
const originalSubscribe = (recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod.bind(recreatedSigner)
;(recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod = (relays: string[], filters: unknown[]) => {
try {
try { DebugBus.info('bunker', 'subscribe', { relays, filters }) } catch (err) { console.warn('[bunker] failed to log subscribe to DebugBus', err) }
} catch (err) { console.warn('[bunker] failed to log subscribe summary', err) }
return originalSubscribe(relays, filters)
}
// Just ensure the signer is listening for responses - don't call connect() again
// The fromBunkerURI already connected with permissions during login
if (!nostrConnectAccount.signer.listening) {
await nostrConnectAccount.signer.open()
} else {
// Signer already listening
}
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
@@ -538,7 +522,7 @@ function App() {
await nostrConnectAccount.signer.connect(undefined, permissions)
}
} catch (e) {
console.warn('[bunker] ⚠️ Guarded connect() failed:', e)
// Ignore reconnect errors
}
// Give the subscription a moment to fully establish before allowing decrypt operations
@@ -578,17 +562,137 @@ function App() {
// Mark this account as reconnected
reconnectedAccounts.add(account.id)
} catch (error) {
console.error('[bunker] ❌ Failed to open signer:', error)
console.error('Failed to open signer:', error)
}
}
})
// 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
// Bunker relays (if any)
let bunkerRelays: string[] = []
if (account.type === 'nostr-connect') {
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
const signerData = nostrConnectAccount.toJSON().signer
bunkerRelays = signerData.relays || []
}
console.log('[relay-init] Bunker relays:', bunkerRelays.length, 'relays', bunkerRelays)
// Start with hardcoded + bunker relays immediately (non-blocking)
const initialRelays = computeRelaySet({
hardcoded: RELAYS,
bunker: bunkerRelays,
userList: [],
blocked: [],
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
})
console.log('[relay-init] Initial relay set (hardcoded):', initialRelays.length, 'relays', initialRelays)
// Apply initial set immediately
applyRelaySetToPool(pool, initialRelays)
console.log('[relay-init] After initial applyRelaySetToPool, pool has:', Array.from(pool.relays.keys()).length, 'relays')
// Prepare keep-alive helper
const updateKeepAlive = () => {
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
poolWithSub._keepAliveSubscription.unsubscribe()
}
const activeRelays = getActiveRelayUrls(pool)
const newKeepAliveSub = pool.subscription(activeRelays, { kinds: [0], limit: 0 }).subscribe({
next: () => {},
error: () => {}
})
poolWithSub._keepAliveSubscription = newKeepAliveSub
}
// Begin loading blocked relays in background
const blockedPromise = loadBlockedRelays(pool, pubkey)
// Stream user relay list; apply immediately on first/updated event
loadUserRelayList(pool, pubkey, {
onUpdate: (userRelays) => {
const interimRelays = computeRelaySet({
hardcoded: [],
bunker: bunkerRelays,
userList: userRelays,
blocked: [],
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
})
console.log('[relay-init] Interim relay set from first user list:', interimRelays.length, 'relays', interimRelays)
applyRelaySetToPool(pool, interimRelays)
updateKeepAlive()
}
}).then(async (userRelayList) => {
const blockedRelays = await blockedPromise.catch(() => [])
console.log('[relay-init] User relay list (10002):', userRelayList.length, 'relays', userRelayList.map(r => r.url))
console.log('[relay-init] Blocked relays (10006):', blockedRelays.length, 'relays', blockedRelays)
const finalRelays = computeRelaySet({
hardcoded: userRelayList.length > 0 ? [] : RELAYS,
bunker: bunkerRelays,
userList: userRelayList,
blocked: blockedRelays,
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
})
console.log('[relay-init] Final relay set (with user preferences):', finalRelays.length, 'relays', finalRelays)
applyRelaySetToPool(pool, finalRelays)
console.log('[relay-init] After user relay list apply, pool has:', Array.from(pool.relays.keys()).length, 'relays')
console.log('[relay-init] Final relay URLs:', Array.from(pool.relays.keys()))
updateKeepAlive()
// Update address loader with new relays
const activeRelays = getActiveRelayUrls(pool)
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: activeRelays
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
}).catch((error) => {
console.error('[relay-init] Failed to load user relay list (continuing with initial set):', error)
// Continue with initial relay set on error - no need to change anything
})
} else {
// User logged out - reset to hardcoded relays
console.log('[relay-init] Applying RELAYS for logged out user, RELAYS.length:', RELAYS.length)
applyRelaySetToPool(pool, RELAYS)
console.log('[relay-init] After applyRelaySetToPool (logged out), pool has:', Array.from(pool.relays.keys()).length, 'relays')
console.log('[relay-init] Relay URLs:', Array.from(pool.relays.keys()))
// Update keep-alive subscription
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
poolWithSub._keepAliveSubscription.unsubscribe()
}
const newKeepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
next: () => {},
error: () => {}
})
poolWithSub._keepAliveSubscription = newKeepAliveSub
// Reset address loader
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: RELAYS
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
}
})
// Keep all relay connections alive indefinitely by creating a persistent subscription
// This prevents disconnection when no other subscriptions are active
// Create a minimal subscription that never completes to keep connections alive
const keepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
next: () => {}, // No-op, we don't care about events
error: (err) => console.warn('Keep-alive subscription error:', err)
next: () => {},
error: () => {}
})
// Store subscription for cleanup
@@ -611,6 +715,7 @@ function App() {
accountsSub.unsubscribe()
activeSub.unsubscribe()
bunkerReconnectSub.unsubscribe()
userRelaysSub.unsubscribe()
// Clean up keep-alive subscription if it exists
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {

View File

@@ -16,7 +16,7 @@ const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilte
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
{ type: 'marked' as const, icon: faBooks, label: 'Marked as Read' }
{ type: 'marked' as const, icon: faBooks, label: 'Archived' }
]
return (

View File

@@ -17,8 +17,8 @@ import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWith
import { UserSettings } from '../services/settingsService'
import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService'
import { RELAYS } from '../config/relays'
import { Hooks } from 'applesauce-react'
import { getActiveRelayUrls } from '../services/relayManager'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import LoginOptions from './LoginOptions'
@@ -125,7 +125,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
throw new Error('Please login to create bookmarks')
}
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, getActiveRelayUrls(relayPool))
}
// Pull-to-refresh for bookmarks

View File

@@ -5,7 +5,7 @@ import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import RichContent from '../RichContent'
import { classifyUrl } from '../../utils/helpers'
import { useImageCache } from '../../hooks/useImageCache'
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
@@ -147,19 +147,15 @@ export const CardView: React.FC<CardViewProps> = ({
)}
{isArticle && articleSummary ? (
<div className="bookmark-content article-summary">
<ContentWithResolvedProfiles content={articleSummary} />
</div>
<RichContent content={articleSummary} className="bookmark-content article-summary" />
) : bookmark.parsedContent ? (
<div className="bookmark-content">
{shouldTruncate && bookmark.content
? <ContentWithResolvedProfiles content={`${bookmark.content.slice(0, 210).trimEnd()}`} />
? <RichContent content={`${bookmark.content.slice(0, 210).trimEnd()}`} className="" />
: renderParsedContent(bookmark.parsedContent)}
</div>
) : bookmark.content && (
<div className="bookmark-content">
<ContentWithResolvedProfiles content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}` : bookmark.content} />
</div>
<RichContent content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}` : bookmark.content} />
)}
{contentLength > 210 && (

View File

@@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDateCompact } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import RichContent from '../RichContent'
interface CompactViewProps {
bookmark: IndividualBookmark
@@ -66,7 +66,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
</span>
{displayText && (
<div className="compact-text">
<ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} />
<RichContent content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} className="" />
</div>
)}
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>

View File

@@ -4,7 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import RichContent from '../RichContent'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { getEventUrl } from '../../config/nostrGateways'
@@ -95,13 +95,9 @@ export const LargeView: React.FC<LargeViewProps> = ({
<div className="large-content">
{isArticle && articleSummary ? (
<div className="large-text article-summary">
<ContentWithResolvedProfiles content={articleSummary} />
</div>
<RichContent content={articleSummary} className="large-text article-summary" />
) : bookmark.content && (
<div className="large-text">
<ContentWithResolvedProfiles content={bookmark.content} />
</div>
<RichContent content={bookmark.content} className="large-text" />
)}
{/* Reading progress indicator for articles - shown only if there's progress */}

View File

@@ -64,7 +64,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
location.pathname === '/me/highlights' ? 'highlights' :
location.pathname === '/me/reading-list' ? 'reading-list' :
location.pathname.startsWith('/me/reads') ? 'reads' :
location.pathname === '/me/links' ? 'links' :
location.pathname.startsWith('/me/links') ? 'links' :
location.pathname === '/me/writings' ? 'writings' : 'highlights'
// Extract tab from profile routes
@@ -328,7 +328,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
) : undefined}
me={showMe ? (
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} settings={settings} /> : null
) : undefined}
profile={showProfile && profilePubkey ? (
relayPool ? <Profile relayPool={relayPool} eventStore={eventStore} pubkey={profilePubkey} activeTab={profileTab} /> : null

View File

@@ -10,8 +10,8 @@ import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt,
import { ContentSkeleton } from './Skeletons'
import { nip19 } from 'nostr-tools'
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
import { RELAYS } from '../config/relays'
import { RelayPool } from 'applesauce-relay'
import { getActiveRelayUrls } from '../services/relayManager'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights'
@@ -29,6 +29,8 @@ import {
hasMarkedEventAsRead,
hasMarkedWebsiteAsRead
} from '../services/reactionService'
import { unarchiveEvent, unarchiveWebsite } from '../services/unarchiveService'
import { archiveController } from '../services/archiveController'
import AuthorCard from './AuthorCard'
import { faBooks } from '../icons/customIcons'
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
@@ -188,8 +190,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onSave: handleSavePosition,
onReadingComplete: () => {
// Auto-mark as read when reading is complete (if enabled in settings)
if (activeAccount && !isMarkedAsRead && settings?.autoMarkAsReadOnCompletion) {
if (!settings?.autoMarkAsReadOnCompletion || !activeAccount) return
if (!isMarkedAsRead) {
handleMarkAsRead()
} else {
// Already archived: still show the success animation for feedback
setShowCheckAnimation(true)
setTimeout(() => setShowCheckAnimation(false), 600)
}
}
})
@@ -350,7 +357,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (!currentArticle) return null
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
const relayHints = RELAYS.filter(r =>
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayHints = activeRelays.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3)
@@ -566,12 +574,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
activeAccount.pubkey,
relayPool
)
// Also check archiveController
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
try {
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
hasRead = hasRead || archiveController.isMarked(naddr)
} catch (e) {
// Silently ignore encoding errors
}
}
} else {
hasRead = await hasMarkedWebsiteAsRead(
selectedUrl,
activeAccount.pubkey,
relayPool
)
// Also check archiveController
const ctrl = archiveController.isMarked(selectedUrl)
hasRead = hasRead || ctrl
}
setIsMarkedAsRead(hasRead)
} catch (error) {
@@ -585,7 +606,35 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
const handleMarkAsRead = () => {
if (!activeAccount || !relayPool || isMarkedAsRead) {
if (!activeAccount || !relayPool) return
// Toggle archive state: if already archived, request deletion; else archive
if (isMarkedAsRead) {
// Optimistically unarchive in UI; background deletion request (NIP-09)
setIsMarkedAsRead(false)
;(async () => {
try {
if (isNostrArticle && currentArticle) {
// Send deletion for all matching reactions
await unarchiveEvent(currentArticle.id, activeAccount, relayPool)
// Also clear controller mark so lists update
try {
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
archiveController.unmark(naddr)
}
} catch (e) {
console.warn('[archive][content] encode naddr failed', e)
}
} else if (selectedUrl) {
await unarchiveWebsite(selectedUrl, activeAccount, relayPool)
archiveController.unmark(selectedUrl)
}
} catch (err) {
console.warn('[archive][content] unarchive failed', err)
}
})()
return
}
@@ -607,14 +656,34 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
currentArticle.pubkey,
currentArticle.kind,
activeAccount,
relayPool
relayPool,
{
aCoord: (() => {
try {
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) return undefined
return `${30023}:${currentArticle.pubkey}:${dTag}`
} catch { return undefined }
})()
}
)
// Update archiveController immediately
try {
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
archiveController.mark(naddr)
}
} catch (err) {
console.warn('[archive][content] optimistic article mark failed', err)
}
} else if (selectedUrl) {
await createWebsiteReaction(
selectedUrl,
activeAccount,
relayPool
)
archiveController.mark(selectedUrl)
}
} catch (error) {
console.error('Failed to mark as read:', error)
@@ -755,8 +824,9 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<button
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
onClick={handleMarkAsRead}
disabled={isMarkedAsRead || isCheckingReadStatus}
disabled={isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
>
<FontAwesomeIcon
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
@@ -918,21 +988,22 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</div>
)}
{/* Mark as Read button */}
{/* Archive button */}
{activeAccount && (
<div className="mark-as-read-container">
<button
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
onClick={handleMarkAsRead}
disabled={isMarkedAsRead || isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
disabled={isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Archived' : 'Move to Archive'}
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
>
<FontAwesomeIcon
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
spin={isCheckingReadStatus}
/>
<span>
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Archived' : 'Move to Archive'}
</span>
</button>
</div>

View File

@@ -114,6 +114,12 @@ const Debug: React.FC<DebugProps> = ({
const [markAsReadReactions, setMarkAsReadReactions] = useState<NostrEvent[]>([])
const [tLoadMarkAsRead, setTLoadMarkAsRead] = useState<number | null>(null)
const [tFirstMarkAsRead, setTFirstMarkAsRead] = useState<number | null>(null)
// Relay list loading state
const [isLoadingRelayList, setIsLoadingRelayList] = useState(false)
const [relayListEvents, setRelayListEvents] = useState<NostrEvent[]>([])
const [tLoadRelayList, setTLoadRelayList] = useState<number | null>(null)
const [tFirstRelayList, setTFirstRelayList] = useState<number | null>(null)
// Deduplicated reading progress from controller
const [deduplicatedProgressMap, setDeduplicatedProgressMap] = useState<Map<string, number>>(new Map())
@@ -127,6 +133,7 @@ const Debug: React.FC<DebugProps> = ({
loadHighlights?: { startTime: number }
loadReadingProgress?: { startTime: number }
loadMarkAsRead?: { startTime: number }
loadRelayList?: { startTime: number }
}>({})
// Web of Trust state
@@ -833,13 +840,13 @@ const Debug: React.FC<DebugProps> = ({
setLiveTiming(prev => ({ ...prev, loadMarkAsRead: { startTime: start } }))
const { queryEvents } = await import('../services/dataFetch')
const { MARK_AS_READ_EMOJI } = await import('../services/reactionService')
const { ARCHIVE_EMOJI } = await import('../services/reactionService')
// Load both kind:7 (reactions to events) and kind:17 (reactions to URLs)
const [kind7Events, kind17Events] = await Promise.all([
queryEvents(relayPool, { kinds: [7], authors: [activeAccount.pubkey] }, {
onEvent: (evt) => {
if (evt.content === MARK_AS_READ_EMOJI) {
if (evt.content === ARCHIVE_EMOJI) {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstMarkAsRead(Math.round(firstEventTime))
@@ -850,7 +857,7 @@ const Debug: React.FC<DebugProps> = ({
}),
queryEvents(relayPool, { kinds: [17], authors: [activeAccount.pubkey] }, {
onEvent: (evt) => {
if (evt.content === MARK_AS_READ_EMOJI) {
if (evt.content === ARCHIVE_EMOJI) {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstMarkAsRead(Math.round(firstEventTime))
@@ -886,6 +893,70 @@ const Debug: React.FC<DebugProps> = ({
DebugBus.info('debug', 'Cleared mark-as-read reactions data')
}
const handleLoadRelayList = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load relay list')
return
}
try {
setIsLoadingRelayList(true)
setRelayListEvents([])
setTLoadRelayList(null)
setTFirstRelayList(null)
DebugBus.info('debug', 'Loading relay list (kind 10002)...')
const start = performance.now()
let firstEventTime: number | null = null
setLiveTiming(prev => ({ ...prev, loadRelayList: { startTime: start } }))
const { queryEvents } = await import('../services/dataFetch')
// Query for kind:10002 (relay list)
const events = await queryEvents(relayPool, {
kinds: [10002],
authors: [activeAccount.pubkey],
limit: 10
}, {
onEvent: (evt) => {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstRelayList(Math.round(firstEventTime))
}
setRelayListEvents(prev => [...prev, evt])
}
})
const elapsed = Math.round(performance.now() - start)
setTLoadRelayList(elapsed)
setLiveTiming(prev => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const { loadRelayList, ...rest } = prev
return rest
})
DebugBus.info('debug', `Loaded ${events.length} relay list events in ${elapsed}ms`)
// Log details about the events
events.forEach((event, index) => {
const relayCount = event.tags.filter(tag => tag[0] === 'r').length
DebugBus.info('debug', `Event ${index + 1}: ${relayCount} relays, created ${new Date(event.created_at * 1000).toISOString()}`)
})
} catch (err) {
console.error('Failed to load relay list:', err)
DebugBus.error('debug', `Failed to load relay list: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setIsLoadingRelayList(false)
}
}
const handleClearRelayList = () => {
setRelayListEvents([])
setTLoadRelayList(null)
setTFirstRelayList(null)
DebugBus.info('debug', 'Cleared relay list data')
}
const handleLoadFriendsList = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load friends list')
@@ -1638,7 +1709,7 @@ const Debug: React.FC<DebugProps> = ({
{/* Mark-as-read Reactions Loading Section */}
<div className="settings-section">
<h3 className="section-title">Mark-as-read Reactions Loading</h3>
<div className="text-sm opacity-70 mb-3">Test loading mark-as-read reactions (kind: 7 and 17) with the MARK_AS_READ_EMOJI for the logged-in user</div>
<div className="text-sm opacity-70 mb-3">Test loading mark-as-read reactions (kind: 7 and 17) with the ARCHIVE_EMOJI for the logged-in user</div>
<div className="flex gap-2 mb-3 items-center">
<button
className="btn btn-primary"
@@ -1698,6 +1769,72 @@ const Debug: React.FC<DebugProps> = ({
)}
</div>
{/* Relay List Loading Section */}
<div className="settings-section">
<h3 className="section-title">Relay List Loading (kind 10002)</h3>
<div className="text-sm opacity-70 mb-3">Load your relay list to debug dynamic relay integration:</div>
<div className="flex gap-2 mb-3 items-center">
<button
className="btn btn-primary"
onClick={handleLoadRelayList}
disabled={isLoadingRelayList || !relayPool || !activeAccount}
>
{isLoadingRelayList ? (
<>
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
Loading...
</>
) : (
'Load Relay List'
)}
</button>
<button
className="btn btn-secondary ml-auto"
onClick={handleClearRelayList}
disabled={relayListEvents.length === 0}
>
Clear
</button>
</div>
<div className="flex gap-4 mb-3 text-sm">
<Stat label="total" value={tLoadRelayList} />
<Stat label="first event" value={tFirstRelayList} />
</div>
{relayListEvents.length > 0 && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Loaded Relay List Events ({relayListEvents.length}):</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{relayListEvents.map((evt, idx) => {
const relayTags = evt.tags?.filter((t: string[]) => t[0] === 'r') || []
return (
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
<div className="font-semibold mb-1">Relay List Event #{idx + 1}</div>
<div className="opacity-70 mb-1">
<div>Kind: {evt.kind}</div>
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
<div>Relays: {relayTags.length}</div>
</div>
<div className="mt-1">
<div className="text-[11px] opacity-70 mb-1">Relay URLs:</div>
{relayTags.map((tag, tagIdx) => (
<div key={tagIdx} className="text-[10px] opacity-60 break-all">
{tag[1]} {tag[2] ? `(${tag[2]})` : ''}
</div>
))}
</div>
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Web of Trust Section */}
<div className="settings-section">
<h3 className="section-title">Web of Trust</h3>

View File

@@ -8,8 +8,8 @@ import { Models, IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { Hooks } from 'applesauce-react'
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
import { RELAYS } from '../config/relays'
import { areAllRelaysLocal } from '../utils/helpers'
import { getActiveRelayUrls } from '../services/relayManager'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
import { createDeletionRequest } from '../services/deletionService'
@@ -17,6 +17,7 @@ import { getNostrUrl } from '../config/nostrGateways'
import CompactButton from './CompactButton'
import { HighlightCitation } from './HighlightCitation'
import { useNavigate } from 'react-router-dom'
import NostrMentionLink from './NostrMentionLink'
// Helper to detect if a URL is an image
const isImageUrl = (url: string): boolean => {
@@ -29,99 +30,6 @@ const isImageUrl = (url: string): boolean => {
}
}
// Helper to render a nostr identifier
const renderNostrId = (nostrUri: string, index: number): React.ReactElement => {
try {
// Remove nostr: prefix
const identifier = nostrUri.replace(/^nostr:/, '')
const decoded = nip19.decode(identifier)
switch (decoded.type) {
case 'npub': {
const pubkey = decoded.data
return (
<a
key={index}
href={`/p/${nip19.npubEncode(pubkey)}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
@{pubkey.slice(0, 8)}...
</a>
)
}
case 'nprofile': {
const { pubkey } = decoded.data
const npub = nip19.npubEncode(pubkey)
return (
<a
key={index}
href={`/p/${npub}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
@{pubkey.slice(0, 8)}...
</a>
)
}
case 'naddr': {
const { kind, pubkey, identifier } = decoded.data
// Check if it's a blog post (kind:30023)
if (kind === 30023) {
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
return (
<a
key={index}
href={`/a/${naddr}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
{identifier || 'Article'}
</a>
)
}
// For other kinds, show shortened identifier
return (
<span key={index} className="highlight-comment-nostr-id">
nostr:{identifier.slice(0, 12)}...
</span>
)
}
case 'note': {
const eventId = decoded.data
return (
<span key={index} className="highlight-comment-nostr-id">
note:{eventId.slice(0, 12)}...
</span>
)
}
case 'nevent': {
const { id } = decoded.data
return (
<span key={index} className="highlight-comment-nostr-id">
event:{id.slice(0, 12)}...
</span>
)
}
default:
// Fallback for unrecognized types
return (
<span key={index} className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
} catch (error) {
// If decoding fails, show shortened identifier
const identifier = nostrUri.replace(/^nostr:/, '')
return (
<span key={index} className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
}
// Component to render comment with links, inline images, and nostr identifiers
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
// Pattern to match both http(s) URLs and nostr: URIs
@@ -131,9 +39,15 @@ const CommentContent: React.FC<{ text: string }> = ({ text }) => {
return (
<>
{parts.map((part, index) => {
// Handle nostr: URIs
// Handle nostr: URIs - now with profile resolution
if (part.startsWith('nostr:')) {
return renderNostrId(part, index)
return (
<NostrMentionLink
key={index}
nostrUri={part}
onClick={(e) => e.stopPropagation()}
/>
)
}
// Handle http(s) URLs
@@ -236,10 +150,10 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
setShowOfflineIndicator(false)
// Update the highlight with all relays after successful sync
if (onHighlightUpdate && highlight.isLocalOnly) {
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
const updatedHighlight = {
...highlight,
publishedRelays: RELAYS,
publishedRelays: getActiveRelayUrls(relayPool),
isLocalOnly: false,
isOfflineCreated: false
}
@@ -250,7 +164,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
})
return unsubscribe
}, [highlight, onHighlightUpdate])
}, [highlight, onHighlightUpdate, relayPool])
useEffect(() => {
if (isSelected && itemRef.current) {
@@ -310,7 +224,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const getHighlightLinks = () => {
// Encode the highlight event itself (kind 9802) as a nevent
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayHints = activeRelays.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
@@ -346,7 +261,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
// Publish to all configured relays - let the relay pool handle connection state
const targetRelays = RELAYS
const targetRelays = getActiveRelayUrls(relayPool)
await relayPool.publish(targetRelays, event)
@@ -414,7 +329,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
// Fallback: show all relays we queried (where this was likely fetched from)
const relayNames = RELAYS.map(url =>
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayNames = activeRelays.map(url =>
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {

View File

@@ -12,7 +12,7 @@ import { HighlightItem } from './HighlightItem'
import { highlightsController } from '../services/highlightsController'
import { writingsController } from '../services/writingsController'
import { fetchLinks } from '../services/linksService'
import { ReadItem } from '../services/readsService'
import { ReadItem, readsController } from '../services/readsController'
import { BlogPostPreview } from '../services/exploreService'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import AuthorCard from './AuthorCard'
@@ -23,13 +23,15 @@ 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 } from '../utils/bookmarkUtils'
import { groupIndividualBookmarks, hasContent, hasCreationDate } from '../utils/bookmarkUtils'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
import { filterByReadingProgress } from '../utils/readingProgressUtils'
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
import { readingProgressController } from '../services/readingProgressController'
import { archiveController } from '../services/archiveController'
import { UserSettings } from '../services/settingsService'
interface MeProps {
relayPool: RelayPool
@@ -37,18 +39,20 @@ interface MeProps {
activeTab?: TabType
bookmarks: Bookmark[] // From centralized App.tsx state
bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use)
settings: UserSettings
}
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
// Valid reading progress filters
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted', 'emoji']
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted', 'archive']
const Me: React.FC<MeProps> = ({
relayPool,
eventStore,
activeTab: propActiveTab,
bookmarks
bookmarks,
settings
}) => {
const activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate()
@@ -59,7 +63,6 @@ const Me: React.FC<MeProps> = ({
const viewingPubkey = activeAccount?.pubkey
const [highlights, setHighlights] = useState<Highlight[]>([])
const [reads, setReads] = useState<ReadItem[]>([])
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
const [links, setLinks] = useState<ReadItem[]>([])
const [, setLinksMap] = useState<Map<string, ReadItem>>(new Map())
const [writings, setWritings] = useState<BlogPostPreview[]>([])
@@ -88,8 +91,10 @@ const Me: React.FC<MeProps> = ({
}
// Initialize reading progress filter from URL param
const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
? (urlFilter as ReadingProgressFilterType)
// Backward compat: map legacy 'emoji' route to 'archive'
const normalizedUrlFilter = urlFilter === 'emoji' ? 'archive' : urlFilter
const initialFilter = normalizedUrlFilter && VALID_FILTERS.includes(normalizedUrlFilter as ReadingProgressFilterType)
? (normalizedUrlFilter as ReadingProgressFilterType)
: 'all'
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
@@ -133,8 +138,9 @@ const Me: React.FC<MeProps> = ({
// Sync filter state with URL changes
useEffect(() => {
const filterFromUrl = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
? (urlFilter as ReadingProgressFilterType)
const normalized = urlFilter === 'emoji' ? 'archive' : urlFilter
const filterFromUrl = normalized && VALID_FILTERS.includes(normalized as ReadingProgressFilterType)
? (normalized as ReadingProgressFilterType)
: 'all'
setReadingProgressFilter(filterFromUrl)
}, [urlFilter])
@@ -148,90 +154,41 @@ const Me: React.FC<MeProps> = ({
} else {
navigate(`/me/reads/${filter}`, { replace: true })
}
} else if (activeTab === 'links') {
if (filter === 'all') {
navigate('/me/links', { replace: true })
} else {
navigate(`/me/links/${filter}`, { replace: true })
}
}
}
// Subscribe to reading progress controller
// Subscribe to reads controller
useEffect(() => {
// Get initial state immediately
setReads(readsController.getReads())
// Subscribe to updates
const unsubReads = readsController.onReads(setReads)
return () => {
unsubReads()
}
}, [])
// Subscribe to reading progress map for writings and links enrichment
useEffect(() => {
// Get initial state immediately
setReadingProgressMap(readingProgressController.getProgressMap())
// Subscribe to updates
const unsubProgress = readingProgressController.onProgress((progressMap) => {
const readItems: ReadItem[] = Array.from(progressMap.entries()).map(([id, progress]) => ({
id,
source: 'reading-progress',
type: 'article',
readingProgress: progress,
markedAsRead: readingProgressController.isMarkedAsRead(id),
readingTimestamp: Math.floor(Date.now() / 1000)
}))
// Include items that are only marked-as-read (no progress event yet)
const markedIds = readingProgressController.getMarkedAsReadIds()
for (const id of markedIds) {
if (!readItems.find(i => i.id === id)) {
const isArticle = id.startsWith('naddr1')
readItems.push({
id,
source: 'marked-as-read',
type: isArticle ? 'article' : 'external',
url: isArticle ? undefined : id,
markedAsRead: true,
readingTimestamp: Math.floor(Date.now() / 1000)
})
}
}
const readsMap = new Map(readItems.map(item => [item.id, item]))
setReadsMap(readsMap)
setReads(readItems)
})
const unsubProgress = readingProgressController.onProgress(setReadingProgressMap)
return () => {
unsubProgress()
}
}, [])
// Subscribe to marked-as-read changes and rebuild reads list
useEffect(() => {
const unsubMarkedAsRead = readingProgressController.onMarkedAsReadChanged(() => {
// Rebuild reads list including marked-as-read-only items
const progressMap = readingProgressController.getProgressMap()
const readItems: ReadItem[] = Array.from(progressMap.entries()).map(([id, progress]) => ({
id,
source: 'reading-progress',
type: 'article',
readingProgress: progress,
markedAsRead: readingProgressController.isMarkedAsRead(id),
readingTimestamp: Math.floor(Date.now() / 1000)
}))
// Include items that are only marked-as-read (no progress event yet)
const markedIds = readingProgressController.getMarkedAsReadIds()
for (const id of markedIds) {
if (!readItems.find(i => i.id === id)) {
const isArticle = id.startsWith('naddr1')
readItems.push({
id,
source: 'marked-as-read',
type: isArticle ? 'article' : 'external',
url: isArticle ? undefined : id,
markedAsRead: true,
readingTimestamp: Math.floor(Date.now() / 1000)
})
}
}
const readsMap = new Map(readItems.map(item => [item.id, item]))
setReadsMap(readsMap)
setReads(readItems)
})
return () => {
unsubMarkedAsRead()
}
}, [])
// Load reading progress data for writings tab
useEffect(() => {
@@ -299,39 +256,12 @@ const Me: React.FC<MeProps> = ({
try {
if (!hasBeenLoaded) setLoading(true)
// Reads come from centralized readingProgressController (already loaded in App.tsx)
// It provides deduped reading progress per article
const progressMap = readingProgressController.getProgressMap()
// Convert progress map to ReadItems
const readItems: ReadItem[] = Array.from(progressMap.entries()).map(([id, progress]) => ({
id,
source: 'reading-progress',
type: 'article',
readingProgress: progress,
markedAsRead: readingProgressController.isMarkedAsRead(id),
readingTimestamp: Math.floor(Date.now() / 1000)
}))
// Include items that are only marked-as-read (no progress event yet)
const markedIds = readingProgressController.getMarkedAsReadIds()
for (const id of markedIds) {
if (!readItems.find(i => i.id === id)) {
const isArticle = id.startsWith('naddr1')
readItems.push({
id,
source: 'marked-as-read',
type: isArticle ? 'article' : 'external',
url: isArticle ? undefined : id,
markedAsRead: true,
readingTimestamp: Math.floor(Date.now() / 1000)
})
}
}
const readsMap = new Map(readItems.map(item => [item.id, item]))
setReadsMap(readsMap)
setReads(readItems)
// Use readsController to get reads with progressive hydration
await readsController.start({
relayPool,
eventStore,
pubkey: viewingPubkey
})
setLoadedTabs(prev => new Set(prev).add('reads'))
if (!hasBeenLoaded) setLoading(false)
@@ -341,28 +271,6 @@ const Me: React.FC<MeProps> = ({
if (!hasBeenLoaded) setLoading(false)
}
}
// Subscribe to reading progress updates
useEffect(() => {
const unsubProgress = readingProgressController.onProgress((progressMap) => {
const readItems: ReadItem[] = Array.from(progressMap.entries()).map(([id, progress]) => ({
id,
source: 'reading-progress',
type: 'article',
readingProgress: progress,
markedAsRead: readingProgressController.isMarkedAsRead(id),
readingTimestamp: Math.floor(Date.now() / 1000)
}))
const readsMap = new Map(readItems.map(item => [item.id, item]))
setReadsMap(readsMap)
setReads(readItems)
})
return () => {
unsubProgress()
}
}, [])
const loadLinksTab = async () => {
if (!viewingPubkey || !activeAccount) return
@@ -584,23 +492,14 @@ const Me: React.FC<MeProps> = ({
// Merge and flatten all individual bookmarks
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
// Apply bookmark filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter)
const groups = groupIndividualBookmarks(filteredBookmarks)
// Enrich reads and links with reading progress from controller
const readsWithProgress = reads.map(item => {
if (item.type === 'article' && item.author) {
const progress = readingProgressMap.get(item.id)
if (progress !== undefined) {
return { ...item, readingProgress: progress }
}
}
return item
})
// Enrich links with reading progress (reads already have progress from controller)
const linksWithProgress = links.map(item => {
if (item.url) {
const progress = readingProgressMap.get(item.url)
@@ -611,9 +510,60 @@ const Me: React.FC<MeProps> = ({
return item
})
// Apply reading progress filter
const filteredReads = filterByReadingProgress(readsWithProgress, readingProgressFilter, highlights)
const filteredLinks = filterByReadingProgress(linksWithProgress, readingProgressFilter, highlights)
// Apply reading progress filter with simple type separation to keep Views distinct and DRY
const filteredReads = filterByReadingProgress(
reads.filter(item => item.type === 'article'),
readingProgressFilter,
highlights
)
const filteredLinks = filterByReadingProgress(
linksWithProgress.filter(item => item.type === 'external'),
readingProgressFilter,
highlights
)
// Helper: build archive-only list from marked IDs and a base list
const buildArchiveOnly = (
baseItems: ReadItem[],
options: { kind: 'article' | 'external' }
): ReadItem[] => {
const allMarked = archiveController.getMarkedIds()
const relevantMarked = options.kind === 'article'
? allMarked.filter(id => id.startsWith('naddr1'))
: allMarked.filter(id => !id.startsWith('naddr1'))
const markedSet = new Set(relevantMarked)
const items: ReadItem[] = []
for (const item of baseItems) {
const key = options.kind === 'article' ? item.id : (item.url || item.id)
if (key && markedSet.has(key)) {
items.push({ ...item, markedAsRead: true })
}
}
for (const id of markedSet) {
const exists = items.find(i => (options.kind === 'article' ? i.id : (i.url || i.id)) === id)
if (!exists) {
items.push({
id,
source: 'marked-as-read',
type: options.kind,
url: options.kind === 'article' ? undefined : id,
markedAsRead: true,
readingTimestamp: Math.floor(Date.now() / 1000)
})
}
}
return items
}
// Archive-only lists: independent of reading progress
const archiveOnlyReads: ReadItem[] = readingProgressFilter === 'archive'
? buildArchiveOnly(reads, { kind: 'article' })
: []
const archiveOnlyLinks: ReadItem[] = readingProgressFilter === 'archive'
? buildArchiveOnly(linksWithProgress, { kind: 'external' })
: []
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
@@ -751,21 +701,42 @@ const Me: React.FC<MeProps> = ({
selectedFilter={readingProgressFilter}
onFilterChange={handleReadingProgressFilterChange}
/>
{filteredReads.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles match this filter.
</div>
{readingProgressFilter === 'archive' ? (
archiveOnlyReads.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles in archive.
</div>
) : (
<div className="explore-grid">
{archiveOnlyReads
.filter(item => item.type === 'article')
.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)
) : (
<div className="explore-grid">
{filteredReads.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
filteredReads.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles match this filter.
</div>
) : (
<div className="explore-grid">
{filteredReads.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)
)}
</>
)
@@ -798,21 +769,40 @@ const Me: React.FC<MeProps> = ({
selectedFilter={readingProgressFilter}
onFilterChange={handleReadingProgressFilterChange}
/>
{filteredLinks.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links match this filter.
</div>
{readingProgressFilter === 'archive' ? (
archiveOnlyLinks.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links in archive.
</div>
) : (
<div className="explore-grid">
{archiveOnlyLinks.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)
) : (
<div className="explore-grid">
{filteredLinks.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
filteredLinks.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links match this filter.
</div>
) : (
<div className="explore-grid">
{filteredLinks.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)
)}
</>
)

View File

@@ -0,0 +1,134 @@
import React from 'react'
import { nip19 } from 'nostr-tools'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
interface NostrMentionLinkProps {
nostrUri: string
onClick?: (e: React.MouseEvent) => void
className?: string
}
/**
* Component to render nostr mentions with resolved profile names
* Handles npub, nprofile, note, nevent, and naddr URIs
*/
const NostrMentionLink: React.FC<NostrMentionLinkProps> = ({
nostrUri,
onClick,
className = 'highlight-comment-link'
}) => {
// Decode the nostr URI first
let decoded: ReturnType<typeof nip19.decode> | null = null
let pubkey: string | undefined
try {
const identifier = nostrUri.replace(/^nostr:/, '')
decoded = nip19.decode(identifier)
// Extract pubkey for profile fetching (works for npub and nprofile)
if (decoded.type === 'npub') {
pubkey = decoded.data
} else if (decoded.type === 'nprofile') {
pubkey = decoded.data.pubkey
}
} catch (error) {
// Decoding failed, will fallback to shortened identifier
}
// Fetch profile at top level (Rules of Hooks)
const profile = useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null)
// If decoding failed, show shortened identifier
if (!decoded) {
const identifier = nostrUri.replace(/^nostr:/, '')
return (
<span className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
// Render based on decoded type
switch (decoded.type) {
case 'npub': {
const pk = decoded.data
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...`
return (
<a
href={`/p/${nip19.npubEncode(pk)}`}
className={className}
onClick={onClick}
>
@{displayName}
</a>
)
}
case 'nprofile': {
const { pubkey: pk } = decoded.data
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...`
const npub = nip19.npubEncode(pk)
return (
<a
href={`/p/${npub}`}
className={className}
onClick={onClick}
>
@{displayName}
</a>
)
}
case 'naddr': {
const { kind, pubkey: pk, identifier: addrIdentifier } = decoded.data
// Check if it's a blog post (kind:30023)
if (kind === 30023) {
const naddr = nip19.naddrEncode({ kind, pubkey: pk, identifier: addrIdentifier })
return (
<a
href={`/a/${naddr}`}
className={className}
onClick={onClick}
>
{addrIdentifier || 'Article'}
</a>
)
}
// For other kinds, show shortened identifier
return (
<span className="highlight-comment-nostr-id">
nostr:{addrIdentifier.slice(0, 12)}...
</span>
)
}
case 'note': {
const eventId = decoded.data
return (
<span className="highlight-comment-nostr-id">
note:{eventId.slice(0, 12)}...
</span>
)
}
case 'nevent': {
const { id } = decoded.data
return (
<span className="highlight-comment-nostr-id">
event:{id.slice(0, 12)}...
</span>
)
}
default: {
// Fallback for unrecognized types
const identifier = nostrUri.replace(/^nostr:/, '')
return (
<span className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
}
}
export default NostrMentionLink

View File

@@ -8,8 +8,8 @@ import { useNavigate } from 'react-router-dom'
import { HighlightItem } from './HighlightItem'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
import { fetchHighlights } from '../services/highlightService'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { getActiveRelayUrls } from '../services/relayManager'
import AuthorCard from './AuthorCard'
import BlogPostCard from './BlogPostCard'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
@@ -109,7 +109,7 @@ const Profile: React.FC<ProfileProps> = ({
})
// Fetch writings in background (no limit for single user profile)
fetchBlogPostsFromAuthors(relayPool, [pubkey], RELAYS, undefined, null)
fetchBlogPostsFromAuthors(relayPool, [pubkey], getActiveRelayUrls(relayPool), undefined, null)
.then(writings => {
writings.forEach(w => eventStore.add(w.event))
})

View File

@@ -1,10 +1,10 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookOpen, faCheckCircle, faAsterisk, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { faBook } from '@fortawesome/free-solid-svg-icons'
import { faBooks } from '../icons/customIcons'
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted' | 'emoji'
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted' | 'archive'
interface ReadingProgressFiltersProps {
selectedFilter: ReadingProgressFilterType
@@ -19,8 +19,8 @@ const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selecte
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
// Emoji-marked items (marked via reaction emoji)
{ type: 'emoji' as const, icon: faBook, label: 'Emoji' }
// Archive-marked items (previously emoji-marked)
{ type: 'archive' as const, icon: faBooks, label: 'Archive' }
]
return (
@@ -34,7 +34,7 @@ const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selecte
activeStyle = { color: '#10b981' } // green
} else if (filter.type === 'highlighted') {
activeStyle = { color: '#fde047' } // yellow
} else if (filter.type === 'emoji') {
} else if (filter.type === 'archive') {
activeStyle = { color: '#60a5fa' } // blue accent
}
}

View File

@@ -0,0 +1,77 @@
import React from 'react'
import NostrMentionLink from './NostrMentionLink'
interface RichContentProps {
content: string
className?: string
}
/**
* Component to render text content with:
* - Clickable links
* - Resolved nostr mentions (npub, nprofile, note, nevent, naddr)
* - Plain text
*
* Handles both nostr:npub1... and plain npub1... formats
*/
const RichContent: React.FC<RichContentProps> = ({
content,
className = 'bookmark-content'
}) => {
// Pattern to match:
// 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.)
// 2. Plain nostr identifiers (npub1..., nprofile1..., note1..., etc.)
// 3. http(s) URLs
const pattern = /(nostr:[a-z0-9]+|npub1[a-z0-9]+|nprofile1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|naddr1[a-z0-9]+|https?:\/\/[^\s]+)/gi
const parts = content.split(pattern)
return (
<div className={className}>
{parts.map((part, index) => {
// Handle nostr: URIs
if (part.startsWith('nostr:')) {
return (
<NostrMentionLink
key={index}
nostrUri={part}
/>
)
}
// Handle plain nostr identifiers (add nostr: prefix)
if (
part.match(/^(npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+$/i)
) {
return (
<NostrMentionLink
key={index}
nostrUri={`nostr:${part}`}
/>
)
}
// Handle http(s) URLs
if (part.match(/^https?:\/\//)) {
return (
<a
key={index}
href={part}
className="nostr-link"
target="_blank"
rel="noopener noreferrer"
>
{part}
</a>
)
}
// Plain text
return <React.Fragment key={index}>{part}</React.Fragment>
})}
</div>
)
}
export default RichContent

View File

@@ -41,7 +41,7 @@ const DEFAULT_SETTINGS: UserSettings = {
paragraphAlignment: 'justify',
syncReadingPosition: true,
autoMarkAsReadOnCompletion: false,
hideBookmarksWithoutCreationDate: false,
hideBookmarksWithoutCreationDate: true,
}
interface SettingsProps {

View File

@@ -127,7 +127,7 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
onChange={(e) => onUpdate({ autoMarkAsReadOnCompletion: e.target.checked })}
className="setting-checkbox"
/>
<span>Automatically mark as read at 100%</span>
<span>Automatically move to archive at 100%</span>
</label>
</div>

View File

@@ -11,12 +11,11 @@ export const RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.dergigi.com',
'wss://wot.dergigi.com',
'wss://relay.snort.social',
'wss://nostr-pub.wellorder.net',
'wss://purplepag.es',
'wss://relay.primal.net',
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87'
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87',
]

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useRef, Dispatch, SetStateAction } from 'react'
import { RelayPool } from 'applesauce-relay'
import { fetchArticleByNaddr } from '../services/articleService'
import { fetchHighlightsForArticle } from '../services/highlightService'
@@ -14,7 +14,7 @@ interface UseArticleLoaderProps {
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
setIsCollapsed: (collapsed: boolean) => void
setHighlights: (highlights: Highlight[]) => void
setHighlights: Dispatch<SetStateAction<Highlight[]>>
setHighlightsLoading: (loading: boolean) => void
setCurrentArticleCoordinate: (coord: string | undefined) => void
setCurrentArticleEventId: (id: string | undefined) => void
@@ -36,18 +36,26 @@ export function useArticleLoader({
setCurrentArticle,
settings
}: UseArticleLoaderProps) {
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
if (!relayPool || !naddr) return
const loadArticle = async () => {
if (!mountedRef.current) return
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
// Keep highlights panel collapsed by default - only open on user interaction
try {
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
if (!mountedRef.current) return
setReaderContent({
title: article.title,
markdown: article.markdown,
@@ -63,26 +71,24 @@ export function useArticleLoader({
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(article.event.id)
setCurrentArticle?.(article.event)
// Set reader loading to false immediately after article content is ready
// Don't wait for highlights to finish loading
setReaderLoading(false)
// Fetch highlights asynchronously without blocking article display
// Stream them as they arrive for instant rendering
try {
if (!mountedRef.current) return
setHighlightsLoading(true)
setHighlights([]) // Clear old highlights
setHighlights([])
await fetchHighlightsForArticle(
relayPool,
articleCoordinate,
article.event.id,
(highlight) => {
// Merge streaming results with existing UI state to preserve locally created highlights
setHighlights((prev) => {
if (prev.some(h => h.id === highlight.id)) return prev
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)
})
@@ -92,19 +98,29 @@ export function useArticleLoader({
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
setHighlightsLoading(false)
if (mountedRef.current) {
setHighlightsLoading(false)
}
}
} catch (err) {
console.error('Failed to load article:', err)
setReaderContent({
title: 'Error Loading Article',
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url: `nostr:${naddr}`
})
setReaderLoading(false)
if (mountedRef.current) {
setReaderContent({
title: 'Error Loading Article',
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url: `nostr:${naddr}`
})
setReaderLoading(false)
}
}
}
loadArticle()
}, [naddr, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, setCurrentArticle, settings])
return () => {
mountedRef.current = false
}
// Intentionally excluding setter functions from dependencies to prevent race conditions
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [naddr, relayPool, settings])
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'
import { useEffect, useRef, useMemo } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { fetchReadableContent, ReadableContent } from '../services/readerService'
@@ -48,6 +48,8 @@ export function useExternalUrlLoader({
setCurrentArticleCoordinate,
setCurrentArticleEventId
}: UseExternalUrlLoaderProps) {
const mountedRef = useRef(true)
// Load cached URL-specific highlights from event store
const urlFilter = useMemo(() => {
if (!url) return null
@@ -63,33 +65,37 @@ export function useExternalUrlLoader({
// Load content and start streaming highlights when URL changes
useEffect(() => {
mountedRef.current = true
if (!relayPool || !url) return
const loadExternalUrl = async () => {
if (!mountedRef.current) return
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(url)
setIsCollapsed(true)
// Clear article-specific state
setCurrentArticleCoordinate(undefined)
setCurrentArticleEventId(undefined)
try {
const content = await fetchReadableContent(url)
if (!mountedRef.current) return
setReaderContent(content)
// Set reader loading to false immediately after content is ready
setReaderLoading(false)
// Fetch highlights for this URL asynchronously
try {
if (!mountedRef.current) return
setHighlightsLoading(true)
// Seed with cached highlights first
if (cachedUrlHighlights.length > 0) {
setHighlights((prev) => {
// Seed with cache but keep any locally created highlights already in state
const seen = new Set<string>(cachedUrlHighlights.map(h => h.id))
const localOnly = prev.filter(h => !seen.has(h.id))
const next = [...cachedUrlHighlights, ...localOnly]
@@ -99,16 +105,16 @@ export function useExternalUrlLoader({
setHighlights([])
}
// Check if fetchHighlightsForUrl exists, otherwise skip
if (typeof fetchHighlightsForUrl === 'function') {
const seen = new Set<string>()
// Seed with cached IDs
cachedUrlHighlights.forEach(h => seen.add(h.id))
await fetchHighlightsForUrl(
relayPool,
url,
(highlight) => {
if (!mountedRef.current) return
if (seen.has(highlight.id)) return
seen.add(highlight.id)
setHighlights((prev) => {
@@ -117,31 +123,40 @@ export function useExternalUrlLoader({
return next.sort((a, b) => b.created_at - a.created_at)
})
},
undefined, // settings
false, // force
undefined,
false,
eventStore || undefined
)
}
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
setHighlightsLoading(false)
if (mountedRef.current) {
setHighlightsLoading(false)
}
}
} catch (err) {
console.error('Failed to load external URL:', err)
// For videos and other media files, use the filename as the title
const filename = getFilenameFromUrl(url)
setReaderContent({
title: filename,
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url
})
setReaderLoading(false)
if (mountedRef.current) {
const filename = getFilenameFromUrl(url)
setReaderContent({
title: filename,
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url
})
setReaderLoading(false)
}
}
}
loadExternalUrl()
}, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId])
return () => {
mountedRef.current = false
}
// Intentionally excluding setter functions from dependencies to prevent race conditions
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url, relayPool, eventStore, cachedUrlHighlights])
// Keep UI highlights synced with cached store updates without reloading content
useEffect(() => {
@@ -154,6 +169,8 @@ export function useExternalUrlLoader({
const next = [...additions, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
}, [cachedUrlHighlights, url, setHighlights])
// setHighlights is intentionally excluded from dependencies - it's stable
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cachedUrlHighlights, url])
}

View File

@@ -0,0 +1,28 @@
import { useRef, useEffect, useCallback } from 'react'
/**
* Hook to track if component is mounted and prevent state updates after unmount.
* Returns a function to check if still mounted.
*
* @example
* const isMounted = useMountedState()
*
* async function loadData() {
* const data = await fetch(...)
* if (isMounted()) {
* setState(data)
* }
* }
*/
export function useMountedState(): () => boolean {
const mountedRef = useRef(true)
useEffect(() => {
return () => {
mountedRef.current = false
}
}, [])
return useCallback(() => mountedRef.current, [])
}

View File

@@ -22,6 +22,7 @@ export const useReadingPosition = ({
completionHoldMs = 2000
}: UseReadingPositionOptions = {}) => {
const [position, setPosition] = useState(0)
const positionRef = useRef(0)
const [isReadingComplete, setIsReadingComplete] = useState(false)
const hasTriggeredComplete = useRef(false)
const lastSavedPosition = useRef(0)
@@ -104,6 +105,7 @@ export const useReadingPosition = ({
}
setPosition(clampedProgress)
positionRef.current = clampedProgress
onPositionChange?.(clampedProgress)
// Schedule auto-save if sync is enabled
@@ -115,7 +117,7 @@ export const useReadingPosition = ({
if (clampedProgress === 1) {
if (!completionTimerRef.current) {
completionTimerRef.current = setTimeout(() => {
if (!hasTriggeredComplete.current && position === 1) {
if (!hasTriggeredComplete.current && positionRef.current === 1) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
onReadingComplete?.()

View File

@@ -0,0 +1,197 @@
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { ARCHIVE_EMOJI } from './reactionService'
import { nip19 } from 'nostr-tools'
type MarkedChangeCallback = (markedIds: Set<string>) => void
class ArchiveController {
private markedIds: Set<string> = new Set()
private lastLoadedPubkey: string | null = null
private listeners: MarkedChangeCallback[] = []
private generation = 0
private timelineSubscription: { unsubscribe: () => void } | null = null
private pendingEventIds: Set<string> = new Set()
onMarked(cb: MarkedChangeCallback): () => void {
this.listeners.push(cb)
// Emit current state immediately to new subscribers
cb(new Set(this.markedIds))
return () => {
this.listeners = this.listeners.filter(l => l !== cb)
}
}
private emit(): void {
const snapshot = new Set(this.markedIds)
this.listeners.forEach(cb => cb(snapshot))
}
mark(id: string): void {
if (!this.markedIds.has(id)) {
this.markedIds.add(id)
this.emit()
}
}
unmark(id: string): void {
if (this.markedIds.delete(id)) {
this.emit()
}
}
isMarked(id: string): boolean {
return this.markedIds.has(id)
}
getMarkedIds(): string[] {
return Array.from(this.markedIds)
}
isLoadedFor(pubkey: string): boolean {
return this.lastLoadedPubkey === pubkey
}
reset(): void {
this.generation++
if (this.timelineSubscription) {
try { this.timelineSubscription.unsubscribe() } catch { /* ignore */ }
this.timelineSubscription = null
}
this.markedIds = new Set()
this.pendingEventIds = new Set()
this.lastLoadedPubkey = null
this.emit()
}
async start(options: {
relayPool: RelayPool
eventStore: IEventStore
pubkey: string
force?: boolean
}): Promise<void> {
const { relayPool, eventStore, pubkey, force = false } = options
const startGen = this.generation
if (!force && this.isLoadedFor(pubkey)) {
return
}
// Mark as loaded immediately (fetch runs non-blocking)
this.lastLoadedPubkey = pubkey
// Handlers for streaming queries
const handleUrlReaction = (evt: NostrEvent) => {
if (evt.content !== ARCHIVE_EMOJI) return
const rTag = evt.tags.find(t => t[0] === 'r')?.[1]
if (!rTag) return
this.markedIds.add(rTag)
this.emit()
}
const handleEventReaction = (evt: NostrEvent) => {
if (evt.content !== ARCHIVE_EMOJI) return
// Direct coordinate tag ('a') - can be mapped immediately
const aTag = evt.tags.find(t => t[0] === 'a')?.[1]
if (aTag) {
try {
const [kindStr, pubkey, identifier] = aTag.split(':')
const kind = Number(kindStr)
if (kind === KINDS.BlogPost && pubkey && identifier) {
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
this.markedIds.add(naddr)
this.emit()
return
}
} catch { /* ignore malformed a-tag */ }
}
const eTag = evt.tags.find(t => t[0] === 'e')?.[1]
if (!eTag) return
this.pendingEventIds.add(eTag)
}
try {
// Stream kind:17 and kind:7 in parallel
const [kind17, kind7] = await Promise.all([
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { onEvent: handleUrlReaction }),
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { onEvent: handleEventReaction })
])
if (startGen !== this.generation) return
// Include EOSE events
kind17.forEach(handleUrlReaction)
kind7.forEach(handleEventReaction)
if (this.pendingEventIds.size > 0) {
// Fetch referenced articles (kind:30023) and map event IDs to naddr
const ids = Array.from(this.pendingEventIds)
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids })
for (const article of articleEvents) {
const dTag = article.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) continue
try {
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: article.pubkey, identifier: dTag })
this.markedIds.add(naddr)
} catch {
// skip invalid
}
}
this.emit()
}
// Try immediate mapping via eventStore for any still-pending e-ids
if (this.pendingEventIds.size > 0) {
const stillPending = new Set<string>()
for (const eId of this.pendingEventIds) {
try {
const store = eventStore as unknown as { getEvent?: (id: string) => NostrEvent | undefined }
const evt: NostrEvent | undefined = typeof store.getEvent === 'function' ? store.getEvent(eId) : undefined
if (evt && evt.kind === KINDS.BlogPost) {
const dTag = evt.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
this.markedIds.add(naddr)
}
} else {
stillPending.add(eId)
}
} catch (e) { stillPending.add(eId) }
}
this.pendingEventIds = stillPending
if (stillPending.size > 0) {
// Subscribe to future 30023 arrivals to finalize mapping
if (this.timelineSubscription) {
try { this.timelineSubscription.unsubscribe() } catch { /* ignore */ }
this.timelineSubscription = null
}
const sub$ = eventStore.timeline({ kinds: [KINDS.BlogPost] })
const genAtSub = this.generation
this.timelineSubscription = sub$.subscribe((events: NostrEvent[]) => {
if (genAtSub !== this.generation) return
for (const evt of events) {
if (!this.pendingEventIds.has(evt.id)) continue
const dTag = evt.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) continue
try {
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
this.markedIds.add(naddr)
this.pendingEventIds.delete(evt.id)
this.emit()
} catch { /* ignore */ }
}
})
}
}
} catch (err) {
// Non-blocking fetch; ignore errors here
}
}
}
export const archiveController = new ArchiveController()

View File

@@ -62,7 +62,8 @@ export { dedupeNip51Events } from './bookmarkEvents'
export const processApplesauceBookmarks = (
bookmarks: unknown,
activeAccount: ActiveAccount,
isPrivate: boolean
isPrivate: boolean,
parentCreatedAt?: number
): IndividualBookmark[] => {
if (!bookmarks) return []
@@ -76,14 +77,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: note.id,
content: '',
created_at: Math.floor(Date.now() / 1000),
created_at: parentCreatedAt || 0,
pubkey: note.author || activeAccount.pubkey,
kind: 1, // Short note kind
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
added_at: parentCreatedAt || 0
})
})
}
@@ -96,14 +97,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: coordinate,
content: '',
created_at: Math.floor(Date.now() / 1000),
created_at: parentCreatedAt || 0,
pubkey: article.pubkey,
kind: article.kind, // Usually 30023 for long-form articles
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
added_at: parentCreatedAt || 0
})
})
}
@@ -114,14 +115,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: `hashtag-${hashtag}`,
content: `#${hashtag}`,
created_at: Math.floor(Date.now() / 1000),
created_at: parentCreatedAt || 0,
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['t', hashtag]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
added_at: parentCreatedAt || 0
})
})
}
@@ -132,14 +133,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: `url-${url}`,
content: url,
created_at: Math.floor(Date.now() / 1000),
created_at: parentCreatedAt || 0,
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['r', url]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
added_at: parentCreatedAt || 0
})
})
}
@@ -153,14 +154,14 @@ export const processApplesauceBookmarks = (
.map((bookmark: BookmarkData) => ({
id: bookmark.id!,
content: bookmark.content || '',
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
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 || Math.floor(Date.now() / 1000)
added_at: bookmark.created_at || parentCreatedAt || 0
}))
}
@@ -169,29 +170,35 @@ export function hydrateItems(
items: IndividualBookmark[],
idToEvent: Map<string, NostrEvent>
): IndividualBookmark[] {
return items.map(item => {
const ev = idToEvent.get(item.id)
if (!ev) return item
// For long-form articles (kind:30023), use the article title as content
let content = ev.content || item.content || ''
if (ev.kind === 30023) {
const articleTitle = getArticleTitle(ev)
if (articleTitle) {
content = articleTitle
return items
.map(item => {
const ev = idToEvent.get(item.id)
if (!ev) return item
// For long-form articles (kind:30023), use the article title as content
let content = ev.content || item.content || ''
if (ev.kind === 30023) {
const articleTitle = getArticleTitle(ev)
if (articleTitle) {
content = articleTitle
}
}
}
return {
...item,
pubkey: ev.pubkey || item.pubkey,
content,
created_at: ev.created_at || item.created_at,
kind: ev.kind || item.kind,
tags: ev.tags || item.tags,
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
}
})
return {
...item,
pubkey: ev.pubkey || item.pubkey,
content,
created_at: ev.created_at || item.created_at,
kind: ev.kind || item.kind,
tags: ev.tags || item.tags,
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
}
})
.filter(item => {
// Filter out bookmark list events (they're containers, not content)
const isBookmarkListEvent = item.kind === 10003 || item.kind === 30003 || item.kind === 30001
return !isBookmarkListEvent
})
}
// Note: event decryption/collection lives in `bookmarkProcessing.ts`

View File

@@ -64,7 +64,7 @@ async function decryptEvent(
const hiddenTags = JSON.parse(decryptedContent) as string[][]
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
privateItems.push(
...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({
...processApplesauceBookmarks(manualPrivate, activeAccount, true, evt.created_at).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
@@ -84,7 +84,7 @@ async function decryptEvent(
const priv = Helpers.getHiddenBookmarks(evt)
if (priv) {
privateItems.push(
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
@@ -155,7 +155,7 @@ export async function collectBookmarksFromEvents(
const pub = Helpers.getPublicBookmarks(evt)
publicItemsAll.push(
...processApplesauceBookmarks(pub, activeAccount, false).map(i => ({
...processApplesauceBookmarks(pub, activeAccount, false, evt.created_at).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
@@ -181,7 +181,7 @@ export async function collectBookmarksFromEvents(
const priv = Helpers.getHiddenBookmarks(evt)
if (priv) {
publicItemsAll.push(
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,

View File

@@ -1,9 +1,8 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { MARK_AS_READ_EMOJI } from './reactionService'
import { ARCHIVE_EMOJI } from './reactionService'
import { BlogPostPreview } from './exploreService'
import { queryEvents } from './dataFetch'
@@ -30,15 +29,15 @@ export async function fetchReadArticles(
try {
// Fetch kind:7 and kind:17 reactions in parallel
const [kind7Events, kind17Events] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }, { relayUrls: RELAYS }),
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] }, { relayUrls: RELAYS })
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }),
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] })
])
const readArticles: ReadArticle[] = []
// Process kind:7 reactions (nostr-native articles)
for (const event of kind7Events) {
if (event.content === MARK_AS_READ_EMOJI) {
if (event.content === ARCHIVE_EMOJI) {
const eTag = event.tags.find((t) => t[0] === 'e')
const pTag = event.tags.find((t) => t[0] === 'p')
const kTag = event.tags.find((t) => t[0] === 'k')
@@ -58,7 +57,7 @@ export async function fetchReadArticles(
// Process kind:17 reactions (external URLs)
for (const event of kind17Events) {
if (event.content === MARK_AS_READ_EMOJI) {
if (event.content === ARCHIVE_EMOJI) {
const rTag = event.tags.find((t) => t[0] === 'r')
if (rTag && rTag[1]) {
@@ -115,8 +114,7 @@ export async function fetchReadArticlesWithData(
const articleEvents = await queryEvents(
relayPool,
{ kinds: [KINDS.BlogPost], ids: eventIds },
{ relayUrls: RELAYS }
{ kinds: [KINDS.BlogPost], ids: eventIds }
)
// Deduplicate article events by ID

View File

@@ -1,13 +1,13 @@
import { EventFactory } from 'applesauce-factory'
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RELAYS } from '../config/relays'
import { EventFactory } from 'applesauce-factory'
import { getActiveRelayUrls } from './relayManager'
const MARK_AS_READ_EMOJI = '📚'
const ARCHIVE_EMOJI = '📚'
export { MARK_AS_READ_EMOJI }
export { ARCHIVE_EMOJI }
/**
* Creates a kind:7 reaction to a nostr event (for nostr-native articles)
@@ -23,7 +23,8 @@ export async function createEventReaction(
eventAuthor: string,
eventKind: number,
account: IAccount,
relayPool: RelayPool
relayPool: RelayPool,
options?: { aCoord?: string }
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
@@ -32,10 +33,13 @@ export async function createEventReaction(
['p', eventAuthor],
['k', eventKind.toString()]
]
if (options?.aCoord) {
tags.push(['a', options.aCoord])
}
const draft = await factory.create(async () => ({
kind: 7, // Reaction
content: MARK_AS_READ_EMOJI,
content: ARCHIVE_EMOJI,
tags,
created_at: Math.floor(Date.now() / 1000)
}))
@@ -44,7 +48,7 @@ export async function createEventReaction(
// Publish to relays
await relayPool.publish(RELAYS, signed)
await relayPool.publish(getActiveRelayUrls(relayPool), signed)
return signed
@@ -85,7 +89,7 @@ export async function createWebsiteReaction(
const draft = await factory.create(async () => ({
kind: 17, // Reaction to a website
content: MARK_AS_READ_EMOJI,
content: ARCHIVE_EMOJI,
tags,
created_at: Math.floor(Date.now() / 1000)
}))
@@ -94,12 +98,33 @@ export async function createWebsiteReaction(
// Publish to relays
await relayPool.publish(RELAYS, signed)
await relayPool.publish(getActiveRelayUrls(relayPool), signed)
return signed
}
/**
* Sends a deletion request (NIP-09) for a reaction event to effectively un-archive.
* The caller must know the reaction event id to delete.
*/
export async function deleteReaction(
reactionEventId: string,
account: IAccount,
relayPool: RelayPool
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
const draft = await factory.create(async () => ({
kind: 5, // Deletion per NIP-09
content: 'unarchive',
tags: [['e', reactionEventId]],
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
await relayPool.publish(getActiveRelayUrls(relayPool), signed)
return signed
}
/**
* Checks if the user has already marked a nostr event as read
* @param eventId The ID of the event to check
@@ -120,7 +145,7 @@ export async function hasMarkedEventAsRead(
}
const events$ = relayPool
.req(RELAYS, filter)
.req(getActiveRelayUrls(relayPool), filter)
.pipe(
onlyEvents(),
completeOnEose(),
@@ -130,8 +155,8 @@ export async function hasMarkedEventAsRead(
const events: NostrEvent[] = await lastValueFrom(events$)
// Check if any reaction has our mark-as-read emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
// Check if any reaction has our archive emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === ARCHIVE_EMOJI)
return hasReadReaction
} catch (error) {
@@ -173,7 +198,7 @@ export async function hasMarkedWebsiteAsRead(
}
const events$ = relayPool
.req(RELAYS, filter)
.req(getActiveRelayUrls(relayPool), filter)
.pipe(
onlyEvents(),
completeOnEose(),
@@ -183,8 +208,8 @@ export async function hasMarkedWebsiteAsRead(
const events: NostrEvent[] = await lastValueFrom(events$)
// Check if any reaction has our mark-as-read emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
// Check if any reaction has our archive emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === ARCHIVE_EMOJI)
return hasReadReaction
} catch (error) {

View File

@@ -3,13 +3,11 @@ import { IEventStore } from 'applesauce-core'
import { NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { processReadingProgress } from './readingDataProcessor'
import { ReadItem } from './readsService'
import { MARK_AS_READ_EMOJI } from './reactionService'
import { ARCHIVE_EMOJI } from './reactionService'
import { nip19 } from 'nostr-tools'
console.log('[readingProgress] Module loaded')
type ProgressMapCallback = (progressMap: Map<string, number>) => void
type LoadingCallback = (loading: boolean) => void
@@ -176,17 +174,14 @@ class ReadingProgressController {
const { relayPool, eventStore, pubkey, force = false } = params
const startGeneration = this.generation
console.log('[readingProgress] start() called for pubkey:', pubkey.slice(0, 16), '...', 'force:', force)
// Skip if already loaded for this pubkey and not forcing
if (!force && this.isLoadedFor(pubkey)) {
console.log('[readingProgress] Already loaded for pubkey, skipping')
return
}
// Prevent concurrent starts
if (this.isLoading) {
console.log('[readingProgress] Already loading, skipping concurrent start')
return
}
@@ -212,7 +207,6 @@ class ReadingProgressController {
this.timelineSubscription = null
}
console.log('[readingProgress] Setting up eventStore subscription...')
const timeline$ = eventStore.timeline({
kinds: [KINDS.ReadingProgress],
authors: [pubkey]
@@ -223,20 +217,17 @@ class ReadingProgressController {
if (!Array.isArray(localEvents) || localEvents.length === 0) return
this.processEvents(localEvents)
})
console.log('[readingProgress] EventStore subscription ready - updates streaming')
// Mark as loaded immediately - queries run in background non-blocking
this.lastLoadedPubkey = pubkey
// Query reading progress from relays in background (non-blocking, fire-and-forget)
console.log('[readingProgress] Starting background relay query for reading progress...')
queryEvents(relayPool, {
kinds: [KINDS.ReadingProgress],
authors: [pubkey]
}, { relayUrls: RELAYS })
})
.then((relayEvents) => {
if (startGeneration !== this.generation) return
console.log('[readingProgress] Got reading progress from relays:', relayEvents.length)
if (relayEvents.length > 0) {
relayEvents.forEach(e => eventStore.add(e))
this.processEvents(relayEvents)
@@ -249,10 +240,8 @@ class ReadingProgressController {
})
// Load mark-as-read reactions in background (non-blocking, streaming)
console.log('[readingProgress] Starting background relay query for mark-as-read reactions...')
this.loadMarkAsReadReactions(relayPool, eventStore, pubkey, startGeneration)
.then(() => {
console.log('[readingProgress] Mark-as-read reactions loading complete')
})
.catch((err) => {
console.warn('[readingProgress] Mark-as-read reactions loading failed:', err)
@@ -265,9 +254,6 @@ class ReadingProgressController {
this.setLoading(false)
}
this.isLoading = false
console.log('[readingProgress] === LOADED ===')
console.log('[readingProgress] progressMap keys:', Array.from(this.currentProgressMap.keys()))
console.log('[readingProgress] markedAsReadIds:', Array.from(this.markedAsReadIds))
}
}
@@ -318,13 +304,12 @@ class ReadingProgressController {
): Promise<void> {
try {
// Stream kind:17 (URL reactions) and kind:7 (event reactions) in parallel
console.log('[readingProgress] Querying kind:17 and kind:7 reactions (streaming)...')
const seenReactionIds = new Set<string>()
const handleUrlReaction = (evt: NostrEvent) => {
if (seenReactionIds.has(evt.id)) return
seenReactionIds.add(evt.id)
if (evt.content !== MARK_AS_READ_EMOJI) return
if (evt.content !== ARCHIVE_EMOJI) return
const rTag = evt.tags.find(t => t[0] === 'r')?.[1]
if (!rTag) return
this.markedAsReadIds.add(rTag)
@@ -335,7 +320,7 @@ class ReadingProgressController {
const handleEventReaction = (evt: NostrEvent) => {
if (seenReactionIds.has(evt.id)) return
seenReactionIds.add(evt.id)
if (evt.content !== MARK_AS_READ_EMOJI) return
if (evt.content !== ARCHIVE_EMOJI) return
const eTag = evt.tags.find(t => t[0] === 'e')?.[1]
if (!eTag) return
pendingEventIds.add(eTag)
@@ -343,8 +328,8 @@ class ReadingProgressController {
// Fire queries with onEvent callbacks for streaming behavior
const [kind17Events, kind7Events] = await Promise.all([
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleUrlReaction }),
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleEventReaction })
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { onEvent: handleUrlReaction }),
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { onEvent: handleEventReaction })
])
if (generation !== this.generation) return
@@ -356,7 +341,7 @@ class ReadingProgressController {
if (pendingEventIds.size > 0) {
// Fetch referenced 30023 events, streaming not required here
const ids = Array.from(pendingEventIds)
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids }, { relayUrls: RELAYS })
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids })
const eventIdToNaddr = new Map<string, string>()
for (const article of articleEvents) {
const dTag = article.tags.find(t => t[0] === 'd')?.[1]
@@ -379,7 +364,6 @@ class ReadingProgressController {
this.emitMarkedAsReadChanged()
}
console.log('[readingProgress] Mark-as-read reactions complete. Total:', Array.from(this.markedAsReadIds).length)
} catch (err) {
console.warn('[readingProgress] Failed to load mark-as-read reactions:', err)
}

View File

@@ -0,0 +1,276 @@
import { RelayPool } from 'applesauce-relay'
import { Helpers, IEventStore } from 'applesauce-core'
import { createAddressLoader } from 'applesauce-loaders/loaders'
import { NostrEvent } from 'nostr-tools'
import { nip19 } from 'nostr-tools'
import { merge } from 'rxjs'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { readingProgressController } from './readingProgressController'
import { archiveController } from './archiveController'
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
export interface ReadItem {
id: string // naddr coordinate
source: 'reading-progress' | 'marked-as-read' | 'bookmark'
type: 'article' | 'external'
// Article data
event?: NostrEvent
url?: string
title?: string
summary?: string
image?: string
published?: number
author?: string
// Reading metadata
readingProgress?: number // 0-1
readingTimestamp?: number // Unix timestamp of last reading activity
markedAsRead?: boolean
markedAt?: number
}
type ReadsCallback = (reads: ReadItem[]) => void
type LoadingCallback = (loading: boolean) => void
/**
* Reads controller - manages read articles with progressive hydration
* Follows the same pattern as bookmarkController
*/
class ReadsController {
private readsListeners: ReadsCallback[] = []
private loadingListeners: LoadingCallback[] = []
private currentReads: Map<string, ReadItem> = new Map()
private isLoading = false
private hydrationGeneration = 0
// Address loader for efficient batching
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
private eventStore: IEventStore | null = null
onReads(cb: ReadsCallback): () => void {
this.readsListeners.push(cb)
return () => {
this.readsListeners = this.readsListeners.filter(l => l !== cb)
}
}
onLoading(cb: LoadingCallback): () => void {
this.loadingListeners.push(cb)
return () => {
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
}
}
reset(): void {
this.hydrationGeneration++
this.currentReads.clear()
this.setLoading(false)
}
private setLoading(loading: boolean): void {
if (this.isLoading !== loading) {
this.isLoading = loading
this.loadingListeners.forEach(cb => cb(loading))
}
}
getReads(): ReadItem[] {
return Array.from(this.currentReads.values())
}
/**
* Hydrate article events by coordinates using AddressLoader (auto-batching, streaming)
*/
private hydrateArticles(
coordinates: string[],
onProgress: () => void,
generation: number
): void {
if (!this.addressLoader) {
return
}
if (coordinates.length === 0) return
// Parse coordinates into pointers
const pointers: Array<{ kind: number; pubkey: string; identifier: string }> = []
for (const coord of coordinates) {
try {
// Decode naddr to get article coordinates
if (coord.startsWith('naddr1')) {
const decoded = nip19.decode(coord)
if (decoded.type === 'naddr' && decoded.data.kind === KINDS.BlogPost) {
pointers.push({
kind: decoded.data.kind,
pubkey: decoded.data.pubkey,
identifier: decoded.data.identifier || ''
})
}
}
} catch (e) {
console.warn('Failed to decode article coordinate:', coord)
}
}
if (pointers.length === 0) return
// Use AddressLoader - it auto-batches and streams results
merge(...pointers.map(this.addressLoader)).subscribe({
next: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
// Build naddr from event
try {
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag
})
const item = this.currentReads.get(naddr)
if (item) {
// Enrich the item with article data
item.event = event
item.title = getArticleTitle(event) || 'Untitled'
item.summary = getArticleSummary(event)
item.image = getArticleImage(event)
item.published = getArticlePublished(event)
item.author = event.pubkey
// Store in event store if available
if (this.eventStore) {
this.eventStore.add(event)
}
onProgress()
}
} catch (e) {
console.warn('Failed to encode naddr for event:', event.id)
}
},
error: () => {
// Silent error - AddressLoader handles retries
}
})
}
/**
* Build ReadItems from reading progress and emit them
*/
private buildAndEmitReads(): void {
const progressMap = readingProgressController.getProgressMap()
const markedIds = Array.from(new Set([
...readingProgressController.getMarkedAsReadIds(),
...archiveController.getMarkedIds()
]))
// Build read items from progress map
const readItems: ReadItem[] = []
for (const [id, progress] of progressMap.entries()) {
const existing = this.currentReads.get(id)
const item: ReadItem = existing || {
id,
source: 'reading-progress',
type: 'article',
readingProgress: progress,
readingTimestamp: Math.floor(Date.now() / 1000)
}
// Update progress
item.readingProgress = progress
item.markedAsRead = markedIds.includes(id)
readItems.push(item)
this.currentReads.set(id, item)
}
// Include items that are only marked-as-read (no progress event yet)
for (const id of markedIds) {
if (!this.currentReads.has(id) && id.startsWith('naddr1')) {
const item: ReadItem = {
id,
source: 'marked-as-read',
type: 'article',
markedAsRead: true,
readingTimestamp: Math.floor(Date.now() / 1000)
}
readItems.push(item)
this.currentReads.set(id, item)
}
}
// Emit current state (items without article data yet)
this.readsListeners.forEach(cb => cb(Array.from(this.currentReads.values())))
// Fetch missing articles in background (progressive hydration)
const generation = this.hydrationGeneration
const onProgress = () => {
this.readsListeners.forEach(cb => cb(Array.from(this.currentReads.values())))
}
const coordinatesToFetch = readItems
.filter(item => !item.event && item.type === 'article')
.map(item => item.id)
this.hydrateArticles(coordinatesToFetch, onProgress, generation)
}
async start(options: {
relayPool: RelayPool
eventStore: IEventStore
pubkey: string
}): Promise<void> {
const { relayPool, eventStore } = options
// Increment generation to cancel any in-flight hydration
this.hydrationGeneration++
this.eventStore = eventStore
// Initialize loader for this session
this.addressLoader = createAddressLoader(relayPool, {
eventStore,
extraRelays: RELAYS
})
this.setLoading(true)
try {
// Subscribe to reading progress changes
const unsubProgress = readingProgressController.onProgress(() => {
this.buildAndEmitReads()
})
const unsubMarked = archiveController.onMarked(() => {
this.buildAndEmitReads()
})
// Build initial reads
this.buildAndEmitReads()
// Cleanup subscriptions on next start
setTimeout(() => {
unsubProgress()
unsubMarked()
}, 0)
} catch (error) {
console.error('Failed to load reads:', error)
this.readsListeners.forEach(cb => cb([]))
} finally {
this.setLoading(false)
}
}
}
// Singleton instance
export const readsController = new ReadsController()

View File

@@ -1,38 +1,20 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { Bookmark } from '../types/bookmarks'
import { fetchReadArticles } from './libraryService'
import { queryEvents } from './dataFetch'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
import { mergeReadItem } from '../utils/readItemMerge'
import type { ReadItem } from './readsController'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
export interface ReadItem {
id: string // event ID or URL or coordinate
source: 'bookmark' | 'reading-progress' | 'marked-as-read'
type: 'article' | 'external' // article=kind:30023, external=URL
// Article data
event?: NostrEvent
url?: string
title?: string
summary?: string
image?: string
published?: number
author?: string
// Reading metadata
readingProgress?: number // 0-1
readingTimestamp?: number // Unix timestamp of last reading activity
markedAsRead?: boolean
markedAt?: number
}
// Re-export ReadItem from readsController for consistency
export type { ReadItem } from './readsController'
/**
* Fetches all reads from multiple sources:
@@ -61,7 +43,7 @@ export async function fetchAllReads(
try {
// Fetch all data sources in parallel
const [progressEvents, markedAsReadArticles] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }),
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }),
fetchReadArticles(relayPool, userPubkey)
])
@@ -117,11 +99,14 @@ export async function fetchAllReads(
// Try to decode as naddr
if (coord.startsWith('naddr1')) {
const decoded = nip19.decode(coord)
if (decoded.type === 'naddr' && decoded.data.kind === KINDS.BlogPost) {
articlesToFetch.push({
pubkey: decoded.data.pubkey,
identifier: decoded.data.identifier || ''
})
if (decoded.type === 'naddr') {
const data = decoded.data as AddressPointer
if (data.kind === KINDS.BlogPost) {
articlesToFetch.push({
pubkey: data.pubkey,
identifier: data.identifier || ''
})
}
}
} else {
// Try coordinate format (kind:pubkey:identifier)
@@ -144,8 +129,7 @@ export async function fetchAllReads(
const events = await queryEvents(
relayPool,
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers },
{ relayUrls: RELAYS }
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers }
)
// Merge event data into ReadItems and emit

View File

@@ -0,0 +1,194 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch'
export interface UserRelayInfo {
url: string
mode?: 'read' | 'write' | 'both'
}
/**
* Loads user's relay list from kind 10002 (NIP-65)
*/
export async function loadUserRelayList(
relayPool: RelayPool,
pubkey: string,
options?: {
onUpdate?: (relays: UserRelayInfo[]) => void
}
): Promise<UserRelayInfo[]> {
try {
console.log('[relayListService] Loading user relay list for pubkey:', pubkey.slice(0, 16) + '...')
console.log('[relayListService] Available relays:', Array.from(relayPool.relays.keys()))
console.log('[relayListService] Starting query for kind 10002...')
const startTime = Date.now()
// Try querying with streaming callback for faster results
const events: NostrEvent[] = []
const eventsMap = new Map<string, NostrEvent>()
const result = await queryEvents(relayPool, {
kinds: [10002],
authors: [pubkey],
limit: 10
}, {
onEvent: (evt) => {
// Deduplicate by id and keep most recent
const existing = eventsMap.get(evt.id)
if (!existing || evt.created_at > existing.created_at) {
eventsMap.set(evt.id, evt)
// Update events array with deduplicated events
events.length = 0
events.push(...Array.from(eventsMap.values()))
// Stream immediate updates to caller using the newest event
if (options?.onUpdate) {
const tags = evt.tags || []
const relays: UserRelayInfo[] = []
for (const tag of tags) {
if (tag[0] === 'r' && tag[1]) {
const url = tag[1]
const mode = (tag[2] as 'read' | 'write' | undefined) || 'both'
relays.push({ url, mode })
}
}
if (relays.length > 0) {
options.onUpdate(relays)
}
}
}
}
})
// Use the streaming results if we got any, otherwise fall back to the full result
const finalEvents = events.length > 0 ? events : result
const queryTime = Date.now() - startTime
console.log('[relayListService] Query completed in', queryTime, 'ms')
// Also try a broader query to see if we get any events at all
console.log('[relayListService] Trying broader query for any kind 10002 events...')
const allEvents = await queryEvents(relayPool, {
kinds: [10002],
limit: 5
})
console.log('[relayListService] Found', allEvents.length, 'total kind 10002 events from any author')
console.log('[relayListService] Found', finalEvents.length, 'kind 10002 events')
if (finalEvents.length > 0) {
console.log('[relayListService] Event details:', finalEvents.map(e => ({ id: e.id, created_at: e.created_at, tags: e.tags.length })))
}
if (finalEvents.length === 0) return []
// Get most recent event
const sortedEvents = finalEvents.sort((a, b) => b.created_at - a.created_at)
const relayListEvent = sortedEvents[0]
const relays: UserRelayInfo[] = []
for (const tag of relayListEvent.tags) {
if (tag[0] === 'r' && tag[1]) {
const url = tag[1]
const mode = tag[2] as 'read' | 'write' | undefined
relays.push({
url,
mode: mode || 'both'
})
}
}
console.log('[relayListService] Parsed', relays.length, 'relays from event')
return relays
} catch (error) {
console.error('Failed to load user relay list:', error)
return []
}
}
/**
* Loads blocked relays from kind 10006 (NIP-51 mute list)
*/
export async function loadBlockedRelays(
relayPool: RelayPool,
pubkey: string
): Promise<string[]> {
try {
const events = await queryEvents(relayPool, {
kinds: [10006],
authors: [pubkey]
})
if (events.length === 0) return []
// Get most recent event
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const muteListEvent = sortedEvents[0]
const blocked: string[] = []
for (const tag of muteListEvent.tags) {
if (tag[0] === 'r' && tag[1]) {
blocked.push(tag[1])
}
}
return blocked
} catch (error) {
console.error('Failed to load blocked relays:', error)
return []
}
}
/**
* Computes final relay set by merging inputs and removing blocked relays
*/
export function computeRelaySet(params: {
hardcoded: string[]
bunker?: string[]
userList?: UserRelayInfo[]
blocked?: string[]
alwaysIncludeLocal: string[]
}): string[] {
const {
hardcoded,
bunker = [],
userList = [],
blocked = [],
alwaysIncludeLocal
} = params
const relaySet = new Set<string>()
const blockedSet = new Set(blocked)
// Helper to check if relay should be included
const shouldInclude = (url: string): boolean => {
// Always include local relays
if (alwaysIncludeLocal.includes(url)) return true
// Otherwise check if blocked
return !blockedSet.has(url)
}
// Add hardcoded relays
for (const url of hardcoded) {
if (shouldInclude(url)) relaySet.add(url)
}
// Add bunker relays
for (const url of bunker) {
if (shouldInclude(url)) relaySet.add(url)
}
// Add user relays (treating 'both' and 'read' as applicable for queries)
for (const relay of userList) {
if (shouldInclude(relay.url)) relaySet.add(relay.url)
}
// Always ensure local relays are present
for (const url of alwaysIncludeLocal) {
relaySet.add(url)
}
return Array.from(relaySet)
}

View File

@@ -0,0 +1,86 @@
import { RelayPool } from 'applesauce-relay'
import { prioritizeLocalRelays } from '../utils/helpers'
/**
* Local relays that are always included
*/
export const ALWAYS_LOCAL_RELAYS = [
'ws://localhost:10547',
'ws://localhost:4869'
]
/**
* Gets active relay URLs from the relay pool
*/
export function getActiveRelayUrls(relayPool: RelayPool): string[] {
const urls = Array.from(relayPool.relays.keys())
return prioritizeLocalRelays(urls)
}
/**
* Normalizes a relay URL to match what applesauce-relay stores internally
* Adds trailing slash for URLs without a path
*/
function normalizeRelayUrl(url: string): string {
try {
const parsed = new URL(url)
// If the pathname is empty or just "/", ensure it ends with "/"
if (parsed.pathname === '' || parsed.pathname === '/') {
return url.endsWith('/') ? url : url + '/'
}
return url
} catch {
// If URL parsing fails, return as-is
return url
}
}
/**
* Applies a new relay set to the pool: adds missing relays, removes extras
*/
export function applyRelaySetToPool(
relayPool: RelayPool,
finalUrls: string[]
): void {
// Normalize all URLs to match pool's internal format
const currentUrls = new Set(Array.from(relayPool.relays.keys()))
const normalizedTargetUrls = new Set(finalUrls.map(normalizeRelayUrl))
console.log('[relayManager] applyRelaySetToPool called')
console.log('[relayManager] Current pool has:', currentUrls.size, 'relays')
console.log('[relayManager] Target has:', finalUrls.length, 'relays')
// Add new relays (use original URLs for adding, not normalized)
const toAdd = finalUrls.filter(url => !currentUrls.has(normalizeRelayUrl(url)))
console.log('[relayManager] Will add:', toAdd.length, 'relays', toAdd)
if (toAdd.length > 0) {
relayPool.group(toAdd)
}
// Remove relays not in target (but always keep local relays)
const toRemove: string[] = []
for (const url of currentUrls) {
// Check if this normalized URL is in the target set
if (!normalizedTargetUrls.has(url)) {
// Also check if it's a local relay (check both normalized and original forms)
const isLocal = ALWAYS_LOCAL_RELAYS.some(localUrl =>
normalizeRelayUrl(localUrl) === url || localUrl === url
)
if (!isLocal) {
toRemove.push(url)
}
}
}
console.log('[relayManager] Will remove:', toRemove.length, 'relays', toRemove)
for (const url of toRemove) {
const relay = relayPool.relays.get(url)
if (relay) {
relay.close()
relayPool.relays.delete(url)
}
}
console.log('[relayManager] After apply, pool has:', relayPool.relays.size, 'relays')
}

View File

@@ -0,0 +1,121 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RELAYS } from '../config/relays'
import { ARCHIVE_EMOJI, deleteReaction } from './reactionService'
/**
* Returns the user's archive reactions (kind:7) for a given event id.
*/
export async function findArchiveReactionsForEvent(
eventId: string,
userPubkey: string,
relayPool: RelayPool
): Promise<NostrEvent[]> {
try {
const filter = {
kinds: [7],
authors: [userPubkey],
'#e': [eventId]
}
const events$ = relayPool
.req(RELAYS, filter)
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(2000)),
toArray()
)
const events: NostrEvent[] = await lastValueFrom(events$)
return events.filter(evt => evt.content === ARCHIVE_EMOJI)
} catch (error) {
console.error('[unarchive] findArchiveReactionsForEvent error:', error)
return []
}
}
/**
* Returns the user's archive reactions (kind:17) for a given website URL.
*/
export async function findArchiveReactionsForWebsite(
url: string,
userPubkey: string,
relayPool: RelayPool
): Promise<NostrEvent[]> {
try {
// Normalize URL same as creation
let normalizedUrl = url
try {
const parsed = new URL(url)
parsed.hash = ''
normalizedUrl = parsed.toString()
if (normalizedUrl.endsWith('/')) normalizedUrl = normalizedUrl.slice(0, -1)
} catch (e) {
console.warn('[unarchive] URL normalize failed:', e)
}
const filter = {
kinds: [17],
authors: [userPubkey],
'#r': [normalizedUrl]
}
const events$ = relayPool
.req(RELAYS, filter)
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(2000)),
toArray()
)
const events: NostrEvent[] = await lastValueFrom(events$)
return events.filter(evt => evt.content === ARCHIVE_EMOJI)
} catch (error) {
console.error('[unarchive] findArchiveReactionsForWebsite error:', error)
return []
}
}
/**
* Sends deletion requests for all of the user's archive reactions to an event.
* Returns the number of deletion requests published.
*/
export async function unarchiveEvent(
eventId: string,
account: IAccount,
relayPool: RelayPool
): Promise<number> {
try {
const reactions = await findArchiveReactionsForEvent(eventId, account.pubkey, relayPool)
await Promise.all(reactions.map(r => deleteReaction(r.id, account, relayPool)))
return reactions.length
} catch (error) {
console.error('[unarchive] unarchiveEvent error:', error)
return 0
}
}
/**
* Sends deletion requests for all of the user's archive reactions to a website URL.
* Returns the number of deletion requests published.
*/
export async function unarchiveWebsite(
url: string,
account: IAccount,
relayPool: RelayPool
): Promise<number> {
try {
const reactions = await findArchiveReactionsForWebsite(url, account.pubkey, relayPool)
await Promise.all(reactions.map(r => deleteReaction(r.id, account, relayPool)))
return reactions.length
} catch (error) {
console.error('[unarchive] unarchiveWebsite error:', error)
return 0
}
}

View File

@@ -1,9 +1,9 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
import { markEventAsOfflineCreated } from './offlineSyncService'
import { getActiveRelayUrls } from './relayManager'
/**
* Unified write helper: add event to EventStore, detect connectivity,
@@ -27,10 +27,13 @@ export async function publishEvent(
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
// Get active relay URLs from the pool
const activeRelays = getActiveRelayUrls(relayPool)
// Determine which relays we expect to succeed
const expectedSuccessRelays = hasRemoteConnection
? RELAYS
: RELAYS.filter(isLocalRelay)
? activeRelays
: activeRelays.filter(isLocalRelay)
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
@@ -42,7 +45,7 @@ export async function publishEvent(
}
// Publish to all configured relays in the background (non-blocking)
relayPool.publish(RELAYS, event)
relayPool.publish(activeRelays, event)
.then(() => {
})
.catch((error) => {

View File

@@ -14,6 +14,31 @@
50% { opacity: 1; transform: scale(1.1); }
}
/* Subtle success burst used for archive action */
@keyframes success-burst {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.45), 0 0 0 0 rgba(16, 185, 129, 0.25);
transform: scale(1);
}
60% {
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0), 0 0 0 0 rgba(16, 185, 129, 0);
transform: scale(1.05);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0), 0 0 0 0 rgba(16, 185, 129, 0);
transform: scale(1);
}
}
/* Apply archive animation when button enters animating state */
.mark-as-read-btn.animating {
animation: success-burst 600ms ease-out 1;
}
.mark-as-read-btn.animating svg {
animation: pulse 600ms ease-in-out 1;
}
@keyframes highlight-pulse-animation {
0%, 100% { box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2); transform: scale(1); }
25% { box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6); transform: scale(1.02); }

View File

@@ -2,7 +2,7 @@ import React from 'react'
import { formatDistanceToNow, differenceInSeconds, differenceInMinutes, differenceInHours, differenceInDays, differenceInMonths, differenceInYears } from 'date-fns'
import { ParsedContent, ParsedNode, IndividualBookmark } from '../types/bookmarks'
import ResolvedMention from '../components/ResolvedMention'
// Note: ContentWithResolvedProfiles is imported by components directly to keep this file component-only for fast refresh
// Note: RichContent is imported by components directly to keep this file component-only for fast refresh
export const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000)

View File

@@ -50,23 +50,23 @@ export function filterByReadingProgress(
return items.filter((item) => {
const progress = item.readingProgress || 0
const isMarked = item.markedAsRead || false
// Reading progress filters MUST ignore emoji/archive reactions
const hasHighlights = (articleHighlightCount.get(item.id) || 0) > 0 ||
(item.url && (articleHighlightCount.get(item.url) || 0) > 0)
switch (filter) {
case 'unopened':
return progress === 0 && !isMarked
return progress === 0
case 'started':
return progress > 0 && progress <= 0.10 && !isMarked
return progress > 0 && progress <= 0.10
case 'reading':
return progress > 0.10 && progress <= 0.94 && !isMarked
return progress > 0.10 && progress <= 0.94
case 'completed':
// Completed is 95%+ progress only (no emoji fallback)
return progress >= 0.95
case 'emoji':
// Emoji-marked items regardless of progress
return isMarked
case 'archive':
// Archive filter handled upstream; keep fallback as false to avoid mixing
return false
case 'highlighted':
return hasHighlights
case 'all':