Compare commits

..

26 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
Gigi
1800ee324e chore: bump version to 0.10.19 2025-10-23 17:01:33 +02:00
Gigi
7d2dac2f1a fix: remove unused props and clean up linting errors
- Remove unused lastFetchTime parameter from BookmarkList
- Remove unused loading and onRefresh parameters from HighlightsPanelHeader
- Update HighlightsPanel to not pass removed props
- All linting and type checking now passing
2025-10-23 17:00:55 +02:00
Gigi
7875f1d0bd refactor: remove refresh button from bookmarks sidebar
Remove the refresh IconButton from bookmarks sidebar and clean up unused imports (faRotate, formatDistanceToNow).
2025-10-23 16:57:56 +02:00
Gigi
d9263e07d1 refactor: remove refresh button from highlights sidebar
Remove the refresh IconButton from highlights panel header as it's no longer needed.
2025-10-23 16:56:51 +02:00
Gigi
9a345a7347 style: match highlights collapse button to bookmarks collapse button
Replace IconButton with native button element and apply same CSS styling as bookmarks collapse button for visual consistency.
2025-10-23 16:56:04 +02:00
Gigi
55d1af3bf9 refactor: move collapse highlights button to left side
Move the collapse highlights panel button from right to left side of the header, making it symmetrical to the bookmarks collapse button. Desktop only (hidden on mobile).
2025-10-23 16:54:08 +02:00
Gigi
feb3134b65 refactor: move grouping toggle to left side next to support button
Move the grouped/chronological toggle button from right/center to the left side, positioned next to the orange heart support button in both BookmarkList and Me components for better UX consistency.
2025-10-23 16:52:24 +02:00
Gigi
7d222e099f refactor: make profile picture trigger dropdown menu
Remove separate three-dot button and make the profile picture itself trigger the dropdown menu. This provides a cleaner, more intuitive UX.
2025-10-23 16:49:54 +02:00
Gigi
59436b5b9e refactor: remove redundant logout button from sidebar header
Remove standalone logout IconButton next to explore since logout is now available in the three-dot profile menu
2025-10-23 16:48:18 +02:00
Gigi
2e08954e83 feat: add three-dot profile menu to sidebar header
Add dropdown menu next to profile picture in bookmarks sidebar with:
- My Highlights
- My Bookmarks
- My Reads
- My Links
- My Writings
- Separator
- Logout

Includes click-outside-to-close functionality and smooth animations.
2025-10-23 16:47:26 +02:00
Gigi
9cb1791a3a docs: update CHANGELOG for v0.10.18 2025-10-23 16:39:07 +02:00
19 changed files with 413 additions and 122 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,112 @@ 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
- User profile routes renamed from `/me` to `/my`
- `/my/highlights` - User's highlights
- `/my/bookmarks` - User's bookmarks
- `/my/reads` - Nostr-native articles with reading progress
- `/my/links` - External URLs with reading progress
- `/my/writings` - User's published articles
- All navigation, tabs, and internal links updated
- Documentation updated to reflect new paths
### Fixed
- `/my/writings` now displays all user writings
- Removed incremental loading with `since` filters from writingsController
- Controller now fetches ALL writings without limits
- Ensures complete results on profile and my pages
- `/my/highlights` now displays all user highlights
- Removed incremental loading with `since` filters from highlightsController
- Controller now fetches ALL highlights without limits
- Consistent behavior across all profile views
### Refactored
- Centralized data fetching in controllers
- Removed duplicate background fetch logic from Me.tsx and Profile.tsx
- All writings fetching now handled by writingsController
- All highlights fetching now handled by highlightsController
- Single source of truth following controller pattern: stream, dedupe, store, emit
- Cleaner, more maintainable code (DRY principle)
## [0.10.17] - 2025-10-23
### Added

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.18",
"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

@@ -1,9 +1,8 @@
import React, { useRef, useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faHeart, faPlus, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
import { faClock } from '@fortawesome/free-regular-svg-icons'
import { formatDistanceToNow } from 'date-fns'
import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import { BookmarkItem } from './BookmarkItem'
@@ -59,7 +58,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onOpenSettings,
onRefresh,
isRefreshing,
lastFetchTime,
loading = false,
relayPool,
isMobile = false,
@@ -172,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)
@@ -314,9 +312,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
variant="ghost"
style={{ color: friendsColor }}
/>
</div>
{activeAccount && (
<div className="view-mode-right">
{activeAccount && (
<IconButton
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
onClick={toggleGroupingMode}
@@ -324,17 +320,10 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
variant="ghost"
/>
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
)}
)}
</div>
{activeAccount && (
<div className="view-mode-right">
<IconButton
icon={faList}
onClick={() => onViewModeChange('compact')}

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

@@ -118,13 +118,11 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
return (
<div className="highlights-container">
<HighlightsPanelHeader
loading={loading}
hasHighlights={filteredHighlights.length > 0}
showHighlights={showHighlights}
highlightVisibility={highlightVisibility}
currentUserPubkey={currentUserPubkey}
onToggleHighlights={handleToggleHighlights}
onRefresh={onRefresh}
onToggleCollapse={onToggleCollapse}
onHighlightVisibilityChange={onHighlightVisibilityChange}
isMobile={isMobile}

View File

@@ -1,29 +1,26 @@
import React from 'react'
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faEye, faEyeSlash, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
import { HighlightVisibility } from '../HighlightsPanel'
import IconButton from '../IconButton'
interface HighlightsPanelHeaderProps {
loading: boolean
hasHighlights: boolean
showHighlights: boolean
highlightVisibility: HighlightVisibility
currentUserPubkey?: string
onToggleHighlights: () => void
onRefresh?: () => void
onToggleCollapse: () => void
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
isMobile?: boolean
}
const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
loading,
hasHighlights,
showHighlights,
highlightVisibility,
currentUserPubkey,
onToggleHighlights,
onRefresh,
onToggleCollapse,
onHighlightVisibilityChange,
isMobile = false
@@ -32,6 +29,16 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
<div className="highlights-header">
<div className="highlights-actions">
<div className="highlights-actions-left">
{!isMobile && (
<button
onClick={onToggleCollapse}
className="toggle-highlights-btn"
title="Collapse highlights panel"
aria-label="Collapse highlights panel"
>
<FontAwesomeIcon icon={faChevronRight} style={{ transform: 'rotate(180deg)' }} />
</button>
)}
{onHighlightVisibilityChange && (
<div className="highlight-level-toggles">
<IconButton
@@ -82,17 +89,8 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
)}
</div>
)}
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title="Refresh highlights"
ariaLabel="Refresh highlights"
variant="ghost"
disabled={loading}
spin={loading}
/>
)}
</div>
<div className="highlights-actions-right">
{hasHighlights && (
<IconButton
icon={showHighlights ? faEye : faEyeSlash}
@@ -103,16 +101,6 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
/>
)}
</div>
{!isMobile && (
<IconButton
icon={faChevronRight}
onClick={onToggleCollapse}
title="Collapse highlights panel"
ariaLabel="Collapse highlights panel"
variant="ghost"
style={{ transform: 'rotate(180deg)' }}
/>
)}
</div>
</div>
)

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faHeart } from '@fortawesome/free-solid-svg-icons'
import { faClock } from '@fortawesome/free-regular-svg-icons'
import { Hooks } from 'applesauce-react'
import { IEventStore } from 'applesauce-core'
@@ -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))
@@ -668,21 +667,26 @@ const Me: React.FC<MeProps> = ({
</div>
</div>
)))}
<div className="view-mode-controls" style={{
display: 'flex',
justifyContent: 'center',
gap: '0.5rem',
padding: '1rem',
marginTop: '1rem',
borderTop: '1px solid var(--border-color)'
}}>
<IconButton
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
onClick={toggleGroupingMode}
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
variant="ghost"
/>
<div className="view-mode-controls">
<div className="view-mode-left">
<IconButton
icon={faHeart}
onClick={() => navigate('/support')}
title="Support Boris"
ariaLabel="Support"
variant="ghost"
style={{ color: 'rgb(251 146 60)' }}
/>
<IconButton
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
onClick={toggleGroupingMode}
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
variant="ghost"
/>
</div>
<div className="view-mode-right">
</div>
</div>
</div>
)

View File

@@ -1,11 +1,12 @@
import React from 'react'
import React, { useState, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faPersonHiking } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faPersonHiking, faHighlighter, faBookmark, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import IconButton from './IconButton'
import { faBooks } from '../icons/customIcons'
interface SidebarHeaderProps {
onToggleCollapse: () => void
@@ -18,6 +19,8 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
const navigate = useNavigate()
const activeAccount = Hooks.useActiveAccount()
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
const [showProfileMenu, setShowProfileMenu] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const getProfileImage = () => {
return profile?.picture || null
@@ -33,22 +36,93 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
const profileImage = getProfileImage()
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setShowProfileMenu(false)
}
}
if (showProfileMenu) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [showProfileMenu])
const handleMenuItemClick = (action: () => void) => {
setShowProfileMenu(false)
action()
}
return (
<>
<div className="sidebar-header-bar">
{activeAccount && (
<button
className="profile-avatar-button"
title={getUserDisplayName()}
onClick={() => navigate('/my')}
aria-label={`Profile: ${getUserDisplayName()}`}
>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
<div className="profile-menu-wrapper" ref={menuRef}>
<button
className="profile-avatar-button"
title={getUserDisplayName()}
onClick={() => setShowProfileMenu(!showProfileMenu)}
aria-label={`Profile: ${getUserDisplayName()}`}
>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
)}
</button>
{showProfileMenu && (
<div className="profile-dropdown-menu">
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/highlights'))}
>
<FontAwesomeIcon icon={faHighlighter} />
<span>My Highlights</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/bookmarks'))}
>
<FontAwesomeIcon icon={faBookmark} />
<span>My Bookmarks</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/reads'))}
>
<FontAwesomeIcon icon={faBooks} />
<span>My Reads</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/links'))}
>
<FontAwesomeIcon icon={faLink} />
<span>My Links</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/writings'))}
>
<FontAwesomeIcon icon={faPenToSquare} />
<span>My Writings</span>
</button>
<div className="profile-menu-separator"></div>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(onLogout)}
>
<FontAwesomeIcon icon={faRightFromBracket} />
<span>Logout</span>
</button>
</div>
)}
</button>
</div>
)}
<div className="sidebar-header-right">
<IconButton
@@ -72,15 +146,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Explore"
variant="ghost"
/>
{activeAccount && (
<IconButton
icon={faRightFromBracket}
onClick={onLogout}
title="Logout"
ariaLabel="Logout"
variant="ghost"
/>
)}
{!isMobile && (
<button
onClick={onToggleCollapse}

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(() => {

View File

@@ -192,7 +192,8 @@
}
.reader-markdown img, .reader-html img {
max-width: 100%;
max-width: 100% !important;
width: auto !important;
height: auto;
}
}

View File

@@ -62,6 +62,28 @@
.highlights-actions { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.highlights-actions-left { display: flex; align-items: center; gap: 0.5rem; }
.highlights-actions-right { display: flex; align-items: center; gap: 0.5rem; }
/* Collapse button in highlights header */
.highlights-header .toggle-highlights-btn {
background: transparent;
color: var(--color-text);
border: 1px solid var(--color-border-subtle);
padding: 0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 33px;
height: 33px;
flex-shrink: 0;
box-sizing: border-box;
}
.highlights-header .toggle-highlights-btn:hover { background: var(--color-bg-elevated); color: var(--color-text); }
.highlights-header .toggle-highlights-btn:active { transform: translateY(1px); }
.highlights-title { display: flex; align-items: center; gap: 0.5rem; }
.highlights-title h3 { margin: 0; font-size: 1rem; font-weight: 600; }

View File

@@ -132,6 +132,70 @@
.profile-avatar-button img { width: 100%; height: 100%; object-fit: cover; }
.profile-avatar-button svg { font-size: 1rem; }
/* Profile menu wrapper */
.profile-menu-wrapper {
position: relative;
}
/* Dropdown menu */
.profile-dropdown-menu {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 180px;
padding: 0.25rem;
z-index: 1000;
animation: profileMenuSlideIn 0.15s ease-out;
}
@keyframes profileMenuSlideIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.profile-menu-item {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.875rem;
background: transparent;
border: none;
border-radius: 4px;
color: var(--color-text);
cursor: pointer;
transition: all 0.15s ease;
font-size: 0.875rem;
text-align: left;
white-space: nowrap;
}
.profile-menu-item:hover {
background: var(--color-bg-hover);
}
.profile-menu-item svg {
width: 1em;
font-size: 1rem;
color: var(--color-text-secondary);
}
.profile-menu-separator {
height: 1px;
background: var(--color-border);
margin: 0.25rem 0;
}
.sidebar-header-bar .toggle-sidebar-btn {
background: transparent;
color: var(--color-text);