Compare commits

...

31 Commits

Author SHA1 Message Date
Gigi
a0ec89458c Merge pull request #50 from dergigi/fathom
Add Fathom script
2025-11-30 22:15:31 +01:00
Gigi
d8849b2d81 chore(analytics): adjust Fathom comment placement 2025-11-30 22:13:38 +01:00
Gigi
a431bbea6c chore(analytics): simplify Fathom comments 2025-11-30 22:13:21 +01:00
Gigi
3cbad434d6 chore(analytics): add Fathom analytics script 2025-11-30 22:12:19 +01:00
Gigi
4d3047476d Merge pull request #49 from dergigi/highlights-feed-etc
Improve highlights rendering and explore layout
2025-11-26 23:40:55 +01:00
Gigi
bf81cd51b7 style: use multi-column layout for explore writings 2025-11-26 23:35:44 +01:00
Gigi
d50276adca style: align explore width with reader 2025-11-26 23:15:45 +01:00
Gigi
785be6aa9e style: make highlight text wrapping more aggressive 2025-11-26 23:12:48 +01:00
Gigi
934bee2d62 feat: render highlight context and colored highlight text 2025-11-26 22:32:47 +01:00
Gigi
00eb9ae55b style: use single column layout on explore 2025-11-26 22:28:27 +01:00
Gigi
61968c8892 docs: update CHANGELOG for v0.11.1 2025-11-22 02:18:25 +01:00
Gigi
bd0dcbb7f2 chore: bump version to 0.11.1 2025-11-22 02:17:47 +01:00
Gigi
645e1f2b18 Merge pull request #48 from dergigi/profile-three-dot-menu
Add three-dot menu to profile view
2025-11-22 02:16:58 +01:00
Gigi
02de0e7011 fix: remove horizontal padding from profile header to match tabs width 2025-11-22 02:13:02 +01:00
Gigi
e491f7e385 fix: move profile menu to top-right inside card 2025-11-22 02:10:24 +01:00
Gigi
62e5b2b0af fix: move profile menu inside card bottom-right
- Wrap AuthorCard in profile-card-with-menu container
- Use CompactButton for ellipsis menu aligned with highlight cards
- Position menu button at bottom-right inside card and open menu upward
2025-11-22 02:04:08 +01:00
Gigi
be03b9c9cc feat: add three-dot menu to profile view
- Add menu button with options: Copy Link, Share, Open with njump, Open with Native App
- Position menu next to AuthorCard in profile header
- Add click-outside detection to close menu
- Style menu consistently with other menus in the app
2025-11-22 01:59:02 +01:00
Gigi
3da6a70f77 Merge pull request #47 from dergigi/fix-article-loading
Fix highlights navigation and article loading
2025-11-22 01:57:35 +01:00
Gigi
a2dc928681 fix(highlights): make go-to-quote use highlight selection
- Prefer onHighlightClick for quote button, quote text, and menu item
- Fall back to navigation when no highlight-click handler is provided
- Ensures reliable scroll-to-quote behavior in the reader
2025-11-22 01:54:39 +01:00
Gigi
1f88201c18 fix(highlights): ensure quote button always navigates to quote
- Add explicit preventDefault to quote button click handler
- Improve navigateToArticle error handling and logging
- Ensure quote button always attempts navigation when clicked
2025-11-22 01:47:14 +01:00
Gigi
85e93b69aa fix(highlights): prevent menu cutoff when only one highlight
- Add bottom padding to highlights-list to ensure menu has space
- Make menu open upward for last highlight item when space is limited
- Prevents three-dot menu from being clipped by container overflow
2025-11-22 01:45:30 +01:00
Gigi
5cede24650 feat(highlights): make quote clickable to navigate to article
- Extract navigateToArticle helper function for reusability
- Make quote button navigate to article and scroll to highlight
- Make quote text (blockquote) clickable to navigate to article
- Add 'Go to quote' menu item in ellipsis menu
- All quote interactions now navigate to article with highlight scroll
2025-11-22 01:44:18 +01:00
Gigi
2348361d1d feat(highlights): add profile navigation from highlight author
- Make author name clickable to navigate to profile view
- Add 'View profile' option in highlight ellipsis menu
- Implement navigateToProfile helper with error handling
- Use existing /p/:npub routing infrastructure
2025-11-22 01:38:37 +01:00
Gigi
c134c3db57 fix: remove unused events variable in useArticleLoader 2025-11-22 01:29:56 +01:00
Gigi
18dbc521ee fix: reuse Explore article events to load articles immediately
- Add articleCoordinate and eventId to BlogPostCard navigation state
- Update useArticleLoader to check navigation state first before cache/EventStore
- Hydrate article content immediately from eventStore when coming from Explore
- Preserve existing cache/EventStore paths for deep links
- Add background query to check for newer replaceable versions without blocking UI
- Guard updates with requestId to prevent race conditions

This fixes the issue where articles opened from Explore would hang on loading
skeleton when queryEvents never completes. Now articles load instantly by reusing
the full event that Explore already fetched and cached.
2025-11-22 01:29:06 +01:00
Gigi
8600c09344 Merge pull request #46 from dergigi/relay-hints
refactor: improve relay hint selection and relay management
2025-11-22 01:21:40 +01:00
Gigi
efb6b56c3b refactor: improve relay hint selection and relay management
- Extract updateKeepAlive and updateAddressLoader helpers in App.tsx for better code reuse
- Improve relay hint selection in HighlightItem with priority: published > seen > configured relays
- Add URL normalization for consistent relay comparison across services
- Unify relay set approach in articleService (hints + configured relays together)
- Improve relay deduplication in relayListService using normalized URLs
- Move normalizeRelayUrl to helpers.ts for shared use
- Update isContentRelay to use normalized URLs for comparison
- Use getFallbackContentRelays for HARDCODED_RELAYS in relayManager
2025-11-22 01:16:10 +01:00
Gigi
cc22524466 fix: remove unused getDefaultRelays import 2025-11-22 00:29:31 +01:00
Gigi
bca1ee2b2e refactor(relays): unify relay config with typed registry and improve hint usage
- Create typed RelayRole and RelayConfig interface in relays.ts
- Add centralized RELAY_CONFIGS registry with role annotations
- Add helper getters: getLocalRelays(), getDefaultRelays(), getContentRelays(), getFallbackContentRelays()
- Maintain backwards compatibility with RELAYS and NON_CONTENT_RELAYS constants
- Refactor relayManager to use new registry helpers
- Harden applyRelaySetToPool with consistent normalization and local relay preservation
- Add RelaySetChangeSummary return type for debugging
- Improve articleService to prioritize and filter relay hints from naddr
- Use centralized fallback content relay helpers instead of hard-coded arrays
2025-11-16 18:30:39 +00:00
Gigi
4d18c84243 feat: improve relay hint selection to exclude non-content relays
- Add NON_CONTENT_RELAYS list and isContentRelay helper to classify relays
- Update ContentPanel to filter out non-content relays (e.g., relay.nsec.app) from naddr hints
- Update HighlightItem to prefer publishedRelays/seenOnRelays and filter using isContentRelay
- Ensures relay.nsec.app and other auth/utility relays are never suggested as content hints
2025-11-15 20:26:37 +00:00
Gigi
c1b171d188 docs: update CHANGELOG for v0.11.0 2025-11-07 23:19:25 +01:00
19 changed files with 970 additions and 188 deletions

View File

@@ -7,6 +7,92 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.11.1] - 2025-11-22
### Added
- Three-dot menu to profile view
- Clickable quote text in highlights to navigate to article
- Profile navigation from highlight author cards
- Improved relay hint selection to exclude non-content relays
### Fixed
- Profile header horizontal padding matches tabs width
- Profile menu positioning inside card
- Highlight quote button navigation reliability
- Highlight menu cutoff when only one highlight
- Article loading reuses Explore article events for immediate display
- Removed unused variables and imports
### Refactored
- Unified relay configuration with typed registry
- Improved relay hint selection and relay management
## [0.11.0] - 2025-11-07
### Added
- Configurable link color setting for article links
- Basic markdown syntax test files for testing
- Article tags and image alt text to OpenGraph metadata
- Storage-backed OpenGraph previews with Upstash Redis
- Always render OpenGraph meta for `/a/:naddr` routes with redirect script for browsers
- Script to publish markdown test files to Nostr using nak
- npm script for publishing test markdown files
### Changed
- Default link color changed to Sky Blue (#38bdf8)
- Link color setting renamed to `--color-link` with dark/light theme support
- Use single link color setting with theme-aware palette
- Increased paragraph spacing in reader view to 1.5rem
- Increased top margin on headlines in reader view
- Improved link visibility in dark mode with lighter indigo-400 color
- Default Nostr gateway changed to njump.to
- Node runtime pinned to 22.x via package.json engines
- Simplified OpenGraph fetch by removing timeout wrapper and background refresh
### Fixed
- Use sentinel query param for OpenGraph redirect to preserve `/a/:naddr` paths
- Gate `/a/:naddr` rewrite to crawlers to prevent refresh redirect
- Update preview link color when link color setting changes
- Store separate link colors for dark and light themes
- Remove unused LINK_COLORS import from ColorPicker
- Increase relay fetch timeout from 3s to 5s for better reliability
- Improve Redis initialization and add debugging for metadata fetch
- Add .js extensions to ESM imports for Vercel compatibility
- Move OpenGraph service files to api/services for Vercel compatibility
- Resolve linting and type errors
- Remove user-agent restriction from article OpenGraph rewrite
- Inline profile display name helper to avoid src import in serverless
- Move profile helpers to lib and import from API and src to fix serverless import resolution
### Performance
- Implement early-return article fetch with micro-wait author
- Increase relay request timeouts (7s article, 5s profile) to improve reliability
- Remove gateway fetch, use relays with short timeout
### Refactored
- Use relative path for preview link to work on localhost
- Move link to 3rd paragraph and remove 4th paragraph from preview
- Update preview link to use real article link instead of sample text
- Move profile helpers to shared lib module to keep code DRY
### Documentation
- Remove Development section from README
- Update source links to point to specific files
- Add source links to basic markdown test files
- Add footnotes explaining Bitcoin frequency notation
- Add explanatory paragraphs to each test table
- Add test account npub and profile link to .env.example
- Add comprehensive documentation for publish-markdown script
## [0.10.33] - 2025-11-05 ## [0.10.33] - 2025-11-05
### Fixed ### Fixed

View File

@@ -27,7 +27,10 @@
<meta name="twitter:title" content="Boris - Read, Highlight, Explore" /> <meta name="twitter:title" content="Boris - Read, Highlight, Explore" />
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." /> <meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" /> <meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
<!-- Fathom -->
<script src="https://cdn.usefathom.com/script.js" data-site="LLSGRVAP" defer></script>
<!-- Default to system theme until settings load from Nostr --> <!-- Default to system theme until settings load from Nostr -->
<script> <script>
document.documentElement.className = 'theme-system'; document.documentElement.className = 'theme-system';

View File

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

View File

@@ -576,6 +576,31 @@ function App() {
} }
}) })
// Helper to update keep-alive subscription based on current active relays
const updateKeepAlive = (relayUrls?: string[]) => {
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
poolWithSub._keepAliveSubscription.unsubscribe()
}
const targetRelays = relayUrls || getActiveRelayUrls(pool)
const newKeepAliveSub = pool.subscription(targetRelays, { kinds: [0], limit: 0 }).subscribe({
next: () => {},
error: () => {}
})
poolWithSub._keepAliveSubscription = newKeepAliveSub
}
// Helper to update address loader based on current active relays
const updateAddressLoader = (relayUrls?: string[]) => {
const targetRelays = relayUrls || getActiveRelayUrls(pool)
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: targetRelays
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
}
// Handle user relay list and blocked relays when account changes // Handle user relay list and blocked relays when account changes
const userRelaysSub = accounts.active$.subscribe((account) => { const userRelaysSub = accounts.active$.subscribe((account) => {
if (account) { if (account) {
@@ -604,20 +629,6 @@ function App() {
// Apply initial set immediately // Apply initial set immediately
applyRelaySetToPool(pool, initialRelays) applyRelaySetToPool(pool, initialRelays)
// Prepare keep-alive helper
const updateKeepAlive = () => {
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
poolWithSub._keepAliveSubscription.unsubscribe()
}
const activeRelays = getActiveRelayUrls(pool)
const newKeepAliveSub = pool.subscription(activeRelays, { kinds: [0], limit: 0 }).subscribe({
next: () => {},
error: () => {}
})
poolWithSub._keepAliveSubscription = newKeepAliveSub
}
// Begin loading blocked relays in background // Begin loading blocked relays in background
const blockedPromise = loadBlockedRelays(pool, pubkey) const blockedPromise = loadBlockedRelays(pool, pubkey)
@@ -649,43 +660,16 @@ function App() {
applyRelaySetToPool(pool, finalRelays) applyRelaySetToPool(pool, finalRelays)
updateKeepAlive() updateKeepAlive()
updateAddressLoader()
// Update address loader with new relays
const activeRelays = getActiveRelayUrls(pool)
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: activeRelays
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
}).catch((error) => { }).catch((error) => {
console.error('[relay-init] Failed to load user relay list (continuing with initial set):', error) console.error('[relay-init] Failed to load user relay list (continuing with initial set):', error)
// Continue with initial relay set on error - no need to change anything // Continue with initial relay set on error - no need to change anything
}) })
} else { } else {
// User logged out - reset to hardcoded relays // User logged out - reset to hardcoded relays
applyRelaySetToPool(pool, RELAYS) applyRelaySetToPool(pool, RELAYS)
updateKeepAlive(RELAYS)
updateAddressLoader(RELAYS)
// Update keep-alive subscription
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
poolWithSub._keepAliveSubscription.unsubscribe()
}
const newKeepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
next: () => {},
error: () => {}
})
poolWithSub._keepAliveSubscription = newKeepAliveSub
// Reset address loader
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: RELAYS
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
} }
}) })

View File

@@ -53,6 +53,10 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
// Reading progress display // 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 ( return (
<Link <Link
to={href} to={href}
@@ -62,7 +66,9 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
image: post.image, image: post.image,
summary: post.summary, summary: post.summary,
published: post.published published: post.published
} },
articleCoordinate,
eventId: post.event.id
}} }}
className={`blog-post-card ${level ? `level-${level}` : ''}`} className={`blog-post-card ${level ? `level-${level}` : ''}`}
style={{ textDecoration: 'none', color: 'inherit' }} style={{ textDecoration: 'none', color: 'inherit' }}

View File

@@ -12,6 +12,8 @@ import { nip19 } from 'nostr-tools'
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways' import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { getActiveRelayUrls } from '../services/relayManager' import { getActiveRelayUrls } from '../services/relayManager'
import { isContentRelay } from '../config/relays'
import { isLocalRelay } from '../utils/helpers'
import { IAccount } from 'applesauce-accounts' import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
@@ -432,9 +434,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || '' const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : [] const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayHints = activeRelays.filter(r => const relayHints = activeRelays
!r.includes('localhost') && !r.includes('127.0.0.1') .filter(url => !isLocalRelay(url))
).slice(0, 3) .filter(url => isContentRelay(url))
.slice(0, 3)
const naddr = nip19.naddrEncode({ const naddr = nip19.naddrEncode({
kind: 30023, kind: 30023,

View File

@@ -595,7 +595,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
case 'highlights': case 'highlights':
if (showSkeletons) { if (showSkeletons) {
return ( return (
<div className="explore-grid"> <div className="explore-grid single-column">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
<HighlightSkeleton key={i} /> <HighlightSkeleton key={i} />
))} ))}
@@ -607,7 +607,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
<span>No highlights to show for the selected scope.</span> <span>No highlights to show for the selected scope.</span>
</div> </div>
) : ( ) : (
<div className="explore-grid"> <div className="explore-grid single-column">
{classifiedHighlights.map((highlight) => ( {classifiedHighlights.map((highlight) => (
<HighlightItem <HighlightItem
key={highlight.id} key={highlight.id}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 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 { faComments } from '@fortawesome/free-regular-svg-icons'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { useEventModel } from 'applesauce-react/hooks' import { useEventModel } from 'applesauce-react/hooks'
@@ -10,6 +10,7 @@ import { Hooks } from 'applesauce-react'
import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService' import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService'
import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers' import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers'
import { getActiveRelayUrls } from '../services/relayManager' import { getActiveRelayUrls } from '../services/relayManager'
import { isContentRelay, getContentRelays, getFallbackContentRelays } from '../config/relays'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils' import { formatDateCompact } from '../utils/bookmarkUtils'
import { createDeletionRequest } from '../services/deletionService' import { createDeletionRequest } from '../services/deletionService'
@@ -179,14 +180,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
} }
}, [showMenu, showDeleteConfirm]) }, [showMenu, showDeleteConfirm])
const handleItemClick = () => { // Navigate to the article that this highlight references and scroll to the highlight
// If onHighlightClick is provided, use it (legacy behavior) const navigateToArticle = () => {
if (onHighlightClick) { // Always try to navigate if we have a reference - quote button should always work
onHighlightClick(highlight.id)
return
}
// Otherwise, navigate to the article that this highlight references
if (highlight.eventReference) { if (highlight.eventReference) {
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier) // Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
const parts = highlight.eventReference.split(':') const parts = highlight.eventReference.split(':')
@@ -209,9 +205,14 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
openHighlights: true 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 to external URL with highlight ID to trigger scroll
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, { navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
state: { state: {
@@ -219,16 +220,57 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
openHighlights: true 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 = () => { const getHighlightLinks = () => {
// Encode the highlight event itself (kind 9802) as a nevent // Encode the highlight event itself (kind 9802) as a nevent
// Get non-local relays for the hint // Relay hint selection priority:
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : [] // 1. Published relays (where we successfully published the event)
const relayHints = activeRelays.filter(r => // 2. Seen relays (where we observed the event)
!r.includes('localhost') && !r.includes('127.0.0.1') // 3. Configured content relays (deterministic fallback)
).slice(0, 3) // Include up to 3 relay hints // All candidates are deduplicated, filtered to content-capable remote relays, and limited to 3
const publishedRelays = highlight.publishedRelays || []
const seenOnRelays = highlight.seenOnRelays || []
// Determine base candidates: prefer published, then seen, then configured relays
let candidates: string[]
if (publishedRelays.length > 0) {
// Prefer published relays, but include seen relays as backup
candidates = Array.from(new Set([...publishedRelays, ...seenOnRelays]))
.sort((a, b) => a.localeCompare(b))
} else if (seenOnRelays.length > 0) {
candidates = seenOnRelays
} else {
// Fallback to deterministic configured content relays
const contentRelays = getContentRelays()
const fallbackRelays = getFallbackContentRelays()
candidates = Array.from(new Set([...contentRelays, ...fallbackRelays]))
}
// Filter to content-capable remote relays (exclude local and non-content relays)
// Then take up to 3 for relay hints
const relayHints = candidates
.filter(url => !isLocalRelay(url))
.filter(url => isContentRelay(url))
.slice(0, 3)
const nevent = nip19.neventEncode({ const nevent = nip19.neventEncode({
id: highlight.id, id: highlight.id,
@@ -434,6 +476,71 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
handleConfirmDelete() 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()
}
}
const renderHighlightText = () => {
const { content, context } = highlight
if (context && context.length > 0) {
const index = context.indexOf(content)
if (index >= 0) {
const before = context.slice(0, index)
const after = context.slice(index + content.length)
return (
<>
{before}
<span className="highlight-core">{content}</span>
{after}
</>
)
}
// Fallback: show context and the core highlight separately
return (
<>
<span className="highlight-context-prefix">{context}</span>
<br />
<span className="highlight-core">{content}</span>
</>
)
}
return <span className="highlight-core">{content}</span>
}
return ( return (
<> <>
<div <div
@@ -483,15 +590,37 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
<CompactButton <CompactButton
className="highlight-quote-button" className="highlight-quote-button"
icon={faQuoteLeft} icon={faQuoteLeft}
title="Quote" title="Go to quote in article"
onClick={(e) => e.stopPropagation()} onClick={(e) => {
e.stopPropagation()
e.preventDefault()
if (onHighlightClick) {
onHighlightClick(highlight.id)
} else {
navigateToArticle()
}
}}
/> />
{/* relay indicator lives in footer for consistent padding/alignment */} {/* relay indicator lives in footer for consistent padding/alignment */}
<div className="highlight-content"> <div className="highlight-content">
<blockquote className="highlight-text"> <blockquote
{highlight.content} className="highlight-text"
onClick={(e) => {
e.stopPropagation()
if (onHighlightClick) {
onHighlightClick(highlight.id)
} else {
navigateToArticle()
}
}}
style={{ cursor: 'pointer' }}
title="Go to quote in article"
>
{renderHighlightText()}
</blockquote> </blockquote>
{showCitation && ( {showCitation && (
@@ -524,9 +653,13 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
/> />
)} )}
<span className="highlight-author"> <CompactButton
className="highlight-author"
onClick={handleAuthorClick}
title="View profile"
>
{getUserDisplayName()} {getUserDisplayName()}
</span> </CompactButton>
</div> </div>
<div className="highlight-menu-wrapper" ref={menuRef}> <div className="highlight-menu-wrapper" ref={menuRef}>
@@ -565,6 +698,20 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
{showMenu && ( {showMenu && (
<div className="highlight-menu"> <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 <button
className="highlight-menu-item" className="highlight-menu-item"
onClick={handleOpenPortal} onClick={handleOpenPortal}

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react' import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faPenToSquare } from '@fortawesome/free-solid-svg-icons' import { faHighlighter, faPenToSquare, faEllipsisH, faCopy, faShare, faExternalLinkAlt, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import { IEventStore } from 'applesauce-core' import { IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
@@ -9,6 +9,7 @@ import { HighlightItem } from './HighlightItem'
import { BlogPostPreview } from '../services/exploreService' import { BlogPostPreview } from '../services/exploreService'
import { KINDS } from '../config/kinds' import { KINDS } from '../config/kinds'
import AuthorCard from './AuthorCard' import AuthorCard from './AuthorCard'
import CompactButton from './CompactButton'
import BlogPostCard from './BlogPostCard' import BlogPostCard from './BlogPostCard'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons' import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { useStoreTimeline } from '../hooks/useStoreTimeline' import { useStoreTimeline } from '../hooks/useStoreTimeline'
@@ -20,6 +21,7 @@ import { Hooks } from 'applesauce-react'
import { readingProgressController } from '../services/readingProgressController' import { readingProgressController } from '../services/readingProgressController'
import { writingsController } from '../services/writingsController' import { writingsController } from '../services/writingsController'
import { highlightsController } from '../services/highlightsController' import { highlightsController } from '../services/highlightsController'
import { getProfileUrl } from '../config/nostrGateways'
interface ProfileProps { interface ProfileProps {
relayPool: RelayPool relayPool: RelayPool
@@ -38,6 +40,8 @@ const Profile: React.FC<ProfileProps> = ({
const activeAccount = Hooks.useActiveAccount() const activeAccount = Hooks.useActiveAccount()
const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights') const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights')
const [refreshTrigger, setRefreshTrigger] = useState(0) const [refreshTrigger, setRefreshTrigger] = useState(0)
const [showProfileMenu, setShowProfileMenu] = useState(false)
const profileMenuRef = useRef<HTMLDivElement>(null)
// Reading progress state (naddr -> progress 0-1) // Reading progress state (naddr -> progress 0-1)
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map()) const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
@@ -168,6 +172,68 @@ const Profile: React.FC<ProfileProps> = ({
const npub = nip19.npubEncode(pubkey) const npub = nip19.npubEncode(pubkey)
const showSkeletons = cachedHighlights.length === 0 && sortedWritings.length === 0 const showSkeletons = cachedHighlights.length === 0 && sortedWritings.length === 0
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (profileMenuRef.current && !profileMenuRef.current.contains(event.target as Node)) {
setShowProfileMenu(false)
}
}
if (showProfileMenu) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [showProfileMenu])
// Profile menu handlers
const handleMenuToggle = () => {
setShowProfileMenu(!showProfileMenu)
}
const handleCopyProfileLink = async () => {
try {
const borisUrl = `${window.location.origin}/p/${npub}`
await navigator.clipboard.writeText(borisUrl)
setShowProfileMenu(false)
} catch (e) {
console.warn('Copy failed', e)
}
}
const handleShareProfile = async () => {
try {
const borisUrl = `${window.location.origin}/p/${npub}`
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
title: 'Profile',
url: borisUrl
})
} else {
await navigator.clipboard.writeText(borisUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowProfileMenu(false)
}
}
const handleOpenPortal = () => {
const portalUrl = getProfileUrl(npub)
window.open(portalUrl, '_blank', 'noopener,noreferrer')
setShowProfileMenu(false)
}
const handleOpenNative = () => {
const nativeUrl = `nostr:${npub}`
window.location.href = nativeUrl
setShowProfileMenu(false)
}
const renderTabContent = () => { const renderTabContent = () => {
switch (activeTab) { switch (activeTab) {
case 'highlights': case 'highlights':
@@ -236,7 +302,51 @@ const Profile: React.FC<ProfileProps> = ({
pullPosition={pullPosition} pullPosition={pullPosition}
/> />
<div className="explore-header"> <div className="explore-header">
<AuthorCard authorPubkey={pubkey} clickable={false} /> <div className="profile-header-wrapper">
<div className="profile-card-with-menu">
<AuthorCard authorPubkey={pubkey} clickable={false} />
<div className="profile-card-menu-wrapper" ref={profileMenuRef}>
<CompactButton
icon={faEllipsisH}
onClick={handleMenuToggle}
title="More options"
ariaLabel="Profile menu"
/>
{showProfileMenu && (
<div className="profile-card-menu">
<button
className="profile-card-menu-item"
onClick={handleCopyProfileLink}
>
<FontAwesomeIcon icon={faCopy} />
<span>Copy Link</span>
</button>
<button
className="profile-card-menu-item"
onClick={handleShareProfile}
>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
<button
className="profile-card-menu-item"
onClick={handleOpenPortal}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open with njump</span>
</button>
<button
className="profile-card-menu-item"
onClick={handleOpenNative}
>
<FontAwesomeIcon icon={faMobileAlt} />
<span>Open with Native App</span>
</button>
</div>
)}
</div>
</div>
</div>
<div className="me-tabs"> <div className="me-tabs">
<button <button

View File

@@ -1,21 +1,101 @@
import { normalizeRelayUrl } from '../utils/helpers'
/** /**
* Centralized relay configuration * Centralized relay configuration
* Single set of relays used throughout the application * Single set of relays used throughout the application
*/ */
// All relays including local relays export type RelayRole = 'local-cache' | 'default' | 'fallback' | 'non-content' | 'bunker'
export const RELAYS = [
'ws://localhost:10547', export interface RelayConfig {
'ws://localhost:4869', url: string
'wss://relay.nsec.app', roles: RelayRole[]
'wss://relay.damus.io', }
'wss://nos.lol',
'wss://relay.nostr.band', /**
'wss://wot.dergigi.com', * Central relay registry with role annotations
'wss://relay.snort.social', */
'wss://nostr-pub.wellorder.net', const RELAY_CONFIGS: RelayConfig[] = [
'wss://purplepag.es', { url: 'ws://localhost:10547', roles: ['local-cache'] },
'wss://relay.primal.net', { url: 'ws://localhost:4869', roles: ['local-cache'] },
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87', { url: 'wss://relay.nsec.app', roles: ['default', 'non-content'] },
{ url: 'wss://relay.damus.io', roles: ['default', 'fallback'] },
{ url: 'wss://nos.lol', roles: ['default', 'fallback'] },
{ url: 'wss://relay.nostr.band', roles: ['default', 'fallback'] },
{ url: 'wss://wot.dergigi.com', roles: ['default'] },
{ url: 'wss://relay.snort.social', roles: ['default'] },
{ url: 'wss://nostr-pub.wellorder.net', roles: ['default'] },
{ url: 'wss://purplepag.es', roles: ['default'] },
{ url: 'wss://relay.primal.net', roles: ['default', 'fallback'] },
{ url: 'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87', roles: ['default'] },
] ]
/**
* Get all local cache relays (localhost relays)
*/
export function getLocalRelays(): string[] {
return RELAY_CONFIGS
.filter(config => config.roles.includes('local-cache'))
.map(config => config.url)
}
/**
* Get all default relays (main public relays)
*/
export function getDefaultRelays(): string[] {
return RELAY_CONFIGS
.filter(config => config.roles.includes('default'))
.map(config => config.url)
}
/**
* Get fallback content relays (last resort public relays for content fetching)
* These are reliable public relays that should be tried when other methods fail
*/
export function getFallbackContentRelays(): string[] {
return RELAY_CONFIGS
.filter(config => config.roles.includes('fallback'))
.map(config => config.url)
}
/**
* Get relays suitable for content fetching (excludes non-content relays like auth/signer relays)
*/
export function getContentRelays(): string[] {
return RELAY_CONFIGS
.filter(config => !config.roles.includes('non-content'))
.map(config => config.url)
}
/**
* Get relays that should NOT be used as content hints
*/
export function getNonContentRelays(): string[] {
return RELAY_CONFIGS
.filter(config => config.roles.includes('non-content'))
.map(config => config.url)
}
/**
* All relays including local relays (backwards compatibility)
*/
export const RELAYS = [
...getLocalRelays(),
...getDefaultRelays(),
]
/**
* Relays that should NOT be used as content hints (backwards compatibility)
*/
export const NON_CONTENT_RELAYS = getNonContentRelays()
/**
* Check if a relay URL is suitable for use as a content hint
* Returns true for relays that are reasonable for posts/highlights
*/
export function isContentRelay(url: string): boolean {
const normalized = normalizeRelayUrl(url)
const nonContentRelays = getNonContentRelays().map(normalizeRelayUrl)
return !nonContentRelays.includes(normalized)
}

View File

@@ -22,6 +22,12 @@ interface PreviewData {
published?: number published?: number
} }
interface NavigationState {
previewData?: PreviewData
articleCoordinate?: string
eventId?: string
}
interface UseArticleLoaderProps { interface UseArticleLoaderProps {
naddr: string | undefined naddr: string | undefined
relayPool: RelayPool | null relayPool: RelayPool | null
@@ -63,8 +69,11 @@ export function useArticleLoader({
// Track in-flight request to prevent stale updates from previous naddr // Track in-flight request to prevent stale updates from previous naddr
const currentRequestIdRef = useRef(0) const currentRequestIdRef = useRef(0)
// Extract preview data from navigation state (from blog post cards) // Extract navigation state (from blog post cards)
const previewData = (location.state as { previewData?: PreviewData })?.previewData 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 // Track the current article title for document title
const [currentTitle, setCurrentTitle] = useState<string | undefined>() const [currentTitle, setCurrentTitle] = useState<string | undefined>()
@@ -83,6 +92,179 @@ export function useArticleLoader({
// This ensures images from previous articles don't flash briefly // This ensures images from previous articles don't flash briefly
setReaderContent(undefined) 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 // Synchronously check cache sources BEFORE checking relayPool
// This prevents showing loading skeletons when content is immediately available // This prevents showing loading skeletons when content is immediately available
// and fixes the race condition where relayPool isn't ready yet // 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) // Check EventStore synchronously (also doesn't need relayPool)
let foundInEventStore = false let foundInEventStore = false
if (eventStore && !foundInCache) { if (eventStore && !foundInCache && !foundInNavState) {
try { try {
// Decode naddr to get the coordinate // Decode naddr to get the coordinate
const decoded = nip19.decode(naddr) 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 // 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) setReaderLoading(true)
setReaderContent(undefined) setReaderContent(undefined)
return return

View File

@@ -4,7 +4,7 @@ import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19' import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core' import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays' import { getContentRelays, getFallbackContentRelays, isContentRelay } from '../config/relays'
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers' import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
import { merge, toArray as rxToArray } from 'rxjs' import { merge, toArray as rxToArray } from 'rxjs'
import { UserSettings } from './settingsService' import { UserSettings } from './settingsService'
@@ -138,13 +138,6 @@ export async function fetchArticleByNaddr(
const pointer = decoded.data as AddressPointer const pointer = decoded.data as AddressPointer
// Define relays to query - use union of relay hints from naddr and configured relays
// This avoids failures when naddr contains stale/unreachable relay hints
const hintedRelays = (pointer.relays && pointer.relays.length > 0) ? pointer.relays : []
const baseRelays = Array.from(new Set<string>([...hintedRelays, ...RELAYS]))
const orderedRelays = prioritizeLocalRelays(baseRelays)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
// Fetch the article event // Fetch the article event
const filter = { const filter = {
kinds: [pointer.kind], kinds: [pointer.kind],
@@ -152,24 +145,45 @@ export async function fetchArticleByNaddr(
'#d': [pointer.identifier] '#d': [pointer.identifier]
} }
// Parallel local+remote, stream immediate, collect up to first from each let events: NostrEvent[] = []
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000)
const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()))
let events = collected as NostrEvent[]
// Fallback: if nothing found, try a second round against a set of reliable public relays // Build unified relay set: hints + configured content relays
// Filter hinted relays to only content-capable relays
const hintedRelays = (pointer.relays && pointer.relays.length > 0)
? pointer.relays.filter(isContentRelay)
: []
// Get configured content relays
const contentRelays = getContentRelays()
// Union of hinted and configured relays (deduplicated)
const unifiedRelays = Array.from(new Set([...hintedRelays, ...contentRelays]))
if (unifiedRelays.length > 0) {
const orderedUnified = prioritizeLocalRelays(unifiedRelays)
const { local: localUnified, remote: remoteUnified } = partitionRelays(orderedUnified)
const { local$, remote$ } = createParallelReqStreams(
relayPool,
localUnified,
remoteUnified,
filter,
1200,
6000
)
const collected = await lastValueFrom(
merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray())
)
events = collected as NostrEvent[]
}
// Last resort: try fallback content relays (most reliable public relays)
if (events.length === 0) { if (events.length === 0) {
const reliableRelays = Array.from(new Set<string>([ const fallbackRelays = getFallbackContentRelays()
'wss://relay.nostr.band',
'wss://relay.primal.net',
'wss://relay.damus.io',
'wss://nos.lol',
...remoteRelays // keep any configured remote relays
]))
const { remote$: fallback$ } = createParallelReqStreams( const { remote$: fallback$ } = createParallelReqStreams(
relayPool, relayPool,
[], // no local [], // no local for fallback
reliableRelays, fallbackRelays,
filter, filter,
1500, 1500,
12000 12000

View File

@@ -1,6 +1,7 @@
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch' import { queryEvents } from './dataFetch'
import { normalizeRelayUrl } from '../utils/helpers'
export interface UserRelayInfo { export interface UserRelayInfo {
url: string url: string
@@ -144,35 +145,55 @@ export function computeRelaySet(params: {
alwaysIncludeLocal alwaysIncludeLocal
} = params } = params
// Normalize all URLs for consistent comparison and deduplication
const normalizedBlocked = new Set(blocked.map(normalizeRelayUrl))
const normalizedLocal = new Set(alwaysIncludeLocal.map(normalizeRelayUrl))
const relaySet = new Set<string>() const relaySet = new Set<string>()
const blockedSet = new Set(blocked) const normalizedRelaySet = new Set<string>()
// Helper to check if relay should be included // Helper to check if relay should be included (using normalized URLs)
const shouldInclude = (url: string): boolean => { const shouldInclude = (normalizedUrl: string): boolean => {
// Always include local relays // Always include local relays
if (alwaysIncludeLocal.includes(url)) return true if (normalizedLocal.has(normalizedUrl)) return true
// Otherwise check if blocked // Otherwise check if blocked
return !blockedSet.has(url) return !normalizedBlocked.has(normalizedUrl)
} }
// Add hardcoded relays // Add hardcoded relays (normalized)
for (const url of hardcoded) { for (const url of hardcoded) {
if (shouldInclude(url)) relaySet.add(url) const normalized = normalizeRelayUrl(url)
if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) {
normalizedRelaySet.add(normalized)
relaySet.add(url) // Keep original URL for output
}
} }
// Add bunker relays // Add bunker relays (normalized)
for (const url of bunker) { for (const url of bunker) {
if (shouldInclude(url)) relaySet.add(url) const normalized = normalizeRelayUrl(url)
if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) {
normalizedRelaySet.add(normalized)
relaySet.add(url) // Keep original URL for output
}
} }
// Add user relays (treating 'both' and 'read' as applicable for queries) // Add user relays (normalized)
for (const relay of userList) { for (const relay of userList) {
if (shouldInclude(relay.url)) relaySet.add(relay.url) const normalized = normalizeRelayUrl(relay.url)
if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) {
normalizedRelaySet.add(normalized)
relaySet.add(relay.url) // Keep original URL for output
}
} }
// Always ensure local relays are present // Always ensure local relays are present (normalized check)
for (const url of alwaysIncludeLocal) { for (const url of alwaysIncludeLocal) {
relaySet.add(url) const normalized = normalizeRelayUrl(url)
if (!normalizedRelaySet.has(normalized)) {
normalizedRelaySet.add(normalized)
relaySet.add(url) // Keep original URL for output
}
} }
return Array.from(relaySet) return Array.from(relaySet)

View File

@@ -1,20 +1,17 @@
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { prioritizeLocalRelays } from '../utils/helpers' import { prioritizeLocalRelays, normalizeRelayUrl } from '../utils/helpers'
import { getLocalRelays, getFallbackContentRelays } from '../config/relays'
/** /**
* Local relays that are always included * Local relays that are always included
*/ */
export const ALWAYS_LOCAL_RELAYS = [ export const ALWAYS_LOCAL_RELAYS = getLocalRelays()
'ws://localhost:10547',
'ws://localhost:4869'
]
/** /**
* Hardcoded relays that are always included * Hardcoded relays that are always included (minimal reliable set)
* Derived from RELAY_CONFIGS fallback relays
*/ */
export const HARDCODED_RELAYS = [ export const HARDCODED_RELAYS = getFallbackContentRelays()
'wss://relay.nostr.band'
]
/** /**
* Gets active relay URLs from the relay pool * Gets active relay URLs from the relay pool
@@ -24,76 +21,84 @@ export function getActiveRelayUrls(relayPool: RelayPool): string[] {
return prioritizeLocalRelays(urls) return prioritizeLocalRelays(urls)
} }
/** export interface RelaySetChangeSummary {
* Normalizes a relay URL to match what applesauce-relay stores internally added: string[]
* Adds trailing slash for URLs without a path removed: string[]
*/
function normalizeRelayUrl(url: string): string {
try {
const parsed = new URL(url)
// If the pathname is empty or just "/", ensure it ends with "/"
if (parsed.pathname === '' || parsed.pathname === '/') {
return url.endsWith('/') ? url : url + '/'
}
return url
} catch {
// If URL parsing fails, return as-is
return url
}
} }
/** /**
* Applies a new relay set to the pool: adds missing relays, removes extras * Applies a new relay set to the pool: adds missing relays, removes extras
* Always preserves local relays even if not in finalUrls
* @returns Summary of changes for debugging
*/ */
export function applyRelaySetToPool( export function applyRelaySetToPool(
relayPool: RelayPool, relayPool: RelayPool,
finalUrls: string[] finalUrls: string[],
): void { options?: { preserveAlwaysLocal?: boolean }
// Normalize all URLs to match pool's internal format ): RelaySetChangeSummary {
const currentUrls = new Set(Array.from(relayPool.relays.keys())) const preserveLocal = options?.preserveAlwaysLocal !== false // default true
const normalizedTargetUrls = new Set(finalUrls.map(normalizeRelayUrl))
// Ensure local relays are always included
// Add new relays (use original URLs for adding, not normalized) const urlsWithLocal = preserveLocal
const toAdd = finalUrls.filter(url => !currentUrls.has(normalizeRelayUrl(url))) ? Array.from(new Set([...finalUrls, ...ALWAYS_LOCAL_RELAYS]))
: finalUrls
if (toAdd.length > 0) { // Normalize all URLs consistently for comparison
relayPool.group(toAdd) const normalizedCurrent = new Set(
Array.from(relayPool.relays.keys()).map(normalizeRelayUrl)
)
const normalizedTarget = new Set(urlsWithLocal.map(normalizeRelayUrl))
// Map normalized URLs back to original for adding
const normalizedToOriginal = new Map<string, string>()
for (const url of urlsWithLocal) {
normalizedToOriginal.set(normalizeRelayUrl(url), url)
} }
// Remove relays not in target (but always keep local relays) // Find relays to add (not in current pool)
const toAdd: string[] = []
for (const normalizedUrl of normalizedTarget) {
if (!normalizedCurrent.has(normalizedUrl)) {
const originalUrl = normalizedToOriginal.get(normalizedUrl) || normalizedUrl
toAdd.push(originalUrl)
}
}
// Find relays to remove (not in target, but preserve local relays)
const normalizedLocal = new Set(ALWAYS_LOCAL_RELAYS.map(normalizeRelayUrl))
const toRemove: string[] = [] const toRemove: string[] = []
for (const url of currentUrls) { for (const currentUrl of relayPool.relays.keys()) {
// Check if this normalized URL is in the target set const normalizedCurrentUrl = normalizeRelayUrl(currentUrl)
if (!normalizedTargetUrls.has(url)) { if (!normalizedTarget.has(normalizedCurrentUrl)) {
// Also check if it's a local relay (check both normalized and original forms) // Always preserve local relays
const isLocal = ALWAYS_LOCAL_RELAYS.some(localUrl => if (!preserveLocal || !normalizedLocal.has(normalizedCurrentUrl)) {
normalizeRelayUrl(localUrl) === url || localUrl === url toRemove.push(currentUrl)
)
if (!isLocal) {
toRemove.push(url)
} }
} }
} }
// Apply changes
if (toAdd.length > 0) {
relayPool.group(toAdd)
}
for (const url of toRemove) { for (const url of toRemove) {
const relay = relayPool.relays.get(url) const relay = relayPool.relays.get(url)
if (relay) { if (relay) {
try { try {
// Only close if relay is actually connected or attempting to connect
// This helps avoid WebSocket warnings for connections that never started
relay.close() relay.close()
} catch (error) { } catch (error) {
// Suppress errors when closing relays that haven't fully connected yet // Suppress errors when closing relays that haven't fully connected yet
// This can happen when switching relay sets before connections establish
// Silently ignore
} }
relayPool.relays.delete(url) relayPool.relays.delete(url)
} }
} }
// Return summary for debugging (useful for understanding relay churn)
if (import.meta.env.DEV && (toAdd.length > 0 || toRemove.length > 0)) {
console.debug('[relay-pool] Changes:', { added: toAdd, removed: toRemove })
}
return { added: toAdd, removed: toRemove }
} }

View File

@@ -263,7 +263,12 @@
.large-read-button:hover { background: var(--color-primary-hover); } .large-read-button:hover { background: var(--color-primary-hover); }
/* Blog cards (Explore) */ /* Blog cards (Explore) */
.explore-container { padding: 2rem; max-width: 1400px; margin: 0 auto; min-height: 100vh; } .explore-container {
padding: 2rem;
max-width: var(--main-max-width);
margin: 0 auto;
min-height: 100vh;
}
.explore-header { text-align: center; margin-bottom: 3rem; } .explore-header { text-align: center; margin-bottom: 3rem; }
.explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: var(--color-primary); display: flex; align-items: center; justify-content: center; gap: 1rem; } .explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: var(--color-primary); display: flex; align-items: center; justify-content: center; gap: 1rem; }
.explore-subtitle { font-size: 1.125rem; color: var(--color-text-secondary); margin: 0; } .explore-subtitle { font-size: 1.125rem; color: var(--color-text-secondary); margin: 0; }
@@ -272,7 +277,15 @@
.explore-loading { min-height: 0; padding: 0.25rem 0; } .explore-loading { min-height: 0; padding: 0.25rem 0; }
.explore-error { color: rgb(239 68 68); /* red-500 */ } .explore-error { color: rgb(239 68 68); /* red-500 */ }
.explore-empty { color: var(--color-text-secondary); } .explore-empty { color: var(--color-text-secondary); }
.explore-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 2rem; margin-top: 2rem; } .explore-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.explore-grid.single-column {
grid-template-columns: 1fr;
}
.blog-post-card { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; overflow: hidden; transition: all 0.3s ease; cursor: pointer; display: flex; flex-direction: column; height: 100%; } .blog-post-card { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; overflow: hidden; transition: all 0.3s ease; cursor: pointer; display: flex; flex-direction: column; height: 100%; }
.blog-post-card:hover { border-color: var(--color-primary); transform: translateY(-4px); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); } .blog-post-card:hover { border-color: var(--color-primary); transform: translateY(-4px); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); }
.blog-post-card.level-mine { border-color: color-mix(in srgb, var(--highlight-color-mine, #fde047) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #fde047) 25%, transparent); } .blog-post-card.level-mine { border-color: color-mix(in srgb, var(--highlight-color-mine, #fde047) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #fde047) 25%, transparent); }

View File

@@ -73,7 +73,8 @@
/* Align highlight list width with profile card width on /my */ /* Align highlight list width with profile card width on /my */
.me-highlights-list { padding-left: 0; padding-right: 0; } .me-highlights-list { padding-left: 0; padding-right: 0; }
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; } .explore-header .profile-header-wrapper { max-width: 600px; margin: 0 auto; width: 100%; }
.explore-header .author-card { max-width: none; margin: 0; width: auto; flex: 1; }
/* Hide tab labels on mobile to save space */ /* Hide tab labels on mobile to save space */
@media (max-width: 768px) { @media (max-width: 768px) {

View File

@@ -10,6 +10,71 @@
.author-card-name { font-size: 1rem; font-weight: 600; color: var(--color-text); margin-bottom: 0.5rem; text-align: left; } .author-card-name { font-size: 1rem; font-weight: 600; color: var(--color-text); margin-bottom: 0.5rem; text-align: left; }
.author-card-bio { font-size: 0.9rem; color: var(--color-text-secondary); line-height: 1.5; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; text-align: left; } .author-card-bio { font-size: 0.9rem; color: var(--color-text-secondary); line-height: 1.5; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; text-align: left; }
/* Profile header */
.profile-header-wrapper {
display: flex;
justify-content: center;
padding: 2rem 1rem 0;
}
/* Remove horizontal padding when inside explore-header to match tabs width */
.explore-header .profile-header-wrapper {
padding-left: 0;
padding-right: 0;
}
.profile-card-with-menu {
position: relative;
max-width: 600px;
width: 100%;
}
/* Profile card menu - inside card, bottom-right */
.profile-card-menu-wrapper {
position: absolute;
right: 1.25rem;
top: 1.25rem;
}
.profile-card-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: 180px;
overflow: hidden;
}
.profile-card-menu-item {
width: 100%;
background: none;
border: none;
color: var(--color-text);
padding: 0.75rem 1rem;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
white-space: nowrap;
}
.profile-card-menu-item:hover {
background: rgba(99, 102, 241, 0.15);
color: var(--color-text);
}
.profile-card-menu-item svg {
font-size: 0.875rem;
flex-shrink: 0;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.author-card-container { .author-card-container {
padding: 1.5rem 1rem; padding: 1.5rem 1rem;
@@ -26,5 +91,13 @@
.author-card-avatar svg { font-size: 2rem; } .author-card-avatar svg { font-size: 2rem; }
.author-card-name { font-size: 0.95rem; } .author-card-name { font-size: 0.95rem; }
.author-card-bio { font-size: 0.85rem; -webkit-line-clamp: 2; } .author-card-bio { font-size: 0.85rem; -webkit-line-clamp: 2; }
}
.profile-header-wrapper {
padding-top: 1.5rem;
}
.profile-card-menu-wrapper {
right: 1rem;
top: 1rem;
}
}

View File

@@ -102,7 +102,7 @@
.highlights-empty svg { color: var(--color-text-muted); margin-bottom: 0.5rem; } .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; } .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 { 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: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); } .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); }
@@ -149,7 +149,40 @@
.highlight-item.level-nostrverse .highlight-quote-icon { color: var(--highlight-color-nostrverse, #9333ea); } .highlight-item.level-nostrverse .highlight-quote-icon { color: var(--highlight-color-nostrverse, #9333ea); }
.highlight-content { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 2.25rem 0.75rem 2.5rem; } .highlight-content { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 2.25rem 0.75rem 2.5rem; }
.highlight-text { margin: 0; padding: 0 0 0 1.25rem; font-style: italic; color: var(--color-text); line-height: 1.6; border-left: none; font-size: 0.95rem; } .highlight-text {
margin: 0;
padding: 0 0 0 1.25rem;
font-style: italic;
color: var(--color-text);
line-height: 1.6;
border-left: none;
font-size: 0.95rem;
/* Aggressive wrapping for long words/URLs inside highlights */
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
hyphens: auto;
}
.highlight-core {
background: color-mix(in srgb, var(--highlight-color, #fde047) 35%, transparent);
padding: 0 0.1em;
border-radius: 3px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.highlight-item.level-mine .highlight-core {
background: color-mix(in srgb, var(--highlight-color-mine, #fde047) 40%, transparent);
}
.highlight-item.level-friends .highlight-core {
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 35%, transparent);
}
.highlight-item.level-nostrverse .highlight-core {
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 35%, transparent);
}
.highlight-context-prefix {
display: block;
margin-bottom: 0.35rem;
}
.highlight-citation { margin-left: 1.25rem; font-size: 0.8rem; color: var(--color-text-secondary); font-style: normal; padding-top: 0.25rem; } .highlight-citation { margin-left: 1.25rem; font-size: 0.8rem; color: var(--color-text-secondary); font-style: normal; padding-top: 0.25rem; }
.highlight-comment { margin-top: 0.5rem; padding: 0.75rem; border-radius: 4px; font-size: 0.875rem; color: var(--color-text); line-height: 1.5; display: flex; gap: 0.5rem; align-items: flex-start; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; min-width: 0; } .highlight-comment { margin-top: 0.5rem; padding: 0.75rem; border-radius: 4px; font-size: 0.875rem; color: var(--color-text); line-height: 1.5; display: flex; gap: 0.5rem; align-items: flex-start; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; min-width: 0; }
.highlight-comment-icon { flex-shrink: 0; margin-top: 0.125rem; } .highlight-comment-icon { flex-shrink: 0; margin-top: 0.125rem; }
@@ -177,7 +210,10 @@
padding: 0.25rem; /* CompactButton base */ padding: 0.25rem; /* CompactButton base */
} }
.highlight-menu-wrapper { position: relative; flex-shrink: 0; display: flex; align-items: center; } .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 { 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:hover { background: rgba(99, 102, 241, 0.15); color: var(--color-text); }
.highlight-menu-item:disabled { opacity: 0.5; cursor: not-allowed; } .highlight-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }

View File

@@ -39,6 +39,24 @@ export const classifyUrl = (url: string | undefined): UrlClassification => {
return { type: 'article' } return { type: 'article' }
} }
/**
* Normalizes a relay URL to match what applesauce-relay stores internally
* Adds trailing slash for URLs without a path
*/
export function normalizeRelayUrl(url: string): string {
try {
const parsed = new URL(url)
// If the pathname is empty or just "/", ensure it ends with "/"
if (parsed.pathname === '' || parsed.pathname === '/') {
return url.endsWith('/') ? url : url + '/'
}
return url
} catch {
// If URL parsing fails, return as-is
return url
}
}
/** /**
* Checks if a relay URL is a local relay (localhost or 127.0.0.1) * Checks if a relay URL is a local relay (localhost or 127.0.0.1)
*/ */