mirror of
https://github.com/dergigi/boris.git
synced 2026-02-17 04:54:56 +01:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
205988a6b0 | ||
|
|
8012752a39 | ||
|
|
c3302da11d | ||
|
|
60e1e3c821 | ||
|
|
6c2247249a | ||
|
|
33a31df2b4 | ||
|
|
f9dda1c5d4 | ||
|
|
6522a2871c | ||
|
|
f39b926e7b | ||
|
|
144cf5cbd1 | ||
|
|
4b9de7cd07 | ||
|
|
2be58332bb | ||
|
|
6fc93cbd0f | ||
|
|
5df426a863 | ||
|
|
8ca4671bea | ||
|
|
ad1a808c6d | ||
|
|
ae118a0581 | ||
|
|
3cddcd850e | ||
|
|
cadf4dcb48 | ||
|
|
47d257faaf | ||
|
|
f542cee4cc | ||
|
|
8274eb26c2 | ||
|
|
35018fef91 | ||
|
|
1fd08bb64a | ||
|
|
d953542c93 | ||
|
|
8c0b73ad0c | ||
|
|
a5d2ed8b07 | ||
|
|
67fec91ab3 | ||
|
|
868fe68ce2 | ||
|
|
66c4bfc449 | ||
|
|
29918f78f9 | ||
|
|
18fcf6064e | ||
|
|
35766d5691 |
93
CHANGELOG.md
93
CHANGELOG.md
@@ -7,6 +7,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
- Content filtering setting to hide articles posted by bots
|
||||
- New "Hide content posted by bots" checkbox in Explore settings (enabled by default)
|
||||
- Filters articles where author's profile name or display_name contains "bot" (case-insensitive)
|
||||
- Applies to both Explore page and Me section writings
|
||||
|
||||
### Fixed
|
||||
|
||||
- Resolved all linting and type checking issues
|
||||
- Added missing React Hook dependencies to `useMemo` and `useEffect`
|
||||
- Wrapped loader functions in `useCallback` to prevent unnecessary re-renders
|
||||
- Removed unused variables (`queryTime`, `startTime`, `allEvents`)
|
||||
- All ESLint warnings and TypeScript errors now resolved
|
||||
|
||||
## [0.10.2] - 2025-10-20
|
||||
|
||||
### Added
|
||||
|
||||
- Text-to-speech (TTS) speaker language selection mode
|
||||
- New "Speaker language" dropdown in TTS settings (system or content)
|
||||
- Detects content language using tinyld for accurate voice matching
|
||||
- Falls back to system language when content detection unavailable
|
||||
- Top 10 languages featured in dropdown for quick access
|
||||
- TTS example text section in settings
|
||||
- Test TTS voices directly in the settings panel
|
||||
- Uses Boris mission statement as example text
|
||||
- Real-time speaker selection testing
|
||||
|
||||
### Changed
|
||||
|
||||
- TTS language selection now uses "Speaker language" terminology
|
||||
- Distinguishes between American English (en-US) and British English (en-GB)
|
||||
- Improved language detection with content-aware voice selection
|
||||
- Streamlined dropdown for better UX
|
||||
|
||||
### Fixed
|
||||
|
||||
- TTS voice detection and selection logic
|
||||
- Proper empty catch block handling instead of silently failing
|
||||
- Consistent use of `setting-select` class for dropdown styling
|
||||
- Improved dropdown spacing with adequate padding-right
|
||||
|
||||
## [0.10.0] - 2025-01-27
|
||||
|
||||
### Added
|
||||
@@ -2284,7 +2371,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.10.0...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.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.0]: https://github.com/dergigi/boris/compare/v0.9.1...v0.10.0
|
||||
[0.9.1]: https://github.com/dergigi/boris/compare/v0.9.0...v0.9.1
|
||||
[0.8.3]: https://github.com/dergigi/boris/compare/v0.8.2...v0.8.3
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.5",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.6",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
@@ -9,6 +9,16 @@
|
||||
"background_color": "#0b1220",
|
||||
"orientation": "any",
|
||||
"categories": ["productivity", "social", "utilities"],
|
||||
"share_target": {
|
||||
"action": "/share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "link"
|
||||
}
|
||||
},
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
|
||||
40
src/App.tsx
40
src/App.tsx
@@ -8,12 +8,14 @@ import { AccountManager, Accounts } from 'applesauce-accounts'
|
||||
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrConnectSigner } from 'applesauce-signers'
|
||||
import type { NostrEvent } from 'nostr-tools'
|
||||
import { getDefaultBunkerPermissions } from './services/nostrConnect'
|
||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import Debug from './components/Debug'
|
||||
import Bookmarks from './components/Bookmarks'
|
||||
import RouteDebug from './components/RouteDebug'
|
||||
import Toast from './components/Toast'
|
||||
import ShareTargetHandler from './components/ShareTargetHandler'
|
||||
import { useToast } from './hooks/useToast'
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||
import { RELAYS } from './config/relays'
|
||||
@@ -158,6 +160,10 @@ function AppRoutes({
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/share-target"
|
||||
element={<ShareTargetHandler relayPool={relayPool} />}
|
||||
/>
|
||||
<Route
|
||||
path="/a/:naddr"
|
||||
element={
|
||||
@@ -386,23 +392,14 @@ function App() {
|
||||
// Wire the signer to use this pool; make publish non-blocking so callers don't
|
||||
// wait for every relay send to finish. Responses still resolve the pending request.
|
||||
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
|
||||
NostrConnectSigner.publishMethod = (relays: string[], event: unknown) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result: any = pool.publish(relays, event as any)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (result && typeof (result as any).subscribe === 'function') {
|
||||
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
try { (result as any).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
|
||||
}
|
||||
// Return an already-resolved promise so upstream await finishes immediately
|
||||
NostrConnectSigner.publishMethod = (relays: string[], event: NostrEvent) => {
|
||||
// Fire-and-forget publish; do not block callers
|
||||
pool.publish(relays, event).catch(() => { /* ignore errors */ })
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// Create a relay group for better event deduplication and management
|
||||
pool.group(RELAYS)
|
||||
console.log('[relay-init] Initial pool setup - added RELAYS:', RELAYS.length, 'relays')
|
||||
console.log('[relay-init] Pool now has:', Array.from(pool.relays.keys()).length, 'relays')
|
||||
|
||||
// Load persisted accounts from localStorage
|
||||
try {
|
||||
@@ -582,7 +579,7 @@ function App() {
|
||||
const signerData = nostrConnectAccount.toJSON().signer
|
||||
bunkerRelays = signerData.relays || []
|
||||
}
|
||||
console.log('[relay-init] Bunker relays:', bunkerRelays.length, 'relays', bunkerRelays)
|
||||
|
||||
|
||||
// Start with hardcoded + bunker relays immediately (non-blocking)
|
||||
const initialRelays = computeRelaySet({
|
||||
@@ -592,11 +589,10 @@ function App() {
|
||||
blocked: [],
|
||||
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
|
||||
})
|
||||
console.log('[relay-init] Initial relay set (hardcoded):', initialRelays.length, 'relays', initialRelays)
|
||||
|
||||
|
||||
// Apply initial set immediately
|
||||
applyRelaySetToPool(pool, initialRelays)
|
||||
console.log('[relay-init] After initial applyRelaySetToPool, pool has:', Array.from(pool.relays.keys()).length, 'relays')
|
||||
|
||||
// Prepare keep-alive helper
|
||||
const updateKeepAlive = () => {
|
||||
@@ -625,14 +621,12 @@ function App() {
|
||||
blocked: [],
|
||||
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
|
||||
})
|
||||
console.log('[relay-init] Interim relay set from first user list:', interimRelays.length, 'relays', interimRelays)
|
||||
|
||||
applyRelaySetToPool(pool, interimRelays)
|
||||
updateKeepAlive()
|
||||
}
|
||||
}).then(async (userRelayList) => {
|
||||
const blockedRelays = await blockedPromise.catch(() => [])
|
||||
console.log('[relay-init] User relay list (10002):', userRelayList.length, 'relays', userRelayList.map(r => r.url))
|
||||
console.log('[relay-init] Blocked relays (10006):', blockedRelays.length, 'relays', blockedRelays)
|
||||
|
||||
const finalRelays = computeRelaySet({
|
||||
hardcoded: userRelayList.length > 0 ? [] : RELAYS,
|
||||
@@ -641,10 +635,9 @@ function App() {
|
||||
blocked: blockedRelays,
|
||||
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
|
||||
})
|
||||
console.log('[relay-init] Final relay set (with user preferences):', finalRelays.length, 'relays', finalRelays)
|
||||
|
||||
applyRelaySetToPool(pool, finalRelays)
|
||||
console.log('[relay-init] After user relay list apply, pool has:', Array.from(pool.relays.keys()).length, 'relays')
|
||||
console.log('[relay-init] Final relay URLs:', Array.from(pool.relays.keys()))
|
||||
|
||||
updateKeepAlive()
|
||||
|
||||
// Update address loader with new relays
|
||||
@@ -661,10 +654,9 @@ function App() {
|
||||
})
|
||||
} else {
|
||||
// User logged out - reset to hardcoded relays
|
||||
console.log('[relay-init] Applying RELAYS for logged out user, RELAYS.length:', RELAYS.length)
|
||||
|
||||
applyRelaySetToPool(pool, RELAYS)
|
||||
console.log('[relay-init] After applyRelaySetToPool (logged out), pool has:', Array.from(pool.relays.keys()).length, 'relays')
|
||||
console.log('[relay-init] Relay URLs:', Array.from(pool.relays.keys()))
|
||||
|
||||
|
||||
// Update keep-alive subscription
|
||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||
|
||||
@@ -6,18 +6,26 @@ import { formatDistance } from 'date-fns'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { isKnownBot } from '../config/bots'
|
||||
|
||||
interface BlogPostCardProps {
|
||||
post: BlogPostPreview
|
||||
href: string
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
readingProgress?: number // 0-1 reading progress (optional)
|
||||
hideBotByName?: boolean // default true
|
||||
}
|
||||
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress }) => {
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress, hideBotByName = true }) => {
|
||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||
const displayName = profile?.name || profile?.display_name ||
|
||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||
const rawName = (profile?.name || profile?.display_name || '').toLowerCase()
|
||||
|
||||
// Hide bot authors by name/display_name
|
||||
if (hideBotByName && (rawName.includes('bot') || isKnownBot(post.author))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const publishedDate = post.published || post.event.created_at
|
||||
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
|
||||
|
||||
@@ -145,8 +145,20 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
}
|
||||
|
||||
if (viewMode === 'compact') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const { articleImage, ...compactProps } = sharedProps
|
||||
const compactProps = {
|
||||
bookmark,
|
||||
index,
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary,
|
||||
contentTypeIcon: getContentTypeIcon(),
|
||||
readingProgress
|
||||
}
|
||||
return <CompactView {...compactProps} />
|
||||
}
|
||||
|
||||
|
||||
@@ -285,6 +285,13 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
</div>
|
||||
{activeAccount && (
|
||||
<div className="view-mode-right">
|
||||
<IconButton
|
||||
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
|
||||
onClick={toggleGroupingMode}
|
||||
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||
variant="ghost"
|
||||
/>
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
@@ -296,13 +303,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
|
||||
onClick={toggleGroupingMode}
|
||||
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
|
||||
@@ -434,11 +434,7 @@ const Debug: React.FC<DebugProps> = ({
|
||||
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setTLoadHighlights(elapsed)
|
||||
setLiveTiming(prev => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const { loadHighlights, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
setLiveTiming(prev => ({ ...prev, loadHighlights: undefined }))
|
||||
|
||||
DebugBus.info('debug', `Loaded ${events.length} highlight events in ${elapsed}ms`)
|
||||
} catch (err) {
|
||||
@@ -798,11 +794,7 @@ const Debug: React.FC<DebugProps> = ({
|
||||
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setTLoadReadingProgress(elapsed)
|
||||
setLiveTiming(prev => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const { loadReadingProgress, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
setLiveTiming(prev => ({ ...prev, loadReadingProgress: undefined }))
|
||||
|
||||
const finalMap = readingProgressController.getProgressMap()
|
||||
DebugBus.info('debug', `Loaded ${rawEvents.length} raw events, deduplicated to ${finalMap.size} articles in ${elapsed}ms`)
|
||||
@@ -871,11 +863,7 @@ const Debug: React.FC<DebugProps> = ({
|
||||
const totalEvents = kind7Events.length + kind17Events.length
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setTLoadMarkAsRead(elapsed)
|
||||
setLiveTiming(prev => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const { loadMarkAsRead, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
setLiveTiming(prev => ({ ...prev, loadMarkAsRead: undefined }))
|
||||
|
||||
DebugBus.info('debug', `Loaded ${totalEvents} mark-as-read reactions in ${elapsed}ms`)
|
||||
} catch (err) {
|
||||
@@ -929,11 +917,7 @@ const Debug: React.FC<DebugProps> = ({
|
||||
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setTLoadRelayList(elapsed)
|
||||
setLiveTiming(prev => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const { loadRelayList, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
setLiveTiming(prev => ({ ...prev, loadRelayList: undefined }))
|
||||
|
||||
DebugBus.info('debug', `Loaded ${events.length} relay list events in ${elapsed}ms`)
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import IconButton from './IconButton'
|
||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore, Helpers } from 'applesauce-core'
|
||||
import { nip19, NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
// Contacts are managed via controller subscription
|
||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||
import { fetchProfiles } from '../services/profileService'
|
||||
@@ -19,20 +19,22 @@ import { Highlight } from '../types/highlights'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
|
||||
import { getCachedPosts, setCachedPosts, getCachedHighlights, setCachedHighlights } from '../services/exploreCache'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||
// import { KINDS } from '../config/kinds'
|
||||
// import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||
// import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
|
||||
import { writingsController } from '../services/writingsController'
|
||||
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
|
||||
import { readingProgressController } from '../services/readingProgressController'
|
||||
import { contactsController } from '../services/contactsController'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
// Accessors from Helpers (currently unused here)
|
||||
// const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
interface ExploreProps {
|
||||
relayPool: RelayPool
|
||||
@@ -55,27 +57,28 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
|
||||
const [hasLoadedMine, setHasLoadedMine] = useState(false)
|
||||
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
|
||||
const hasHydratedRef = useRef(false)
|
||||
|
||||
// Get myHighlights directly from controller
|
||||
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||
const [/* myHighlights */, setMyHighlights] = useState<Highlight[]>([])
|
||||
// Remove unused loading state to avoid warnings
|
||||
|
||||
// Reading progress state (naddr -> progress 0-1)
|
||||
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||
|
||||
// Load cached content from event store (instant display)
|
||||
const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
|
||||
// const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
|
||||
|
||||
const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}), [])
|
||||
// const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
|
||||
// event,
|
||||
// title: getArticleTitle(event) || 'Untitled',
|
||||
// summary: getArticleSummary(event),
|
||||
// image: getArticleImage(event),
|
||||
// published: getArticlePublished(event),
|
||||
// author: event.pubkey
|
||||
// }), [])
|
||||
|
||||
const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
|
||||
// const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
|
||||
|
||||
|
||||
|
||||
@@ -105,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
|
||||
useEffect(() => {
|
||||
const apply = (incoming: Highlight[]) => {
|
||||
@@ -230,242 +248,95 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
}
|
||||
}, [propActiveTab])
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// begin load, but do not block rendering
|
||||
setLoading(true)
|
||||
// Load initial data and refresh on triggers
|
||||
const loadData = useCallback(() => {
|
||||
if (!relayPool) return
|
||||
|
||||
// If not logged in, only fetch nostrverse content with streaming posts
|
||||
if (!activeAccount) {
|
||||
// Logged out: rely entirely on centralized controllers; do not fetch here
|
||||
setLoading(false)
|
||||
}
|
||||
// Seed from cache for instant UI
|
||||
if (activeAccount) {
|
||||
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||
if (cachedPosts && cachedPosts.length > 0) setBlogPosts(cachedPosts)
|
||||
const cached = getCachedHighlights(activeAccount.pubkey)
|
||||
if (cached && cached.length > 0) setHighlights(cached)
|
||||
}
|
||||
|
||||
// Seed from in-memory cache if available to avoid empty flash
|
||||
const memoryCachedPosts = activeAccount ? getCachedPosts(activeAccount.pubkey) : []
|
||||
if (memoryCachedPosts && memoryCachedPosts.length > 0) {
|
||||
setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev)
|
||||
}
|
||||
const memoryCachedHighlights = activeAccount ? getCachedHighlights(activeAccount.pubkey) : []
|
||||
if (memoryCachedHighlights && memoryCachedHighlights.length > 0) {
|
||||
setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev)
|
||||
}
|
||||
|
||||
// Seed with cached content from event store (instant display)
|
||||
if (cachedHighlights.length > 0 || myHighlights.length > 0) {
|
||||
const merged = dedupeHighlightsById([...cachedHighlights, ...myHighlights])
|
||||
setHighlights(prev => {
|
||||
const all = dedupeHighlightsById([...prev, ...merged])
|
||||
return all.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
}
|
||||
|
||||
// Seed with cached writings from event store
|
||||
if (cachedWritings.length > 0) {
|
||||
setBlogPosts(prev => {
|
||||
const all = dedupeWritingsByReplaceable([...prev, ...cachedWritings])
|
||||
return all.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
})
|
||||
}
|
||||
setLoading(true)
|
||||
|
||||
// At this point, we have seeded any available data; lift the loading state
|
||||
setLoading(false)
|
||||
try {
|
||||
// Prepare parallel fetches
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
|
||||
// Fetch the user's contacts (friends)
|
||||
const contacts = await fetchContacts(
|
||||
// Nostrverse writings: subscribe-style via onPost; hydrate on first post
|
||||
if (!activeAccount || (activeAccount && visibility.nostrverse)) {
|
||||
fetchNostrverseBlogPosts(
|
||||
relayPool,
|
||||
activeAccount?.pubkey || '',
|
||||
(partial) => {
|
||||
// Store followed pubkeys for highlight classification
|
||||
setFollowedPubkeys(partial)
|
||||
// When local contacts are available, kick off early fetch
|
||||
if (partial.size > 0) {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const partialArray = Array.from(partial)
|
||||
|
||||
// Fetch blog posts
|
||||
fetchBlogPostsFromAuthors(
|
||||
relayPool,
|
||||
partialArray,
|
||||
relayUrls,
|
||||
(post) => {
|
||||
setBlogPosts((prev) => {
|
||||
// Deduplicate by author:d-tag (replaceable event key)
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${post.author}:${dTag}`
|
||||
const existingIndex = prev.findIndex(p => {
|
||||
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
return `${p.author}:${pDTag}` === key
|
||||
})
|
||||
|
||||
// If exists, only replace if this one is newer
|
||||
if (existingIndex >= 0) {
|
||||
const existing = prev[existingIndex]
|
||||
if (post.event.created_at <= existing.event.created_at) {
|
||||
return prev // Keep existing (newer or same)
|
||||
}
|
||||
// Replace with newer version
|
||||
const next = [...prev]
|
||||
next[existingIndex] = post
|
||||
return next.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
}
|
||||
|
||||
// New post, add it
|
||||
const next = [...prev, post]
|
||||
return next.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
})
|
||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
|
||||
}
|
||||
).then((all) => {
|
||||
setBlogPosts((prev) => {
|
||||
// Deduplicate by author:d-tag (replaceable event key)
|
||||
const byKey = new Map<string, BlogPostPreview>()
|
||||
|
||||
// Add existing posts
|
||||
for (const p of prev) {
|
||||
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${p.author}:${dTag}`
|
||||
byKey.set(key, p)
|
||||
}
|
||||
|
||||
// Merge in new posts (keeping newer versions)
|
||||
for (const post of all) {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${post.author}:${dTag}`
|
||||
const existing = byKey.get(key)
|
||||
if (!existing || post.event.created_at > existing.event.created_at) {
|
||||
byKey.set(key, post)
|
||||
}
|
||||
}
|
||||
|
||||
const merged = Array.from(byKey.values()).sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||
return merged
|
||||
})
|
||||
})
|
||||
|
||||
// Fetch highlights
|
||||
fetchHighlightsFromAuthors(
|
||||
relayPool,
|
||||
partialArray,
|
||||
(highlight) => {
|
||||
setHighlights((prev) => {
|
||||
const exists = prev.some(h => h.id === highlight.id)
|
||||
if (exists) return prev
|
||||
const next = [...prev, highlight]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
if (activeAccount) setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
|
||||
}
|
||||
).then((all) => {
|
||||
setHighlights((prev) => {
|
||||
const byId = new Map(prev.map(h => [h.id, h]))
|
||||
for (const highlight of all) byId.set(highlight.id, highlight)
|
||||
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
|
||||
return merged
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Always proceed to load nostrverse content even if no contacts
|
||||
// (removed blocking error for empty contacts)
|
||||
|
||||
// Store final followed pubkeys
|
||||
setFollowedPubkeys(contacts)
|
||||
|
||||
// Fetch friends content and (optionally) nostrverse + mine content in parallel
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const contactsArray = Array.from(contacts)
|
||||
// Use centralized writingsController for my posts (non-blocking)
|
||||
// pull from writingsController; no need to store promise
|
||||
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...writingsController.getWritings()]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
||||
setHasLoadedMine(true)
|
||||
const nostrversePostsPromise = visibility.nostrverse
|
||||
? fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined, (post) => {
|
||||
// Stream nostrverse posts too when logged in
|
||||
setBlogPosts(prev => {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${post.author}:${dTag}`
|
||||
const existingIndex = prev.findIndex(p => {
|
||||
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
return `${p.author}:${pDTag}` === key
|
||||
})
|
||||
if (existingIndex >= 0) {
|
||||
const existing = prev[existingIndex]
|
||||
if (post.event.created_at <= existing.event.created_at) return prev
|
||||
const next = [...prev]
|
||||
next[existingIndex] = post
|
||||
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
}
|
||||
const next = [...prev, post]
|
||||
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
})
|
||||
})
|
||||
: Promise.resolve([] as BlogPostPreview[])
|
||||
|
||||
// Fire non-blocking fetches and merge as they resolve
|
||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls)
|
||||
.then((friendsPosts) => {
|
||||
relayUrls,
|
||||
50,
|
||||
eventStore || undefined,
|
||||
(post) => {
|
||||
setBlogPosts(prev => {
|
||||
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
|
||||
const sorted = merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, sorted)
|
||||
// Pre-cache profiles in background
|
||||
const authorPubkeys = Array.from(new Set(sorted.map(p => p.author)))
|
||||
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {})
|
||||
return sorted
|
||||
const merged = dedupeWritingsByReplaceable([...prev, post])
|
||||
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)
|
||||
.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) => {
|
||||
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
|
||||
}
|
||||
).then((nostrversePosts) => {
|
||||
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
||||
}).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) {
|
||||
console.error('Failed to load data:', err)
|
||||
// No blocking error - user can pull-to-refresh
|
||||
} finally {
|
||||
// loading is already turned off after seeding
|
||||
}
|
||||
}
|
||||
}, [relayPool, activeAccount, eventStore, visibility.nostrverse])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
|
||||
}, [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) }
|
||||
}).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)
|
||||
useEffect(() => {
|
||||
@@ -509,7 +380,12 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
})
|
||||
}).catch(() => {})
|
||||
}, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverse])
|
||||
|
||||
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
||||
.then((nostriverseHighlights) => {
|
||||
setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at))
|
||||
}).catch(() => {})
|
||||
}, [activeAccount, relayPool, visibility.nostrverse, hasLoadedNostrverse, eventStore])
|
||||
|
||||
// Lazy-load nostrverse highlights when user toggles it on (logged in)
|
||||
useEffect(() => {
|
||||
@@ -586,6 +462,12 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
const publishedTime = post.published || post.event.created_at
|
||||
if (publishedTime > maxFutureTime) return false
|
||||
|
||||
// Hide bot authors by profile display name if setting enabled
|
||||
if (settings?.hideBotArticlesByName !== false) {
|
||||
// Profile resolution and filtering is handled in BlogPostCard via ProfileModel
|
||||
// Keep list intact here; individual cards will render null if author is a bot
|
||||
}
|
||||
|
||||
// Apply visibility filters
|
||||
const isMine = activeAccount && post.author === activeAccount.pubkey
|
||||
const isFriend = followedPubkeys.has(post.author)
|
||||
@@ -604,7 +486,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
||||
return { ...post, level }
|
||||
})
|
||||
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility])
|
||||
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility, settings?.hideBotArticlesByName])
|
||||
|
||||
// Helper to get reading progress for a post
|
||||
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
|
||||
@@ -653,6 +535,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
href={getPostUrl(post)}
|
||||
level={post.level}
|
||||
readingProgress={getReadingProgress(post)}
|
||||
hideBotByName={settings?.hideBotArticlesByName !== false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
@@ -57,7 +57,7 @@ const Me: React.FC<MeProps> = ({
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const navigate = useNavigate()
|
||||
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||
const activeTab = propActiveTab || 'highlights'
|
||||
|
||||
// Only for own profile
|
||||
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
|
||||
useEffect(() => {
|
||||
const normalized = urlFilter === 'emoji' ? 'archive' : urlFilter
|
||||
@@ -205,15 +198,15 @@ const Me: React.FC<MeProps> = ({
|
||||
}, [viewingPubkey, relayPool, eventStore, refreshTrigger])
|
||||
|
||||
// Tab-specific loading functions
|
||||
const loadHighlightsTab = async () => {
|
||||
const loadHighlightsTab = useCallback(async () => {
|
||||
if (!viewingPubkey) return
|
||||
|
||||
// Highlights come from controller subscription (sync effect handles it)
|
||||
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
||||
setLoading(false)
|
||||
}
|
||||
}, [viewingPubkey])
|
||||
|
||||
const loadWritingsTab = async () => {
|
||||
const loadWritingsTab = useCallback(async () => {
|
||||
if (!viewingPubkey) return
|
||||
|
||||
try {
|
||||
@@ -230,28 +223,29 @@ const Me: React.FC<MeProps> = ({
|
||||
console.error('Failed to load writings:', err)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [viewingPubkey, relayPool, eventStore, refreshTrigger])
|
||||
|
||||
const loadReadingListTab = async () => {
|
||||
const loadReadingListTab = useCallback(async () => {
|
||||
if (!viewingPubkey || !activeAccount) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('reading-list')
|
||||
|
||||
try {
|
||||
setLoadedTabs(prev => {
|
||||
const hasBeenLoaded = prev.has('reading-list')
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
// Bookmarks come from centralized loading in App.tsx
|
||||
setLoadedTabs(prev => new Set(prev).add('reading-list'))
|
||||
} catch (err) {
|
||||
console.error('Failed to load reading list:', err)
|
||||
} finally {
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
}
|
||||
}
|
||||
return new Set(prev).add('reading-list')
|
||||
})
|
||||
|
||||
// Always turn off loading after a tick
|
||||
setTimeout(() => setLoading(false), 0)
|
||||
}, [viewingPubkey, activeAccount])
|
||||
|
||||
const loadReadsTab = async () => {
|
||||
const loadReadsTab = useCallback(async () => {
|
||||
if (!viewingPubkey || !activeAccount) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('reads')
|
||||
let hasBeenLoaded = false
|
||||
setLoadedTabs(prev => {
|
||||
hasBeenLoaded = prev.has('reads')
|
||||
return prev
|
||||
})
|
||||
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
@@ -270,12 +264,16 @@ const Me: React.FC<MeProps> = ({
|
||||
console.error('Failed to load reads:', err)
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [viewingPubkey, activeAccount, relayPool, eventStore])
|
||||
|
||||
const loadLinksTab = async () => {
|
||||
const loadLinksTab = useCallback(async () => {
|
||||
if (!viewingPubkey || !activeAccount) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('links')
|
||||
let hasBeenLoaded = false
|
||||
setLoadedTabs(prev => {
|
||||
hasBeenLoaded = prev.has('links')
|
||||
return prev
|
||||
})
|
||||
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
@@ -310,10 +308,10 @@ const Me: React.FC<MeProps> = ({
|
||||
console.error('Failed to load links:', err)
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [viewingPubkey, activeAccount, bookmarks, relayPool, readingProgressMap])
|
||||
|
||||
// Load active tab data
|
||||
useEffect(() => {
|
||||
const loadActiveTab = useCallback(() => {
|
||||
if (!viewingPubkey || !activeTab) {
|
||||
setLoading(false)
|
||||
return
|
||||
@@ -346,8 +344,11 @@ const Me: React.FC<MeProps> = ({
|
||||
loadLinksTab()
|
||||
break
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab, viewingPubkey, refreshTrigger, bookmarks])
|
||||
}, [viewingPubkey, activeTab, loadHighlightsTab, loadWritingsTab, loadReadingListTab, loadReadsTab, loadLinksTab])
|
||||
|
||||
useEffect(() => {
|
||||
loadActiveTab()
|
||||
}, [loadActiveTab])
|
||||
|
||||
// Sync myHighlights from controller
|
||||
useEffect(() => {
|
||||
@@ -829,6 +830,7 @@ const Me: React.FC<MeProps> = ({
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
readingProgress={getWritingReadingProgress(post)}
|
||||
hideBotByName={settings.hideBotArticlesByName !== false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,19 @@ const ExploreSettings: React.FC<ExploreSettingsProps> = ({ settings, onUpdate })
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="hideBotArticlesByName" className="checkbox-label">
|
||||
<input
|
||||
id="hideBotArticlesByName"
|
||||
type="checkbox"
|
||||
checked={settings.hideBotArticlesByName !== false}
|
||||
onChange={(e) => onUpdate({ hideBotArticlesByName: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Hide content posted by bots</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
99
src/components/ShareTargetHandler.tsx
Normal file
99
src/components/ShareTargetHandler.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { createWebBookmark } from '../services/webBookmarkService'
|
||||
import { getActiveRelayUrls } from '../services/relayManager'
|
||||
import { useToast } from '../hooks/useToast'
|
||||
|
||||
interface ShareTargetHandlerProps {
|
||||
relayPool: RelayPool
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming shared URLs from the Web Share Target API.
|
||||
* Auto-saves the shared URL as a web bookmark (NIP-B0).
|
||||
*/
|
||||
export default function ShareTargetHandler({ relayPool }: ShareTargetHandlerProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const { showToast } = useToast()
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [waitingForLogin, setWaitingForLogin] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleSharedContent = async () => {
|
||||
// Parse query parameters
|
||||
const params = new URLSearchParams(location.search)
|
||||
const link = params.get('link')
|
||||
const title = params.get('title')
|
||||
const text = params.get('text')
|
||||
|
||||
// Validate we have a URL
|
||||
if (!link) {
|
||||
showToast('No URL to save')
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
|
||||
// If no active account, wait for login
|
||||
if (!activeAccount) {
|
||||
setWaitingForLogin(true)
|
||||
showToast('Please log in to save this bookmark')
|
||||
return
|
||||
}
|
||||
|
||||
// We have account and URL, proceed with saving
|
||||
if (!processing) {
|
||||
setProcessing(true)
|
||||
try {
|
||||
await createWebBookmark(
|
||||
link,
|
||||
title || undefined,
|
||||
text || undefined,
|
||||
undefined,
|
||||
activeAccount,
|
||||
relayPool,
|
||||
getActiveRelayUrls(relayPool)
|
||||
)
|
||||
showToast('Bookmark saved!')
|
||||
navigate('/me/links')
|
||||
} catch (err) {
|
||||
console.error('Failed to save shared bookmark:', err)
|
||||
showToast('Failed to save bookmark')
|
||||
navigate('/')
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleSharedContent()
|
||||
}, [activeAccount, location.search, navigate, relayPool, showToast, processing])
|
||||
|
||||
// Show waiting for login state
|
||||
if (waitingForLogin && !activeAccount) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-center">
|
||||
<FontAwesomeIcon icon={faSpinner} spin className="text-4xl mb-4" />
|
||||
<p className="text-lg">Waiting for login...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show processing state
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-center">
|
||||
<FontAwesomeIcon icon={faSpinner} spin className="text-4xl mb-4" />
|
||||
<p className="text-lg">Saving bookmark...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
17
src/config/bots.ts
Normal file
17
src/config/bots.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
/**
|
||||
* Hardcoded list of bot pubkeys (hex format) to hide articles from
|
||||
* These are accounts known to be bots or automated services
|
||||
*/
|
||||
export const BOT_PUBKEYS = new Set([
|
||||
// Step Counter Bot (npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss)
|
||||
nip19.decode('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss').data as string,
|
||||
])
|
||||
|
||||
/**
|
||||
* Check if a pubkey corresponds to a known bot
|
||||
*/
|
||||
export function isKnownBot(pubkey: string): boolean {
|
||||
return BOT_PUBKEYS.has(pubkey)
|
||||
}
|
||||
@@ -120,7 +120,18 @@ export function useArticleLoader({
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
// Intentionally excluding setter functions from dependencies to prevent race conditions
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [naddr, relayPool, settings])
|
||||
}, [
|
||||
naddr,
|
||||
relayPool,
|
||||
settings,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId,
|
||||
setCurrentArticle
|
||||
])
|
||||
}
|
||||
|
||||
@@ -154,9 +154,20 @@ export function useExternalUrlLoader({
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
// Intentionally excluding setter functions from dependencies to prevent race conditions
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url, relayPool, eventStore, cachedUrlHighlights])
|
||||
}, [
|
||||
url,
|
||||
relayPool,
|
||||
eventStore,
|
||||
cachedUrlHighlights,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setSelectedUrl,
|
||||
setHighlights,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId,
|
||||
setHighlightsLoading
|
||||
])
|
||||
|
||||
// Keep UI highlights synced with cached store updates without reloading content
|
||||
useEffect(() => {
|
||||
@@ -169,8 +180,6 @@ export function useExternalUrlLoader({
|
||||
const next = [...additions, ...prev]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
// setHighlights is intentionally excluded from dependencies - it's stable
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cachedUrlHighlights, url])
|
||||
}, [cachedUrlHighlights, url, setHighlights])
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export const useReadingPosition = ({
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const hasSavedOnce = useRef(false)
|
||||
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const lastSavedAtRef = useRef<number>(0)
|
||||
|
||||
// Debounced save function
|
||||
const scheduleSave = useCallback((currentPosition: number) => {
|
||||
@@ -36,14 +37,49 @@ export const useReadingPosition = ({
|
||||
return
|
||||
}
|
||||
|
||||
// Don't save if position hasn't changed significantly (less than 1%)
|
||||
// But always save if we've reached 100% (completion)
|
||||
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
|
||||
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
|
||||
const isInitialSave = !hasSavedOnce.current
|
||||
|
||||
if (!hasSignificantChange && !hasReachedCompletion && !isInitialSave) {
|
||||
// Not significant enough to save
|
||||
// Always save instantly when we reach completion (1.0)
|
||||
if (currentPosition === 1 && lastSavedPosition.current < 1) {
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = null
|
||||
}
|
||||
lastSavedPosition.current = 1
|
||||
hasSavedOnce.current = true
|
||||
lastSavedAtRef.current = Date.now()
|
||||
onSave(1)
|
||||
return
|
||||
}
|
||||
|
||||
// Require at least 5% progress change to consider saving
|
||||
const MIN_DELTA = 0.05
|
||||
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= MIN_DELTA
|
||||
|
||||
// Enforce a minimum interval between saves (15s) to avoid spamming
|
||||
const MIN_INTERVAL_MS = 15000
|
||||
const nowMs = Date.now()
|
||||
const enoughTimeElapsed = nowMs - lastSavedAtRef.current >= MIN_INTERVAL_MS
|
||||
|
||||
// Allow the very first meaningful save (when crossing 5%) regardless of interval
|
||||
const isFirstMeaningful = !hasSavedOnce.current && currentPosition >= MIN_DELTA
|
||||
|
||||
if (!hasSignificantChange && !isFirstMeaningful) {
|
||||
return
|
||||
}
|
||||
|
||||
// If interval hasn't elapsed yet, delay until autoSaveInterval but still cap frequency
|
||||
if (!enoughTimeElapsed && !isFirstMeaningful) {
|
||||
// Clear and reschedule within the remaining window, but not sooner than MIN_INTERVAL_MS
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
const remaining = Math.max(0, MIN_INTERVAL_MS - (nowMs - lastSavedAtRef.current))
|
||||
const delay = Math.max(autoSaveInterval, remaining)
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
lastSavedPosition.current = currentPosition
|
||||
hasSavedOnce.current = true
|
||||
lastSavedAtRef.current = Date.now()
|
||||
onSave(currentPosition)
|
||||
}, delay)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -52,27 +88,26 @@ export const useReadingPosition = ({
|
||||
clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
|
||||
// Schedule new save
|
||||
// Schedule new save using the larger of autoSaveInterval and MIN_INTERVAL_MS
|
||||
const delay = Math.max(autoSaveInterval, MIN_INTERVAL_MS)
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
lastSavedPosition.current = currentPosition
|
||||
hasSavedOnce.current = true
|
||||
lastSavedAtRef.current = Date.now()
|
||||
onSave(currentPosition)
|
||||
}, autoSaveInterval)
|
||||
}, delay)
|
||||
}, [syncEnabled, onSave, autoSaveInterval])
|
||||
|
||||
// Immediate save function
|
||||
const saveNow = useCallback(() => {
|
||||
if (!syncEnabled || !onSave) return
|
||||
|
||||
// Cancel any pending saves
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = null
|
||||
}
|
||||
|
||||
// Always allow immediate save (including 0%)
|
||||
lastSavedPosition.current = position
|
||||
hasSavedOnce.current = true
|
||||
lastSavedAtRef.current = Date.now()
|
||||
onSave(position)
|
||||
}, [syncEnabled, onSave, position])
|
||||
|
||||
@@ -97,13 +132,6 @@ export const useReadingPosition = ({
|
||||
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
|
||||
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
||||
|
||||
// Only log on significant changes (every 5%) to avoid flooding console
|
||||
const prevPercent = Math.floor(position * 20) // Groups by 5%
|
||||
const newPercent = Math.floor(clampedProgress * 20)
|
||||
if (prevPercent !== newPercent) {
|
||||
// Position threshold crossed
|
||||
}
|
||||
|
||||
setPosition(clampedProgress)
|
||||
positionRef.current = clampedProgress
|
||||
onPositionChange?.(clampedProgress)
|
||||
@@ -160,9 +188,7 @@ export const useReadingPosition = ({
|
||||
clearTimeout(completionTimerRef.current)
|
||||
}
|
||||
}
|
||||
// position is intentionally not in deps - it's computed from scroll and would cause infinite re-renders
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
|
||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave, completionHoldMs])
|
||||
|
||||
// Reset reading complete state when enabled changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -16,7 +16,7 @@ interface UseSettingsParams {
|
||||
}
|
||||
|
||||
export function useSettings({ relayPool, eventStore, pubkey, accountManager }: UseSettingsParams) {
|
||||
const [settings, setSettings] = useState<UserSettings>({ renderVideoLinksAsEmbeds: true })
|
||||
const [settings, setSettings] = useState<UserSettings>({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true })
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const [toastType, setToastType] = useState<'success' | 'error'>('success')
|
||||
|
||||
@@ -27,7 +27,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
const loadAndWatch = async () => {
|
||||
try {
|
||||
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
|
||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, ...loadedSettings })
|
||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err)
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
loadAndWatch()
|
||||
|
||||
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
|
||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, ...loadedSettings })
|
||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||
})
|
||||
|
||||
return () => subscription.unsubscribe()
|
||||
|
||||
@@ -50,6 +50,11 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null)
|
||||
const spokenTextRef = useRef<string>('')
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -79,11 +84,21 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
}
|
||||
}, [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 u = new SpeechSynthesisUtteranceConstructor(text) as SpeechSynthesisUtterance
|
||||
u.lang = voice?.lang || defaultLang
|
||||
if (voice) u.voice = voice
|
||||
const resolvedLang = langOverride || voice?.lang || defaultLang
|
||||
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.pitch = pitch
|
||||
u.volume = volume
|
||||
@@ -109,6 +124,17 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
u.onend = () => {
|
||||
if (utteranceRef.current !== self) return
|
||||
console.debug('[tts] onend')
|
||||
// Continue with next chunk if available
|
||||
const hasMore = chunkIndexRef.current < (chunksRef.current.length - 1)
|
||||
if (hasMore) {
|
||||
chunkIndexRef.current += 1
|
||||
globalOffsetRef.current += self.text.length
|
||||
const next = chunksRef.current[chunkIndexRef.current] || ''
|
||||
const nextUtterance = createUtterance(next, langRef.current)
|
||||
utteranceRef.current = nextUtterance
|
||||
synth!.speak(nextUtterance)
|
||||
return
|
||||
}
|
||||
setSpeaking(false)
|
||||
setPaused(false)
|
||||
utteranceRef.current = null
|
||||
@@ -123,7 +149,7 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
u.onboundary = (ev: SpeechSynthesisEvent) => {
|
||||
if (utteranceRef.current !== self) return
|
||||
if (typeof ev.charIndex === 'number') {
|
||||
const newIndex = ev.charIndex
|
||||
const newIndex = globalOffsetRef.current + ev.charIndex
|
||||
if (newIndex > charIndexRef.current) {
|
||||
charIndexRef.current = newIndex
|
||||
}
|
||||
@@ -131,7 +157,43 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (!supported) return
|
||||
@@ -142,6 +204,9 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
utteranceRef.current = null
|
||||
charIndexRef.current = 0
|
||||
spokenTextRef.current = ''
|
||||
chunksRef.current = []
|
||||
chunkIndexRef.current = 0
|
||||
globalOffsetRef.current = 0
|
||||
}, [supported, synth])
|
||||
|
||||
const speak = useCallback((text: string, langOverride?: string) => {
|
||||
@@ -150,19 +215,9 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
synth!.cancel()
|
||||
spokenTextRef.current = text
|
||||
charIndexRef.current = 0
|
||||
|
||||
const u = createUtterance(text)
|
||||
if (langOverride) {
|
||||
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])
|
||||
langRef.current = langOverride
|
||||
startSpeakingChunks(text)
|
||||
}, [supported, synth, startSpeakingChunks, rate])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (!supported) return
|
||||
@@ -191,21 +246,23 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
|
||||
if (synth!.speaking && !synth!.paused) {
|
||||
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)
|
||||
|
||||
console.debug('[tts] restart at new rate', { startIndex, remainingLen: remainingText.length })
|
||||
synth!.cancel()
|
||||
const u = createUtterance(remainingText)
|
||||
utteranceRef.current = u
|
||||
synth!.speak(u)
|
||||
// restart chunked from current global index
|
||||
spokenTextRef.current = remainingText
|
||||
charIndexRef.current = 0
|
||||
// keep current language selection; no change needed here
|
||||
startSpeakingChunks(remainingText)
|
||||
return
|
||||
}
|
||||
|
||||
if (utteranceRef.current) {
|
||||
utteranceRef.current.rate = rate
|
||||
}
|
||||
}, [rate, supported, synth, createUtterance])
|
||||
}, [rate, supported, synth, startSpeakingChunks])
|
||||
|
||||
const updateRate = useCallback((newRate: number) => {
|
||||
setRate(newRate)
|
||||
|
||||
@@ -18,11 +18,7 @@ export async function loadUserRelayList(
|
||||
}
|
||||
): Promise<UserRelayInfo[]> {
|
||||
try {
|
||||
console.log('[relayListService] Loading user relay list for pubkey:', pubkey.slice(0, 16) + '...')
|
||||
console.log('[relayListService] Available relays:', Array.from(relayPool.relays.keys()))
|
||||
|
||||
console.log('[relayListService] Starting query for kind 10002...')
|
||||
const startTime = Date.now()
|
||||
|
||||
// Try querying with streaming callback for faster results
|
||||
const events: NostrEvent[] = []
|
||||
@@ -64,22 +60,13 @@ export async function loadUserRelayList(
|
||||
// Use the streaming results if we got any, otherwise fall back to the full result
|
||||
const finalEvents = events.length > 0 ? events : result
|
||||
|
||||
const queryTime = Date.now() - startTime
|
||||
console.log('[relayListService] Query completed in', queryTime, 'ms')
|
||||
|
||||
// Also try a broader query to see if we get any events at all
|
||||
console.log('[relayListService] Trying broader query for any kind 10002 events...')
|
||||
const allEvents = await queryEvents(relayPool, {
|
||||
await queryEvents(relayPool, {
|
||||
kinds: [10002],
|
||||
limit: 5
|
||||
})
|
||||
console.log('[relayListService] Found', allEvents.length, 'total kind 10002 events from any author')
|
||||
|
||||
|
||||
console.log('[relayListService] Found', finalEvents.length, 'kind 10002 events')
|
||||
if (finalEvents.length > 0) {
|
||||
console.log('[relayListService] Event details:', finalEvents.map(e => ({ id: e.id, created_at: e.created_at, tags: e.tags.length })))
|
||||
}
|
||||
|
||||
|
||||
if (finalEvents.length === 0) return []
|
||||
|
||||
@@ -99,7 +86,6 @@ export async function loadUserRelayList(
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[relayListService] Parsed', relays.length, 'relays from event')
|
||||
return relays
|
||||
} catch (error) {
|
||||
console.error('Failed to load user relay list:', error)
|
||||
|
||||
@@ -46,13 +46,11 @@ export function applyRelaySetToPool(
|
||||
const currentUrls = new Set(Array.from(relayPool.relays.keys()))
|
||||
const normalizedTargetUrls = new Set(finalUrls.map(normalizeRelayUrl))
|
||||
|
||||
console.log('[relayManager] applyRelaySetToPool called')
|
||||
console.log('[relayManager] Current pool has:', currentUrls.size, 'relays')
|
||||
console.log('[relayManager] Target has:', finalUrls.length, 'relays')
|
||||
|
||||
|
||||
// Add new relays (use original URLs for adding, not normalized)
|
||||
const toAdd = finalUrls.filter(url => !currentUrls.has(normalizeRelayUrl(url)))
|
||||
console.log('[relayManager] Will add:', toAdd.length, 'relays', toAdd)
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
relayPool.group(toAdd)
|
||||
}
|
||||
@@ -71,7 +69,7 @@ export function applyRelaySetToPool(
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('[relayManager] Will remove:', toRemove.length, 'relays', toRemove)
|
||||
|
||||
|
||||
for (const url of toRemove) {
|
||||
const relay = relayPool.relays.get(url)
|
||||
@@ -81,6 +79,6 @@ export function applyRelaySetToPool(
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[relayManager] After apply, pool has:', relayPool.relays.size, 'relays')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface UserSettings {
|
||||
autoMarkAsReadOnCompletion?: boolean // default: false (opt-in)
|
||||
// Bookmark filtering
|
||||
hideBookmarksWithoutCreationDate?: boolean // default: false
|
||||
// Content filtering
|
||||
hideBotArticlesByName?: boolean // default: true - hide authors whose profile name includes "bot"
|
||||
// TTS language selection
|
||||
ttsUseSystemLanguage?: boolean // default: false
|
||||
ttsDetectContentLanguage?: boolean // default: true
|
||||
|
||||
34
src/sw.ts
34
src/sw.ts
@@ -98,10 +98,42 @@ sw.addEventListener('message', (event: ExtendableMessageEvent) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Log fetch errors for debugging (doesn't affect functionality)
|
||||
// Handle Web Share Target POST requests
|
||||
sw.addEventListener('fetch', (event: FetchEvent) => {
|
||||
const url = new URL(event.request.url)
|
||||
|
||||
// Handle POST to /share-target (Web Share Target API)
|
||||
if (event.request.method === 'POST' && url.pathname === '/share-target') {
|
||||
event.respondWith((async () => {
|
||||
const formData = await event.request.formData()
|
||||
const title = (formData.get('title') || '').toString()
|
||||
const text = (formData.get('text') || '').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
|
||||
if (!link && text) {
|
||||
const urlMatch = text.match(/https?:\/\/[^\s]+/)
|
||||
if (urlMatch) {
|
||||
link = urlMatch[0]
|
||||
}
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams()
|
||||
if (link) queryParams.set('link', link)
|
||||
if (title) queryParams.set('title', title)
|
||||
if (text) queryParams.set('text', text)
|
||||
|
||||
return Response.redirect(`/share-target?${queryParams.toString()}`, 303)
|
||||
})())
|
||||
return
|
||||
}
|
||||
|
||||
// Don't interfere with WebSocket connections (relay traffic)
|
||||
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
||||
return
|
||||
|
||||
@@ -114,6 +114,17 @@ export default defineConfig({
|
||||
background_color: '#0b1220',
|
||||
orientation: 'any',
|
||||
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: [
|
||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
|
||||
Reference in New Issue
Block a user