mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae118a0581 | ||
|
|
3cddcd850e | ||
|
|
cadf4dcb48 | ||
|
|
47d257faaf | ||
|
|
f542cee4cc | ||
|
|
8274eb26c2 | ||
|
|
35018fef91 | ||
|
|
1fd08bb64a | ||
|
|
d953542c93 | ||
|
|
8c0b73ad0c | ||
|
|
a5d2ed8b07 |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -7,7 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.10.1] - 2025-10-20
|
||||
## [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 +2329,9 @@ 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.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
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.10.2",
|
||||
"version": "0.10.4",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
@@ -9,6 +9,16 @@
|
||||
"background_color": "#0b1220",
|
||||
"orientation": "any",
|
||||
"categories": ["productivity", "social", "utilities"],
|
||||
"share_target": {
|
||||
"action": "/share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "link"
|
||||
}
|
||||
},
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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(), {
|
||||
|
||||
@@ -263,44 +263,44 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
? 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
|
||||
})
|
||||
// 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
|
||||
})
|
||||
}).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(() => {})
|
||||
|
||||
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
|
||||
}
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -431,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)
|
||||
@@ -449,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 => {
|
||||
@@ -498,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>
|
||||
|
||||
@@ -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,7 +310,7 @@ 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
|
||||
const loadActiveTab = useCallback(() => {
|
||||
@@ -346,7 +346,7 @@ const Me: React.FC<MeProps> = ({
|
||||
loadLinksTab()
|
||||
break
|
||||
}
|
||||
}, [viewingPubkey, activeTab, bookmarks, refreshTrigger])
|
||||
}, [viewingPubkey, activeTab, loadHighlightsTab, loadWritingsTab, loadReadingListTab, loadReadsTab, loadLinksTab])
|
||||
|
||||
useEffect(() => {
|
||||
loadActiveTab()
|
||||
@@ -832,6 +832,7 @@ const Me: React.FC<MeProps> = ({
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
readingProgress={getWritingReadingProgress(post)}
|
||||
hideBotByName={settings.hideBotArticlesByName !== false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,19 @@ const ExploreSettings: React.FC<ExploreSettingsProps> = ({ settings, onUpdate })
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="hideBotArticlesByName" className="checkbox-label">
|
||||
<input
|
||||
id="hideBotArticlesByName"
|
||||
type="checkbox"
|
||||
checked={settings.hideBotArticlesByName !== false}
|
||||
onChange={(e) => onUpdate({ hideBotArticlesByName: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Hide content posted by bots</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
99
src/components/ShareTargetHandler.tsx
Normal file
99
src/components/ShareTargetHandler.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { createWebBookmark } from '../services/webBookmarkService'
|
||||
import { getActiveRelayUrls } from '../services/relayManager'
|
||||
import { useToast } from '../hooks/useToast'
|
||||
|
||||
interface ShareTargetHandlerProps {
|
||||
relayPool: RelayPool
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming shared URLs from the Web Share Target API.
|
||||
* Auto-saves the shared URL as a web bookmark (NIP-B0).
|
||||
*/
|
||||
export default function ShareTargetHandler({ relayPool }: ShareTargetHandlerProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const { showToast } = useToast()
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [waitingForLogin, setWaitingForLogin] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleSharedContent = async () => {
|
||||
// Parse query parameters
|
||||
const params = new URLSearchParams(location.search)
|
||||
const link = params.get('link')
|
||||
const title = params.get('title')
|
||||
const text = params.get('text')
|
||||
|
||||
// Validate we have a URL
|
||||
if (!link) {
|
||||
showToast('No URL to save')
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
|
||||
// If no active account, wait for login
|
||||
if (!activeAccount) {
|
||||
setWaitingForLogin(true)
|
||||
showToast('Please log in to save this bookmark')
|
||||
return
|
||||
}
|
||||
|
||||
// We have account and URL, proceed with saving
|
||||
if (!processing) {
|
||||
setProcessing(true)
|
||||
try {
|
||||
await createWebBookmark(
|
||||
link,
|
||||
title || undefined,
|
||||
text || undefined,
|
||||
undefined,
|
||||
activeAccount,
|
||||
relayPool,
|
||||
getActiveRelayUrls(relayPool)
|
||||
)
|
||||
showToast('Bookmark saved!')
|
||||
navigate('/me/links')
|
||||
} catch (err) {
|
||||
console.error('Failed to save shared bookmark:', err)
|
||||
showToast('Failed to save bookmark')
|
||||
navigate('/')
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleSharedContent()
|
||||
}, [activeAccount, location.search, navigate, relayPool, showToast, processing])
|
||||
|
||||
// Show waiting for login state
|
||||
if (waitingForLogin && !activeAccount) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-center">
|
||||
<FontAwesomeIcon icon={faSpinner} spin className="text-4xl mb-4" />
|
||||
<p className="text-lg">Waiting for login...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show processing state
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-center">
|
||||
<FontAwesomeIcon icon={faSpinner} spin className="text-4xl mb-4" />
|
||||
<p className="text-lg">Saving bookmark...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
17
src/config/bots.ts
Normal file
17
src/config/bots.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
/**
|
||||
* Hardcoded list of bot pubkeys (hex format) to hide articles from
|
||||
* These are accounts known to be bots or automated services
|
||||
*/
|
||||
export const BOT_PUBKEYS = new Set([
|
||||
// Step Counter Bot (npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss)
|
||||
nip19.decode('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss').data as string,
|
||||
])
|
||||
|
||||
/**
|
||||
* Check if a pubkey corresponds to a known bot
|
||||
*/
|
||||
export function isKnownBot(pubkey: string): boolean {
|
||||
return BOT_PUBKEYS.has(pubkey)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
28
src/sw.ts
28
src/sw.ts
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user