Compare commits

...

15 Commits

Author SHA1 Message Date
Gigi
4ea03c9042 chore: bump version to 0.2.10 2025-10-09 12:33:38 +01:00
Gigi
4720416f2c fix(settings): remove trailing slash from relay URLs
- Update formatRelayUrl to strip trailing / from URLs
- Cleaner display of relay addresses
2025-10-09 12:31:30 +01:00
Gigi
8ad9e652fb feat(settings): highlight active zap split preset
- Add isPresetActive function to detect current preset
- Add 'active' class to preset button matching current weights
- Organize presets in a central object for easier maintenance
- Users can now see which preset is currently applied
2025-10-09 12:31:02 +01:00
Gigi
98c72389e2 refactor(settings): rename 'Default View Mode' to 'Default Bookmark View'
- More descriptive label clarifying this controls bookmark display
- Better indicates what view is being configured
2025-10-09 12:30:18 +01:00
Gigi
e032f432dd refactor(settings): move Show highlights checkbox after Default Highlight Visibility
- Reorder settings for better logical flow
- Show highlights toggle now appears after visibility controls
- Positioned right before the preview section
2025-10-09 12:29:49 +01:00
Gigi
852465bee7 fix(settings): constrain Reading Font dropdown width
- Wrap FontSelector in setting-control div
- Prevents dropdown from stretching across full page width
- Matches layout of other inline controls like color pickers
2025-10-09 12:29:13 +01:00
Gigi
39d0147cfa feat(routing): add /settings route and URL-based settings navigation
- Add /settings route to router
- Derive showSettings from location.pathname instead of state
- Update onOpenSettings to navigate to /settings
- Update onCloseSettings to navigate back to previous location
- Track previous location to restore context when closing settings
- Remove showSettings state from useBookmarksUI hook
2025-10-09 12:27:43 +01:00
Gigi
10cc7ce9b0 refactor(settings): move Default Highlight Visibility to Reading & Display
- Move setting from Startup Preferences to Reading & Display section
- Position above preview so changes are immediately visible
- Update preview to respect defaultHighlightVisibility settings
- Each highlight level (mine/friends/nostrverse) now toggles in preview
2025-10-09 12:24:50 +01:00
Gigi
6b8442ebdd refactor(settings): combine relay info into single paragraph
- Merge two separate paragraphs into one continuous text
- Remove line break between relay recommendations and educational links
2025-10-09 12:23:40 +01:00
Gigi
5aba283e92 refactor(settings): use sidebar-style colored buttons for highlight visibility
- Replace generic IconButton with colored level-toggle-btn elements
- Match the UI style from HighlightsPanelHeader in sidebar
- Show highlight colors (purple, orange, yellow) when active
- Use same CSS classes and structure for visual consistency
2025-10-09 12:23:05 +01:00
Gigi
59df232e2e refactor(settings): simplify Relays section by removing summary text
- Remove 'X active relays' summary count
- Remove 'Active' heading for cleaner UI
- Keep 'Recently Seen' heading for context since those are different
2025-10-09 12:21:36 +01:00
Gigi
702c001d46 feat(settings): add educational links about relays in reader view
- Add message with links to learn about relays (nostr.how and substack article)
- Links open in Boris's reader view via /r/ route instead of external tabs
- Close settings panel when links are clicked to show the content
- Use react-router navigation for seamless in-app experience
2025-10-09 12:21:09 +01:00
Gigi
48a9919db8 feat(reader): display article publication date
- Add published field to ReadableContent interface
- Pass published date from article loader through component chain
- Display formatted publication date in ReaderHeader with calendar icon
- Format date as 'MMMM d, yyyy' using date-fns
2025-10-09 12:15:28 +01:00
Gigi
d6d0755b89 feat(settings): add local relay recommendations to Relays section
- Add informational message recommending Citrine or nostr-relay-tray
- Include direct links to download pages for both local relay options
2025-10-09 12:13:41 +01:00
Gigi
facdd36145 feat(settings): add Relays section showing active and recently connected relays
- Add relayStatusService to track relay connections with 20-minute history
- Add useRelayStatus hook for polling relay status updates
- Create RelaySettings component to display active and recent relays
- Update Settings and ThreePaneLayout to integrate relay status display
- Shows relay connection status with visual indicators and timestamps
2025-10-09 12:09:53 +01:00
17 changed files with 447 additions and 72 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "boris", "name": "boris",
"version": "0.2.9", "version": "0.2.10",
"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

@@ -52,6 +52,15 @@ function AppRoutes({
/> />
} }
/> />
<Route
path="/settings"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} /> <Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes> </Routes>
) )

View File

@@ -1,5 +1,5 @@
import React, { useMemo } from 'react' import React, { useMemo, useEffect, useRef } from 'react'
import { useParams, useLocation } from 'react-router-dom' import { useParams, useLocation, useNavigate } from 'react-router-dom'
import { Hooks } from 'applesauce-react' import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks' import { useEventStore } from 'applesauce-react/hooks'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
@@ -23,10 +23,21 @@ interface BookmarksProps {
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => { const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const { naddr } = useParams<{ naddr?: string }>() const { naddr } = useParams<{ naddr?: string }>()
const location = useLocation() const location = useLocation()
const navigate = useNavigate()
const previousLocationRef = useRef<string>()
const externalUrl = location.pathname.startsWith('/r/') const externalUrl = location.pathname.startsWith('/r/')
? decodeURIComponent(location.pathname.slice(3)) ? decodeURIComponent(location.pathname.slice(3))
: undefined : undefined
const showSettings = location.pathname === '/settings'
// Track previous location for going back from settings
useEffect(() => {
if (!showSettings) {
previousLocationRef.current = location.pathname
}
}, [location.pathname, showSettings])
const activeAccount = Hooks.useActiveAccount() const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager() const accountManager = Hooks.useAccountManager()
@@ -50,8 +61,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setShowHighlights, setShowHighlights,
selectedHighlightId, selectedHighlightId,
setSelectedHighlightId, setSelectedHighlightId,
showSettings,
setShowSettings,
currentArticleCoordinate, currentArticleCoordinate,
setCurrentArticleCoordinate, setCurrentArticleCoordinate,
currentArticleEventId, currentArticleEventId,
@@ -94,7 +103,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
relayPool, relayPool,
settings, settings,
setIsCollapsed, setIsCollapsed,
setShowSettings, setShowSettings: () => {}, // No-op since we use route-based settings now
setCurrentArticle setCurrentArticle
}) })
@@ -160,7 +169,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
onLogout={onLogout} onLogout={onLogout}
onViewModeChange={setViewMode} onViewModeChange={setViewMode}
onOpenSettings={() => { onOpenSettings={() => {
setShowSettings(true) navigate('/settings')
setIsCollapsed(true) setIsCollapsed(true)
setIsHighlightsCollapsed(true) setIsHighlightsCollapsed(true)
}} }}
@@ -171,7 +180,11 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
selectedUrl={selectedUrl} selectedUrl={selectedUrl}
settings={settings} settings={settings}
onSaveSettings={saveSettings} onSaveSettings={saveSettings}
onCloseSettings={() => setShowSettings(false)} onCloseSettings={() => {
// Navigate back to previous location or default
const backTo = previousLocationRef.current || '/'
navigate(backTo)
}}
classifiedHighlights={classifiedHighlights} classifiedHighlights={classifiedHighlights}
showHighlights={showHighlights} showHighlights={showHighlights}
selectedHighlightId={selectedHighlightId} selectedHighlightId={selectedHighlightId}

View File

@@ -20,6 +20,7 @@ interface ContentPanelProps {
selectedUrl?: string selectedUrl?: string
image?: string image?: string
summary?: string summary?: string
published?: number
highlights?: Highlight[] highlights?: Highlight[]
showHighlights?: boolean showHighlights?: boolean
highlightStyle?: 'marker' | 'underline' highlightStyle?: 'marker' | 'underline'
@@ -42,6 +43,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
selectedUrl, selectedUrl,
image, image,
summary, summary,
published,
highlights = [], highlights = [],
showHighlights = true, showHighlights = true,
highlightStyle = 'marker', highlightStyle = 'marker',
@@ -120,6 +122,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
title={title} title={title}
image={image} image={image}
summary={summary} summary={summary}
published={published}
readingTimeText={readingStats ? readingStats.text : null} readingTimeText={readingStats ? readingStats.text : null}
hasHighlights={hasHighlights} hasHighlights={hasHighlights}
highlightCount={relevantHighlights.length} highlightCount={relevantHighlights.length}

View File

@@ -1,11 +1,13 @@
import React from 'react' import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons' import { faHighlighter, faClock, faCalendar } from '@fortawesome/free-solid-svg-icons'
import { format } from 'date-fns'
interface ReaderHeaderProps { interface ReaderHeaderProps {
title?: string title?: string
image?: string image?: string
summary?: string summary?: string
published?: number
readingTimeText?: string | null readingTimeText?: string | null
hasHighlights: boolean hasHighlights: boolean
highlightCount: number highlightCount: number
@@ -15,10 +17,12 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
title, title,
image, image,
summary, summary,
published,
readingTimeText, readingTimeText,
hasHighlights, hasHighlights,
highlightCount highlightCount
}) => { }) => {
const formattedDate = published ? format(new Date(published * 1000), 'MMMM d, yyyy') : null
if (image) { if (image) {
return ( return (
<div className="reader-hero-image"> <div className="reader-hero-image">
@@ -28,6 +32,12 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
<h2 className="reader-title">{title}</h2> <h2 className="reader-title">{title}</h2>
{summary && <p className="reader-summary">{summary}</p>} {summary && <p className="reader-summary">{summary}</p>}
<div className="reader-meta"> <div className="reader-meta">
{formattedDate && (
<div className="publish-date">
<FontAwesomeIcon icon={faCalendar} />
<span>{formattedDate}</span>
</div>
)}
{readingTimeText && ( {readingTimeText && (
<div className="reading-time"> <div className="reading-time">
<FontAwesomeIcon icon={faClock} /> <FontAwesomeIcon icon={faClock} />
@@ -54,6 +64,12 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
<h2 className="reader-title">{title}</h2> <h2 className="reader-title">{title}</h2>
{summary && <p className="reader-summary">{summary}</p>} {summary && <p className="reader-summary">{summary}</p>}
<div className="reader-meta"> <div className="reader-meta">
{formattedDate && (
<div className="publish-date">
<FontAwesomeIcon icon={faCalendar} />
<span>{formattedDate}</span>
</div>
)}
{readingTimeText && ( {readingTimeText && (
<div className="reading-time"> <div className="reading-time">
<FontAwesomeIcon icon={faClock} /> <FontAwesomeIcon icon={faClock} />

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { faTimes, faUndo } from '@fortawesome/free-solid-svg-icons' import { faTimes, faUndo } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { UserSettings } from '../services/settingsService' import { UserSettings } from '../services/settingsService'
import IconButton from './IconButton' import IconButton from './IconButton'
import { loadFont } from '../utils/fontLoader' import { loadFont } from '../utils/fontLoader'
@@ -7,6 +8,8 @@ import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings' import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings' import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
import ZapSettings from './Settings/ZapSettings' import ZapSettings from './Settings/ZapSettings'
import RelaySettings from './Settings/RelaySettings'
import { useRelayStatus } from '../hooks/useRelayStatus'
const DEFAULT_SETTINGS: UserSettings = { const DEFAULT_SETTINGS: UserSettings = {
collapseOnArticleOpen: true, collapseOnArticleOpen: true,
@@ -33,9 +36,10 @@ interface SettingsProps {
settings: UserSettings settings: UserSettings
onSave: (settings: UserSettings) => Promise<void> onSave: (settings: UserSettings) => Promise<void>
onClose: () => void onClose: () => void
relayPool: RelayPool | null
} }
const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => { const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPool }) => {
const [localSettings, setLocalSettings] = useState<UserSettings>(() => { const [localSettings, setLocalSettings] = useState<UserSettings>(() => {
// Migrate old settings format to new weight-based format // Migrate old settings format to new weight-based format
const migrated = { ...settings } const migrated = { ...settings }
@@ -52,6 +56,8 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
const isInitialMount = useRef(true) const isInitialMount = useRef(true)
const saveTimeoutRef = useRef<number | null>(null) const saveTimeoutRef = useRef<number | null>(null)
const isLocallyUpdating = useRef(false) const isLocallyUpdating = useRef(false)
const relayStatuses = useRelayStatus({ relayPool })
useEffect(() => { useEffect(() => {
// Don't update from external settings if we're currently making local changes // Don't update from external settings if we're currently making local changes
@@ -152,6 +158,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} /> <LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} /> <StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
<ZapSettings settings={localSettings} onUpdate={handleUpdate} /> <ZapSettings settings={localSettings} onUpdate={handleUpdate} />
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
</div> </div>
</div> </div>
) )

View File

@@ -14,7 +14,7 @@ const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ set
<h3 className="section-title">Layout & Navigation</h3> <h3 className="section-title">Layout & Navigation</h3>
<div className="setting-group setting-inline"> <div className="setting-group setting-inline">
<label>Default View Mode</label> <label>Default Bookmark View</label>
<div className="setting-buttons"> <div className="setting-buttons">
<IconButton <IconButton
icon={faList} icon={faList}

View File

@@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import { faHighlighter, faUnderline } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService' import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton' import IconButton from '../IconButton'
import ColorPicker from '../ColorPicker' import ColorPicker from '../ColorPicker'
@@ -21,10 +22,12 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
<div className="setting-group setting-inline"> <div className="setting-group setting-inline">
<label htmlFor="readingFont">Reading Font</label> <label htmlFor="readingFont">Reading Font</label>
<FontSelector <div className="setting-control">
value={settings.readingFont || 'source-serif-4'} <FontSelector
onChange={(font) => onUpdate({ readingFont: font })} value={settings.readingFont || 'source-serif-4'}
/> onChange={(font) => onUpdate({ readingFont: font })}
/>
</div>
</div> </div>
<div className="setting-group setting-inline"> <div className="setting-group setting-inline">
@@ -44,19 +47,6 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
</div> </div>
</div> </div>
<div className="setting-group">
<label htmlFor="showHighlights" className="checkbox-label">
<input
id="showHighlights"
type="checkbox"
checked={settings.showHighlights !== false}
onChange={(e) => onUpdate({ showHighlights: e.target.checked })}
className="setting-checkbox"
/>
<span>Show highlights</span>
</label>
</div>
<div className="setting-group setting-inline"> <div className="setting-group setting-inline">
<label>Highlight Style</label> <label>Highlight Style</label>
<div className="setting-buttons"> <div className="setting-buttons">
@@ -107,6 +97,52 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
</div> </div>
</div> </div>
<div className="setting-group setting-inline">
<label>Default Highlight Visibility</label>
<div className="highlight-level-toggles">
<button
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityNostrverse !== false) ? 'active' : ''}`}
title="Nostrverse highlights"
aria-label="Toggle nostrverse highlights by default"
style={{ color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
>
<FontAwesomeIcon icon={faNetworkWired} />
</button>
<button
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityFriends !== false) ? 'active' : ''}`}
title="Friends highlights"
aria-label="Toggle friends highlights by default"
style={{ color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined }}
>
<FontAwesomeIcon icon={faUserGroup} />
</button>
<button
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityMine !== false) ? 'active' : ''}`}
title="My highlights"
aria-label="Toggle my highlights by default"
style={{ color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined }}
>
<FontAwesomeIcon icon={faUser} />
</button>
</div>
</div>
<div className="setting-group">
<label htmlFor="showHighlights" className="checkbox-label">
<input
id="showHighlights"
type="checkbox"
checked={settings.showHighlights !== false}
onChange={(e) => onUpdate({ showHighlights: e.target.checked })}
className="setting-checkbox"
/>
<span>Show highlights</span>
</label>
</div>
<div className="setting-preview"> <div className="setting-preview">
<div className="preview-label">Preview</div> <div className="preview-label">Preview</div>
<div <div
@@ -118,9 +154,9 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
} as React.CSSProperties} } as React.CSSProperties}
> >
<h3>The Quick Brown Fox</h3> <h3>The Quick Brown Fox</h3>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={settings.showHighlights !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p> <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityMine !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={settings.showHighlights !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p> <p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityFriends !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={settings.showHighlights !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p> <p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityNostrverse !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p> <p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,204 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheckCircle, faCircle, faClock } from '@fortawesome/free-solid-svg-icons'
import { RelayStatus } from '../../services/relayStatusService'
import { formatDistanceToNow } from 'date-fns'
interface RelaySettingsProps {
relayStatuses: RelayStatus[]
onClose?: () => void
}
const RelaySettings: React.FC<RelaySettingsProps> = ({ relayStatuses, onClose }) => {
const navigate = useNavigate()
const activeRelays = relayStatuses.filter(r => r.isInPool)
const recentRelays = relayStatuses.filter(r => !r.isInPool)
const handleLinkClick = (url: string) => {
if (onClose) onClose()
navigate(`/r/${encodeURIComponent(url)}`)
}
const formatRelayUrl = (url: string) => {
return url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
}
const formatLastSeen = (timestamp: number) => {
try {
return formatDistanceToNow(timestamp, { addSuffix: true })
} catch {
return 'just now'
}
}
return (
<div className="settings-section">
<h3>Relays</h3>
{activeRelays.length > 0 && (
<div className="relay-group" style={{ marginBottom: '1.5rem' }}>
<div className="relay-list">
{activeRelays.map((relay) => (
<div
key={relay.url}
className="relay-item"
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem',
background: 'var(--surface-secondary)',
borderRadius: '6px',
marginBottom: '0.5rem'
}}
>
<FontAwesomeIcon
icon={faCheckCircle}
style={{
color: 'var(--success, #22c55e)',
fontSize: '1rem'
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '0.9rem',
fontFamily: 'var(--font-mono, monospace)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{formatRelayUrl(relay.url)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{recentRelays.length > 0 && (
<div className="relay-group">
<h4 style={{
fontSize: '0.85rem',
fontWeight: 600,
color: 'var(--text-secondary)',
marginBottom: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
Recently Seen
</h4>
<div className="relay-list">
{recentRelays.map((relay) => (
<div
key={relay.url}
className="relay-item"
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem',
background: 'var(--surface-secondary)',
borderRadius: '6px',
marginBottom: '0.5rem',
opacity: 0.7
}}
>
<FontAwesomeIcon
icon={faCircle}
style={{
color: 'var(--text-tertiary, #6b7280)',
fontSize: '0.7rem'
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '0.9rem',
fontFamily: 'var(--font-mono, monospace)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{formatRelayUrl(relay.url)}
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontSize: '0.8rem',
color: 'var(--text-tertiary)',
whiteSpace: 'nowrap'
}}>
<FontAwesomeIcon icon={faClock} />
{formatLastSeen(relay.lastSeen)}
</div>
</div>
))}
</div>
</div>
)}
{relayStatuses.length === 0 && (
<p style={{ color: 'var(--text-secondary)', fontStyle: 'italic' }}>
No relay connections found
</p>
)}
<div style={{
marginTop: '1.5rem',
padding: '1rem',
background: 'var(--surface-secondary)',
borderRadius: '6px',
fontSize: '0.9rem',
lineHeight: '1.6'
}}>
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
Boris works best with a local relay. Consider running{' '}
<a
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent, #8b5cf6)' }}
>
Citrine
</a>
{' or '}
<a
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent, #8b5cf6)' }}
>
nostr-relay-tray
</a>
. Don't know what relays are? Learn more{' '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('https://nostr.how/en/relays')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
{' and '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
.
</p>
</div>
</div>
)
}
export default RelaySettings

View File

@@ -1,7 +1,5 @@
import React from 'react' import React from 'react'
import { faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService' import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
interface StartupPreferencesSettingsProps { interface StartupPreferencesSettingsProps {
settings: UserSettings settings: UserSettings
@@ -38,33 +36,6 @@ const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({
<span>Start with highlights panel collapsed</span> <span>Start with highlights panel collapsed</span>
</label> </label>
</div> </div>
<div className="setting-group setting-inline">
<label>Default Highlight Visibility</label>
<div className="setting-buttons">
<IconButton
icon={faNetworkWired}
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
title="Nostrverse highlights"
ariaLabel="Toggle nostrverse highlights by default"
variant={(settings.defaultHighlightVisibilityNostrverse !== false) ? 'primary' : 'ghost'}
/>
<IconButton
icon={faUserGroup}
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
title="Friends highlights"
ariaLabel="Toggle friends highlights by default"
variant={(settings.defaultHighlightVisibilityFriends !== false) ? 'primary' : 'ghost'}
/>
<IconButton
icon={faUser}
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
title="My highlights"
ariaLabel="Toggle my highlights by default"
variant={(settings.defaultHighlightVisibilityMine !== false) ? 'primary' : 'ghost'}
/>
</div>
</div>
</div> </div>
) )
} }

View File

@@ -17,6 +17,19 @@ const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
const borisPercentage = totalWeight > 0 ? (borisWeight / totalWeight) * 100 : 0 const borisPercentage = totalWeight > 0 ? (borisWeight / totalWeight) * 100 : 0
const authorPercentage = totalWeight > 0 ? (authorWeight / totalWeight) * 100 : 0 const authorPercentage = totalWeight > 0 ? (authorWeight / totalWeight) * 100 : 0
const presets = {
default: { highlighter: 50, boris: 2.1, author: 50 },
generous: { highlighter: 5, boris: 10, author: 75 },
selfless: { highlighter: 1, boris: 19, author: 80 },
boris: { highlighter: 10, boris: 80, author: 10 },
}
const isPresetActive = (preset: { highlighter: number; boris: number; author: number }) => {
return highlighterWeight === preset.highlighter &&
borisWeight === preset.boris &&
authorWeight === preset.author
}
const applyPreset = (preset: { highlighter: number; boris: number; author: number }) => { const applyPreset = (preset: { highlighter: number; boris: number; author: number }) => {
onUpdate({ onUpdate({
zapSplitHighlighterWeight: preset.highlighter, zapSplitHighlighterWeight: preset.highlighter,
@@ -33,29 +46,29 @@ const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
<label className="setting-label">Presets</label> <label className="setting-label">Presets</label>
<div className="zap-preset-buttons"> <div className="zap-preset-buttons">
<button <button
onClick={() => applyPreset({ highlighter: 50, boris: 2.1, author: 50 })} onClick={() => applyPreset(presets.default)}
className="zap-preset-btn" className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
title="You: 49%, Author: 49%, Boris: 2%" title="You: 49%, Author: 49%, Boris: 2%"
> >
Default Default
</button> </button>
<button <button
onClick={() => applyPreset({ highlighter: 5, boris: 10, author: 75 })} onClick={() => applyPreset(presets.generous)}
className="zap-preset-btn" className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
title="You: 6%, Author: 83%, Boris: 11%" title="You: 6%, Author: 83%, Boris: 11%"
> >
Generous Generous
</button> </button>
<button <button
onClick={() => applyPreset({ highlighter: 1, boris: 19, author: 80 })} onClick={() => applyPreset(presets.selfless)}
className="zap-preset-btn" className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
title="You: 1%, Author: 80%, Boris: 19%" title="You: 1%, Author: 80%, Boris: 19%"
> >
Selfless Selfless
</button> </button>
<button <button
onClick={() => applyPreset({ highlighter: 10, boris: 80, author: 10 })} onClick={() => applyPreset(presets.boris)}
className="zap-preset-btn" className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
title="You: 10%, Author: 10%, Boris: 80%" title="You: 10%, Author: 10%, Boris: 80%"
> >
Boris 🧡 Boris 🧡

View File

@@ -97,6 +97,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
settings={props.settings} settings={props.settings}
onSave={props.onSaveSettings} onSave={props.onSaveSettings}
onClose={props.onCloseSettings} onClose={props.onCloseSettings}
relayPool={props.relayPool}
/> />
) : ( ) : (
<ContentPanel <ContentPanel
@@ -106,6 +107,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
markdown={props.readerContent?.markdown} markdown={props.readerContent?.markdown}
image={props.readerContent?.image} image={props.readerContent?.image}
summary={props.readerContent?.summary} summary={props.readerContent?.summary}
published={props.readerContent?.published}
selectedUrl={props.selectedUrl} selectedUrl={props.selectedUrl}
highlights={props.classifiedHighlights} highlights={props.classifiedHighlights}
showHighlights={props.showHighlights} showHighlights={props.showHighlights}

View File

@@ -50,6 +50,7 @@ export function useArticleLoader({
markdown: article.markdown, markdown: article.markdown,
image: article.image, image: article.image,
summary: article.summary, summary: article.summary,
published: article.published,
url: `nostr:${naddr}` url: `nostr:${naddr}`
}) })

View File

@@ -14,7 +14,6 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
const [viewMode, setViewMode] = useState<ViewMode>('compact') const [viewMode, setViewMode] = useState<ViewMode>('compact')
const [showHighlights, setShowHighlights] = useState(true) const [showHighlights, setShowHighlights] = useState(true)
const [selectedHighlightId, setSelectedHighlightId] = useState<string | undefined>(undefined) const [selectedHighlightId, setSelectedHighlightId] = useState<string | undefined>(undefined)
const [showSettings, setShowSettings] = useState(false)
const [currentArticleCoordinate, setCurrentArticleCoordinate] = useState<string | undefined>(undefined) const [currentArticleCoordinate, setCurrentArticleCoordinate] = useState<string | undefined>(undefined)
const [currentArticleEventId, setCurrentArticleEventId] = useState<string | undefined>(undefined) const [currentArticleEventId, setCurrentArticleEventId] = useState<string | undefined>(undefined)
const [currentArticle, setCurrentArticle] = useState<NostrEvent | undefined>(undefined) const [currentArticle, setCurrentArticle] = useState<NostrEvent | undefined>(undefined)
@@ -46,8 +45,6 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
setShowHighlights, setShowHighlights,
selectedHighlightId, selectedHighlightId,
setSelectedHighlightId, setSelectedHighlightId,
showSettings,
setShowSettings,
currentArticleCoordinate, currentArticleCoordinate,
setCurrentArticleCoordinate, setCurrentArticleCoordinate,
currentArticleEventId, currentArticleEventId,

View File

@@ -0,0 +1,37 @@
import { useState, useEffect } from 'react'
import { RelayPool } from 'applesauce-relay'
import { RelayStatus, updateAndGetRelayStatuses } from '../services/relayStatusService'
interface UseRelayStatusParams {
relayPool: RelayPool | null
pollingInterval?: number // in milliseconds
}
export function useRelayStatus({
relayPool,
pollingInterval = 5000
}: UseRelayStatusParams) {
const [relayStatuses, setRelayStatuses] = useState<RelayStatus[]>([])
useEffect(() => {
if (!relayPool) return
const updateStatuses = () => {
const statuses = updateAndGetRelayStatuses(relayPool)
setRelayStatuses(statuses)
}
// Initial update
updateStatuses()
// Poll for updates
const interval = setInterval(updateStatuses, pollingInterval)
return () => {
clearInterval(interval)
}
}, [relayPool, pollingInterval])
return relayStatuses
}

View File

@@ -8,6 +8,7 @@ export interface ReadableContent {
markdown?: string markdown?: string
image?: string image?: string
summary?: string summary?: string
published?: number
} }
interface CachedContent { interface CachedContent {

View File

@@ -0,0 +1,65 @@
import { RelayPool } from 'applesauce-relay'
export interface RelayStatus {
url: string
isInPool: boolean
lastSeen: number // timestamp
}
const RECENT_CONNECTION_WINDOW = 20 * 60 * 1000 // 20 minutes
// In-memory tracking of relay last seen times
const relayLastSeen = new Map<string, number>()
/**
* Updates and gets the current status of all relays
*/
export function updateAndGetRelayStatuses(relayPool: RelayPool): RelayStatus[] {
const statuses: RelayStatus[] = []
const now = Date.now()
const currentRelayUrls = new Set<string>()
// Update relays currently in the pool
for (const relay of relayPool.relays.values()) {
currentRelayUrls.add(relay.url)
relayLastSeen.set(relay.url, now)
statuses.push({
url: relay.url,
isInPool: true,
lastSeen: now
})
}
// Add recently seen relays that are no longer in the pool
const cutoffTime = now - RECENT_CONNECTION_WINDOW
for (const [url, lastSeen] of relayLastSeen.entries()) {
if (!currentRelayUrls.has(url) && lastSeen >= cutoffTime) {
statuses.push({
url,
isInPool: false,
lastSeen
})
}
}
// Clean up old entries
for (const [url, lastSeen] of relayLastSeen.entries()) {
if (lastSeen < cutoffTime) {
relayLastSeen.delete(url)
}
}
return statuses.sort((a, b) => {
if (a.isInPool !== b.isInPool) return a.isInPool ? -1 : 1
return b.lastSeen - a.lastSeen
})
}
/**
* Gets count of currently active relays
*/
export function getActiveCount(statuses: RelayStatus[]): number {
return statuses.filter(r => r.isInPool).length
}