Compare commits

...

25 Commits

Author SHA1 Message Date
Gigi
28ba620967 chore: bump version to 0.10.18 2025-10-23 16:37:43 +02:00
Gigi
56f2d33e93 fix: fetch all highlights without incremental loading
- Remove incremental loading (since filter) from highlightsController
- Fetch ALL highlights without limits for complete results
- Remove unused timestamp tracking methods and constant
- Ensures /my/highlights shows all highlights consistently
- Matches the fix applied to writingsController
2025-10-23 16:36:02 +02:00
Gigi
312c742969 refactor: centralize writings fetching in controller
- Remove incremental loading (since filter) from writingsController
- Fetch ALL writings without limits for complete results
- Remove duplicate background fetch from Me.tsx and Profile.tsx
- Use writingsController.start() in Profile to populate event store
- Keep code DRY by having single source of truth in controller
- Follows controller pattern: stream, dedupe, store, emit
2025-10-23 16:31:56 +02:00
Gigi
0781c4ebfc fix: fetch all writings in background on /my/writings page
Add background fetch effect to Me component to populate event store with
all writings without limits, matching the behavior of Profile component.
This ensures all writings are displayed on /my/writings page.
2025-10-23 16:28:50 +02:00
Gigi
85f4cd3590 docs: update FEATURES and CHANGELOG from /me to /my 2025-10-23 16:18:32 +02:00
Gigi
89bc6258b1 docs: update CSS comments from Me to My 2025-10-23 16:14:32 +02:00
Gigi
534b628aea refactor: update ShareTargetHandler to navigate to /my/links 2025-10-23 16:13:35 +02:00
Gigi
317d2e0b53 refactor: update Bookmarks component to detect /my routes 2025-10-23 16:13:20 +02:00
Gigi
9ea69589fa refactor: update sidebar avatar navigation to /my 2025-10-23 16:12:52 +02:00
Gigi
89eaa97d30 refactor: update Me component navigation to /my routes 2025-10-23 16:12:33 +02:00
Gigi
0283405fb5 refactor: rename /me routes to /my in App.tsx 2025-10-23 16:12:01 +02:00
Gigi
5eade913d1 docs: update CHANGELOG for v0.10.17 2025-10-23 16:01:31 +02:00
Gigi
15a7129b6d chore: bump version to 0.10.17 2025-10-23 15:59:45 +02:00
Gigi
b9e17e0982 fix: remove unused variable in highlight timestamp handler 2025-10-23 15:59:11 +02:00
Gigi
1be8c62c94 fix: restore edge-to-edge hero image on mobile with adjusted negative margins 2025-10-23 15:56:34 +02:00
Gigi
e2bf243b01 style: increase mobile reader padding to 1rem for better title/body alignment 2025-10-23 15:55:00 +02:00
Gigi
85d816b2a7 style: increase horizontal padding in reader on mobile for better readability 2025-10-23 15:52:36 +02:00
Gigi
623bee4632 fix: timestamp in highlight cards now opens content in app instead of external search 2025-10-23 09:44:20 +02:00
Gigi
e68b97bde8 fix: add equal right padding to blockquotes for better mobile layout 2025-10-23 09:41:25 +02:00
Gigi
ca32dfca51 perf: reduce reading position throttle from 3s to 1s
Reading position now saves every 1 second during continuous scrolling
instead of every 3 seconds, providing more frequent position updates.
2025-10-23 01:04:54 +02:00
Gigi
9de8b00d5d chore: remove remaining console.log statements from reading position code
Removed all debug logs from readingPositionService.ts that were left
from the stabilization collector implementation.
2025-10-23 00:56:53 +02:00
Gigi
033ef5e995 fix: relay article link now opens via /a/ path instead of /r/
Updated handleLinkClick in PWASettings to check if URL is an internal
route (starts with /) and navigate directly, otherwise wrap external
URLs with /r/ path. This fixes the third relay education link to open
the nostr article correctly.
2025-10-23 00:54:29 +02:00
Gigi
c986b0d517 feat: add setting to control auto-scroll to reading position
- Added autoScrollToReadingPosition setting (enabled by default)
- Users can now disable auto-scroll while keeping position sync enabled
- Setting appears in Layout & Behavior section of settings
- Auto-scroll only happens when both syncReadingPosition and
  autoScrollToReadingPosition are enabled
2025-10-23 00:52:51 +02:00
Gigi
1729a5b066 chore: remove debug logs from reading position code 2025-10-23 00:51:01 +02:00
Gigi
c6186ea84e docs: update CHANGELOG for v0.10.16 2025-10-23 00:48:24 +02:00
21 changed files with 200 additions and 228 deletions

View File

@@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.10.17] - 2025-10-23
### Added
- Setting to control auto-scroll to reading position
- New toggle in Settings > Reading Experience
- Allows users to disable automatic scroll restoration
- Defaults to enabled (preserves existing behavior)
### Fixed
- Blockquote styling on mobile devices
- Added equal right padding to match left padding (2rem on both sides)
- Prevents awkward text cutoff on narrow screens
- Timestamp clicks in highlight cards now navigate within app
- Articles (kind:30023) open via `/a/{naddr}` route
- External URLs open via `/r/{encodedUrl}` route
- Previously opened external search portal (ants.sh)
- Highlight automatically scrolls into view with sidebar open
- Hero images now properly extend edge-to-edge on mobile
- Adjusted negative margins to match new reader padding
- Image bleeds to screen edges while text maintains comfortable margins
- Article relay links now open via `/a/` path instead of `/r/`
- Ensures nostr-native articles route correctly
- External links continue to use `/r/` path
### Changed
- Mobile reader padding increased for better readability
- Horizontal padding increased from 0.5rem to 1rem
- Title, summary, and body text now properly aligned
- More comfortable reading experience on small screens
- Reading position save interval reduced from 3s to 1s
- More frequent auto-saves during active reading
- Better preservation of reading progress
## [0.10.16] - 2025-10-22
### Fixed
- Reading position auto-save now works correctly during continuous scrolling
- Fixed critical bug where save timer was cleared when tracking toggled
- Timer now persists across tracking state changes
- Saves fire reliably every 3 seconds during active reading
- Throttle mechanism now works as intended
- Reading position tracking stability improved
- Tracking state no longer toggles erratically
- Content stability checks refined to prevent false negatives
- Infinite loop fixed in position save handler
### Changed
- Reading position save mechanism changed from debounce to throttle
- Ensures saves happen at regular 3-second intervals during continuous scrolling
- Previous debounce approach could skip saves during slow continuous scrolling
- More predictable save behavior for users
- Simplified reading position logic by removing unused complexity
- Removed 5% delta requirement for scheduling saves
- Removed unnecessary state tracking (lastSavedPosition, hasSavedOnce, lastSavedAtRef)
- Cleaner, more maintainable code
### Fixed
- Highlights now scroll into view when clicked from `/my/highlights` page
- Navigation state properly passes highlight ID and openHighlights flag
- Works for both article links and external URL links
## [0.10.15] - 2025-01-22
### Changed
@@ -128,8 +195,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Default bookmark view changed to flat chronological list (newest first)
- Bookmark URL changed from `/me/reading-list` to `/me/bookmarks`
- Router updated to handle `/me/reading-list``/me/bookmarks` redirect
- Bookmark URL changed from `/my/reading-list` to `/my/bookmarks`
- Router updated to handle `/my/reading-list``/my/bookmarks` redirect
- Me.tsx bookmarks tab now uses dynamic filter titles and chronological sorting
- Me.tsx updated to use faClock icon instead of faBars
- Removed bookmark count from section headings for cleaner display
@@ -235,7 +302,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Tab switching regression on `/me` page
- Tab switching regression on `/my` 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
@@ -593,7 +660,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- `/me/bookmarks` tab now displays in cards view only
- `/my/bookmarks` tab now displays in cards view only
- Removed view mode toggle buttons (compact, large) from bookmarks tab
- Cards view provides optimal bookmark browsing experience
- Grouping toggle (grouped/flat) still available
@@ -706,7 +773,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Prevent "No highlights yet" flash on `/me/highlights` page
- Prevent "No highlights yet" flash on `/my/highlights` page
- Force React to remount tab content when switching tabs for proper state management
- Deduplicate blog posts by author:d-tag instead of event ID for better accuracy
- Show skeleton placeholders while highlights are loading for better UX
@@ -914,7 +981,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Filter icons colored when active (blue for most, green for completed)
- URL routing support for reading progress filters
- Reading progress filters available in Archive tab and bookmarks sidebar
- Reads and Links tabs on `/me` page
- Reads and Links tabs on `/my` page
- Reads tab shows nostr-native articles with reading progress
- Links tab shows external URLs with reading progress
- Both tabs populate instantly from bookmarks for fast loading
@@ -960,7 +1027,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Bookmark filter buttons by content type (articles, videos, images, web links)
- Filter bookmarks by their content type on bookmarks sidebar
- Filters also available on `/me` page bookmarks tab
- Filters also available on `/my` page bookmarks tab
- Separate filter for external articles with link icon
- Multiple filters can be active simultaneously
- Private Bookmarks section for encrypted legacy bookmarks
@@ -974,7 +1041,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Better categorization of bookmark types
- Bookmark filter button styling refined
- Reduced whitespace around bookmark filters for cleaner layout
- Dramatically reduced whitespace on both sidebar and `/me` page
- Dramatically reduced whitespace on both sidebar and `/my` page
- Lock icon removed from individual bookmarks
- Encryption status now indicated by section grouping
- Cleaner bookmark item appearance
@@ -1141,7 +1208,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Camera icon for image bookmarks
- Sticky note icon for text-only bookmarks without URLs
- Bookmark grouping and sections
- Grouped sections in sidebar and `/me` reading-list
- Grouped sections in sidebar and `/my` reading-list
- Web bookmarks, default bookmarks, and legacy bookmarks in separate sections
- Grouping and sorting helpers for organizing bookmark sections
- Adaptive text color for publication date over hero images
@@ -1204,7 +1271,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Mobile bookmark button visibility across all pages
- Now visible on `/p/` (profile), `/explore`, `/me`, and `/support` pages
- Now visible on `/p/` (profile), `/explore`, `/my`, and `/support` pages
- Only hidden on settings page or when scrolling down while reading
- Prevents users from getting stuck without navigation options
- Mobile highlights button behavior at page top
@@ -1385,7 +1452,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Highlights tab on `/explore` page
- View highlights from friends and followed users
- Tab structure matching `/me` and profile pages
- Tab structure matching `/my` and profile pages
- Grid layout for highlights with cards
- Highlights shown first, writings second
- Clicking highlight opens source article and scrolls to position
@@ -1542,7 +1609,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Writings tab on `/me` page to display user's published articles
- Writings tab on `/my` page to display user's published articles
- Comprehensive headline styling (h1-h6) with Tailwind typography
- List styling for ordered and unordered lists in articles
- Blockquote styling with indentation and italics
@@ -1565,7 +1632,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Horizontal overflow from code blocks and wide content on mobile
- Settings view now mobile-friendly with proper width constraints
- Long relay URLs no longer cause horizontal overflow on mobile
- Sidebar/highlights toggle buttons hidden on settings/explore/me pages
- Sidebar/highlights toggle buttons hidden on settings/explore/my pages
- Video titles now show filename instead of 'Error Loading Content'
- AddBookmarkModal z-index issue fixed using React Portal
- Highlight matching for text spanning multiple DOM nodes/inline elements
@@ -1620,7 +1687,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- YouTube video metadata extraction with title, description, and captions
- Responsive video player with aspect ratio support
- Thumbnail images in compact view
- URL routing for /me page tabs
- URL routing for /my page tabs
- Bookmark navigation in reading list
- Video duration display for video URLs
- Three-dot menu for videos with open/native/copy/share actions
@@ -1650,7 +1717,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Style
- Hide tab counts on mobile for /me page
- Hide tab counts on mobile for /my page
- Remove max-width on main pane, constrain reader instead
- Full width layout for videos
- Reader-video specific styles
@@ -1663,11 +1730,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Prism.js syntax highlighting for code blocks
- Inline image rendering in nostr-native blog posts
- Image placeholders on blog post cards in `/explore`
- Caching on `/me` page for faster loading
- Caching on `/my` page for faster loading
### Changed
- Reading List on `/me` now uses the same components as the bookmarks sidebar
- Reading List on `/my` now uses the same components as the bookmarks sidebar
- Improve bookmarks sidebar visual design
- Make article menu button more subtle by removing border
@@ -1687,8 +1754,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- `/me` page with tabbed layout featuring Highlights, Reading List, and Library tabs
- Two-pane layout for `/me` page with article sources and highlights
- `/my` page with tabbed layout featuring Highlights, Reading List, and Library tabs
- Two-pane layout for `/my` page with article sources and highlights
- Custom FontAwesome Pro books icon for Archive tab
- CompactButton component for highlight cards
- Instant mark-as-read functionality with checkmark animation and read status checking
@@ -1697,7 +1764,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Rename Library tab to Archive
- Move highlight timestamp to top-right corner of cards
- Replace username with AuthorCard component on `/me` page
- Replace username with AuthorCard component on `/my` page
- Use user's custom highlight color for Highlights tab
- Render library articles using BlogPostCard component for consistency
- Use faBooks icon for Mark as Read button
@@ -1717,12 +1784,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Style
- Match `/me` profile card width to highlight cards
- Improve Me page mobile tabs and avoid overlap with sidebar buttons
- Match `/my` profile card width to highlight cards
- Improve My page mobile tabs and avoid overlap with sidebar buttons
- Reduce margins/paddings to make highlight cards more compact
- Tighten vertical spacing on highlight cards
- Left-align text inside author card
- Constrain `/me` page content width to match author card (600px)
- Constrain `/my` page content width to match author card (600px)
- Improve tab border styling for dark theme
- Make relay indicator match CompactButton (same look as menu)
- Align relay indicator within footer with symmetric spacing
@@ -1827,7 +1894,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Highlights: merge remote results after local for article/url
- Explore: always query remote relays after local; stream merge into UI
- Improve mobile touch targets for highlight icons
- Color `/me` highlights with "my highlights" color setting
- Color `/my` highlights with "my highlights" color setting
### Performance
@@ -1859,7 +1926,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Confirmation dialog prevents accidental deletions
- Styled to match relay indicator (subtle, same size)
- Removes highlights from UI immediately after deletion request
- `/me` page showing user's recent highlights
- `/my` page showing user's recent highlights
- Accessible by clicking profile picture in bookmark sidebar
- Displays all highlights created by the logged-in user
- Uses same rendering as Settings and Explore pages

View File

@@ -40,7 +40,7 @@
- **Explore**: Discover friends' highlights and writings, plus a "nostrverse" feed.
- **Filters**: Visibility toggles (mine, friends, nostrverse) apply to Explore highlights.
- **Profiles**: View your own (`/me`) or other users (`/p/:npub`) with tabs for Highlights, Bookmarks, Archive, and Writings.
- **Profiles**: View your own (`/my`) or other users (`/p/:npub`) with tabs for Highlights, Bookmarks, Archive, and Writings.
## Support

View File

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

View File

@@ -237,11 +237,11 @@ function AppRoutes({
}
/>
<Route
path="/me"
element={<Navigate to="/me/highlights" replace />}
path="/my"
element={<Navigate to="/my/highlights" replace />}
/>
<Route
path="/me/highlights"
path="/my/highlights"
element={
<Bookmarks
relayPool={relayPool}
@@ -253,7 +253,7 @@ function AppRoutes({
}
/>
<Route
path="/me/bookmarks"
path="/my/bookmarks"
element={
<Bookmarks
relayPool={relayPool}
@@ -265,7 +265,7 @@ function AppRoutes({
}
/>
<Route
path="/me/reads"
path="/my/reads"
element={
<Bookmarks
relayPool={relayPool}
@@ -277,7 +277,7 @@ function AppRoutes({
}
/>
<Route
path="/me/reads/:filter"
path="/my/reads/:filter"
element={
<Bookmarks
relayPool={relayPool}
@@ -289,7 +289,7 @@ function AppRoutes({
}
/>
<Route
path="/me/links"
path="/my/links"
element={
<Bookmarks
relayPool={relayPool}
@@ -301,7 +301,7 @@ function AppRoutes({
}
/>
<Route
path="/me/links/:filter"
path="/my/links/:filter"
element={
<Bookmarks
relayPool={relayPool}
@@ -313,7 +313,7 @@ function AppRoutes({
}
/>
<Route
path="/me/writings"
path="/my/writings"
element={
<Bookmarks
relayPool={relayPool}

View File

@@ -53,7 +53,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
const showSettings = location.pathname === '/settings'
const showExplore = location.pathname.startsWith('/explore')
const showMe = location.pathname.startsWith('/me')
const showMe = location.pathname.startsWith('/my')
const showProfile = location.pathname.startsWith('/p/')
const showSupport = location.pathname === '/support'
const eventId = eventIdParam
@@ -62,12 +62,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
// Extract tab from me routes
const meTab = location.pathname === '/me' ? 'highlights' :
location.pathname === '/me/highlights' ? 'highlights' :
location.pathname === '/me/bookmarks' ? 'bookmarks' :
location.pathname.startsWith('/me/reads') ? 'reads' :
location.pathname.startsWith('/me/links') ? 'links' :
location.pathname === '/me/writings' ? 'writings' : 'highlights'
const meTab = location.pathname === '/my' ? 'highlights' :
location.pathname === '/my/highlights' ? 'highlights' :
location.pathname === '/my/bookmarks' ? 'bookmarks' :
location.pathname.startsWith('/my/reads') ? 'reads' :
location.pathname.startsWith('/my/links') ? 'links' :
location.pathname === '/my/writings' ? 'writings' : 'highlights'
// Extract tab from profile routes
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
@@ -87,7 +87,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
}
}
// Track previous location for going back from settings/me/explore/profile
// Track previous location for going back from settings/my/explore/profile
useEffect(() => {
if (!showSettings && !showMe && !showExplore && !showProfile) {
previousLocationRef.current = location.pathname

View File

@@ -153,20 +153,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// 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])
@@ -187,24 +178,20 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// 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(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,
@@ -217,9 +204,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
scrollTop
}
)
console.log(`[reading-position] [${new Date().toISOString()}] ✅ Position published successfully`)
} catch (error) {
console.error(`[reading-position] [${new Date().toISOString()}] ❌ Failed to save reading position:`, error)
console.error('[reading-position] Failed to save reading position:', error)
}
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition])
@@ -236,7 +222,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (!isTextContent) {
// Disable tracking if content is not suitable
if (isTrackingEnabled) {
console.log('[reading-position] ⏸️ Disabling tracking (not text content)')
setIsTrackingEnabled(false)
}
return
@@ -245,7 +230,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
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)
@@ -285,35 +269,24 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
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 (settings?.autoScrollToReadingPosition === false) {
return
}
if (!isTrackingEnabled) {
console.log('[reading-position] ⏭️ Restore skipped: tracking not yet enabled (waiting for content stability)')
return
}
// 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
}
console.log('[reading-position] 🔄 Initiating restore for article:', articleIdentifier)
// Mark as attempted using composite key
hasAttemptedRestoreRef.current = restoreKey
@@ -321,12 +294,9 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
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)
@@ -340,20 +310,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
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)
@@ -361,20 +321,17 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
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])
}, [isTextContent, activeAccount, articleIdentifier, settings?.syncReadingPosition, settings?.autoScrollToReadingPosition, 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
// 2. The auto-save with 1s throttle already captures position during reading
// 3. Position state may not reflect actual reading position during navigation
// Close menu when clicking outside

View File

@@ -433,7 +433,31 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
title={new Date(highlight.created_at * 1000).toLocaleString()}
onClick={(e) => {
e.stopPropagation()
window.location.href = highlightLinks.native
// Navigate within app using same logic as handleItemClick
if (highlight.eventReference) {
const parts = highlight.eventReference.split(':')
if (parts.length === 3 && parts[0] === '30023') {
const [, pubkey, identifier] = parts
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey,
identifier
})
navigate(`/a/${naddr}`, {
state: {
highlightId: highlight.id,
openHighlights: true
}
})
}
} else if (highlight.urlReference) {
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
state: {
highlightId: highlight.id,
openHighlights: true
}
})
}
}}
>
{formatDateCompact(highlight.created_at)}

View File

@@ -144,15 +144,15 @@ const Me: React.FC<MeProps> = ({
setReadingProgressFilter(filter)
if (activeTab === 'reads') {
if (filter === 'all') {
navigate('/me/reads', { replace: true })
navigate('/my/reads', { replace: true })
} else {
navigate(`/me/reads/${filter}`, { replace: true })
navigate(`/my/reads/${filter}`, { replace: true })
}
} else if (activeTab === 'links') {
if (filter === 'all') {
navigate('/me/links', { replace: true })
navigate('/my/links', { replace: true })
} else {
navigate(`/me/links/${filter}`, { replace: true })
navigate(`/my/links/${filter}`, { replace: true })
}
}
}
@@ -867,7 +867,7 @@ const Me: React.FC<MeProps> = ({
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights"
onClick={() => navigate('/me/highlights')}
onClick={() => navigate('/my/highlights')}
>
<FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span>
@@ -875,7 +875,7 @@ const Me: React.FC<MeProps> = ({
<button
className={`me-tab ${activeTab === 'bookmarks' ? 'active' : ''}`}
data-tab="bookmarks"
onClick={() => navigate('/me/bookmarks')}
onClick={() => navigate('/my/bookmarks')}
>
<FontAwesomeIcon icon={faBookmark} />
<span className="tab-label">Bookmarks</span>
@@ -883,7 +883,7 @@ const Me: React.FC<MeProps> = ({
<button
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
data-tab="reads"
onClick={() => navigate('/me/reads')}
onClick={() => navigate('/my/reads')}
>
<FontAwesomeIcon icon={faBooks} />
<span className="tab-label">Reads</span>
@@ -891,7 +891,7 @@ const Me: React.FC<MeProps> = ({
<button
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
data-tab="links"
onClick={() => navigate('/me/links')}
onClick={() => navigate('/my/links')}
>
<FontAwesomeIcon icon={faLink} />
<span className="tab-label">Links</span>
@@ -899,7 +899,7 @@ const Me: React.FC<MeProps> = ({
<button
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings"
onClick={() => navigate('/me/writings')}
onClick={() => navigate('/my/writings')}
>
<FontAwesomeIcon icon={faPenToSquare} />
<span className="tab-label">Writings</span>

View File

@@ -6,10 +6,8 @@ import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
import { HighlightItem } from './HighlightItem'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
import { fetchHighlights } from '../services/highlightService'
import { BlogPostPreview } from '../services/exploreService'
import { KINDS } from '../config/kinds'
import { getActiveRelayUrls } from '../services/relayManager'
import AuthorCard from './AuthorCard'
import BlogPostCard from './BlogPostCard'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
@@ -20,6 +18,8 @@ import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { Hooks } from 'applesauce-react'
import { readingProgressController } from '../services/readingProgressController'
import { writingsController } from '../services/writingsController'
import { highlightsController } from '../services/highlightsController'
interface ProfileProps {
relayPool: RelayPool
@@ -103,17 +103,16 @@ const Profile: React.FC<ProfileProps> = ({
})
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
// Background fetch to populate event store (non-blocking)
// Background fetch via controllers to populate event store
useEffect(() => {
if (!pubkey || !relayPool || !eventStore) return
// Fetch all highlights and writings in background (no limits)
const relayUrls = getActiveRelayUrls(relayPool)
fetchHighlights(relayPool, pubkey, undefined, undefined, false, eventStore)
// Start controllers to fetch and populate event store
// Controllers handle streaming, deduplication, and storage
highlightsController.start({ relayPool, eventStore, pubkey })
.catch(err => console.warn('⚠️ [Profile] Failed to fetch highlights:', err))
fetchBlogPostsFromAuthors(relayPool, [pubkey], relayUrls, undefined, null, eventStore)
writingsController.start({ relayPool, eventStore, pubkey, force: refreshTrigger > 0 })
.catch(err => console.warn('⚠️ [Profile] Failed to fetch writings:', err))
}, [pubkey, relayPool, eventStore, refreshTrigger])

View File

@@ -44,6 +44,7 @@ const DEFAULT_SETTINGS: UserSettings = {
fullWidthImages: true,
renderVideoLinksAsEmbeds: true,
syncReadingPosition: true,
autoScrollToReadingPosition: true,
autoMarkAsReadOnCompletion: false,
hideBookmarksWithoutCreationDate: true,
ttsUseSystemLanguage: false,

View File

@@ -118,6 +118,19 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
</label>
</div>
<div className="setting-group">
<label htmlFor="autoScrollToReadingPosition" className="checkbox-label">
<input
id="autoScrollToReadingPosition"
type="checkbox"
checked={settings.autoScrollToReadingPosition !== false}
onChange={(e) => onUpdate({ autoScrollToReadingPosition: e.target.checked })}
className="setting-checkbox"
/>
<span>Auto-scroll to saved reading position</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="autoMarkAsReadOnCompletion" className="checkbox-label">
<input

View File

@@ -33,7 +33,13 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
const handleLinkClick = (url: string) => {
if (onClose) onClose()
navigate(`/r/${encodeURIComponent(url)}`)
// If it's an internal route (starts with /), navigate directly
if (url.startsWith('/')) {
navigate(url)
} else {
// External URL: wrap with /r/ path
navigate(`/r/${encodeURIComponent(url)}`)
}
}
const handleClearCache = async () => {

View File

@@ -60,7 +60,7 @@ export default function ShareTargetHandler({ relayPool }: ShareTargetHandlerProp
getActiveRelayUrls(relayPool)
)
showToast('Bookmark saved!')
navigate('/me/links')
navigate('/my/links')
} catch (err) {
console.error('Failed to save shared bookmark:', err)
showToast('Failed to save bookmark')

View File

@@ -40,7 +40,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
<button
className="profile-avatar-button"
title={getUserDisplayName()}
onClick={() => navigate('/me')}
onClick={() => navigate('/my')}
aria-label={`Profile: ${getUserDisplayName()}`}
>
{profileImage ? (

View File

@@ -33,15 +33,11 @@ export const useReadingPosition = ({
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
// Throttled save function - saves at 1s 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
}
@@ -51,7 +47,6 @@ export const useReadingPosition = ({
clearTimeout(saveTimerRef.current)
saveTimerRef.current = null
}
console.log(`[reading-position] [${new Date().toISOString()}] 💾 Instant save at 100% completion`)
lastSaved100Ref.current = true
onSave(1)
return
@@ -61,18 +56,15 @@ export const useReadingPosition = ({
pendingPositionRef.current = currentPosition
// Throttle: only schedule a save if one isn't already pending
// This ensures saves happen at regular 3s intervals during continuous scrolling
// This ensures saves happen at regular 1s intervals during continuous scrolling
if (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
}
const THROTTLE_MS = 3000
console.log(`[reading-position] [${new Date().toISOString()}] ⏰ Scheduling save in ${THROTTLE_MS}ms`)
const THROTTLE_MS = 1000
saveTimerRef.current = setTimeout(() => {
// 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
}, THROTTLE_MS)

View File

@@ -8,8 +8,6 @@ import { eventToHighlight, sortHighlights } from './highlightEventProcessor'
type HighlightsCallback = (highlights: Highlight[]) => void
type LoadingCallback = (loading: boolean) => void
const LAST_SYNCED_KEY = 'highlights_last_synced'
/**
* Shared highlights controller
* Manages the user's highlights centrally, similar to bookmarkController
@@ -68,37 +66,10 @@ class HighlightsController {
this.emitHighlights(this.currentHighlights)
}
/**
* Get last synced timestamp for incremental loading
*/
private getLastSyncedAt(pubkey: string): number | null {
try {
const data = localStorage.getItem(LAST_SYNCED_KEY)
if (!data) return null
const parsed = JSON.parse(data)
return parsed[pubkey] || null
} catch {
return null
}
}
/**
* Update last synced timestamp
*/
private setLastSyncedAt(pubkey: string, timestamp: number): void {
try {
const data = localStorage.getItem(LAST_SYNCED_KEY)
const parsed = data ? JSON.parse(data) : {}
parsed[pubkey] = timestamp
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
} catch (err) {
console.warn('[highlights] Failed to save last synced timestamp:', err)
}
}
/**
* Load highlights for a user
* Streams results and stores in event store
* Always fetches ALL highlights to ensure completeness
*/
async start(options: {
relayPool: RelayPool
@@ -124,15 +95,12 @@ class HighlightsController {
const seenIds = new Set<string>()
const highlightsMap = new Map<string, Highlight>()
// Get last synced timestamp for incremental loading
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
const filter: { kinds: number[]; authors: string[]; since?: number } = {
// Fetch ALL highlights without limits (no since filter)
// This ensures we get complete results for profile/my pages
const filter = {
kinds: [KINDS.Highlights],
authors: [pubkey]
}
if (lastSyncedAt) {
filter.since = lastSyncedAt
}
const events = await queryEvents(
relayPool,
@@ -179,12 +147,6 @@ class HighlightsController {
this.lastLoadedPubkey = pubkey
this.emitHighlights(sorted)
// Update last synced timestamp
if (sorted.length > 0) {
const newestTimestamp = Math.max(...sorted.map(h => h.created_at))
this.setLastSyncedAt(pubkey, newestTimestamp)
}
} catch (error) {
console.error('[highlights] ❌ Failed to load highlights:', error)
this.currentHighlights = []

View File

@@ -203,14 +203,10 @@ export function collectReadingPositionsOnce(params: {
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
@@ -218,13 +214,10 @@ export function collectReadingPositionsOnce(params: {
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,
@@ -233,21 +226,16 @@ export function collectReadingPositionsOnce(params: {
(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()

View File

@@ -62,6 +62,7 @@ export interface UserSettings {
renderVideoLinksAsEmbeds?: boolean // default: false
// Reading position sync
syncReadingPosition?: boolean // default: false (opt-in)
autoScrollToReadingPosition?: boolean // default: true - automatically scroll to saved position when opening article
autoMarkAsReadOnCompletion?: boolean // default: false (opt-in)
// Bookmark filtering
hideBookmarksWithoutCreationDate?: boolean // default: false

View File

@@ -10,8 +10,6 @@ const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished
type WritingsCallback = (posts: BlogPostPreview[]) => void
type LoadingCallback = (loading: boolean) => void
const LAST_SYNCED_KEY = 'writings_last_synced'
/**
* Shared writings controller
* Manages the user's nostr-native long-form articles (kind:30023) centrally,
@@ -71,34 +69,6 @@ class WritingsController {
this.emitWritings(this.currentPosts)
}
/**
* Get last synced timestamp for incremental loading
*/
private getLastSyncedAt(pubkey: string): number | null {
try {
const data = localStorage.getItem(LAST_SYNCED_KEY)
if (!data) return null
const parsed = JSON.parse(data)
return parsed[pubkey] || null
} catch {
return null
}
}
/**
* Update last synced timestamp
*/
private setLastSyncedAt(pubkey: string, timestamp: number): void {
try {
const data = localStorage.getItem(LAST_SYNCED_KEY)
const parsed = data ? JSON.parse(data) : {}
parsed[pubkey] = timestamp
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
} catch (err) {
console.warn('[writings] Failed to save last synced timestamp:', err)
}
}
/**
* Convert NostrEvent to BlogPostPreview using applesauce Helpers
*/
@@ -127,6 +97,7 @@ class WritingsController {
/**
* Load writings for a user (kind:30023)
* Streams results and stores in event store
* Always fetches ALL writings to ensure completeness
*/
async start(options: {
relayPool: RelayPool
@@ -152,15 +123,12 @@ class WritingsController {
const seenIds = new Set<string>()
const uniqueByReplaceable = new Map<string, BlogPostPreview>()
// Get last synced timestamp for incremental loading
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
const filter: { kinds: number[]; authors: string[]; since?: number } = {
// Fetch ALL writings without limits (no since filter)
// This ensures we get complete results for profile/my pages
const filter = {
kinds: [KINDS.BlogPost],
authors: [pubkey]
}
if (lastSyncedAt) {
filter.since = lastSyncedAt
}
const events = await queryEvents(
relayPool,
@@ -221,12 +189,6 @@ class WritingsController {
this.lastLoadedPubkey = pubkey
this.emitWritings(sorted)
// Update last synced timestamp
if (sorted.length > 0) {
const newestTimestamp = Math.max(...sorted.map(p => p.event.created_at))
this.setLastSyncedAt(pubkey, newestTimestamp)
}
} catch (error) {
console.error('[writings] ❌ Failed to load writings:', error)
this.currentPosts = []

View File

@@ -1,4 +1,4 @@
/* Me page tabs */
/* My page tabs */
.me-tabs {
display: flex;
gap: 0.5rem;
@@ -71,7 +71,7 @@
padding-top: 0.25rem;
}
/* Align highlight list width with profile card width on /me */
/* Align highlight list width with profile card width on /my */
.me-highlights-list { padding-left: 0; padding-right: 0; }
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
@@ -83,7 +83,7 @@
text-align: left; /* Override center alignment from .app */
}
/* Bookmark filters in Me page */
/* Bookmark filters in My page */
.me-tab-content .bookmark-filters {
background: transparent;
border: none;

View File

@@ -145,7 +145,7 @@
}
.reader-markdown blockquote, .reader-html blockquote {
margin: 1.5rem 0;
padding: 1rem 0 1rem 2rem;
padding: 1rem 2rem;
font-style: italic;
}
.reader-markdown blockquote p, .reader-html blockquote p { margin: 0.5rem 0; }
@@ -232,7 +232,7 @@
max-width: 100%;
width: 100%;
margin: 0;
padding: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0;
border-left: none;
border-right: none;
@@ -261,7 +261,7 @@
.reader-header-overlay .reader-summary.hide-on-mobile { display: none; }
.reader-summary-below-image { display: block; padding: 0 0 1.5rem 0; margin-top: -1rem; }
.reader-summary-below-image .reader-summary { color: var(--color-text-secondary); font-size: 1rem; line-height: 1.6; margin: 0; }
.reader-hero-image { min-height: 280px; max-height: 400px; height: 50vh; }
.reader-hero-image { width: calc(100% + 2rem); margin: -0.5rem -1rem 2rem -1rem; min-height: 280px; max-height: 400px; height: 50vh; }
.reader-hero-image img { height: 100%; width: 100%; object-fit: cover; object-position: center; }
.reader-header-overlay { padding: 1.5rem 1rem 1rem; }
.reader-header-overlay .reader-title { font-size: 2rem; line-height: 1.3; }