Merge pull request #47 from dergigi/fix-article-loading

Fix highlights navigation and article loading
This commit is contained in:
Gigi
2025-11-22 01:57:35 +01:00
committed by GitHub
4 changed files with 302 additions and 22 deletions

View File

@@ -53,6 +53,10 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
// Reading progress display
}
// Build article coordinate for navigation state (kind:pubkey:dTag)
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = dTag ? `${post.event.kind}:${post.author}:${dTag}` : undefined
return (
<Link
to={href}
@@ -62,7 +66,9 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
image: post.image,
summary: post.summary,
published: post.published
}
},
articleCoordinate,
eventId: post.event.id
}}
className={`blog-post-card ${level ? `level-${level}` : ''}`}
style={{ textDecoration: 'none', color: 'inherit' }}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt, faUser } from '@fortawesome/free-solid-svg-icons'
import { faComments } from '@fortawesome/free-regular-svg-icons'
import { Highlight } from '../types/highlights'
import { useEventModel } from 'applesauce-react/hooks'
@@ -180,14 +180,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
}, [showMenu, showDeleteConfirm])
const handleItemClick = () => {
// If onHighlightClick is provided, use it (legacy behavior)
if (onHighlightClick) {
onHighlightClick(highlight.id)
return
}
// Otherwise, navigate to the article that this highlight references
// Navigate to the article that this highlight references and scroll to the highlight
const navigateToArticle = () => {
// Always try to navigate if we have a reference - quote button should always work
if (highlight.eventReference) {
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
const parts = highlight.eventReference.split(':')
@@ -210,9 +205,14 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
openHighlights: true
}
})
return
}
}
} else if (highlight.urlReference) {
// If eventReference is just an event ID (not a coordinate), we can't navigate to it
// as we don't have enough info to construct the article URL
}
if (highlight.urlReference) {
// Navigate to external URL with highlight ID to trigger scroll
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
state: {
@@ -220,7 +220,23 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
openHighlights: true
}
})
return
}
// If we get here, there's no valid reference to navigate to
// This shouldn't happen for valid highlights, but we'll log it for debugging
console.warn('Cannot navigate to article: highlight has no valid eventReference or urlReference', highlight.id)
}
const handleItemClick = () => {
// If onHighlightClick is provided, use it (legacy behavior)
if (onHighlightClick) {
onHighlightClick(highlight.id)
return
}
// Otherwise, navigate to the article that this highlight references
navigateToArticle()
}
const getHighlightLinks = () => {
@@ -460,6 +476,39 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
handleConfirmDelete()
}
// Navigate to author's profile
const navigateToProfile = (tab?: 'highlights' | 'writings') => {
try {
const npub = nip19.npubEncode(highlight.pubkey)
const path = tab === 'writings' ? `/p/${npub}/writings` : `/p/${npub}`
navigate(path)
} catch (err) {
console.error('Failed to encode npub for profile navigation:', err)
}
}
const handleAuthorClick = (e: React.MouseEvent) => {
e.stopPropagation()
navigateToProfile()
}
const handleMenuViewProfile = (e: React.MouseEvent) => {
e.stopPropagation()
setShowMenu(false)
navigateToProfile()
}
const handleMenuGoToQuote = (e: React.MouseEvent) => {
e.stopPropagation()
setShowMenu(false)
if (onHighlightClick) {
onHighlightClick(highlight.id)
} else {
navigateToArticle()
}
}
return (
<>
<div
@@ -509,14 +558,36 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
<CompactButton
className="highlight-quote-button"
icon={faQuoteLeft}
title="Quote"
onClick={(e) => e.stopPropagation()}
title="Go to quote in article"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
if (onHighlightClick) {
onHighlightClick(highlight.id)
} else {
navigateToArticle()
}
}}
/>
{/* relay indicator lives in footer for consistent padding/alignment */}
<div className="highlight-content">
<blockquote className="highlight-text">
<blockquote
className="highlight-text"
onClick={(e) => {
e.stopPropagation()
if (onHighlightClick) {
onHighlightClick(highlight.id)
} else {
navigateToArticle()
}
}}
style={{ cursor: 'pointer' }}
title="Go to quote in article"
>
{highlight.content}
</blockquote>
@@ -550,9 +621,13 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
/>
)}
<span className="highlight-author">
<CompactButton
className="highlight-author"
onClick={handleAuthorClick}
title="View profile"
>
{getUserDisplayName()}
</span>
</CompactButton>
</div>
<div className="highlight-menu-wrapper" ref={menuRef}>
@@ -591,6 +666,20 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
{showMenu && (
<div className="highlight-menu">
<button
className="highlight-menu-item"
onClick={handleMenuGoToQuote}
>
<FontAwesomeIcon icon={faQuoteLeft} />
<span>Go to quote</span>
</button>
<button
className="highlight-menu-item"
onClick={handleMenuViewProfile}
>
<FontAwesomeIcon icon={faUser} />
<span>View profile</span>
</button>
<button
className="highlight-menu-item"
onClick={handleOpenPortal}

View File

@@ -22,6 +22,12 @@ interface PreviewData {
published?: number
}
interface NavigationState {
previewData?: PreviewData
articleCoordinate?: string
eventId?: string
}
interface UseArticleLoaderProps {
naddr: string | undefined
relayPool: RelayPool | null
@@ -63,8 +69,11 @@ export function useArticleLoader({
// Track in-flight request to prevent stale updates from previous naddr
const currentRequestIdRef = useRef(0)
// Extract preview data from navigation state (from blog post cards)
const previewData = (location.state as { previewData?: PreviewData })?.previewData
// Extract navigation state (from blog post cards)
const navState = (location.state as NavigationState | null) || {}
const previewData = navState.previewData
const navArticleCoordinate = navState.articleCoordinate
const navEventId = navState.eventId
// Track the current article title for document title
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
@@ -83,6 +92,179 @@ export function useArticleLoader({
// This ensures images from previous articles don't flash briefly
setReaderContent(undefined)
// FIRST: Check navigation state for article coordinate/eventId (from Explore)
// This allows immediate hydration when coming from Explore without refetching
let foundInNavState = false
if (eventStore && (navArticleCoordinate || navEventId)) {
try {
let storedEvent: NostrEvent | undefined
// Try coordinate first (most reliable for replaceable events)
if (navArticleCoordinate) {
storedEvent = eventStore.getEvent?.(navArticleCoordinate) as NostrEvent | undefined
}
// Fallback to eventId if coordinate lookup failed
if (!storedEvent && navEventId) {
// Note: eventStore.getEvent might not support eventId lookup directly
// We'll decode naddr to get coordinate as fallback
try {
const decoded = nip19.decode(naddr)
if (decoded.type === 'naddr') {
const pointer = decoded.data as AddressPointer
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
storedEvent = eventStore.getEvent?.(coordinate) as NostrEvent | undefined
}
} catch {
// Ignore decode errors
}
}
if (storedEvent) {
foundInNavState = true
const title = Helpers.getArticleTitle(storedEvent) || previewData?.title || 'Untitled Article'
setCurrentTitle(title)
const image = Helpers.getArticleImage(storedEvent) || previewData?.image
const summary = Helpers.getArticleSummary(storedEvent) || previewData?.summary
const published = Helpers.getArticlePublished(storedEvent) || previewData?.published
setReaderContent({
title,
markdown: storedEvent.content,
image,
summary,
published,
url: `nostr:${naddr}`
})
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(storedEvent.id)
setCurrentArticle?.(storedEvent)
setReaderLoading(false)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
// Preload image if available
if (image) {
preloadImage(image)
}
// Fetch highlights in background if relayPool is available
if (relayPool) {
const coord = dTag ? `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}` : undefined
const eventId = storedEvent.id
if (coord && eventId) {
setHighlightsLoading(true)
fetchHighlightsForArticle(
relayPool,
coord,
eventId,
(highlight) => {
if (!mountedRef.current) return
setHighlights((prev: Highlight[]) => {
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
const next = [highlight, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
},
settings,
false,
eventStore || undefined
).then(() => {
if (mountedRef.current) {
setHighlightsLoading(false)
}
}).catch(() => {
if (mountedRef.current) {
setHighlightsLoading(false)
}
})
}
}
// Start background query to check for newer replaceable version
// but don't block UI - we already have content
if (relayPool) {
const backgroundRequestId = ++currentRequestIdRef.current
const originalCreatedAt = storedEvent.created_at
// Fire and forget background fetch
;(async () => {
try {
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') return
const pointer = decoded.data as AddressPointer
const filter = {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier]
}
await queryEvents(relayPool, filter, {
onEvent: (evt) => {
if (!mountedRef.current || currentRequestIdRef.current !== backgroundRequestId) return
// Store in event store
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eventStore?.add?.(evt as unknown as any)
} catch {
// Ignore store errors
}
// Only update if this is a newer version than what we loaded
if (evt.created_at > originalCreatedAt) {
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
const image = Helpers.getArticleImage(evt)
const summary = Helpers.getArticleSummary(evt)
const published = Helpers.getArticlePublished(evt)
setCurrentTitle(title)
setReaderContent({
title,
markdown: evt.content,
image,
summary,
published,
url: `nostr:${naddr}`
})
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${evt.kind}:${evt.pubkey}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(evt.id)
setCurrentArticle?.(evt)
// Update cache
const articleContent = {
title,
markdown: evt.content,
image,
summary,
published,
author: evt.pubkey,
event: evt
}
saveToCache(naddr, articleContent, settings)
}
}
})
} catch (err) {
// Silently ignore background fetch errors - we already have content
console.warn('[article-loader] Background fetch failed:', err)
}
})()
}
// Return early - we have content from navigation state
return
}
} catch (err) {
// If navigation state lookup fails, fall through to cache/EventStore
console.warn('[article-loader] Navigation state lookup failed:', err)
}
}
// Synchronously check cache sources BEFORE checking relayPool
// This prevents showing loading skeletons when content is immediately available
// and fixes the race condition where relayPool isn't ready yet
@@ -173,7 +355,7 @@ export function useArticleLoader({
// Check EventStore synchronously (also doesn't need relayPool)
let foundInEventStore = false
if (eventStore && !foundInCache) {
if (eventStore && !foundInCache && !foundInNavState) {
try {
// Decode naddr to get the coordinate
const decoded = nip19.decode(naddr)
@@ -251,7 +433,7 @@ export function useArticleLoader({
}
// Only return early if we have no content AND no relayPool to fetch from
if (!relayPool && !foundInCache && !foundInEventStore) {
if (!relayPool && !foundInCache && !foundInEventStore && !foundInNavState) {
setReaderLoading(true)
setReaderContent(undefined)
return

View File

@@ -102,7 +102,7 @@
.highlights-empty svg { color: var(--color-text-muted); margin-bottom: 0.5rem; }
.empty-hint { font-size: 0.875rem; color: var(--color-text-muted); margin-top: 0.5rem; }
.highlights-list { overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
.highlights-list { overflow-y: auto; padding: 1rem; padding-bottom: 10rem; display: flex; flex-direction: column; gap: 0.75rem; }
.highlight-item { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 0; display: flex; transition: border-color 0.2s ease; position: relative; }
.highlight-item:hover { border-color: var(--color-primary); }
.highlight-item.selected { border-color: var(--color-primary); background: var(--color-bg-elevated); box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3); }
@@ -177,7 +177,10 @@
padding: 0.25rem; /* CompactButton base */
}
.highlight-menu-wrapper { position: relative; flex-shrink: 0; display: flex; align-items: center; }
.highlight-menu { position: absolute; right: 0; top: calc(100% + 4px); background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 160px; overflow: hidden; }
.highlight-menu { position: absolute; right: 0; top: calc(100% + 4px); bottom: auto; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 160px; overflow: hidden; }
/* Open menu upward when there's not enough space below */
.highlight-menu-wrapper:last-child .highlight-menu,
.highlight-item:last-child .highlight-menu-wrapper .highlight-menu { top: auto; bottom: calc(100% + 4px); }
.highlight-menu-item { width: 100%; background: none; border: none; color: var(--color-text); padding: 0.625rem 0.875rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.625rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
.highlight-menu-item:hover { background: rgba(99, 102, 241, 0.15); color: var(--color-text); }
.highlight-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }