Compare commits

...

9 Commits

Author SHA1 Message Date
Gigi
c536de0144 chore: bump version to 0.1.9 2025-10-05 19:07:06 +01:00
Gigi
8e0970b717 fix: show markdown content immediately when finalHtml is empty
- Render markdown directly with ReactMarkdown when finalHtml is not ready yet
- Prevents empty content display while markdown is being converted to HTML
- Fixes issue where default article text doesn't show
2025-10-05 19:06:53 +01:00
Gigi
560a4a6785 chore: bump version to 0.1.8 2025-10-05 13:46:27 +01:00
Gigi
320e7f000a fix: prevent 'No readable content' flash for markdown articles
- Check for markdown/html existence before checking finalHtml
- Show empty container while markdown is being converted to HTML
- Fixes issue where nostr blog posts briefly showed error message
2025-10-05 13:34:38 +01:00
Gigi
832740fb59 fix: enable highlights display and scroll-to for markdown content
- Convert markdown to HTML before applying highlights
- Use hidden ReactMarkdown preview to render markdown
- Apply highlights to rendered HTML for both HTML and markdown content
- Fix scroll-to-highlight functionality for nostr blog posts (kind:30023)
- Ensure highlight marks are properly injected into markdown-rendered content
2025-10-05 13:28:49 +01:00
Gigi
4aea7b899b feat: persist accounts to localStorage
- Register common account types for deserialization
- Load persisted accounts and active account on app init
- Subscribe to account changes and save to localStorage
- Add cleanup for subscriptions on unmount
2025-10-05 13:26:28 +01:00
Gigi
43492a4488 refactor: simplify login by handling it directly in sidebar
Instead of navigating to /login route, login now happens directly when
clicking the login button in the sidebar header.

Changes:
- Moved login logic from Login component to SidebarHeader
- Uses Accounts.ExtensionAccount.fromExtension() directly
- Removed onLogin prop chain (App → Bookmarks → BookmarkList)
- Removed unnecessary BookmarksRoute wrapper component
- Shows 'Connecting...' state in button title during login
- Keeps code DRY by reusing same login logic without navigation

Result: Simpler, more direct user experience - one click to log in
from anywhere in the app.
2025-10-05 13:17:22 +01:00
Gigi
1552dd85d9 feat: show login button when logged out instead of logout button
- Added onLogin prop to Bookmarks, BookmarkList, and SidebarHeader
- SidebarHeader now conditionally renders login or logout button
- Login button uses faRightToBracket icon
- Logout button uses faRightFromBracket icon
- Clicking login button navigates to /login route
- Created BookmarksRoute wrapper to handle navigation
- Better UX for anonymous users browsing articles
2025-10-05 13:12:32 +01:00
Gigi
0bc89889e0 feat: show highlights in article content and add mode toggle
Fixes:
- Fixed highlight filtering for Nostr articles in urlHelpers.ts
  Now returns all highlights for nostr: URLs since they're pre-filtered
- This fixes highlights not appearing in article content

Features:
- Added highlight mode toggle: 'my highlights' vs 'other highlights'
- Icons: faUser (mine) and faUserGroup (others)
- Mode toggle only shows when user is logged in
- Filters highlights by user pubkey based on selected mode
- Default mode is 'others' to show community highlights
- Added CSS styling for mode toggle buttons

Result: Highlights now show both in the panel AND underlined in
the article text. Users can switch between viewing their own
highlights vs highlights from others.
2025-10-05 12:57:09 +01:00
9 changed files with 264 additions and 102 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.1.7",
"version": "0.1.9",
"description": "A minimal nostr client for bookmark management",
"type": "module",
"scripts": {

View File

@@ -3,12 +3,12 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { EventStoreProvider, AccountsProvider } from 'applesauce-react'
import { EventStore } from 'applesauce-core'
import { AccountManager } from 'applesauce-accounts'
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
import { RelayPool } from 'applesauce-relay'
import { createAddressLoader } from 'applesauce-loaders/loaders'
import Login from './components/Login'
import Bookmarks from './components/Bookmarks'
// Load default article from environment variable with fallback
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
@@ -21,6 +21,44 @@ function App() {
// Initialize event store, account manager, and relay pool
const store = new EventStore()
const accounts = new AccountManager()
// Register common account types (needed for deserialization)
registerCommonAccountTypes(accounts)
// Load persisted accounts from localStorage
const loadAccounts = async () => {
try {
const json = JSON.parse(localStorage.getItem('accounts') || '[]')
await accounts.fromJSON(json)
console.log('Loaded', accounts.accounts.length, 'accounts from storage')
// Load active account from storage
const activeId = localStorage.getItem('active')
if (activeId && accounts.getAccount(activeId)) {
accounts.setActive(activeId)
console.log('Restored active account:', activeId)
}
} catch (err) {
console.error('Failed to load accounts from storage:', err)
}
}
loadAccounts()
// Subscribe to accounts changes and persist to localStorage
const accountsSub = accounts.accounts$.subscribe(() => {
localStorage.setItem('accounts', JSON.stringify(accounts.toJSON()))
})
// Subscribe to active account changes and persist to localStorage
const activeSub = accounts.active$.subscribe((account) => {
if (account) {
localStorage.setItem('active', account.id)
} else {
localStorage.removeItem('active')
}
})
const pool = new RelayPool()
// Define relay URLs for bookmark fetching
@@ -57,6 +95,12 @@ function App() {
setEventStore(store)
setAccountManager(accounts)
setRelayPool(pool)
// Cleanup subscriptions on unmount
return () => {
accountsSub.unsubscribe()
activeSub.unsubscribe()
}
}, [])
if (!eventStore || !accountManager || !relayPool) {

View File

@@ -19,8 +19,8 @@ interface BookmarkListProps {
onOpenSettings: () => void
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
bookmarks,
export const BookmarkList: React.FC<BookmarkListProps> = ({
bookmarks,
onSelectUrl,
isCollapsed,
onToggleCollapse,

View File

@@ -16,6 +16,7 @@ import Toast from './Toast'
import { useSettings } from '../hooks/useSettings'
import { useArticleLoader } from '../hooks/useArticleLoader'
import { loadContent, BookmarkReference } from '../utils/contentLoader'
import { HighlightMode } from './HighlightsPanel'
export type ViewMode = 'compact' | 'cards' | 'large'
interface BookmarksProps {
@@ -39,6 +40,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const [showSettings, setShowSettings] = useState(false)
const [currentArticleCoordinate, setCurrentArticleCoordinate] = useState<string | undefined>(undefined)
const [currentArticleEventId, setCurrentArticleEventId] = useState<string | undefined>(undefined)
const [highlightMode, setHighlightMode] = useState<HighlightMode>('others')
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
const eventStore = useEventStore()
@@ -191,6 +193,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
selectedHighlightId={selectedHighlightId}
onRefresh={handleFetchHighlights}
onHighlightClick={setSelectedHighlightId}
currentUserPubkey={activeAccount?.pubkey}
highlightMode={highlightMode}
onHighlightModeChange={setHighlightMode}
/>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useEffect, useRef } from 'react'
import React, { useMemo, useEffect, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
@@ -39,68 +39,42 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
selectedHighlightId
}) => {
const contentRef = useRef<HTMLDivElement>(null)
const originalHtmlRef = useRef<string>('')
// Scroll to selected highlight in article when clicked from sidebar
useEffect(() => {
if (!selectedHighlightId || !contentRef.current) return
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
if (markElement) {
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
// Add pulsing animation after scroll completes
const htmlElement = markElement as HTMLElement
setTimeout(() => {
htmlElement.classList.add('highlight-pulse')
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
}, 500)
}
}, [selectedHighlightId])
const markdownPreviewRef = useRef<HTMLDivElement>(null)
const [renderedHtml, setRenderedHtml] = useState<string>('')
const relevantHighlights = useMemo(() => filterHighlightsByUrl(highlights, selectedUrl), [selectedUrl, highlights])
// Store original HTML when content changes
// Convert markdown to HTML when markdown content changes
useEffect(() => {
if (!contentRef.current) return
// Store the fresh HTML content
originalHtmlRef.current = contentRef.current.innerHTML
}, [html, markdown, selectedUrl])
if (!markdown) {
setRenderedHtml('')
return
}
// Apply highlights after DOM is rendered
useEffect(() => {
// Skip if no content or underlines are hidden
if ((!html && !markdown) || !showUnderlines) {
// If underlines are hidden, restore original HTML
if (!showUnderlines && contentRef.current && originalHtmlRef.current) {
contentRef.current.innerHTML = originalHtmlRef.current
}
return
}
// Skip if no relevant highlights
if (relevantHighlights.length === 0) {
// Restore original HTML if no highlights
if (contentRef.current && originalHtmlRef.current) {
contentRef.current.innerHTML = originalHtmlRef.current
}
return
}
// Use requestAnimationFrame to ensure DOM is fully rendered
// Use requestAnimationFrame to ensure ReactMarkdown has rendered
const rafId = requestAnimationFrame(() => {
if (!contentRef.current || !originalHtmlRef.current) return
// Always apply highlights to the ORIGINAL HTML, not already-highlighted content
const highlightedHTML = applyHighlightsToHTML(originalHtmlRef.current, relevantHighlights, highlightStyle)
contentRef.current.innerHTML = highlightedHTML
if (markdownPreviewRef.current) {
setRenderedHtml(markdownPreviewRef.current.innerHTML)
}
})
return () => cancelAnimationFrame(rafId)
}, [relevantHighlights, html, markdown, showUnderlines, highlightStyle])
// Attach click handlers separately (only when handler changes)
return () => cancelAnimationFrame(rafId)
}, [markdown])
// Prepare the final HTML with highlights applied
const finalHtml = useMemo(() => {
const sourceHtml = markdown ? renderedHtml : html
if (!sourceHtml) return ''
// Apply highlights if we have them and underlines are shown
if (showUnderlines && relevantHighlights.length > 0) {
return applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
}
return sourceHtml
}, [html, renderedHtml, markdown, relevantHighlights, showUnderlines, highlightStyle])
// Attach click handlers to highlight marks
useEffect(() => {
if (!onHighlightClick || !contentRef.current) return
@@ -122,9 +96,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
mark.removeEventListener('click', handler)
})
}
}, [onHighlightClick, relevantHighlights])
}, [onHighlightClick, finalHtml])
const highlightedMarkdown = useMemo(() => markdown, [markdown])
// Scroll to selected highlight in article when clicked from sidebar
useEffect(() => {
if (!selectedHighlightId || !contentRef.current) return
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
if (markElement) {
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
// Add pulsing animation after scroll completes
const htmlElement = markElement as HTMLElement
setTimeout(() => {
htmlElement.classList.add('highlight-pulse')
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
}, 500)
}
}, [selectedHighlightId, finalHtml])
// Calculate reading time from content (must be before early returns)
const readingStats = useMemo(() => {
@@ -160,6 +150,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
return (
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
{/* Hidden markdown preview to convert markdown to HTML */}
{markdown && (
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{markdown}
</ReactMarkdown>
</div>
)}
{image && (
<div className="reader-hero-image">
<img src={image} alt={title || 'Article image'} />
@@ -184,14 +183,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</div>
</div>
)}
{markdown ? (
<div ref={contentRef} className="reader-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{highlightedMarkdown}
</ReactMarkdown>
</div>
) : html ? (
<div ref={contentRef} className="reader-html" dangerouslySetInnerHTML={{ __html: html }} />
{markdown || html ? (
finalHtml ? (
<div
ref={contentRef}
className={markdown ? "reader-markdown" : "reader-html"}
dangerouslySetInnerHTML={{ __html: finalHtml }}
/>
) : markdown ? (
<div
ref={contentRef}
className="reader-markdown"
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{markdown}
</ReactMarkdown>
</div>
) : (
<div className="reader-html" ref={contentRef} />
)
) : (
<div className="reader empty">
<p>No readable content found for this URL.</p>

View File

@@ -1,9 +1,11 @@
import React, { useMemo, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate, faUser, faUserGroup } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
export type HighlightMode = 'mine' | 'others'
interface HighlightsPanelProps {
highlights: Highlight[]
loading: boolean
@@ -15,6 +17,9 @@ interface HighlightsPanelProps {
selectedHighlightId?: string
onRefresh?: () => void
onHighlightClick?: (highlightId: string) => void
currentUserPubkey?: string
highlightMode?: HighlightMode
onHighlightModeChange?: (mode: HighlightMode) => void
}
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
@@ -27,7 +32,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onToggleUnderlines,
selectedHighlightId,
onRefresh,
onHighlightClick
onHighlightClick,
currentUserPubkey,
highlightMode = 'others',
onHighlightModeChange
}) => {
const [showUnderlines, setShowUnderlines] = useState(true)
@@ -37,36 +45,48 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onToggleUnderlines?.(newValue)
}
// Filter highlights to show only those relevant to the current URL or article
// Filter highlights based on mode and URL
const filteredHighlights = useMemo(() => {
if (!selectedUrl) return highlights
// For Nostr articles (URL starts with "nostr:"), we don't need to filter
let urlFiltered = highlights
// For Nostr articles (URL starts with "nostr:"), we don't need to filter by URL
// because we already fetched highlights specifically for this article
if (selectedUrl.startsWith('nostr:')) {
return highlights
}
// For web URLs, filter by URL matching
const normalizeUrl = (url: string) => {
try {
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`)
return `${urlObj.hostname.replace(/^www\./, '')}${urlObj.pathname}`.replace(/\/$/, '').toLowerCase()
} catch {
return url.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '').toLowerCase()
if (!selectedUrl.startsWith('nostr:')) {
// For web URLs, filter by URL matching
const normalizeUrl = (url: string) => {
try {
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`)
return `${urlObj.hostname.replace(/^www\./, '')}${urlObj.pathname}`.replace(/\/$/, '').toLowerCase()
} catch {
return url.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '').toLowerCase()
}
}
const normalizedSelected = normalizeUrl(selectedUrl)
urlFiltered = highlights.filter(h => {
if (!h.urlReference) return false
const normalizedRef = normalizeUrl(h.urlReference)
return normalizedSelected === normalizedRef ||
normalizedSelected.includes(normalizedRef) ||
normalizedRef.includes(normalizedSelected)
})
}
const normalizedSelected = normalizeUrl(selectedUrl)
// Filter by mode (mine vs others)
if (!currentUserPubkey) {
// If no user is logged in, show all highlights (others mode only makes sense)
return urlFiltered
}
return highlights.filter(h => {
if (!h.urlReference) return false
const normalizedRef = normalizeUrl(h.urlReference)
return normalizedSelected === normalizedRef ||
normalizedSelected.includes(normalizedRef) ||
normalizedRef.includes(normalizedSelected)
})
}, [highlights, selectedUrl])
if (highlightMode === 'mine') {
return urlFiltered.filter(h => h.pubkey === currentUserPubkey)
} else {
return urlFiltered.filter(h => h.pubkey !== currentUserPubkey)
}
}, [highlights, selectedUrl, highlightMode, currentUserPubkey])
if (isCollapsed) {
const hasHighlights = filteredHighlights.length > 0
@@ -95,6 +115,26 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
{!loading && <span className="count">({filteredHighlights.length})</span>}
</div>
<div className="highlights-actions">
{currentUserPubkey && onHighlightModeChange && (
<div className="highlight-mode-toggle">
<button
onClick={() => onHighlightModeChange('mine')}
className={`mode-btn ${highlightMode === 'mine' ? 'active' : ''}`}
title="My highlights"
aria-label="Show my highlights"
>
<FontAwesomeIcon icon={faUser} />
</button>
<button
onClick={() => onHighlightModeChange('others')}
className={`mode-btn ${highlightMode === 'others' ? 'active' : ''}`}
title="Other highlights"
aria-label="Show highlights from others"
>
<FontAwesomeIcon icon={faUserGroup} />
</button>
</div>
)}
{onRefresh && (
<button
onClick={onRefresh}

View File

@@ -1,9 +1,10 @@
import React from 'react'
import React, { useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faUser, faList, faThLarge, faImage, faGear } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUser, faList, faThLarge, faImage, faGear } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { Accounts } from 'applesauce-accounts'
import IconButton from './IconButton'
import { ViewMode } from './Bookmarks'
@@ -16,9 +17,25 @@ interface SidebarHeaderProps {
}
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, viewMode, onViewModeChange, onOpenSettings }) => {
const [isConnecting, setIsConnecting] = useState(false)
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
const handleLogin = async () => {
try {
setIsConnecting(true)
const account = await Accounts.ExtensionAccount.fromExtension()
accountManager.addAccount(account)
accountManager.setActive(account)
} catch (error) {
console.error('Login failed:', error)
alert('Login failed. Please install a nostr browser extension and try again.')
} finally {
setIsConnecting(false)
}
}
const getProfileImage = () => {
return profile?.picture || null
}
@@ -58,13 +75,23 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Settings"
variant="ghost"
/>
<IconButton
icon={faRightFromBracket}
onClick={onLogout}
title="Logout"
ariaLabel="Logout"
variant="ghost"
/>
{activeAccount ? (
<IconButton
icon={faRightFromBracket}
onClick={onLogout}
title="Logout"
ariaLabel="Logout"
variant="ghost"
/>
) : (
<IconButton
icon={faRightToBracket}
onClick={isConnecting ? () => {} : handleLogin}
title={isConnecting ? "Connecting..." : "Login"}
ariaLabel="Login"
variant="ghost"
/>
)}
</div>
<div className="view-mode-controls">
<IconButton

View File

@@ -1260,6 +1260,35 @@ body {
gap: 0.5rem;
}
.highlight-mode-toggle {
display: flex;
gap: 0.25rem;
padding: 0.25rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.highlight-mode-toggle .mode-btn {
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 0.375rem 0.5rem;
border-radius: 3px;
transition: all 0.2s;
font-size: 0.9rem;
}
.highlight-mode-toggle .mode-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.highlight-mode-toggle .mode-btn.active {
background: #646cff;
color: #fff;
}
.refresh-highlights-btn,
.toggle-underlines-btn,
.toggle-highlights-btn {

View File

@@ -12,6 +12,13 @@ export function normalizeUrl(url: string): string {
export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: string | undefined): Highlight[] {
if (!selectedUrl || highlights.length === 0) return []
// For Nostr articles, we already fetched highlights specifically for this article
// So we don't need to filter them - they're all relevant
if (selectedUrl.startsWith('nostr:')) {
return highlights
}
// For web URLs, filter by URL matching
const normalizedSelected = normalizeUrl(selectedUrl)
return highlights.filter(h => {