mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e382310c88 | ||
|
|
e6b99490dd | ||
|
|
09ee05861d | ||
|
|
205988a6b0 | ||
|
|
8012752a39 | ||
|
|
c3302da11d | ||
|
|
60e1e3c821 | ||
|
|
6c2247249a | ||
|
|
33a31df2b4 | ||
|
|
f9dda1c5d4 | ||
|
|
6522a2871c | ||
|
|
f39b926e7b | ||
|
|
144cf5cbd1 | ||
|
|
4b9de7cd07 | ||
|
|
2be58332bb | ||
|
|
6fc93cbd0f | ||
|
|
5df426a863 | ||
|
|
8ca4671bea | ||
|
|
ad1a808c6d |
62
CHANGELOG.md
62
CHANGELOG.md
@@ -7,6 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
- Text-to-speech reliability improvements
|
||||||
|
- Chunking support for long-form content to prevent WebSpeech API cutoffs
|
||||||
|
- Automatic chunk-based resumption for interrupted playback
|
||||||
|
- Better handling of content exceeding browser TTS limits
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Tab switching regression on `/me` page
|
||||||
|
- Resolved infinite update loop caused by circular dependency in `useCallback` hooks
|
||||||
|
- Tab navigation now properly updates UI when URL changes
|
||||||
|
- Removed `loadedTabs` from dependency arrays to prevent re-render cycles
|
||||||
|
- Explore page data loading patterns
|
||||||
|
- Implemented subscribe-first, non-blocking loading model
|
||||||
|
- Removed all timeouts in favor of immediate subscription and progressive hydration
|
||||||
|
- Contacts, writings, and highlights now stream results as they arrive
|
||||||
|
- Nostrverse content loads in background without blocking UI
|
||||||
|
- Text-to-speech handler cleanup
|
||||||
|
- Removed no-op self-assignment in rate change handler
|
||||||
|
|
||||||
|
## [0.10.4] - 2025-10-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Web Share Target support for PWA (system-level share integration)
|
||||||
|
- Boris can now receive shared URLs from other apps on mobile and desktop
|
||||||
|
- Implements POST-based Web Share Target API per Chrome standards
|
||||||
|
- Service worker intercepts share requests and redirects to handler route
|
||||||
|
- ShareTargetHandler component auto-saves shared URLs as web bookmarks
|
||||||
|
- Android compatibility with URL extraction from text field when url param is missing
|
||||||
|
- Automatic navigation to bookmarks list after successful save
|
||||||
|
- Login prompt when sharing while logged out
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Manifest now includes `share_target` configuration for system share menu integration
|
||||||
|
- Service worker handles POST requests to `/share-target` endpoint
|
||||||
|
- Added `/share-target` route for processing incoming shared content
|
||||||
|
|
||||||
## [0.10.3] - 2025-10-21
|
## [0.10.3] - 2025-10-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -2329,7 +2388,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Optimize relay usage following applesauce-relay best practices
|
- Optimize relay usage following applesauce-relay best practices
|
||||||
- Use applesauce-react event models for better profile handling
|
- Use applesauce-react event models for better profile handling
|
||||||
|
|
||||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.10.3...HEAD
|
[Unreleased]: https://github.com/dergigi/boris/compare/v0.10.4...HEAD
|
||||||
|
[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.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
|
[0.10.2]: https://github.com/dergigi/boris/compare/v0.10.1...v0.10.2
|
||||||
[0.10.1]: https://github.com/dergigi/boris/compare/v0.10.0...v0.10.1
|
[0.10.1]: https://github.com/dergigi/boris/compare/v0.10.0...v0.10.1
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.10.2",
|
"version": "0.10.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.10.2",
|
"version": "0.10.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.10.4",
|
"version": "0.10.7",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://read.withboris.com/",
|
"homepage": "https://read.withboris.com/",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
20
src/App.tsx
20
src/App.tsx
@@ -21,7 +21,7 @@ import { useOnlineStatus } from './hooks/useOnlineStatus'
|
|||||||
import { RELAYS } from './config/relays'
|
import { RELAYS } from './config/relays'
|
||||||
import { SkeletonThemeProvider } from './components/Skeletons'
|
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||||
import { loadUserRelayList, loadBlockedRelays, computeRelaySet } from './services/relayListService'
|
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 { Bookmark } from './types/bookmarks'
|
||||||
import { bookmarkController } from './services/bookmarkController'
|
import { bookmarkController } from './services/bookmarkController'
|
||||||
import { contactsController } from './services/contactsController'
|
import { contactsController } from './services/contactsController'
|
||||||
@@ -95,7 +95,7 @@ function AppRoutes({
|
|||||||
|
|
||||||
// Load bookmarks
|
// Load bookmarks
|
||||||
if (bookmarks.length === 0 && !bookmarksLoading) {
|
if (bookmarks.length === 0 && !bookmarksLoading) {
|
||||||
bookmarkController.start({ relayPool, activeAccount, accountManager })
|
bookmarkController.start({ relayPool, activeAccount, accountManager, eventStore: eventStore || undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load contacts
|
// Load contacts
|
||||||
@@ -348,6 +348,18 @@ function AppRoutes({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/e/:eventId"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/debug"
|
path="/debug"
|
||||||
element={
|
element={
|
||||||
@@ -615,7 +627,7 @@ function App() {
|
|||||||
loadUserRelayList(pool, pubkey, {
|
loadUserRelayList(pool, pubkey, {
|
||||||
onUpdate: (userRelays) => {
|
onUpdate: (userRelays) => {
|
||||||
const interimRelays = computeRelaySet({
|
const interimRelays = computeRelaySet({
|
||||||
hardcoded: [],
|
hardcoded: HARDCODED_RELAYS,
|
||||||
bunker: bunkerRelays,
|
bunker: bunkerRelays,
|
||||||
userList: userRelays,
|
userList: userRelays,
|
||||||
blocked: [],
|
blocked: [],
|
||||||
@@ -629,7 +641,7 @@ function App() {
|
|||||||
const blockedRelays = await blockedPromise.catch(() => [])
|
const blockedRelays = await blockedPromise.catch(() => [])
|
||||||
|
|
||||||
const finalRelays = computeRelaySet({
|
const finalRelays = computeRelaySet({
|
||||||
hardcoded: userRelayList.length > 0 ? [] : RELAYS,
|
hardcoded: userRelayList.length > 0 ? HARDCODED_RELAYS : RELAYS,
|
||||||
bunker: bunkerRelays,
|
bunker: bunkerRelays,
|
||||||
userList: userRelayList,
|
userList: userRelayList,
|
||||||
blocked: blockedRelays,
|
blocked: blockedRelays,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
@@ -26,11 +27,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
contentTypeIcon,
|
contentTypeIcon,
|
||||||
readingProgress
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
const isWebBookmark = bookmark.kind === 39701
|
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)
|
let progressColor = '#6366f1' // Default blue (reading)
|
||||||
if (readingProgress && readingProgress >= 0.95) {
|
if (readingProgress && readingProgress >= 0.95) {
|
||||||
progressColor = '#10b981' // Green (completed)
|
progressColor = '#10b981' // Green (completed)
|
||||||
@@ -39,20 +44,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCompactClick = () => {
|
const handleCompactClick = () => {
|
||||||
if (!onSelectUrl) return
|
|
||||||
|
|
||||||
if (isArticle) {
|
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) {
|
} 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 (
|
return (
|
||||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||||
<div
|
<div
|
||||||
@@ -64,10 +64,14 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
<span className="bookmark-type-compact">
|
<span className="bookmark-type-compact">
|
||||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
</span>
|
</span>
|
||||||
{displayText && (
|
{displayText ? (
|
||||||
<div className="compact-text">
|
<div className="compact-text">
|
||||||
<RichContent content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} className="" />
|
<RichContent content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} className="" />
|
||||||
</div>
|
</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)}</span>
|
||||||
{/* CTA removed */}
|
{/* CTA removed */}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
|
|||||||
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||||
|
import { useEventLoader } from '../hooks/useEventLoader'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import ThreePaneLayout from './ThreePaneLayout'
|
import ThreePaneLayout from './ThreePaneLayout'
|
||||||
import Explore from './Explore'
|
import Explore from './Explore'
|
||||||
@@ -38,7 +39,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
bookmarksLoading,
|
bookmarksLoading,
|
||||||
onRefreshBookmarks
|
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 location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const previousLocationRef = useRef<string>()
|
const previousLocationRef = useRef<string>()
|
||||||
@@ -55,6 +56,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
const showMe = location.pathname.startsWith('/me')
|
const showMe = location.pathname.startsWith('/me')
|
||||||
const showProfile = location.pathname.startsWith('/p/')
|
const showProfile = location.pathname.startsWith('/p/')
|
||||||
const showSupport = location.pathname === '/support'
|
const showSupport = location.pathname === '/support'
|
||||||
|
const eventId = eventIdParam
|
||||||
|
|
||||||
// Extract tab from explore routes
|
// Extract tab from explore routes
|
||||||
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
||||||
@@ -255,6 +257,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
setCurrentArticleEventId
|
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
|
// Classify highlights with levels based on user context
|
||||||
const classifiedHighlights = useMemo(() => {
|
const classifiedHighlights = useMemo(() => {
|
||||||
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
||||||
|
|||||||
@@ -485,7 +485,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenSearch = () => {
|
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')
|
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
|
||||||
}
|
}
|
||||||
setShowArticleMenu(false)
|
setShowArticleMenu(false)
|
||||||
|
|||||||
@@ -651,7 +651,9 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
return timeB - timeA
|
return timeB - timeA
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
|
100,
|
||||||
|
eventStore || undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
setWritingPosts(posts)
|
setWritingPosts(posts)
|
||||||
|
|||||||
1
src/components/EventViewer.tsx
Normal file
1
src/components/EventViewer.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
@@ -8,7 +8,7 @@ import { RelayPool } from 'applesauce-relay'
|
|||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { fetchContacts } from '../services/contactService'
|
// Contacts are managed via controller subscription
|
||||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||||
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||||
import { fetchProfiles } from '../services/profileService'
|
import { fetchProfiles } from '../services/profileService'
|
||||||
@@ -31,6 +31,7 @@ import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedu
|
|||||||
import { writingsController } from '../services/writingsController'
|
import { writingsController } from '../services/writingsController'
|
||||||
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
|
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
|
||||||
import { readingProgressController } from '../services/readingProgressController'
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
|
import { contactsController } from '../services/contactsController'
|
||||||
|
|
||||||
// Accessors from Helpers (currently unused here)
|
// Accessors from Helpers (currently unused here)
|
||||||
// const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
// const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
@@ -56,6 +57,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
|
const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
|
||||||
const [hasLoadedMine, setHasLoadedMine] = useState(false)
|
const [hasLoadedMine, setHasLoadedMine] = useState(false)
|
||||||
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
|
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
|
||||||
|
const hasHydratedRef = useRef(false)
|
||||||
|
|
||||||
// Get myHighlights directly from controller
|
// Get myHighlights directly from controller
|
||||||
const [/* myHighlights */, setMyHighlights] = useState<Highlight[]>([])
|
const [/* myHighlights */, setMyHighlights] = useState<Highlight[]>([])
|
||||||
@@ -106,6 +108,21 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to contacts stream and mirror into local state
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = contactsController.onContacts((contacts) => {
|
||||||
|
setFollowedPubkeys(new Set(contacts))
|
||||||
|
})
|
||||||
|
return () => unsubscribe()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Ensure contacts controller is started for the active account (non-blocking)
|
||||||
|
useEffect(() => {
|
||||||
|
if (relayPool && activeAccount?.pubkey) {
|
||||||
|
contactsController.start({ relayPool, pubkey: activeAccount.pubkey }).catch(() => {})
|
||||||
|
}
|
||||||
|
}, [relayPool, activeAccount?.pubkey])
|
||||||
|
|
||||||
// Subscribe to nostrverse highlights controller for global stream
|
// Subscribe to nostrverse highlights controller for global stream
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const apply = (incoming: Highlight[]) => {
|
const apply = (incoming: Highlight[]) => {
|
||||||
@@ -246,67 +263,81 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Followed pubkeys
|
|
||||||
if (activeAccount?.pubkey) {
|
|
||||||
fetchContacts(relayPool, activeAccount.pubkey)
|
|
||||||
.then((contacts) => {
|
|
||||||
setFollowedPubkeys(new Set(contacts))
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare parallel fetches
|
// Prepare parallel fetches
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
const contactsArray = Array.from(followedPubkeys)
|
|
||||||
|
|
||||||
const nostrversePostsPromise: Promise<BlogPostPreview[]> = (!activeAccount || (activeAccount && visibility.nostrverse))
|
// Nostrverse writings: subscribe-style via onPost; hydrate on first post
|
||||||
? fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined).catch(() => [])
|
if (!activeAccount || (activeAccount && visibility.nostrverse)) {
|
||||||
: Promise.resolve([])
|
fetchNostrverseBlogPosts(
|
||||||
|
relayPool,
|
||||||
// Fire non-blocking fetches and merge as they resolve
|
relayUrls,
|
||||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls)
|
50,
|
||||||
.then((friendsPosts) => {
|
eventStore || undefined,
|
||||||
|
(post) => {
|
||||||
setBlogPosts(prev => {
|
setBlogPosts(prev => {
|
||||||
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
|
const merged = dedupeWritingsByReplaceable([...prev, post])
|
||||||
const sorted = merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, sorted)
|
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
// Pre-cache profiles in background
|
|
||||||
const authorPubkeys = Array.from(new Set(sorted.map(p => p.author)))
|
|
||||||
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {})
|
|
||||||
return sorted
|
|
||||||
})
|
})
|
||||||
}).catch(() => {})
|
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
|
||||||
|
}
|
||||||
fetchHighlightsFromAuthors(relayPool, contactsArray)
|
).then((nostrversePosts) => {
|
||||||
.then((friendsHighlights) => {
|
|
||||||
setHighlights(prev => {
|
|
||||||
const merged = dedupeHighlightsById([...prev, ...friendsHighlights])
|
|
||||||
const sorted = merged.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
if (activeAccount) setCachedHighlights(activeAccount.pubkey, sorted)
|
|
||||||
return sorted
|
|
||||||
})
|
|
||||||
}).catch(() => {})
|
|
||||||
|
|
||||||
nostrversePostsPromise.then((nostrversePosts) => {
|
|
||||||
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
}
|
||||||
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
|
||||||
.then((nostriverseHighlights) => {
|
|
||||||
setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at))
|
|
||||||
}).catch(() => {})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load data:', err)
|
console.error('Failed to load data:', err)
|
||||||
// No blocking error - user can pull-to-refresh
|
// No blocking error - user can pull-to-refresh
|
||||||
} finally {
|
} finally {
|
||||||
// loading is already turned off after seeding
|
// loading is already turned off after seeding
|
||||||
}
|
}
|
||||||
}, [relayPool, activeAccount, eventStore, settings, visibility.nostrverse, followedPubkeys])
|
}, [relayPool, activeAccount, eventStore, visibility.nostrverse])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [loadData, refreshTrigger])
|
}, [loadData, refreshTrigger])
|
||||||
|
|
||||||
|
// Kick off friends fetches reactively when contacts arrive
|
||||||
|
useEffect(() => {
|
||||||
|
if (!relayPool) return
|
||||||
|
if (followedPubkeys.size === 0) return
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
const contactsArray = Array.from(followedPubkeys)
|
||||||
|
|
||||||
|
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls, (post) => {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const merged = dedupeWritingsByReplaceable([...prev, post])
|
||||||
|
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||||
|
// Pre-cache profiles in background
|
||||||
|
const authorPubkeys = Array.from(new Set(merged.map(p => p.author)))
|
||||||
|
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {})
|
||||||
|
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
|
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
|
||||||
|
}, 100, eventStore).then((friendsPosts) => {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
|
||||||
|
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||||
|
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
|
fetchHighlightsFromAuthors(relayPool, contactsArray, (highlight) => {
|
||||||
|
setHighlights(prev => {
|
||||||
|
const merged = dedupeHighlightsById([...prev, highlight])
|
||||||
|
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
|
||||||
|
return merged.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
|
||||||
|
}, eventStore || undefined).then((friendsHighlights) => {
|
||||||
|
setHighlights(prev => {
|
||||||
|
const merged = dedupeHighlightsById([...prev, ...friendsHighlights])
|
||||||
|
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
|
||||||
|
return merged.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [relayPool, followedPubkeys, eventStore, settings, activeAccount])
|
||||||
|
|
||||||
// Lazy-load nostrverse writings when user toggles it on (logged in)
|
// Lazy-load nostrverse writings when user toggles it on (logged in)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverse) return
|
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverse) return
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
||||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
const activeTab = propActiveTab || 'highlights'
|
||||||
|
|
||||||
// Only for own profile
|
// Only for own profile
|
||||||
const viewingPubkey = activeAccount?.pubkey
|
const viewingPubkey = activeAccount?.pubkey
|
||||||
@@ -129,13 +129,6 @@ const Me: React.FC<MeProps> = ({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Update local state when prop changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (propActiveTab) {
|
|
||||||
setActiveTab(propActiveTab)
|
|
||||||
}
|
|
||||||
}, [propActiveTab])
|
|
||||||
|
|
||||||
// Sync filter state with URL changes
|
// Sync filter state with URL changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalized = urlFilter === 'emoji' ? 'archive' : urlFilter
|
const normalized = urlFilter === 'emoji' ? 'archive' : urlFilter
|
||||||
@@ -235,23 +228,24 @@ const Me: React.FC<MeProps> = ({
|
|||||||
const loadReadingListTab = useCallback(async () => {
|
const loadReadingListTab = useCallback(async () => {
|
||||||
if (!viewingPubkey || !activeAccount) return
|
if (!viewingPubkey || !activeAccount) return
|
||||||
|
|
||||||
const hasBeenLoaded = loadedTabs.has('reading-list')
|
setLoadedTabs(prev => {
|
||||||
|
const hasBeenLoaded = prev.has('reading-list')
|
||||||
try {
|
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
// Bookmarks come from centralized loading in App.tsx
|
return new Set(prev).add('reading-list')
|
||||||
setLoadedTabs(prev => new Set(prev).add('reading-list'))
|
})
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load reading list:', err)
|
// Always turn off loading after a tick
|
||||||
} finally {
|
setTimeout(() => setLoading(false), 0)
|
||||||
if (!hasBeenLoaded) setLoading(false)
|
}, [viewingPubkey, activeAccount])
|
||||||
}
|
|
||||||
}, [viewingPubkey, activeAccount, loadedTabs])
|
|
||||||
|
|
||||||
const loadReadsTab = useCallback(async () => {
|
const loadReadsTab = useCallback(async () => {
|
||||||
if (!viewingPubkey || !activeAccount) return
|
if (!viewingPubkey || !activeAccount) return
|
||||||
|
|
||||||
const hasBeenLoaded = loadedTabs.has('reads')
|
let hasBeenLoaded = false
|
||||||
|
setLoadedTabs(prev => {
|
||||||
|
hasBeenLoaded = prev.has('reads')
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
@@ -270,12 +264,16 @@ const Me: React.FC<MeProps> = ({
|
|||||||
console.error('Failed to load reads:', err)
|
console.error('Failed to load reads:', err)
|
||||||
if (!hasBeenLoaded) setLoading(false)
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
}
|
}
|
||||||
}, [viewingPubkey, activeAccount, loadedTabs, relayPool, eventStore])
|
}, [viewingPubkey, activeAccount, relayPool, eventStore])
|
||||||
|
|
||||||
const loadLinksTab = useCallback(async () => {
|
const loadLinksTab = useCallback(async () => {
|
||||||
if (!viewingPubkey || !activeAccount) return
|
if (!viewingPubkey || !activeAccount) return
|
||||||
|
|
||||||
const hasBeenLoaded = loadedTabs.has('links')
|
let hasBeenLoaded = false
|
||||||
|
setLoadedTabs(prev => {
|
||||||
|
hasBeenLoaded = prev.has('links')
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
@@ -310,7 +308,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
console.error('Failed to load links:', err)
|
console.error('Failed to load links:', err)
|
||||||
if (!hasBeenLoaded) setLoading(false)
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
}
|
}
|
||||||
}, [viewingPubkey, activeAccount, loadedTabs, bookmarks, relayPool, readingProgressMap])
|
}, [viewingPubkey, activeAccount, bookmarks, relayPool, readingProgressMap])
|
||||||
|
|
||||||
// Load active tab data
|
// Load active tab data
|
||||||
const loadActiveTab = useCallback(() => {
|
const loadActiveTab = useCallback(() => {
|
||||||
|
|||||||
@@ -107,24 +107,14 @@ const Profile: React.FC<ProfileProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pubkey || !relayPool || !eventStore) return
|
if (!pubkey || !relayPool || !eventStore) return
|
||||||
|
|
||||||
|
// Fetch all highlights and writings in background (no limits)
|
||||||
|
const relayUrls = getActiveRelayUrls(relayPool)
|
||||||
|
|
||||||
// Fetch highlights in background
|
|
||||||
fetchHighlights(relayPool, pubkey, undefined, undefined, false, eventStore)
|
fetchHighlights(relayPool, pubkey, undefined, undefined, false, eventStore)
|
||||||
.then(() => {
|
.catch(err => console.warn('⚠️ [Profile] Failed to fetch highlights:', err))
|
||||||
// Highlights fetched
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.warn('⚠️ [Profile] Failed to fetch highlights:', err)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fetch writings in background (no limit for single user profile)
|
fetchBlogPostsFromAuthors(relayPool, [pubkey], relayUrls, undefined, null, eventStore)
|
||||||
fetchBlogPostsFromAuthors(relayPool, [pubkey], getActiveRelayUrls(relayPool), undefined, null)
|
.catch(err => console.warn('⚠️ [Profile] Failed to fetch writings:', err))
|
||||||
.then(writings => {
|
|
||||||
writings.forEach(w => eventStore.add(w.event))
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.warn('⚠️ [Profile] Failed to fetch writings:', err)
|
|
||||||
})
|
|
||||||
}, [pubkey, relayPool, eventStore, refreshTrigger])
|
}, [pubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Pull-to-refresh
|
||||||
|
|||||||
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])
|
||||||
|
}
|
||||||
@@ -50,6 +50,11 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null)
|
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null)
|
||||||
const spokenTextRef = useRef<string>('')
|
const spokenTextRef = useRef<string>('')
|
||||||
const charIndexRef = useRef<number>(0)
|
const charIndexRef = useRef<number>(0)
|
||||||
|
// Chunking state to reliably speak long texts from web URLs
|
||||||
|
const chunksRef = useRef<string[]>([])
|
||||||
|
const chunkIndexRef = useRef<number>(0)
|
||||||
|
const globalOffsetRef = useRef<number>(0)
|
||||||
|
const langRef = useRef<string | undefined>(undefined)
|
||||||
|
|
||||||
// Update rate when defaultRate option changes
|
// Update rate when defaultRate option changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,11 +84,21 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
}
|
}
|
||||||
}, [supported, defaultLang, voice, synth])
|
}, [supported, defaultLang, voice, synth])
|
||||||
|
|
||||||
const createUtterance = useCallback((text: string): SpeechSynthesisUtterance => {
|
const createUtterance = useCallback((text: string, langOverride?: string): SpeechSynthesisUtterance => {
|
||||||
const SpeechSynthesisUtteranceConstructor = (window as Window & typeof globalThis).SpeechSynthesisUtterance
|
const SpeechSynthesisUtteranceConstructor = (window as Window & typeof globalThis).SpeechSynthesisUtterance
|
||||||
const u = new SpeechSynthesisUtteranceConstructor(text) as SpeechSynthesisUtterance
|
const u = new SpeechSynthesisUtteranceConstructor(text) as SpeechSynthesisUtterance
|
||||||
u.lang = voice?.lang || defaultLang
|
const resolvedLang = langOverride || voice?.lang || defaultLang
|
||||||
if (voice) u.voice = voice
|
u.lang = resolvedLang
|
||||||
|
if (langOverride) {
|
||||||
|
const match = voices.find(v => v.lang?.toLowerCase().startsWith(langOverride.toLowerCase()))
|
||||||
|
if (match) {
|
||||||
|
u.voice = match
|
||||||
|
} else if (voice) {
|
||||||
|
u.voice = voice
|
||||||
|
}
|
||||||
|
} else if (voice) {
|
||||||
|
u.voice = voice
|
||||||
|
}
|
||||||
u.rate = rate
|
u.rate = rate
|
||||||
u.pitch = pitch
|
u.pitch = pitch
|
||||||
u.volume = volume
|
u.volume = volume
|
||||||
@@ -109,6 +124,17 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
u.onend = () => {
|
u.onend = () => {
|
||||||
if (utteranceRef.current !== self) return
|
if (utteranceRef.current !== self) return
|
||||||
console.debug('[tts] onend')
|
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)
|
||||||
|
utteranceRef.current = nextUtterance
|
||||||
|
synth!.speak(nextUtterance)
|
||||||
|
return
|
||||||
|
}
|
||||||
setSpeaking(false)
|
setSpeaking(false)
|
||||||
setPaused(false)
|
setPaused(false)
|
||||||
utteranceRef.current = null
|
utteranceRef.current = null
|
||||||
@@ -123,7 +149,7 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
u.onboundary = (ev: SpeechSynthesisEvent) => {
|
u.onboundary = (ev: SpeechSynthesisEvent) => {
|
||||||
if (utteranceRef.current !== self) return
|
if (utteranceRef.current !== self) return
|
||||||
if (typeof ev.charIndex === 'number') {
|
if (typeof ev.charIndex === 'number') {
|
||||||
const newIndex = ev.charIndex
|
const newIndex = globalOffsetRef.current + ev.charIndex
|
||||||
if (newIndex > charIndexRef.current) {
|
if (newIndex > charIndexRef.current) {
|
||||||
charIndexRef.current = newIndex
|
charIndexRef.current = newIndex
|
||||||
}
|
}
|
||||||
@@ -131,7 +157,43 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return u
|
return u
|
||||||
}, [voice, defaultLang, rate, pitch, volume])
|
}, [voice, defaultLang, rate, pitch, volume, voices, synth])
|
||||||
|
|
||||||
|
const splitIntoChunks = useCallback((text: string, maxLen = 2400): string[] => {
|
||||||
|
const normalized = text.replace(/\s+/g, ' ').trim()
|
||||||
|
if (normalized.length <= maxLen) return [normalized]
|
||||||
|
const sentences = normalized.split(/(?<=[.!?])\s+/)
|
||||||
|
const chunks: string[] = []
|
||||||
|
let current = ''
|
||||||
|
for (const s of sentences) {
|
||||||
|
if ((current + (current ? ' ' : '') + s).length > maxLen) {
|
||||||
|
if (current) chunks.push(current)
|
||||||
|
if (s.length > maxLen) {
|
||||||
|
// Hard split very long sentence
|
||||||
|
for (let i = 0; i < s.length; i += maxLen) {
|
||||||
|
chunks.push(s.slice(i, i + maxLen))
|
||||||
|
}
|
||||||
|
current = ''
|
||||||
|
} else {
|
||||||
|
current = s
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current = current ? `${current} ${s}` : s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) chunks.push(current)
|
||||||
|
return chunks
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startSpeakingChunks = useCallback((text: string) => {
|
||||||
|
chunksRef.current = splitIntoChunks(text)
|
||||||
|
chunkIndexRef.current = 0
|
||||||
|
globalOffsetRef.current = 0
|
||||||
|
const first = chunksRef.current[0] || ''
|
||||||
|
const u = createUtterance(first, langRef.current)
|
||||||
|
utteranceRef.current = u
|
||||||
|
synth!.speak(u)
|
||||||
|
}, [createUtterance, splitIntoChunks, synth])
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
if (!supported) return
|
if (!supported) return
|
||||||
@@ -142,6 +204,9 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
utteranceRef.current = null
|
utteranceRef.current = null
|
||||||
charIndexRef.current = 0
|
charIndexRef.current = 0
|
||||||
spokenTextRef.current = ''
|
spokenTextRef.current = ''
|
||||||
|
chunksRef.current = []
|
||||||
|
chunkIndexRef.current = 0
|
||||||
|
globalOffsetRef.current = 0
|
||||||
}, [supported, synth])
|
}, [supported, synth])
|
||||||
|
|
||||||
const speak = useCallback((text: string, langOverride?: string) => {
|
const speak = useCallback((text: string, langOverride?: string) => {
|
||||||
@@ -150,19 +215,9 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
synth!.cancel()
|
synth!.cancel()
|
||||||
spokenTextRef.current = text
|
spokenTextRef.current = text
|
||||||
charIndexRef.current = 0
|
charIndexRef.current = 0
|
||||||
|
langRef.current = langOverride
|
||||||
const u = createUtterance(text)
|
startSpeakingChunks(text)
|
||||||
if (langOverride) {
|
}, [supported, synth, startSpeakingChunks, rate])
|
||||||
u.lang = langOverride
|
|
||||||
// try to pick a voice that matches the override
|
|
||||||
const available = voices
|
|
||||||
const match = available.find(v => v.lang?.toLowerCase().startsWith(langOverride.toLowerCase()))
|
|
||||||
if (match) u.voice = match
|
|
||||||
}
|
|
||||||
|
|
||||||
utteranceRef.current = u
|
|
||||||
synth!.speak(u)
|
|
||||||
}, [supported, synth, createUtterance, rate, voices])
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
const pause = useCallback(() => {
|
||||||
if (!supported) return
|
if (!supported) return
|
||||||
@@ -191,21 +246,23 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
|
|
||||||
if (synth!.speaking && !synth!.paused) {
|
if (synth!.speaking && !synth!.paused) {
|
||||||
const fullText = spokenTextRef.current
|
const fullText = spokenTextRef.current
|
||||||
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length - 1))
|
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length))
|
||||||
const remainingText = fullText.slice(startIndex)
|
const remainingText = fullText.slice(startIndex)
|
||||||
|
|
||||||
console.debug('[tts] restart at new rate', { startIndex, remainingLen: remainingText.length })
|
console.debug('[tts] restart at new rate', { startIndex, remainingLen: remainingText.length })
|
||||||
synth!.cancel()
|
synth!.cancel()
|
||||||
const u = createUtterance(remainingText)
|
// restart chunked from current global index
|
||||||
utteranceRef.current = u
|
spokenTextRef.current = remainingText
|
||||||
synth!.speak(u)
|
charIndexRef.current = 0
|
||||||
|
// keep current language selection; no change needed here
|
||||||
|
startSpeakingChunks(remainingText)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (utteranceRef.current) {
|
if (utteranceRef.current) {
|
||||||
utteranceRef.current.rate = rate
|
utteranceRef.current.rate = rate
|
||||||
}
|
}
|
||||||
}, [rate, supported, synth, createUtterance])
|
}, [rate, supported, synth, startSpeakingChunks])
|
||||||
|
|
||||||
const updateRate = useCallback((newRate: number) => {
|
const updateRate = useCallback((newRate: number) => {
|
||||||
setRate(newRate)
|
setRate(newRate)
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { Helpers, EventStore } from 'applesauce-core'
|
|||||||
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
|
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { EventPointer } from 'nostr-tools/nip19'
|
import { EventPointer } from 'nostr-tools/nip19'
|
||||||
import { merge } from 'rxjs'
|
import { from } from 'rxjs'
|
||||||
|
import { mergeMap } from 'rxjs/operators'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
@@ -69,6 +70,7 @@ class BookmarkController {
|
|||||||
private eventStore = new EventStore()
|
private eventStore = new EventStore()
|
||||||
private eventLoader: ReturnType<typeof createEventLoader> | null = null
|
private eventLoader: ReturnType<typeof createEventLoader> | null = null
|
||||||
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
|
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
|
||||||
|
private externalEventStore: EventStore | null = null
|
||||||
|
|
||||||
onRawEvent(cb: RawEventCallback): () => void {
|
onRawEvent(cb: RawEventCallback): () => void {
|
||||||
this.rawEventListeners.push(cb)
|
this.rawEventListeners.push(cb)
|
||||||
@@ -138,8 +140,11 @@ class BookmarkController {
|
|||||||
// Convert IDs to EventPointers
|
// Convert IDs to EventPointers
|
||||||
const pointers: EventPointer[] = unique.map(id => ({ id }))
|
const pointers: EventPointer[] = unique.map(id => ({ id }))
|
||||||
|
|
||||||
// Use EventLoader - it auto-batches and streams results
|
// Use mergeMap with concurrency limit instead of merge to properly batch requests
|
||||||
merge(...pointers.map(this.eventLoader)).subscribe({
|
// This prevents overwhelming relays with 96+ simultaneous requests
|
||||||
|
from(pointers).pipe(
|
||||||
|
mergeMap(pointer => this.eventLoader!(pointer), 5)
|
||||||
|
).subscribe({
|
||||||
next: (event) => {
|
next: (event) => {
|
||||||
// Check if hydration was cancelled
|
// Check if hydration was cancelled
|
||||||
if (this.hydrationGeneration !== generation) return
|
if (this.hydrationGeneration !== generation) return
|
||||||
@@ -153,6 +158,11 @@ class BookmarkController {
|
|||||||
idToEvent.set(coordinate, event)
|
idToEvent.set(coordinate, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add to external event store if available
|
||||||
|
if (this.externalEventStore) {
|
||||||
|
this.externalEventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
onProgress()
|
onProgress()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -183,8 +193,10 @@ class BookmarkController {
|
|||||||
identifier: c.identifier
|
identifier: c.identifier
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Use AddressLoader - it auto-batches and streams results
|
// Use mergeMap with concurrency limit instead of merge to properly batch requests
|
||||||
merge(...pointers.map(this.addressLoader)).subscribe({
|
from(pointers).pipe(
|
||||||
|
mergeMap(pointer => this.addressLoader!(pointer), 5)
|
||||||
|
).subscribe({
|
||||||
next: (event) => {
|
next: (event) => {
|
||||||
// Check if hydration was cancelled
|
// Check if hydration was cancelled
|
||||||
if (this.hydrationGeneration !== generation) return
|
if (this.hydrationGeneration !== generation) return
|
||||||
@@ -194,6 +206,11 @@ class BookmarkController {
|
|||||||
idToEvent.set(coordinate, event)
|
idToEvent.set(coordinate, event)
|
||||||
idToEvent.set(event.id, event)
|
idToEvent.set(event.id, event)
|
||||||
|
|
||||||
|
// Add to external event store if available
|
||||||
|
if (this.externalEventStore) {
|
||||||
|
this.externalEventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
onProgress()
|
onProgress()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -244,30 +261,42 @@ class BookmarkController {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const allItems = [...publicItemsAll, ...privateItemsAll]
|
const allItems = [...publicItemsAll, ...privateItemsAll]
|
||||||
|
const deduped = dedupeBookmarksById(allItems)
|
||||||
|
|
||||||
// Separate hex IDs from coordinates
|
// Separate hex IDs from coordinates for fetching
|
||||||
const noteIds: string[] = []
|
const noteIds: string[] = []
|
||||||
const coordinates: string[] = []
|
const coordinates: string[] = []
|
||||||
|
|
||||||
allItems.forEach(i => {
|
// Request hydration for all items that don't have content yet
|
||||||
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
deduped.forEach(i => {
|
||||||
noteIds.push(i.id)
|
// If item has no content, we need to fetch it
|
||||||
} else if (i.id.includes(':')) {
|
if (!i.content || i.content.length === 0) {
|
||||||
coordinates.push(i.id)
|
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
||||||
|
noteIds.push(i.id)
|
||||||
|
} else if (i.id.includes(':')) {
|
||||||
|
coordinates.push(i.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log(`📋 Requesting hydration for: ${noteIds.length} note IDs, ${coordinates.length} coordinates`)
|
||||||
|
|
||||||
// Helper to build and emit bookmarks
|
// Helper to build and emit bookmarks
|
||||||
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
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(publicItemsAll, idToEvent),
|
||||||
...hydrateItems(privateItemsAll, idToEvent)
|
...hydrateItems(privateItemsAll, idToEvent)
|
||||||
])
|
]
|
||||||
|
|
||||||
const enriched = allBookmarks.map(b => ({
|
const enriched = allBookmarks.map(b => ({
|
||||||
...b,
|
...b,
|
||||||
tags: b.tags || [],
|
tags: b.tags || [],
|
||||||
content: b.content || ''
|
// Prefer hydrated content; fallback to any cached event content in external store
|
||||||
|
content: b.content && b.content.length > 0
|
||||||
|
? b.content
|
||||||
|
: (this.externalEventStore?.getEvent(b.id)?.content || '')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const sortedBookmarks = enriched
|
const sortedBookmarks = enriched
|
||||||
@@ -324,8 +353,12 @@ class BookmarkController {
|
|||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
activeAccount: unknown
|
activeAccount: unknown
|
||||||
accountManager: { getActive: () => unknown }
|
accountManager: { getActive: () => unknown }
|
||||||
|
eventStore?: EventStore
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { relayPool, activeAccount, accountManager } = options
|
const { relayPool, activeAccount, accountManager, eventStore } = options
|
||||||
|
|
||||||
|
// Store the external event store reference for adding hydrated events
|
||||||
|
this.externalEventStore = eventStore || null
|
||||||
|
|
||||||
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -184,6 +184,9 @@ export function hydrateItems(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure all events with content get parsed content for proper rendering
|
||||||
|
const parsedContent = content ? (getParsedContent(content) as ParsedContent) : undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
pubkey: ev.pubkey || item.pubkey,
|
pubkey: ev.pubkey || item.pubkey,
|
||||||
@@ -191,7 +194,7 @@ export function hydrateItems(
|
|||||||
created_at: ev.created_at || item.created_at,
|
created_at: ev.created_at || item.created_at,
|
||||||
kind: ev.kind || item.kind,
|
kind: ev.kind || item.kind,
|
||||||
tags: ev.tags || item.tags,
|
tags: ev.tags || item.tags,
|
||||||
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
|
parsedContent: parsedContent || item.parsedContent
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter(item => {
|
.filter(item => {
|
||||||
|
|||||||
148
src/services/eventManager.ts
Normal file
148
src/services/eventManager.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.fetchFromRelay(eventId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actually fetch the event from relay
|
||||||
|
*/
|
||||||
|
private fetchFromRelay(eventId: string): void {
|
||||||
|
// If no loader yet, schedule retry
|
||||||
|
if (!this.relayPool || !this.eventLoader) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.eventLoader && this.pendingRequests.has(eventId)) {
|
||||||
|
this.fetchFromRelay(eventId)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
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))
|
||||||
|
this.rejectPending(eventId, error)
|
||||||
|
subscription.unsubscribe()
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
// Completed without next - consider not found
|
||||||
|
if (!delivered) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
this.rejectPending(eventId, new Error('Event not found'))
|
||||||
|
}
|
||||||
|
subscription.unsubscribe()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Safety timeout
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (!delivered) {
|
||||||
|
this.rejectPending(eventId, new Error('Timed out fetching event'))
|
||||||
|
subscription.unsubscribe()
|
||||||
|
}
|
||||||
|
}, 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.fetchFromRelay(eventId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const eventManager = new EventManager()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers, IEventStore } from 'applesauce-core'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ export interface BlogPostPreview {
|
|||||||
* @param relayUrls - Array of relay URLs to query
|
* @param relayUrls - Array of relay URLs to query
|
||||||
* @param onPost - Optional callback for streaming posts
|
* @param onPost - Optional callback for streaming posts
|
||||||
* @param limit - Limit for number of events to fetch (default: 100, pass null for no limit)
|
* @param limit - Limit for number of events to fetch (default: 100, pass null for no limit)
|
||||||
|
* @param eventStore - Optional event store to persist fetched events
|
||||||
* @returns Array of blog post previews
|
* @returns Array of blog post previews
|
||||||
*/
|
*/
|
||||||
export const fetchBlogPostsFromAuthors = async (
|
export const fetchBlogPostsFromAuthors = async (
|
||||||
@@ -29,7 +30,8 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
pubkeys: string[],
|
pubkeys: string[],
|
||||||
relayUrls: string[],
|
relayUrls: string[],
|
||||||
onPost?: (post: BlogPostPreview) => void,
|
onPost?: (post: BlogPostPreview) => void,
|
||||||
limit: number | null = 100
|
limit: number | null = 100,
|
||||||
|
eventStore?: IEventStore
|
||||||
): Promise<BlogPostPreview[]> => {
|
): Promise<BlogPostPreview[]> => {
|
||||||
try {
|
try {
|
||||||
if (pubkeys.length === 0) {
|
if (pubkeys.length === 0) {
|
||||||
@@ -45,12 +47,17 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
? { kinds: [KINDS.BlogPost], authors: pubkeys, limit }
|
? { kinds: [KINDS.BlogPost], authors: pubkeys, limit }
|
||||||
: { kinds: [KINDS.BlogPost], authors: pubkeys }
|
: { kinds: [KINDS.BlogPost], authors: pubkeys }
|
||||||
|
|
||||||
await queryEvents(
|
const events = await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
filter,
|
filter,
|
||||||
{
|
{
|
||||||
relayUrls,
|
relayUrls,
|
||||||
onEvent: (event: NostrEvent) => {
|
onEvent: (event: NostrEvent) => {
|
||||||
|
// Store in event store immediately if provided
|
||||||
|
if (eventStore) {
|
||||||
|
eventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
const key = `${event.pubkey}:${dTag}`
|
const key = `${event.pubkey}:${dTag}`
|
||||||
const existing = uniqueEvents.get(key)
|
const existing = uniqueEvents.get(key)
|
||||||
@@ -73,6 +80,10 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Store all events in event store if provided (safety net for any missed during streaming)
|
||||||
|
if (eventStore) {
|
||||||
|
events.forEach(evt => eventStore.add(evt))
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to blog post previews and sort by published date (most recent first)
|
// Convert to blog post previews and sort by published date (most recent first)
|
||||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ export const ALWAYS_LOCAL_RELAYS = [
|
|||||||
'ws://localhost:4869'
|
'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
|
* Gets active relay URLs from the relay pool
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -108,7 +108,13 @@ sw.addEventListener('fetch', (event: FetchEvent) => {
|
|||||||
const formData = await event.request.formData()
|
const formData = await event.request.formData()
|
||||||
const title = (formData.get('title') || '').toString()
|
const title = (formData.get('title') || '').toString()
|
||||||
const text = (formData.get('text') || '').toString()
|
const text = (formData.get('text') || '').toString()
|
||||||
let link = (formData.get('link') || '').toString()
|
// Accept multiple possible field names just in case different casings are used
|
||||||
|
let link = (
|
||||||
|
formData.get('link') ||
|
||||||
|
formData.get('Link') ||
|
||||||
|
formData.get('url') ||
|
||||||
|
''
|
||||||
|
).toString()
|
||||||
|
|
||||||
// Android often omits url param, extract from text
|
// Android often omits url param, extract from text
|
||||||
if (!link && text) {
|
if (!link && text) {
|
||||||
|
|||||||
@@ -114,6 +114,17 @@ export default defineConfig({
|
|||||||
background_color: '#0b1220',
|
background_color: '#0b1220',
|
||||||
orientation: 'any',
|
orientation: 'any',
|
||||||
categories: ['productivity', 'social', 'utilities'],
|
categories: ['productivity', 'social', 'utilities'],
|
||||||
|
// Web Share Target configuration so the installed PWA shows up in the system share sheet
|
||||||
|
share_target: {
|
||||||
|
action: '/share-target',
|
||||||
|
method: 'POST',
|
||||||
|
enctype: 'multipart/form-data',
|
||||||
|
params: {
|
||||||
|
title: 'title',
|
||||||
|
text: 'text',
|
||||||
|
url: 'link'
|
||||||
|
}
|
||||||
|
},
|
||||||
icons: [
|
icons: [
|
||||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
|||||||
Reference in New Issue
Block a user