mirror of
https://github.com/dergigi/boris.git
synced 2026-02-18 05:25:04 +01:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
104332fd94 | ||
|
|
e736c9f5b9 | ||
|
|
103e104cb2 | ||
|
|
5389397e9b | ||
|
|
54cba2beed | ||
|
|
da76cb247c | ||
|
|
9b4a7b6263 |
45
CHANGELOG.md
45
CHANGELOG.md
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.10.32] - 2025-11-02
|
||||
|
||||
### Added
|
||||
|
||||
- Loading states with shimmer effect for profile lookups in articles
|
||||
- localStorage caching for profile resolution with LRU eviction
|
||||
- Progressive profile resolution that updates from fallback to resolved names
|
||||
|
||||
### Changed
|
||||
|
||||
- Standardized applesauce helpers for npub/nprofile detection and display
|
||||
- Standardized profile display name fallbacks across codebase
|
||||
- Removed 'npub1' prefix from shortened npub displays
|
||||
- Improved @ prefix handling for profile mentions
|
||||
- Profile fetching is now reactive (removed timeouts)
|
||||
- Profile label updates are batched to prevent UI flickering
|
||||
|
||||
### Fixed
|
||||
|
||||
- Profile label updates now work correctly and preserve pending updates
|
||||
- Race condition in profile label updates resolved
|
||||
- React hooks exhaustive-deps warnings resolved
|
||||
- Rules of Hooks violation in profile mapping
|
||||
- Syntax error in RichContent try-catch structure
|
||||
- Profile fetching re-checks eventStore for async profile arrivals
|
||||
- LRU cache eviction handles QuotaExceededError
|
||||
- Reduced markdown reprocessing to prevent flicker
|
||||
- TypeScript errors in nostrUriResolver resolved
|
||||
- Profile labels initialize synchronously from cache for instant display
|
||||
|
||||
### Performance
|
||||
|
||||
- Added timing metrics for profile resolution performance
|
||||
- Increased remote relay timeout for profile fetches
|
||||
- Batch profile label updates to prevent UI flickering
|
||||
- Ensure purplepag.es relay is used for profile lookups
|
||||
|
||||
### Refactored
|
||||
|
||||
- Replaced custom NIP-19 parsing with applesauce helpers
|
||||
- Standardized profile name extraction and code quality
|
||||
- Standardized npub/nprofile display implementation
|
||||
- Use pubkey (hex) as Map key instead of encoded nprofile/npub strings
|
||||
- Standardized profile display name fallbacks
|
||||
|
||||
## [0.10.31] - 2025-11-02
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.10.32",
|
||||
"version": "0.10.33",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
@@ -133,7 +133,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
return selectedUrl || `${title || ''}:${(markdown || html || '').length}`
|
||||
}, [selectedUrl, title, markdown, html])
|
||||
|
||||
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
|
||||
const { contentRef } = useHighlightInteractions({
|
||||
onHighlightClick,
|
||||
selectedHighlightId,
|
||||
onTextSelection,
|
||||
@@ -815,8 +815,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
html={finalHtml}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
|
||||
className="reader-markdown"
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
) : (
|
||||
<div className="reader-markdown">
|
||||
@@ -830,8 +828,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
html={finalHtml || html || ''}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
|
||||
className="reader-html"
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, forwardRef } from 'react'
|
||||
import { useMemo, forwardRef } from 'react'
|
||||
import ReactPlayer from 'react-player'
|
||||
import { classifyUrl } from '../utils/helpers'
|
||||
|
||||
@@ -6,8 +6,6 @@ interface VideoEmbedProcessorProps {
|
||||
html: string
|
||||
renderVideoLinksAsEmbeds: boolean
|
||||
className?: string
|
||||
onMouseUp?: (e: React.MouseEvent) => void
|
||||
onTouchEnd?: (e: React.TouchEvent) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,9 +15,7 @@ interface VideoEmbedProcessorProps {
|
||||
const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>(({
|
||||
html,
|
||||
renderVideoLinksAsEmbeds,
|
||||
className,
|
||||
onMouseUp,
|
||||
onTouchEnd
|
||||
className
|
||||
}, ref) => {
|
||||
// Process HTML and extract video URLs in a single pass to keep them in sync
|
||||
const { processedHtml, videoUrls } = useMemo(() => {
|
||||
@@ -109,8 +105,6 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
|
||||
ref={ref}
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: processedHtml }}
|
||||
onMouseUp={onMouseUp}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -119,7 +113,7 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
|
||||
const parts = processedHtml.split(/(__VIDEO_EMBED_\d+__)/)
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className} onMouseUp={onMouseUp} onTouchEnd={onTouchEnd}>
|
||||
<div ref={ref} className={className}>
|
||||
{parts.map((part, index) => {
|
||||
const videoMatch = part.match(/^__VIDEO_EMBED_(\d+)__$/)
|
||||
if (videoMatch) {
|
||||
|
||||
@@ -93,26 +93,37 @@ export const useHighlightInteractions = ({
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [selectedHighlightId, contentVersion])
|
||||
|
||||
// Handle text selection (works for both mouse and touch)
|
||||
const handleSelectionEnd = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
onClearSelection?.()
|
||||
return
|
||||
}
|
||||
// Shared function to check and handle text selection
|
||||
const checkSelection = useCallback(() => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
onClearSelection?.()
|
||||
return
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const text = selection.toString().trim()
|
||||
const range = selection.getRangeAt(0)
|
||||
const text = selection.toString().trim()
|
||||
|
||||
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
|
||||
onTextSelection?.(text)
|
||||
} else {
|
||||
onClearSelection?.()
|
||||
}
|
||||
}, 10)
|
||||
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
|
||||
onTextSelection?.(text)
|
||||
} else {
|
||||
onClearSelection?.()
|
||||
}
|
||||
}, [onTextSelection, onClearSelection])
|
||||
|
||||
return { contentRef, handleSelectionEnd }
|
||||
// Listen to selectionchange events for immediate detection (works reliably on mobile)
|
||||
useEffect(() => {
|
||||
const handleSelectionChange = () => {
|
||||
// Use requestAnimationFrame to ensure selection is checked after browser updates
|
||||
requestAnimationFrame(checkSelection)
|
||||
}
|
||||
|
||||
document.addEventListener('selectionchange', handleSelectionChange)
|
||||
return () => {
|
||||
document.removeEventListener('selectionchange', handleSelectionChange)
|
||||
}
|
||||
}, [checkSelection])
|
||||
|
||||
return { contentRef }
|
||||
}
|
||||
|
||||
|
||||
@@ -64,10 +64,36 @@ export function tryMarkInTextNodes(
|
||||
let actualIndex = index
|
||||
if (useNormalized) {
|
||||
// Map normalized index back to original text
|
||||
let normalizedIdx = 0
|
||||
for (let i = 0; i < text.length && normalizedIdx < index; i++) {
|
||||
if (!/\s/.test(text[i]) || (i > 0 && !/\s/.test(text[i-1]))) normalizedIdx++
|
||||
actualIndex = i + 1
|
||||
// Build normalized text while tracking original positions
|
||||
let normalizedPos = 0
|
||||
let prevWasWs = false
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]
|
||||
const isWs = /\s/.test(ch)
|
||||
|
||||
if (isWs) {
|
||||
// Whitespace: count only at start of whitespace sequence
|
||||
if (!prevWasWs) {
|
||||
if (normalizedPos === index) {
|
||||
actualIndex = i
|
||||
break
|
||||
}
|
||||
normalizedPos++
|
||||
}
|
||||
prevWasWs = true
|
||||
} else {
|
||||
// Non-whitespace: count each character
|
||||
if (normalizedPos === index) {
|
||||
actualIndex = i
|
||||
break
|
||||
}
|
||||
normalizedPos++
|
||||
prevWasWs = false
|
||||
}
|
||||
}
|
||||
// If we didn't find exact match, use last position
|
||||
if (normalizedPos < index) {
|
||||
actualIndex = text.length
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,54 @@
|
||||
import { Highlight } from '../../types/highlights'
|
||||
import { tryMarkInTextNodes } from './domUtils'
|
||||
|
||||
interface CacheEntry {
|
||||
html: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// Simple in-memory cache for highlighted HTML results
|
||||
const highlightCache = new Map<string, CacheEntry>()
|
||||
const CACHE_TTL = 5 * 60 * 1000 // 5 minutes
|
||||
const MAX_CACHE_SIZE = 50 // FIFO eviction after this many entries
|
||||
|
||||
/**
|
||||
* Generate cache key from content and highlights
|
||||
*/
|
||||
function getCacheKey(html: string, highlights: Highlight[], highlightStyle: string): string {
|
||||
// Create a stable key from content hash (first 200 chars) and highlight IDs
|
||||
const contentHash = html.slice(0, 200).replace(/\s+/g, ' ').trim()
|
||||
const highlightIds = highlights
|
||||
.map(h => h.id)
|
||||
.sort()
|
||||
.join(',')
|
||||
return `${contentHash.length}:${highlightIds}:${highlightStyle}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old cache entries and enforce size limit
|
||||
*/
|
||||
function cleanupCache(): void {
|
||||
const now = Date.now()
|
||||
const entries = Array.from(highlightCache.entries())
|
||||
|
||||
// Remove expired entries
|
||||
for (const [key, entry] of entries) {
|
||||
if (now - entry.timestamp > CACHE_TTL) {
|
||||
highlightCache.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce size limit with FIFO eviction (oldest first)
|
||||
if (highlightCache.size > MAX_CACHE_SIZE) {
|
||||
const sortedEntries = Array.from(highlightCache.entries())
|
||||
.sort((a, b) => a[1].timestamp - b[1].timestamp)
|
||||
const toRemove = sortedEntries.slice(0, highlightCache.size - MAX_CACHE_SIZE)
|
||||
for (const [key] of toRemove) {
|
||||
highlightCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply highlights to HTML content by injecting mark tags using DOM manipulation
|
||||
*/
|
||||
@@ -13,19 +61,24 @@ export function applyHighlightsToHTML(
|
||||
return html
|
||||
}
|
||||
|
||||
// Check cache
|
||||
const cacheKey = getCacheKey(html, highlights, highlightStyle)
|
||||
const cached = highlightCache.get(cacheKey)
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.html
|
||||
}
|
||||
|
||||
// Clean up cache periodically
|
||||
cleanupCache()
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// Collect all text nodes once before processing highlights (performance optimization)
|
||||
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
|
||||
const textNodes: Text[] = []
|
||||
let node: Node | null
|
||||
while ((node = walker.nextNode())) textNodes.push(node as Text)
|
||||
|
||||
for (const highlight of highlights) {
|
||||
const searchText = highlight.content.trim()
|
||||
@@ -34,14 +87,6 @@ export function applyHighlightsToHTML(
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// Collect all text nodes
|
||||
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
|
||||
const textNodes: Text[] = []
|
||||
let node: Node | null
|
||||
while ((node = walker.nextNode())) textNodes.push(node as Text)
|
||||
|
||||
|
||||
// Try exact match first, then normalized match
|
||||
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
|
||||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
|
||||
@@ -51,7 +96,14 @@ export function applyHighlightsToHTML(
|
||||
}
|
||||
}
|
||||
|
||||
const result = tempDiv.innerHTML
|
||||
|
||||
return tempDiv.innerHTML
|
||||
// Store in cache
|
||||
highlightCache.set(cacheKey, {
|
||||
html: result,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user