Compare commits

..

9 Commits

7 changed files with 172 additions and 192 deletions

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

View File

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

View File

@@ -2,13 +2,12 @@ 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'
@@ -16,6 +15,38 @@ import { useToast } from './hooks/useToast'
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="/" 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 +54,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 +77,72 @@ 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()
// 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 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 +159,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

@@ -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 } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../services/settingsService'
import IconButton from './IconButton'
import ColorPicker from './ColorPicker'
@@ -7,6 +7,21 @@ 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',
}
interface SettingsProps {
settings: UserSettings
onSave: (settings: UserSettings) => Promise<void>
@@ -45,17 +60,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">

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

@@ -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' }
]