Compare commits

..

63 Commits

Author SHA1 Message Date
Gigi
366e10b23a feat(/e/): check eventStore first for author profile
- Try to load author profile from eventStore cache first
- Only fetch from relays if not found in cache
- Instant title update if profile already loaded
2025-10-22 01:19:09 +02:00
Gigi
bb66823915 fix(/e/): Search button opens note via /e/ path not search portal
- For kind:1 notes, open directly via /e/{eventId}
- For articles (kind:30023), continue using search portal
- Removes nostr-event: prefix in URLs
2025-10-22 01:18:51 +02:00
Gigi
f09973c858 feat(/e/): display publication date in top-right like articles
- Remove inline metadata HTML from note content
- Pass event.created_at as published timestamp via ReadableContent
- ReaderHeader now displays date in top-right corner
2025-10-22 01:18:14 +02:00
Gigi
d03726801d feat(/e/): title 'Note by @author' with background profile fetch
- Immediate fallback title using short pubkey
- Fetch kind:0 profile in background; update title when available
- Keeps UI responsive while improving attribution
2025-10-22 01:16:30 +02:00
Gigi
164e941a1f fix(events): make direct event loading robust
- Add completion and timeout handling to eventManager.fetchEvent
- Resolve/reject all pending promises correctly
- Prevent silent completes when event not found
- Improves /e/:eventId reliability on cold loads
2025-10-22 01:09:36 +02:00
Gigi
6def58f128 fix(bookmarks): show eventStore content as fallback for bookmarks without hydrated content
- Enrich bookmarks with content from externalEventStore when hydration hasn't populated yet
- Keeps sidebar from showing only event IDs while background hydration continues
2025-10-22 01:04:23 +02:00
Gigi
347e23ff6f fix: only request hydration for items without content
- Only fetch events for bookmarks that don't have content yet
- Bookmarks with existing content (web bookmarks, etc.) don't need fetching
- This reduces unnecessary fetches and focuses on what's needed
- Should show much better content in bookmarks list
2025-10-22 01:01:23 +02:00
Gigi
934768ebf2 chore: remove debug logging from hydration 2025-10-22 01:01:04 +02:00
Gigi
60e9ede9cf debug: add more detail to hydration logging 2025-10-22 00:59:06 +02:00
Gigi
c70e6bc2aa debug: log hydration progress to track content population
- Add logging to see how many hydrated items have content
- This will help diagnose why bookmarks are showing IDs instead of content
2025-10-22 00:57:47 +02:00
Gigi
ab8665815b chore: remove debug logging from bookmarkHelpers
- Remove 'NO MATCHES' debug logs from hydrateItems
- Console is now clean, hydration is working properly
2025-10-22 00:56:40 +02:00
Gigi
1929b50892 fix: properly implement eventManager with promise-based API
- Fix eventManager to handle async fetching with proper promise resolution
- Track pending requests and deduplicate concurrent requests for same event
- Auto-retry when relay pool becomes available
- Resolve all pending callbacks when event arrives
- Update useEventLoader to use eventManager.fetchEvent
- Simplify useEventLoader with just one effect for fetching
- Handles both instant cache hits and deferred relay fetching
2025-10-22 00:55:20 +02:00
Gigi
160dca628d fix: simplify eventManager and restore working event fetching
- Revert eventManager to simpler role: initialization and service coordination
- Restore original working fetching logic in useEventLoader
- eventManager now provides: getCachedEvent, getEventLoader, setServices
- Fixes broken bookmark hydration and direct event loading
- Uses eventManager for cache checking but direct subscription for fetching
2025-10-22 00:54:33 +02:00
Gigi
c04ba0c787 feat: add centralized eventManager for event fetching
- Create eventManager singleton for fetching and caching events
- Handles deduplication of concurrent requests for same event
- Waits for relay pool to become available before fetching
- Provides both async/await and callback-based APIs
- Update useEventLoader to use eventManager instead of direct loader
- Simplifies event fetching logic and enables better reuse across app
2025-10-22 00:52:15 +02:00
Gigi
479d9314bd fix: make event loading non-blocking and wait for relay pool
- Don't show error if relayPool isn't available yet
- Instead, keep loading state and wait for relayPool to become available
- Effect will re-run automatically when relayPool is set
- Enables smooth loading when navigating directly to /e/ URLs on page load
- Fetching happens in background without blocking user
2025-10-22 00:50:14 +02:00
Gigi
b9d5e501f4 improve: better error messages when direct event loading fails
- Show error if relayPool is not available when loading direct URL
- Improved error message wording to be clearer
- These messages will help diagnose direct /e/ path loading issues
2025-10-22 00:49:50 +02:00
Gigi
43e0dd76c4 fix: don't show user highlights when viewing events on /e/ path
- Set selectedUrl and ReadableContent url to empty string for events
- This prevents ThreePaneLayout from displaying user highlights for event views
- Events should only show event-specific content, not global user highlights
- Fixes issue where 422 highlights were always shown for all notes
2025-10-22 00:48:43 +02:00
Gigi
dc9a49e895 chore: remove debug logging from event loader and compact view
- Remove debug logs from useEventLoader hook
- Remove debug logs from Bookmarks component
- Remove empty kind:1 bookmark debug logging from CompactView
- Clean console output now that features are working correctly
2025-10-22 00:46:44 +02:00
Gigi
3200bdf378 fix: add hydrated bookmark events to global eventStore
- bookmarkController now accepts eventStore in start() options
- All hydrated events (both by ID and by coordinates) are added to the external eventStore
- This makes hydrated bookmark events available to useEventLoader and other hooks
- Fixes issue where /e/ path couldn't find events because they weren't in the global eventStore
- Now instant loading works for all bookmarked events
2025-10-22 00:42:25 +02:00
Gigi
2254586960 perf: check eventStore before setting loading state for instant cached event display
- Synchronously check eventStore first before setting loading state
- If event is cached, display it immediately without loading spinner
- Only set loading state if event not found in cache
- Provides instant display of events that are already hydrated
- Improves perceived performance when navigating to bookmarked events
2025-10-22 00:38:42 +02:00
Gigi
18c78c19be fix: render events as plain text html instead of markdown
- kind:1 notes are plain text, not markdown
- Changed from markdown to html rendering
- HTML-escape content to prevent injection
- Preserve whitespace and newlines for plain text display
- Display event metadata in styled HTML header
2025-10-22 00:36:55 +02:00
Gigi
167d5f2041 fix: clear reader content when loading event and set proper selectedUrl
- Clear readerContent at start of loading to ensure old content doesn't persist
- Set selectedUrl to nostr:eventId to match pattern used in other loaders
- This ensures consistent behavior across all content loaders
2025-10-22 00:35:33 +02:00
Gigi
cce7507e50 fix: properly extract eventId from route params
- Add eventId to useParams instead of manually parsing pathname
- useParams automatically extracts eventId from /e/:eventId route
- Add debug logging to track event loading
- This fixes the issue where eventId wasn't being passed to useEventLoader
2025-10-22 00:30:54 +02:00
Gigi
e83d4dbcdb feat: render notes like articles with markdown processing
- Change useEventLoader to set markdown instead of html
- Notes now get proper markdown processing and rendering similar to articles
- Use markdown comments for event metadata instead of HTML
- This enables proper styling and markdown features for note display
2025-10-22 00:28:29 +02:00
Gigi
a5bdde68fc fix: resolve all linter and type check errors
- Fix mergeMap concurrency syntax (pass as second parameter, not object)
- Fix type casting in CompactView debug logging
- Update useEventLoader to use ReadableContent type
- Fix eventStore type compatibility in useEventLoader
- All linter and TypeScript checks now pass
2025-10-22 00:27:45 +02:00
Gigi
5551cc3a55 feat: add relay.nostr.band as hardcoded relay
- Create HARDCODED_RELAYS constant with relay.nostr.band
- Always include hardcoded relays in relay pool
- Update computeRelaySet calls to use HARDCODED_RELAYS
- Ensures we can fetch events even if user has no relay list
- relay.nostr.band is a public searchable relay that indexes all events
2025-10-22 00:23:01 +02:00
Gigi
145ff138b0 feat: integrate event viewer into three-pane layout for /e/:eventId
- Create useEventLoader hook to fetch and display individual events
- Events display in middle pane with metadata (ID, timestamp, kind)
- Integrates with existing Bookmarks three-pane layout
- Remove standalone EventViewer component
- Route /e/:eventId now uses Bookmarks component
- Metadata displayed above event content for context
2025-10-22 00:22:04 +02:00
Gigi
5bd5686805 feat: add /e/:eventId route to display individual notes
- New EventViewer component to display kind:1 notes and other events
- Shows event ID, creation time, and content with RichContent rendering
- Add /e/:eventId route in App.tsx
- Update CompactView to navigate to /e/:eventId when clicking kind:1 bookmarks
- Mobile-optimized styling with back button and full viewport display
- Fallback for missing events with error message
2025-10-22 00:19:20 +02:00
Gigi
d2c1a16ca6 chore: remove verbose debug logging from hydration
- Clean up console output after diagnosing ID mismatch issue
- Keep error logging for when matches aren't found
- Deduplication before hydration now working
2025-10-22 00:17:03 +02:00
Gigi
b8242312b5 fix: deduplicate bookmarks before requesting hydration
- Collect all items, then dedupe before separating IDs/coordinates
- Prevents requesting hydration for 410 duplicate items
- Only requests ~96 unique event IDs instead
- Events are still hydrated for both public and private lists
- Dedupe after combining hydrated results
2025-10-22 00:15:27 +02:00
Gigi
96ef227f79 debug: log all fetched events to identify ID mismatch
- Show sample of note IDs being requested
- Log every event fetched with kind and content length
- Helps diagnose why kind:1 events aren't in the hydration map
2025-10-22 00:13:38 +02:00
Gigi
30ed5fb436 fix: batch event hydration with concurrency limit
- Replace merge(...map(eventLoader)) with mergeMap concurrency: 5
- Prevents overwhelming relays with 96+ simultaneous requests
- EventLoader now properly throttles to 5 concurrent requests at a time
- Fixes issue where only ~7 out of 96 events were being fetched
2025-10-22 00:12:34 +02:00
Gigi
42d7143845 debug: add logging for event ID requests
- Log how many note IDs and coordinates we're requesting
- Log how many unique event IDs are passed to EventLoader
- Track if all bookmarks are being requested for hydration
2025-10-22 00:11:06 +02:00
Gigi
f02bc21faf debug: simplify hydration logging for easier diagnosis
- Show how many items were matched in the map
- If zero matches, show actual IDs from both sides
- Makes it easy to see ID mismatch issues
2025-10-22 00:10:13 +02:00
Gigi
0f5d42465d debug: add detailed logging to hydrateItems
- Log which kind:1 items are being processed
- Show how many match events in the idToEvent map
- Compare sample IDs from items vs map keys
- Identify ID mismatch issue between bookmarks and fetched events
2025-10-22 00:08:47 +02:00
Gigi
004367bab6 debug: log the actual Bookmark object being emitted to component
- Show what's actually in individualBookmarks when emitted
- Check if content is present in the emitted object vs what component receives
- Identify if the issue is in hydration or state propagation
2025-10-22 00:05:04 +02:00
Gigi
312adea9f9 debug: add hydration logging to diagnose empty bookmarks
- Log when kind:1 events are fetched from relays
- Log when bookmarks are emitted with hydration status
- Track how many events are in the idToEvent map
- Check if event IDs match between bookmarks and fetched events
2025-10-22 00:03:14 +02:00
Gigi
a081b26333 feat: show event IDs for empty bookmarks and add debug logging
- Display event ID (first 12 chars) when bookmark content is missing
- Shows ID in dimmed code font as fallback for empty items
- Add debug console logging to identify which bookmarks are empty
- Helps diagnose hydration issues and identify events that aren't loading
2025-10-22 00:02:11 +02:00
Gigi
51e48804fe debug: remove console logging for kind:1 hydration
- Removed 📝, 💧, 🎨 and 📊 debug logs
- These were added for troubleshooting but are no longer needed
- Kind:1 content hydration and rendering is working correctly
2025-10-21 23:58:16 +02:00
Gigi
e08ce0e477 debug: add BookmarkList logging to track kind:1 filtering
- Log how many kind:1 bookmarks make it past the hasContent filter
- Show sample content to verify hydration is reaching the list
- Help identify where bookmarks are being filtered out
2025-10-21 23:55:10 +02:00
Gigi
2791c69ebe debug: add logging to CompactView to diagnose missing content rendering
- Log when kind:1 without URLs is being rendered
- Check if bookmark.content is actually present at render time
- Help diagnose why text isn't displaying even though it's hydrated
2025-10-21 23:54:15 +02:00
Gigi
96451e6173 debug: add logging to track kind:1 event hydration
- Log when kind:1 events are fetched by EventLoader
- Log when kind:1 events are hydrated with content
- Helps diagnose why text content isn't displaying for bookmarked notes
2025-10-21 23:52:39 +02:00
Gigi
d20cc684c3 feat: ensure kind:1 events display their text content in bookmarks bar
- Update hydrateItems to parse content for all events with text
- Previously, kind:1 events without URLs would appear empty in the bookmarks list
- Now any kind:1 event will display its text content appropriately
- Improves handling of short-form text notes in bookmarks
2025-10-21 23:50:12 +02:00
Gigi
4316c46a4d docs: update CHANGELOG for v0.10.7 2025-10-21 23:40:05 +02:00
Gigi
e382310c88 chore: bump version to 0.10.7 2025-10-21 23:39:11 +02:00
Gigi
e6b99490dd refactor: simplify profile background fetching
- Remove unnecessary .then() callback
- Extract relayUrls variable for clarity
- Make error handlers consistent
- Add clearer comment about no-limit fetching
2025-10-21 23:35:56 +02:00
Gigi
09ee05861d fix: ensure all writings are stored in eventStore for profile pages
- Add eventStore parameter to fetchBlogPostsFromAuthors
- Store events as they stream in, not just at the end
- Update all callers to pass eventStore parameter
- This fixes issue where profile pages don't show all writings
2025-10-21 23:28:27 +02:00
Gigi
205988a6b0 docs: update CHANGELOG for v0.10.6 2025-10-21 23:15:50 +02:00
Gigi
8012752a39 chore: bump version to 0.10.6 2025-10-21 23:14:18 +02:00
Gigi
c3302da11d chore(me): remove debug logs after fixing tab switching 2025-10-21 23:13:10 +02:00
Gigi
60e1e3c821 fix(me): remove loadedTabs from useCallback deps to prevent infinite loop 2025-10-21 23:11:22 +02:00
Gigi
6c2247249a fix(me): use propActiveTab directly to avoid infinite update loop 2025-10-21 23:07:51 +02:00
Gigi
33a31df2b4 fix(me): restore useEffect to sync propActiveTab to local state on route changes 2025-10-21 23:05:17 +02:00
Gigi
f9dda1c5d4 fix(me): add key to tab content div to force re-render on tab switch 2025-10-21 22:59:09 +02:00
Gigi
6522a2871c fix(me): derive activeTab directly from route prop to update instantly on navigation 2025-10-21 22:54:48 +02:00
Gigi
f39b926e7b fix(tts): remove self-assignment in rate-change handler; keep current lang without no-op 2025-10-21 22:48:01 +02:00
Gigi
144cf5cbd1 fix(explore): subscribe-first loading model for contacts, writings, highlights; no timeouts; hydrate on first result; non-blocking nostrverse streams 2025-10-21 22:44:49 +02:00
Gigi
4b9de7cd07 feat(tts): make Web TTS reliable by chunking long text and resuming by chunks 2025-10-21 22:26:51 +02:00
Gigi
2be58332bb chore: bump version to 0.10.5 2025-10-21 22:18:00 +02:00
Gigi
6fc93cbd0f fix(pwa): accept link/Link/url form fields in Web Share Target POST handler 2025-10-21 22:04:34 +02:00
Gigi
5df426a863 fix(pwa): include share_target in build manifest via vite-plugin-pwa 2025-10-21 21:57:33 +02:00
Gigi
8ca4671bea chore: update package-lock.json 2025-10-21 21:37:09 +02:00
Gigi
ad1a808c6d docs: update CHANGELOG for v0.10.4 2025-10-21 21:36:22 +02:00
21 changed files with 674 additions and 150 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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,

View File

@@ -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 */}

View File

@@ -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)

View File

@@ -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)

View File

@@ -651,7 +651,9 @@ const Debug: React.FC<DebugProps> = ({
return timeB - timeA return timeB - timeA
}) })
}) })
} },
100,
eventStore || undefined
) )
setWritingPosts(posts) setWritingPosts(posts)

View File

@@ -0,0 +1 @@

View File

@@ -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

View File

@@ -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(() => {

View File

@@ -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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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])
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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 => {

View 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()

View File

@@ -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())

View File

@@ -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
*/ */

View File

@@ -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) {

View File

@@ -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' },