mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c798376411 | ||
|
|
e83c301e6a | ||
|
|
2c0aee3fe4 | ||
|
|
d0f043fb5a | ||
|
|
039b988869 | ||
|
|
d285003e1d | ||
|
|
530abeeb33 | ||
|
|
3ac6954cb7 | ||
|
|
1c0f619a47 | ||
|
|
0fcfd200a4 | ||
|
|
e01c8d33fc | ||
|
|
51c0f7d923 | ||
|
|
8c79b5fd75 | ||
|
|
29746f1042 | ||
|
|
829ec4bf6e | ||
|
|
30ae0d9dfb | ||
|
|
8924f1b307 | ||
|
|
f92fa2cc93 | ||
|
|
cc70b533e5 | ||
|
|
003c439658 | ||
|
|
019958073c | ||
|
|
3d47dddbd2 | ||
|
|
cabf897df8 | ||
|
|
4801c0d621 | ||
|
|
ae76d6e4ea | ||
|
|
a611e99ff6 | ||
|
|
1c039e164f | ||
|
|
ffa4b38106 | ||
|
|
3b22cb5c5d | ||
|
|
7bc4522be4 | ||
|
|
048e0d802b | ||
|
|
b282bc4972 | ||
|
|
c1a23c1f8f | ||
|
|
8a5aacfe7b | ||
|
|
9126910de5 | ||
|
|
496bbc36f4 | ||
|
|
90f25420b2 | ||
|
|
9167134a89 | ||
|
|
b5717f1ebf | ||
|
|
0c8eaaf220 | ||
|
|
80b2720838 | ||
|
|
ea69740fc8 | ||
|
|
d650997ff9 | ||
|
|
ba3554b173 | ||
|
|
2cc39d0200 | ||
|
|
9aa914a704 | ||
|
|
497b6fa4be | ||
|
|
4c838b0123 | ||
|
|
d551f66ef1 | ||
|
|
34514199ee | ||
|
|
228304f68a | ||
|
|
ba263acdff | ||
|
|
5131cbe12c |
89
CHANGELOG.md
89
CHANGELOG.md
@@ -7,6 +7,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.10.15] - 2025-01-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Reading position restore now uses pre-loaded data from controller
|
||||
- No longer fetches position from scratch when opening articles
|
||||
- Uses position already loaded and displayed on bookmark cards
|
||||
- Faster restore with no network wait
|
||||
- Simpler code without stabilization window complexity
|
||||
- Reading position scroll animation restored to smooth behavior
|
||||
- Changed from instant jump back to smooth animated scroll
|
||||
- Better user experience when restoring position
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reading position no longer saves 0% during back navigation on mobile
|
||||
- Removed save-on-unmount behavior that was error-prone
|
||||
- Browser scroll-to-top during back gesture no longer overwrites progress
|
||||
- Auto-save with 3-second debounce is sufficient for normal reading
|
||||
- Prevents accidental reset of reading progress when navigating away
|
||||
|
||||
## [0.10.14] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- Third relay education article link in PWA settings
|
||||
- Added "Relay Setup 101" article to relay information section
|
||||
- Now links to three educational resources about relays
|
||||
|
||||
### Changed
|
||||
|
||||
- Timestamp links in bookmark cards now navigate within app
|
||||
- Articles (kind:30023) open in `/a/{naddr}` route
|
||||
- Notes (kind:1) open in `/e/{eventId}` route
|
||||
- External URLs open in `/r/{encodedUrl}` route
|
||||
- Uses React Router Link for client-side navigation instead of external search
|
||||
- Relay article links punctuation improved for better readability
|
||||
- Changed from "here and here" to "here, here, and here"
|
||||
|
||||
### Fixed
|
||||
|
||||
- Duplicate video embeds and stray HTML artifacts eliminated
|
||||
- VideoEmbedProcessor now processes HTML and extracts URLs in single pass
|
||||
- Placeholder indices now correctly match collected video URLs
|
||||
- Empty HTML parts no longer rendered, preventing stray characters like `">`
|
||||
- Highlights loading spinner no longer spins forever when article has zero highlights
|
||||
- Loading state properly cleared when no highlights exist
|
||||
- "No highlights" message displays immediately
|
||||
|
||||
## [0.10.13] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- Instant article preview when navigating from blog post cards
|
||||
- Title, image, summary, and date display immediately via navigation state
|
||||
- No skeleton loading for metadata already visible on cards
|
||||
- Article content loads seamlessly in background from eventStore or relays
|
||||
- Reliable relay fallback for article fetching
|
||||
- Queries nostr.band, primal, damus, and nos.lol if initial fetch returns no events
|
||||
- Reduces "Article not found" errors
|
||||
|
||||
### Changed
|
||||
|
||||
- Article loading now follows local-first controller pattern
|
||||
- Uses eventStore and queryEvents for streaming results
|
||||
- Emits content immediately on first event from store or local relays
|
||||
- Finalizes with newest version after EOSE (no artificial timeouts)
|
||||
- Background relay query continues to check for updates
|
||||
- Service Worker now only registers in production builds
|
||||
- Disabled in development to avoid stale cache issues
|
||||
- Preserves PWA functionality in production
|
||||
- Article fetching queries union of naddr relay hints and configured relays
|
||||
- Prevents failures when naddr contains stale or unreachable relay hints
|
||||
- Maintains fast local/hinted paths with reliable fallback
|
||||
|
||||
### Fixed
|
||||
|
||||
- Article loading race conditions eliminated
|
||||
- Request ID guards prevent stale fetches from overwriting current content
|
||||
- Stale highlights from previous articles no longer appear
|
||||
- Content/title mismatch when switching articles resolved
|
||||
- Markdown preview clears immediately on content change
|
||||
- Forced re-mount of rendered HTML per article via stable content keys
|
||||
- Request guards in external URL loader prevent cross-article bleed
|
||||
- Article re-fetching on settings changes prevented
|
||||
- Settings memoized via ref to avoid triggering effect dependencies
|
||||
- Explore writings tab now shows skeletons instead of spinner when loading
|
||||
- Consistent loading UI across all views
|
||||
|
||||
## [0.10.12] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.10.13",
|
||||
"version": "0.10.16",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { faGlobe, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { npubEncode, neventEncode } from 'nostr-tools/nip19'
|
||||
import { npubEncode } from 'nostr-tools/nip19'
|
||||
import { IndividualBookmark } from '../types/bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { classifyUrl } from '../utils/helpers'
|
||||
@@ -58,8 +58,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
// Resolve author profile using applesauce
|
||||
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
|
||||
const authorNpub = npubEncode(bookmark.pubkey)
|
||||
const isHexId = /^[0-9a-f]{64}$/i.test(bookmark.id)
|
||||
const eventNevent = isHexId ? neventEncode({ id: bookmark.id }) : undefined
|
||||
|
||||
// Get display name for author
|
||||
const getAuthorDisplayName = () => {
|
||||
@@ -135,7 +133,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
@@ -152,7 +149,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary,
|
||||
|
||||
@@ -9,7 +9,7 @@ import RichContent from '../RichContent'
|
||||
import { classifyUrl } from '../../utils/helpers'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
||||
import { getEventUrl } from '../../config/nostrGateways'
|
||||
import { naddrEncode } from 'nostr-tools/nip19'
|
||||
|
||||
interface CardViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -18,7 +18,6 @@ interface CardViewProps {
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
authorNpub: string
|
||||
eventNevent?: string
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleImage?: string
|
||||
@@ -34,7 +33,6 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
@@ -82,6 +80,29 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Get internal route for the bookmark
|
||||
const getInternalRoute = (): string | null => {
|
||||
if (bookmark.kind === 30023) {
|
||||
// Nostr-native article - use /a/ route
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
const naddr = naddrEncode({
|
||||
kind: bookmark.kind,
|
||||
pubkey: bookmark.pubkey,
|
||||
identifier: dTag
|
||||
})
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
} else if (bookmark.kind === 1) {
|
||||
// Note - use /e/ route
|
||||
return `/e/${bookmark.id}`
|
||||
} else if (firstUrl) {
|
||||
// External URL - use /r/ route
|
||||
return `/r/${encodeURIComponent(firstUrl)}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${bookmark.id}-${index}`}
|
||||
@@ -103,17 +124,15 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
</span>
|
||||
|
||||
{eventNevent ? (
|
||||
<a
|
||||
href={getEventUrl(eventNevent)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{getInternalRoute() ? (
|
||||
<Link
|
||||
to={getInternalRoute()!}
|
||||
className="bookmark-date-link"
|
||||
title="Open event in search"
|
||||
title="Open in app"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { formatDate } from '../../utils/bookmarkUtils'
|
||||
import RichContent from '../RichContent'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { getEventUrl } from '../../config/nostrGateways'
|
||||
import { naddrEncode } from 'nostr-tools/nip19'
|
||||
|
||||
interface LargeViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -18,7 +18,6 @@ interface LargeViewProps {
|
||||
getIconForUrlType: IconGetter
|
||||
previewImage: string | null
|
||||
authorNpub: string
|
||||
eventNevent?: string
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleSummary?: string
|
||||
@@ -35,7 +34,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
getIconForUrlType,
|
||||
previewImage,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary,
|
||||
@@ -63,6 +61,30 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Get internal route for the bookmark
|
||||
const getInternalRoute = (): string | null => {
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
if (bookmark.kind === 30023) {
|
||||
// Nostr-native article - use /a/ route
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
const naddr = naddrEncode({
|
||||
kind: bookmark.kind,
|
||||
pubkey: bookmark.pubkey,
|
||||
identifier: dTag
|
||||
})
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
} else if (bookmark.kind === 1) {
|
||||
// Note - use /e/ route
|
||||
return `/e/${bookmark.id}`
|
||||
} else if (firstUrl) {
|
||||
// External URL - use /r/ route
|
||||
return `/r/${encodeURIComponent(firstUrl)}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${bookmark.id}-${index}`}
|
||||
@@ -136,16 +158,17 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
</Link>
|
||||
</span>
|
||||
|
||||
{eventNevent && (
|
||||
<a
|
||||
href={getEventUrl(eventNevent)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{getInternalRoute() ? (
|
||||
<Link
|
||||
to={getInternalRoute()!}
|
||||
className="bookmark-date-link"
|
||||
title="Open in app"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||
)}
|
||||
|
||||
{/* CTA removed */}
|
||||
|
||||
@@ -43,9 +43,9 @@ import { EventFactory } from 'applesauce-factory'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import {
|
||||
generateArticleIdentifier,
|
||||
saveReadingPosition,
|
||||
startReadingPositionStream
|
||||
saveReadingPosition
|
||||
} from '../services/readingPositionService'
|
||||
import { readingProgressController } from '../services/readingProgressController'
|
||||
import TTSControls from './TTSControls'
|
||||
|
||||
interface ContentPanelProps {
|
||||
@@ -150,8 +150,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
// Get event store for reading position service
|
||||
const eventStore = Hooks.useEventStore()
|
||||
|
||||
// Reading position tracking - only for text content, not videos
|
||||
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
||||
// Reading position tracking - only for text content that's loaded and long enough
|
||||
// Wait for content to load, check it's not a video, and verify it's long enough to track
|
||||
const isTextContent = useMemo(() => {
|
||||
const result = {
|
||||
loading,
|
||||
hasMarkdown: !!markdown,
|
||||
hasHtml: !!html,
|
||||
isVideo: selectedUrl?.includes('youtube') || selectedUrl?.includes('vimeo'),
|
||||
longEnough: shouldTrackReadingProgress(html, markdown)
|
||||
}
|
||||
|
||||
if (loading) return false
|
||||
if (!markdown && !html) return false
|
||||
if (selectedUrl?.includes('youtube') || selectedUrl?.includes('vimeo')) return false
|
||||
if (!shouldTrackReadingProgress(html, markdown)) return false
|
||||
|
||||
console.log('[reading-position] 📊 isTextContent check:', result, '→', true)
|
||||
return true
|
||||
}, [loading, markdown, html, selectedUrl])
|
||||
|
||||
// Generate article identifier for saving/loading position
|
||||
const articleIdentifier = useMemo(() => {
|
||||
@@ -159,23 +176,35 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
return generateArticleIdentifier(selectedUrl)
|
||||
}, [selectedUrl])
|
||||
|
||||
// Use refs for content to avoid recreating callback on every content change
|
||||
const htmlRef = useRef(html)
|
||||
const markdownRef = useRef(markdown)
|
||||
useEffect(() => {
|
||||
htmlRef.current = html
|
||||
markdownRef.current = markdown
|
||||
}, [html, markdown])
|
||||
|
||||
// Callback to save reading position
|
||||
const handleSavePosition = useCallback(async (position: number) => {
|
||||
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||
console.log('[reading-position] ❌ Cannot save: missing dependencies')
|
||||
return
|
||||
}
|
||||
if (!settings?.syncReadingPosition) {
|
||||
console.log('[reading-position] ⚠️ Save skipped: sync disabled in settings')
|
||||
return
|
||||
}
|
||||
|
||||
// Check if content is long enough to track reading progress
|
||||
if (!shouldTrackReadingProgress(html, markdown)) {
|
||||
if (!shouldTrackReadingProgress(htmlRef.current, markdownRef.current)) {
|
||||
console.log('[reading-position] ⚠️ Save skipped: content too short')
|
||||
return
|
||||
}
|
||||
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
|
||||
try {
|
||||
console.log(`[reading-position] [${new Date().toISOString()}] 🚀 Publishing position ${Math.round(position * 100)}% to relays...`)
|
||||
const factory = new EventFactory({ signer: activeAccount })
|
||||
await saveReadingPosition(
|
||||
relayPool,
|
||||
@@ -188,13 +217,43 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
scrollTop
|
||||
}
|
||||
)
|
||||
console.log(`[reading-position] [${new Date().toISOString()}] ✅ Position published successfully`)
|
||||
} catch (error) {
|
||||
console.error('[progress] ❌ ContentPanel: Failed to save reading position:', error)
|
||||
console.error(`[reading-position] [${new Date().toISOString()}] ❌ Failed to save reading position:`, error)
|
||||
}
|
||||
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, html, markdown])
|
||||
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition])
|
||||
|
||||
const { progressPercentage, saveNow } = useReadingPosition({
|
||||
enabled: isTextContent,
|
||||
// Delay enabling position tracking to ensure content is stable
|
||||
const [isTrackingEnabled, setIsTrackingEnabled] = useState(false)
|
||||
|
||||
// Reset tracking when article changes
|
||||
useEffect(() => {
|
||||
setIsTrackingEnabled(false)
|
||||
}, [selectedUrl])
|
||||
|
||||
// Enable/disable tracking based on content state
|
||||
useEffect(() => {
|
||||
if (!isTextContent) {
|
||||
// Disable tracking if content is not suitable
|
||||
if (isTrackingEnabled) {
|
||||
console.log('[reading-position] ⏸️ Disabling tracking (not text content)')
|
||||
setIsTrackingEnabled(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!isTrackingEnabled) {
|
||||
// Wait 500ms after content loads before enabling tracking
|
||||
const timer = setTimeout(() => {
|
||||
console.log('[reading-position] ✅ Enabling tracking after stability delay')
|
||||
setIsTrackingEnabled(true)
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isTextContent, isTrackingEnabled])
|
||||
|
||||
const { progressPercentage, suppressSavesFor } = useReadingPosition({
|
||||
enabled: isTrackingEnabled,
|
||||
syncEnabled: settings?.syncReadingPosition !== false,
|
||||
onSave: handleSavePosition,
|
||||
onReadingComplete: () => {
|
||||
@@ -214,48 +273,109 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
useEffect(() => {
|
||||
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
|
||||
|
||||
// Load saved reading position when article loads (non-blocking, EOSE-driven)
|
||||
// Load saved reading position when article loads (using pre-loaded data from controller)
|
||||
const suppressSavesForRef = useRef(suppressSavesFor)
|
||||
useEffect(() => {
|
||||
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||
suppressSavesForRef.current = suppressSavesFor
|
||||
}, [suppressSavesFor])
|
||||
|
||||
// Track if we've successfully started restore for this article + tracking state
|
||||
// Use a composite key to ensure we only restore once per article when tracking is enabled
|
||||
const restoreKey = `${articleIdentifier}-${isTrackingEnabled}`
|
||||
const hasAttemptedRestoreRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[reading-position] 🔍 Restore effect running:', {
|
||||
isTextContent,
|
||||
isTrackingEnabled,
|
||||
hasAccount: !!activeAccount,
|
||||
articleIdentifier,
|
||||
restoreKey,
|
||||
hasAttempted: hasAttemptedRestoreRef.current
|
||||
})
|
||||
|
||||
if (!isTextContent || !activeAccount || !articleIdentifier) {
|
||||
console.log('[reading-position] ⏭️ Restore skipped: missing dependencies or not text content')
|
||||
return
|
||||
}
|
||||
if (settings?.syncReadingPosition === false) {
|
||||
console.log('[reading-position] ⏭️ Restore skipped: sync disabled in settings')
|
||||
return
|
||||
}
|
||||
if (!isTrackingEnabled) {
|
||||
console.log('[reading-position] ⏭️ Restore skipped: tracking not yet enabled (waiting for content stability)')
|
||||
return
|
||||
}
|
||||
|
||||
const stop = startReadingPositionStream(
|
||||
relayPool,
|
||||
eventStore,
|
||||
activeAccount.pubkey,
|
||||
articleIdentifier,
|
||||
(savedPosition) => {
|
||||
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
||||
// Wait for content to be fully rendered before scrolling
|
||||
setTimeout(() => {
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
const windowHeight = window.innerHeight
|
||||
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
|
||||
|
||||
window.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}, 500) // Give content time to render
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => stop()
|
||||
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||
|
||||
// Save position before unmounting or changing article
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveNow) {
|
||||
saveNow()
|
||||
}
|
||||
// Only attempt restore once per article (after tracking is enabled)
|
||||
if (hasAttemptedRestoreRef.current === restoreKey) {
|
||||
console.log('[reading-position] ⏭️ Restore skipped: already attempted for this article')
|
||||
return
|
||||
}
|
||||
}, [saveNow, selectedUrl])
|
||||
|
||||
console.log('[reading-position] 🔄 Initiating restore for article:', articleIdentifier)
|
||||
// Mark as attempted using composite key
|
||||
hasAttemptedRestoreRef.current = restoreKey
|
||||
|
||||
// Get the saved position from the controller (already loaded and displayed on card)
|
||||
const savedProgress = readingProgressController.getProgress(articleIdentifier)
|
||||
|
||||
if (!savedProgress || savedProgress <= 0.05 || savedProgress >= 1) {
|
||||
console.log('[reading-position] ℹ️ No position to restore (progress:', savedProgress, ')')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[reading-position] 🎯 Found saved position:', Math.round(savedProgress * 100) + '%')
|
||||
|
||||
// Suppress saves during restore (500ms render + 1000ms smooth scroll = 1500ms)
|
||||
if (suppressSavesForRef.current) {
|
||||
suppressSavesForRef.current(1500)
|
||||
}
|
||||
|
||||
// Wait for content to be fully rendered
|
||||
setTimeout(() => {
|
||||
const docH = document.documentElement.scrollHeight
|
||||
const winH = window.innerHeight
|
||||
const maxScroll = Math.max(0, docH - winH)
|
||||
const currentTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
const targetTop = savedProgress * maxScroll
|
||||
|
||||
console.log('[reading-position] 📐 Restore calculation:', {
|
||||
docHeight: docH,
|
||||
winHeight: winH,
|
||||
maxScroll,
|
||||
currentTop,
|
||||
targetTop,
|
||||
targetPercent: Math.round(savedProgress * 100) + '%'
|
||||
})
|
||||
|
||||
// Skip if delta is too small (< 48px or < 5%)
|
||||
const deltaPx = Math.abs(targetTop - currentTop)
|
||||
const deltaPct = maxScroll > 0 ? Math.abs((targetTop - currentTop) / maxScroll) : 0
|
||||
if (deltaPx < 48 || deltaPct < 0.05) {
|
||||
console.log('[reading-position] ⏭️ Restore skipped: delta too small (', deltaPx, 'px,', Math.round(deltaPct * 100) + '%)')
|
||||
// Allow saves immediately since no scroll happened
|
||||
if (suppressSavesForRef.current) {
|
||||
suppressSavesForRef.current(0)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[reading-position] 📜 Restoring scroll position (delta:', deltaPx, 'px,', Math.round(deltaPct * 100) + '%)')
|
||||
|
||||
// Perform smooth animated restore
|
||||
window.scrollTo({
|
||||
top: targetTop,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
console.log('[reading-position] ✅ Scroll restored to', Math.round(savedProgress * 100) + '%')
|
||||
}, 500) // Give content time to render
|
||||
}, [isTextContent, activeAccount, articleIdentifier, settings?.syncReadingPosition, selectedUrl, isTrackingEnabled, restoreKey])
|
||||
|
||||
// Note: We intentionally do NOT save on unmount because:
|
||||
// 1. Browser may scroll to top during back navigation, causing 0% saves
|
||||
// 2. The auto-save with 3s debounce already captures position during reading
|
||||
// 3. Position state may not reflect actual reading position during navigation
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
|
||||
@@ -212,12 +212,23 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
pubkey,
|
||||
identifier
|
||||
})
|
||||
navigate(`/a/${naddr}`)
|
||||
// Pass highlight ID in navigation state to trigger scroll
|
||||
navigate(`/a/${naddr}`, {
|
||||
state: {
|
||||
highlightId: highlight.id,
|
||||
openHighlights: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (highlight.urlReference) {
|
||||
// Navigate to external URL
|
||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`)
|
||||
// Navigate to external URL with highlight ID to trigger scroll
|
||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
|
||||
state: {
|
||||
highlightId: highlight.id,
|
||||
openHighlights: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
||||
>
|
||||
here
|
||||
</a>
|
||||
{' and '}
|
||||
{', '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -161,6 +161,16 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
||||
>
|
||||
here
|
||||
</a>
|
||||
{', and '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('/a/naddr1qvzqqqr4gupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqq9hyetvv9uj6um9w36hq9mgjg8')
|
||||
}}
|
||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -21,9 +21,10 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
|
||||
onMouseUp,
|
||||
onTouchEnd
|
||||
}, ref) => {
|
||||
const processedHtml = useMemo(() => {
|
||||
// Process HTML and extract video URLs in a single pass to keep them in sync
|
||||
const { processedHtml, videoUrls } = useMemo(() => {
|
||||
if (!renderVideoLinksAsEmbeds || !html) {
|
||||
return html
|
||||
return { processedHtml: html, videoUrls: [] }
|
||||
}
|
||||
|
||||
// Process HTML in stages: <video> blocks, <img> tags with video src, and bare video URLs
|
||||
@@ -86,71 +87,19 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
|
||||
|
||||
const remainingUrls = [...fileVideoUrls, ...platformVideoUrls].filter(url => !collectedUrls.includes(url))
|
||||
|
||||
let processedHtml = result
|
||||
let finalHtml = result
|
||||
remainingUrls.forEach((url) => {
|
||||
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
|
||||
processedHtml = processedHtml.replace(new RegExp(url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), placeholder)
|
||||
finalHtml = finalHtml.replace(new RegExp(url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), placeholder)
|
||||
collectedUrls.push(url)
|
||||
placeholderIndex++
|
||||
})
|
||||
|
||||
// If nothing collected, return original html
|
||||
if (collectedUrls.length === 0) {
|
||||
return html
|
||||
// Return both processed HTML and collected URLs (in the same order as placeholders)
|
||||
return {
|
||||
processedHtml: collectedUrls.length > 0 ? finalHtml : html,
|
||||
videoUrls: collectedUrls
|
||||
}
|
||||
|
||||
return processedHtml
|
||||
}, [html, renderVideoLinksAsEmbeds])
|
||||
|
||||
const videoUrls = useMemo(() => {
|
||||
if (!renderVideoLinksAsEmbeds || !html) {
|
||||
return []
|
||||
}
|
||||
|
||||
const urls: string[] = []
|
||||
|
||||
// 1) Extract from <video> blocks first (video src or nested source src)
|
||||
const videoBlockPattern = /<video[^>]*>[\s\S]*?<\/video>/gi
|
||||
const videoBlocks = html.match(videoBlockPattern) || []
|
||||
videoBlocks.forEach((block) => {
|
||||
let url: string | null = null
|
||||
const videoSrcMatch = block.match(/<video[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
|
||||
if (videoSrcMatch && videoSrcMatch[1]) {
|
||||
url = videoSrcMatch[1]
|
||||
} else {
|
||||
const sourceSrcMatch = block.match(/<source[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
|
||||
if (sourceSrcMatch && sourceSrcMatch[1]) {
|
||||
url = sourceSrcMatch[1]
|
||||
}
|
||||
}
|
||||
if (url && !urls.includes(url)) urls.push(url)
|
||||
})
|
||||
|
||||
// 2) Extract from <img> tags with video src
|
||||
const imgTagPattern = /<img[^>]*>/gi
|
||||
const allImgTags = html.match(imgTagPattern) || []
|
||||
allImgTags.forEach((imgTag) => {
|
||||
const srcMatch = imgTag.match(/src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?/i)
|
||||
if (srcMatch && srcMatch[1] && !urls.includes(srcMatch[1])) {
|
||||
urls.push(srcMatch[1])
|
||||
}
|
||||
})
|
||||
|
||||
// 3) Extract remaining direct file URLs and platform-classified video URLs
|
||||
const fileVideoPattern = /https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)(?:\?[^\s<>"']*)?/gi
|
||||
const fileVideoUrls: string[] = html.match(fileVideoPattern) || []
|
||||
fileVideoUrls.forEach(u => { if (!urls.includes(u)) urls.push(u) })
|
||||
|
||||
const allUrlPattern = /https?:\/\/[^\s<>"']+(?=\s|>|"|'|$)/gi
|
||||
const allUrls: string[] = html.match(allUrlPattern) || []
|
||||
allUrls.forEach(u => {
|
||||
const classification = classifyUrl(u)
|
||||
if (classification.type === 'video' && !urls.includes(u)) {
|
||||
urls.push(u)
|
||||
}
|
||||
})
|
||||
|
||||
return urls
|
||||
}, [html, renderVideoLinksAsEmbeds])
|
||||
|
||||
// If no video embedding is enabled, just render the HTML normally
|
||||
@@ -195,13 +144,16 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
|
||||
}
|
||||
}
|
||||
|
||||
// Regular HTML content
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
dangerouslySetInnerHTML={{ __html: part }}
|
||||
/>
|
||||
)
|
||||
// Regular HTML content - only render if not empty
|
||||
if (part.trim()) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
dangerouslySetInnerHTML={{ __html: part }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -229,13 +229,15 @@ export function useArticleLoader({
|
||||
// Fetch highlights after content is shown
|
||||
try {
|
||||
if (!mountedRef.current) return
|
||||
setHighlightsLoading(true)
|
||||
setHighlights([])
|
||||
|
||||
const le = latestEvent as NostrEvent | null
|
||||
const dTag = le ? (le.tags.find((t: string[]) => t[0] === 'd')?.[1] || '') : ''
|
||||
const coord = le && dTag ? `${le.kind}:${le.pubkey}:${dTag}` : undefined
|
||||
const eventId = le ? le.id : undefined
|
||||
|
||||
if (coord && eventId) {
|
||||
setHighlightsLoading(true)
|
||||
setHighlights([])
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
coord,
|
||||
@@ -251,6 +253,10 @@ export function useArticleLoader({
|
||||
},
|
||||
settingsRef.current
|
||||
)
|
||||
} else {
|
||||
// No article event to fetch highlights for - clear and don't show loading
|
||||
setHighlights([])
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
|
||||
@@ -7,7 +7,6 @@ interface UseReadingPositionOptions {
|
||||
readingCompleteThreshold?: number // Default 0.95 (95%) - matches filter threshold
|
||||
syncEnabled?: boolean // Whether to sync positions to Nostr
|
||||
onSave?: (position: number) => void // Callback for saving position
|
||||
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
|
||||
completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000)
|
||||
}
|
||||
|
||||
@@ -18,98 +17,66 @@ export const useReadingPosition = ({
|
||||
readingCompleteThreshold = 0.95, // Match filter threshold for consistency
|
||||
syncEnabled = false,
|
||||
onSave,
|
||||
autoSaveInterval = 5000,
|
||||
completionHoldMs = 2000
|
||||
}: UseReadingPositionOptions = {}) => {
|
||||
const [position, setPosition] = useState(0)
|
||||
const positionRef = useRef(0)
|
||||
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||
const hasTriggeredComplete = useRef(false)
|
||||
const lastSavedPosition = useRef(0)
|
||||
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)
|
||||
const suppressUntilRef = useRef<number>(0)
|
||||
const pendingPositionRef = useRef<number>(0) // Track latest position for throttled save
|
||||
const lastSaved100Ref = useRef(false) // Track if we've saved 100% to avoid duplicate saves
|
||||
|
||||
// Debounced save function
|
||||
// Suppress auto-saves for a given duration (used after programmatic restore)
|
||||
const suppressSavesFor = useCallback((ms: number) => {
|
||||
const until = Date.now() + ms
|
||||
suppressUntilRef.current = until
|
||||
console.log(`[reading-position] [${new Date().toISOString()}] 🛡️ Suppressing saves for ${ms}ms until ${new Date(until).toISOString()}`)
|
||||
}, [])
|
||||
|
||||
// Throttled save function - saves at 3s intervals during scrolling
|
||||
const scheduleSave = useCallback((currentPosition: number) => {
|
||||
console.log(`[reading-position] [${new Date().toISOString()}] 📞 scheduleSave called at ${Math.round(currentPosition * 100)}%, syncEnabled=${syncEnabled}, hasOnSave=${!!onSave}`)
|
||||
|
||||
if (!syncEnabled || !onSave) {
|
||||
console.log(`[reading-position] [${new Date().toISOString()}] ⏭️ Save skipped: syncEnabled=${syncEnabled}, hasOnSave=${!!onSave}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Always save instantly when we reach completion (1.0)
|
||||
if (currentPosition === 1 && lastSavedPosition.current < 1) {
|
||||
if (currentPosition === 1 && !lastSaved100Ref.current) {
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = null
|
||||
}
|
||||
lastSavedPosition.current = 1
|
||||
hasSavedOnce.current = true
|
||||
lastSavedAtRef.current = Date.now()
|
||||
console.log(`[reading-position] [${new Date().toISOString()}] 💾 Instant save at 100% completion`)
|
||||
lastSaved100Ref.current = true
|
||||
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
|
||||
// Always update the pending position (latest position to save)
|
||||
pendingPositionRef.current = currentPosition
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Clear existing timer
|
||||
// Throttle: only schedule a save if one isn't already pending
|
||||
// This ensures saves happen at regular 3s intervals during continuous scrolling
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
console.log(`[reading-position] [${new Date().toISOString()}] ⏳ Timer already pending, updated pending position to ${Math.round(currentPosition * 100)}%`)
|
||||
return // Already have a save scheduled, don't reset the timer
|
||||
}
|
||||
|
||||
// Schedule new save using the larger of autoSaveInterval and MIN_INTERVAL_MS
|
||||
const delay = Math.max(autoSaveInterval, MIN_INTERVAL_MS)
|
||||
const THROTTLE_MS = 3000
|
||||
console.log(`[reading-position] [${new Date().toISOString()}] ⏰ Scheduling save in ${THROTTLE_MS}ms`)
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
lastSavedPosition.current = currentPosition
|
||||
hasSavedOnce.current = true
|
||||
lastSavedAtRef.current = Date.now()
|
||||
onSave(currentPosition)
|
||||
}, delay)
|
||||
}, [syncEnabled, onSave, autoSaveInterval])
|
||||
|
||||
// Immediate save function
|
||||
const saveNow = useCallback(() => {
|
||||
if (!syncEnabled || !onSave) return
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
// Save the latest position, not the one from when timer was scheduled
|
||||
const positionToSave = pendingPositionRef.current
|
||||
console.log(`[reading-position] [${new Date().toISOString()}] 💾 Auto-save at ${Math.round(positionToSave * 100)}%`)
|
||||
onSave(positionToSave)
|
||||
saveTimerRef.current = null
|
||||
}
|
||||
lastSavedPosition.current = position
|
||||
hasSavedOnce.current = true
|
||||
lastSavedAtRef.current = Date.now()
|
||||
onSave(position)
|
||||
}, [syncEnabled, onSave, position])
|
||||
}, THROTTLE_MS)
|
||||
}, [syncEnabled, onSave])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
@@ -123,21 +90,27 @@ export const useReadingPosition = ({
|
||||
const windowHeight = window.innerHeight
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
|
||||
// Ignore if document is too small (likely during page transition)
|
||||
if (documentHeight < 100) return
|
||||
|
||||
// Calculate position based on how much of the content has been scrolled through
|
||||
// Add a small threshold (5px) to account for rounding and make it easier to reach 100%
|
||||
const maxScroll = documentHeight - windowHeight
|
||||
const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0
|
||||
|
||||
// If we're within 5px of the bottom, consider it 100%
|
||||
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
|
||||
// Only consider it 100% if we're truly at the bottom AND have scrolled significantly
|
||||
// This prevents false 100% during page transitions
|
||||
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5 && scrollTop > 100
|
||||
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
||||
|
||||
setPosition(clampedProgress)
|
||||
positionRef.current = clampedProgress
|
||||
onPositionChange?.(clampedProgress)
|
||||
|
||||
// Schedule auto-save if sync is enabled
|
||||
scheduleSave(clampedProgress)
|
||||
// Schedule auto-save if sync is enabled (unless suppressed)
|
||||
if (Date.now() >= suppressUntilRef.current) {
|
||||
scheduleSave(clampedProgress)
|
||||
}
|
||||
// Note: Suppression is silent to avoid log spam during scrolling
|
||||
|
||||
// Completion detection with 2s hold at 100%
|
||||
if (!hasTriggeredComplete.current) {
|
||||
@@ -180,10 +153,8 @@ export const useReadingPosition = ({
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', handleScroll)
|
||||
|
||||
// Clear save timer on unmount
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
// DON'T clear save timer - let it complete even if tracking is temporarily disabled
|
||||
// Only clear completion timer since that's tied to the current scroll session
|
||||
if (completionTimerRef.current) {
|
||||
clearTimeout(completionTimerRef.current)
|
||||
}
|
||||
@@ -195,8 +166,7 @@ export const useReadingPosition = ({
|
||||
if (!enabled) {
|
||||
setIsReadingComplete(false)
|
||||
hasTriggeredComplete.current = false
|
||||
hasSavedOnce.current = false
|
||||
lastSavedPosition.current = 0
|
||||
lastSaved100Ref.current = false
|
||||
if (completionTimerRef.current) {
|
||||
clearTimeout(completionTimerRef.current)
|
||||
completionTimerRef.current = null
|
||||
@@ -208,6 +178,6 @@ export const useReadingPosition = ({
|
||||
position,
|
||||
isReadingComplete,
|
||||
progressPercentage: Math.round(position * 100),
|
||||
saveNow
|
||||
suppressSavesFor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,101 @@ export function startReadingPositionStream(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stabilized reading position collector
|
||||
* Collects position updates for a brief window, then emits the best one (newest, then highest progress)
|
||||
* @returns Object with stop() to cancel and onStable(cb) to register callback
|
||||
*/
|
||||
export function collectReadingPositionsOnce(params: {
|
||||
relayPool: RelayPool
|
||||
eventStore: IEventStore
|
||||
pubkey: string
|
||||
articleIdentifier: string
|
||||
windowMs?: number
|
||||
}): { stop: () => void; onStable: (cb: (pos: ReadingPosition | null) => void) => void } {
|
||||
const { relayPool, eventStore, pubkey, articleIdentifier, windowMs = 700 } = params
|
||||
|
||||
const candidates: ReadingPosition[] = []
|
||||
let stableCallback: ((pos: ReadingPosition | null) => void) | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let streamStop: (() => void) | null = null
|
||||
let hasEmitted = false
|
||||
|
||||
const emitStable = () => {
|
||||
if (hasEmitted || !stableCallback) return
|
||||
hasEmitted = true
|
||||
|
||||
if (candidates.length === 0) {
|
||||
console.log('[reading-position] 📊 No candidates collected during stabilization window')
|
||||
stableCallback(null)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[reading-position] 📊 Collected', candidates.length, 'position candidates:',
|
||||
candidates.map(c => `${Math.round(c.position * 100)}% @${new Date(c.timestamp * 1000).toLocaleTimeString()}`).join(', '))
|
||||
|
||||
// Sort: newest first, then highest progress
|
||||
candidates.sort((a, b) => {
|
||||
const timeDiff = b.timestamp - a.timestamp
|
||||
if (timeDiff !== 0) return timeDiff
|
||||
return b.position - a.position
|
||||
})
|
||||
|
||||
console.log('[reading-position] ✅ Best position selected:', Math.round(candidates[0].position * 100) + '%',
|
||||
'from', new Date(candidates[0].timestamp * 1000).toLocaleTimeString())
|
||||
stableCallback(candidates[0])
|
||||
}
|
||||
|
||||
// Start streaming and collecting
|
||||
console.log('[reading-position] 🎯 Starting stabilized position collector (window:', windowMs, 'ms)')
|
||||
streamStop = startReadingPositionStream(
|
||||
relayPool,
|
||||
eventStore,
|
||||
pubkey,
|
||||
articleIdentifier,
|
||||
(pos) => {
|
||||
if (hasEmitted) return
|
||||
if (!pos) {
|
||||
console.log('[reading-position] 📥 Received null position')
|
||||
return
|
||||
}
|
||||
if (pos.position <= 0.05 || pos.position >= 1) {
|
||||
console.log('[reading-position] 🚫 Ignoring position', Math.round(pos.position * 100) + '% (outside 5%-100% range)')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[reading-position] 📥 Received position candidate:', Math.round(pos.position * 100) + '%',
|
||||
'from', new Date(pos.timestamp * 1000).toLocaleTimeString())
|
||||
candidates.push(pos)
|
||||
|
||||
// Schedule one-shot emission if not already scheduled
|
||||
if (!timer) {
|
||||
console.log('[reading-position] ⏰ Starting', windowMs, 'ms stabilization timer')
|
||||
timer = setTimeout(() => {
|
||||
emitStable()
|
||||
if (streamStop) streamStop()
|
||||
}, windowMs)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
if (streamStop) {
|
||||
streamStop()
|
||||
streamStop = null
|
||||
}
|
||||
},
|
||||
onStable: (cb) => {
|
||||
stableCallback = cb
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load reading position from Nostr (kind 39802)
|
||||
* @deprecated Use startReadingPositionStream for non-blocking behavior
|
||||
|
||||
Reference in New Issue
Block a user