Compare commits

..

30 Commits

Author SHA1 Message Date
Gigi
e382310c88 chore: bump version to 0.10.7 2025-10-21 23:39:11 +02:00
Gigi
e6b99490dd refactor: simplify profile background fetching
- Remove unnecessary .then() callback
- Extract relayUrls variable for clarity
- Make error handlers consistent
- Add clearer comment about no-limit fetching
2025-10-21 23:35:56 +02:00
Gigi
09ee05861d fix: ensure all writings are stored in eventStore for profile pages
- Add eventStore parameter to fetchBlogPostsFromAuthors
- Store events as they stream in, not just at the end
- Update all callers to pass eventStore parameter
- This fixes issue where profile pages don't show all writings
2025-10-21 23:28:27 +02:00
Gigi
205988a6b0 docs: update CHANGELOG for v0.10.6 2025-10-21 23:15:50 +02:00
Gigi
8012752a39 chore: bump version to 0.10.6 2025-10-21 23:14:18 +02:00
Gigi
c3302da11d chore(me): remove debug logs after fixing tab switching 2025-10-21 23:13:10 +02:00
Gigi
60e1e3c821 fix(me): remove loadedTabs from useCallback deps to prevent infinite loop 2025-10-21 23:11:22 +02:00
Gigi
6c2247249a fix(me): use propActiveTab directly to avoid infinite update loop 2025-10-21 23:07:51 +02:00
Gigi
33a31df2b4 fix(me): restore useEffect to sync propActiveTab to local state on route changes 2025-10-21 23:05:17 +02:00
Gigi
f9dda1c5d4 fix(me): add key to tab content div to force re-render on tab switch 2025-10-21 22:59:09 +02:00
Gigi
6522a2871c fix(me): derive activeTab directly from route prop to update instantly on navigation 2025-10-21 22:54:48 +02:00
Gigi
f39b926e7b fix(tts): remove self-assignment in rate-change handler; keep current lang without no-op 2025-10-21 22:48:01 +02:00
Gigi
144cf5cbd1 fix(explore): subscribe-first loading model for contacts, writings, highlights; no timeouts; hydrate on first result; non-blocking nostrverse streams 2025-10-21 22:44:49 +02:00
Gigi
4b9de7cd07 feat(tts): make Web TTS reliable by chunking long text and resuming by chunks 2025-10-21 22:26:51 +02:00
Gigi
2be58332bb chore: bump version to 0.10.5 2025-10-21 22:18:00 +02:00
Gigi
6fc93cbd0f fix(pwa): accept link/Link/url form fields in Web Share Target POST handler 2025-10-21 22:04:34 +02:00
Gigi
5df426a863 fix(pwa): include share_target in build manifest via vite-plugin-pwa 2025-10-21 21:57:33 +02:00
Gigi
8ca4671bea chore: update package-lock.json 2025-10-21 21:37:09 +02:00
Gigi
ad1a808c6d docs: update CHANGELOG for v0.10.4 2025-10-21 21:36:22 +02:00
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
22 changed files with 549 additions and 158 deletions

View File

@@ -7,7 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.10.1] - 2025-10-20
## [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
@@ -2312,7 +2371,10 @@ 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.1...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

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.10.2",
"version": "0.10.7",
"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

@@ -15,6 +15,7 @@ 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'
@@ -159,6 +160,10 @@ function AppRoutes({
return (
<Routes>
<Route
path="/share-target"
element={<ShareTargetHandler relayPool={relayPool} />}
/>
<Route
path="/a/:naddr"
element={

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

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

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
@@ -8,7 +8,7 @@ import { RelayPool } from 'applesauce-relay'
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'
@@ -31,6 +31,7 @@ import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedu
import { writingsController } from '../services/writingsController'
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
import { readingProgressController } from '../services/readingProgressController'
import { contactsController } from '../services/contactsController'
// Accessors from Helpers (currently unused here)
// const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -56,6 +57,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
const [hasLoadedMine, setHasLoadedMine] = useState(false)
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
const hasHydratedRef = useRef(false)
// Get myHighlights directly from controller
const [/* myHighlights */, setMyHighlights] = useState<Highlight[]>([])
@@ -106,6 +108,21 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}
}, [])
// Subscribe to contacts stream and mirror into local state
useEffect(() => {
const unsubscribe = contactsController.onContacts((contacts) => {
setFollowedPubkeys(new Set(contacts))
})
return () => unsubscribe()
}, [])
// Ensure contacts controller is started for the active account (non-blocking)
useEffect(() => {
if (relayPool && activeAccount?.pubkey) {
contactsController.start({ relayPool, pubkey: activeAccount.pubkey }).catch(() => {})
}
}, [relayPool, activeAccount?.pubkey])
// Subscribe to nostrverse highlights controller for global stream
useEffect(() => {
const apply = (incoming: Highlight[]) => {
@@ -246,67 +263,81 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
setLoading(true)
try {
// Followed pubkeys
if (activeAccount?.pubkey) {
fetchContacts(relayPool, activeAccount.pubkey)
.then((contacts) => {
setFollowedPubkeys(new Set(contacts))
})
.catch(() => {})
}
// Prepare parallel fetches
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const contactsArray = Array.from(followedPubkeys)
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)
.then((friendsPosts) => {
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
})
// Nostrverse writings: subscribe-style via onPost; hydrate on first post
if (!activeAccount || (activeAccount && visibility.nostrverse)) {
fetchNostrverseBlogPosts(
relayPool,
relayUrls,
50,
eventStore || undefined,
(post) => {
setBlogPosts(prev => {
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))
})
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(() => {})
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) => {
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, settings, visibility.nostrverse, followedPubkeys])
}
} 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()
}, [loadData, refreshTrigger])
// Kick off friends fetches reactively when contacts arrive
useEffect(() => {
if (!relayPool) return
if (followedPubkeys.size === 0) return
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const contactsArray = Array.from(followedPubkeys)
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls, (post) => {
setBlogPosts(prev => {
const merged = dedupeWritingsByReplaceable([...prev, post])
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
// Pre-cache profiles in background
const authorPubkeys = Array.from(new Set(merged.map(p => p.author)))
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {})
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
}, 100, eventStore).then((friendsPosts) => {
setBlogPosts(prev => {
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
}).catch(() => {})
fetchHighlightsFromAuthors(relayPool, contactsArray, (highlight) => {
setHighlights(prev => {
const merged = dedupeHighlightsById([...prev, highlight])
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
return merged.sort((a, b) => b.created_at - a.created_at)
})
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
}, eventStore || undefined).then((friendsHighlights) => {
setHighlights(prev => {
const merged = dedupeHighlightsById([...prev, ...friendsHighlights])
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
return merged.sort((a, b) => b.created_at - a.created_at)
})
}).catch(() => {})
}, [relayPool, followedPubkeys, eventStore, settings, activeAccount])
// Lazy-load nostrverse writings when user toggles it on (logged in)
useEffect(() => {
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverse) return
@@ -431,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)
@@ -449,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 => {
@@ -498,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>

View File

@@ -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,7 +308,7 @@ 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
const loadActiveTab = useCallback(() => {
@@ -346,7 +344,7 @@ const Me: React.FC<MeProps> = ({
loadLinksTab()
break
}
}, [viewingPubkey, activeTab, bookmarks, refreshTrigger])
}, [viewingPubkey, activeTab, loadHighlightsTab, loadWritingsTab, loadReadingListTab, loadReadsTab, loadLinksTab])
useEffect(() => {
loadActiveTab()
@@ -832,6 +830,7 @@ const Me: React.FC<MeProps> = ({
post={post}
href={getPostUrl(post)}
readingProgress={getWritingReadingProgress(post)}
hideBotByName={settings.hideBotArticlesByName !== false}
/>
))}
</div>

View File

@@ -107,24 +107,14 @@ const Profile: React.FC<ProfileProps> = ({
useEffect(() => {
if (!pubkey || !relayPool || !eventStore) return
// Fetch all highlights and writings in background (no limits)
const relayUrls = getActiveRelayUrls(relayPool)
// Fetch highlights in background
fetchHighlights(relayPool, pubkey, undefined, undefined, false, eventStore)
.then(() => {
// Highlights fetched
})
.catch(err => {
console.warn('⚠️ [Profile] Failed to fetch highlights:', err)
})
.catch(err => console.warn('⚠️ [Profile] Failed to fetch highlights:', err))
// Fetch writings in background (no limit for single user profile)
fetchBlogPostsFromAuthors(relayPool, [pubkey], getActiveRelayUrls(relayPool), undefined, null)
.then(writings => {
writings.forEach(w => eventStore.add(w.event))
})
.catch(err => {
console.warn('⚠️ [Profile] Failed to fetch writings:', err)
})
fetchBlogPostsFromAuthors(relayPool, [pubkey], relayUrls, undefined, null, eventStore)
.catch(err => console.warn('⚠️ [Profile] Failed to fetch writings:', err))
}, [pubkey, relayPool, eventStore, refreshTrigger])
// Pull-to-refresh

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

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

@@ -163,7 +163,10 @@ export function useExternalUrlLoader({
setReaderLoading,
setIsCollapsed,
setSelectedUrl,
setHighlights
setHighlights,
setCurrentArticleCoordinate,
setCurrentArticleEventId,
setHighlightsLoading
])
// Keep UI highlights synced with cached store updates without reloading content

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])
@@ -153,7 +188,7 @@ export const useReadingPosition = ({
clearTimeout(completionTimerRef.current)
}
}
}, [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

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

View File

@@ -1,6 +1,6 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { Helpers, IEventStore } from 'applesauce-core'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
@@ -22,6 +22,7 @@ export interface BlogPostPreview {
* @param relayUrls - Array of relay URLs to query
* @param onPost - Optional callback for streaming posts
* @param limit - Limit for number of events to fetch (default: 100, pass null for no limit)
* @param eventStore - Optional event store to persist fetched events
* @returns Array of blog post previews
*/
export const fetchBlogPostsFromAuthors = async (
@@ -29,7 +30,8 @@ export const fetchBlogPostsFromAuthors = async (
pubkeys: string[],
relayUrls: string[],
onPost?: (post: BlogPostPreview) => void,
limit: number | null = 100
limit: number | null = 100,
eventStore?: IEventStore
): Promise<BlogPostPreview[]> => {
try {
if (pubkeys.length === 0) {
@@ -45,12 +47,17 @@ export const fetchBlogPostsFromAuthors = async (
? { kinds: [KINDS.BlogPost], authors: pubkeys, limit }
: { kinds: [KINDS.BlogPost], authors: pubkeys }
await queryEvents(
const events = await queryEvents(
relayPool,
filter,
{
relayUrls,
onEvent: (event: NostrEvent) => {
// Store in event store immediately if provided
if (eventStore) {
eventStore.add(event)
}
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
@@ -73,6 +80,10 @@ export const fetchBlogPostsFromAuthors = async (
}
)
// Store all events in event store if provided (safety net for any missed during streaming)
if (eventStore) {
events.forEach(evt => eventStore.add(evt))
}
// Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())

View File

@@ -19,7 +19,6 @@ export async function loadUserRelayList(
): Promise<UserRelayInfo[]> {
try {
const startTime = Date.now()
// Try querying with streaming callback for faster results
const events: NostrEvent[] = []
@@ -61,10 +60,8 @@ 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
// Also try a broader query to see if we get any events at all
const allEvents = await queryEvents(relayPool, {
await queryEvents(relayPool, {
kinds: [10002],
limit: 5
})

View File

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

View File

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

View File

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