Compare commits

..

14 Commits

Author SHA1 Message Date
Gigi
6b240b01ec docs: update changelog for v0.6.2 2025-10-14 00:54:28 +02:00
Gigi
945894e3db chore: bump version to 0.6.2 2025-10-14 00:53:17 +02:00
Gigi
667397e528 fix: align title, summary, meta, and body text in reader
- Add consistent 2rem horizontal padding to reader-header on desktop
- Apply same padding to reader-summary-below-image, article-menu-container, and mark-as-read-container
- All content elements now align properly with body text
- Mobile (< 769px) retains base padding only
2025-10-14 00:52:19 +02:00
Gigi
e4b0d6d1cd refactor: unify button styles across sidebars using IconButton
- Convert HighlightsPanel buttons to use IconButton component
- Add style prop support to IconButton for custom styling
- Remove redundant CSS for old button classes (level-toggle-btn, refresh-highlights-btn, etc.)
- Keep only highlight-level-toggles container styling
- Consistent button appearance across left and right sidebars
- DRY: Single IconButton component handles all sidebar buttons
2025-10-14 00:50:52 +02:00
Gigi
3cdda2dcb7 refactor: move bookmark refresh button to footer with view controls
- Remove separate refresh section from bookmarks list
- Add refresh button to view-mode-controls footer
- Show last update time in button tooltip instead of inline text
- Cleaner UI with all controls in one footer section
2025-10-14 00:48:47 +02:00
Gigi
876ecc808d feat: add pull-to-refresh for mobile on all scrollable views
- Create reusable usePullToRefresh hook with touch gesture detection
- Add PullToRefreshIndicator component with visual feedback
- Implement pull-to-refresh on HighlightsPanel (right sidebar)
- Implement pull-to-refresh on Explore page
- Implement pull-to-refresh on Me pages (all tabs)
- Implement pull-to-refresh on BookmarkList (left sidebar)
- Only activates on touch devices for mobile-first experience
- Shows rotating arrow icon that becomes refresh spinner
- Displays contextual messages (pull/release/refreshing)
- Integrates with existing refresh handlers and loading states
2025-10-14 00:47:48 +02:00
Gigi
34671bd067 feat: add three-dot menu for external URLs in /r/ path
- Add menu button with options to open original URL, copy URL, and share
- Reuse existing menu styling for consistency
- Menu positioned at end of article content before mark-as-read button
2025-10-14 00:39:53 +02:00
Gigi
a6285f6a1d fix(highlights): precise normalized-to-original mapping to eliminate intra-word spaces
- Walk original text across node boundaries while tracking normalized positions
- Identify exact start/end nodes and offsets for the match
- Compute combined indices from node spans to create accurate DOM Range
- Eliminates artifacts like 'We b' by preventing whitespace from splitting words
- Keeps strict bounds checks and graceful failures
2025-10-14 00:37:56 +02:00
Gigi
36508d600a fix(highlights): remove existing highlight marks before applying new ones
- Strip all existing mark elements from HTML before re-highlighting
- Prevents old broken highlights from persisting in the DOM
- Ensures clean text is used as the base for new highlight application
- Fixes 'We b' spacing issue caused by corrupted marks from previous buggy renders
- Remove debug logging now that position mapping is working correctly
2025-10-14 00:34:55 +02:00
Gigi
a304bb7c26 debug: add detailed position mapping logging to diagnose spacing issues
- Log search text, match indices, and extracted text during position mapping
- Show sample of combined text around the extracted range
- Help identify where position mapping is going wrong for 'We b' issue
2025-10-14 00:31:59 +02:00
Gigi
04bab96a07 fix(highlights): improve normalized text position mapping to prevent character spacing issues
- Build explicit position map array from normalized to original text indices
- Properly handle whitespace sequences in position mapping
- Ensure each normalized character position maps to correct original position
- Validate mapped positions are within bounds before using
- Fixes spacing issues like 'We b' appearing instead of 'Web' in highlights
2025-10-14 00:27:38 +02:00
Gigi
22ebbff755 fix(highlights): add text validation before applying highlights
- Validate extracted range text matches search text before highlighting
- Check single-node matches are not empty or whitespace-only
- Compare both exact and normalized text to handle whitespace variations
- Prevent broken/corrupted highlights from being applied to DOM
- Add detailed logging for validation failures to aid debugging
2025-10-14 00:25:18 +02:00
Gigi
b43f40597f fix(highlights): add robust validation and error handling for multi-node highlighting
- Implement proper normalized-to-original text position mapping
- Add comprehensive validation for range indices and node offsets
- Verify range is not collapsed before extracting content
- Add try-catch block to handle DOM manipulation errors gracefully
- Add detailed warning logs for debugging failed highlight matches
- Prevent invalid ranges from corrupting the DOM structure
- Fix broken text nodes and visual artifacts in highlighted content
2025-10-14 00:23:43 +02:00
Gigi
fe3af25c5f docs: update changelog for v0.6.1 release 2025-10-14 00:21:31 +02:00
17 changed files with 757 additions and 148 deletions

View File

@@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.2] - 2025-01-27
### Added
- Pull-to-refresh gesture on mobile for all scrollable views
- HighlightsPanel (right sidebar) - refresh highlights for current article
- Explore page - refresh blog posts from friends
- Me pages (all tabs) - refresh user data
- BookmarkList (left sidebar) - refresh bookmarks
- Touch-only activation using coarse pointer detection
- Visual indicator with rotating arrow and contextual messages
- Three-dot menu for external URLs in reader (`/r/` path)
- Open in browser, Copy URL, Share URL actions
- Consistent with article menu functionality
### Changed
- Bookmark refresh button moved to footer alongside view mode controls
- Last update time now shown in button tooltip
- Cleaner UI with all controls consolidated in footer
- Unified button styles across left and right sidebars
- All sidebar buttons now use IconButton component for consistency
- Removed 73 lines of redundant CSS for old button classes
- Highlights panel buttons match bookmark sidebar styling
### Fixed
- Reader content alignment on desktop
- Title, summary, metadata, and body text now properly aligned
- All reader elements now have consistent 2rem horizontal padding
- Mobile layout retains compact padding
- Highlight text matching with multiple improvements
- Precise normalized-to-original character position mapping
- Remove existing highlight marks before applying new ones
- Robust validation and error handling for multi-node highlights
- Prevent character spacing issues in highlighted text
- Add text validation before applying highlights
- Eliminate intra-word spaces in highlighted text
## [0.6.1] - 2025-10-13
### Added
- Writings tab on `/me` 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
- Vertical padding to blockquotes for better readability
- Horizontal padding for reader text content on desktop
- Drop-shadows to sidebars for visual depth
- MutationObserver for tracking highlight DOM changes
### Changed
- Article titles now larger and more prominent
- Article summaries now display properly in reader header
- Zap splits settings UI with preset buttons and full-width sliders
- Sidebars now extend to 100vh height
- Blockquote styling simplified to minimal indent and italic
- Improved zap splits settings visual design
### Fixed
- 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
- 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
- Highlights now appear as single continuous element across DOM nodes
- Highlights display immediately after creation with synchronous render
- Scroll-to-highlight functionality restored after DOM updates
- Padding gaps around sidebars removed
- TypeScript errors in video-meta.ts resolved
### Refactored
- Migrated entire color system to Tailwind v4 color palette
- Migrated all CSS files (sidebar, highlights, cards, forms, reader, etc.) to Tailwind colors
- Updated default highlight colors to yellow-400 for markers and yellow-300 for other contexts
- Added comprehensive color system documentation (COLOR_SYSTEM.md)
- Cleaned up legacy.css removing unused debugging styles
## [0.6.0] - 2025-10-13
### Added
@@ -1012,7 +1096,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling
[Unreleased]: https://github.com/dergigi/boris/compare/v0.5.5...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.2...HEAD
[0.6.2]: https://github.com/dergigi/boris/compare/v0.6.1...v0.6.2
[0.6.1]: https://github.com/dergigi/boris/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/dergigi/boris/compare/v0.5.7...v0.6.0
[0.5.7]: https://github.com/dergigi/boris/compare/v0.5.6...v0.5.7
[0.5.6]: https://github.com/dergigi/boris/compare/v0.5.5...v0.5.6
[0.5.5]: https://github.com/dergigi/boris/compare/v0.5.4...v0.5.5
[0.5.2]: https://github.com/dergigi/boris/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/dergigi/boris/compare/v0.5.0...v0.5.1

View File

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

View File

@@ -1,4 +1,4 @@
import React from 'react'
import React, { useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
import { formatDistanceToNow } from 'date-fns'
@@ -10,6 +10,8 @@ import IconButton from './IconButton'
import { ViewMode } from './Bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { UserSettings } from '../services/settingsService'
import { usePullToRefresh } from '../hooks/usePullToRefresh'
import PullToRefreshIndicator from './PullToRefreshIndicator'
interface BookmarkListProps {
bookmarks: Bookmark[]
@@ -48,6 +50,19 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
settings,
isMobile = false
}) => {
const bookmarksListRef = useRef<HTMLDivElement>(null)
// Pull-to-refresh for bookmarks
const pullToRefreshState = usePullToRefresh(bookmarksListRef, {
onRefresh: () => {
if (onRefresh) {
onRefresh()
}
},
isRefreshing: isRefreshing || false,
disabled: !onRefresh
})
// Helper to check if a bookmark has either content or a URL
const hasContentOrUrl = (ib: IndividualBookmark) => {
// Check if has content (text)
@@ -124,7 +139,16 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
</div>
)
) : (
<div className="bookmarks-list">
<div
ref={bookmarksListRef}
className={`bookmarks-list pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
>
<PullToRefreshIndicator
isPulling={pullToRefreshState.isPulling}
pullDistance={pullToRefreshState.pullDistance}
canRefresh={pullToRefreshState.canRefresh}
isRefreshing={isRefreshing || false}
/>
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{allIndividualBookmarks.map((individualBookmark, index) =>
<BookmarkItem
@@ -137,37 +161,20 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
/>
)}
</div>
{onRefresh && (
<div className="refresh-section" style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
padding: '1rem',
marginTop: '1rem',
borderTop: '1px solid var(--border-color)',
fontSize: '0.85rem',
color: 'var(--text-secondary)'
}}>
<IconButton
icon={faRotate}
onClick={onRefresh}
title="Refresh bookmarks"
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
{lastFetchTime && (
<span>
Updated {formatDistanceToNow(lastFetchTime, { addSuffix: true })}
</span>
)}
</div>
)}
</div>
)}
<div className="view-mode-controls">
{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}
/>
)}
<IconButton
icon={faList}
onClick={() => onViewModeChange('compact')}

View File

@@ -98,8 +98,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
const [showArticleMenu, setShowArticleMenu] = useState(false)
const [showVideoMenu, setShowVideoMenu] = useState(false)
const [showExternalMenu, setShowExternalMenu] = useState(false)
const articleMenuRef = useRef<HTMLDivElement>(null)
const videoMenuRef = useRef<HTMLDivElement>(null)
const externalMenuRef = useRef<HTMLDivElement>(null)
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
@@ -145,15 +147,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
setShowVideoMenu(false)
}
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
setShowExternalMenu(false)
}
}
if (showArticleMenu || showVideoMenu) {
if (showArticleMenu || showVideoMenu || showExternalMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [showArticleMenu, showVideoMenu])
}, [showArticleMenu, showVideoMenu, showExternalMenu])
const readingStats = useMemo(() => {
const content = markdown || html || ''
@@ -280,6 +285,38 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
setShowVideoMenu(false)
}
}
// External article actions
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
const handleOpenExternalUrl = () => {
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
setShowExternalMenu(false)
}
const handleCopyExternalUrl = async () => {
try {
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
} catch (e) {
console.warn('Clipboard copy failed', e)
} finally {
setShowExternalMenu(false)
}
}
const handleShareExternalUrl = async () => {
try {
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Article', url: selectedUrl })
} else if (selectedUrl) {
await navigator.clipboard.writeText(selectedUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowExternalMenu(false)
}
}
// Check if article is already marked as read when URL/article changes
useEffect(() => {
@@ -533,6 +570,47 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
/>
)}
{/* Article menu for external URLs */}
{!isNostrArticle && !isExternalVideo && selectedUrl && (
<div className="article-menu-container">
<div className="article-menu-wrapper" ref={externalMenuRef}>
<button
className="article-menu-btn"
onClick={toggleExternalMenu}
title="More options"
>
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showExternalMenu && (
<div className="article-menu">
<button
className="article-menu-item"
onClick={handleOpenExternalUrl}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Original URL</span>
</button>
<button
className="article-menu-item"
onClick={handleCopyExternalUrl}
>
<FontAwesomeIcon icon={faCopy} />
<span>Copy URL</span>
</button>
<button
className="article-menu-item"
onClick={handleShareExternalUrl}
>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
</div>
)}
</div>
</div>
)}
{/* Article menu for nostr-native articles */}
{isNostrArticle && currentArticle && articleLinks && (
<div className="article-menu-container">

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
@@ -8,6 +8,8 @@ import { fetchContacts } from '../services/contactService'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import BlogPostCard from './BlogPostCard'
import { getCachedPosts, upsertCachedPost, setCachedPosts } from '../services/exploreCache'
import { usePullToRefresh } from '../hooks/usePullToRefresh'
import PullToRefreshIndicator from './PullToRefreshIndicator'
interface ExploreProps {
relayPool: RelayPool
@@ -18,6 +20,8 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const exploreContainerRef = useRef<HTMLDivElement>(null)
const [refreshTrigger, setRefreshTrigger] = useState(0)
useEffect(() => {
const loadBlogPosts = async () => {
@@ -116,7 +120,15 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
}
loadBlogPosts()
}, [relayPool, activeAccount, blogPosts.length])
}, [relayPool, activeAccount, blogPosts.length, refreshTrigger])
// Pull-to-refresh
const pullToRefreshState = usePullToRefresh(exploreContainerRef, {
onRefresh: () => {
setRefreshTrigger(prev => prev + 1)
},
isRefreshing: loading
})
const getPostUrl = (post: BlogPostPreview) => {
// Get the d-tag identifier
@@ -144,7 +156,16 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
}
return (
<div className="explore-container">
<div
ref={exploreContainerRef}
className={`explore-container pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
>
<PullToRefreshIndicator
isPulling={pullToRefreshState.isPulling}
pullDistance={pullToRefreshState.pullDistance}
canRefresh={pullToRefreshState.canRefresh}
isRefreshing={loading && pullToRefreshState.canRefresh}
/>
<div className="explore-header">
<h1>
<FontAwesomeIcon icon={faNewspaper} />

View File

@@ -1,11 +1,13 @@
import React, { useState } from 'react'
import React, { useState, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
import { useFilteredHighlights } from '../hooks/useFilteredHighlights'
import { usePullToRefresh } from '../hooks/usePullToRefresh'
import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
import PullToRefreshIndicator from './PullToRefreshIndicator'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { UserSettings } from '../services/settingsService'
@@ -57,12 +59,24 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
}) => {
const [showHighlights, setShowHighlights] = useState(true)
const [localHighlights, setLocalHighlights] = useState(highlights)
const highlightsListRef = useRef<HTMLDivElement>(null)
const handleToggleHighlights = () => {
const newValue = !showHighlights
setShowHighlights(newValue)
onToggleHighlights?.(newValue)
}
// Pull-to-refresh for highlights
const pullToRefreshState = usePullToRefresh(highlightsListRef, {
onRefresh: () => {
if (onRefresh) {
onRefresh()
}
},
isRefreshing: loading,
disabled: !onRefresh
})
// Keep track of highlight updates
React.useEffect(() => {
@@ -127,7 +141,16 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
</p>
</div>
) : (
<div className="highlights-list">
<div
ref={highlightsListRef}
className={`highlights-list pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
>
<PullToRefreshIndicator
isPulling={pullToRefreshState.isPulling}
pullDistance={pullToRefreshState.pullDistance}
canRefresh={pullToRefreshState.canRefresh}
isRefreshing={loading}
/>
{filteredHighlights.map((highlight) => (
<HighlightItem
key={highlight.id}

View File

@@ -1,7 +1,7 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
import { HighlightVisibility } from '../HighlightsPanel'
import IconButton from '../IconButton'
interface HighlightsPanelHeaderProps {
loading: boolean
@@ -32,76 +32,72 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
<div className="highlights-actions-left">
{onHighlightVisibilityChange && (
<div className="highlight-level-toggles">
<button
<IconButton
icon={faNetworkWired}
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
nostrverse: !highlightVisibility.nostrverse
})}
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
title="Toggle nostrverse highlights"
aria-label="Toggle nostrverse highlights"
ariaLabel="Toggle nostrverse highlights"
variant={highlightVisibility.nostrverse ? 'primary' : 'ghost'}
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
>
<FontAwesomeIcon icon={faNetworkWired} />
</button>
<button
/>
<IconButton
icon={faUserGroup}
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
friends: !highlightVisibility.friends
})}
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
aria-label="Toggle friends highlights"
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
ariaLabel="Toggle friends highlights"
variant={highlightVisibility.friends ? 'primary' : 'ghost'}
disabled={!currentUserPubkey}
>
<FontAwesomeIcon icon={faUserGroup} />
</button>
<button
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
/>
<IconButton
icon={faUser}
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
mine: !highlightVisibility.mine
})}
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
aria-label="Toggle my highlights"
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
ariaLabel="Toggle my highlights"
variant={highlightVisibility.mine ? 'primary' : 'ghost'}
disabled={!currentUserPubkey}
>
<FontAwesomeIcon icon={faUser} />
</button>
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
/>
</div>
)}
{onRefresh && (
<button
<IconButton
icon={faRotate}
onClick={onRefresh}
className="refresh-highlights-btn"
title="Refresh highlights"
aria-label="Refresh highlights"
ariaLabel="Refresh highlights"
variant="ghost"
disabled={loading}
>
<FontAwesomeIcon icon={faRotate} spin={loading} />
</button>
spin={loading}
/>
)}
{hasHighlights && (
<button
<IconButton
icon={showHighlights ? faEye : faEyeSlash}
onClick={onToggleHighlights}
className="toggle-highlight-display-btn"
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
aria-label={showHighlights ? 'Hide highlights' : 'Show highlights'}
>
<FontAwesomeIcon icon={showHighlights ? faEye : faEyeSlash} />
</button>
ariaLabel={showHighlights ? 'Hide highlights' : 'Show highlights'}
variant="ghost"
/>
)}
</div>
<button
<IconButton
icon={faChevronRight}
onClick={onToggleCollapse}
className="toggle-highlights-btn"
title="Collapse highlights panel"
aria-label="Collapse highlights panel"
>
<FontAwesomeIcon icon={faChevronRight} rotation={180} />
</button>
ariaLabel="Collapse highlights panel"
variant="ghost"
style={{ transform: 'rotate(180deg)' }}
/>
</div>
</div>
)

View File

@@ -12,6 +12,7 @@ interface IconButtonProps {
disabled?: boolean
spin?: boolean
className?: string
style?: React.CSSProperties
}
const IconButton: React.FC<IconButtonProps> = ({
@@ -23,7 +24,8 @@ const IconButton: React.FC<IconButtonProps> = ({
size = 33,
disabled = false,
spin = false,
className = ''
className = '',
style
}) => {
return (
<button
@@ -31,7 +33,7 @@ const IconButton: React.FC<IconButtonProps> = ({
onClick={onClick}
title={title}
aria-label={ariaLabel || title}
style={{ width: size, height: size }}
style={{ width: size, height: size, ...style }}
disabled={disabled}
>
<FontAwesomeIcon icon={icon} spin={spin} />

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
@@ -21,6 +21,8 @@ import { ViewMode } from './Bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from '../hooks/usePullToRefresh'
import PullToRefreshIndicator from './PullToRefreshIndicator'
interface MeProps {
relayPool: RelayPool
@@ -40,6 +42,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<ViewMode>('cards')
const meContainerRef = useRef<HTMLDivElement>(null)
const [refreshTrigger, setRefreshTrigger] = useState(0)
// Update local state when prop changes
useEffect(() => {
@@ -102,7 +106,15 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
}
loadData()
}, [relayPool, activeAccount])
}, [relayPool, activeAccount, refreshTrigger])
// Pull-to-refresh
const pullToRefreshState = usePullToRefresh(meContainerRef, {
onRefresh: () => {
setRefreshTrigger(prev => prev + 1)
},
isRefreshing: loading
})
const handleHighlightDelete = (highlightId: string) => {
setHighlights(prev => {
@@ -301,7 +313,16 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
}
return (
<div className="explore-container">
<div
ref={meContainerRef}
className={`explore-container pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
>
<PullToRefreshIndicator
isPulling={pullToRefreshState.isPulling}
pullDistance={pullToRefreshState.pullDistance}
canRefresh={pullToRefreshState.canRefresh}
isRefreshing={loading && pullToRefreshState.canRefresh}
/>
<div className="explore-header">
{activeAccount && <AuthorCard authorPubkey={activeAccount.pubkey} />}

View File

@@ -0,0 +1,61 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faArrowDown, faSpinner } from '@fortawesome/free-solid-svg-icons'
interface PullToRefreshIndicatorProps {
isPulling: boolean
pullDistance: number
canRefresh: boolean
isRefreshing: boolean
threshold?: number
}
const PullToRefreshIndicator: React.FC<PullToRefreshIndicatorProps> = ({
isPulling,
pullDistance,
canRefresh,
isRefreshing,
threshold = 80
}) => {
// Don't show if not pulling and not refreshing
if (!isPulling && !isRefreshing) return null
const opacity = Math.min(pullDistance / threshold, 1)
const rotation = (pullDistance / threshold) * 180
return (
<div
className="pull-to-refresh-indicator"
style={{
opacity: isRefreshing ? 1 : opacity,
transform: `translateY(${isRefreshing ? 0 : -20 + pullDistance / 2}px)`
}}
>
<div
className="pull-to-refresh-icon"
style={{
transform: isRefreshing ? 'none' : `rotate(${rotation}deg)`
}}
>
{isRefreshing ? (
<FontAwesomeIcon icon={faSpinner} spin />
) : (
<FontAwesomeIcon
icon={faArrowDown}
style={{ color: canRefresh ? 'var(--accent-color, #3b82f6)' : 'var(--text-secondary)' }}
/>
)}
</div>
<div className="pull-to-refresh-text">
{isRefreshing
? 'Refreshing...'
: canRefresh
? 'Release to refresh'
: 'Pull to refresh'}
</div>
</div>
)
}
export default PullToRefreshIndicator

View File

@@ -0,0 +1,153 @@
import { useEffect, useRef, useState, RefObject } from 'react'
import { useIsCoarsePointer } from './useMediaQuery'
interface UsePullToRefreshOptions {
onRefresh: () => void | Promise<void>
isRefreshing?: boolean
disabled?: boolean
threshold?: number // Distance in pixels to trigger refresh
resistance?: number // Resistance factor (higher = harder to pull)
}
interface PullToRefreshState {
isPulling: boolean
pullDistance: number
canRefresh: boolean
}
/**
* Hook to enable pull-to-refresh gesture on touch devices
* @param containerRef - Ref to the scrollable container element
* @param options - Configuration options
* @returns State of the pull gesture
*/
export function usePullToRefresh(
containerRef: RefObject<HTMLElement>,
options: UsePullToRefreshOptions
): PullToRefreshState {
const {
onRefresh,
isRefreshing = false,
disabled = false,
threshold = 80,
resistance = 2.5
} = options
const isTouch = useIsCoarsePointer()
const [pullState, setPullState] = useState<PullToRefreshState>({
isPulling: false,
pullDistance: 0,
canRefresh: false
})
const touchStartY = useRef<number>(0)
const startScrollTop = useRef<number>(0)
const isDragging = useRef<boolean>(false)
useEffect(() => {
const container = containerRef.current
if (!container || !isTouch || disabled || isRefreshing) return
const handleTouchStart = (e: TouchEvent) => {
// Only start if scrolled to top
const scrollTop = container.scrollTop
if (scrollTop <= 0) {
touchStartY.current = e.touches[0].clientY
startScrollTop.current = scrollTop
isDragging.current = true
}
}
const handleTouchMove = (e: TouchEvent) => {
if (!isDragging.current) return
const currentY = e.touches[0].clientY
const deltaY = currentY - touchStartY.current
const scrollTop = container.scrollTop
// Only pull down when at top and pulling down
if (scrollTop <= 0 && deltaY > 0) {
// Prevent default scroll behavior
e.preventDefault()
// Apply resistance to make pulling feel natural
const distance = Math.min(deltaY / resistance, threshold * 1.5)
const canRefresh = distance >= threshold
setPullState({
isPulling: true,
pullDistance: distance,
canRefresh
})
} else {
// Reset if scrolled or pulling up
isDragging.current = false
setPullState({
isPulling: false,
pullDistance: 0,
canRefresh: false
})
}
}
const handleTouchEnd = async () => {
if (!isDragging.current) return
isDragging.current = false
if (pullState.canRefresh && !isRefreshing) {
// Keep the indicator visible while refreshing
setPullState(prev => ({
...prev,
isPulling: false
}))
// Trigger refresh
await onRefresh()
}
// Reset state
setPullState({
isPulling: false,
pullDistance: 0,
canRefresh: false
})
}
const handleTouchCancel = () => {
isDragging.current = false
setPullState({
isPulling: false,
pullDistance: 0,
canRefresh: false
})
}
// Add event listeners with passive: false to allow preventDefault
container.addEventListener('touchstart', handleTouchStart, { passive: true })
container.addEventListener('touchmove', handleTouchMove, { passive: false })
container.addEventListener('touchend', handleTouchEnd, { passive: true })
container.addEventListener('touchcancel', handleTouchCancel, { passive: true })
return () => {
container.removeEventListener('touchstart', handleTouchStart)
container.removeEventListener('touchmove', handleTouchMove)
container.removeEventListener('touchend', handleTouchEnd)
container.removeEventListener('touchcancel', handleTouchCancel)
}
}, [containerRef, isTouch, disabled, isRefreshing, threshold, resistance, onRefresh, pullState.canRefresh])
// Reset pull state when refresh completes
useEffect(() => {
if (!isRefreshing && pullState.isPulling) {
setPullState({
isPulling: false,
pullDistance: 0,
canRefresh: false
})
}
}, [isRefreshing, pullState.isPulling])
return pullState
}

View File

@@ -12,6 +12,7 @@
@import './styles/components/reader.css';
@import './styles/components/settings.css';
@import './styles/components/me.css';
@import './styles/components/pull-to-refresh.css';
@import './styles/utils/animations.css';
@import './styles/utils/utilities.css';
@import './styles/utils/legacy.css';

View File

@@ -0,0 +1,53 @@
/* Pull-to-refresh indicator styles */
.pull-to-refresh-indicator {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem;
z-index: 100;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.pull-to-refresh-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--background-secondary);
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
font-size: 1rem;
color: var(--text-secondary);
}
.pull-to-refresh-text {
font-size: 0.75rem;
color: var(--text-secondary);
text-align: center;
white-space: nowrap;
font-weight: 500;
background: var(--background-secondary);
padding: 0.25rem 0.75rem;
border-radius: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Container needs relative positioning for absolute indicator */
.pull-to-refresh-container {
position: relative;
}
/* Ensure smooth transitions during pull */
.pull-to-refresh-container.is-pulling {
overflow: visible;
}

View File

@@ -169,7 +169,12 @@
/* Desktop: increase horizontal padding for text content */
@media (min-width: 769px) {
.reader-html, .reader-markdown {
.reader-header,
.reader-summary-below-image,
.reader-html,
.reader-markdown,
.article-menu-container,
.mark-as-read-container {
padding-left: 2rem;
padding-right: 2rem;
}

View File

@@ -74,38 +74,6 @@
/* Three-level highlight toggles */
.highlight-level-toggles { display: flex; gap: 0.25rem; padding: 0.25rem; background: rgba(255, 255, 255, 0.05); border-radius: 4px; }
.highlight-level-toggles .level-toggle-btn { background: none; border: none; color: rgb(161 161 170); /* zinc-400 */ cursor: pointer; padding: 0.375rem 0.5rem; border-radius: 3px; transition: all 0.2s; font-size: 0.9rem; }
.highlight-level-toggles .level-toggle-btn:hover { background: rgba(255, 255, 255, 0.1); }
.highlight-level-toggles .level-toggle-btn.active { background: rgba(255, 255, 255, 0.1); opacity: 1; }
.highlight-level-toggles .level-toggle-btn:not(.active) { opacity: 0.4; }
.highlight-level-toggles .level-toggle-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.highlight-level-toggles .level-toggle-btn:disabled:hover { background: none; }
.refresh-highlights-btn,
.toggle-highlight-display-btn,
.toggle-highlights-btn {
background: transparent;
color: rgb(228 228 231); /* zinc-200 */
border: 1px solid rgb(82 82 91); /* zinc-600 */
padding: 0;
border-radius: 6px;
cursor: pointer;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.refresh-highlights-btn:hover,
.toggle-highlight-display-btn:hover,
.toggle-highlights-btn:hover { background: rgb(39 39 42); /* zinc-800 */ color: rgb(255 255 255); /* white */ }
.refresh-highlights-btn:active,
.toggle-highlight-display-btn:active,
.toggle-highlights-btn:active { transform: translateY(1px); }
.refresh-highlights-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.refresh-highlights-btn:disabled:hover { background: transparent; color: rgb(228 228 231); /* zinc-200 */ }
.highlights-loading,
.highlights-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem 1rem; color: rgb(161 161 170); /* zinc-400 */ text-align: center; gap: 0.5rem; }

View File

@@ -74,6 +74,13 @@ export function tryMarkInTextNodes(
const before = text.substring(0, actualIndex)
const match = text.substring(actualIndex, actualIndex + searchText.length)
const after = text.substring(actualIndex + searchText.length)
// Validate the match makes sense (not just whitespace or empty)
if (!match || match.trim().length === 0) {
console.warn('Invalid match (empty or whitespace only)')
continue
}
const mark = createMarkElement(highlight, match, highlightStyle)
replaceTextWithMark(textNode, before, after, mark)
@@ -117,13 +124,81 @@ function tryMultiNodeMatch(
// Map normalized index back to original if needed
let startIndex = matchIndex
let endIndex = matchIndex + searchText.length
let endIndex = matchIndex + searchFor.length
if (useNormalized) {
// This is a simplified mapping - for normalized matches we approximate
const ratio = combinedText.length / searchIn.length
startIndex = Math.floor(matchIndex * ratio)
endIndex = Math.min(combinedText.length, startIndex + searchText.length)
// Build precise mapping by walking original text and advancing a normalized counter
const endPos = matchIndex + searchFor.length // end position in normalized text (exclusive)
let normPos = 0
let foundStart = false
let foundEnd = false
let startNode: Text | null = null
let startOffset = 0
let endNode: Text | null = null
let endOffset = 0
for (const nodeInfo of nodeMap) {
const text = nodeInfo.originalText
let prevWasWs = false
for (let i = 0; i < text.length && (!foundStart || !foundEnd); i++) {
const ch = text[i]
const isWs = /\s/.test(ch)
if (isWs) {
if (!prevWasWs) {
// This whitespace sequence counts as one in normalized text
if (!foundStart && normPos === matchIndex) {
startNode = nodeInfo.node
startOffset = i
foundStart = true
}
if (!foundEnd && normPos === endPos) {
endNode = nodeInfo.node
endOffset = i
foundEnd = true
}
normPos++
}
prevWasWs = true
} else {
if (!foundStart && normPos === matchIndex) {
startNode = nodeInfo.node
startOffset = i
foundStart = true
}
normPos++
if (!foundEnd && normPos === endPos) {
endNode = nodeInfo.node
endOffset = i + 1 // end after this character
foundEnd = true
}
prevWasWs = false
}
}
if (foundStart && foundEnd) break
}
if (!foundStart || !foundEnd || !startNode || !endNode) {
console.warn('Failed to map normalized positions to nodes', { matchIndex, endPos, normPos })
return false
}
// Set indices relative to combinedText by reconstructing start/end using nodeMap
const startNodeInfo = nodeMap.find(n => n.node === startNode)!
const endNodeInfo = nodeMap.find(n => n.node === endNode)!
startIndex = startNodeInfo.start + startOffset
endIndex = endNodeInfo.start + endOffset
if (startIndex < 0 || endIndex <= startIndex || endIndex > combinedText.length) {
console.warn('Mapped indices invalid', { startIndex, endIndex, combinedTextLength: combinedText.length })
return false
}
}
// Validate indices
if (startIndex < 0 || endIndex > combinedText.length || startIndex >= endIndex) {
console.warn('Invalid highlight range:', { startIndex, endIndex, combinedTextLength: combinedText.length })
return false
}
// Find which nodes contain the match
@@ -133,37 +208,82 @@ function tryMultiNodeMatch(
if (startIndex < nodeInfo.end && endIndex > nodeInfo.start) {
const nodeStart = Math.max(0, startIndex - nodeInfo.start)
const nodeEnd = Math.min(nodeInfo.originalText.length, endIndex - nodeInfo.start)
// Validate node offsets
if (nodeStart < 0 || nodeEnd > nodeInfo.originalText.length || nodeStart > nodeEnd) {
console.warn('Invalid node offsets:', { nodeStart, nodeEnd, nodeLength: nodeInfo.originalText.length })
continue
}
affectedNodes.push({ node: nodeInfo.node, startOffset: nodeStart, endOffset: nodeEnd })
}
}
if (affectedNodes.length === 0) return false
if (affectedNodes.length === 0) {
console.warn('No affected nodes found for highlight')
return false
}
// Create a Range to wrap the entire selection in a single mark element
const range = document.createRange()
const firstNode = affectedNodes[0]
const lastNode = affectedNodes[affectedNodes.length - 1]
range.setStart(firstNode.node, firstNode.startOffset)
range.setEnd(lastNode.node, lastNode.endOffset)
// Extract the content from the range
const extractedContent = range.extractContents()
// Create a single mark element
const mark = document.createElement('mark')
const levelClass = highlight.level ? ` level-${highlight.level}` : ''
mark.className = `content-highlight-${highlightStyle}${levelClass}`
mark.setAttribute('data-highlight-id', highlight.id)
mark.setAttribute('data-highlight-level', highlight.level || 'nostrverse')
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
// Append the extracted content to the mark
mark.appendChild(extractedContent)
// Insert the mark at the range position
range.insertNode(mark)
return true
try {
// Create a Range to wrap the entire selection in a single mark element
const range = document.createRange()
const firstNode = affectedNodes[0]
const lastNode = affectedNodes[affectedNodes.length - 1]
range.setStart(firstNode.node, firstNode.startOffset)
range.setEnd(lastNode.node, lastNode.endOffset)
// Verify the range isn't collapsed or invalid
if (range.collapsed) {
console.warn('Range is collapsed, skipping highlight')
return false
}
// Get the text content before extraction to verify it matches
const rangeText = range.toString()
const normalizedRangeText = normalizeWhitespace(rangeText)
const normalizedSearchText = normalizeWhitespace(searchText)
// Validate that the extracted text matches what we're searching for
if (!rangeText.includes(searchText) &&
!normalizedRangeText.includes(normalizedSearchText) &&
normalizedRangeText !== normalizedSearchText) {
console.warn('Range text does not match search text:', {
rangeText: rangeText.substring(0, 100),
searchText: searchText.substring(0, 100),
rangeLength: rangeText.length,
searchLength: searchText.length
})
return false
}
// Extract the content from the range
const extractedContent = range.extractContents()
// Verify we actually extracted something
if (!extractedContent || extractedContent.childNodes.length === 0) {
console.warn('No content extracted from range')
return false
}
// Create a single mark element
const mark = document.createElement('mark')
const levelClass = highlight.level ? ` level-${highlight.level}` : ''
mark.className = `content-highlight-${highlightStyle}${levelClass}`
mark.setAttribute('data-highlight-id', highlight.id)
mark.setAttribute('data-highlight-level', highlight.level || 'nostrverse')
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
// Append the extracted content to the mark
mark.appendChild(extractedContent)
// Insert the mark at the range position
range.insertNode(mark)
return true
} catch (error) {
console.error('Error applying multi-node highlight:', error)
return false
}
}

View File

@@ -22,6 +22,17 @@ export function applyHighlightsToHTML(
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html
// CRITICAL: Remove any existing highlight marks to start with clean HTML
// This prevents old broken highlights from corrupting the new rendering
const existingMarks = tempDiv.querySelectorAll('mark[data-highlight-id]')
existingMarks.forEach(mark => {
// Replace the mark with its text content
const textNode = document.createTextNode(mark.textContent || '')
mark.parentNode?.replaceChild(textNode, mark)
})
console.log('🧹 Removed', existingMarks.length, 'existing highlight marks')
let appliedCount = 0
for (const highlight of highlights) {