Compare commits

..

15 Commits

Author SHA1 Message Date
Gigi
d9c46e602a chore: bump version to 0.10.21 2025-10-23 20:41:32 +02:00
Gigi
4d980bf91c fix: deduplicate bookmarks in Me component
- Add dedupeBookmarksById to flatten operation in Me.tsx
- Same article can appear in multiple bookmark lists/sets
- Use coordinate-based deduplication (kind:pubkey:identifier) for articles
- Prevents duplicate display when article is in multiple bookmark sources
2025-10-23 20:38:06 +02:00
Gigi
cb3b0e38e9 fix: show article title instead of summary in compact view
- Extract article title from tags in BookmarkItem
- Update CompactView to display title as main text for articles
- Remove unused articleSummary prop from CompactView to keep code DRY
- Follows NIP-23 article metadata structure
2025-10-23 20:29:38 +02:00
Gigi
fbf5c455ca fix: prevent tracking reading position for nostr-event sentinel URLs
- Add check to filter out nostr-event: URLs from reading position tracking
- nostr-event: is an internal sentinel, not a valid Nostr URI per NIP-21
- Prevents these sentinel URLs from being saved to reading position data
2025-10-23 20:21:41 +02:00
Gigi
ed5decf3e9 fix: properly route nostr-event URLs to /e/ path instead of /a/
- Add special handling for nostr-event: URLs in getReadItemUrl
- Add special handling for nostr-event: URLs in handleSelectUrl
- Prevent nostr-event URLs from being incorrectly routed to /a/ path (which expects naddr)
- Route nostr-event URLs to /e/ path for proper event loading
- Fixes 'String must be lowercase or uppercase' error when loading base64-encoded nostr-event URLs
2025-10-23 20:18:35 +02:00
Gigi
44a7e6ae2c docs: update CHANGELOG for v0.10.20 2025-10-23 20:08:49 +02:00
Gigi
f52b94d72a chore: bump version to 0.10.20 2025-10-23 20:07:04 +02:00
Gigi
d0833b5ed4 fix(lint): add headings to markdown rule files
Add top-level headings and proper spacing around lists in
markdown documentation files to satisfy markdown linting rules
2025-10-23 20:06:23 +02:00
Gigi
2f20b393bc fix(mobile): preserve scroll position and fix infinite loop
- Fix scroll position reset when toggling highlights panel on mobile
  by using a ref to store the position and requestAnimationFrame
  to restore it after the DOM updates
- Fix infinite loop in useReadingPosition caused by callbacks in
  dependency array by storing them in refs instead
2025-10-23 20:04:18 +02:00
Gigi
13fa6cd485 fix(mobile): preserve scroll position when toggling highlights panel
When opening/closing the highlights sidebar on mobile, the body gets
position:fixed to prevent background scrolling. This was causing the
scroll position to reset to the top.

Now we save the scroll position before locking, apply it as a negative
top value to maintain visual position, and restore it when unlocking.
2025-10-23 20:00:16 +02:00
Gigi
e6e7240cd5 fix: navigate to article route instead of passing empty URL
- Update CompactView to navigate to /a/:naddr for kind:30023 articles
- Update BookmarkItem handleReadNow to navigate to /a/:naddr for articles
- Fixes issue where clicking bookmarked articles showed 'Select a bookmark' message
2025-10-23 19:55:11 +02:00
Gigi
c1ff3b44d1 fix: stop infinite skeleton loading when article has zero highlights 2025-10-23 17:32:54 +02:00
Gigi
0577f862fd feat: show Web Bookmarks first when grouped by source 2025-10-23 17:30:55 +02:00
Gigi
883cb352ff chore: update package-lock version to 0.10.19 2025-10-23 17:26:44 +02:00
Gigi
238cc9bc00 docs: update CHANGELOG for v0.10.19 2025-10-23 17:06:34 +02:00
13 changed files with 164 additions and 38 deletions

View File

@@ -3,8 +3,11 @@ description: fetching data from relays
alwaysApply: false
---
# Fetching Data with Controllers
We fetch data from relays using controllers:
- Start controllers immediatly; dont await.
- Start controllers immediatly; don't await.
- Stream via onEvent; dedupe replaceables; emit immediately.
- Parallel local/remote queries; complete on EOSE.
- Finalize and persist since after completion.

View File

@@ -3,6 +3,8 @@ description: anything related to UI/UX
alwaysApply: false
---
# Mobile-First UI/UX
This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.)
Let's not show too many error messages, and more importantly: let's not make them red. Nothing is ever this tragic.

View File

@@ -7,6 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.10.20] - 2025-10-23
### Added
- Web Bookmarks section now appears first when bookmarks are grouped by source
- Provides quicker access to external URL bookmarks
- Better organization for mixed bookmark collections
### Fixed
- Mobile scroll position preservation when toggling highlights panel
- Opening/closing the highlights sidebar no longer resets scroll to top
- Scroll position is saved before locking and restored after unlocking
- Uses `requestAnimationFrame` to ensure DOM updates before restoring
- Infinite loop in reading position tracking
- Fixed "Maximum update depth exceeded" error in `useReadingPosition` hook
- Callbacks now stored in refs to avoid dependency array issues
- Prevents unnecessary re-renders during scroll tracking
- Skeleton loading state for articles with zero highlights
- Articles without highlights no longer show infinite loading skeletons
- Properly transitions to empty state message
- Navigation to bookmarked articles
- Clicking bookmarked kind:30023 articles now navigates to `/a/:naddr` route
- Fixes issue where clicking showed "Select a bookmark" message instead
- Applies to both compact view and bookmark item interactions
## [0.10.19] - 2025-10-23
### Added
- Profile dropdown menu in sidebar header
- Click profile picture to access quick navigation menu
- Menu items: My Highlights, My Bookmarks, My Reads, My Links, My Writings
- Logout option included with separator
- Click outside to close
- Smooth slide-in animation
### Changed
- Profile picture interaction updated
- Now triggers dropdown menu instead of navigating to profile page
- More efficient access to all profile sections
- Collapse buttons repositioned for symmetry
- Highlights collapse button moved to left side of header
- Both bookmarks and highlights collapse buttons now left-aligned
- Consistent visual hierarchy across panels
- Grouping toggle button repositioned
- Moved from right/center to left side
- Now next to support button (orange heart)
- Better logical grouping of controls
- Collapse button styling standardized
- Highlights collapse button now matches bookmarks style
- Consistent 33x33px size, border radius, and hover states
- Uses native button element for better consistency
### Removed
- Redundant logout button from sidebar header
- Previously next to Explore button
- Now only accessible via profile dropdown menu
- Refresh buttons from sidebars
- Removed from bookmarks sidebar
- Removed from highlights sidebar
- Pull-to-refresh functionality remains available
### Fixed
- Cleaned up unused component props and parameters
- Removed `lastFetchTime` from BookmarkList
- Removed `loading` and `onRefresh` from HighlightsPanelHeader
- All linting errors resolved
- Type checking passing
## [0.10.18] - 2025-10-23
### Changed

4
package-lock.json generated
View File

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

View File

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

View File

@@ -1,10 +1,11 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
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 } from 'nostr-tools/nip19'
import { npubEncode, naddrEncode } from 'nostr-tools/nip19'
import { IndividualBookmark } from '../types/bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { classifyUrl } from '../utils/helpers'
@@ -23,6 +24,7 @@ interface BookmarkItemProps {
}
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
const navigate = useNavigate()
const [ogImage, setOgImage] = useState<string | null>(null)
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
@@ -40,10 +42,11 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
const firstUrl = hasUrls ? extractedUrls[0] : null
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : null
// For kind:30023 articles, extract image and summary tags (per NIP-23)
// For kind:30023 articles, extract title, image and summary tags (per NIP-23)
// Note: We extract directly from tags here since we don't have the full event.
// When we have full events, we use getArticleImage() helper (see articleService.ts)
const isArticle = bookmark.kind === 30023
const articleTitle = isArticle ? bookmark.tags.find(t => t[0] === 'title')?.[1] : undefined
const articleImage = isArticle ? bookmark.tags.find(t => t[0] === 'image')?.[1] : undefined
const articleSummary = isArticle ? bookmark.tags.find(t => t[0] === 'summary')?.[1] : undefined
@@ -108,10 +111,16 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
const handleReadNow = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
// For kind:30023 articles, pass the bookmark data instead of URL
// For kind:30023 articles, navigate to /a/:naddr route
if (bookmark.kind === 30023) {
if (onSelectUrl) {
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = naddrEncode({
kind: bookmark.kind,
pubkey: bookmark.pubkey,
identifier: dTag
})
navigate(`/a/${naddr}`)
}
return
}
@@ -148,10 +157,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
hasUrls,
extractedUrls,
onSelectUrl,
authorNpub,
getAuthorDisplayName,
handleReadNow,
articleSummary,
articleTitle,
contentTypeIcon: getContentTypeIcon(),
readingProgress
}

View File

@@ -170,11 +170,11 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
groupingMode === 'flat'
? [{ key: 'all', title: getFilterTitle(selectedFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
: [
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb },
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic }
]
// Add bookmark sets as additional sections (only in grouped mode)

View File

@@ -5,6 +5,7 @@ import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDateCompact } from '../../utils/bookmarkUtils'
import RichContent from '../RichContent'
import { naddrEncode } from 'nostr-tools/nip19'
interface CompactViewProps {
bookmark: IndividualBookmark
@@ -12,7 +13,7 @@ interface CompactViewProps {
hasUrls: boolean
extractedUrls: string[]
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
articleSummary?: string
articleTitle?: string
contentTypeIcon: IconDefinition
readingProgress?: number
}
@@ -23,7 +24,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
hasUrls,
extractedUrls,
onSelectUrl,
articleSummary,
articleTitle,
contentTypeIcon,
readingProgress
}) => {
@@ -33,7 +34,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
const isNote = bookmark.kind === 1
const isClickable = hasUrls || isArticle || isWebBookmark || isNote
const displayText = isArticle && articleSummary ? articleSummary : bookmark.content
const displayText = isArticle && articleTitle ? articleTitle : bookmark.content
// Calculate progress color
let progressColor = '#6366f1' // Default blue (reading)
@@ -45,7 +46,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
const handleCompactClick = () => {
if (isArticle) {
onSelectUrl?.('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = naddrEncode({
kind: bookmark.kind,
pubkey: bookmark.pubkey,
identifier: dTag
})
navigate(`/a/${naddr}`)
}
} else if (hasUrls) {
onSelectUrl?.(extractedUrls[0])
} else if (isNote) {

View File

@@ -155,6 +155,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const isTextContent = useMemo(() => {
if (loading) return false
if (!markdown && !html) return false
// Don't track internal sentinel URLs (nostr-event: is not a real Nostr URI per NIP-21)
if (selectedUrl?.startsWith('nostr-event:')) return false
if (selectedUrl?.includes('youtube') || selectedUrl?.includes('vimeo')) return false
if (!shouldTrackReadingProgress(html, markdown)) return false

View File

@@ -25,6 +25,7 @@ import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { groupIndividualBookmarks, hasContent, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
import { dedupeBookmarksById } from '../services/bookmarkHelpers'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
@@ -394,8 +395,7 @@ const Me: React.FC<MeProps> = ({
}
const getReadItemUrl = (item: ReadItem) => {
if (item.type === 'article') {
// ID is already in naddr format
if (item.type === 'article' && item.id.startsWith('naddr1')) {
return `/a/${item.id}`
} else if (item.url) {
return `/r/${encodeURIComponent(item.url)}`
@@ -438,19 +438,16 @@ const Me: React.FC<MeProps> = ({
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
if (bookmark && bookmark.kind === 30023) {
// For kind:30023 articles, navigate to the article route
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
if (dTag && bookmark.pubkey) {
const pointer = {
identifier: dTag,
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: bookmark.pubkey,
}
const naddr = nip19.naddrEncode(pointer)
identifier: dTag
})
navigate(`/a/${naddr}`)
}
} else if (url) {
// For regular URLs, navigate to the reader route
navigate(`/r/${encodeURIComponent(url)}`)
}
}
@@ -491,8 +488,10 @@ const Me: React.FC<MeProps> = ({
return undefined
}
// Merge and flatten all individual bookmarks
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
// Merge and flatten all individual bookmarks with deduplication
const allIndividualBookmarks = dedupeBookmarksById(
bookmarks.flatMap(b => b.individualBookmarks || [])
)
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))

View File

@@ -134,15 +134,30 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
const showHighlightsButton = scrollDirection !== 'down' && !isAtTop
// Lock body scroll when mobile sidebar or highlights is open
const savedScrollPosition = useRef<number>(0)
useEffect(() => {
if (isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed)) {
// Save current scroll position
savedScrollPosition.current = window.scrollY
document.body.style.top = `-${savedScrollPosition.current}px`
document.body.classList.add('mobile-sidebar-open')
} else {
// Restore scroll position
document.body.classList.remove('mobile-sidebar-open')
document.body.style.top = ''
if (savedScrollPosition.current > 0) {
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
window.scrollTo(0, savedScrollPosition.current)
savedScrollPosition.current = 0
})
}
}
return () => {
document.body.classList.remove('mobile-sidebar-open')
document.body.style.top = ''
}
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed])

View File

@@ -158,7 +158,10 @@ export const useBookmarksData = ({
// Fetch article-specific highlights when viewing an article
useEffect(() => {
if (!relayPool || !activeAccount) return
if (!relayPool || !activeAccount) {
setHighlightsLoading(false)
return
}
// Fetch article-specific highlights when viewing an article
// External URLs have their highlights fetched by useExternalUrlLoader
if (effectiveArticleCoordinate && !externalUrl) {
@@ -167,6 +170,9 @@ export const useBookmarksData = ({
// Clear article highlights when not viewing an article
setArticleHighlights([])
setHighlightsLoading(false)
} else {
// For external URLs or other cases, loading is not needed
setHighlightsLoading(false)
}
}, [relayPool, activeAccount, effectiveArticleCoordinate, naddr, externalUrl, handleFetchHighlights])

View File

@@ -28,6 +28,17 @@ export const useReadingPosition = ({
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
// Store callbacks in refs to avoid them being dependencies
const onPositionChangeRef = useRef(onPositionChange)
const onReadingCompleteRef = useRef(onReadingComplete)
const onSaveRef = useRef(onSave)
useEffect(() => {
onPositionChangeRef.current = onPositionChange
onReadingCompleteRef.current = onReadingComplete
onSaveRef.current = onSave
}, [onPositionChange, onReadingComplete, onSave])
// Suppress auto-saves for a given duration (used after programmatic restore)
const suppressSavesFor = useCallback((ms: number) => {
@@ -37,7 +48,7 @@ export const useReadingPosition = ({
// Throttled save function - saves at 1s intervals during scrolling
const scheduleSave = useCallback((currentPosition: number) => {
if (!syncEnabled || !onSave) {
if (!syncEnabled || !onSaveRef.current) {
return
}
@@ -48,7 +59,7 @@ export const useReadingPosition = ({
saveTimerRef.current = null
}
lastSaved100Ref.current = true
onSave(1)
onSaveRef.current(1)
return
}
@@ -65,10 +76,10 @@ export const useReadingPosition = ({
saveTimerRef.current = setTimeout(() => {
// Save the latest position, not the one from when timer was scheduled
const positionToSave = pendingPositionRef.current
onSave(positionToSave)
onSaveRef.current?.(positionToSave)
saveTimerRef.current = null
}, THROTTLE_MS)
}, [syncEnabled, onSave])
}, [syncEnabled])
useEffect(() => {
if (!enabled) return
@@ -96,7 +107,7 @@ export const useReadingPosition = ({
setPosition(clampedProgress)
positionRef.current = clampedProgress
onPositionChange?.(clampedProgress)
onPositionChangeRef.current?.(clampedProgress)
// Schedule auto-save if sync is enabled (unless suppressed)
if (Date.now() >= suppressUntilRef.current) {
@@ -113,7 +124,7 @@ export const useReadingPosition = ({
if (!hasTriggeredComplete.current && positionRef.current === 1) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
onReadingComplete?.()
onReadingCompleteRef.current?.()
}
completionTimerRef.current = null
}, completionHoldMs)
@@ -127,7 +138,7 @@ export const useReadingPosition = ({
if (clampedProgress >= readingCompleteThreshold) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
onReadingComplete?.()
onReadingCompleteRef.current?.()
}
}
}
@@ -151,7 +162,7 @@ export const useReadingPosition = ({
clearTimeout(completionTimerRef.current)
}
}
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave, completionHoldMs])
}, [enabled, readingCompleteThreshold, scheduleSave, completionHoldMs])
// Reset reading complete state when enabled changes
useEffect(() => {