Compare commits

...

29 Commits

Author SHA1 Message Date
Gigi
ae118a0581 chore: bump version to 0.10.4 2025-10-21 21:35:47 +02:00
Gigi
3cddcd850e feat: add Web Share Target support for auto-saving shared URLs
- Add share_target to manifest.webmanifest with POST method
- Implement service worker handler for POST /share-target requests
- Create ShareTargetHandler component to process and save shared URLs
- Add /share-target route in App.tsx
- Auto-saves shared URLs as web bookmarks (NIP-B0)
- Handles Android case where url param is omitted from share data
2025-10-21 21:32:50 +02:00
Gigi
cadf4dcb48 perf(reading): debounce reading position saves (>=5% delta, 15s min interval, instant on completion) 2025-10-21 21:19:45 +02:00
Gigi
47d257faaf feat: add hardcoded bot pubkey filtering 2025-10-21 09:01:10 +02:00
Gigi
f542cee4cc docs: update CHANGELOG for v0.10.3 2025-10-21 08:29:00 +02:00
Gigi
8274eb26c2 chore: bump version to 0.10.3 2025-10-21 08:28:11 +02:00
Gigi
35018fef91 style: update bot filter setting to 'Hide content posted by bots' 2025-10-21 08:27:06 +02:00
Gigi
1fd08bb64a style: simplify bot filter setting text 2025-10-21 08:25:06 +02:00
Gigi
d953542c93 style: remove example bots text from setting 2025-10-21 08:23:52 +02:00
Gigi
8c0b73ad0c fix: resolve all linting and type checking issues 2025-10-21 08:21:36 +02:00
Gigi
a5d2ed8b07 feat: hide articles from bot accounts by name; add setting (default on) 2025-10-21 07:36:00 +02:00
Gigi
67fec91ab3 chore: bump version to 0.10.2 2025-10-21 07:29:34 +02:00
Gigi
868fe68ce2 chore: remove console.log debug output across app and relay services 2025-10-21 07:27:32 +02:00
Gigi
66c4bfc449 refactor: remove all eslint-disable comments; fix types and deps; clean unused imports 2025-10-21 07:26:00 +02:00
Gigi
29918f78f9 refactor: remove eslint-disable comments by typing publish, fixing unused-vars, and updating effect deps 2025-10-21 07:21:01 +02:00
Gigi
18fcf6064e feat: swap position of refresh and list/group buttons in bookmarks bar 2025-10-21 07:12:24 +02:00
Gigi
35766d5691 docs: update CHANGELOG.md for v0.10.1 2025-10-20 23:20:42 +02:00
Gigi
7450ba4251 chore: bump version to 0.10.1 2025-10-20 23:20:19 +02:00
Gigi
95c770c083 deps: update package-lock.json 2025-10-20 23:20:13 +02:00
Gigi
14a7e1138e feat: differentiate between American and British English in TTS 2025-10-20 23:16:26 +02:00
Gigi
9c45c71c8a feat: add top 10 TTS languages to speaker language selector 2025-10-20 23:15:14 +02:00
Gigi
23b9224272 style: remove 'Test Example' label from TTS settings 2025-10-20 23:10:26 +02:00
Gigi
bcd4a12542 content: update TTS example text to Boris mission statement 2025-10-20 23:10:03 +02:00
Gigi
d82e22ce1c refactor: use TTSControls component in TTS settings for consistent UI 2025-10-20 23:09:36 +02:00
Gigi
ea5c173745 feat: add example text section to test TTS in settings 2025-10-20 23:08:47 +02:00
Gigi
a214c487cc style: increase padding-right on dropdown chevron to 1.75rem 2025-10-20 23:07:06 +02:00
Gigi
43f56fc29a style: add more padding-right to dropdown selector for better spacing 2025-10-20 23:06:06 +02:00
Gigi
cfbc3efeeb style: use consistent setting-select class for speaker language dropdown 2025-10-20 23:05:20 +02:00
Gigi
bb9e98ff16 docs: update CHANGELOG.md for v0.10.0 2025-10-20 23:04:45 +02:00
25 changed files with 587 additions and 346 deletions

View File

@@ -7,6 +7,153 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
- Centralized bookmark loading with streaming and auto-decrypt
- Bookmarks now load progressively with streaming updates
- Auto-decrypt bookmarks as they arrive from relays
- Individual decrypt buttons for encrypted bookmark events
- Centralized bookmark controller for consistent loading across the app
- Enhanced debug page with comprehensive diagnostics
- Interactive NIP-04 and NIP-44 encryption/decryption testing
- Live performance timing with stopwatch display
- Bookmark loading and decryption diagnostics
- Real-time bunker logs with filtering and clearing
- Version and git commit footer
- Bunker (NIP-46) authentication support
- Support for remote signing via Nostr Connect protocol
- Bunker URI input with validation and error handling
- Automatic reconnection on app restore with proper permissions
- Signer suggestions in error messages (Amber, nsec.app, Nostrum)
### Changed
- Improved bookmark loading performance
- Non-blocking, progressive bookmark updates via callback pattern
- Batched background hydration using EventLoader and AddressLoader
- Shorter timeouts for debug page bookmark loading
- Sequential decryption instead of concurrent to avoid queue issues
- Enhanced bunker error messages
- Formatted error messages with signer suggestions
- Links to nos2x, Amber, nsec.app, and Nostrum signers
- Better error handling for missing signer extensions
- Centralized bookmark loading architecture
- Single shared bookmark controller for consistent loading
- Unified bookmark loading with streaming and auto-decrypt
- Consolidated bookmark loading into single centralized function
### Fixed
- NIP-46 bunker signing and decryption
- NostrConnectSigner properly reconnects with permissions on app restore
- Bunker relays added to relay pool for signing requests
- Proper setup of pool and relays before bunker reconnection
- Expose nip04/nip44 on NostrConnectAccount for bookmark decryption
- Cache wrapped nip04/nip44 objects instead of using getters
- Wait for bunker relay connections before marking signer ready
- Validate bunker URI (remote must differ from user pubkey)
- Accept remote===pubkey for Amber compatibility
- Bookmark loading and decryption
- Bookmarks load and complete properly with streaming
- Auto-decrypt private bookmarks with NIP-04 detection
- Include decrypted private bookmarks in sidebar
- Skip background event fetching when there are too many IDs
- Only build bookmarks from ready events (unencrypted or decrypted)
- Restore Debug page decrypt display via onDecryptComplete callback
- Make controller onEvent non-blocking for queryEvents completion
- Proper timeout handling for bookmark decryption (no hanging)
- Smart encryption detection with consistent padlock display
- Sequential decryption instead of concurrent to avoid queue issues
- Add extraRelays to EventLoader and AddressLoader
- TypeScript and linting errors throughout
- Replace empty catch blocks with warnings
- Fix explicit any types
- Add missing useEffect dependencies
- Resolve all linting issues in App.tsx, Debug.tsx, and async utilities
### Performance
- Non-blocking NIP-46 operations
- Fire-and-forget NIP-46 publish for better UI responsiveness
- Non-blocking bookmark decryption with sequential processing
- Make controller onEvent non-blocking for queryEvents completion
- Optimized bookmark loading
- Batched background hydration using EventLoader and AddressLoader
- Progressive, non-blocking bookmark loading with streaming
- Shorter timeouts for debug page bookmark loading
- Remove artificial delays from bookmark decryption
### Refactored
- Centralized bookmark controller architecture
- Extract bookmark streaming helpers and centralize loading
- Consolidated bookmark loading into single function
- Remove deprecated bookmark service files
- Share bookmark controller between components
- Debug page organization
- Extract VersionFooter component to eliminate duplication
- Structured sections with proper layout and styling
- Apply settings page styling structure
- Simplified bunker implementation following applesauce patterns
- Clean up bunker implementation for better maintainability
- Import RELAYS from central config (DRY principle)
- Update RELAYS list with relay.nsec.app
### Documentation
- Comprehensive Amber.md documentation
- Amethyst-style bookmarks section
- Bunker decrypt investigation summary
- Critical queue disabling requirement
- NIP-46 setup and troubleshooting
## [0.9.1] - 2025-10-20
### Added
@@ -2182,7 +2329,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.9.1...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.10.3...HEAD
[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
[0.8.2]: https://github.com/dergigi/boris/compare/v0.8.0...v0.8.2

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "boris",
"version": "0.9.1",
"version": "0.10.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boris",
"version": "0.9.1",
"version": "0.10.2",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.10.0",
"version": "0.10.4",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,8 @@ 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'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
@@ -19,20 +19,21 @@ 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'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
// Accessors from Helpers (currently unused here)
// const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
interface ExploreProps {
relayPool: RelayPool
@@ -57,25 +58,25 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(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, [])
@@ -230,198 +231,37 @@ 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 {
// Followed pubkeys
if (activeAccount?.pubkey) {
fetchContacts(relayPool, activeAccount.pubkey)
.then((contacts) => {
setFollowedPubkeys(new Set(contacts))
})
.catch(() => {})
}
// Fetch the user's contacts (friends)
const contacts = await fetchContacts(
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)
// Prepare parallel fetches
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const contactsArray = Array.from(followedPubkeys)
// 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[])
const nostrversePostsPromise: Promise<BlogPostPreview[]> = (!activeAccount || (activeAccount && visibility.nostrverse))
? fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined).catch(() => [])
: Promise.resolve([])
// Fire non-blocking fetches and merge as they resolve
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls)
@@ -461,11 +301,11 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
} finally {
// loading is already turned off after seeding
}
}
}, [relayPool, activeAccount, eventStore, settings, visibility.nostrverse, followedPubkeys])
useEffect(() => {
loadData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
}, [loadData, refreshTrigger])
// Lazy-load nostrverse writings when user toggles it on (logged in)
useEffect(() => {
@@ -509,7 +349,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 +431,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 +455,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 +504,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
href={getPostUrl(post)}
level={post.level}
readingProgress={getReadingProgress(post)}
hideBotByName={settings?.hideBotArticlesByName !== false}
/>
))}
</div>

View File

@@ -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'
@@ -205,15 +205,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,9 +230,9 @@ 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')
@@ -246,9 +246,9 @@ const Me: React.FC<MeProps> = ({
} finally {
if (!hasBeenLoaded) setLoading(false)
}
}
}, [viewingPubkey, activeAccount, loadedTabs])
const loadReadsTab = async () => {
const loadReadsTab = useCallback(async () => {
if (!viewingPubkey || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('reads')
@@ -270,9 +270,9 @@ const Me: React.FC<MeProps> = ({
console.error('Failed to load reads:', err)
if (!hasBeenLoaded) setLoading(false)
}
}
}, [viewingPubkey, activeAccount, loadedTabs, relayPool, eventStore])
const loadLinksTab = async () => {
const loadLinksTab = useCallback(async () => {
if (!viewingPubkey || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('links')
@@ -310,10 +310,10 @@ const Me: React.FC<MeProps> = ({
console.error('Failed to load links:', err)
if (!hasBeenLoaded) setLoading(false)
}
}
}, [viewingPubkey, activeAccount, loadedTabs, bookmarks, relayPool, readingProgressMap])
// Load active tab data
useEffect(() => {
const loadActiveTab = useCallback(() => {
if (!viewingPubkey || !activeTab) {
setLoading(false)
return
@@ -346,8 +346,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 +832,7 @@ const Me: React.FC<MeProps> = ({
post={post}
href={getPostUrl(post)}
readingProgress={getWritingReadingProgress(post)}
hideBotByName={settings.hideBotArticlesByName !== false}
/>
))}
</div>

View File

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

View File

@@ -2,6 +2,7 @@ import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faGauge } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import TTSControls from '../TTSControls'
interface TTSSettingsProps {
settings: UserSettings
@@ -9,6 +10,7 @@ interface TTSSettingsProps {
}
const SPEED_OPTIONS = [0.8, 1, 1.2, 1.4, 1.6, 1.8, 2, 2.1, 2.4, 2.8, 3]
const EXAMPLE_TEXT = "Boris aims to be a calm reader app with clean typography, beautiful design, and a focus on readability. Boris does not and will never have ads, trackers, paywalls, subscriptions, or any other distractions."
const TTSSettings: React.FC<TTSSettingsProps> = ({ settings, onUpdate }) => {
const currentSpeed = settings.ttsDefaultSpeed || 2.1
@@ -43,14 +45,40 @@ const TTSSettings: React.FC<TTSSettingsProps> = ({ settings, onUpdate }) => {
<div className="setting-control">
<select
value={settings.ttsLanguageMode || 'content'}
onChange={e => onUpdate({ ttsLanguageMode: (e.target.value as 'system' | 'content'), ttsUseSystemLanguage: e.target.value === 'system', ttsDetectContentLanguage: e.target.value !== 'system' })}
style={{ background: 'var(--color-bg-elevated)', color: 'var(--color-text)', border: '1px solid var(--color-border)', borderRadius: 6, padding: '0.25rem 0.5rem' }}
onChange={e => {
const value = e.target.value
onUpdate({
ttsLanguageMode: value,
ttsUseSystemLanguage: value === 'system',
ttsDetectContentLanguage: value === 'content'
})
}}
className="setting-select"
>
<option value="system">System Language</option>
<option value="content">Content (auto-detect)</option>
<option disabled></option>
<option value="en-US">English (American)</option>
<option value="en-GB">English (British)</option>
<option value="zh">Mandarin Chinese</option>
<option value="es">Spanish</option>
<option value="hi">Hindi</option>
<option value="ar">Arabic</option>
<option value="fr">French</option>
<option value="pt">Portuguese</option>
<option value="de">German</option>
<option value="ja">Japanese</option>
<option value="ru">Russian</option>
</select>
</div>
</div>
<div className="setting-group">
<div style={{ padding: '0.75rem', backgroundColor: 'var(--color-bg)', borderRadius: '4px', marginBottom: '0.75rem', fontSize: '0.95rem', lineHeight: '1.5' }}>
{EXAMPLE_TEXT}
</div>
<TTSControls text={EXAMPLE_TEXT} settings={settings} />
</div>
</div>
)
}

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

View File

@@ -37,12 +37,25 @@ const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }
return settings?.ttsDetectContentLanguage !== false
}, [settings?.ttsLanguageMode, settings?.ttsDetectContentLanguage])
const specificLang = useMemo(() => {
const mode = settings?.ttsLanguageMode
// If mode is not 'system' or 'content', it's a specific language code
if (mode && mode !== 'system' && mode !== 'content') {
return mode
}
return undefined
}, [settings?.ttsLanguageMode])
const handlePlayPause = () => {
if (!canPlay) return
if (!speaking) {
let langOverride: string | undefined
if (detectContentLang && text) {
// Priority: specific language > content detection > system language
if (specificLang) {
langOverride = specificLang
} else if (detectContentLang && text) {
try {
const lang = detect(text)
if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2)

17
src/config/bots.ts Normal file
View 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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -65,10 +65,12 @@ 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
ttsLanguageMode?: 'system' | 'content' // default: 'content'
ttsLanguageMode?: 'system' | 'content' | string // default: 'content', can also be language code like 'en', 'es', etc.
// Text-to-Speech settings
ttsDefaultSpeed?: number // default: 2.1
}

View File

@@ -43,7 +43,7 @@
word-wrap: break-word;
text-align: var(--paragraph-alignment, justify);
}
.setting-select { width: 100%; padding: 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
.setting-select { width: 100%; padding: 0.5rem 1.75rem 0.5rem 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
.setting-select:focus { outline: none; border-color: var(--color-primary); }
.font-select option { padding: 0.5rem; font-size: 1rem; }

View File

@@ -98,10 +98,36 @@ 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()
let link = (formData.get('link') || '').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