mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 20:45:01 +01:00
Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa8eed4f4e | ||
|
|
3ff57c4b67 | ||
|
|
51c364ea53 | ||
|
|
4d032372dc | ||
|
|
48b5aa3a30 | ||
|
|
d4483a2f91 | ||
|
|
c62cb21962 | ||
|
|
3f7d726ae6 | ||
|
|
ac0e5eb585 | ||
|
|
5a0dd49e4e | ||
|
|
d067193f21 | ||
|
|
774e2ba67c | ||
|
|
6f1c31058f | ||
|
|
7551a05aee | ||
|
|
df485b883d | ||
|
|
6f428af1bc | ||
|
|
e821aaf058 | ||
|
|
a84d439489 | ||
|
|
67bf7e017d | ||
|
|
e47419a0b8 | ||
|
|
2dda52c30f | ||
|
|
2e0a493243 | ||
|
|
2e955e9bed | ||
|
|
538cbd2296 | ||
|
|
c17eab5a47 | ||
|
|
b3c61ba635 | ||
|
|
3bfa750a0c | ||
|
|
d1f7e549c2 | ||
|
|
0fec120410 | ||
|
|
9b21075a9b | ||
|
|
4f78ee4794 | ||
|
|
8bb871913b | ||
|
|
49eb6855ca | ||
|
|
748b2e1631 | ||
|
|
9fa83a2a1c | ||
|
|
d45705e8e4 | ||
|
|
83c170b4e2 | ||
|
|
8459853c43 | ||
|
|
f7eeb080e1 | ||
|
|
2769b2dba7 | ||
|
|
46636b8e6a | ||
|
|
92a85761ef | ||
|
|
f6a325f7e9 | ||
|
|
a501fa816f | ||
|
|
5ece80b8e9 | ||
|
|
87c017b2c2 | ||
|
|
550ee415f0 | ||
|
|
aaaf226623 | ||
|
|
23ce0c9d4c | ||
|
|
dddf8575c4 | ||
|
|
3ab0610e1e | ||
|
|
e40f820fdc | ||
|
|
3f82bc7873 | ||
|
|
b913cc4d7f | ||
|
|
bc1aed30b4 | ||
|
|
9a801975aa | ||
|
|
f3e44edd51 | ||
|
|
0be6aa81ce | ||
|
|
c7b885cfcd | ||
|
|
11041df1fb | ||
|
|
89273e2a03 | ||
|
|
0610454e74 | ||
|
|
a02413a7cb | ||
|
|
0bc84e7c6c | ||
|
|
a1e28c6bc9 | ||
|
|
a1a7f0e4a4 | ||
|
|
cde8e30ab2 | ||
|
|
aa7e532950 | ||
|
|
c9208cfff2 | ||
|
|
2fb4132342 | ||
|
|
81180c8ba8 | ||
|
|
1c48adf44e | ||
|
|
366e10b23a | ||
|
|
bb66823915 | ||
|
|
f09973c858 | ||
|
|
d03726801d | ||
|
|
164e941a1f | ||
|
|
6def58f128 | ||
|
|
347e23ff6f | ||
|
|
934768ebf2 | ||
|
|
60e9ede9cf | ||
|
|
c70e6bc2aa | ||
|
|
ab8665815b | ||
|
|
1929b50892 | ||
|
|
160dca628d | ||
|
|
c04ba0c787 | ||
|
|
479d9314bd | ||
|
|
b9d5e501f4 | ||
|
|
43e0dd76c4 | ||
|
|
dc9a49e895 | ||
|
|
3200bdf378 | ||
|
|
2254586960 | ||
|
|
18c78c19be | ||
|
|
167d5f2041 | ||
|
|
cce7507e50 | ||
|
|
e83d4dbcdb | ||
|
|
a5bdde68fc | ||
|
|
5551cc3a55 | ||
|
|
145ff138b0 | ||
|
|
5bd5686805 | ||
|
|
d2c1a16ca6 | ||
|
|
b8242312b5 | ||
|
|
96ef227f79 | ||
|
|
30ed5fb436 | ||
|
|
42d7143845 | ||
|
|
f02bc21faf | ||
|
|
0f5d42465d | ||
|
|
004367bab6 | ||
|
|
312adea9f9 | ||
|
|
a081b26333 | ||
|
|
51e48804fe | ||
|
|
e08ce0e477 | ||
|
|
2791c69ebe | ||
|
|
96451e6173 | ||
|
|
d20cc684c3 | ||
|
|
4316c46a4d |
@@ -2,4 +2,4 @@
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
Keep files below 210 lines.
|
||||
Keep files below 420 lines.
|
||||
18
.cursor/rules/fetching-data-with-controllers.mdc
Normal file
18
.cursor/rules/fetching-data-with-controllers.mdc
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
description: fetching data from relays
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
We fetch data from relays using controllers:
|
||||
- Start controllers immediatly; don’t await.
|
||||
- Stream via onEvent; dedupe replaceables; emit immediately.
|
||||
- Parallel local/remote queries; complete on EOSE.
|
||||
- Finalize and persist since after completion.
|
||||
- Guard with generations to cancel stale runs.
|
||||
- UI flips off loading on first streamed result.
|
||||
|
||||
We always include and prefer local relays for reads; optionally rebroadcast fetched content to local relays (depending on setting); and tolerate local‑only mode for writes (queueing for later).
|
||||
|
||||
Since we are streaming results, we should NEVER use timeouts for fetching data. We should always rely on EOSE.
|
||||
|
||||
In short: Local-first hydration, background network fetch, reactive updates, and replaceable lookups provide instant UI with eventual consistency. Use local relays as local data store for everything we fetch from remote relays.
|
||||
138
CHANGELOG.md
138
CHANGELOG.md
@@ -7,6 +7,134 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.10.12] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- Person hiking icon (fa-person-hiking) for explore navigation
|
||||
|
||||
### Changed
|
||||
|
||||
- Explore icon changed from newspaper to person hiking for better semantic meaning
|
||||
- Settings button moved before explore button in sidebar navigation
|
||||
- Profile avatar button now uses 44px touch target on mobile (matches other icon buttons)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Web bookmarks (kind:39701) now properly deduplicate by d-tag
|
||||
- Same URL bookmarked multiple times now only appears once
|
||||
- Web bookmark IDs use coordinate format (kind:pubkey:d-tag) for consistent deduplication
|
||||
- Profile avatar button sizing on mobile now matches other IconButton components
|
||||
- Removed all console.log statements from bookmarkController and bookmarkProcessing
|
||||
|
||||
## [0.10.11] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- Clock icon for chronological bookmark view
|
||||
- Clickable highlight count to open highlights sidebar
|
||||
- Dynamic bookmark filter titles based on selected filter
|
||||
- Profile picture moved to first position (left-aligned) with consistent sizing
|
||||
|
||||
### Changed
|
||||
|
||||
- Default bookmark view changed to flat chronological list (newest first)
|
||||
- Bookmark URL changed from `/me/reading-list` to `/me/bookmarks`
|
||||
- Router updated to handle `/me/reading-list` → `/me/bookmarks` redirect
|
||||
- Me.tsx bookmarks tab now uses dynamic filter titles and chronological sorting
|
||||
- Me.tsx updated to use faClock icon instead of faBars
|
||||
- Removed bookmark count from section headings for cleaner display
|
||||
- Hide close/collapse sidebar buttons on mobile for better UX
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bookmark sorting now uses proper display time (created_at || listUpdatedAt) with nulls last
|
||||
- Robust sorting of merged bookmarks with fallback timestamps
|
||||
- Corrected bookmark timestamp to use bookmark list creation time, not content creation time
|
||||
- Preserved content created_at while adding listUpdatedAt for proper sorting
|
||||
- Removed synthetic added_at field, now uses created_at from bookmark list event
|
||||
- Consistent chronological sorting with useMemo optimization
|
||||
- Removed unused faTimes import
|
||||
- Bookmark timestamps now show sane dates using created_at fallback to listUpdatedAt
|
||||
- Guarded formatters to prevent timestamp display errors
|
||||
|
||||
### Refactored
|
||||
|
||||
- Removed excessive debug logging for cleaner console output
|
||||
- Bookmark timestamp handling never defaults to "now", allows nulls and sorts nulls last
|
||||
- Renders empty when timestamp is missing instead of showing invalid dates
|
||||
|
||||
## [0.10.10] - 2025-10-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Version bump for consistency (no user-facing changes)
|
||||
|
||||
## [0.10.9] - 2025-10-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- Event fetching reliability with exponential backoff in eventManager
|
||||
- Improved retry logic with incremental backoff delays
|
||||
- Better handling of concurrent event requests
|
||||
- More robust event retrieval from relay pool
|
||||
- Bookmark timestamp handling
|
||||
- Use per-item `added_at`/`created_at` timestamps when available
|
||||
- Improves accuracy of bookmark date tracking
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed all debug console logs
|
||||
- Cleaner console output in development and production
|
||||
- Improved performance by eliminating debugging statements
|
||||
|
||||
## [0.10.8] - 2025-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- Individual event rendering via `/e/:eventId` path
|
||||
- Display `kind:1` notes and other events with article-like presentation
|
||||
- Publication date displayed in top-right corner like articles
|
||||
- Author attribution with "Note by @author" titles
|
||||
- Direct event loading with intelligent caching from eventStore
|
||||
- Centralized event fetching via new `eventManager` singleton
|
||||
- Request deduplication for concurrent fetches
|
||||
- Automatic retry logic when relay pool becomes available
|
||||
- Non-blocking background fetching with 12-second timeout
|
||||
- Seamless integration with eventStore for instant cached event display
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bookmark hydration efficiency
|
||||
- Only request content for bookmarks missing data (not all bookmarks)
|
||||
- Use eventStore fallback for instant display of cached profiles
|
||||
- Prevents over-fetching and improves initial load performance
|
||||
- Search button behavior for notes
|
||||
- Opens `kind:1` notes directly via `/e/{eventId}` instead of search portal
|
||||
- Articles continue to use search portal with proper naddr encoding
|
||||
- Removes unwanted `nostr-event:` prefix from URLs
|
||||
- Author profile resolution
|
||||
- Fetch author profiles from eventStore cache first before relay requests
|
||||
- Instant title updates if profile already loaded
|
||||
- Graceful fallback to short pubkey display if profile unavailable
|
||||
|
||||
## [0.10.7] - 2025-10-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- Profile pages now display all writings correctly
|
||||
- Events are now stored in eventStore as they stream in from relays
|
||||
- `fetchBlogPostsFromAuthors` now accepts `eventStore` parameter like other fetch functions
|
||||
- Ensures all writings appear on `/p/` routes, not just the first few
|
||||
- Background fetching of highlights and writings uses consistent patterns
|
||||
|
||||
### Changed
|
||||
|
||||
- Simplified profile background fetching logic for better maintainability
|
||||
- Extracted relay URLs to variable for clarity
|
||||
- Consistent error handling patterns across fetch functions
|
||||
- Clearer comments about no-limit fetching behavior
|
||||
|
||||
## [0.10.6] - 2025-10-21
|
||||
|
||||
### Added
|
||||
@@ -2371,7 +2499,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.10.4...HEAD
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.10.12...HEAD
|
||||
[0.10.12]: https://github.com/dergigi/boris/compare/v0.10.11...v0.10.12
|
||||
[0.10.11]: https://github.com/dergigi/boris/compare/v0.10.10...v0.10.11
|
||||
[0.10.10]: https://github.com/dergigi/boris/compare/v0.10.9...v0.10.10
|
||||
[0.10.9]: https://github.com/dergigi/boris/compare/v0.10.8...v0.10.9
|
||||
[0.10.8]: https://github.com/dergigi/boris/compare/v0.10.7...v0.10.8
|
||||
[0.10.7]: https://github.com/dergigi/boris/compare/v0.10.6...v0.10.7
|
||||
[0.10.6]: https://github.com/dergigi/boris/compare/v0.10.5...v0.10.6
|
||||
[0.10.5]: https://github.com/dergigi/boris/compare/v0.10.4...v0.10.5
|
||||
[0.10.4]: https://github.com/dergigi/boris/compare/v0.10.3...v0.10.4
|
||||
[0.10.3]: https://github.com/dergigi/boris/compare/v0.10.2...v0.10.3
|
||||
[0.10.2]: https://github.com/dergigi/boris/compare/v0.10.1...v0.10.2
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.10.5",
|
||||
"version": "0.10.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.10.5",
|
||||
"version": "0.10.9",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.10.7",
|
||||
"version": "0.10.13",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
24
src/App.tsx
24
src/App.tsx
@@ -21,7 +21,7 @@ import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||
import { RELAYS } from './config/relays'
|
||||
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||
import { loadUserRelayList, loadBlockedRelays, computeRelaySet } from './services/relayListService'
|
||||
import { applyRelaySetToPool, getActiveRelayUrls, ALWAYS_LOCAL_RELAYS } from './services/relayManager'
|
||||
import { applyRelaySetToPool, getActiveRelayUrls, ALWAYS_LOCAL_RELAYS, HARDCODED_RELAYS } from './services/relayManager'
|
||||
import { Bookmark } from './types/bookmarks'
|
||||
import { bookmarkController } from './services/bookmarkController'
|
||||
import { contactsController } from './services/contactsController'
|
||||
@@ -95,7 +95,7 @@ function AppRoutes({
|
||||
|
||||
// Load bookmarks
|
||||
if (bookmarks.length === 0 && !bookmarksLoading) {
|
||||
bookmarkController.start({ relayPool, activeAccount, accountManager })
|
||||
bookmarkController.start({ relayPool, activeAccount, accountManager, eventStore: eventStore || undefined })
|
||||
}
|
||||
|
||||
// Load contacts
|
||||
@@ -253,7 +253,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/reading-list"
|
||||
path="/me/bookmarks"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -348,6 +348,18 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/e/:eventId"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/debug"
|
||||
element={
|
||||
@@ -566,8 +578,6 @@ function App() {
|
||||
|
||||
// Handle user relay list and blocked relays when account changes
|
||||
const userRelaysSub = accounts.active$.subscribe((account) => {
|
||||
console.log('[relay-init] userRelaysSub fired, account:', account ? 'logged in' : 'logged out')
|
||||
console.log('[relay-init] Pool has', Array.from(pool.relays.keys()).length, 'relays before applying changes')
|
||||
if (account) {
|
||||
// User logged in - start with hardcoded relays immediately, then stream user relay list updates
|
||||
const pubkey = account.pubkey
|
||||
@@ -615,7 +625,7 @@ function App() {
|
||||
loadUserRelayList(pool, pubkey, {
|
||||
onUpdate: (userRelays) => {
|
||||
const interimRelays = computeRelaySet({
|
||||
hardcoded: [],
|
||||
hardcoded: HARDCODED_RELAYS,
|
||||
bunker: bunkerRelays,
|
||||
userList: userRelays,
|
||||
blocked: [],
|
||||
@@ -629,7 +639,7 @@ function App() {
|
||||
const blockedRelays = await blockedPromise.catch(() => [])
|
||||
|
||||
const finalRelays = computeRelaySet({
|
||||
hardcoded: userRelayList.length > 0 ? [] : RELAYS,
|
||||
hardcoded: userRelayList.length > 0 ? HARDCODED_RELAYS : RELAYS,
|
||||
bunker: bunkerRelays,
|
||||
userList: userRelayList,
|
||||
blocked: blockedRelays,
|
||||
|
||||
@@ -50,6 +50,14 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
state={{
|
||||
previewData: {
|
||||
title: post.title,
|
||||
image: post.image,
|
||||
summary: post.summary,
|
||||
published: post.published
|
||||
}
|
||||
}}
|
||||
className={`blog-post-card ${level ? `level-${level}` : ''}`}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import React, { useRef, useState, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faClock } from '@fortawesome/free-regular-svg-icons'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
@@ -13,7 +14,7 @@ import { ViewMode } from './Bookmarks'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { BookmarkSkeleton } from './Skeletons'
|
||||
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate } from '../utils/bookmarkUtils'
|
||||
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import AddBookmarkModal from './AddBookmarkModal'
|
||||
import { createWebBookmark } from '../services/webBookmarkService'
|
||||
@@ -71,7 +72,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
||||
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
||||
const saved = localStorage.getItem('bookmarkGroupingMode')
|
||||
return saved === 'flat' ? 'flat' : 'grouped'
|
||||
return saved === 'grouped' ? 'grouped' : 'flat'
|
||||
})
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||
@@ -120,6 +121,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
localStorage.setItem('bookmarkGroupingMode', newMode)
|
||||
}
|
||||
|
||||
const getFilterTitle = (filter: BookmarkFilterType): string => {
|
||||
const titles: Record<BookmarkFilterType, string> = {
|
||||
'all': 'All Bookmarks',
|
||||
'article': 'Bookmarked Reads',
|
||||
'external': 'Bookmarked Links',
|
||||
'video': 'Bookmarked Videos',
|
||||
'note': 'Bookmarked Notes',
|
||||
'web': 'Web Bookmarks'
|
||||
}
|
||||
return titles[filter]
|
||||
}
|
||||
|
||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
throw new Error('Please login to create bookmarks')
|
||||
@@ -140,39 +153,58 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
isDisabled: !onRefresh
|
||||
})
|
||||
|
||||
// Merge and flatten all individual bookmarks from all lists
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContent)
|
||||
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
|
||||
// Merge and flatten all individual bookmarks from all lists - memoized to ensure consistent sorting
|
||||
const sections = useMemo(() => {
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContent)
|
||||
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
|
||||
|
||||
// Apply filter
|
||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
||||
|
||||
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
||||
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
||||
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
||||
|
||||
// Group non-set bookmarks by source or flatten based on mode
|
||||
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
||||
const sectionsArray: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||
groupingMode === 'flat'
|
||||
? [{ key: 'all', title: getFilterTitle(selectedFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
|
||||
: [
|
||||
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
||||
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
||||
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
|
||||
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
|
||||
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
|
||||
]
|
||||
|
||||
// Add bookmark sets as additional sections (only in grouped mode)
|
||||
if (groupingMode === 'grouped') {
|
||||
bookmarkSets.forEach(set => {
|
||||
sectionsArray.push({
|
||||
key: `set-${set.name}`,
|
||||
title: set.title || set.name,
|
||||
items: set.bookmarks
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return sectionsArray
|
||||
}, [bookmarks, selectedFilter, groupingMode, settings?.hideBookmarksWithoutCreationDate])
|
||||
|
||||
// Apply filter
|
||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
||||
// Get all filtered bookmarks for empty state checks
|
||||
const allIndividualBookmarks = useMemo(() =>
|
||||
bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContent)
|
||||
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b)),
|
||||
[bookmarks, settings?.hideBookmarksWithoutCreationDate]
|
||||
)
|
||||
|
||||
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
||||
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
||||
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
||||
|
||||
// Group non-set bookmarks by source or flatten based on mode
|
||||
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||
groupingMode === 'flat'
|
||||
? [{ key: 'all', title: `All Bookmarks (${bookmarksWithoutSet.length})`, items: bookmarksWithoutSet }]
|
||||
: [
|
||||
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
||||
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
||||
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
|
||||
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
|
||||
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
|
||||
]
|
||||
|
||||
// Add bookmark sets as additional sections
|
||||
bookmarkSets.forEach(set => {
|
||||
sections.push({
|
||||
key: `set-${set.name}`,
|
||||
title: set.title || set.name,
|
||||
items: set.bookmarks
|
||||
})
|
||||
})
|
||||
const filteredBookmarks = useMemo(() =>
|
||||
filterBookmarksByType(allIndividualBookmarks, selectedFilter),
|
||||
[allIndividualBookmarks, selectedFilter]
|
||||
)
|
||||
|
||||
if (isCollapsed) {
|
||||
// Check if the selected URL is in bookmarks
|
||||
@@ -286,7 +318,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
{activeAccount && (
|
||||
<div className="view-mode-right">
|
||||
<IconButton
|
||||
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
|
||||
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
|
||||
onClick={toggleGroupingMode}
|
||||
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||
|
||||
@@ -112,10 +112,10 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
title="Open event in search"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at)}
|
||||
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
|
||||
</a>
|
||||
) : (
|
||||
<span className="bookmark-date">{formatDate(bookmark.created_at)}</span>
|
||||
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
@@ -26,11 +27,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
contentTypeIcon,
|
||||
readingProgress
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
const isClickable = hasUrls || isArticle || isWebBookmark
|
||||
const isNote = bookmark.kind === 1
|
||||
const isClickable = hasUrls || isArticle || isWebBookmark || isNote
|
||||
|
||||
// Calculate progress color (matching BlogPostCard logic)
|
||||
const displayText = isArticle && articleSummary ? articleSummary : bookmark.content
|
||||
|
||||
// Calculate progress color
|
||||
let progressColor = '#6366f1' // Default blue (reading)
|
||||
if (readingProgress && readingProgress >= 0.95) {
|
||||
progressColor = '#10b981' // Green (completed)
|
||||
@@ -39,20 +44,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
}
|
||||
|
||||
const handleCompactClick = () => {
|
||||
if (!onSelectUrl) return
|
||||
|
||||
if (isArticle) {
|
||||
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||
onSelectUrl?.('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||
} else if (hasUrls) {
|
||||
onSelectUrl(extractedUrls[0])
|
||||
onSelectUrl?.(extractedUrls[0])
|
||||
} else if (isNote) {
|
||||
navigate(`/e/${bookmark.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// For articles, prefer summary; for others, use content
|
||||
const displayText = isArticle && articleSummary
|
||||
? articleSummary
|
||||
: bookmark.content
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
<div
|
||||
@@ -64,12 +64,16 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
<span className="bookmark-type-compact">
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
</span>
|
||||
{displayText && (
|
||||
{displayText ? (
|
||||
<div className="compact-text">
|
||||
<RichContent content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} className="" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="compact-text" style={{ opacity: 0.5, fontSize: '0.85em' }}>
|
||||
<code>{bookmark.id.slice(0, 12)}...</code>
|
||||
</div>
|
||||
)}
|
||||
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
||||
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||
{/* CTA removed */}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
className="bookmark-date-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at)}
|
||||
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
|
||||
</a>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
|
||||
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||
import { useEventLoader } from '../hooks/useEventLoader'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import ThreePaneLayout from './ThreePaneLayout'
|
||||
import Explore from './Explore'
|
||||
@@ -38,7 +39,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
bookmarksLoading,
|
||||
onRefreshBookmarks
|
||||
}) => {
|
||||
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
|
||||
const { naddr, npub, eventId: eventIdParam } = useParams<{ naddr?: string; npub?: string; eventId?: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const previousLocationRef = useRef<string>()
|
||||
@@ -55,6 +56,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
const showMe = location.pathname.startsWith('/me')
|
||||
const showProfile = location.pathname.startsWith('/p/')
|
||||
const showSupport = location.pathname === '/support'
|
||||
const eventId = eventIdParam
|
||||
|
||||
// Extract tab from explore routes
|
||||
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
||||
@@ -62,7 +64,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
// Extract tab from me routes
|
||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||
location.pathname === '/me/highlights' ? 'highlights' :
|
||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
||||
location.pathname === '/me/bookmarks' ? 'bookmarks' :
|
||||
location.pathname.startsWith('/me/reads') ? 'reads' :
|
||||
location.pathname.startsWith('/me/links') ? 'links' :
|
||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||
@@ -228,6 +230,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
useArticleLoader({
|
||||
naddr,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
@@ -255,6 +258,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
setCurrentArticleEventId
|
||||
})
|
||||
|
||||
// Load event if /e/:eventId route is used
|
||||
useEventLoader({
|
||||
eventId,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed
|
||||
})
|
||||
|
||||
// Classify highlights with levels based on user context
|
||||
const classifiedHighlights = useMemo(() => {
|
||||
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
||||
|
||||
@@ -43,8 +43,8 @@ import { EventFactory } from 'applesauce-factory'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import {
|
||||
generateArticleIdentifier,
|
||||
loadReadingPosition,
|
||||
saveReadingPosition
|
||||
saveReadingPosition,
|
||||
startReadingPositionStream
|
||||
} from '../services/readingPositionService'
|
||||
import TTSControls from './TTSControls'
|
||||
|
||||
@@ -76,6 +76,7 @@ interface ContentPanelProps {
|
||||
// For reading progress indicator positioning
|
||||
isSidebarCollapsed?: boolean
|
||||
isHighlightsCollapsed?: boolean
|
||||
onOpenHighlights?: () => void
|
||||
}
|
||||
|
||||
const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
@@ -103,7 +104,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
onTextSelection,
|
||||
onClearSelection,
|
||||
isSidebarCollapsed = false,
|
||||
isHighlightsCollapsed = false
|
||||
isHighlightsCollapsed = false,
|
||||
onOpenHighlights
|
||||
}) => {
|
||||
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
|
||||
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
||||
@@ -132,6 +134,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
currentUserPubkey,
|
||||
followedPubkeys
|
||||
})
|
||||
// Key used to force re-mount of markdown preview/render when content changes
|
||||
const contentKey = useMemo(() => {
|
||||
// Prefer selectedUrl as a stable per-article key; fallback to title+length
|
||||
return selectedUrl || `${title || ''}:${(markdown || html || '').length}`
|
||||
}, [selectedUrl, title, markdown, html])
|
||||
|
||||
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
|
||||
onHighlightClick,
|
||||
@@ -207,7 +214,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
useEffect(() => {
|
||||
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
|
||||
|
||||
// Load saved reading position when article loads
|
||||
// Load saved reading position when article loads (non-blocking, EOSE-driven)
|
||||
useEffect(() => {
|
||||
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||
return
|
||||
@@ -216,15 +223,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
const loadPosition = async () => {
|
||||
try {
|
||||
const savedPosition = await loadReadingPosition(
|
||||
relayPool,
|
||||
eventStore,
|
||||
activeAccount.pubkey,
|
||||
articleIdentifier
|
||||
)
|
||||
|
||||
const stop = startReadingPositionStream(
|
||||
relayPool,
|
||||
eventStore,
|
||||
activeAccount.pubkey,
|
||||
articleIdentifier,
|
||||
(savedPosition) => {
|
||||
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
||||
// Wait for content to be fully rendered before scrolling
|
||||
setTimeout(() => {
|
||||
@@ -237,19 +241,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}, 500) // Give content time to render
|
||||
} else if (savedPosition) {
|
||||
if (savedPosition.position === 1) {
|
||||
// Article was completed, start from top
|
||||
} else {
|
||||
// Position was too early, skip restore
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [ContentPanel] Failed to load reading position:', error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
loadPosition()
|
||||
return () => stop()
|
||||
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||
|
||||
// Save position before unmounting or changing article
|
||||
@@ -485,7 +481,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
}
|
||||
|
||||
const handleOpenSearch = () => {
|
||||
if (articleLinks) {
|
||||
// For regular notes (kind:1), open via /e/ path
|
||||
if (currentArticle?.kind === 1) {
|
||||
const borisUrl = `${window.location.origin}/e/${currentArticle.id}`
|
||||
window.open(borisUrl, '_blank', 'noopener,noreferrer')
|
||||
} else if (articleLinks) {
|
||||
// For articles, use search portal
|
||||
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
setShowArticleMenu(false)
|
||||
@@ -572,7 +573,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
const handleSearchExternalUrl = () => {
|
||||
if (selectedUrl) {
|
||||
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
|
||||
// If it's a nostr event sentinel, open the event directly on ants.sh
|
||||
if (selectedUrl.startsWith('nostr-event:')) {
|
||||
const eventId = selectedUrl.replace('nostr-event:', '')
|
||||
window.open(`https://ants.sh/e/${eventId}`, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
@@ -749,7 +756,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
||||
{/* Hidden markdown preview to convert markdown to HTML */}
|
||||
{markdown && (
|
||||
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
||||
<div ref={markdownPreviewRef} key={`preview:${contentKey}`} style={{ display: 'none' }}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypePrism]}
|
||||
@@ -778,6 +785,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
settings={settings}
|
||||
highlights={relevantHighlights}
|
||||
highlightVisibility={highlightVisibility}
|
||||
onHighlightCountClick={onOpenHighlights}
|
||||
/>
|
||||
{isTextContent && articleText && (
|
||||
<div style={{ padding: '0 0.75rem 0.5rem 0.75rem' }}>
|
||||
@@ -869,6 +877,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
{markdown ? (
|
||||
renderedMarkdownHtml && finalHtml ? (
|
||||
<VideoEmbedProcessor
|
||||
key={`content:${contentKey}`}
|
||||
ref={contentRef}
|
||||
html={finalHtml}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||
@@ -885,6 +894,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
)
|
||||
) : (
|
||||
<VideoEmbedProcessor
|
||||
key={`content:${contentKey}`}
|
||||
ref={contentRef}
|
||||
html={finalHtml || html || ''}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||
@@ -922,13 +932,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy URL</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Original</span>
|
||||
</button>
|
||||
{/* Only show "Open Original" for actual external URLs, not nostr events */}
|
||||
{!selectedUrl?.startsWith('nostr-event:') && (
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Original</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleSearchExternalUrl}
|
||||
|
||||
@@ -781,9 +781,16 @@ const Debug: React.FC<DebugProps> = ({
|
||||
}
|
||||
})
|
||||
|
||||
// Load deduplicated results via controller
|
||||
// Load deduplicated results via controller (includes articles and external URLs)
|
||||
const unsubProgress = readingProgressController.onProgress((progressMap) => {
|
||||
setDeduplicatedProgressMap(new Map(progressMap))
|
||||
|
||||
// Regression guard: ensure keys include both naddr and raw URL forms when present
|
||||
try {
|
||||
const keys = Array.from(progressMap.keys())
|
||||
const sample = keys.slice(0, 5).join(', ')
|
||||
DebugBus.info('debug', `Progress keys sample: ${sample}`)
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
|
||||
// Run both in parallel
|
||||
|
||||
1
src/components/EventViewer.tsx
Normal file
1
src/components/EventViewer.tsx
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faPersonHiking, faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import IconButton from './IconButton'
|
||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
@@ -523,8 +523,10 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
)
|
||||
}
|
||||
return filteredBlogPosts.length === 0 ? (
|
||||
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<BlogPostSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -584,7 +586,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
/>
|
||||
<div className="explore-header">
|
||||
<h1>
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
<FontAwesomeIcon icon={faPersonHiking} />
|
||||
Explore
|
||||
</h1>
|
||||
|
||||
@@ -656,7 +658,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key={activeTab}>
|
||||
<div>
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,7 @@ interface HighlightsPanelProps {
|
||||
relayPool?: RelayPool | null
|
||||
eventStore?: IEventStore | null
|
||||
settings?: UserSettings
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
@@ -56,7 +57,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
followedPubkeys = new Set(),
|
||||
relayPool,
|
||||
eventStore,
|
||||
settings
|
||||
settings,
|
||||
isMobile = false
|
||||
}) => {
|
||||
const [showHighlights, setShowHighlights] = useState(true)
|
||||
const [localHighlights, setLocalHighlights] = useState(highlights)
|
||||
@@ -125,6 +127,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
onRefresh={onRefresh}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onHighlightVisibilityChange={onHighlightVisibilityChange}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{loading && filteredHighlights.length === 0 ? (
|
||||
|
||||
@@ -13,6 +13,7 @@ interface HighlightsPanelHeaderProps {
|
||||
onRefresh?: () => void
|
||||
onToggleCollapse: () => void
|
||||
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
@@ -24,7 +25,8 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
onToggleHighlights,
|
||||
onRefresh,
|
||||
onToggleCollapse,
|
||||
onHighlightVisibilityChange
|
||||
onHighlightVisibilityChange,
|
||||
isMobile = false
|
||||
}) => {
|
||||
return (
|
||||
<div className="highlights-header">
|
||||
@@ -101,14 +103,16 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<IconButton
|
||||
icon={faChevronRight}
|
||||
onClick={onToggleCollapse}
|
||||
title="Collapse highlights panel"
|
||||
ariaLabel="Collapse highlights panel"
|
||||
variant="ghost"
|
||||
style={{ transform: 'rotate(180deg)' }}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<IconButton
|
||||
icon={faChevronRight}
|
||||
onClick={onToggleCollapse}
|
||||
title="Collapse highlights panel"
|
||||
ariaLabel="Collapse highlights panel"
|
||||
variant="ghost"
|
||||
style={{ transform: 'rotate(180deg)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faClock } from '@fortawesome/free-regular-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||
@@ -23,7 +24,7 @@ import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { groupIndividualBookmarks, hasContent, hasCreationDate } from '../utils/bookmarkUtils'
|
||||
import { groupIndividualBookmarks, hasContent, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
|
||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
||||
@@ -42,7 +43,7 @@ interface MeProps {
|
||||
settings: UserSettings
|
||||
}
|
||||
|
||||
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
||||
type TabType = 'highlights' | 'bookmarks' | 'reads' | 'links' | 'writings'
|
||||
|
||||
// Valid reading progress filters
|
||||
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted', 'archive']
|
||||
@@ -229,9 +230,9 @@ const Me: React.FC<MeProps> = ({
|
||||
if (!viewingPubkey || !activeAccount) return
|
||||
|
||||
setLoadedTabs(prev => {
|
||||
const hasBeenLoaded = prev.has('reading-list')
|
||||
const hasBeenLoaded = prev.has('bookmarks')
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
return new Set(prev).add('reading-list')
|
||||
return new Set(prev).add('bookmarks')
|
||||
})
|
||||
|
||||
// Always turn off loading after a tick
|
||||
@@ -334,7 +335,7 @@ const Me: React.FC<MeProps> = ({
|
||||
case 'writings':
|
||||
loadWritingsTab()
|
||||
break
|
||||
case 'reading-list':
|
||||
case 'bookmarks':
|
||||
loadReadingListTab()
|
||||
break
|
||||
case 'reads':
|
||||
@@ -418,7 +419,7 @@ const Me: React.FC<MeProps> = ({
|
||||
const mockEvent = {
|
||||
id: item.id,
|
||||
pubkey: item.author || '',
|
||||
created_at: item.readingTimestamp || Math.floor(Date.now() / 1000),
|
||||
created_at: item.readingTimestamp || 0,
|
||||
kind: 1,
|
||||
tags: [] as string[][],
|
||||
content: item.title || item.url || 'Untitled',
|
||||
@@ -565,9 +566,21 @@ const Me: React.FC<MeProps> = ({
|
||||
? buildArchiveOnly(linksWithProgress, { kind: 'external' })
|
||||
: []
|
||||
|
||||
const getFilterTitle = (filter: BookmarkFilterType): string => {
|
||||
const titles: Record<BookmarkFilterType, string> = {
|
||||
'all': 'All Bookmarks',
|
||||
'article': 'Bookmarked Reads',
|
||||
'external': 'Bookmarked Links',
|
||||
'video': 'Bookmarked Videos',
|
||||
'note': 'Bookmarked Notes',
|
||||
'web': 'Web Bookmarks'
|
||||
}
|
||||
return titles[filter]
|
||||
}
|
||||
|
||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||
groupingMode === 'flat'
|
||||
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
|
||||
? [{ key: 'all', title: getFilterTitle(bookmarkFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
|
||||
: [
|
||||
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
||||
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
||||
@@ -609,7 +622,7 @@ const Me: React.FC<MeProps> = ({
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'reading-list':
|
||||
case 'bookmarks':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="bookmarks-list">
|
||||
@@ -664,7 +677,7 @@ const Me: React.FC<MeProps> = ({
|
||||
borderTop: '1px solid var(--border-color)'
|
||||
}}>
|
||||
<IconButton
|
||||
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
|
||||
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
|
||||
onClick={toggleGroupingMode}
|
||||
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||
@@ -860,9 +873,9 @@ const Me: React.FC<MeProps> = ({
|
||||
<span className="tab-label">Highlights</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
|
||||
data-tab="reading-list"
|
||||
onClick={() => navigate('/me/reading-list')}
|
||||
className={`me-tab ${activeTab === 'bookmarks' ? 'active' : ''}`}
|
||||
data-tab="bookmarks"
|
||||
onClick={() => navigate('/me/bookmarks')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
<span className="tab-label">Bookmarks</span>
|
||||
|
||||
@@ -20,6 +20,7 @@ interface ReaderHeaderProps {
|
||||
settings?: UserSettings
|
||||
highlights?: Highlight[]
|
||||
highlightVisibility?: HighlightVisibility
|
||||
onHighlightCountClick?: () => void
|
||||
}
|
||||
|
||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
@@ -32,7 +33,8 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
highlightCount,
|
||||
settings,
|
||||
highlights = [],
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true }
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||
onHighlightCountClick
|
||||
}) => {
|
||||
const cachedImage = useImageCache(image)
|
||||
const { textColor } = useAdaptiveTextColor(cachedImage)
|
||||
@@ -107,8 +109,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div
|
||||
className="highlight-indicator"
|
||||
className="highlight-indicator clickable"
|
||||
style={getHighlightIndicatorStyles(true)}
|
||||
onClick={onHighlightCountClick}
|
||||
title="Open highlights sidebar"
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
@@ -152,8 +156,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div
|
||||
className="highlight-indicator"
|
||||
className="highlight-indicator clickable"
|
||||
style={getHighlightIndicatorStyles(false)}
|
||||
onClick={onHighlightCountClick}
|
||||
title="Open highlights sidebar"
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function RouteDebug() {
|
||||
// Unexpected during deep-link refresh tests
|
||||
console.warn('[RouteDebug] unexpected root redirect', info)
|
||||
} else {
|
||||
console.debug('[RouteDebug]', info)
|
||||
// silent
|
||||
}
|
||||
}, [location, matchArticle])
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faPersonHiking } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
@@ -36,70 +36,61 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
return (
|
||||
<>
|
||||
<div className="sidebar-header-bar">
|
||||
{isMobile ? (
|
||||
<IconButton
|
||||
icon={faTimes}
|
||||
onClick={onToggleCollapse}
|
||||
title="Close sidebar"
|
||||
ariaLabel="Close sidebar"
|
||||
variant="ghost"
|
||||
className="mobile-close-btn"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-sidebar-btn"
|
||||
title="Collapse bookmarks sidebar"
|
||||
aria-label="Collapse bookmarks sidebar"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
)}
|
||||
<div className="sidebar-header-right">
|
||||
{activeAccount && (
|
||||
<div
|
||||
className="profile-avatar"
|
||||
<button
|
||||
className="profile-avatar-button"
|
||||
title={getUserDisplayName()}
|
||||
onClick={() => navigate('/me')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
aria-label={`Profile: ${getUserDisplayName()}`}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faHome}
|
||||
onClick={() => navigate('/')}
|
||||
title="Home"
|
||||
ariaLabel="Home"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faNewspaper}
|
||||
onClick={() => navigate('/explore')}
|
||||
title="Explore"
|
||||
ariaLabel="Explore"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
{activeAccount && (
|
||||
<div className="sidebar-header-right">
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
onClick={onLogout}
|
||||
title="Logout"
|
||||
ariaLabel="Logout"
|
||||
icon={faHome}
|
||||
onClick={() => navigate('/')}
|
||||
title="Home"
|
||||
ariaLabel="Home"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faPersonHiking}
|
||||
onClick={() => navigate('/explore')}
|
||||
title="Explore"
|
||||
ariaLabel="Explore"
|
||||
variant="ghost"
|
||||
/>
|
||||
{activeAccount && (
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
onClick={onLogout}
|
||||
title="Logout"
|
||||
ariaLabel="Logout"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-sidebar-btn"
|
||||
title="Collapse bookmarks sidebar"
|
||||
aria-label="Collapse bookmarks sidebar"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -60,7 +60,7 @@ const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }
|
||||
const lang = detect(text)
|
||||
if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2)
|
||||
} catch (err) {
|
||||
console.debug('[tts][detect] failed', err)
|
||||
// ignore detection errors
|
||||
}
|
||||
}
|
||||
if (!langOverride && resolvedSystemLang) {
|
||||
@@ -78,7 +78,6 @@ const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }
|
||||
const currentIndex = SPEED_OPTIONS.indexOf(rate)
|
||||
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
|
||||
const next = SPEED_OPTIONS[nextIndex]
|
||||
console.debug('[tts][ui] cycle speed', { from: rate, to: next, speaking, paused })
|
||||
setRate(next)
|
||||
}
|
||||
|
||||
|
||||
@@ -387,6 +387,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
currentArticle={props.currentArticle}
|
||||
isSidebarCollapsed={props.isCollapsed}
|
||||
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
||||
onOpenHighlights={() => {
|
||||
if (props.isHighlightsCollapsed) {
|
||||
props.onToggleHighlightsPanel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -413,6 +418,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
relayPool={props.relayPool}
|
||||
eventStore={props.eventStore}
|
||||
settings={props.settings}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useEffect, useRef, Dispatch, SetStateAction } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import type { IEventStore } from 'applesauce-core'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { queryEvents } from '../services/dataFetch'
|
||||
import { fetchArticleByNaddr } from '../services/articleService'
|
||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
@@ -7,9 +13,17 @@ import { Highlight } from '../types/highlights'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface PreviewData {
|
||||
title: string
|
||||
image?: string
|
||||
summary?: string
|
||||
published?: number
|
||||
}
|
||||
|
||||
interface UseArticleLoaderProps {
|
||||
naddr: string | undefined
|
||||
relayPool: RelayPool | null
|
||||
eventStore?: IEventStore | null
|
||||
setSelectedUrl: (url: string) => void
|
||||
setReaderContent: (content: ReadableContent | undefined) => void
|
||||
setReaderLoading: (loading: boolean) => void
|
||||
@@ -25,6 +39,7 @@ interface UseArticleLoaderProps {
|
||||
export function useArticleLoader({
|
||||
naddr,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
@@ -36,7 +51,18 @@ export function useArticleLoader({
|
||||
setCurrentArticle,
|
||||
settings
|
||||
}: UseArticleLoaderProps) {
|
||||
const location = useLocation()
|
||||
const mountedRef = useRef(true)
|
||||
// Hold latest settings without retriggering effect
|
||||
const settingsRef = useRef<UserSettings | undefined>(settings)
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings
|
||||
}, [settings])
|
||||
// Track in-flight request to prevent stale updates from previous naddr
|
||||
const currentRequestIdRef = useRef(0)
|
||||
|
||||
// Extract preview data from navigation state (from blog post cards)
|
||||
const previewData = (location.state as { previewData?: PreviewData })?.previewData
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
@@ -44,67 +70,198 @@ export function useArticleLoader({
|
||||
if (!relayPool || !naddr) return
|
||||
|
||||
const loadArticle = async () => {
|
||||
const requestId = ++currentRequestIdRef.current
|
||||
if (!mountedRef.current) return
|
||||
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
setSelectedUrl(`nostr:${naddr}`)
|
||||
setIsCollapsed(true)
|
||||
|
||||
try {
|
||||
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
|
||||
|
||||
if (!mountedRef.current) return
|
||||
|
||||
// If we have preview data from navigation, show it immediately (no skeleton!)
|
||||
if (previewData) {
|
||||
setReaderContent({
|
||||
title: article.title,
|
||||
markdown: article.markdown,
|
||||
image: article.image,
|
||||
summary: article.summary,
|
||||
published: article.published,
|
||||
title: previewData.title,
|
||||
markdown: '', // Will be loaded from store or relay
|
||||
image: previewData.image,
|
||||
summary: previewData.summary,
|
||||
published: previewData.published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
|
||||
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
|
||||
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(article.event.id)
|
||||
setCurrentArticle?.(article.event)
|
||||
setReaderLoading(false)
|
||||
|
||||
// Fetch highlights asynchronously without blocking article display
|
||||
setReaderLoading(false) // Turn off loading immediately - we have the preview!
|
||||
} else {
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode naddr to filter
|
||||
const decoded = nip19.decode(naddr)
|
||||
if (decoded.type !== 'naddr') {
|
||||
throw new Error('Invalid naddr format')
|
||||
}
|
||||
const pointer = decoded.data as AddressPointer
|
||||
const filter = {
|
||||
kinds: [pointer.kind],
|
||||
authors: [pointer.pubkey],
|
||||
'#d': [pointer.identifier]
|
||||
}
|
||||
|
||||
let firstEmitted = false
|
||||
let latestEvent: NostrEvent | null = null
|
||||
|
||||
// Check eventStore first for instant load (from bookmark cards, explore, etc.)
|
||||
if (eventStore) {
|
||||
try {
|
||||
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
|
||||
const storedEvent = eventStore.getEvent?.(coordinate)
|
||||
if (storedEvent) {
|
||||
latestEvent = storedEvent as NostrEvent
|
||||
firstEmitted = true
|
||||
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
|
||||
const image = Helpers.getArticleImage(storedEvent)
|
||||
const summary = Helpers.getArticleSummary(storedEvent)
|
||||
const published = Helpers.getArticlePublished(storedEvent)
|
||||
setReaderContent({
|
||||
title,
|
||||
markdown: storedEvent.content,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(storedEvent.id)
|
||||
setCurrentArticle?.(storedEvent)
|
||||
setReaderLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore store errors, fall through to relay query
|
||||
}
|
||||
}
|
||||
|
||||
// Stream local-first via queryEvents; rely on EOSE (no timeouts)
|
||||
const events = await queryEvents(relayPool, filter, {
|
||||
onEvent: (evt) => {
|
||||
if (!mountedRef.current) return
|
||||
if (currentRequestIdRef.current !== requestId) return
|
||||
|
||||
// Store in event store for future local reads
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
eventStore?.add?.(evt as unknown as any)
|
||||
} catch {
|
||||
// Silently ignore store errors
|
||||
}
|
||||
|
||||
// Keep latest by created_at
|
||||
if (!latestEvent || evt.created_at > latestEvent.created_at) {
|
||||
latestEvent = evt
|
||||
}
|
||||
|
||||
// Emit immediately on first event
|
||||
if (!firstEmitted) {
|
||||
firstEmitted = true
|
||||
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
|
||||
const image = Helpers.getArticleImage(evt)
|
||||
const summary = Helpers.getArticleSummary(evt)
|
||||
const published = Helpers.getArticlePublished(evt)
|
||||
setReaderContent({
|
||||
title,
|
||||
markdown: evt.content,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${evt.kind}:${evt.pubkey}:${dTag}`
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(evt.id)
|
||||
setCurrentArticle?.(evt)
|
||||
setReaderLoading(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
|
||||
|
||||
// Finalize with newest version if it's newer than what we first rendered
|
||||
const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent
|
||||
if (finalEvent) {
|
||||
const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article'
|
||||
const image = Helpers.getArticleImage(finalEvent)
|
||||
const summary = Helpers.getArticleSummary(finalEvent)
|
||||
const published = Helpers.getArticlePublished(finalEvent)
|
||||
setReaderContent({
|
||||
title,
|
||||
markdown: finalEvent.content,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
|
||||
const dTag = finalEvent.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${finalEvent.kind}:${finalEvent.pubkey}:${dTag}`
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(finalEvent.id)
|
||||
setCurrentArticle?.(finalEvent)
|
||||
} else {
|
||||
// As a last resort, fall back to the legacy helper (which includes cache)
|
||||
const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current)
|
||||
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
|
||||
setReaderContent({
|
||||
title: article.title,
|
||||
markdown: article.markdown,
|
||||
image: article.image,
|
||||
summary: article.summary,
|
||||
published: article.published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(article.event.id)
|
||||
setCurrentArticle?.(article.event)
|
||||
}
|
||||
|
||||
// Fetch highlights after content is shown
|
||||
try {
|
||||
if (!mountedRef.current) return
|
||||
|
||||
setHighlightsLoading(true)
|
||||
setHighlights([])
|
||||
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
articleCoordinate,
|
||||
article.event.id,
|
||||
(highlight) => {
|
||||
if (!mountedRef.current) return
|
||||
|
||||
setHighlights((prev: Highlight[]) => {
|
||||
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||
const next = [highlight, ...prev]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
},
|
||||
settings
|
||||
)
|
||||
const le = latestEvent as NostrEvent | null
|
||||
const dTag = le ? (le.tags.find((t: string[]) => t[0] === 'd')?.[1] || '') : ''
|
||||
const coord = le && dTag ? `${le.kind}:${le.pubkey}:${dTag}` : undefined
|
||||
const eventId = le ? le.id : undefined
|
||||
if (coord && eventId) {
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
coord,
|
||||
eventId,
|
||||
(highlight) => {
|
||||
if (!mountedRef.current) return
|
||||
if (currentRequestIdRef.current !== requestId) return
|
||||
setHighlights((prev: Highlight[]) => {
|
||||
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||
const next = [highlight, ...prev]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
},
|
||||
settingsRef.current
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load article:', err)
|
||||
if (mountedRef.current) {
|
||||
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||
setReaderContent({
|
||||
title: 'Error Loading Article',
|
||||
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||
@@ -123,7 +280,8 @@ export function useArticleLoader({
|
||||
}, [
|
||||
naddr,
|
||||
relayPool,
|
||||
settings,
|
||||
eventStore,
|
||||
previewData,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
|
||||
132
src/hooks/useEventLoader.ts
Normal file
132
src/hooks/useEventLoader.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
import { eventManager } from '../services/eventManager'
|
||||
import { fetchProfiles } from '../services/profileService'
|
||||
|
||||
interface UseEventLoaderProps {
|
||||
eventId?: string
|
||||
relayPool?: RelayPool | null
|
||||
eventStore?: IEventStore | null
|
||||
setSelectedUrl: (url: string) => void
|
||||
setReaderContent: (content: ReadableContent | undefined) => void
|
||||
setReaderLoading: (loading: boolean) => void
|
||||
setIsCollapsed: (collapsed: boolean) => void
|
||||
}
|
||||
|
||||
export function useEventLoader({
|
||||
eventId,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed
|
||||
}: UseEventLoaderProps) {
|
||||
const displayEvent = useCallback((event: NostrEvent) => {
|
||||
// Escape HTML in content and convert newlines to breaks for plain text display
|
||||
const escapedContent = event.content
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br />')
|
||||
|
||||
// Initial title
|
||||
let title = `Note (${event.kind})`
|
||||
if (event.kind === 1) {
|
||||
title = `Note by @${event.pubkey.slice(0, 8)}...`
|
||||
}
|
||||
|
||||
// Emit immediately
|
||||
const baseContent: ReadableContent = {
|
||||
url: '',
|
||||
html: `<div style="white-space: pre-wrap; word-break: break-word;">${escapedContent}</div>`,
|
||||
title,
|
||||
published: event.created_at
|
||||
}
|
||||
setReaderContent(baseContent)
|
||||
|
||||
// Background: resolve author profile for kind:1 and update title
|
||||
if (event.kind === 1 && eventStore) {
|
||||
(async () => {
|
||||
try {
|
||||
let resolved = ''
|
||||
|
||||
// First, try to get from event store cache
|
||||
const storedProfile = eventStore.getEvent(event.pubkey + ':0')
|
||||
if (storedProfile) {
|
||||
try {
|
||||
const obj = JSON.parse(storedProfile.content || '{}') as { name?: string; display_name?: string; nip05?: string }
|
||||
resolved = obj.display_name || obj.name || obj.nip05 || ''
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// If not found in event store, fetch from relays
|
||||
if (!resolved && relayPool) {
|
||||
const profiles = await fetchProfiles(relayPool, eventStore as unknown as IEventStore, [event.pubkey])
|
||||
if (profiles && profiles.length > 0) {
|
||||
const latest = profiles.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
|
||||
try {
|
||||
const obj = JSON.parse(latest.content || '{}') as { name?: string; display_name?: string; nip05?: string }
|
||||
resolved = obj.display_name || obj.name || obj.nip05 || ''
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolved) {
|
||||
setReaderContent({ ...baseContent, title: `Note by @${resolved}` })
|
||||
}
|
||||
} catch {
|
||||
// ignore profile failures; keep fallback title
|
||||
}
|
||||
})()
|
||||
}
|
||||
}, [setReaderContent, relayPool, eventStore])
|
||||
|
||||
// Initialize event manager with services
|
||||
useEffect(() => {
|
||||
eventManager.setServices(eventStore || null, relayPool || null)
|
||||
}, [eventStore, relayPool])
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventId) return
|
||||
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
setSelectedUrl(`nostr-event:${eventId}`) // sentinel: truthy selection, not treated as article
|
||||
setIsCollapsed(false)
|
||||
|
||||
// Fetch using event manager (handles cache, deduplication, and retry)
|
||||
let cancelled = false
|
||||
|
||||
eventManager.fetchEvent(eventId).then(
|
||||
(event) => {
|
||||
if (!cancelled) {
|
||||
displayEvent(event)
|
||||
setReaderLoading(false)
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
if (!cancelled) {
|
||||
const errorContent: ReadableContent = {
|
||||
url: '',
|
||||
html: `<div style="padding: 1rem; color: var(--color-error, red);">Failed to load event: ${err instanceof Error ? err.message : 'Unknown error'}</div>`,
|
||||
title: 'Error'
|
||||
}
|
||||
setReaderContent(errorContent)
|
||||
setReaderLoading(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [eventId, displayEvent, setReaderLoading, setSelectedUrl, setIsCollapsed, setReaderContent])
|
||||
}
|
||||
@@ -49,6 +49,8 @@ export function useExternalUrlLoader({
|
||||
setCurrentArticleEventId
|
||||
}: UseExternalUrlLoaderProps) {
|
||||
const mountedRef = useRef(true)
|
||||
// Track in-flight request to prevent stale updates when switching quickly
|
||||
const currentRequestIdRef = useRef(0)
|
||||
|
||||
// Load cached URL-specific highlights from event store
|
||||
const urlFilter = useMemo(() => {
|
||||
@@ -70,6 +72,7 @@ export function useExternalUrlLoader({
|
||||
if (!relayPool || !url) return
|
||||
|
||||
const loadExternalUrl = async () => {
|
||||
const requestId = ++currentRequestIdRef.current
|
||||
if (!mountedRef.current) return
|
||||
|
||||
setReaderLoading(true)
|
||||
@@ -83,6 +86,7 @@ export function useExternalUrlLoader({
|
||||
const content = await fetchReadableContent(url)
|
||||
|
||||
if (!mountedRef.current) return
|
||||
if (currentRequestIdRef.current !== requestId) return
|
||||
|
||||
setReaderContent(content)
|
||||
setReaderLoading(false)
|
||||
@@ -114,6 +118,7 @@ export function useExternalUrlLoader({
|
||||
url,
|
||||
(highlight) => {
|
||||
if (!mountedRef.current) return
|
||||
if (currentRequestIdRef.current !== requestId) return
|
||||
|
||||
if (seen.has(highlight.id)) return
|
||||
seen.add(highlight.id)
|
||||
@@ -131,13 +136,13 @@ export function useExternalUrlLoader({
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load external URL:', err)
|
||||
if (mountedRef.current) {
|
||||
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||
const filename = getFilenameFromUrl(url)
|
||||
setReaderContent({
|
||||
title: filename,
|
||||
|
||||
@@ -20,9 +20,11 @@ export const useMarkdownToHTML = (
|
||||
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
// Always clear previous render immediately to avoid showing stale content while processing
|
||||
setRenderedHtml('')
|
||||
setProcessedMarkdown('')
|
||||
|
||||
if (!markdown) {
|
||||
setRenderedHtml('')
|
||||
setProcessedMarkdown('')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { IEventStore } from 'applesauce-core'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { AccountManager } from 'applesauce-accounts'
|
||||
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
|
||||
import { UserSettings, saveSettings, watchSettings, startSettingsStream } from '../services/settingsService'
|
||||
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
||||
import { applyTheme } from '../utils/theme'
|
||||
import { RELAYS } from '../config/relays'
|
||||
@@ -20,26 +20,24 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const [toastType, setToastType] = useState<'success' | 'error'>('success')
|
||||
|
||||
// Load settings and set up subscription
|
||||
// Load settings and set up streaming subscription (non-blocking, EOSE-driven)
|
||||
useEffect(() => {
|
||||
if (!relayPool || !pubkey || !eventStore) return
|
||||
|
||||
const loadAndWatch = async () => {
|
||||
try {
|
||||
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
|
||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err)
|
||||
}
|
||||
}
|
||||
|
||||
loadAndWatch()
|
||||
// Start settings stream: seed from store, stream updates to store in background
|
||||
const stopNetwork = startSettingsStream(relayPool, eventStore, pubkey, RELAYS, (loadedSettings) => {
|
||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||
})
|
||||
|
||||
// Also watch store reactively for any further updates
|
||||
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
|
||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||
})
|
||||
|
||||
return () => subscription.unsubscribe()
|
||||
return () => {
|
||||
subscription.unsubscribe()
|
||||
stopNetwork()
|
||||
}
|
||||
}, [relayPool, pubkey, eventStore])
|
||||
|
||||
// Apply settings to document
|
||||
|
||||
@@ -59,7 +59,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
// Update rate when defaultRate option changes
|
||||
useEffect(() => {
|
||||
if (options.defaultRate !== undefined) {
|
||||
console.debug('[tts] defaultRate changed ->', options.defaultRate)
|
||||
setRate(options.defaultRate)
|
||||
}
|
||||
}, [options.defaultRate])
|
||||
@@ -73,7 +72,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
if (!voice && v.length) {
|
||||
const byLang = v.find(x => x.lang?.toLowerCase().startsWith(defaultLang.toLowerCase()))
|
||||
setVoice(byLang || v[0] || null)
|
||||
console.debug('[tts] voices loaded', { total: v.length, picked: (byLang || v[0] || null)?.lang })
|
||||
}
|
||||
}
|
||||
load()
|
||||
@@ -107,44 +105,37 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
|
||||
u.onstart = () => {
|
||||
if (utteranceRef.current !== self) return
|
||||
console.debug('[tts] onstart')
|
||||
setSpeaking(true)
|
||||
setPaused(false)
|
||||
}
|
||||
u.onpause = () => {
|
||||
if (utteranceRef.current !== self) return
|
||||
console.debug('[tts] onpause')
|
||||
setPaused(true)
|
||||
}
|
||||
u.onresume = () => {
|
||||
if (utteranceRef.current !== self) return
|
||||
console.debug('[tts] onresume')
|
||||
setPaused(false)
|
||||
}
|
||||
u.onend = () => {
|
||||
if (utteranceRef.current !== self) return
|
||||
console.debug('[tts] onend')
|
||||
// Continue with next chunk if available
|
||||
const hasMore = chunkIndexRef.current < (chunksRef.current.length - 1)
|
||||
if (hasMore) {
|
||||
chunkIndexRef.current += 1
|
||||
globalOffsetRef.current += self.text.length
|
||||
const next = chunksRef.current[chunkIndexRef.current] || ''
|
||||
const nextUtterance = createUtterance(next, langRef.current)
|
||||
chunkIndexRef.current++
|
||||
charIndexRef.current += self.text.length
|
||||
const nextChunk = chunksRef.current[chunkIndexRef.current]
|
||||
const nextUtterance = createUtterance(nextChunk, langRef.current)
|
||||
utteranceRef.current = nextUtterance
|
||||
synth!.speak(nextUtterance)
|
||||
return
|
||||
} else {
|
||||
setSpeaking(false)
|
||||
setPaused(false)
|
||||
}
|
||||
setSpeaking(false)
|
||||
setPaused(false)
|
||||
utteranceRef.current = null
|
||||
}
|
||||
u.onerror = () => {
|
||||
if (utteranceRef.current !== self) return
|
||||
console.debug('[tts] onerror')
|
||||
setSpeaking(false)
|
||||
setPaused(false)
|
||||
utteranceRef.current = null
|
||||
}
|
||||
u.onboundary = (ev: SpeechSynthesisEvent) => {
|
||||
if (utteranceRef.current !== self) return
|
||||
@@ -197,7 +188,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (!supported) return
|
||||
console.debug('[tts] stop')
|
||||
synth!.cancel()
|
||||
setSpeaking(false)
|
||||
setPaused(false)
|
||||
@@ -211,18 +201,16 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
|
||||
const speak = useCallback((text: string, langOverride?: string) => {
|
||||
if (!supported || !text?.trim()) return
|
||||
console.debug('[tts] speak', { len: text.length, rate })
|
||||
synth!.cancel()
|
||||
spokenTextRef.current = text
|
||||
charIndexRef.current = 0
|
||||
langRef.current = langOverride
|
||||
startSpeakingChunks(text)
|
||||
}, [supported, synth, startSpeakingChunks, rate])
|
||||
}, [supported, synth, startSpeakingChunks])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (!supported) return
|
||||
if (synth!.speaking && !synth!.paused) {
|
||||
console.debug('[tts] pause')
|
||||
synth!.pause()
|
||||
setPaused(true)
|
||||
}
|
||||
@@ -231,7 +219,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
const resume = useCallback(() => {
|
||||
if (!supported) return
|
||||
if (synth!.speaking && synth!.paused) {
|
||||
console.debug('[tts] resume')
|
||||
synth!.resume()
|
||||
setPaused(false)
|
||||
}
|
||||
@@ -242,14 +229,11 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
if (!supported) return
|
||||
if (!utteranceRef.current) return
|
||||
|
||||
console.debug('[tts] rate change', { rate, speaking: synth!.speaking, paused: synth!.paused, charIndex: charIndexRef.current })
|
||||
|
||||
if (synth!.speaking && !synth!.paused) {
|
||||
const fullText = spokenTextRef.current
|
||||
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length))
|
||||
const remainingText = fullText.slice(startIndex)
|
||||
|
||||
console.debug('[tts] restart at new rate', { startIndex, remainingLen: remainingText.length })
|
||||
synth!.cancel()
|
||||
// restart chunked from current global index
|
||||
spokenTextRef.current = remainingText
|
||||
@@ -273,7 +257,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
const fullText = spokenTextRef.current
|
||||
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length - 1))
|
||||
const remainingText = fullText.slice(startIndex)
|
||||
console.debug('[tts] updateRate -> restart', { newRate, startIndex, remainingLen: remainingText.length })
|
||||
synth!.cancel()
|
||||
const u = createUtterance(remainingText)
|
||||
// ensure the new rate is applied immediately on the new utterance
|
||||
@@ -281,7 +264,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
utteranceRef.current = u
|
||||
synth!.speak(u)
|
||||
} else if (utteranceRef.current) {
|
||||
console.debug('[tts] updateRate -> set on utterance', { newRate })
|
||||
utteranceRef.current.rate = newRate
|
||||
}
|
||||
}, [supported, synth, createUtterance])
|
||||
|
||||
@@ -5,13 +5,12 @@ import './styles/tailwind.css'
|
||||
import './index.css'
|
||||
import 'react-loading-skeleton/dist/skeleton.css'
|
||||
|
||||
// Register Service Worker for PWA functionality
|
||||
if ('serviceWorker' in navigator) {
|
||||
// Register Service Worker for PWA functionality (production only)
|
||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js', { type: 'module' })
|
||||
.register('/sw.js')
|
||||
.then(registration => {
|
||||
|
||||
// Check for updates periodically
|
||||
setInterval(() => {
|
||||
registration.update()
|
||||
@@ -24,8 +23,6 @@ if ('serviceWorker' in navigator) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// New service worker available
|
||||
|
||||
// Optionally show a toast notification
|
||||
const updateAvailable = new CustomEvent('sw-update-available')
|
||||
window.dispatchEvent(updateAvailable)
|
||||
}
|
||||
|
||||
@@ -97,10 +97,10 @@ export async function fetchArticleByNaddr(
|
||||
|
||||
const pointer = decoded.data as AddressPointer
|
||||
|
||||
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local)
|
||||
const baseRelays = pointer.relays && pointer.relays.length > 0
|
||||
? pointer.relays
|
||||
: RELAYS
|
||||
// Define relays to query - use union of relay hints from naddr and configured relays
|
||||
// This avoids failures when naddr contains stale/unreachable relay hints
|
||||
const hintedRelays = (pointer.relays && pointer.relays.length > 0) ? pointer.relays : []
|
||||
const baseRelays = Array.from(new Set<string>([...hintedRelays, ...RELAYS]))
|
||||
const orderedRelays = prioritizeLocalRelays(baseRelays)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||
|
||||
@@ -114,7 +114,28 @@ export async function fetchArticleByNaddr(
|
||||
// Parallel local+remote, stream immediate, collect up to first from each
|
||||
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000)
|
||||
const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()))
|
||||
const events = collected as NostrEvent[]
|
||||
let events = collected as NostrEvent[]
|
||||
|
||||
// Fallback: if nothing found, try a second round against a set of reliable public relays
|
||||
if (events.length === 0) {
|
||||
const reliableRelays = Array.from(new Set<string>([
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
...remoteRelays // keep any configured remote relays
|
||||
]))
|
||||
const { remote$: fallback$ } = createParallelReqStreams(
|
||||
relayPool,
|
||||
[], // no local
|
||||
reliableRelays,
|
||||
filter,
|
||||
1500,
|
||||
12000
|
||||
)
|
||||
const fallbackCollected = await lastValueFrom(fallback$.pipe(take(1), rxToArray()))
|
||||
events = fallbackCollected as NostrEvent[]
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
throw new Error('Article not found')
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Helpers, EventStore } from 'applesauce-core'
|
||||
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { EventPointer } from 'nostr-tools/nip19'
|
||||
import { merge } from 'rxjs'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { collectBookmarksFromEvents } from './bookmarkProcessing'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import {
|
||||
@@ -64,11 +60,8 @@ class BookmarkController {
|
||||
}> = new Map()
|
||||
private isLoading = false
|
||||
private hydrationGeneration = 0
|
||||
|
||||
// Event loaders for efficient batching
|
||||
private eventStore = new EventStore()
|
||||
private eventLoader: ReturnType<typeof createEventLoader> | null = null
|
||||
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
|
||||
private externalEventStore: EventStore | null = null
|
||||
private relayPool: RelayPool | null = null
|
||||
|
||||
onRawEvent(cb: RawEventCallback): () => void {
|
||||
this.rawEventListeners.push(cb)
|
||||
@@ -117,15 +110,15 @@ class BookmarkController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate events by IDs using EventLoader (auto-batching, streaming)
|
||||
* Hydrate events by IDs using queryEvents (local-first, streaming)
|
||||
*/
|
||||
private hydrateByIds(
|
||||
private async hydrateByIds(
|
||||
ids: string[],
|
||||
idToEvent: Map<string, NostrEvent>,
|
||||
onProgress: () => void,
|
||||
generation: number
|
||||
): void {
|
||||
if (!this.eventLoader) {
|
||||
): Promise<void> {
|
||||
if (!this.relayPool) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -135,71 +128,146 @@ class BookmarkController {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert IDs to EventPointers
|
||||
const pointers: EventPointer[] = unique.map(id => ({ id }))
|
||||
|
||||
// Use EventLoader - it auto-batches and streams results
|
||||
merge(...pointers.map(this.eventLoader)).subscribe({
|
||||
next: (event) => {
|
||||
// Check if hydration was cancelled
|
||||
if (this.hydrationGeneration !== generation) return
|
||||
|
||||
idToEvent.set(event.id, event)
|
||||
|
||||
// Also index by coordinate for addressable events
|
||||
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
|
||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, event)
|
||||
// Fetch events using local-first queryEvents
|
||||
await queryEvents(
|
||||
this.relayPool,
|
||||
{ ids: unique },
|
||||
{
|
||||
onEvent: (event) => {
|
||||
// Check if hydration was cancelled
|
||||
if (this.hydrationGeneration !== generation) return
|
||||
|
||||
idToEvent.set(event.id, event)
|
||||
|
||||
// Also index by coordinate for addressable events
|
||||
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
|
||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, event)
|
||||
}
|
||||
|
||||
// Add to external event store if available
|
||||
if (this.externalEventStore) {
|
||||
this.externalEventStore.add(event)
|
||||
}
|
||||
|
||||
onProgress()
|
||||
}
|
||||
|
||||
onProgress()
|
||||
},
|
||||
error: () => {
|
||||
// Silent error - EventLoader handles retries
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate addressable events by coordinates using AddressLoader (auto-batching, streaming)
|
||||
* Hydrate addressable events by coordinates using queryEvents (local-first, streaming)
|
||||
*/
|
||||
private hydrateByCoordinates(
|
||||
private async hydrateByCoordinates(
|
||||
coords: Array<{ kind: number; pubkey: string; identifier: string }>,
|
||||
idToEvent: Map<string, NostrEvent>,
|
||||
onProgress: () => void,
|
||||
generation: number
|
||||
): void {
|
||||
if (!this.addressLoader) {
|
||||
): Promise<void> {
|
||||
if (!this.relayPool) {
|
||||
return
|
||||
}
|
||||
|
||||
if (coords.length === 0) return
|
||||
if (coords.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert coordinates to AddressPointers
|
||||
const pointers = coords.map(c => ({
|
||||
kind: c.kind,
|
||||
pubkey: c.pubkey,
|
||||
identifier: c.identifier
|
||||
}))
|
||||
|
||||
// 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] || ''
|
||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, event)
|
||||
idToEvent.set(event.id, event)
|
||||
|
||||
onProgress()
|
||||
},
|
||||
error: () => {
|
||||
// Silent error - AddressLoader handles retries
|
||||
// Group by kind and pubkey for efficient batching
|
||||
const filtersByKind = new Map<number, Map<string, string[]>>()
|
||||
|
||||
for (const coord of coords) {
|
||||
if (!filtersByKind.has(coord.kind)) {
|
||||
filtersByKind.set(coord.kind, new Map())
|
||||
}
|
||||
})
|
||||
const byPubkey = filtersByKind.get(coord.kind)!
|
||||
if (!byPubkey.has(coord.pubkey)) {
|
||||
byPubkey.set(coord.pubkey, [])
|
||||
}
|
||||
byPubkey.get(coord.pubkey)!.push(coord.identifier || '')
|
||||
}
|
||||
|
||||
// Kick off all queries in parallel (fire-and-forget)
|
||||
const promises: Promise<void>[] = []
|
||||
|
||||
for (const [kind, byPubkey] of filtersByKind) {
|
||||
for (const [pubkey, identifiers] of byPubkey) {
|
||||
// Separate empty and non-empty identifiers
|
||||
const nonEmptyIdentifiers = identifiers.filter(id => id && id.length > 0)
|
||||
const hasEmptyIdentifier = identifiers.some(id => !id || id.length === 0)
|
||||
|
||||
// Fetch events with non-empty d-tags
|
||||
if (nonEmptyIdentifiers.length > 0) {
|
||||
promises.push(
|
||||
queryEvents(
|
||||
this.relayPool,
|
||||
{ kinds: [kind], authors: [pubkey], '#d': nonEmptyIdentifiers },
|
||||
{
|
||||
onEvent: (event) => {
|
||||
// Check if hydration was cancelled
|
||||
if (this.hydrationGeneration !== generation) return
|
||||
|
||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, event)
|
||||
idToEvent.set(event.id, event)
|
||||
|
||||
// Add to external event store if available
|
||||
if (this.externalEventStore) {
|
||||
this.externalEventStore.add(event)
|
||||
}
|
||||
|
||||
onProgress()
|
||||
}
|
||||
}
|
||||
).then(() => {
|
||||
// Query completed successfully
|
||||
}).catch(() => {
|
||||
// Silent error - individual query failed
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch events with empty d-tag separately (without '#d' filter)
|
||||
if (hasEmptyIdentifier) {
|
||||
promises.push(
|
||||
queryEvents(
|
||||
this.relayPool,
|
||||
{ kinds: [kind], authors: [pubkey] },
|
||||
{
|
||||
onEvent: (event) => {
|
||||
// Check if hydration was cancelled
|
||||
if (this.hydrationGeneration !== generation) return
|
||||
|
||||
// Only process events with empty d-tag
|
||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
if (dTag !== '') return
|
||||
|
||||
const coordinate = `${event.kind}:${event.pubkey}:`
|
||||
idToEvent.set(coordinate, event)
|
||||
idToEvent.set(event.id, event)
|
||||
|
||||
// Add to external event store if available
|
||||
if (this.externalEventStore) {
|
||||
this.externalEventStore.add(event)
|
||||
}
|
||||
|
||||
onProgress()
|
||||
}
|
||||
}
|
||||
).then(() => {
|
||||
// Query completed successfully
|
||||
}).catch(() => {
|
||||
// Silent error - individual query failed
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all queries to complete
|
||||
await Promise.all(promises)
|
||||
}
|
||||
|
||||
private async buildAndEmitBookmarks(
|
||||
@@ -244,42 +312,58 @@ class BookmarkController {
|
||||
})
|
||||
|
||||
const allItems = [...publicItemsAll, ...privateItemsAll]
|
||||
const deduped = dedupeBookmarksById(allItems)
|
||||
|
||||
// Separate hex IDs from coordinates
|
||||
// Separate hex IDs from coordinates for fetching
|
||||
const noteIds: string[] = []
|
||||
const coordinates: string[] = []
|
||||
|
||||
allItems.forEach(i => {
|
||||
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
||||
noteIds.push(i.id)
|
||||
} else if (i.id.includes(':')) {
|
||||
coordinates.push(i.id)
|
||||
// Request hydration for all items that don't have content yet
|
||||
deduped.forEach(i => {
|
||||
// If item has no content, we need to fetch it
|
||||
if (!i.content || i.content.length === 0) {
|
||||
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
||||
noteIds.push(i.id)
|
||||
} else if (i.id.includes(':')) {
|
||||
coordinates.push(i.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Helper to build and emit bookmarks
|
||||
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
||||
const allBookmarks = dedupeBookmarksById([
|
||||
// Now hydrate the ORIGINAL items (which may have duplicates), using the deduplicated results
|
||||
// This preserves the original public/private split while still getting all the content
|
||||
const allBookmarks = [
|
||||
...hydrateItems(publicItemsAll, idToEvent),
|
||||
...hydrateItems(privateItemsAll, idToEvent)
|
||||
])
|
||||
|
||||
]
|
||||
|
||||
const enriched = allBookmarks.map(b => ({
|
||||
...b,
|
||||
tags: b.tags || [],
|
||||
content: b.content || ''
|
||||
content: b.content || this.externalEventStore?.getEvent(b.id)?.content || '', // Fallback to eventStore content
|
||||
created_at: (b.created_at ?? this.externalEventStore?.getEvent(b.id)?.created_at ?? null)
|
||||
}))
|
||||
|
||||
const sortedBookmarks = enriched
|
||||
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
.map(b => ({
|
||||
...b,
|
||||
urlReferences: extractUrlsFromContent(b.content)
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// Sort by display time: created_at, else listUpdatedAt. Newest first. Nulls last.
|
||||
const aTs = (a.created_at ?? a.listUpdatedAt ?? -Infinity)
|
||||
const bTs = (b.created_at ?? b.listUpdatedAt ?? -Infinity)
|
||||
return bTs - aTs
|
||||
})
|
||||
|
||||
const bookmark: Bookmark = {
|
||||
id: `${activeAccount.pubkey}-bookmarks`,
|
||||
title: `Bookmarks (${sortedBookmarks.length})`,
|
||||
url: '',
|
||||
content: latestContent,
|
||||
created_at: newestCreatedAt || Math.floor(Date.now() / 1000),
|
||||
created_at: newestCreatedAt || 0,
|
||||
tags: allTags,
|
||||
bookmarkCount: sortedBookmarks.length,
|
||||
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
|
||||
@@ -295,7 +379,7 @@ class BookmarkController {
|
||||
const idToEvent: Map<string, NostrEvent> = new Map()
|
||||
emitBookmarks(idToEvent)
|
||||
|
||||
// Now fetch events progressively in background using batched hydrators
|
||||
// Now fetch events progressively in background using local-first queries
|
||||
|
||||
const generation = this.hydrationGeneration
|
||||
const onProgress = () => emitBookmarks(idToEvent)
|
||||
@@ -310,10 +394,14 @@ class BookmarkController {
|
||||
}
|
||||
})
|
||||
|
||||
// Kick off batched hydration (streaming, non-blocking)
|
||||
// EventLoader and AddressLoader handle batching and streaming automatically
|
||||
this.hydrateByIds(noteIds, idToEvent, onProgress, generation)
|
||||
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation)
|
||||
// Kick off hydration (streaming, non-blocking, local-first)
|
||||
// Fire-and-forget - don't await, let it run in background
|
||||
this.hydrateByIds(noteIds, idToEvent, onProgress, generation).catch(() => {
|
||||
// Silent error - hydration will retry or show partial results
|
||||
})
|
||||
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation).catch(() => {
|
||||
// Silent error - hydration will retry or show partial results
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to build bookmarks:', error)
|
||||
this.bookmarksListeners.forEach(cb => cb([]))
|
||||
@@ -324,8 +412,13 @@ class BookmarkController {
|
||||
relayPool: RelayPool
|
||||
activeAccount: unknown
|
||||
accountManager: { getActive: () => unknown }
|
||||
eventStore?: EventStore
|
||||
}): Promise<void> {
|
||||
const { relayPool, activeAccount, accountManager } = options
|
||||
const { relayPool, activeAccount, accountManager, eventStore } = options
|
||||
|
||||
// Store references for hydration
|
||||
this.relayPool = relayPool
|
||||
this.externalEventStore = eventStore || null
|
||||
|
||||
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
||||
return
|
||||
@@ -336,16 +429,6 @@ class BookmarkController {
|
||||
// Increment generation to cancel any in-flight hydration
|
||||
this.hydrationGeneration++
|
||||
|
||||
// Initialize loaders for this session
|
||||
this.eventLoader = createEventLoader(relayPool, {
|
||||
eventStore: this.eventStore,
|
||||
extraRelays: RELAYS
|
||||
})
|
||||
this.addressLoader = createAddressLoader(relayPool, {
|
||||
eventStore: this.eventStore,
|
||||
extraRelays: RELAYS
|
||||
})
|
||||
|
||||
this.setLoading(true)
|
||||
|
||||
try {
|
||||
|
||||
@@ -15,28 +15,30 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
|
||||
}
|
||||
const unique = Array.from(byId.values())
|
||||
|
||||
// Separate web bookmarks (kind:39701) from list-based bookmarks
|
||||
const webBookmarks = unique.filter(e => e.kind === 39701)
|
||||
|
||||
const bookmarkLists = unique
|
||||
.filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001)
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd'))
|
||||
|
||||
// Deduplicate replaceable events (kind:30003, 30001, 39701) by d-tag
|
||||
const byD = new Map<string, NostrEvent>()
|
||||
for (const e of unique) {
|
||||
if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001) {
|
||||
if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001 || e.kind === 39701) {
|
||||
const d = (e.tags || []).find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const prev = byD.get(d)
|
||||
if (!prev || (e.created_at || 0) > (prev.created_at || 0)) byD.set(d, e)
|
||||
}
|
||||
}
|
||||
|
||||
const setsAndNamedLists = Array.from(byD.values())
|
||||
// Separate web bookmarks from bookmark sets/lists
|
||||
const allReplaceable = Array.from(byD.values())
|
||||
const webBookmarks = allReplaceable.filter(e => e.kind === 39701)
|
||||
const setsAndNamedLists = allReplaceable.filter(e => e.kind !== 39701)
|
||||
|
||||
const out: NostrEvent[] = []
|
||||
if (latestBookmarkList) out.push(latestBookmarkList)
|
||||
out.push(...setsAndNamedLists)
|
||||
// Add web bookmarks as individual events
|
||||
// Add deduplicated web bookmarks as individual events
|
||||
out.push(...webBookmarks)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -21,12 +21,16 @@ export interface AddressPointer {
|
||||
pubkey: string
|
||||
identifier: string
|
||||
relays?: string[]
|
||||
added_at?: number
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
export interface EventPointer {
|
||||
id: string
|
||||
relays?: string[]
|
||||
author?: string
|
||||
added_at?: number
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
export interface ApplesauceBookmarks {
|
||||
@@ -77,14 +81,14 @@ export const processApplesauceBookmarks = (
|
||||
allItems.push({
|
||||
id: note.id,
|
||||
content: '',
|
||||
created_at: parentCreatedAt || 0,
|
||||
created_at: note.created_at ?? null,
|
||||
pubkey: note.author || activeAccount.pubkey,
|
||||
kind: 1, // Short note kind
|
||||
tags: [],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: parentCreatedAt || 0
|
||||
listUpdatedAt: parentCreatedAt || 0
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -97,14 +101,14 @@ export const processApplesauceBookmarks = (
|
||||
allItems.push({
|
||||
id: coordinate,
|
||||
content: '',
|
||||
created_at: parentCreatedAt || 0,
|
||||
created_at: article.created_at ?? null,
|
||||
pubkey: article.pubkey,
|
||||
kind: article.kind, // Usually 30023 for long-form articles
|
||||
tags: [],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: parentCreatedAt || 0
|
||||
listUpdatedAt: parentCreatedAt ?? null
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -115,14 +119,14 @@ export const processApplesauceBookmarks = (
|
||||
allItems.push({
|
||||
id: `hashtag-${hashtag}`,
|
||||
content: `#${hashtag}`,
|
||||
created_at: parentCreatedAt || 0,
|
||||
created_at: 0, // Hashtags don't have their own creation time
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: 1,
|
||||
tags: [['t', hashtag]],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: parentCreatedAt || 0
|
||||
listUpdatedAt: parentCreatedAt ?? null
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -133,14 +137,14 @@ export const processApplesauceBookmarks = (
|
||||
allItems.push({
|
||||
id: `url-${url}`,
|
||||
content: url,
|
||||
created_at: parentCreatedAt || 0,
|
||||
created_at: 0, // URLs don't have their own creation time
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: 1,
|
||||
tags: [['r', url]],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: parentCreatedAt || 0
|
||||
listUpdatedAt: parentCreatedAt || 0
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -149,20 +153,24 @@ export const processApplesauceBookmarks = (
|
||||
}
|
||||
|
||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||
return bookmarkArray
|
||||
const processed = bookmarkArray
|
||||
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
||||
.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id!,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at || parentCreatedAt || 0,
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: bookmark.kind || 30001,
|
||||
tags: bookmark.tags || [],
|
||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: bookmark.created_at || parentCreatedAt || 0
|
||||
}))
|
||||
.map((bookmark: BookmarkData) => {
|
||||
return {
|
||||
id: bookmark.id!,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at ?? null,
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: bookmark.kind || 30001,
|
||||
tags: bookmark.tags || [],
|
||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
listUpdatedAt: parentCreatedAt ?? null
|
||||
}
|
||||
})
|
||||
|
||||
return processed
|
||||
}
|
||||
|
||||
// Types and guards around signer/decryption APIs
|
||||
@@ -184,6 +192,9 @@ export function hydrateItems(
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all events with content get parsed content for proper rendering
|
||||
const parsedContent = content ? (getParsedContent(content) as ParsedContent) : undefined
|
||||
|
||||
return {
|
||||
...item,
|
||||
pubkey: ev.pubkey || item.pubkey,
|
||||
@@ -191,7 +202,7 @@ export function hydrateItems(
|
||||
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
|
||||
parsedContent: parsedContent || item.parsedContent
|
||||
}
|
||||
})
|
||||
.filter(item => {
|
||||
|
||||
@@ -133,29 +133,36 @@ export async function collectBookmarksFromEvents(
|
||||
|
||||
// Handle web bookmarks (kind:39701) as individual bookmarks
|
||||
if (evt.kind === 39701) {
|
||||
// Use coordinate format for web bookmarks to enable proper deduplication
|
||||
// Web bookmarks are replaceable events (kind:39701:pubkey:d-tag)
|
||||
const webBookmarkId = dTag ? `${evt.kind}:${evt.pubkey}:${dTag}` : evt.id
|
||||
|
||||
publicItemsAll.push({
|
||||
id: evt.id,
|
||||
id: webBookmarkId,
|
||||
content: evt.content || '',
|
||||
created_at: evt.created_at || Math.floor(Date.now() / 1000),
|
||||
created_at: evt.created_at ?? null,
|
||||
pubkey: evt.pubkey,
|
||||
kind: evt.kind,
|
||||
tags: evt.tags || [],
|
||||
parsedContent: undefined,
|
||||
type: 'web' as const,
|
||||
isPrivate: false,
|
||||
added_at: evt.created_at || Math.floor(Date.now() / 1000),
|
||||
sourceKind: 39701,
|
||||
setName: dTag,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setImage
|
||||
setImage,
|
||||
listUpdatedAt: evt.created_at ?? null
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const pub = Helpers.getPublicBookmarks(evt)
|
||||
const processedPub = processApplesauceBookmarks(pub, activeAccount, false, evt.created_at)
|
||||
|
||||
|
||||
publicItemsAll.push(
|
||||
...processApplesauceBookmarks(pub, activeAccount, false, evt.created_at).map(i => ({
|
||||
...processedPub.map(i => ({
|
||||
...i,
|
||||
sourceKind: evt.kind,
|
||||
setName: dTag,
|
||||
|
||||
162
src/services/eventManager.ts
Normal file
162
src/services/eventManager.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { createEventLoader } from 'applesauce-loaders/loaders'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (event: NostrEvent) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized event manager for event fetching and caching
|
||||
* Handles deduplication of concurrent requests and coordinate with relay pool
|
||||
*/
|
||||
class EventManager {
|
||||
private eventStore: IEventStore | null = null
|
||||
private relayPool: RelayPool | null = null
|
||||
private eventLoader: ReturnType<typeof createEventLoader> | null = null
|
||||
|
||||
// Track pending requests to deduplicate and resolve all at once
|
||||
private pendingRequests = new Map<string, PendingRequest[]>()
|
||||
|
||||
// Safety timeout for event fetches (ms)
|
||||
private fetchTimeoutMs = 12000
|
||||
// Retry policy
|
||||
private maxAttempts = 4
|
||||
private baseBackoffMs = 700
|
||||
|
||||
/**
|
||||
* Initialize the event manager with event store and relay pool
|
||||
*/
|
||||
setServices(eventStore: IEventStore | null, relayPool: RelayPool | null): void {
|
||||
this.eventStore = eventStore
|
||||
this.relayPool = relayPool
|
||||
|
||||
// Recreate loader when services change
|
||||
if (relayPool) {
|
||||
this.eventLoader = createEventLoader(relayPool, {
|
||||
eventStore: eventStore || undefined
|
||||
})
|
||||
|
||||
// Retry any pending requests now that we have a loader
|
||||
this.retryAllPending()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached event from event store
|
||||
*/
|
||||
getCachedEvent(eventId: string): NostrEvent | null {
|
||||
if (!this.eventStore) return null
|
||||
return this.eventStore.getEvent(eventId) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an event by ID, returning a promise
|
||||
* Automatically deduplicates concurrent requests for the same event
|
||||
*/
|
||||
fetchEvent(eventId: string): Promise<NostrEvent> {
|
||||
// Check cache first
|
||||
const cached = this.getCachedEvent(eventId)
|
||||
if (cached) {
|
||||
return Promise.resolve(cached)
|
||||
}
|
||||
|
||||
return new Promise<NostrEvent>((resolve, reject) => {
|
||||
// Check if we're already fetching this event
|
||||
if (this.pendingRequests.has(eventId)) {
|
||||
// Add to existing request queue
|
||||
this.pendingRequests.get(eventId)!.push({ resolve, reject })
|
||||
return
|
||||
}
|
||||
|
||||
// Start a new fetch request
|
||||
this.pendingRequests.set(eventId, [{ resolve, reject }])
|
||||
this.fetchFromRelayWithRetry(eventId, 1)
|
||||
})
|
||||
}
|
||||
|
||||
private resolvePending(eventId: string, event: NostrEvent): void {
|
||||
const requests = this.pendingRequests.get(eventId) || []
|
||||
this.pendingRequests.delete(eventId)
|
||||
requests.forEach(req => req.resolve(event))
|
||||
}
|
||||
|
||||
private rejectPending(eventId: string, error: Error): void {
|
||||
const requests = this.pendingRequests.get(eventId) || []
|
||||
this.pendingRequests.delete(eventId)
|
||||
requests.forEach(req => req.reject(error))
|
||||
}
|
||||
|
||||
private fetchFromRelayWithRetry(eventId: string, attempt: number): void {
|
||||
// If no loader yet, schedule retry
|
||||
if (!this.relayPool || !this.eventLoader) {
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(eventId)) {
|
||||
this.fetchFromRelayWithRetry(eventId, attempt)
|
||||
}
|
||||
}, this.baseBackoffMs)
|
||||
return
|
||||
}
|
||||
|
||||
let delivered = false
|
||||
const subscription = this.eventLoader({ id: eventId }).subscribe({
|
||||
next: (event: NostrEvent) => {
|
||||
delivered = true
|
||||
clearTimeout(timeoutId)
|
||||
this.resolvePending(eventId, event)
|
||||
subscription.unsubscribe()
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
clearTimeout(timeoutId)
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
// Retry on error until attempts exhausted
|
||||
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
|
||||
setTimeout(() => this.fetchFromRelayWithRetry(eventId, attempt + 1), this.baseBackoffMs * attempt)
|
||||
} else {
|
||||
this.rejectPending(eventId, error)
|
||||
}
|
||||
subscription.unsubscribe()
|
||||
},
|
||||
complete: () => {
|
||||
// Completed without next - consider not found, but retry a few times
|
||||
if (!delivered) {
|
||||
clearTimeout(timeoutId)
|
||||
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
|
||||
setTimeout(() => this.fetchFromRelayWithRetry(eventId, attempt + 1), this.baseBackoffMs * attempt)
|
||||
} else {
|
||||
this.rejectPending(eventId, new Error('Event not found'))
|
||||
}
|
||||
}
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
})
|
||||
|
||||
// Safety timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!delivered) {
|
||||
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
|
||||
subscription.unsubscribe()
|
||||
this.fetchFromRelayWithRetry(eventId, attempt + 1)
|
||||
} else {
|
||||
subscription.unsubscribe()
|
||||
this.rejectPending(eventId, new Error('Timed out fetching event'))
|
||||
}
|
||||
}
|
||||
}, this.fetchTimeoutMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry all pending requests after relay pool becomes available
|
||||
*/
|
||||
private retryAllPending(): void {
|
||||
const pendingIds = Array.from(this.pendingRequests.keys())
|
||||
pendingIds.forEach(eventId => {
|
||||
this.fetchFromRelayWithRetry(eventId, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const eventManager = new EventManager()
|
||||
@@ -75,10 +75,17 @@ export function processReadingProgress(
|
||||
continue
|
||||
}
|
||||
} else if (dTag.startsWith('url:')) {
|
||||
// It's a URL with base64url encoding
|
||||
const encoded = dTag.replace('url:', '')
|
||||
// It's a URL. We support both raw URLs and base64url-encoded URLs.
|
||||
const value = dTag.slice(4)
|
||||
const looksBase64Url = /^[A-Za-z0-9_-]+$/.test(value) && (value.includes('-') || value.includes('_'))
|
||||
try {
|
||||
itemUrl = atob(encoded.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
if (looksBase64Url) {
|
||||
// Decode base64url to raw URL
|
||||
itemUrl = atob(value.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
} else {
|
||||
// Treat as raw URL (already decoded)
|
||||
itemUrl = value
|
||||
}
|
||||
itemId = itemUrl
|
||||
itemType = 'external'
|
||||
} catch (e) {
|
||||
|
||||
@@ -98,11 +98,8 @@ export function generateArticleIdentifier(naddrOrUrl: string): string {
|
||||
if (naddrOrUrl.startsWith('nostr:')) {
|
||||
return naddrOrUrl.replace('nostr:', '')
|
||||
}
|
||||
// For URLs, use base64url encoding (URL-safe)
|
||||
return btoa(naddrOrUrl)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
// For URLs, return the raw URL. Downstream tag generation will encode as needed.
|
||||
return naddrOrUrl
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,8 +135,53 @@ export async function saveReadingPosition(
|
||||
await publishEvent(relayPool, eventStore, signed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming reading position loader (non-blocking, EOSE-driven)
|
||||
* Seeds from local eventStore, streams relay updates to store in background
|
||||
* @returns Unsubscribe function to cancel both store watch and network stream
|
||||
*/
|
||||
export function startReadingPositionStream(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
pubkey: string,
|
||||
articleIdentifier: string,
|
||||
onPosition: (pos: ReadingPosition | null) => void
|
||||
): () => void {
|
||||
const dTag = generateDTag(articleIdentifier)
|
||||
|
||||
// 1) Seed from local replaceable immediately and watch for updates
|
||||
const storeSub = eventStore
|
||||
.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
|
||||
.subscribe((event: NostrEvent | undefined) => {
|
||||
if (!event) {
|
||||
onPosition(null)
|
||||
return
|
||||
}
|
||||
const parsed = getReadingProgressContent(event)
|
||||
onPosition(parsed || null)
|
||||
})
|
||||
|
||||
// 2) Stream from relays in background; pipe into store; no timeout/unsubscribe timer
|
||||
const networkSub = relayPool
|
||||
.subscription(RELAYS, {
|
||||
kinds: [READING_PROGRESS_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
// Caller manages lifecycle
|
||||
return () => {
|
||||
try { storeSub.unsubscribe() } catch { /* ignore */ }
|
||||
try { networkSub.unsubscribe() } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load reading position from Nostr (kind 39802)
|
||||
* @deprecated Use startReadingPositionStream for non-blocking behavior
|
||||
* Returns current local position immediately (or null) and starts background sync
|
||||
*/
|
||||
export async function loadReadingPosition(
|
||||
relayPool: RelayPool,
|
||||
@@ -149,101 +191,29 @@ export async function loadReadingPosition(
|
||||
): Promise<ReadingPosition | null> {
|
||||
const dTag = generateDTag(articleIdentifier)
|
||||
|
||||
// Check local event store first
|
||||
let initial: ReadingPosition | null = null
|
||||
try {
|
||||
const localEvent = await firstValueFrom(
|
||||
eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
|
||||
)
|
||||
if (localEvent) {
|
||||
const content = getReadingProgressContent(localEvent)
|
||||
if (content) {
|
||||
// Fetch from relays in background to get any updates
|
||||
relayPool
|
||||
.subscription(RELAYS, {
|
||||
kinds: [READING_PROGRESS_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
return content
|
||||
}
|
||||
if (content) initial = content
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors and fetch from relays
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Fetch from relays
|
||||
const result = await fetchFromRelays(
|
||||
relayPool,
|
||||
eventStore,
|
||||
pubkey,
|
||||
READING_PROGRESS_KIND,
|
||||
dTag,
|
||||
getReadingProgressContent
|
||||
)
|
||||
|
||||
return result || null
|
||||
}
|
||||
|
||||
// Helper function to fetch from relays with timeout
|
||||
async function fetchFromRelays(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
pubkey: string,
|
||||
kind: number,
|
||||
dTag: string,
|
||||
parser: (event: NostrEvent) => ReadingPosition | undefined
|
||||
): Promise<ReadingPosition | null> {
|
||||
return new Promise((resolve) => {
|
||||
let hasResolved = false
|
||||
const timeout = setTimeout(() => {
|
||||
if (!hasResolved) {
|
||||
hasResolved = true
|
||||
resolve(null)
|
||||
}
|
||||
}, 3000)
|
||||
|
||||
const sub = relayPool
|
||||
.subscription(RELAYS, {
|
||||
kinds: [kind],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe({
|
||||
complete: async () => {
|
||||
clearTimeout(timeout)
|
||||
if (!hasResolved) {
|
||||
hasResolved = true
|
||||
try {
|
||||
const event = await firstValueFrom(
|
||||
eventStore.replaceable(kind, pubkey, dTag)
|
||||
)
|
||||
if (event) {
|
||||
const content = parser(event)
|
||||
resolve(content || null)
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
} catch (err) {
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
clearTimeout(timeout)
|
||||
if (!hasResolved) {
|
||||
hasResolved = true
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
sub.unsubscribe()
|
||||
}, 3000)
|
||||
})
|
||||
// Start background sync (fire-and-forget; no timeout)
|
||||
relayPool
|
||||
.subscription(RELAYS, {
|
||||
kinds: [READING_PROGRESS_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
return initial
|
||||
}
|
||||
|
||||
|
||||
@@ -276,10 +276,10 @@ class ReadingProgressController {
|
||||
// Process new events
|
||||
processReadingProgress(events, readsMap)
|
||||
|
||||
// Convert back to progress map (naddr -> progress)
|
||||
// Convert back to progress map (id -> progress). Include both articles and external URLs.
|
||||
const newProgressMap = new Map<string, number>()
|
||||
for (const [id, item] of readsMap.entries()) {
|
||||
if (item.readingProgress !== undefined && item.type === 'article') {
|
||||
if (item.readingProgress !== undefined) {
|
||||
newProgressMap.set(id, item.readingProgress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function fetchAllReads(
|
||||
source: 'bookmark',
|
||||
type: 'article',
|
||||
readingProgress: 0,
|
||||
readingTimestamp: bookmark.added_at || bookmark.created_at
|
||||
readingTimestamp: bookmark.created_at ?? undefined
|
||||
}
|
||||
readsMap.set(coordinate, item)
|
||||
if (onItem) emitItem(item)
|
||||
|
||||
@@ -9,6 +9,13 @@ export const ALWAYS_LOCAL_RELAYS = [
|
||||
'ws://localhost:4869'
|
||||
]
|
||||
|
||||
/**
|
||||
* Hardcoded relays that are always included
|
||||
*/
|
||||
export const HARDCODED_RELAYS = [
|
||||
'wss://relay.nostr.band'
|
||||
]
|
||||
|
||||
/**
|
||||
* Gets active relay URLs from the relay pool
|
||||
*/
|
||||
|
||||
@@ -75,90 +75,82 @@ export interface UserSettings {
|
||||
ttsDefaultSpeed?: number // default: 2.1
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming settings loader (non-blocking, EOSE-driven)
|
||||
* Seeds from local eventStore, streams relay updates to store in background
|
||||
* @returns Unsubscribe function to cancel both store watch and network stream
|
||||
*/
|
||||
export function startSettingsStream(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
pubkey: string,
|
||||
relays: string[],
|
||||
onSettings: (settings: UserSettings | null) => void
|
||||
): () => void {
|
||||
// 1) Seed from local replaceable immediately and watch for updates
|
||||
const storeSub = eventStore
|
||||
.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
|
||||
.subscribe((event: NostrEvent | undefined) => {
|
||||
if (!event) {
|
||||
onSettings(null)
|
||||
return
|
||||
}
|
||||
const content = getAppDataContent<UserSettings>(event)
|
||||
onSettings(content || null)
|
||||
})
|
||||
|
||||
// 2) Stream from relays in background; pipe into store; no timeout/unsubscribe timer
|
||||
const networkSub = relayPool
|
||||
.subscription(relays, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [SETTINGS_IDENTIFIER]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
// Caller manages lifecycle
|
||||
return () => {
|
||||
try { storeSub.unsubscribe() } catch { /* ignore */ }
|
||||
try { networkSub.unsubscribe() } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use startSettingsStream + watchSettings for non-blocking behavior.
|
||||
* Returns current local settings immediately (or null if not present) and starts background sync.
|
||||
*/
|
||||
export async function loadSettings(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
pubkey: string,
|
||||
relays: string[]
|
||||
): Promise<UserSettings | null> {
|
||||
|
||||
// First, check if we already have settings in the local event store
|
||||
let initial: UserSettings | null = null
|
||||
|
||||
try {
|
||||
const localEvent = await firstValueFrom(
|
||||
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
|
||||
)
|
||||
if (localEvent) {
|
||||
const content = getAppDataContent<UserSettings>(localEvent)
|
||||
|
||||
// Still fetch from relays in the background to get any updates
|
||||
relayPool
|
||||
.subscription(relays, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [SETTINGS_IDENTIFIER]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
return content || null
|
||||
initial = content || null
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore local store errors
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// If not in local store, fetch from relays
|
||||
return new Promise((resolve) => {
|
||||
let hasResolved = false
|
||||
const timeout = setTimeout(() => {
|
||||
if (!hasResolved) {
|
||||
console.warn('⚠️ Settings load timeout - no settings event found')
|
||||
hasResolved = true
|
||||
resolve(null)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
const sub = relayPool
|
||||
.subscription(relays, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [SETTINGS_IDENTIFIER]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe({
|
||||
complete: async () => {
|
||||
clearTimeout(timeout)
|
||||
if (!hasResolved) {
|
||||
hasResolved = true
|
||||
try {
|
||||
const event = await firstValueFrom(
|
||||
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
|
||||
)
|
||||
if (event) {
|
||||
const content = getAppDataContent<UserSettings>(event)
|
||||
resolve(content || null)
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Error loading settings:', err)
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('❌ Settings subscription error:', err)
|
||||
clearTimeout(timeout)
|
||||
if (!hasResolved) {
|
||||
hasResolved = true
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
// Start background sync (fire-and-forget; no timeout)
|
||||
relayPool
|
||||
.subscription(relays, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [SETTINGS_IDENTIFIER]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
setTimeout(() => {
|
||||
sub.unsubscribe()
|
||||
}, 5000)
|
||||
})
|
||||
return initial
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
.reading-time svg { font-size: 0.875rem; }
|
||||
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); border-radius: 6px; font-size: 0.875rem; color: var(--color-text); }
|
||||
.highlight-indicator svg { font-size: 0.875rem; }
|
||||
.highlight-indicator.clickable { cursor: pointer; transition: all 0.2s ease; }
|
||||
.highlight-indicator.clickable:hover { background: rgba(99, 102, 241, 0.15); border-color: rgba(99, 102, 241, 0.5); transform: translateY(-1px); }
|
||||
.reader-html { color: var(--color-text); line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-family: var(--reading-font); font-size: var(--reading-font-size); }
|
||||
.reader-markdown { color: var(--color-text); line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
|
||||
/* Ensure font inheritance */
|
||||
|
||||
@@ -59,6 +59,8 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Mobile hamburger button now uses Tailwind utilities in ThreePaneLayout */
|
||||
@@ -92,7 +94,7 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
.profile-avatar-button {
|
||||
min-width: 33px;
|
||||
min-height: 33px;
|
||||
width: 33px;
|
||||
@@ -108,10 +110,27 @@
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.profile-avatar svg { font-size: 1rem; }
|
||||
/* Mobile touch target improvements */
|
||||
@media (max-width: 768px) {
|
||||
.profile-avatar-button {
|
||||
min-width: var(--min-touch-target);
|
||||
min-height: var(--min-touch-target);
|
||||
width: var(--min-touch-target);
|
||||
height: var(--min-touch-target);
|
||||
}
|
||||
}
|
||||
|
||||
.profile-avatar-button:hover {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.profile-avatar-button img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.profile-avatar-button svg { font-size: 1rem; }
|
||||
|
||||
.sidebar-header-bar .toggle-sidebar-btn {
|
||||
background: transparent;
|
||||
|
||||
@@ -31,7 +31,8 @@ export interface Bookmark {
|
||||
export interface IndividualBookmark {
|
||||
id: string
|
||||
content: string
|
||||
created_at: number
|
||||
// Timestamp when the content was created (from the content event itself)
|
||||
created_at: number | null
|
||||
pubkey: string
|
||||
kind: number
|
||||
tags: string[][]
|
||||
@@ -40,8 +41,6 @@ export interface IndividualBookmark {
|
||||
type: 'event' | 'article' | 'web'
|
||||
isPrivate?: boolean
|
||||
encryptedContent?: string
|
||||
// When the item was added to the bookmark list (synthetic, for sorting)
|
||||
added_at?: number
|
||||
// The kind of the source list/set that produced this bookmark (e.g., 10003, 30003, 30001, or 39701 for web)
|
||||
sourceKind?: number
|
||||
// The 'd' tag value from kind 30003 bookmark sets
|
||||
@@ -50,6 +49,9 @@ export interface IndividualBookmark {
|
||||
setTitle?: string
|
||||
setDescription?: string
|
||||
setImage?: string
|
||||
// Timestamp of the bookmark list event (best proxy for "when bookmarked")
|
||||
// Note: This is imperfect - it's when the list was last updated, not necessarily when this item was added
|
||||
listUpdatedAt?: number | null
|
||||
}
|
||||
|
||||
export interface ActiveAccount {
|
||||
|
||||
@@ -4,13 +4,15 @@ import { ParsedContent, ParsedNode, IndividualBookmark } from '../types/bookmark
|
||||
import ResolvedMention from '../components/ResolvedMention'
|
||||
// Note: RichContent is imported by components directly to keep this file component-only for fast refresh
|
||||
|
||||
export const formatDate = (timestamp: number) => {
|
||||
export const formatDate = (timestamp: number | null | undefined) => {
|
||||
if (!timestamp || !isFinite(timestamp) || timestamp <= 0) return ''
|
||||
const date = new Date(timestamp * 1000)
|
||||
return formatDistanceToNow(date, { addSuffix: true })
|
||||
}
|
||||
|
||||
// Ultra-compact date format for tight spaces (e.g., compact view)
|
||||
export const formatDateCompact = (timestamp: number) => {
|
||||
export const formatDateCompact = (timestamp: number | null | undefined) => {
|
||||
if (!timestamp || !isFinite(timestamp) || timestamp <= 0) return ''
|
||||
const date = new Date(timestamp * 1000)
|
||||
const now = new Date()
|
||||
|
||||
@@ -85,9 +87,8 @@ export const renderParsedContent = (parsedContent: ParsedContent) => {
|
||||
|
||||
// Sorting and grouping for bookmarks
|
||||
export const sortIndividualBookmarks = (items: IndividualBookmark[]) => {
|
||||
return items
|
||||
.slice()
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
const getSortTime = (b: IndividualBookmark) => b.created_at ?? b.listUpdatedAt ?? -Infinity
|
||||
return items.slice().sort((a, b) => getSortTime(b) - getSortTime(a))
|
||||
}
|
||||
|
||||
export function groupIndividualBookmarks(items: IndividualBookmark[]) {
|
||||
@@ -122,10 +123,7 @@ export function hasContent(bookmark: IndividualBookmark): boolean {
|
||||
export function hasCreationDate(bookmark: IndividualBookmark): boolean {
|
||||
if (!bookmark.created_at) return false
|
||||
// If timestamp is missing or equals current time (within 1 second), consider it invalid
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const createdAt = Math.floor(bookmark.created_at)
|
||||
// If created_at is within 1 second of now, it's likely missing/placeholder
|
||||
return Math.abs(createdAt - now) > 1
|
||||
return true
|
||||
}
|
||||
|
||||
// Bookmark sets helpers (kind 30003)
|
||||
|
||||
@@ -51,7 +51,7 @@ export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
||||
summary,
|
||||
image,
|
||||
readingProgress: 0,
|
||||
readingTimestamp: bookmark.added_at || bookmark.created_at
|
||||
readingTimestamp: bookmark.created_at ?? undefined
|
||||
}
|
||||
|
||||
linksMap.set(url, item)
|
||||
|
||||
@@ -49,7 +49,7 @@ export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
||||
source: 'bookmark',
|
||||
type: 'article',
|
||||
readingProgress: 0,
|
||||
readingTimestamp: bookmark.added_at || bookmark.created_at,
|
||||
readingTimestamp: bookmark.created_at ?? undefined,
|
||||
title,
|
||||
summary,
|
||||
image,
|
||||
|
||||
Reference in New Issue
Block a user