Compare commits

...

28 Commits

Author SHA1 Message Date
Gigi
f8d3fac149 chore: bump version to 0.2.2 2025-10-06 20:57:42 +01:00
Gigi
61e948f6a4 refactor: use icon toggle buttons for highlight visibility settings
- Replace checkboxes with IconButton components matching existing UI pattern
- Use faNetworkWired, faUserGroup, and faUser icons
- Maintain consistent visual style with other settings toggles
2025-10-06 20:55:56 +01:00
Gigi
22323591c9 feat: add default highlight visibility settings
- Add defaultHighlightVisibilityNostrverse/Friends/Mine to UserSettings interface
- Add toggle controls in Settings page under Startup Preferences section
- Apply default visibility settings on app startup in Bookmarks component
- Users can now set which highlight levels (nostrverse/friends/mine) should be visible by default
2025-10-06 20:46:33 +01:00
Gigi
1b548cee3c feat: add proxy.nostr-relay.app relay to configuration 2025-10-06 20:43:12 +01:00
Gigi
fbb8fbdc20 fix: handle web bookmarks with URLs in d tag and prevent crash
- Extract URL from 'd' tag for kind:39701 web bookmarks
- Add protocol prefix (https://) if missing from web bookmark URLs
- Make classifyUrl handle undefined input gracefully
- Prevent crash when web bookmarks have no content
2025-10-06 20:34:37 +01:00
Gigi
1e7be50e35 refactor: change nostrverse icon from fa-globe to fa-network-wired
- Avoid icon conflict with web bookmarks which use fa-globe
- fa-network-wired better represents the nostr network/nostrverse concept
2025-10-06 20:31:47 +01:00
Gigi
1a7a8367a0 feat: add support for web bookmarks (NIP-B0, kind:39701)
- Update bookmarkService to fetch kind:39701 events
- Add processing logic for web bookmark events in bookmarkProcessing
- Update bookmark deduplication to handle web bookmarks
- Add 'web' type to IndividualBookmark interface
- Implement distinct icon (fa-bookmark + fa-globe) for web bookmarks
- Update CompactView and CardView to display web bookmark icon
- Add web-bookmarks rule documentation
2025-10-06 20:30:53 +01:00
Gigi
1f9dbf576c fix: load settings from local cache first to eliminate FOUT
- Check eventStore for cached settings before querying relays
- This eliminates the 5-second timeout on every page load
- Still fetch from relays in background to sync updates
- Fixes flash of unstyled text (FOUT) when custom fonts are set
2025-10-06 20:24:28 +01:00
Gigi
630c7ef0a4 feat: add comprehensive logging to settings service
- Add debug logs for settings loading from nostr
- Log when settings are found, missing, or timeout
- Add logging for settings save operations
- Track settings event publishing to relays

This will help diagnose why custom fonts/settings aren't being applied.
2025-10-06 20:20:41 +01:00
Gigi
b01293aa20 fix: ensure fonts are fully loaded before applying styles
- Convert loadFont to async function that returns a Promise
- Use Font Loading API to wait for fonts to be actually ready
- Add comprehensive logging for font loading stages
- Wait for font loading in useSettings before applying CSS variables
- Update Settings component to handle async font loading
- Prevents FOUT (Flash of Unstyled Text) by ensuring fonts are ready
- Fixes timing issue where custom fonts weren't being applied consistently

This ensures custom fonts are fully loaded and ready before being applied,
eliminating the race condition where content would render with system fonts
before custom fonts were available.
2025-10-06 20:04:11 +01:00
Gigi
d9db10fd70 fix: improve highlight rendering pipeline with comprehensive debugging
- Add extensive logging to track highlight rendering through entire pipeline
- Fix markdown rendering to wait for HTML conversion before displaying
- Prevent fallback to non-highlighted markdown during initial render
- Add debugging to URL filtering to identify matching issues
- Add logging to highlight application to track matching success/failures
- Ensure highlights are always applied when content is ready
- Show mini loading spinner while markdown is being converted

This will help diagnose and fix cases where highlights aren't showing up.
2025-10-06 19:56:20 +01:00
Gigi
872d38c7f3 feat: implement optimistic updates for highlight creation
- Update createHighlight to return the signed NostrEvent
- Add eventToHighlight helper to convert events to Highlight objects
- Immediately add new highlights to UI without fetching from relays
- Remove handleHighlightCreated refresh logic (no longer needed)
- Improves UX with instant feedback when creating highlights
2025-10-06 19:51:14 +01:00
Gigi
06c3c1ff20 feat: enable highlight creation from external URLs
- Update createHighlight service to accept both NostrEvent and URL string as source
- Modify Bookmarks component to support highlighting on /r/* paths
- Add fetchHighlightsForUrl import for refreshing URL-based highlights
- Extract context from reader content (markdown/html) for external URLs
- Automatically use 'r' tag for external URLs via HighlightBlueprint
2025-10-06 19:43:27 +01:00
Gigi
107d6757bd feat: add routing support for external URLs
- Add /r/* route in App.tsx for external URL content
- Create useExternalUrlLoader hook to load external web content
- Add fetchHighlightsForUrl service to fetch highlights by URL using 'r' tag
- Update Bookmarks component to handle both nostr-native (naddr) and external URLs
- Support two URL patterns: /a/naddr... for nostr content, /r/https://... for external URLs
2025-10-06 19:22:18 +01:00
Gigi
89bd9f631a feat: add context to highlights (previous and next sentences) 2025-10-06 07:41:19 +01:00
Gigi
beeb296d3b feat: add Boris branding to highlight alt tag 2025-10-06 07:39:45 +01:00
Gigi
0e992ae814 fix: update local relay port to 10547 2025-10-05 23:42:44 +01:00
Gigi
8b023af6a0 refactor: simplify to single RELAYS constant (DRY) 2025-10-05 23:41:27 +01:00
Gigi
6e2f1102f7 feat: add local relay support and centralize relay configuration 2025-10-05 23:38:56 +01:00
Gigi
7de8c49b01 chore: bump version to 0.2.1 2025-10-05 23:35:23 +01:00
Gigi
c3aece1722 fix: properly await account loading from localStorage on refresh 2025-10-05 23:34:33 +01:00
Gigi
7a4cb77aa3 refactor: remove dedicated login page, handle login through main UI 2025-10-05 23:32:52 +01:00
Gigi
9065501043 fix: add protected routes to prevent logout on page refresh 2025-10-05 23:31:30 +01:00
Gigi
c9ace72d4d fix: use undo icon for reset to defaults button 2025-10-05 23:29:33 +01:00
Gigi
be6ad79f60 docs: add vision section and explain three-level highlight system 2025-10-05 23:28:50 +01:00
Gigi
0473ba71fb fix: update color palette to include default friends/nostrverse colors 2025-10-05 23:28:18 +01:00
Gigi
7e575ea617 feat: add reset to defaults button in settings 2025-10-05 23:27:43 +01:00
Gigi
c3a2dd5603 feat: load and apply settings upon login 2025-10-05 23:23:59 +01:00
30 changed files with 894 additions and 324 deletions

View File

@@ -0,0 +1,10 @@
---
description: anything to do with "web bookmarks" aka NIP-B0
alwaysApply: false
---
The app also supports web bookmarks (`kind:39701`) which are distinct from public/private bookmarks as defined in NIP-51.
See NIP-B0 for details:
- https://github.com/nostr-protocol/nips/blob/master/B0.md

View File

@@ -2,7 +2,27 @@
Your reading list for the Nostr world.
Boris turns your Nostr bookmarks into a calm, fast, and focused reading experience. Connect your Nostr account and youll get a clean threepane reader: bookmarks on the left, the article in the middle, and highlights on the right.
Boris turns your Nostr bookmarks into a calm, fast, and focused reading experience. Connect your Nostr account and you'll get a clean threepane reader: bookmarks on the left, the article in the middle, and highlights on the right.
## The Vision
When I wrote "Purple Text, Orange Highlights" 2.5 years ago, I had a certain interface in mind that would allow the reader to curate, discover, highlight, and provide value to writers and other readers alike. Boris is my attempt to build this interface.
Boris has three "levels" of highlights for each article:
- user = yellow
- friends = orange
- nostrverse = purple
In case it's not self-explanatory:
- **your highlights** = highlights that the logged-in npub made
- **friends** = highlights that your friends made, i.e. highlights of the npubs that the logged-in user follows
- **nostrverse** = all the highlights we can find on all the relays we're connected to
The user can toggle hide/show any of these "levels".
In addition to rendering articles from nostr and the legacy web, Boris can act as a "read it later" app, thanks to the power of nostr bookmarks.
If you bookmark something on nostr, Boris will show it in the bookmarks bar. If said something contains a URL, Boris will extract and render it in a distraction-free and reader-friendly way.
## What Boris does

4
dist/index.html vendored
View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Boris - Nostr Bookmarks</title>
<script type="module" crossorigin src="/assets/index--wClm1wz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bj-Uhit8.css">
<script type="module" crossorigin src="/assets/index-rEUBRPdE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bqz-n1DY.css">
</head>
<body>
<div id="root"></div>

View File

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

View File

@@ -2,20 +2,61 @@ import { useState, useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { EventStoreProvider, AccountsProvider } from 'applesauce-react'
import { EventStoreProvider, AccountsProvider, Hooks } 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'
import Toast from './components/Toast'
import { useToast } from './hooks/useToast'
import { RELAYS } from './config/relays'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
// AppRoutes component that has access to hooks
function AppRoutes({
relayPool,
showToast
}: {
relayPool: RelayPool
showToast: (message: string) => void
}) {
const accountManager = Hooks.useAccountManager()
const handleLogout = () => {
accountManager.setActive(undefined as never)
localStorage.removeItem('active')
showToast('Logged out successfully')
}
return (
<Routes>
<Route
path="/a/:naddr"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/r/*"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes>
)
}
function App() {
const [eventStore, setEventStore] = useState<EventStore | null>(null)
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
@@ -23,15 +64,15 @@ function App() {
const { toastMessage, toastType, showToast, clearToast } = useToast()
useEffect(() => {
// 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 () => {
const initializeApp = async () => {
// 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
try {
const json = JSON.parse(localStorage.getItem('accounts') || '[]')
await accounts.fromJSON(json)
@@ -46,65 +87,54 @@ function App() {
} catch (err) {
console.error('Failed to load accounts from storage:', err)
}
// 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()
// Create a relay group for better event deduplication and management
pool.group(RELAYS)
console.log('Created relay group with', RELAYS.length, 'relays (including local)')
console.log('Relay URLs:', RELAYS)
// Attach address/replaceable loaders so ProfileModel can fetch profiles
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: RELAYS
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
setEventStore(store)
setAccountManager(accounts)
setRelayPool(pool)
// Cleanup function
return () => {
accountsSub.unsubscribe()
activeSub.unsubscribe()
}
}
loadAccounts()
// Subscribe to accounts changes and persist to localStorage
const accountsSub = accounts.accounts$.subscribe(() => {
localStorage.setItem('accounts', JSON.stringify(accounts.toJSON()))
let cleanup: (() => void) | undefined
initializeApp().then((fn) => {
cleanup = fn
})
// 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
const relayUrls = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.dergigi.com',
'wss://wot.dergigi.com',
'wss://relay.snort.social',
'wss://relay.current.fyi',
'wss://nostr-pub.wellorder.net'
]
// Create a relay group for better event deduplication and management
// This follows the applesauce-relay documentation pattern
// Note: We could use pool.group(relayUrls) for direct requests in the future
pool.group(relayUrls)
console.log('Created relay group with', relayUrls.length, 'relays')
console.log('Relay URLs:', relayUrls)
// Attach address/replaceable loaders so ProfileModel can fetch profiles
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: [
'wss://purplepag.es',
'wss://relay.primal.net',
'wss://relay.nostr.band'
]
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
setEventStore(store)
setAccountManager(accounts)
setRelayPool(pool)
// Cleanup subscriptions on unmount
return () => {
accountsSub.unsubscribe()
activeSub.unsubscribe()
if (cleanup) cleanup()
}
}, [])
@@ -121,26 +151,7 @@ function App() {
<AccountsProvider manager={accountManager}>
<BrowserRouter>
<div className="app">
<Routes>
<Route
path="/a/:naddr"
element={
<Bookmarks
relayPool={relayPool}
onLogout={() => {
if (accountManager) {
accountManager.setActive(undefined as never)
localStorage.removeItem('active')
showToast('Logged out successfully')
console.log('Logged out')
}
}}
/>
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
<Route path="/login" element={<Login onLogin={() => showToast('Logged in successfully')} />} />
</Routes>
<AppRoutes relayPool={relayPool} showToast={showToast} />
</div>
</BrowserRouter>
{toastMessage && (

View File

@@ -24,8 +24,15 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
// Extract URLs from bookmark content
const extractedUrls = extractUrlsFromContent(bookmark.content)
// For web bookmarks (kind:39701), URL is stored in the 'd' tag
const isWebBookmark = bookmark.kind === 39701
const webBookmarkUrl = isWebBookmark ? bookmark.tags.find(t => t[0] === 'd')?.[1] : null
// Extract URLs from bookmark content (for regular bookmarks)
// For web bookmarks, ensure URL has protocol
const extractedUrls = webBookmarkUrl
? [webBookmarkUrl.startsWith('http') ? webBookmarkUrl : `https://${webBookmarkUrl}`]
: extractUrlsFromContent(bookmark.content)
const hasUrls = extractedUrls.length > 0
const firstUrl = hasUrls ? extractedUrls[0] : null
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : null

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faUserLock, faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
import { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
@@ -42,6 +42,7 @@ export const CardView: React.FC<CardViewProps> = ({
const contentLength = (bookmark.content || '').length
const shouldTruncate = !expanded && contentLength > 210
const isArticle = bookmark.kind === 30023
const isWebBookmark = bookmark.kind === 39701
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
@@ -54,7 +55,12 @@ export const CardView: React.FC<CardViewProps> = ({
)}
<div className="bookmark-header">
<span className="bookmark-type">
{bookmark.isPrivate ? (
{isWebBookmark ? (
<span className="fa-layers fa-fw">
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
</span>
) : bookmark.isPrivate ? (
<>
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />

View File

@@ -1,6 +1,6 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faUserLock } from '@fortawesome/free-solid-svg-icons'
import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
@@ -27,7 +27,8 @@ export const CompactView: React.FC<CompactViewProps> = ({
firstUrlClassification
}) => {
const isArticle = bookmark.kind === 30023
const isClickable = hasUrls || isArticle
const isWebBookmark = bookmark.kind === 39701
const isClickable = hasUrls || isArticle || isWebBookmark
const handleCompactClick = () => {
if (!onSelectUrl) return
@@ -48,7 +49,12 @@ export const CompactView: React.FC<CompactViewProps> = ({
tabIndex={isClickable ? 0 : undefined}
>
<span className="bookmark-type-compact">
{bookmark.isPrivate ? (
{isWebBookmark ? (
<span className="fa-layers fa-fw">
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
</span>
) : bookmark.isPrivate ? (
<>
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react'
import { useParams } from 'react-router-dom'
import { useParams, useLocation } from 'react-router-dom'
import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks'
import { RelayPool } from 'applesauce-relay'
@@ -16,10 +16,11 @@ import Settings from './Settings'
import Toast from './Toast'
import { useSettings } from '../hooks/useSettings'
import { useArticleLoader } from '../hooks/useArticleLoader'
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
import { loadContent, BookmarkReference } from '../utils/contentLoader'
import { HighlightVisibility } from './HighlightsPanel'
import { HighlightButton, HighlightButtonRef } from './HighlightButton'
import { createHighlight } from '../services/highlightCreationService'
import { createHighlight, eventToHighlight } from '../services/highlightCreationService'
import { useRef, useCallback } from 'react'
import { NostrEvent } from 'nostr-tools'
export type ViewMode = 'compact' | 'cards' | 'large'
@@ -31,6 +32,13 @@ interface BookmarksProps {
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const { naddr } = useParams<{ naddr?: string }>()
const location = useLocation()
// Extract external URL from /r/* route
const externalUrl = location.pathname.startsWith('/r/')
? location.pathname.slice(3) // Remove '/r/' prefix
: undefined
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [bookmarksLoading, setBookmarksLoading] = useState(true)
const [highlights, setHighlights] = useState<Highlight[]>([])
@@ -66,7 +74,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
accountManager
})
// Load article if naddr is in URL
// Load nostr-native article if naddr is in URL
useArticleLoader({
naddr,
relayPool,
@@ -80,6 +88,20 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setCurrentArticleEventId,
setCurrentArticle
})
// Load external URL if /r/* route is used
useExternalUrlLoader({
url: externalUrl,
relayPool,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed,
setHighlights,
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId
})
// Load initial data on login
useEffect(() => {
@@ -103,6 +125,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
useEffect(() => {
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
if (settings.showHighlights !== undefined) setShowHighlights(settings.showHighlights)
// Apply default highlight visibility settings
setHighlightVisibility({
nostrverse: settings.defaultHighlightVisibilityNostrverse !== false,
friends: settings.defaultHighlightVisibilityFriends !== false,
mine: settings.defaultHighlightVisibilityMine !== false
})
// Always start with both panels collapsed on initial load
// Don't apply saved collapse settings on initial load - let user control them
}, [settings])
@@ -201,22 +229,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
}
}
const handleHighlightCreated = async () => {
// Refresh highlights after creating a new one
if (!relayPool || !currentArticleCoordinate) return
try {
const newHighlights = await fetchHighlightsForArticle(
relayPool,
currentArticleCoordinate,
currentArticleEventId
)
setHighlights(newHighlights)
} catch (err) {
console.error('Failed to refresh highlights:', err)
}
}
const handleTextSelection = useCallback((text: string) => {
highlightButtonRef.current?.updateSelection(text)
}, [])
@@ -226,28 +238,45 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
}, [])
const handleCreateHighlight = useCallback(async (text: string) => {
if (!activeAccount || !relayPool || !currentArticle) {
if (!activeAccount || !relayPool) {
console.error('Missing requirements for highlight creation')
return
}
// Need either a nostr article or an external URL
if (!currentArticle && !selectedUrl) {
console.error('No source available for highlight creation')
return
}
try {
await createHighlight(
// Determine the source: prefer currentArticle (for nostr content), fallback to selectedUrl (for external URLs)
const source = currentArticle || selectedUrl!
// For context extraction, use article content or reader content
const contentForContext = currentArticle
? currentArticle.content
: readerContent?.markdown || readerContent?.html
// Create and publish the highlight
const signedEvent = await createHighlight(
text,
currentArticle,
source,
activeAccount,
relayPool
relayPool,
contentForContext
)
console.log('✅ Highlight created successfully!')
highlightButtonRef.current?.clearSelection()
// Trigger refresh of highlights
handleHighlightCreated()
// Immediately add the highlight to the UI (optimistic update)
const newHighlight = eventToHighlight(signedEvent)
setHighlights(prev => [newHighlight, ...prev])
} catch (error) {
console.error('Failed to create highlight:', error)
}
}, [activeAccount, relayPool, currentArticle, handleHighlightCreated])
}, [activeAccount, relayPool, currentArticle, selectedUrl, readerContent])
return (
<>

View File

@@ -58,10 +58,17 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// Filter highlights by URL and visibility settings
const relevantHighlights = useMemo(() => {
console.log('🔍 ContentPanel: Processing highlights', {
totalHighlights: highlights.length,
selectedUrl,
showHighlights
})
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
console.log('📌 URL filtered highlights:', urlFiltered.length)
// Apply visibility filtering
return urlFiltered
const filtered = urlFiltered
.map(h => {
// Classify highlight level
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
@@ -78,7 +85,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (h.level === 'friends') return highlightVisibility.friends
return highlightVisibility.nostrverse
})
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys])
console.log('✅ Relevant highlights after filtering:', filtered.length, filtered.map(h => h.content.substring(0, 30)))
return filtered
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys, showHighlights])
// Convert markdown to HTML when markdown content changes
useEffect(() => {
@@ -87,10 +97,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
return
}
console.log('📝 Converting markdown to HTML...')
// Use requestAnimationFrame to ensure ReactMarkdown has rendered
const rafId = requestAnimationFrame(() => {
if (markdownPreviewRef.current) {
setRenderedHtml(markdownPreviewRef.current.innerHTML)
const html = markdownPreviewRef.current.innerHTML
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
setRenderedHtml(html)
} else {
console.warn('⚠️ markdownPreviewRef.current is null')
}
})
@@ -100,13 +116,30 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// Prepare the final HTML with highlights applied
const finalHtml = useMemo(() => {
const sourceHtml = markdown ? renderedHtml : html
if (!sourceHtml) return ''
console.log('🎨 Preparing final HTML:', {
hasMarkdown: !!markdown,
hasHtml: !!html,
renderedHtmlLength: renderedHtml.length,
sourceHtmlLength: sourceHtml?.length || 0,
showHighlights,
relevantHighlightsCount: relevantHighlights.length
})
if (!sourceHtml) {
console.warn('⚠️ No source HTML available')
return ''
}
// Apply highlights if we have them and highlights are enabled
if (showHighlights && relevantHighlights.length > 0) {
return applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
console.log('✨ Applying', relevantHighlights.length, 'highlights to HTML')
const highlightedHtml = applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
console.log('✅ Highlights applied, result length:', highlightedHtml.length)
return highlightedHtml
}
console.log('📄 Returning source HTML without highlights')
return sourceHtml
}, [html, renderedHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
@@ -224,7 +257,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
/>
{markdown || html ? (
markdown ? (
finalHtml ? (
// For markdown, always use finalHtml once it's ready to ensure highlights are applied
renderedHtml && finalHtml ? (
<div
ref={contentRef}
className="reader-markdown"
@@ -232,17 +266,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onMouseUp={handleMouseUp}
/>
) : (
<div
ref={contentRef}
className="reader-markdown"
onMouseUp={handleMouseUp}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{markdown}
</ReactMarkdown>
// Show loading state while markdown is being converted to HTML
<div className="reader-markdown">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
</div>
</div>
)
) : (
// For HTML, use finalHtml directly
<div
ref={contentRef}
className="reader-html"

View File

@@ -1,6 +1,6 @@
import React, { useMemo, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faGlobe } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
@@ -136,7 +136,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
aria-label="Toggle nostrverse highlights"
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
>
<FontAwesomeIcon icon={faGlobe} />
<FontAwesomeIcon icon={faNetworkWired} />
</button>
<button
onClick={() => onHighlightVisibilityChange({

View File

@@ -1,47 +0,0 @@
import React, { useState } from 'react'
import { Hooks } from 'applesauce-react'
import { Accounts } from 'applesauce-accounts'
interface LoginProps {
onLogin: () => void
}
const Login: React.FC<LoginProps> = ({ onLogin }) => {
const [isConnecting, setIsConnecting] = useState(false)
const accountManager = Hooks.useAccountManager()
const handleLogin = async () => {
try {
setIsConnecting(true)
// Create account from nostr extension
const account = await Accounts.ExtensionAccount.fromExtension()
accountManager.addAccount(account)
accountManager.setActive(account)
onLogin()
} catch (error) {
console.error('Login failed:', error)
alert('Login failed. Please install a nostr browser extension and try again.')
} finally {
setIsConnecting(false)
}
}
return (
<div className="login-container">
<div className="login-card">
<h2>Welcome to Boris</h2>
<p>Connect your nostr account to view your bookmarks</p>
<button
onClick={handleLogin}
disabled={isConnecting}
className="login-button"
>
{isConnecting ? 'Connecting...' : 'Connect with Nostr'}
</button>
</div>
</div>
)
}
export default Login

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'
import { faTimes, faList, faThLarge, faImage, faUnderline, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { faTimes, faList, faThLarge, faImage, faUnderline, faHighlighter, faUndo, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../services/settingsService'
import IconButton from './IconButton'
import ColorPicker from './ColorPicker'
@@ -7,6 +7,24 @@ import FontSelector from './FontSelector'
import { loadFont, getFontFamily } from '../utils/fontLoader'
import { hexToRgb } from '../utils/colorHelpers'
const DEFAULT_SETTINGS: UserSettings = {
collapseOnArticleOpen: true,
defaultViewMode: 'compact',
showHighlights: true,
sidebarCollapsed: true,
highlightsCollapsed: true,
readingFont: 'source-serif-4',
fontSize: 18,
highlightStyle: 'marker',
highlightColor: '#ffff00',
highlightColorNostrverse: '#9333ea',
highlightColorFriends: '#f97316',
highlightColorMine: '#ffff00',
defaultHighlightVisibilityNostrverse: true,
defaultHighlightVisibilityFriends: true,
defaultHighlightVisibilityMine: true,
}
interface SettingsProps {
settings: UserSettings
onSave: (settings: UserSettings) => Promise<void>
@@ -24,13 +42,15 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
useEffect(() => {
// Preload all fonts for the dropdown
const fonts = ['inter', 'lora', 'merriweather', 'open-sans', 'roboto', 'source-serif-4', 'crimson-text', 'libre-baskerville', 'pt-serif']
fonts.forEach(font => loadFont(font))
fonts.forEach(font => {
loadFont(font).catch(err => console.warn('Failed to preload font:', font, err))
})
}, [])
useEffect(() => {
// Load font for preview when it changes
const fontToLoad = localSettings.readingFont || 'source-serif-4'
loadFont(fontToLoad)
loadFont(fontToLoad).catch(err => console.warn('Failed to load preview font:', fontToLoad, err))
}, [localSettings.readingFont])
// Auto-save settings whenever they change (except on initial mount)
@@ -45,17 +65,32 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
const previewFontFamily = getFontFamily(localSettings.readingFont || 'source-serif-4')
const handleResetToDefaults = () => {
if (confirm('Reset all settings to defaults?')) {
setLocalSettings(DEFAULT_SETTINGS)
}
}
return (
<div className="settings-view">
<div className="settings-header">
<h2>Settings</h2>
<IconButton
icon={faTimes}
onClick={onClose}
title="Close settings"
ariaLabel="Close settings"
variant="ghost"
/>
<div className="settings-header-actions">
<IconButton
icon={faUndo}
onClick={handleResetToDefaults}
title="Reset to defaults"
ariaLabel="Reset to defaults"
variant="ghost"
/>
<IconButton
icon={faTimes}
onClick={onClose}
title="Close settings"
ariaLabel="Close settings"
variant="ghost"
/>
</div>
</div>
<div className="settings-content">
@@ -224,6 +259,33 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
<span>Start with highlights panel collapsed</span>
</label>
</div>
<div className="setting-group setting-inline">
<label>Default Highlight Visibility</label>
<div className="setting-buttons">
<IconButton
icon={faNetworkWired}
onClick={() => setLocalSettings({ ...localSettings, defaultHighlightVisibilityNostrverse: !(localSettings.defaultHighlightVisibilityNostrverse !== false) })}
title="Nostrverse highlights"
ariaLabel="Toggle nostrverse highlights by default"
variant={(localSettings.defaultHighlightVisibilityNostrverse !== false) ? 'primary' : 'ghost'}
/>
<IconButton
icon={faUserGroup}
onClick={() => setLocalSettings({ ...localSettings, defaultHighlightVisibilityFriends: !(localSettings.defaultHighlightVisibilityFriends !== false) })}
title="Friends highlights"
ariaLabel="Toggle friends highlights by default"
variant={(localSettings.defaultHighlightVisibilityFriends !== false) ? 'primary' : 'ghost'}
/>
<IconButton
icon={faUser}
onClick={() => setLocalSettings({ ...localSettings, defaultHighlightVisibilityMine: !(localSettings.defaultHighlightVisibilityMine !== false) })}
title="My highlights"
ariaLabel="Toggle my highlights by default"
variant={(localSettings.defaultHighlightVisibilityMine !== false) ? 'primary' : 'ghost'}
/>
</div>
</div>
</div>
</div>
</div>

21
src/config/relays.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* Centralized relay configuration
* Single set of relays used throughout the application
*/
// All relays including local relay
export const RELAYS = [
'ws://localhost:10547',
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.dergigi.com',
'wss://wot.dergigi.com',
'wss://relay.snort.social',
'wss://relay.current.fyi',
'wss://nostr-pub.wellorder.net',
'wss://purplepag.es',
'wss://relay.primal.net',
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87'
]

View File

@@ -0,0 +1,85 @@
import { useEffect } from 'react'
import { RelayPool } from 'applesauce-relay'
import { fetchReadableContent, ReadableContent } from '../services/readerService'
import { fetchHighlightsForUrl } from '../services/highlightService'
import { Highlight } from '../types/highlights'
interface UseExternalUrlLoaderProps {
url: string | undefined
relayPool: RelayPool | null
setSelectedUrl: (url: string) => void
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
setIsCollapsed: (collapsed: boolean) => void
setHighlights: (highlights: Highlight[]) => void
setHighlightsLoading: (loading: boolean) => void
setCurrentArticleCoordinate: (coord: string | undefined) => void
setCurrentArticleEventId: (id: string | undefined) => void
}
export function useExternalUrlLoader({
url,
relayPool,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed,
setHighlights,
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId
}: UseExternalUrlLoaderProps) {
useEffect(() => {
if (!relayPool || !url) return
const loadExternalUrl = async () => {
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(url)
setIsCollapsed(true)
// Clear article-specific state
setCurrentArticleCoordinate(undefined)
setCurrentArticleEventId(undefined)
try {
const content = await fetchReadableContent(url)
setReaderContent(content)
console.log('🌐 External URL loaded:', content.title)
// Set reader loading to false immediately after content is ready
setReaderLoading(false)
// Fetch highlights for this URL asynchronously
try {
setHighlightsLoading(true)
setHighlights([])
// Check if fetchHighlightsForUrl exists, otherwise skip
if (typeof fetchHighlightsForUrl === 'function') {
const highlightsList = await fetchHighlightsForUrl(relayPool, url)
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
} else {
console.log('📌 Highlight fetching for URLs not yet implemented')
}
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
setHighlightsLoading(false)
}
} catch (err) {
console.error('Failed to load external URL:', err)
setReaderContent({
title: 'Error Loading Content',
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url
})
setReaderLoading(false)
}
}
loadExternalUrl()
}, [url, relayPool])
}

View File

@@ -5,11 +5,7 @@ import { EventFactory } from 'applesauce-factory'
import { AccountManager } from 'applesauce-accounts'
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
import { loadFont, getFontFamily } from '../utils/fontLoader'
const RELAY_URLS = [
'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.nostr.band',
'wss://relay.dergigi.com', 'wss://wot.dergigi.com'
]
import { RELAYS } from '../config/relays'
interface UseSettingsParams {
relayPool: RelayPool | null
@@ -29,7 +25,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
const loadAndWatch = async () => {
try {
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAY_URLS)
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
if (loadedSettings) setSettings(loadedSettings)
} catch (err) {
console.error('Failed to load settings:', err)
@@ -47,16 +43,32 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
// Apply settings to document
useEffect(() => {
const root = document.documentElement.style
const fontKey = settings.readingFont || 'system'
if (fontKey !== 'system') loadFont(fontKey)
root.setProperty('--reading-font', getFontFamily(fontKey))
root.setProperty('--reading-font-size', `${settings.fontSize || 18}px`)
const applyStyles = async () => {
const root = document.documentElement.style
const fontKey = settings.readingFont || 'system'
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize })
// Load font first and wait for it to be ready
if (fontKey !== 'system') {
console.log('⏳ Waiting for font to load...')
await loadFont(fontKey)
console.log('✅ Font loaded, applying styles')
}
// Apply font settings after font is loaded
root.setProperty('--reading-font', getFontFamily(fontKey))
root.setProperty('--reading-font-size', `${settings.fontSize || 18}px`)
// Set highlight colors for three levels
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00')
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
console.log('✅ All styles applied')
}
// Set highlight colors for three levels
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00')
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
applyStyles()
}, [settings])
const saveSettingsWithToast = useCallback(async (newSettings: UserSettings) => {
@@ -65,7 +77,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
const fullAccount = accountManager.getActive()
if (!fullAccount) throw new Error('No active account')
const factory = new EventFactory({ signer: fullAccount })
await saveSettings(relayPool, eventStore, factory, newSettings, RELAY_URLS)
await saveSettings(relayPool, eventStore, factory, newSettings, RELAYS)
setSettings(newSettings)
setToastType('success')
setToastMessage('Settings saved')

View File

@@ -56,53 +56,6 @@ body {
color: #888;
}
/* Login Styles */
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 50vh;
}
.login-card {
background: #1a1a1a;
padding: 2rem;
border-radius: 8px;
border: 1px solid #333;
max-width: 400px;
width: 100%;
}
.login-card h2 {
margin: 0 0 1rem 0;
color: #fff;
}
.login-card p {
margin: 0 0 1.5rem 0;
color: #ccc;
}
.login-button {
background: #646cff;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
.login-button:hover:not(:disabled) {
background: #535bf2;
}
.login-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Bookmarks Styles */
.bookmarks-container {
background: #1a1a1a;
@@ -1147,7 +1100,6 @@ body {
background-color: #ffffff;
}
.login-card,
.bookmark-item {
background: #f9f9f9;
border-color: #ddd;
@@ -1846,6 +1798,12 @@ body {
text-align: left;
}
.settings-header-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.settings-content {
overflow-y: auto;
flex: 1;

View File

@@ -9,6 +9,7 @@ import {
getArticlePublished,
getArticleSummary
} from 'applesauce-core/helpers'
import { RELAYS } from '../config/relays'
export interface ArticleContent {
title: string
@@ -95,15 +96,10 @@ export async function fetchArticleByNaddr(
const pointer = decoded.data as AddressPointer
// Define relays to query
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local)
const relays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.primal.net'
]
: RELAYS
// Fetch the article event
const filter = {

View File

@@ -15,6 +15,9 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
}
const unique = Array.from(byId.values())
// Separate web bookmarks (kind:39701) from list-based bookmarks
const webBookmarks = unique.filter(e => e.kind === 39701)
const bookmarkLists = unique
.filter(e => e.kind === 10003 || e.kind === 30001)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
@@ -33,6 +36,8 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
const out: NostrEvent[] = []
if (latestBookmarkList) out.push(latestBookmarkList)
out.push(...setsAndNamedLists)
// Add web bookmarks as individual events
out.push(...webBookmarks)
return out
}

View File

@@ -33,6 +33,23 @@ export async function collectBookmarksFromEvents(
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
// Handle web bookmarks (kind:39701) as individual bookmarks
if (evt.kind === 39701) {
publicItemsAll.push({
id: evt.id,
content: evt.content || '',
created_at: evt.created_at || Math.floor(Date.now() / 1000),
pubkey: evt.pubkey,
kind: evt.kind,
tags: evt.tags || [],
parsedContent: undefined,
type: 'web' as const,
isPrivate: false,
added_at: evt.created_at || Math.floor(Date.now() / 1000)
})
continue
}
const pub = Helpers.getPublicBookmarks(evt)
publicItemsAll.push(...processApplesauceBookmarks(pub, activeAccount, false))

View File

@@ -29,11 +29,11 @@ export const fetchBookmarks = async (
}
// Get relay URLs from the pool
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
// Fetch bookmark events - NIP-51 standards and legacy formats
// Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0)
console.log('🔍 Fetching bookmark events from relays:', relayUrls)
const rawEvents = await lastValueFrom(
relayPool
.req(relayUrls, { kinds: [10003, 30003, 30001], authors: [activeAccount.pubkey] })
.req(relayUrls, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
.pipe(completeOnEose(), takeUntil(timer(20000)), toArray())
)
console.log('📊 Raw events fetched:', rawEvents.length, 'events')

View File

@@ -4,50 +4,81 @@ import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools'
import { RELAYS } from '../config/relays'
import { Highlight } from '../types/highlights'
import {
getHighlightText,
getHighlightContext,
getHighlightComment,
getHighlightSourceEventPointer,
getHighlightSourceAddressPointer,
getHighlightSourceUrl,
getHighlightAttributions
} from 'applesauce-core/helpers'
/**
* Creates and publishes a highlight event (NIP-84)
* Supports both nostr-native articles and external URLs
* Returns the signed event for immediate UI updates
*/
export async function createHighlight(
selectedText: string,
article: NostrEvent | null,
source: NostrEvent | string,
account: IAccount,
relayPool: RelayPool,
contentForContext?: string,
comment?: string
): Promise<void> {
if (!selectedText || !article) {
): Promise<NostrEvent> {
if (!selectedText || !source) {
throw new Error('Missing required data to create highlight')
}
// Create EventFactory with the account as signer
const factory = new EventFactory({ signer: account })
// Parse article coordinate to get address pointer
const addressPointer = parseArticleCoordinate(article)
let blueprintSource: NostrEvent | AddressPointer | string
let context: string | undefined
// Handle NostrEvent (article) source
if (typeof source === 'object' && 'kind' in source) {
blueprintSource = parseArticleCoordinate(source)
context = extractContext(selectedText, source.content)
}
// Handle URL string source
else {
blueprintSource = source
// Try to extract context from provided content if available
if (contentForContext) {
context = extractContext(selectedText, contentForContext)
}
}
// Create highlight event using the blueprint
const highlightEvent = await factory.create(
HighlightBlueprint,
selectedText,
addressPointer,
comment ? { comment } : undefined
blueprintSource,
context ? { comment, context } : comment ? { comment } : undefined
)
// Update the alt tag to identify Boris as the creator
const altTagIndex = highlightEvent.tags.findIndex(tag => tag[0] === 'alt')
if (altTagIndex !== -1) {
highlightEvent.tags[altTagIndex] = ['alt', 'Highlight created by Boris. readwithboris.com']
} else {
highlightEvent.tags.push(['alt', 'Highlight created by Boris. readwithboris.com'])
}
// Sign the event
const signedEvent = await factory.sign(highlightEvent)
// Publish to relays
const relayUrls = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.snort.social',
'wss://purplepag.es'
]
await relayPool.publish(relayUrls, signedEvent)
// Publish to relays (including local relay)
await relayPool.publish(RELAYS, signedEvent)
console.log('✅ Highlight published:', signedEvent)
console.log('✅ Highlight published to', RELAYS.length, 'relays (including local):', signedEvent)
// Return the signed event for immediate UI updates
return signedEvent
}
/**
@@ -65,3 +96,110 @@ function parseArticleCoordinate(article: NostrEvent): AddressPointer {
}
}
/**
* Extracts context for a highlight by finding the previous and next sentences
* in the same paragraph as the selected text
*/
function extractContext(selectedText: string, articleContent: string): string | undefined {
if (!selectedText || !articleContent) return undefined
// Find the position of the selected text in the article
const selectedIndex = articleContent.indexOf(selectedText)
if (selectedIndex === -1) return undefined
// Split content into paragraphs (by double newlines or single newlines)
const paragraphs = articleContent.split(/\n\n+/)
// Find which paragraph contains the selected text
let currentPos = 0
let containingParagraph: string | undefined
for (const paragraph of paragraphs) {
const paragraphEnd = currentPos + paragraph.length
if (selectedIndex >= currentPos && selectedIndex < paragraphEnd) {
containingParagraph = paragraph
break
}
currentPos = paragraphEnd + 2 // Account for the double newline
}
if (!containingParagraph) return undefined
// Split paragraph into sentences (basic sentence splitting)
// This regex splits on periods, exclamation marks, or question marks followed by space or end of string
const sentences = containingParagraph.split(/([.!?]+\s+)/).filter(s => s.trim().length > 0)
// Reconstruct sentences properly by joining sentence text with punctuation
const reconstructedSentences: string[] = []
for (let i = 0; i < sentences.length; i++) {
if (sentences[i].match(/^[.!?]+\s*$/)) {
// This is punctuation, attach it to previous sentence
if (reconstructedSentences.length > 0) {
reconstructedSentences[reconstructedSentences.length - 1] += sentences[i]
}
} else {
reconstructedSentences.push(sentences[i])
}
}
// Find which sentence contains the selected text
let selectedSentenceIndex = -1
for (let i = 0; i < reconstructedSentences.length; i++) {
if (reconstructedSentences[i].includes(selectedText)) {
selectedSentenceIndex = i
break
}
}
if (selectedSentenceIndex === -1) return undefined
// Build context from previous and next sentences
const contextParts: string[] = []
// Add previous sentence if it exists
if (selectedSentenceIndex > 0) {
contextParts.push(reconstructedSentences[selectedSentenceIndex - 1].trim())
}
// Add the selected sentence itself
contextParts.push(reconstructedSentences[selectedSentenceIndex].trim())
// Add next sentence if it exists
if (selectedSentenceIndex < reconstructedSentences.length - 1) {
contextParts.push(reconstructedSentences[selectedSentenceIndex + 1].trim())
}
// Only return context if we have more than just the selected sentence
return contextParts.length > 1 ? contextParts.join(' ') : undefined
}
/**
* Converts a NostrEvent to a Highlight object for immediate UI display
*/
export function eventToHighlight(event: NostrEvent): Highlight {
const highlightText = getHighlightText(event)
const context = getHighlightContext(event)
const comment = getHighlightComment(event)
const sourceEventPointer = getHighlightSourceEventPointer(event)
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
const sourceUrl = getHighlightSourceUrl(event)
const attributions = getHighlightAttributions(event)
const author = attributions.find(a => a.role === 'author')?.pubkey
const eventReference = sourceEventPointer?.id ||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
return {
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
content: highlightText,
tags: event.tags,
eventReference,
urlReference: sourceUrl,
author,
context,
comment
}
}

View File

@@ -11,6 +11,7 @@ import {
getHighlightAttributions
} from 'applesauce-core/helpers'
import { Highlight } from '../types/highlights'
import { RELAYS } from '../config/relays'
/**
* Deduplicate highlight events by ID
@@ -42,18 +43,9 @@ export const fetchHighlightsForArticle = async (
onHighlight?: (highlight: Highlight) => void
): Promise<Highlight[]> => {
try {
// Use well-known relays for highlights even if user isn't logged in
const highlightRelays = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.snort.social',
'wss://purplepag.es'
]
console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate)
console.log('🔍 Event ID:', eventId || 'none')
console.log('🔍 From relays:', highlightRelays)
console.log('🔍 From relays (including local):', RELAYS)
const seenIds = new Set<string>()
const processEvent = (event: NostrEvent): Highlight | null => {
@@ -89,7 +81,7 @@ export const fetchHighlightsForArticle = async (
// Query for highlights that reference this article via the 'a' tag
const aTagEvents = await lastValueFrom(
relayPool
.req(highlightRelays, { kinds: [9802], '#a': [articleCoordinate] })
.req(RELAYS, { kinds: [9802], '#a': [articleCoordinate] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
@@ -111,7 +103,7 @@ export const fetchHighlightsForArticle = async (
if (eventId) {
eTagEvents = await lastValueFrom(
relayPool
.req(highlightRelays, { kinds: [9802], '#e': [eventId] })
.req(RELAYS, { kinds: [9802], '#e': [eventId] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
@@ -183,6 +175,70 @@ export const fetchHighlightsForArticle = async (
}
}
/**
* Fetches highlights for a specific URL
* @param relayPool - The relay pool to query
* @param url - The external URL to find highlights for
*/
export const fetchHighlightsForUrl = async (
relayPool: RelayPool,
url: string
): Promise<Highlight[]> => {
try {
console.log('🔍 Fetching highlights (kind 9802) for URL:', url)
const seenIds = new Set<string>()
const rawEvents = await lastValueFrom(
relayPool
.req(RELAYS, { kinds: [9802], '#r': [url] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
seenIds.add(event.id)
}),
completeOnEose(),
takeUntil(timer(10000)),
toArray()
)
)
console.log('📊 Highlights for URL:', rawEvents.length)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map((event: NostrEvent) => {
const highlightText = getHighlightText(event)
const context = getHighlightContext(event)
const comment = getHighlightComment(event)
const sourceEventPointer = getHighlightSourceEventPointer(event)
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
const sourceUrl = getHighlightSourceUrl(event)
const attributions = getHighlightAttributions(event)
const author = attributions.find(a => a.role === 'author')?.pubkey
const eventReference = sourceEventPointer?.id ||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
return {
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
content: highlightText,
tags: event.tags,
eventReference,
urlReference: sourceUrl,
author,
context,
comment
}
})
return highlights.sort((a, b) => b.created_at - a.created_at)
} catch (error) {
console.error('Failed to fetch highlights for URL:', error)
return []
}
}
/**
* Fetches highlights created by a specific user
* @param relayPool - The relay pool to query

View File

@@ -22,6 +22,10 @@ export interface UserSettings {
highlightColorNostrverse?: string
highlightColorFriends?: string
highlightColorMine?: string
// Default highlight visibility toggles
defaultHighlightVisibilityNostrverse?: boolean
defaultHighlightVisibilityFriends?: boolean
defaultHighlightVisibilityMine?: boolean
}
export async function loadSettings(
@@ -30,10 +34,39 @@ export async function loadSettings(
pubkey: string,
relays: string[]
): Promise<UserSettings | null> {
console.log('⚙️ Loading settings from nostr...', { pubkey: pubkey.slice(0, 8) + '...', relays })
// First, check if we already have settings in the local event store
try {
const localEvent = await firstValueFrom(
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
)
if (localEvent) {
const content = getAppDataContent<UserSettings>(localEvent)
console.log('✅ Settings loaded from local store (cached):', content)
// Still fetch from relays in the background to get any updates
relayPool
.subscription(relays, {
kinds: [APP_DATA_KIND],
authors: [pubkey],
'#d': [SETTINGS_IDENTIFIER]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
return content || null
}
} catch (err) {
console.log('📭 No cached settings found, fetching from relays...')
}
// If not in local store, fetch from relays
return new Promise((resolve) => {
let hasResolved = false
const timeout = setTimeout(() => {
if (!hasResolved) {
console.warn('⚠️ Settings load timeout - no settings event found')
hasResolved = true
resolve(null)
}
@@ -57,16 +90,20 @@ export async function loadSettings(
)
if (event) {
const content = getAppDataContent<UserSettings>(event)
console.log('✅ Settings loaded from relays:', content)
resolve(content || null)
} else {
console.log('📭 No settings event found - using defaults')
resolve(null)
}
} catch {
} catch (err) {
console.error('❌ Error loading settings:', err)
resolve(null)
}
}
},
error: () => {
error: (err) => {
console.error('❌ Settings subscription error:', err)
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true
@@ -88,11 +125,17 @@ export async function saveSettings(
settings: UserSettings,
relays: string[]
): Promise<void> {
console.log('💾 Saving settings to nostr:', settings)
const draft = await factory.create(AppDataBlueprint, SETTINGS_IDENTIFIER, settings, false)
const signed = await factory.sign(draft)
console.log('📤 Publishing settings event:', signed.id, 'to', relays.length, 'relays')
eventStore.add(signed)
await relayPool.publish(relays, signed)
console.log('✅ Settings published successfully')
}
export function watchSettings(

View File

@@ -37,7 +37,7 @@ export interface IndividualBookmark {
tags: string[][]
parsedContent?: ParsedContent
author?: string
type: 'event' | 'article'
type: 'event' | 'article' | 'web'
isPrivate?: boolean
encryptedContent?: string
// When the item was added to the bookmark list (synthetic, for sorting)

View File

@@ -8,9 +8,9 @@ export function hexToRgb(hex: string): string {
export const HIGHLIGHT_COLORS = [
{ name: 'Yellow', value: '#ffff00' },
{ name: 'Orange', value: '#ff9500' },
{ name: 'Orange', value: '#f97316' },
{ name: 'Pink', value: '#ff69b4' },
{ name: 'Green', value: '#00ff7f' },
{ name: 'Blue', value: '#4da6ff' },
{ name: 'Purple', value: '#b19cd9' }
{ name: 'Purple', value: '#9333ea' }
]

View File

@@ -12,25 +12,83 @@ const FONT_FAMILIES: Record<string, string> = {
}
const loadedFonts = new Set<string>()
const loadingFonts = new Map<string, Promise<void>>()
export function loadFont(fontKey: string) {
if (fontKey === 'system' || loadedFonts.has(fontKey)) {
return
export async function loadFont(fontKey: string): Promise<void> {
if (fontKey === 'system') {
console.log('📝 Using system font')
return Promise.resolve()
}
if (loadedFonts.has(fontKey)) {
console.log('✅ Font already loaded:', fontKey)
return Promise.resolve()
}
// If font is currently loading, return the existing promise
if (loadingFonts.has(fontKey)) {
console.log('⏳ Font already loading:', fontKey)
return loadingFonts.get(fontKey)!
}
const fontFamily = FONT_FAMILIES[fontKey]
if (!fontFamily) {
console.warn(`Unknown font: ${fontKey}`)
return
return Promise.resolve()
}
// Create a link element to load the font from Bunny Fonts
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = `https://fonts.bunny.net/css?family=${encodeURIComponent(fontFamily.toLowerCase().replace(/ /g, '-'))}:400,400i,700,700i`
document.head.appendChild(link)
console.log('🔤 Loading font:', fontFamily)
loadedFonts.add(fontKey)
// Create a promise for this font loading
const loadPromise = new Promise<void>((resolve) => {
// Create a link element to load the font from Bunny Fonts
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = `https://fonts.bunny.net/css?family=${encodeURIComponent(fontFamily.toLowerCase().replace(/ /g, '-'))}:400,400i,700,700i`
// Wait for the stylesheet to load
link.onload = () => {
console.log('📄 Stylesheet loaded for:', fontFamily)
// Use Font Loading API to wait for the actual font to be ready
if ('fonts' in document) {
Promise.all([
document.fonts.load(`400 16px "${fontFamily}"`),
document.fonts.load(`700 16px "${fontFamily}"`)
]).then(() => {
console.log('✅ Font ready:', fontFamily)
loadedFonts.add(fontKey)
loadingFonts.delete(fontKey)
resolve()
}).catch((err) => {
console.warn('⚠️ Font loading failed:', fontFamily, err)
loadedFonts.add(fontKey) // Mark as loaded anyway to prevent retries
loadingFonts.delete(fontKey)
resolve()
})
} else {
// Fallback: just wait a bit for older browsers
setTimeout(() => {
console.log('✅ Font assumed ready (no Font Loading API):', fontFamily)
loadedFonts.add(fontKey)
loadingFonts.delete(fontKey)
resolve()
}, 100)
}
}
link.onerror = () => {
console.error('❌ Failed to load font stylesheet:', fontFamily)
loadedFonts.add(fontKey) // Mark as loaded to prevent retries
loadingFonts.delete(fontKey)
resolve() // Resolve anyway so we don't block
}
document.head.appendChild(link)
})
loadingFonts.set(fontKey, loadPromise)
return loadPromise
}
export function getFontFamily(fontKey: string | undefined): string {

View File

@@ -13,7 +13,10 @@ export interface UrlClassification {
buttonText: string
}
export const classifyUrl = (url: string): UrlClassification => {
export const classifyUrl = (url: string | undefined): UrlClassification => {
if (!url) {
return { type: 'article', buttonText: 'READ NOW' }
}
const urlLower = url.toLowerCase()
// Check for YouTube

View File

@@ -144,8 +144,6 @@ function tryMarkInTextNodes(
if (index === -1) continue
console.log(`✅ Found ${useNormalized ? 'normalized' : 'exact'} match:`, text.slice(0, 50))
let actualIndex = index
if (useNormalized) {
// Map normalized index back to original text
@@ -172,14 +170,26 @@ function tryMarkInTextNodes(
* Apply highlights to HTML content by injecting mark tags using DOM manipulation
*/
export function applyHighlightsToHTML(html: string, highlights: Highlight[], highlightStyle: 'marker' | 'underline' = 'marker'): string {
if (!html || highlights.length === 0) return html
if (!html || highlights.length === 0) {
console.log('⚠️ applyHighlightsToHTML: No HTML or highlights', { htmlLength: html?.length, highlightsCount: highlights.length })
return html
}
console.log('🔨 applyHighlightsToHTML: Processing', highlights.length, 'highlights')
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html
let appliedCount = 0
for (const highlight of highlights) {
const searchText = highlight.content.trim()
if (!searchText) continue
if (!searchText) {
console.warn('⚠️ Empty highlight content:', highlight.id)
continue
}
console.log('🔍 Searching for highlight:', searchText.substring(0, 50) + '...')
// Collect all text nodes
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
@@ -187,10 +197,21 @@ export function applyHighlightsToHTML(html: string, highlights: Highlight[], hig
let node: Node | null
while ((node = walker.nextNode())) textNodes.push(node as Text)
console.log('📄 Found', textNodes.length, 'text nodes to search')
// Try exact match first, then normalized match
tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
if (found) {
appliedCount++
console.log('✅ Highlight applied successfully')
} else {
console.warn('❌ Could not find match for highlight:', searchText.substring(0, 50))
}
}
console.log('🎉 Applied', appliedCount, '/', highlights.length, 'highlights')
return tempDiv.innerHTML
}

View File

@@ -10,22 +10,43 @@ export function normalizeUrl(url: string): string {
}
export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: string | undefined): Highlight[] {
if (!selectedUrl || highlights.length === 0) return []
if (!selectedUrl || highlights.length === 0) {
console.log('🔍 filterHighlightsByUrl: No URL or highlights', { selectedUrl, count: highlights.length })
return []
}
console.log('🔍 filterHighlightsByUrl:', { selectedUrl, totalHighlights: highlights.length })
// 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:')) {
console.log('📌 Nostr article - returning all', highlights.length, 'highlights')
return highlights
}
// For web URLs, filter by URL matching
const normalizedSelected = normalizeUrl(selectedUrl)
console.log('🔗 Normalized selected URL:', normalizedSelected)
return highlights.filter(h => {
if (!h.urlReference) return false
const filtered = highlights.filter(h => {
if (!h.urlReference) {
console.log('⚠️ Highlight has no urlReference:', h.id, 'eventReference:', h.eventReference)
return false
}
const normalizedRef = normalizeUrl(h.urlReference)
return normalizedSelected === normalizedRef ||
const matches = normalizedSelected === normalizedRef ||
normalizedSelected.includes(normalizedRef) ||
normalizedRef.includes(normalizedSelected)
if (matches) {
console.log('✅ URL match:', normalizedRef)
} else {
console.log('❌ URL mismatch:', normalizedRef, 'vs', normalizedSelected)
}
return matches
})
console.log('📊 Filtered to', filtered.length, 'highlights')
return filtered
}