mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0ec89458c | ||
|
|
d8849b2d81 | ||
|
|
a431bbea6c | ||
|
|
3cbad434d6 | ||
|
|
4d3047476d | ||
|
|
bf81cd51b7 | ||
|
|
d50276adca | ||
|
|
785be6aa9e | ||
|
|
934bee2d62 | ||
|
|
00eb9ae55b | ||
|
|
61968c8892 | ||
|
|
bd0dcbb7f2 | ||
|
|
645e1f2b18 | ||
|
|
02de0e7011 | ||
|
|
e491f7e385 | ||
|
|
62e5b2b0af | ||
|
|
be03b9c9cc | ||
|
|
3da6a70f77 | ||
|
|
a2dc928681 | ||
|
|
1f88201c18 | ||
|
|
85e93b69aa | ||
|
|
5cede24650 | ||
|
|
2348361d1d | ||
|
|
c134c3db57 | ||
|
|
18dbc521ee | ||
|
|
8600c09344 | ||
|
|
efb6b56c3b | ||
|
|
cc22524466 | ||
|
|
bca1ee2b2e | ||
|
|
4d18c84243 | ||
|
|
c1b171d188 |
86
CHANGELOG.md
86
CHANGELOG.md
@@ -7,6 +7,92 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -27,7 +27,10 @@
|
||||
<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: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 -->
|
||||
<script>
|
||||
document.documentElement.className = 'theme-system';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.1",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
72
src/App.tsx
72
src/App.tsx
@@ -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
|
||||
const userRelaysSub = accounts.active$.subscribe((account) => {
|
||||
if (account) {
|
||||
@@ -604,20 +629,6 @@ function App() {
|
||||
// Apply initial set immediately
|
||||
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
|
||||
const blockedPromise = loadBlockedRelays(pool, pubkey)
|
||||
|
||||
@@ -649,43 +660,16 @@ function App() {
|
||||
applyRelaySetToPool(pool, finalRelays)
|
||||
|
||||
updateKeepAlive()
|
||||
|
||||
// Update address loader with new relays
|
||||
const activeRelays = getActiveRelayUrls(pool)
|
||||
const addressLoader = createAddressLoader(pool, {
|
||||
eventStore: store,
|
||||
lookupRelays: activeRelays
|
||||
})
|
||||
store.addressableLoader = addressLoader
|
||||
store.replaceableLoader = addressLoader
|
||||
updateAddressLoader()
|
||||
}).catch((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
|
||||
})
|
||||
} else {
|
||||
// User logged out - reset to hardcoded relays
|
||||
|
||||
applyRelaySetToPool(pool, 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
|
||||
updateKeepAlive(RELAYS)
|
||||
updateAddressLoader(RELAYS)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { nip19 } from 'nostr-tools'
|
||||
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { getActiveRelayUrls } from '../services/relayManager'
|
||||
import { isContentRelay } from '../config/relays'
|
||||
import { isLocalRelay } from '../utils/helpers'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
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 activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
||||
const relayHints = activeRelays.filter(r =>
|
||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||
).slice(0, 3)
|
||||
const relayHints = activeRelays
|
||||
.filter(url => !isLocalRelay(url))
|
||||
.filter(url => isContentRelay(url))
|
||||
.slice(0, 3)
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
|
||||
@@ -595,7 +595,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
case 'highlights':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="explore-grid">
|
||||
<div className="explore-grid single-column">
|
||||
{Array.from({ length: 8 }).map((_, 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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
<div className="explore-grid single-column">
|
||||
{classifiedHighlights.map((highlight) => (
|
||||
<HighlightItem
|
||||
key={highlight.id}
|
||||
|
||||
@@ -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'
|
||||
@@ -10,6 +10,7 @@ import { Hooks } from 'applesauce-react'
|
||||
import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService'
|
||||
import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers'
|
||||
import { getActiveRelayUrls } from '../services/relayManager'
|
||||
import { isContentRelay, getContentRelays, getFallbackContentRelays } from '../config/relays'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { formatDateCompact } from '../utils/bookmarkUtils'
|
||||
import { createDeletionRequest } from '../services/deletionService'
|
||||
@@ -179,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(':')
|
||||
@@ -209,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: {
|
||||
@@ -219,16 +220,57 @@ 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 = () => {
|
||||
// Encode the highlight event itself (kind 9802) as a nevent
|
||||
// Get non-local relays for the hint
|
||||
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
||||
const relayHints = activeRelays.filter(r =>
|
||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||
).slice(0, 3) // Include up to 3 relay hints
|
||||
// Relay hint selection priority:
|
||||
// 1. Published relays (where we successfully published the event)
|
||||
// 2. Seen relays (where we observed the event)
|
||||
// 3. Configured content relays (deterministic fallback)
|
||||
// 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({
|
||||
id: highlight.id,
|
||||
@@ -434,6 +476,71 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
@@ -483,15 +590,37 @@ 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">
|
||||
{highlight.content}
|
||||
<blockquote
|
||||
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>
|
||||
|
||||
{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()}
|
||||
</span>
|
||||
</CompactButton>
|
||||
</div>
|
||||
|
||||
<div className="highlight-menu-wrapper" ref={menuRef}>
|
||||
@@ -565,6 +698,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}
|
||||
|
||||
@@ -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 { 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 { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
@@ -9,6 +9,7 @@ import { HighlightItem } from './HighlightItem'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import CompactButton from './CompactButton'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||
@@ -20,6 +21,7 @@ import { Hooks } from 'applesauce-react'
|
||||
import { readingProgressController } from '../services/readingProgressController'
|
||||
import { writingsController } from '../services/writingsController'
|
||||
import { highlightsController } from '../services/highlightsController'
|
||||
import { getProfileUrl } from '../config/nostrGateways'
|
||||
|
||||
interface ProfileProps {
|
||||
relayPool: RelayPool
|
||||
@@ -38,6 +40,8 @@ const Profile: React.FC<ProfileProps> = ({
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights')
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false)
|
||||
const profileMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Reading progress state (naddr -> progress 0-1)
|
||||
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||
@@ -168,6 +172,68 @@ const Profile: React.FC<ProfileProps> = ({
|
||||
const npub = nip19.npubEncode(pubkey)
|
||||
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 = () => {
|
||||
switch (activeTab) {
|
||||
case 'highlights':
|
||||
@@ -236,7 +302,51 @@ const Profile: React.FC<ProfileProps> = ({
|
||||
pullPosition={pullPosition}
|
||||
/>
|
||||
<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">
|
||||
<button
|
||||
|
||||
@@ -1,21 +1,101 @@
|
||||
import { normalizeRelayUrl } from '../utils/helpers'
|
||||
|
||||
/**
|
||||
* Centralized relay configuration
|
||||
* Single set of relays used throughout the application
|
||||
*/
|
||||
|
||||
// All relays including local relays
|
||||
export const RELAYS = [
|
||||
'ws://localhost:10547',
|
||||
'ws://localhost:4869',
|
||||
'wss://relay.nsec.app',
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.primal.net',
|
||||
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87',
|
||||
export type RelayRole = 'local-cache' | 'default' | 'fallback' | 'non-content' | 'bunker'
|
||||
|
||||
export interface RelayConfig {
|
||||
url: string
|
||||
roles: RelayRole[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Central relay registry with role annotations
|
||||
*/
|
||||
const RELAY_CONFIGS: RelayConfig[] = [
|
||||
{ url: 'ws://localhost:10547', roles: ['local-cache'] },
|
||||
{ url: 'ws://localhost:4869', roles: ['local-cache'] },
|
||||
{ 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ import { nip19 } from 'nostr-tools'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
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 { merge, toArray as rxToArray } from 'rxjs'
|
||||
import { UserSettings } from './settingsService'
|
||||
@@ -138,13 +138,6 @@ export async function fetchArticleByNaddr(
|
||||
|
||||
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
|
||||
const filter = {
|
||||
kinds: [pointer.kind],
|
||||
@@ -152,24 +145,45 @@ export async function fetchArticleByNaddr(
|
||||
'#d': [pointer.identifier]
|
||||
}
|
||||
|
||||
// Parallel local+remote, stream immediate, collect up to first from each
|
||||
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[]
|
||||
let events: 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) {
|
||||
const reliableRelays = Array.from(new Set<string>([
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
...remoteRelays // keep any configured remote relays
|
||||
]))
|
||||
const fallbackRelays = getFallbackContentRelays()
|
||||
const { remote$: fallback$ } = createParallelReqStreams(
|
||||
relayPool,
|
||||
[], // no local
|
||||
reliableRelays,
|
||||
[], // no local for fallback
|
||||
fallbackRelays,
|
||||
filter,
|
||||
1500,
|
||||
12000
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { normalizeRelayUrl } from '../utils/helpers'
|
||||
|
||||
export interface UserRelayInfo {
|
||||
url: string
|
||||
@@ -144,35 +145,55 @@ export function computeRelaySet(params: {
|
||||
alwaysIncludeLocal
|
||||
} = 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 blockedSet = new Set(blocked)
|
||||
const normalizedRelaySet = new Set<string>()
|
||||
|
||||
// Helper to check if relay should be included
|
||||
const shouldInclude = (url: string): boolean => {
|
||||
// Helper to check if relay should be included (using normalized URLs)
|
||||
const shouldInclude = (normalizedUrl: string): boolean => {
|
||||
// Always include local relays
|
||||
if (alwaysIncludeLocal.includes(url)) return true
|
||||
if (normalizedLocal.has(normalizedUrl)) return true
|
||||
// Otherwise check if blocked
|
||||
return !blockedSet.has(url)
|
||||
return !normalizedBlocked.has(normalizedUrl)
|
||||
}
|
||||
|
||||
// Add hardcoded relays
|
||||
// Add hardcoded relays (normalized)
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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)
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
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
|
||||
*/
|
||||
export const ALWAYS_LOCAL_RELAYS = [
|
||||
'ws://localhost:10547',
|
||||
'ws://localhost:4869'
|
||||
]
|
||||
export const ALWAYS_LOCAL_RELAYS = getLocalRelays()
|
||||
|
||||
/**
|
||||
* 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 = [
|
||||
'wss://relay.nostr.band'
|
||||
]
|
||||
export const HARDCODED_RELAYS = getFallbackContentRelays()
|
||||
|
||||
/**
|
||||
* Gets active relay URLs from the relay pool
|
||||
@@ -24,76 +21,84 @@ export function getActiveRelayUrls(relayPool: RelayPool): string[] {
|
||||
return prioritizeLocalRelays(urls)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a relay URL to match what applesauce-relay stores internally
|
||||
* Adds trailing slash for URLs without a path
|
||||
*/
|
||||
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
|
||||
}
|
||||
export interface RelaySetChangeSummary {
|
||||
added: string[]
|
||||
removed: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
relayPool: RelayPool,
|
||||
finalUrls: string[]
|
||||
): void {
|
||||
// Normalize all URLs to match pool's internal format
|
||||
const currentUrls = new Set(Array.from(relayPool.relays.keys()))
|
||||
const normalizedTargetUrls = new Set(finalUrls.map(normalizeRelayUrl))
|
||||
|
||||
finalUrls: string[],
|
||||
options?: { preserveAlwaysLocal?: boolean }
|
||||
): RelaySetChangeSummary {
|
||||
const preserveLocal = options?.preserveAlwaysLocal !== false // default true
|
||||
|
||||
|
||||
// Add new relays (use original URLs for adding, not normalized)
|
||||
const toAdd = finalUrls.filter(url => !currentUrls.has(normalizeRelayUrl(url)))
|
||||
// Ensure local relays are always included
|
||||
const urlsWithLocal = preserveLocal
|
||||
? Array.from(new Set([...finalUrls, ...ALWAYS_LOCAL_RELAYS]))
|
||||
: finalUrls
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
relayPool.group(toAdd)
|
||||
// Normalize all URLs consistently for comparison
|
||||
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[] = []
|
||||
for (const url of currentUrls) {
|
||||
// Check if this normalized URL is in the target set
|
||||
if (!normalizedTargetUrls.has(url)) {
|
||||
// Also check if it's a local relay (check both normalized and original forms)
|
||||
const isLocal = ALWAYS_LOCAL_RELAYS.some(localUrl =>
|
||||
normalizeRelayUrl(localUrl) === url || localUrl === url
|
||||
)
|
||||
if (!isLocal) {
|
||||
toRemove.push(url)
|
||||
for (const currentUrl of relayPool.relays.keys()) {
|
||||
const normalizedCurrentUrl = normalizeRelayUrl(currentUrl)
|
||||
if (!normalizedTarget.has(normalizedCurrentUrl)) {
|
||||
// Always preserve local relays
|
||||
if (!preserveLocal || !normalizedLocal.has(normalizedCurrentUrl)) {
|
||||
toRemove.push(currentUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Apply changes
|
||||
if (toAdd.length > 0) {
|
||||
relayPool.group(toAdd)
|
||||
}
|
||||
|
||||
for (const url of toRemove) {
|
||||
const relay = relayPool.relays.get(url)
|
||||
if (relay) {
|
||||
try {
|
||||
// Only close if relay is actually connected or attempting to connect
|
||||
// This helps avoid WebSocket warnings for connections that never started
|
||||
relay.close()
|
||||
} catch (error) {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -263,7 +263,12 @@
|
||||
.large-read-button:hover { background: var(--color-primary-hover); }
|
||||
|
||||
/* 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 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; }
|
||||
@@ -272,7 +277,15 @@
|
||||
.explore-loading { min-height: 0; padding: 0.25rem 0; }
|
||||
.explore-error { color: rgb(239 68 68); /* red-500 */ }
|
||||
.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: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); }
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
|
||||
/* Align highlight list width with profile card width on /my */
|
||||
.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 */
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -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-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) {
|
||||
.author-card-container {
|
||||
padding: 1.5rem 1rem;
|
||||
@@ -26,5 +91,13 @@
|
||||
.author-card-avatar svg { font-size: 2rem; }
|
||||
.author-card-name { font-size: 0.95rem; }
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
@@ -149,7 +149,40 @@
|
||||
.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-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-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; }
|
||||
@@ -177,7 +210,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; }
|
||||
|
||||
@@ -39,6 +39,24 @@ export const classifyUrl = (url: string | undefined): UrlClassification => {
|
||||
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)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user