Compare commits

...

84 Commits

Author SHA1 Message Date
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
Gigi
ad54f2aaa5 chore: bump version to 0.2.0 2025-10-05 23:16:50 +01:00
Gigi
a6ea97b731 fix: replace any types with proper NostrEvent types
- Replace any type with NostrEvent | undefined in Bookmarks component
- Replace any type with NostrEvent in useArticleLoader hook
- Remove incorrect bookmark-to-article assignment
- All linter warnings resolved
- Type checks passing
2025-10-05 23:16:05 +01:00
Gigi
2f2e19fdf9 fix: move FAB to Bookmarks component for proper floating
Move HighlightButton outside .pane.main container by rendering it in Bookmarks component. This bypasses the CSS contain property that was preventing position:fixed from working properly. FAB now floats correctly in bottom-right corner.
2025-10-05 23:14:24 +01:00
Gigi
ce99600aa9 fix: move FAB outside reader container for proper viewport-fixed positioning
The CSS contain property on .reader was creating a new containing block that broke position:fixed. Moving FAB outside allows it to float properly.
2025-10-05 23:11:52 +01:00
Gigi
77bcc481b5 fix: revert FAB opacity to 0.4 when no text selected 2025-10-05 23:10:42 +01:00
Gigi
8bb97b3e4e fix: set FAB to 50% transparent when no text selected
Change opacity from 0 to 0.5 for better visibility
2025-10-05 23:10:27 +01:00
Gigi
2bbfa82eec refactor: make FAB fully transparent when no text selected
- Change opacity from 0.4 to 0 when no selection (fully transparent)
- Remove shadow when transparent for cleaner look
- Use pointerEvents: none to prevent interaction when invisible
- Remove disabled attribute, handle interaction via pointer events
- Button smoothly fades in/scales up when text is selected
2025-10-05 23:10:08 +01:00
Gigi
cc68e67726 refactor: change highlight button to FAB style
- Replace floating popup button with persistent FAB in bottom-right corner
- Button always visible but disabled when no text is selected
- Uses user's highlight color from settings
- Visual feedback: scales up and becomes opaque when text is selected
- Follows Google apps design pattern for floating action buttons
2025-10-05 23:09:36 +01:00
Gigi
f3a8cf1c23 fix: highlight button positioning with scroll
Change button from absolute to fixed positioning so it appears at the correct location relative to selected text regardless of scroll position
2025-10-05 23:06:33 +01:00
Gigi
290d9303b5 feat: add simple highlight creation feature
- Create HighlightButton component that appears on text selection
- Add highlightCreationService using EventFactory and HighlightBlueprint
- Integrate highlight button into ContentPanel with text selection detection
- Update Bookmarks to pass required props and refresh highlights after creation
- Publish highlights to NIP-84 relays automatically
- Only show button when user is logged in
2025-10-05 23:03:23 +01:00
Gigi
0ca62c4797 chore: bump version to 0.1.11 2025-10-05 22:54:47 +01:00
Gigi
1441d8d998 style: reduce padding between bookmark items and panel edge 2025-10-05 22:53:33 +01:00
Gigi
9252078fb7 refactor: rename 'underlines' to 'highlights' throughout codebase 2025-10-05 22:52:42 +01:00
Gigi
d5ab88082f feat: show author name in highlight cards 2025-10-05 22:50:20 +01:00
Gigi
a8e48ba280 feat: sync highlight level toggles between sidebar and main article text 2025-10-05 22:49:07 +01:00
Gigi
dbccb28113 fix: prevent bookmark text from being cut off in compact view 2025-10-05 22:47:59 +01:00
Gigi
b1f6ac88a6 fix: show highlights immediately when opening panel if already loaded 2025-10-05 22:46:39 +01:00
Gigi
c07797ff7c fix: resolve all linting and type errors 2025-10-05 22:45:57 +01:00
Gigi
41fb51c357 feat: stream highlights progressively as they arrive from relays 2025-10-05 22:42:39 +01:00
Gigi
5e2abfa8c7 fix: display article immediately without waiting for highlights to load 2025-10-05 22:40:54 +01:00
Gigi
7cf2b7d35d fix: remove redundant setReaderLoading call in error handler 2025-10-05 22:39:51 +01:00
Gigi
66f0b2bc3f fix: correct default highlight color for 'mine' to yellow (#ffff00) 2025-10-05 22:35:37 +01:00
Gigi
647cf1caf7 feat: update default highlight colors to orange for friends and purple for nostrverse 2025-10-05 22:34:54 +01:00
Gigi
d4e8e465b4 style: remove padding from collapsed sidebar buttons for flush alignment 2025-10-05 22:31:48 +01:00
Gigi
fa52d61c20 fix(highlights): prevent highlights panel from auto-opening on article load 2025-10-05 22:28:07 +01:00
Gigi
c407663c2b fix(settings): make startup preference checkboxes checked by default and remove redundant text 2025-10-05 22:26:04 +01:00
Gigi
e931f36dee fix(layout): remove all borders and reduce padding to glue expand buttons to main panel 2025-10-05 22:25:33 +01:00
Gigi
ba34e51803 fix(bookmarks): ensure both panels start collapsed on initial load regardless of saved settings 2025-10-05 22:24:32 +01:00
Gigi
c67d831efd fix(layout): remove all padding/margin between collapsed sidebar and main panel 2025-10-05 22:22:29 +01:00
Gigi
c1dedb248d fix(bookmarks): fix collapsed sidebar button being cut off by increasing width and padding 2025-10-05 22:21:16 +01:00
Gigi
b177907eb9 fix(bookmarks): reduce padding to prevent text truncation in compact view 2025-10-05 22:20:29 +01:00
Gigi
518c6d9714 feat(settings): set default font size to middle option (18px) 2025-10-05 22:19:51 +01:00
Gigi
89b14ce5b7 feat(toast): add login/logout success messages using existing toast system 2025-10-05 22:19:30 +01:00
Gigi
5f7aab90a7 feat(settings): align color pickers and labels for better visual layout 2025-10-05 22:18:55 +01:00
Gigi
6d41d95627 feat(highlights): update default colors to yellow, orange, purple 2025-10-05 22:17:39 +01:00
Gigi
9aea1f9a70 feat(settings): enhance preview with longer text and three-level highlights 2025-10-05 22:16:20 +01:00
Gigi
8594b733ef style(settings): remove 'Color' from highlight setting labels 2025-10-05 22:15:35 +01:00
Gigi
be42203944 remove(settings): remove legacy highlight color setting 2025-10-05 22:15:08 +01:00
Gigi
c51c1810c4 feat(settings): set Source Serif 4 as default reading font 2025-10-05 22:14:47 +01:00
Gigi
6bbc5eb1fc docs(settings): clarify that both panels default to collapsed state 2025-10-05 22:14:01 +01:00
Gigi
ff5c974557 style(icons): change user icon to fa-user-circle in sidebar header 2025-10-05 22:13:21 +01:00
Gigi
61bc64ea26 feat(auth): make user icon clickable to trigger login when logged out 2025-10-05 22:13:01 +01:00
Gigi
73da428cd7 remove(highlights): remove 'Show context' functionality from highlight items 2025-10-05 22:12:41 +01:00
Gigi
ce2ccd54b3 fix(lint): resolve all linting and TypeScript errors 2025-10-05 22:12:07 +01:00
Gigi
4f8bc0c641 style(bookmarks): move view mode controls to bottom of bookmarks sidebar 2025-10-05 22:11:14 +01:00
Gigi
d6edddc572 style(bookmarks): right-align all buttons except collapse button in sidebar header 2025-10-05 22:10:09 +01:00
Gigi
d275cb37ab fix(bookmarks): position expand button at top of collapsed sidebar 2025-10-05 22:09:04 +01:00
Gigi
959e83699a fix(auth): implement logout functionality to clear active account and localStorage 2025-10-05 22:08:11 +01:00
Gigi
6e0a88fbd9 style(bookmarks): reorder sidebar header buttons to collapse, refresh, settings, avatar, login/logout 2025-10-05 22:07:48 +01:00
Gigi
ba682dde1d style(panels): make left panel styling match right panel with consistent background and borders 2025-10-05 22:04:43 +01:00
Gigi
5e788b0026 style(highlights): move collapse button to far right of highlights header 2025-10-05 22:04:01 +01:00
Gigi
256540bf60 feat(bookmarks): add loading state to bookmark list with spinner 2025-10-05 22:03:12 +01:00
Gigi
e710391962 style(ui): replace all loading text with spinners per fontawesome rule 2025-10-05 22:02:01 +01:00
Gigi
29906397db fix(bookmarks): prevent decrypted JSON from showing as cyphertext in bookmark list 2025-10-05 22:00:53 +01:00
Gigi
aac4adeda6 feat(bookmarks): add refresh button to sidebar header with loading state 2025-10-05 22:00:18 +01:00
Gigi
008c14c14a style(bookmarks): make compact buttons monochrome and subtle (no green background) 2025-10-05 21:59:01 +01:00
Gigi
0798267084 style(bookmarks): ensure green buttons align to far right in compact view 2025-10-05 21:58:45 +01:00
Gigi
6088dcc395 style(highlights): show only external-link icon for source (no label) 2025-10-05 21:57:57 +01:00
Gigi
7425121746 style(highlights): apply level color to sidebar quote icon 2025-10-05 21:57:12 +01:00
Gigi
7735508c77 docs: rewrite README to be user-focused and non-technical 2025-10-05 21:56:35 +01:00
Gigi
f2422e9601 feat(highlights): color sidebar highlight items by level (mine/friends/nostrverse) 2025-10-05 21:54:02 +01:00
Gigi
336f2b62ab style(layout): use full-width three-pane with CSS vars; reduce padding; edge-to-edge side panels 2025-10-05 21:51:47 +01:00
Gigi
d3ad08dd61 refactor(reader): extract ReaderHeader to keep ContentPanel concise (<210 lines) 2025-10-05 21:46:31 +01:00
Gigi
d148433fcc fix(content): render markdown immediately while computing highlights; prevent initial login refresh from overwriting article highlights 2025-10-05 21:45:47 +01:00
Gigi
9638ab0b84 chore: bump version to 0.1.10 2025-10-05 21:20:16 +01:00
Gigi
8d7b853e75 fix: ensure highlights always render on markdown content
- Add logic to wait for HTML conversion when highlights need to be applied
- Prevent rendering plain markdown when highlights are pending
- Show ReactMarkdown fallback only when no highlights need to be applied
- Fixes default article highlights not showing
2025-10-05 21:15:30 +01:00
Gigi
cdbb920a5f fix: resolve linter errors
- Add missing useMemo import to Bookmarks component
- Remove unused NostrEvent import from contactService
- All ESLint checks passing
- All TypeScript type checks passing
2025-10-05 21:13:39 +01:00
Gigi
cc311c7dc4 fix: classify highlights before passing to ContentPanel
- Add classifiedHighlights memo in Bookmarks to ensure highlights have level property
- Pass classified highlights to ContentPanel so color-coded rendering works
- Reduce reader border-radius from 12px to 8px to reduce visual separation
- Fixes highlights not showing with proper colors on default article
2025-10-05 20:26:03 +01:00
Gigi
d4d54b1a7c fix: position toggle buttons directly adjacent to main panel
- Reduce padding on collapsed containers to minimal spacing
- Move spacing from pane containers to content containers
- Toggle buttons now appear immediately next to article view with no gap
2025-10-05 20:20:28 +01:00
Gigi
235d6e33a9 fix: make panel toggle buttons stick to main content
- Remove grid gap and use padding on expanded panels instead
- Toggle buttons now appear directly adjacent to main panel when collapsed
- Maintain visual spacing only when panels are expanded
- Improves UX by making collapse/expand buttons more accessible
2025-10-05 20:19:03 +01:00
Gigi
0fe1085457 feat: always show friends and user highlight buttons
- Show friends and user highlight buttons regardless of login status
- Disable buttons when user is not logged in (instead of hiding them)
- Add helpful tooltips indicating login is required
- Add disabled state styling with reduced opacity and not-allowed cursor
2025-10-05 20:18:06 +01:00
Gigi
65e7709c63 fix: remove Highlights title and count from panel, fix markdown rendering
- Remove 'Highlights' text and count number to save space in panel
- Fix markdown rendering fallback to always show content when finalHtml is not ready
- Simplify render logic by removing highlight count condition that prevented content display
2025-10-05 20:17:23 +01:00
Gigi
17b5ffd96e feat: implement three-level highlight system
- Add three highlight levels: nostrverse (all), friends (followed), and mine (user's own)
- Create contactService to fetch user's follow list from kind 3 events
- Add three configurable colors in settings (purple, orange, yellow defaults)
- Replace mode switcher with independent toggle buttons for each level
- Update highlight rendering to apply level-specific colors using CSS custom properties
- Add CSS styles for three-level highlights in both marker and underline modes
- Classify highlights dynamically based on user's context and follow list
- All three levels can be shown/hidden independently via toggle buttons
2025-10-05 20:11:10 +01:00
Gigi
7f95eae405 fix: ensure highlights are shown for markdown content
- Only show raw ReactMarkdown when there are no highlights
- Wait for finalHtml (with highlights) when highlights are present
- Prevents highlights from being bypassed during markdown conversion
2025-10-05 20:01:41 +01:00
Gigi
8f1e5e1082 fix: prevent highlight bleeding into sidebar
- Add overflow-x: hidden and contain: layout style to .pane.main
- Add overflow: hidden and contain: layout style to .reader
- Add contain: layout style to highlight elements
- Prevents yellow highlights from bleeding into the right sidebar
2025-10-05 19:08:43 +01:00
29 changed files with 1467 additions and 656 deletions

View File

@@ -0,0 +1,9 @@
---
description: when dealing with user and app settings
alwaysApply: false
---
We use nostr to load/save/sync our settings.
- https://nostrbook.dev/kinds/30078
- https://github.com/nostr-protocol/nips/blob/master/78.md

View File

@@ -3,4 +3,6 @@ description: when creating or modifying UI elements, especially related to icons
alwaysApply: false
---
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon. Always strive to keep the UI modern, beautiful, and minimalistic. Shy away from using too many colors, borders, glow, and animations.
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon. Always strive to keep the UI modern, beautiful, and minimalistic. Shy away from using too many colors, borders, glow, and animations.
Never write "Loading" - always show a spinner, and just a spinner.

212
README.md
View File

@@ -1,187 +1,77 @@
# Boris
A minimal nostr client for bookmark management, built with [applesauce](https://github.com/hzrd149/applesauce).
Your reading list for the Nostr world.
## Features
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.
- **Nostr Authentication**: Connect using your nostr account via browser extension
- **Bookmark Display**: View your nostr bookmarks as per [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md)
- **Content Classification**: Automatically detect and classify URLs (articles, videos, YouTube, images)
- **Reader Mode**: View article content inline with readable formatting
- **Collapsible Sidebar**: Expand/collapse bookmark list for focused reading
- **Profile Integration**: Display user profile images using applesauce ProfileModel
- **Relative Timestamps**: Human-friendly time display (e.g., "2 hours ago")
- **Event Links**: Quick access to view bookmarks on search.dergigi.com
- **Private Bookmarks**: Support for Amethyst-style hidden/encrypted bookmarks
- **Highlights Panel**: View and manage your NIP-84 highlights in a dedicated collapsible panel
- **Three-Pane Layout**: Bookmarks sidebar, content viewer, and highlights panel working together
- **Minimal UI**: Clean, modern interface focused on bookmark management
## The Vision
## Getting Started
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.
### Prerequisites
Boris has three "levels" of highlights for each article:
- user = yellow
- friends = orange
- nostrverse = purple
- Node.js 18+
- npm, pnpm, or yarn
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
### Installation
The user can toggle hide/show any of these "levels".
1. Clone the repository:
```bash
git clone <your-repo-url>
cd boris
```
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.
2. Install dependencies:
```bash
npm install
# or
pnpm install
# or
yarn install
```
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.
3. Start the development server:
```bash
npm run dev
# or
pnpm dev
# or
yarn dev
```
## What Boris does
4. Open your browser and navigate to `http://localhost:3000`
- Collects your saved links from Nostr and shows them as a tidy reading list
- Opens articles in a distractionfree reader with clear typography
- Shows community highlights layered on the article (yours, friends, everyone)
- Lets you collapse sidebars anytime for fullfocus reading
- Remembers simple preferences like view mode, fonts, and highlight style
## Usage
## How it works
1. **Connect**: Click "Connect with Nostr" to authenticate using your nostr account
2. **View Bookmarks**: Once connected, you'll see all your nostr bookmarks in the left sidebar
3. **View Highlights**: Your NIP-84 highlights appear in the right panel
4. **Navigate**: Click on bookmark URLs to view content in the center panel
5. **Collapse Panels**: Use the collapse buttons to hide/show sidebars for focused viewing
1. Connect your Nostr account.
- Click “Connect” and approve with your usual Nostr signer.
2. Browse your bookmarks.
- Your lists and items appear on the left. Pick anything to read.
3. Read in comfort.
- The center panel renders a readable article view with images and headings.
4. See what people highlighted.
- The right panel shows highlights by level:
- Mine (your highlights)
- Friends (people you follow)
- Nostrverse (everyone else)
- Each level has its own color. Click any highlight to jump to that spot.
5. Focus when you want.
- Collapse one or both side panels. The layout adapts without wasting space.
## Technical Details
## Why people like Boris
- Built with React and TypeScript
- Uses [applesauce-core](https://github.com/hzrd149/applesauce) for nostr functionality
- Implements [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) for bookmark management
- Supports both individual bookmarks and bookmark lists
- No noise: Just your saved links and the best excerpts others found
- Fast by default: Opens instantly in your browser
- Portable: Works with any Nostr account; your data travels with you
- Designed for reading: Smooth navigation and instant scrolltohighlight
## Development
## Tips
### Project Structure
- Hover icons and counters to see what they do — most controls are discoverable.
- Lots of highlights? Scan the right panel and click to jump between them.
- Open Settings to switch fonts, tweak highlight styles, and change the list view.
```
src/
├── components/
│ ├── Login.tsx # Authentication component
│ ├── Bookmarks.tsx # Main bookmarks view with layout
│ ├── BookmarkList.tsx # Bookmark list sidebar
│ ├── BookmarkItem.tsx # Individual bookmark card
│ ├── SidebarHeader.tsx # Header bar with collapse, profile, logout
│ ├── ContentPanel.tsx # Content viewer panel
│ ├── HighlightsPanel.tsx # Highlights sidebar panel (NIP-84)
│ ├── HighlightItem.tsx # Individual highlight display
│ ├── IconButton.tsx # Reusable icon button component
│ ├── ContentWithResolvedProfiles.tsx # Profile mention resolver
│ ├── ResolvedMention.tsx # Nostr mention component
│ └── kindIcon.ts # Kind-specific icon mapping
├── services/
│ ├── bookmarkService.ts # Main bookmark fetching orchestration
│ ├── bookmarkProcessing.ts # Decryption and processing pipeline
│ ├── bookmarkHelpers.ts # Shared types, guards, and utilities
│ ├── bookmarkEvents.ts # Event type handling and deduplication
│ ├── highlightService.ts # Highlight fetching (NIP-84)
│ └── readerService.ts # Content extraction via reader API
├── types/
│ ├── bookmarks.ts # Bookmark type definitions
│ ├── highlights.ts # Highlight type definitions (NIP-84)
│ ├── nostr.d.ts # Nostr type augmentations
│ └── relative-time.d.ts # relative-time package types
├── utils/
│ ├── bookmarkUtils.tsx # Bookmark rendering utilities
│ └── helpers.ts # General helper functions
├── App.tsx # Main application component
├── main.tsx # Application entry point
└── index.css # Global styles
```
## Privacy and data
### Private (hidden) bookmarks (Amethyst-style)
- Boris doesnt ask for an email or create a new account — it connects to your existing Nostr identity.
- Your bookmarks and highlights live on Nostr. Boris reads from the network and renders everything locally in your browser.
We support Amethyst-style private (hidden) bookmark lists alongside public ones (NIP51):
## Troubleshooting
- **Detection and unlock**
- Use `Helpers.hasHiddenTags(evt)` and `Helpers.isHiddenTagsLocked(evt)` to detect hidden tags.
- First try `Helpers.unlockHiddenTags(evt, signer)`; if that fails, try with `'nip44'`.
- For events with encrypted `content` that arent recognized as supporting hidden tags (e.g. kind 30001), manually decrypt:
- Prefer `signer.nip44.decrypt(evt.pubkey, evt.content)`, fallback to `signer.nip04.decrypt(evt.pubkey, evt.content)`.
- **Parsing and rendering**
- Decrypted `content` is JSON `string[][]` (tags). Convert with `Helpers.parseBookmarkTags(hiddenTags)`.
- Map to `IndividualBookmark[]` via our `processApplesauceBookmarks(..., isPrivate=true)` and append to the private list so they render immediately alongside public items.
- **Caching for downstream helpers**
- Cache manual results on the event with `BookmarkHiddenSymbol` and also store the decrypted blob under `EncryptedContentSymbol` to aid debugging and hydration.
- **Structure**
- `src/services/bookmarkService.ts`: orchestrates fetching, hydration, and assembling the final bookmark payload.
- `src/services/bookmarkProcessing.ts`: decryption/collection pipeline (unlock, manual decrypt, parse, merge).
- `src/services/bookmarkHelpers.ts`: shared types, guards, mapping, hydration, and symbols.
- `src/services/bookmarkEvents.ts`: event type and deduplication for NIP51 lists/sets.
- **Notes**
- We avoid `any` via narrow type guards for `nip44`/`nip04` decrypt functions.
- Files are kept small and DRY per project rules.
- Built on applesauce helpers (`Helpers.getPublicBookmarks`, `Helpers.getHiddenBookmarks`, etc.). See applesauce docs: https://hzrd149.github.io/applesauce/typedoc/modules.html
### Building for Production
```bash
npm run build
# or
pnpm build
# or
yarn build
```
## TODO
### High Priority
- [ ] **Mobile Responsive Design**: Optimize sidebar and content panel for mobile devices
- [ ] **Keyboard Shortcuts**: Add keyboard navigation (collapse sidebar, navigate bookmarks)
- [ ] **Search & Filter**: Add ability to search bookmarks by title, URL, or content
- [ ] **Error Handling**: Improve error states and retry logic for failed fetches
- [ ] **Loading States**: Better skeleton screens and loading indicators
### Medium Priority
- [ ] **Bookmark Creation**: Add ability to create new bookmarks
- [ ] **Bookmark Editing**: Edit existing bookmark metadata and tags
- [ ] **Bookmark Deletion**: Remove bookmarks from lists
- [ ] **Sorting Options**: Sort by date, title, kind, or custom order
- [ ] **Bulk Actions**: Select and perform actions on multiple bookmarks
- [ ] **Video Embeds**: Inline YouTube and video playback for video bookmarks
### Nice to Have
- [ ] **Dark/Light Mode Toggle**: User preference for color scheme
- [ ] **Export Functionality**: Export bookmarks as JSON, CSV, or HTML
- [ ] **Import Bookmarks**: Import from browser bookmarks or other formats
- [ ] **Tags & Categories**: Better organization with custom tags
- [ ] **Bookmark Collections**: Create and manage custom bookmark collections
- [ ] **Offline Support**: Cache bookmarks for offline viewing
- [ ] **Share Bookmarks**: Generate shareable links to bookmark lists
- [ ] **Performance Optimization**: Virtual scrolling for large bookmark lists
- [ ] **Browser Extension**: Quick bookmark saving from any page
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request. Make sure to:
- Follow the existing code style
- Keep files under 210 lines
- Use conventional commits
- Run linter and type checks before submitting
- If something looks empty, try opening another article and coming back — network data can arrive in bursts.
- Not every article has highlights yet; they grow as the community reads.
## License
MIT

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-8PiwZoBK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dljx1pJR.css">
<script type="module" crossorigin src="/assets/index--wClm1wz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bj-Uhit8.css">
</head>
<body>
<div id="root"></div>

View File

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

View File

@@ -1,32 +1,68 @@
import { useState, useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { EventStoreProvider, AccountsProvider } from 'applesauce-react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
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'
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)
const [relayPool, setRelayPool] = useState<RelayPool | null>(null)
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)
@@ -41,70 +77,81 @@ 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()
}
}, [])
if (!eventStore || !accountManager || !relayPool) {
return <div>Loading...</div>
return (
<div className="loading">
<FontAwesomeIcon icon={faSpinner} spin />
</div>
)
}
return (
@@ -112,21 +159,16 @@ function App() {
<AccountsProvider manager={accountManager}>
<BrowserRouter>
<div className="app">
<Routes>
<Route
path="/a/:naddr"
element={
<Bookmarks
relayPool={relayPool}
onLogout={() => {}}
/>
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
<Route path="/login" element={<Login onLogin={() => {}} />} />
</Routes>
<AppRoutes relayPool={relayPool} showToast={showToast} />
</div>
</BrowserRouter>
{toastMessage && (
<Toast
message={toastMessage}
type={toastType}
onClose={clearToast}
/>
)}
</AccountsProvider>
</EventStoreProvider>
)

View File

@@ -1,10 +1,11 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark } from '@fortawesome/free-solid-svg-icons'
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
import { Bookmark } from '../types/bookmarks'
import { BookmarkItem } from './BookmarkItem'
import { formatDate, renderParsedContent } from '../utils/bookmarkUtils'
import SidebarHeader from './SidebarHeader'
import IconButton from './IconButton'
import { ViewMode } from './Bookmarks'
interface BookmarkListProps {
@@ -17,6 +18,9 @@ interface BookmarkListProps {
onViewModeChange: (mode: ViewMode) => void
selectedUrl?: string
onOpenSettings: () => void
onRefresh?: () => void
isRefreshing?: boolean
loading?: boolean
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
@@ -28,7 +32,10 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
viewMode,
onViewModeChange,
selectedUrl,
onOpenSettings
onOpenSettings,
onRefresh,
isRefreshing,
loading = false
}) => {
if (isCollapsed) {
// Check if the selected URL is in bookmarks
@@ -57,12 +64,16 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
<SidebarHeader
onToggleCollapse={onToggleCollapse}
onLogout={onLogout}
viewMode={viewMode}
onViewModeChange={onViewModeChange}
onOpenSettings={onOpenSettings}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
/>
{bookmarks.length === 0 ? (
{loading ? (
<div className="loading">
<FontAwesomeIcon icon={faSpinner} spin />
</div>
) : bookmarks.length === 0 ? (
<div className="empty-state">
<p>No bookmarks found.</p>
<p>Add bookmarks using your nostr client to see them here.</p>
@@ -139,6 +150,29 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
))}
</div>
)}
<div className="view-mode-controls">
<IconButton
icon={faList}
onClick={() => onViewModeChange('compact')}
title="Compact list view"
ariaLabel="Compact list view"
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faThLarge}
onClick={() => onViewModeChange('cards')}
title="Cards view"
ariaLabel="Cards view"
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faImage}
onClick={() => onViewModeChange('large')}
title="Large preview view"
ariaLabel="Large preview view"
variant={viewMode === 'large' ? 'primary' : 'ghost'}
/>
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useMemo } from 'react'
import { useParams } from 'react-router-dom'
import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks'
@@ -8,6 +8,7 @@ import { Highlight } from '../types/highlights'
import { BookmarkList } from './BookmarkList'
import { fetchBookmarks } from '../services/bookmarkService'
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
import { fetchContacts } from '../services/contactService'
import ContentPanel from './ContentPanel'
import { HighlightsPanel } from './HighlightsPanel'
import { ReadableContent } from '../services/readerService'
@@ -16,7 +17,11 @@ import Toast from './Toast'
import { useSettings } from '../hooks/useSettings'
import { useArticleLoader } from '../hooks/useArticleLoader'
import { loadContent, BookmarkReference } from '../utils/contentLoader'
import { HighlightMode } from './HighlightsPanel'
import { HighlightVisibility } from './HighlightsPanel'
import { HighlightButton, HighlightButtonRef } from './HighlightButton'
import { createHighlight } from '../services/highlightCreationService'
import { useRef, useCallback } from 'react'
import { NostrEvent } from 'nostr-tools'
export type ViewMode = 'compact' | 'cards' | 'large'
interface BookmarksProps {
@@ -27,6 +32,7 @@ interface BookmarksProps {
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const { naddr } = useParams<{ naddr?: string }>()
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [bookmarksLoading, setBookmarksLoading] = useState(true)
const [highlights, setHighlights] = useState<Highlight[]>([])
const [highlightsLoading, setHighlightsLoading] = useState(true)
const [selectedUrl, setSelectedUrl] = useState<string | undefined>(undefined)
@@ -35,15 +41,23 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const [isCollapsed, setIsCollapsed] = useState(true) // Start collapsed
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(true) // Start collapsed
const [viewMode, setViewMode] = useState<ViewMode>('compact')
const [showUnderlines, setShowUnderlines] = useState(true)
const [showHighlights, setShowHighlights] = useState(true)
const [selectedHighlightId, setSelectedHighlightId] = useState<string | undefined>(undefined)
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 [currentArticle, setCurrentArticle] = useState<NostrEvent | undefined>(undefined) // Store the current article event
const [highlightVisibility, setHighlightVisibility] = useState<HighlightVisibility>({
nostrverse: true,
friends: true,
mine: true
})
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [isRefreshing, setIsRefreshing] = useState(false)
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
const eventStore = useEventStore()
const highlightButtonRef = useRef<HighlightButtonRef>(null)
const { settings, saveSettings, toastMessage, toastType, clearToast } = useSettings({
relayPool,
@@ -60,32 +74,48 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setReaderContent,
setReaderLoading,
setIsCollapsed,
setIsHighlightsCollapsed,
setHighlights,
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId
setCurrentArticleEventId,
setCurrentArticle
})
// Load initial data on login
useEffect(() => {
if (!relayPool || !activeAccount) return
handleFetchBookmarks()
handleFetchHighlights()
// Avoid overwriting article-specific highlights during initial article load
// If an article is being viewed (naddr present), let useArticleLoader own the first highlights set
if (!naddr) {
handleFetchHighlights()
}
handleFetchContacts()
}, [relayPool, activeAccount?.pubkey])
const handleFetchContacts = async () => {
if (!relayPool || !activeAccount) return
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
setFollowedPubkeys(contacts)
}
// Apply UI settings
useEffect(() => {
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
if (settings.showUnderlines !== undefined) setShowUnderlines(settings.showUnderlines)
if (settings.sidebarCollapsed !== undefined) setIsCollapsed(settings.sidebarCollapsed)
if (settings.highlightsCollapsed !== undefined) setIsHighlightsCollapsed(settings.highlightsCollapsed)
if (settings.showHighlights !== undefined) setShowHighlights(settings.showHighlights)
// Always start with both panels collapsed on initial load
// Don't apply saved collapse settings on initial load - let user control them
}, [settings])
const handleFetchBookmarks = async () => {
if (!relayPool || !activeAccount) return
const fullAccount = accountManager.getActive()
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks)
setBookmarksLoading(true)
try {
const fullAccount = accountManager.getActive()
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks)
} finally {
setBookmarksLoading(false)
}
}
const handleFetchHighlights = async () => {
@@ -95,13 +125,18 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
try {
// If we're viewing an article, fetch highlights for that article
if (currentArticleCoordinate) {
const fetchedHighlights = await fetchHighlightsForArticle(
const highlightsList: Highlight[] = []
await fetchHighlightsForArticle(
relayPool,
currentArticleCoordinate,
currentArticleEventId
currentArticleEventId,
(highlight) => {
// Render each highlight immediately as it arrives
highlightsList.push(highlight)
setHighlights([...highlightsList].sort((a, b) => b.created_at - a.created_at))
}
)
console.log(`🔄 Refreshed ${fetchedHighlights.length} highlights for article`)
setHighlights(fetchedHighlights)
console.log(`🔄 Refreshed ${highlightsList.length} highlights for article`)
}
// Otherwise, if logged in, fetch user's own highlights
else if (activeAccount) {
@@ -115,18 +150,50 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
}
}
const handleRefreshBookmarks = async () => {
if (!relayPool || !activeAccount || isRefreshing) return
setIsRefreshing(true)
try {
await handleFetchBookmarks()
await handleFetchHighlights()
await handleFetchContacts()
} catch (err) {
console.error('Failed to refresh bookmarks:', err)
} finally {
setIsRefreshing(false)
}
}
// Classify highlights with levels based on user context
const classifiedHighlights = useMemo(() => {
return highlights.map(h => {
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
if (h.pubkey === activeAccount?.pubkey) {
level = 'mine'
} else if (followedPubkeys.has(h.pubkey)) {
level = 'friends'
}
return { ...h, level }
})
}, [highlights, activeAccount?.pubkey, followedPubkeys])
const handleSelectUrl = async (url: string, bookmark?: BookmarkReference) => {
if (!relayPool) return
setSelectedUrl(url)
setReaderLoading(true)
setReaderContent(undefined)
setCurrentArticle(undefined) // Clear previous article
setShowSettings(false)
if (settings.collapseOnArticleOpen !== false) setIsCollapsed(true)
try {
const content = await loadContent(url, relayPool, bookmark)
setReaderContent(content)
// Note: currentArticle is set by useArticleLoader when loading Nostr articles
// For web bookmarks, there's no article event to set
} catch (err) {
console.warn('Failed to fetch content:', err)
} finally {
@@ -134,6 +201,54 @@ 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)
}, [])
const handleClearSelection = useCallback(() => {
highlightButtonRef.current?.clearSelection()
}, [])
const handleCreateHighlight = useCallback(async (text: string) => {
if (!activeAccount || !relayPool || !currentArticle) {
console.error('Missing requirements for highlight creation')
return
}
try {
await createHighlight(
text,
currentArticle,
activeAccount,
relayPool
)
console.log('✅ Highlight created successfully!')
highlightButtonRef.current?.clearSelection()
// Trigger refresh of highlights
handleHighlightCreated()
} catch (error) {
console.error('Failed to create highlight:', error)
}
}, [activeAccount, relayPool, currentArticle, handleHighlightCreated])
return (
<>
<div className={`three-pane ${isCollapsed ? 'sidebar-collapsed' : ''} ${isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
@@ -152,6 +267,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setIsCollapsed(true)
setIsHighlightsCollapsed(true)
}}
onRefresh={handleRefreshBookmarks}
isRefreshing={isRefreshing}
loading={bookmarksLoading}
/>
</div>
<div className="pane main">
@@ -169,8 +287,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
markdown={readerContent?.markdown}
image={readerContent?.image}
selectedUrl={selectedUrl}
highlights={highlights}
showUnderlines={showUnderlines}
highlights={classifiedHighlights}
showHighlights={showHighlights}
highlightStyle={settings.highlightStyle || 'marker'}
highlightColor={settings.highlightColor || '#ffff00'}
onHighlightClick={(id) => {
@@ -178,6 +296,11 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
if (isHighlightsCollapsed) setIsHighlightsCollapsed(false)
}}
selectedHighlightId={selectedHighlightId}
highlightVisibility={highlightVisibility}
onTextSelection={handleTextSelection}
onClearSelection={handleClearSelection}
currentUserPubkey={activeAccount?.pubkey}
followedPubkeys={followedPubkeys}
/>
)}
</div>
@@ -189,16 +312,24 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
onToggleCollapse={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
onSelectUrl={handleSelectUrl}
selectedUrl={selectedUrl}
onToggleUnderlines={setShowUnderlines}
onToggleHighlights={setShowHighlights}
selectedHighlightId={selectedHighlightId}
onRefresh={handleFetchHighlights}
onHighlightClick={setSelectedHighlightId}
currentUserPubkey={activeAccount?.pubkey}
highlightMode={highlightMode}
onHighlightModeChange={setHighlightMode}
highlightVisibility={highlightVisibility}
onHighlightVisibilityChange={setHighlightVisibility}
followedPubkeys={followedPubkeys}
/>
</div>
</div>
{activeAccount && relayPool && (
<HighlightButton
ref={highlightButtonRef}
onHighlight={handleCreateHighlight}
highlightColor={settings.highlightColor || '#ffff00'}
/>
)}
{toastMessage && (
<Toast
message={toastMessage}

View File

@@ -1,13 +1,15 @@
import React, { useMemo, useEffect, useRef, useState } from 'react'
import React, { useMemo, useEffect, useRef, useState, useCallback } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { applyHighlightsToHTML } from '../utils/highlightMatching'
import { readingTime } from 'reading-time-estimator'
import { filterHighlightsByUrl } from '../utils/urlHelpers'
import { hexToRgb } from '../utils/colorHelpers'
import ReaderHeader from './ReaderHeader'
import { HighlightVisibility } from './HighlightsPanel'
interface ContentPanelProps {
loading: boolean
@@ -17,11 +19,17 @@ interface ContentPanelProps {
selectedUrl?: string
image?: string
highlights?: Highlight[]
showUnderlines?: boolean
showHighlights?: boolean
highlightStyle?: 'marker' | 'underline'
highlightColor?: string
onHighlightClick?: (highlightId: string) => void
selectedHighlightId?: string
highlightVisibility?: HighlightVisibility
currentUserPubkey?: string
followedPubkeys?: Set<string>
// For highlight creation
onTextSelection?: (text: string) => void
onClearSelection?: () => void
}
const ContentPanel: React.FC<ContentPanelProps> = ({
@@ -32,17 +40,45 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
selectedUrl,
image,
highlights = [],
showUnderlines = true,
showHighlights = true,
highlightStyle = 'marker',
highlightColor = '#ffff00',
onHighlightClick,
selectedHighlightId
selectedHighlightId,
highlightVisibility = { nostrverse: true, friends: true, mine: true },
currentUserPubkey,
followedPubkeys = new Set(),
// For highlight creation
onTextSelection,
onClearSelection
}) => {
const contentRef = useRef<HTMLDivElement>(null)
const markdownPreviewRef = useRef<HTMLDivElement>(null)
const [renderedHtml, setRenderedHtml] = useState<string>('')
const relevantHighlights = useMemo(() => filterHighlightsByUrl(highlights, selectedUrl), [selectedUrl, highlights])
// Filter highlights by URL and visibility settings
const relevantHighlights = useMemo(() => {
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
// Apply visibility filtering
return urlFiltered
.map(h => {
// Classify highlight level
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
if (h.pubkey === currentUserPubkey) {
level = 'mine'
} else if (followedPubkeys.has(h.pubkey)) {
level = 'friends'
}
return { ...h, level }
})
.filter(h => {
// Filter by visibility settings
if (h.level === 'mine') return highlightVisibility.mine
if (h.level === 'friends') return highlightVisibility.friends
return highlightVisibility.nostrverse
})
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys])
// Convert markdown to HTML when markdown content changes
useEffect(() => {
@@ -66,13 +102,14 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const sourceHtml = markdown ? renderedHtml : html
if (!sourceHtml) return ''
// Apply highlights if we have them and underlines are shown
if (showUnderlines && relevantHighlights.length > 0) {
// Apply highlights if we have them and highlights are enabled
if (showHighlights && relevantHighlights.length > 0) {
return applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
}
return sourceHtml
}, [html, renderedHtml, markdown, relevantHighlights, showUnderlines, highlightStyle])
}, [html, renderedHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
// Attach click handlers to highlight marks
useEffect(() => {
@@ -127,6 +164,26 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const hasHighlights = relevantHighlights.length > 0
// Handle text selection for highlight creation
const handleMouseUp = useCallback(() => {
setTimeout(() => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
onClearSelection?.()
return
}
const range = selection.getRangeAt(0)
const text = selection.toString().trim()
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
onTextSelection?.(text)
} else {
onClearSelection?.()
}
}, 10)
}, [onTextSelection, onClearSelection])
if (!selectedUrl) {
return (
<div className="reader empty">
@@ -140,7 +197,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<div className="reader loading">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin />
<span>Loading content</span>
</div>
</div>
)
@@ -159,48 +215,40 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</div>
)}
{image && (
<div className="reader-hero-image">
<img src={image} alt={title || 'Article image'} />
</div>
)}
{title && (
<div className="reader-header">
<h2 className="reader-title">{title}</h2>
<div className="reader-meta">
{readingStats && (
<div className="reading-time">
<FontAwesomeIcon icon={faClock} />
<span>{readingStats.text}</span>
</div>
)}
{hasHighlights && (
<div className="highlight-indicator">
<FontAwesomeIcon icon={faHighlighter} />
<span>{relevantHighlights.length} highlight{relevantHighlights.length !== 1 ? 's' : ''}</span>
</div>
)}
</div>
</div>
)}
<ReaderHeader
title={title}
image={image}
readingTimeText={readingStats ? readingStats.text : null}
hasHighlights={hasHighlights}
highlightCount={relevantHighlights.length}
/>
{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>
markdown ? (
finalHtml ? (
<div
ref={contentRef}
className="reader-markdown"
dangerouslySetInnerHTML={{ __html: finalHtml }}
onMouseUp={handleMouseUp}
/>
) : (
<div
ref={contentRef}
className="reader-markdown"
onMouseUp={handleMouseUp}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{markdown}
</ReactMarkdown>
</div>
)
) : (
<div className="reader-html" ref={contentRef} />
<div
ref={contentRef}
className="reader-html"
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
onMouseUp={handleMouseUp}
/>
)
) : (
<div className="reader empty">

View File

@@ -0,0 +1,79 @@
import React, { useCallback, useImperativeHandle, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter } from '@fortawesome/free-solid-svg-icons'
interface HighlightButtonProps {
onHighlight: (text: string) => void
highlightColor?: string
}
export interface HighlightButtonRef {
updateSelection: (text: string) => void
clearSelection: () => void
}
export const HighlightButton = React.forwardRef<HighlightButtonRef, HighlightButtonProps>(
({ onHighlight, highlightColor = '#ffff00' }, ref) => {
const currentSelectionRef = useRef<string>('')
const [hasSelection, setHasSelection] = useState(false)
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (currentSelectionRef.current) {
onHighlight(currentSelectionRef.current)
}
},
[onHighlight]
)
// Expose methods to update selection
useImperativeHandle(ref, () => ({
updateSelection: (text: string) => {
currentSelectionRef.current = text
setHasSelection(!!text)
},
clearSelection: () => {
currentSelectionRef.current = ''
setHasSelection(false)
}
}))
return (
<button
className="highlight-fab"
style={{
position: 'fixed',
bottom: '32px',
right: '32px',
zIndex: 1000,
width: '56px',
height: '56px',
borderRadius: '50%',
backgroundColor: highlightColor,
color: '#000',
border: 'none',
boxShadow: hasSelection ? '0 4px 12px rgba(0, 0, 0, 0.3)' : 'none',
cursor: hasSelection ? 'pointer' : 'default',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.3s ease',
opacity: hasSelection ? 1 : 0.4,
transform: hasSelection ? 'scale(1)' : 'scale(0.8)',
pointerEvents: hasSelection ? 'auto' : 'none',
userSelect: 'none'
}}
onClick={handleClick}
aria-label="Create highlight from selection"
title={hasSelection ? 'Create highlight' : ''}
>
<FontAwesomeIcon icon={faHighlighter} size="lg" />
</button>
)
}
)
HighlightButton.displayName = 'HighlightButton'

View File

@@ -1,11 +1,17 @@
import React, { useEffect, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faLink, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'
import { faQuoteLeft, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { formatDistanceToNow } from 'date-fns'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
interface HighlightWithLevel extends Highlight {
level?: 'mine' | 'friends' | 'nostrverse'
}
interface HighlightItemProps {
highlight: Highlight
highlight: HighlightWithLevel
onSelectUrl?: (url: string) => void
isSelected?: boolean
onHighlightClick?: (highlightId: string) => void
@@ -14,6 +20,16 @@ interface HighlightItemProps {
export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelectUrl, isSelected, onHighlightClick }) => {
const itemRef = useRef<HTMLDivElement>(null)
// Resolve the profile of the user who made the highlight
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
// Get display name for the user
const getUserDisplayName = () => {
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
}
useEffect(() => {
if (isSelected && itemRef.current) {
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
@@ -45,7 +61,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
return (
<div
ref={itemRef}
className={`highlight-item ${isSelected ? 'selected' : ''}`}
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
data-highlight-id={highlight.id}
onClick={handleItemClick}
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
@@ -65,14 +81,12 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
</div>
)}
{highlight.context && (
<details className="highlight-context">
<summary>Show context</summary>
<p className="context-text">{highlight.context}</p>
</details>
)}
<div className="highlight-meta">
<span className="highlight-author">
{getUserDisplayName()}
</span>
<span className="highlight-meta-separator"></span>
<span className="highlight-time">
{formatDistanceToNow(new Date(highlight.created_at * 1000), { addSuffix: true })}
</span>
@@ -84,10 +98,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
rel="noopener noreferrer"
onClick={(e) => highlight.urlReference && onSelectUrl ? handleLinkClick(highlight.urlReference, e) : undefined}
className="highlight-source"
title={highlight.eventReference ? 'View on Nostr' : 'View source'}
title={highlight.eventReference ? 'Open on Nostr' : 'Open source'}
>
<FontAwesomeIcon icon={highlight.eventReference ? faLink : faExternalLinkAlt} />
<span>{highlight.eventReference ? 'Nostr event' : 'Source'}</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</a>
)}
</div>

View File

@@ -1,10 +1,14 @@
import React, { useMemo, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate, faUser, faUserGroup } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faGlobe } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
export type HighlightMode = 'mine' | 'others'
export interface HighlightVisibility {
nostrverse: boolean
friends: boolean
mine: boolean
}
interface HighlightsPanelProps {
highlights: Highlight[]
@@ -13,13 +17,14 @@ interface HighlightsPanelProps {
onToggleCollapse: () => void
onSelectUrl?: (url: string) => void
selectedUrl?: string
onToggleUnderlines?: (show: boolean) => void
onToggleHighlights?: (show: boolean) => void
selectedHighlightId?: string
onRefresh?: () => void
onHighlightClick?: (highlightId: string) => void
currentUserPubkey?: string
highlightMode?: HighlightMode
onHighlightModeChange?: (mode: HighlightMode) => void
highlightVisibility?: HighlightVisibility
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
followedPubkeys?: Set<string>
}
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
@@ -29,23 +34,24 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onToggleCollapse,
onSelectUrl,
selectedUrl,
onToggleUnderlines,
onToggleHighlights,
selectedHighlightId,
onRefresh,
onHighlightClick,
currentUserPubkey,
highlightMode = 'others',
onHighlightModeChange
highlightVisibility = { nostrverse: true, friends: true, mine: true },
onHighlightVisibilityChange,
followedPubkeys = new Set()
}) => {
const [showUnderlines, setShowUnderlines] = useState(true)
const [showHighlights, setShowHighlights] = useState(true)
const handleToggleUnderlines = () => {
const newValue = !showUnderlines
setShowUnderlines(newValue)
onToggleUnderlines?.(newValue)
const handleToggleHighlights = () => {
const newValue = !showHighlights
setShowHighlights(newValue)
onToggleHighlights?.(newValue)
}
// Filter highlights based on mode and URL
// Filter highlights based on visibility levels and URL
const filteredHighlights = useMemo(() => {
if (!selectedUrl) return highlights
@@ -75,18 +81,25 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
})
}
// Filter by mode (mine vs others)
if (!currentUserPubkey) {
// If no user is logged in, show all highlights (others mode only makes sense)
return urlFiltered
}
if (highlightMode === 'mine') {
return urlFiltered.filter(h => h.pubkey === currentUserPubkey)
} else {
return urlFiltered.filter(h => h.pubkey !== currentUserPubkey)
}
}, [highlights, selectedUrl, highlightMode, currentUserPubkey])
// Classify and filter by visibility levels
return urlFiltered
.map(h => {
// Classify highlight level
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
if (h.pubkey === currentUserPubkey) {
level = 'mine'
} else if (followedPubkeys.has(h.pubkey)) {
level = 'friends'
}
return { ...h, level }
})
.filter(h => {
// Filter by visibility settings
if (h.level === 'mine') return highlightVisibility.mine
if (h.level === 'friends') return highlightVisibility.friends
return highlightVisibility.nostrverse
})
}, [highlights, selectedUrl, highlightVisibility, currentUserPubkey, followedPubkeys])
if (isCollapsed) {
const hasHighlights = filteredHighlights.length > 0
@@ -109,53 +122,72 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
return (
<div className="highlights-container">
<div className="highlights-header">
<div className="highlights-title">
<FontAwesomeIcon icon={faHighlighter} />
<h3>Highlights</h3>
{!loading && <span className="count">({filteredHighlights.length})</span>}
</div>
<div className="highlights-actions">
{currentUserPubkey && onHighlightModeChange && (
<div className="highlight-mode-toggle">
<div className="highlights-actions-left">
{onHighlightVisibilityChange && (
<div className="highlight-level-toggles">
<button
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
nostrverse: !highlightVisibility.nostrverse
})}
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
title="Toggle nostrverse highlights"
aria-label="Toggle nostrverse highlights"
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
>
<FontAwesomeIcon icon={faGlobe} />
</button>
<button
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
friends: !highlightVisibility.friends
})}
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
aria-label="Toggle friends highlights"
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
disabled={!currentUserPubkey}
>
<FontAwesomeIcon icon={faUserGroup} />
</button>
<button
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
mine: !highlightVisibility.mine
})}
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
aria-label="Toggle my highlights"
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
disabled={!currentUserPubkey}
>
<FontAwesomeIcon icon={faUser} />
</button>
</div>
)}
{onRefresh && (
<button
onClick={() => onHighlightModeChange('mine')}
className={`mode-btn ${highlightMode === 'mine' ? 'active' : ''}`}
title="My highlights"
aria-label="Show my highlights"
onClick={onRefresh}
className="refresh-highlights-btn"
title="Refresh highlights"
aria-label="Refresh highlights"
disabled={loading}
>
<FontAwesomeIcon icon={faUser} />
<FontAwesomeIcon icon={faRotate} spin={loading} />
</button>
)}
{filteredHighlights.length > 0 && (
<button
onClick={() => onHighlightModeChange('others')}
className={`mode-btn ${highlightMode === 'others' ? 'active' : ''}`}
title="Other highlights"
aria-label="Show highlights from others"
onClick={handleToggleHighlights}
className="toggle-highlight-display-btn"
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
aria-label={showHighlights ? 'Hide highlights' : 'Show highlights'}
>
<FontAwesomeIcon icon={faUserGroup} />
<FontAwesomeIcon icon={showHighlights ? faEye : faEyeSlash} />
</button>
</div>
)}
{onRefresh && (
<button
onClick={onRefresh}
className="refresh-highlights-btn"
title="Refresh highlights"
aria-label="Refresh highlights"
disabled={loading}
>
<FontAwesomeIcon icon={faRotate} spin={loading} />
</button>
)}
{filteredHighlights.length > 0 && (
<button
onClick={handleToggleUnderlines}
className="toggle-underlines-btn"
title={showUnderlines ? 'Hide underlines' : 'Show underlines'}
aria-label={showUnderlines ? 'Hide underlines' : 'Show underlines'}
>
<FontAwesomeIcon icon={showUnderlines ? faEye : faEyeSlash} />
</button>
)}
)}
</div>
<button
onClick={onToggleCollapse}
className="toggle-highlights-btn"
@@ -167,9 +199,9 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
</div>
</div>
{loading ? (
{loading && filteredHighlights.length === 0 ? (
<div className="highlights-loading">
<p>Loading highlights...</p>
<FontAwesomeIcon icon={faHighlighter} spin />
</div>
) : filteredHighlights.length === 0 ? (
<div className="highlights-empty">

View File

@@ -9,6 +9,8 @@ interface IconButtonProps {
ariaLabel?: string
variant?: 'primary' | 'success' | 'ghost'
size?: number
disabled?: boolean
spin?: boolean
}
const IconButton: React.FC<IconButtonProps> = ({
@@ -17,7 +19,9 @@ const IconButton: React.FC<IconButtonProps> = ({
title,
ariaLabel,
variant = 'ghost',
size = 33
size = 33,
disabled = false,
spin = false
}) => {
return (
<button
@@ -26,8 +30,9 @@ const IconButton: React.FC<IconButtonProps> = ({
title={title}
aria-label={ariaLabel || title}
style={{ width: size, height: size }}
disabled={disabled}
>
<FontAwesomeIcon icon={icon} />
<FontAwesomeIcon icon={icon} spin={spin} />
</button>
)
}

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

@@ -0,0 +1,52 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
interface ReaderHeaderProps {
title?: string
image?: string
readingTimeText?: string | null
hasHighlights: boolean
highlightCount: number
}
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
title,
image,
readingTimeText,
hasHighlights,
highlightCount
}) => {
return (
<>
{image && (
<div className="reader-hero-image">
<img src={image} alt={title || 'Article image'} />
</div>
)}
{title && (
<div className="reader-header">
<h2 className="reader-title">{title}</h2>
<div className="reader-meta">
{readingTimeText && (
<div className="reading-time">
<FontAwesomeIcon icon={faClock} />
<span>{readingTimeText}</span>
</div>
)}
{hasHighlights && (
<div className="highlight-indicator">
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
</div>
)}
</div>
</div>
)}
</>
)
}
export default ReaderHeader

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>
@@ -29,9 +44,8 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
useEffect(() => {
// Load font for preview when it changes
if (localSettings.readingFont) {
loadFont(localSettings.readingFont)
}
const fontToLoad = localSettings.readingFont || 'source-serif-4'
loadFont(fontToLoad)
}, [localSettings.readingFont])
// Auto-save settings whenever they change (except on initial mount)
@@ -44,19 +58,34 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
onSave(localSettings)
}, [localSettings, onSave])
const previewFontFamily = getFontFamily(localSettings.readingFont)
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">
@@ -66,7 +95,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
<div className="setting-group setting-inline">
<label htmlFor="readingFont">Reading Font</label>
<FontSelector
value={localSettings.readingFont || 'system'}
value={localSettings.readingFont || 'source-serif-4'}
onChange={(font) => setLocalSettings({ ...localSettings, readingFont: font })}
/>
</div>
@@ -78,7 +107,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
<button
key={size}
onClick={() => setLocalSettings({ ...localSettings, fontSize: size })}
className={`font-size-btn ${(localSettings.fontSize || 16) === size ? 'active' : ''}`}
className={`font-size-btn ${(localSettings.fontSize || 18) === size ? 'active' : ''}`}
title={`${size}px`}
style={{ fontSize: `${size - 2}px` }}
>
@@ -89,12 +118,12 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
</div>
<div className="setting-group">
<label htmlFor="showUnderlines" className="checkbox-label">
<label htmlFor="showHighlights" className="checkbox-label">
<input
id="showUnderlines"
id="showHighlights"
type="checkbox"
checked={localSettings.showUnderlines !== false}
onChange={(e) => setLocalSettings({ ...localSettings, showUnderlines: e.target.checked })}
checked={localSettings.showHighlights !== false}
onChange={(e) => setLocalSettings({ ...localSettings, showHighlights: e.target.checked })}
className="setting-checkbox"
/>
<span>Show highlights</span>
@@ -121,12 +150,35 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
</div>
</div>
<div className="setting-group setting-inline">
<label>Highlight Color</label>
<ColorPicker
selectedColor={localSettings.highlightColor || '#ffff00'}
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColor: color })}
/>
<label className="setting-label">My Highlights</label>
<div className="setting-control">
<ColorPicker
selectedColor={localSettings.highlightColorMine || '#ffff00'}
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorMine: color })}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">Friends Highlights</label>
<div className="setting-control">
<ColorPicker
selectedColor={localSettings.highlightColorFriends || '#f97316'}
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorFriends: color })}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">Nostrverse Highlights</label>
<div className="setting-control">
<ColorPicker
selectedColor={localSettings.highlightColorNostrverse || '#9333ea'}
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorNostrverse: color })}
/>
</div>
</div>
<div className="setting-preview">
@@ -135,13 +187,15 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
className="preview-content"
style={{
fontFamily: previewFontFamily,
fontSize: `${localSettings.fontSize || 16}px`,
fontSize: `${localSettings.fontSize || 18}px`,
'--highlight-rgb': hexToRgb(localSettings.highlightColor || '#ffff00')
} as React.CSSProperties}
>
<h3>The Quick Brown Fox</h3>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={localSettings.showUnderlines !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'}` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam.</p>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={localSettings.showHighlights !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={localSettings.showHighlights !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={localSettings.showHighlights !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
</div>
</div>
</div>
@@ -180,7 +234,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
<input
id="sidebarCollapsed"
type="checkbox"
checked={localSettings.sidebarCollapsed === true}
checked={localSettings.sidebarCollapsed !== false}
onChange={(e) => setLocalSettings({ ...localSettings, sidebarCollapsed: e.target.checked })}
className="setting-checkbox"
/>
@@ -193,7 +247,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
<input
id="highlightsCollapsed"
type="checkbox"
checked={localSettings.highlightsCollapsed === true}
checked={localSettings.highlightsCollapsed !== false}
onChange={(e) => setLocalSettings({ ...localSettings, highlightsCollapsed: e.target.checked })}
className="setting-checkbox"
/>

View File

@@ -1,22 +1,21 @@
import React, { useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUser, faList, faThLarge, faImage, faGear } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate } 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'
interface SidebarHeaderProps {
onToggleCollapse: () => void
onLogout: () => void
viewMode: ViewMode
onViewModeChange: (mode: ViewMode) => void
onOpenSettings: () => void
onRefresh?: () => void
isRefreshing?: boolean
}
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, viewMode, onViewModeChange, onOpenSettings }) => {
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, onRefresh, isRefreshing }) => {
const [isConnecting, setIsConnecting] = useState(false)
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
@@ -61,13 +60,18 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
<div className="profile-avatar" title={getUserDisplayName()}>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUser} />
)}
</div>
<div className="sidebar-header-right">
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title="Refresh bookmarks"
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
)}
<IconButton
icon={faGear}
onClick={onOpenSettings}
@@ -75,6 +79,18 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Settings"
variant="ghost"
/>
<div
className="profile-avatar"
title={activeAccount ? getUserDisplayName() : "Login"}
onClick={!activeAccount ? (isConnecting ? () => {} : handleLogin) : undefined}
style={{ cursor: !activeAccount ? 'pointer' : 'default' }}
>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
)}
</div>
{activeAccount ? (
<IconButton
icon={faRightFromBracket}
@@ -92,29 +108,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
variant="ghost"
/>
)}
</div>
<div className="view-mode-controls">
<IconButton
icon={faList}
onClick={() => onViewModeChange('compact')}
title="Compact list view"
ariaLabel="Compact list view"
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faThLarge}
onClick={() => onViewModeChange('cards')}
title="Cards view"
ariaLabel="Cards view"
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faImage}
onClick={() => onViewModeChange('large')}
title="Large preview view"
ariaLabel="Large preview view"
variant={viewMode === 'large' ? 'primary' : 'ghost'}
/>
</div>
</div>
</>
)

View File

@@ -4,6 +4,7 @@ import { fetchArticleByNaddr } from '../services/articleService'
import { fetchHighlightsForArticle } from '../services/highlightService'
import { ReadableContent } from '../services/readerService'
import { Highlight } from '../types/highlights'
import { NostrEvent } from 'nostr-tools'
interface UseArticleLoaderProps {
naddr: string | undefined
@@ -12,11 +13,11 @@ interface UseArticleLoaderProps {
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
setIsCollapsed: (collapsed: boolean) => void
setIsHighlightsCollapsed: (collapsed: boolean) => void
setHighlights: (highlights: Highlight[]) => void
setHighlightsLoading: (loading: boolean) => void
setCurrentArticleCoordinate: (coord: string | undefined) => void
setCurrentArticleEventId: (id: string | undefined) => void
setCurrentArticle?: (article: NostrEvent) => void
}
export function useArticleLoader({
@@ -26,11 +27,11 @@ export function useArticleLoader({
setReaderContent,
setReaderLoading,
setIsCollapsed,
setIsHighlightsCollapsed,
setHighlights,
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId
setCurrentArticleEventId,
setCurrentArticle
}: UseArticleLoaderProps) {
useEffect(() => {
if (!relayPool || !naddr) return
@@ -40,7 +41,7 @@ export function useArticleLoader({
setReaderContent(undefined)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
setIsHighlightsCollapsed(false)
// Keep highlights panel collapsed by default - only open on user interaction
try {
const article = await fetchArticleByNaddr(relayPool, naddr)
@@ -56,19 +57,33 @@ export function useArticleLoader({
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(article.event.id)
setCurrentArticle?.(article.event)
console.log('📰 Article loaded:', article.title)
console.log('📍 Coordinate:', articleCoordinate)
// Set reader loading to false immediately after article content is ready
// Don't wait for highlights to finish loading
setReaderLoading(false)
// Fetch highlights asynchronously without blocking article display
// Stream them as they arrive for instant rendering
try {
setHighlightsLoading(true)
const fetchedHighlights = await fetchHighlightsForArticle(
setHighlights([]) // Clear old highlights
const highlightsList: Highlight[] = []
await fetchHighlightsForArticle(
relayPool,
articleCoordinate,
article.event.id
article.event.id,
(highlight) => {
// Render each highlight immediately as it arrives
highlightsList.push(highlight)
setHighlights([...highlightsList].sort((a, b) => b.created_at - a.created_at))
}
)
console.log(`📌 Found ${fetchedHighlights.length} highlights`)
setHighlights(fetchedHighlights)
console.log(`📌 Found ${highlightsList.length} highlights`)
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
@@ -82,8 +97,6 @@ export function useArticleLoader({
url: `nostr:${naddr}`
})
setReaderLoading(false)
} finally {
setReaderLoading(false)
}
}

View File

@@ -51,7 +51,12 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
const fontKey = settings.readingFont || 'system'
if (fontKey !== 'system') loadFont(fontKey)
root.setProperty('--reading-font', getFontFamily(fontKey))
root.setProperty('--reading-font-size', `${settings.fontSize || 16}px`)
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')
}, [settings])
const saveSettingsWithToast = useCallback(async (newSettings: UserSettings) => {

25
src/hooks/useToast.ts Normal file
View File

@@ -0,0 +1,25 @@
import { useState, useCallback } from 'react'
interface ToastState {
message: string | null
type: 'success' | 'error'
}
export function useToast() {
const [toast, setToast] = useState<ToastState>({ message: null, type: 'success' })
const showToast = useCallback((message: string, type: 'success' | 'error' = 'success') => {
setToast({ message, type })
}, [])
const clearToast = useCallback(() => {
setToast({ message: null, type: 'success' })
}, [])
return {
toastMessage: toast.message,
toastType: toast.type,
showToast,
clearToast
}
}

View File

@@ -13,8 +13,15 @@
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
--reading-font: system-ui, -apple-system, sans-serif;
--reading-font-size: 16px;
--reading-font: 'Source Serif 4', serif;
--reading-font-size: 18px;
/* Layout variables */
--sidebar-width: 320px;
--sidebar-collapsed-width: 64px;
--highlights-width: 360px;
--highlights-collapsed-width: 56px;
--main-max-width: 900px;
--main-horizontal-padding: 1rem;
}
body {
@@ -24,9 +31,9 @@ body {
}
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
max-width: none;
margin: 0;
padding: 1rem;
}
.app {
@@ -49,56 +56,34 @@ 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;
border: 1px solid #333;
border-radius: 12px;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
text-align: left;
padding: 0;
}
.bookmarks-container .view-mode-controls {
margin-top: auto;
padding: 0.75rem 1rem;
border-top: 1px solid #333;
background: #1a1a1a;
border-radius: 0 0 12px 12px;
}
.bookmarks-container .bookmarks-list {
padding: 0.25rem;
overflow-y: auto;
flex: 1;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.sidebar-header-bar {
@@ -109,8 +94,15 @@ body {
padding: 0.75rem 1rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
margin-bottom: 0.5rem;
border-radius: 12px 12px 0 0;
margin-bottom: 0;
}
.sidebar-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.view-mode-controls {
@@ -179,8 +171,8 @@ body {
.bookmarks-container.collapsed {
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: 0.75rem 0 0 0;
justify-content: flex-start;
padding: 0;
background: transparent;
border: none;
}
@@ -188,16 +180,17 @@ body {
.bookmarks-container.collapsed .toggle-sidebar-btn {
background: #2a2a2a;
color: #ddd;
border: 1px solid #444;
border: none;
padding: 0;
border-radius: 6px;
border-radius: 0;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
width: 48px;
height: 36px;
flex-shrink: 0;
}
.bookmarks-container.collapsed .toggle-sidebar-btn:hover {
@@ -423,7 +416,7 @@ body {
.two-pane {
display: grid;
grid-template-columns: 360px 1fr;
gap: 1rem;
column-gap: 0;
height: calc(100vh - 4rem);
transition: grid-template-columns 0.3s ease;
}
@@ -435,22 +428,22 @@ body {
/* Three-pane layout */
.three-pane {
display: grid;
grid-template-columns: 360px 1fr 360px;
gap: 1rem;
height: calc(100vh - 4rem);
grid-template-columns: var(--sidebar-width) 1fr var(--highlights-width);
column-gap: 0;
height: calc(100vh - 2rem);
transition: grid-template-columns 0.3s ease;
}
.three-pane.sidebar-collapsed {
grid-template-columns: 60px 1fr 360px;
grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-width);
}
.three-pane.highlights-collapsed {
grid-template-columns: 360px 1fr 60px;
grid-template-columns: var(--sidebar-width) 1fr var(--highlights-collapsed-width);
}
.three-pane.sidebar-collapsed.highlights-collapsed {
grid-template-columns: 60px 1fr 60px;
grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-collapsed-width);
}
.pane.sidebar {
@@ -461,9 +454,20 @@ body {
.pane.main {
overflow-y: auto;
height: 100%;
max-width: 900px;
max-width: var(--main-max-width);
margin: 0 auto;
padding: 0 2rem;
padding: 0 var(--main-horizontal-padding);
overflow-x: hidden;
contain: layout style;
}
/* Remove padding when sidebar is collapsed for zero gap */
.three-pane.sidebar-collapsed .pane.main {
padding-left: 0;
}
.three-pane.sidebar-collapsed.highlights-collapsed .pane.main {
padding-left: 0;
}
.pane.highlights {
@@ -474,9 +478,11 @@ body {
.reader {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
padding: 1rem;
border-radius: 8px;
padding: 0.75rem;
text-align: left;
overflow: hidden;
contain: layout style;
}
.reader.empty {
@@ -699,6 +705,8 @@ body {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
max-width: 100%;
}
.bookmarks-grid.bookmarks-compact {
@@ -711,7 +719,7 @@ body {
.individual-bookmark {
background: #2a2a2a;
padding: 1.25rem;
padding: 1rem;
border-radius: 8px;
transition: all 0.2s ease;
border: 1px solid #333;
@@ -728,11 +736,13 @@ body {
/* Compact view styles */
.individual-bookmark.compact {
padding: 0.4rem 0.75rem;
padding: 0.3rem 0.25rem;
background: transparent;
border-bottom: 1px solid #333;
border-radius: 0;
box-shadow: none;
width: 100%;
max-width: 100%;
}
.individual-bookmark.compact:hover {
@@ -746,6 +756,9 @@ body {
align-items: center;
gap: 0.75rem;
height: 28px;
justify-content: space-between;
width: 100%;
min-width: 0;
}
.compact-row.clickable {
@@ -766,7 +779,7 @@ body {
}
.compact-text {
flex: 1;
flex: 1 1 0;
min-width: 0;
color: #ccc;
font-size: 0.85rem;
@@ -774,6 +787,7 @@ body {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.bookmark-date-compact {
@@ -784,8 +798,8 @@ body {
}
.compact-read-btn {
background: #28a745;
color: white;
background: transparent;
color: #888;
border: none;
padding: 0;
border-radius: 4px;
@@ -797,11 +811,12 @@ body {
width: 26px;
height: 22px;
flex-shrink: 0;
transition: background-color 0.2s ease;
margin-left: auto;
transition: color 0.2s ease;
}
.compact-read-btn:hover {
background: #218838;
color: #ccc;
}
.compact-read-btn:active {
@@ -1085,7 +1100,6 @@ body {
background-color: #ffffff;
}
.login-card,
.bookmark-item {
background: #f9f9f9;
border-color: #ddd;
@@ -1171,13 +1185,14 @@ body {
flex-direction: column;
height: 100%;
overflow: hidden;
padding-right: 1rem;
}
.highlights-container.collapsed {
display: flex;
align-items: flex-start;
justify-content: flex-start;
padding: 0.75rem 0 0 0;
padding: 0;
background: transparent;
border: none;
}
@@ -1185,9 +1200,9 @@ body {
.highlights-container.collapsed .toggle-highlights-btn {
background: #2a2a2a;
color: #ddd;
border: 1px solid #444;
border: none;
padding: 0;
border-radius: 6px;
border-radius: 0;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
@@ -1233,10 +1248,23 @@ body {
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid #333;
background: #1e1e1e;
background: #1a1a1a;
border-radius: 12px 12px 0 0;
}
.highlights-actions {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.highlights-actions-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.highlights-title {
display: flex;
align-items: center;
@@ -1289,8 +1317,50 @@ body {
color: #fff;
}
/* Three-level highlight toggles */
.highlight-level-toggles {
display: flex;
gap: 0.25rem;
padding: 0.25rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.highlight-level-toggles .level-toggle-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-level-toggles .level-toggle-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.highlight-level-toggles .level-toggle-btn.active {
background: rgba(255, 255, 255, 0.1);
opacity: 1;
}
.highlight-level-toggles .level-toggle-btn:not(.active) {
opacity: 0.4;
}
.highlight-level-toggles .level-toggle-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.highlight-level-toggles .level-toggle-btn:disabled:hover {
background: none;
}
.refresh-highlights-btn,
.toggle-underlines-btn,
.toggle-highlight-display-btn,
.toggle-highlights-btn {
background: transparent;
color: #ddd;
@@ -1307,14 +1377,14 @@ body {
}
.refresh-highlights-btn:hover,
.toggle-underlines-btn:hover,
.toggle-highlight-display-btn:hover,
.toggle-highlights-btn:hover {
background: #2a2a2a;
color: #fff;
}
.refresh-highlights-btn:active,
.toggle-underlines-btn:active,
.toggle-highlight-display-btn:active,
.toggle-highlights-btn:active {
transform: translateY(1px);
}
@@ -1380,6 +1450,22 @@ body {
box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.3);
}
/* Level colors in sidebar items */
.highlight-item.level-mine {
border-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 60%, #333);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 25%, transparent);
}
.highlight-item.level-friends {
border-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 60%, #333);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent);
}
.highlight-item.level-nostrverse {
border-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 60%, #333);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent);
}
.highlight-quote-icon {
color: #646cff;
font-size: 1.2rem;
@@ -1387,6 +1473,19 @@ body {
margin-top: 0.25rem;
}
/* Level-colored quote icon */
.highlight-item.level-mine .highlight-quote-icon {
color: var(--highlight-color-mine, #ffff00);
}
.highlight-item.level-friends .highlight-quote-icon {
color: var(--highlight-color-friends, #f97316);
}
.highlight-item.level-nostrverse .highlight-quote-icon {
color: var(--highlight-color-nostrverse, #9333ea);
}
.highlight-content {
flex: 1;
display: flex;
@@ -1415,41 +1514,25 @@ body {
line-height: 1.5;
}
.highlight-context {
margin-top: 0.5rem;
}
.highlight-context summary {
cursor: pointer;
font-size: 0.875rem;
color: #888;
user-select: none;
transition: color 0.2s ease;
}
.highlight-context summary:hover {
color: #aaa;
}
.context-text {
margin: 0.5rem 0 0 0;
padding: 0.75rem;
background: #252525;
border-radius: 6px;
font-size: 0.875rem;
color: #aaa;
line-height: 1.5;
}
.highlight-meta {
display: flex;
align-items: center;
gap: 0.75rem;
gap: 0.5rem;
font-size: 0.875rem;
color: #888;
flex-wrap: wrap;
}
.highlight-author {
color: #aaa;
font-weight: 500;
}
.highlight-meta-separator {
color: #666;
}
.highlight-time {
color: #888;
}
@@ -1482,6 +1565,7 @@ body {
position: relative;
border-radius: 2px;
box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2);
contain: layout style;
}
.content-highlight:hover,
@@ -1501,6 +1585,7 @@ body {
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.8);
text-decoration-thickness: 2px;
text-underline-offset: 2px;
contain: layout style;
}
.content-highlight-underline:hover {
@@ -1549,6 +1634,68 @@ body {
text-decoration: none;
}
/* Three-level highlight colors */
.content-highlight-marker.level-mine,
.content-highlight.level-mine {
background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 35%, transparent);
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 20%, transparent);
}
.content-highlight-marker.level-mine:hover,
.content-highlight.level-mine:hover {
background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 50%, transparent);
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 30%, transparent);
}
.content-highlight-marker.level-friends,
.content-highlight.level-friends {
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 35%, transparent);
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-friends, #f97316) 20%, transparent);
}
.content-highlight-marker.level-friends:hover,
.content-highlight.level-friends:hover {
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 50%, transparent);
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-friends, #f97316) 30%, transparent);
}
.content-highlight-marker.level-nostrverse,
.content-highlight.level-nostrverse {
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 35%, transparent);
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 20%, transparent);
}
.content-highlight-marker.level-nostrverse:hover,
.content-highlight.level-nostrverse:hover {
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 50%, transparent);
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 30%, transparent);
}
/* Underline styles for three levels */
.content-highlight-underline.level-mine {
text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 80%, transparent);
}
.content-highlight-underline.level-mine:hover {
text-decoration-color: var(--highlight-color-mine, #ffff00);
}
.content-highlight-underline.level-friends {
text-decoration-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 80%, transparent);
}
.content-highlight-underline.level-friends:hover {
text-decoration-color: var(--highlight-color-friends, #f97316);
}
.content-highlight-underline.level-nostrverse {
text-decoration-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 80%, transparent);
}
.content-highlight-underline.level-nostrverse:hover {
text-decoration-color: var(--highlight-color-nostrverse, #9333ea);
}
/* Ensure highlights work in both light and dark mode */
@media (prefers-color-scheme: light) {
.content-highlight,
@@ -1571,6 +1718,55 @@ body {
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 1);
}
/* Three-level overrides for light mode */
.content-highlight-marker.level-mine,
.content-highlight.level-mine {
background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 40%, transparent);
box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 15%, transparent);
}
.content-highlight-marker.level-mine:hover,
.content-highlight.level-mine:hover {
background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 55%, transparent);
box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 25%, transparent);
}
.content-highlight-marker.level-friends,
.content-highlight.level-friends {
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 40%, transparent);
box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-friends, #f97316) 15%, transparent);
}
.content-highlight-marker.level-friends:hover,
.content-highlight.level-friends:hover {
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 55%, transparent);
box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent);
}
.content-highlight-marker.level-nostrverse,
.content-highlight.level-nostrverse {
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 40%, transparent);
box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 15%, transparent);
}
.content-highlight-marker.level-nostrverse:hover,
.content-highlight.level-nostrverse:hover {
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 55%, transparent);
box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent);
}
.content-highlight-underline.level-mine {
text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 90%, transparent);
}
.content-highlight-underline.level-friends {
text-decoration-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 90%, transparent);
}
.content-highlight-underline.level-nostrverse {
text-decoration-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 90%, transparent);
}
.highlight-indicator {
background: rgba(100, 108, 255, 0.15);
border-color: rgba(100, 108, 255, 0.4);
@@ -1602,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;
@@ -1640,6 +1842,17 @@ body {
gap: 1rem;
}
.setting-label {
text-align: left;
flex: 1;
}
.setting-control {
display: flex;
justify-content: flex-end;
align-items: center;
}
.setting-group.setting-inline label {
margin-bottom: 0;
}

View File

@@ -80,9 +80,7 @@ export async function collectBookmarksFromEvents(
privateItemsAll.push(...processApplesauceBookmarks(manualPrivate, activeAccount, true))
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
if (!latestContent) {
latestContent = decryptedContent
}
// Don't set latestContent to decrypted JSON - it's not user-facing content
} catch {
// ignore
}

View File

@@ -0,0 +1,50 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
/**
* Fetches the contact list (follows) for a specific user
* @param relayPool - The relay pool to query
* @param pubkey - The user's public key
* @returns Set of pubkeys that the user follows
*/
export const fetchContacts = async (
relayPool: RelayPool,
pubkey: string
): Promise<Set<string>> => {
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
const events = await lastValueFrom(
relayPool
.req(relayUrls, { kinds: [3], authors: [pubkey] })
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
)
console.log('📊 Contact events fetched:', events.length)
if (events.length === 0) {
return new Set()
}
// Get the most recent contact list
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const contactList = sortedEvents[0]
// Extract pubkeys from 'p' tags
const followedPubkeys = new Set<string>()
for (const tag of contactList.tags) {
if (tag[0] === 'p' && tag[1]) {
followedPubkeys.add(tag[1])
}
}
console.log('👥 Followed contacts:', followedPubkeys.size)
return followedPubkeys
} catch (error) {
console.error('Failed to fetch contacts:', error)
return new Set()
}
}

View File

@@ -0,0 +1,67 @@
import { EventFactory } from 'applesauce-factory'
import { HighlightBlueprint } from 'applesauce-factory/blueprints'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools'
/**
* Creates and publishes a highlight event (NIP-84)
*/
export async function createHighlight(
selectedText: string,
article: NostrEvent | null,
account: IAccount,
relayPool: RelayPool,
comment?: string
): Promise<void> {
if (!selectedText || !article) {
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)
// Create highlight event using the blueprint
const highlightEvent = await factory.create(
HighlightBlueprint,
selectedText,
addressPointer,
comment ? { comment } : undefined
)
// 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)
console.log('✅ Highlight published:', signedEvent)
}
/**
* Parse article coordinate to create address pointer
*/
function parseArticleCoordinate(article: NostrEvent): AddressPointer {
// Try to get identifier from article tags
const identifier = article.tags.find(tag => tag[0] === 'd')?.[1] || ''
return {
kind: article.kind,
pubkey: article.pubkey,
identifier,
relays: [] // Optional relays hint
}
}

View File

@@ -1,5 +1,5 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import {
getHighlightText,
@@ -38,7 +38,8 @@ function dedupeHighlights(events: NostrEvent[]): NostrEvent[] {
export const fetchHighlightsForArticle = async (
relayPool: RelayPool,
articleCoordinate: string,
eventId?: string
eventId?: string,
onHighlight?: (highlight: Highlight) => void
): Promise<Highlight[]> => {
try {
// Use well-known relays for highlights even if user isn't logged in
@@ -54,12 +55,53 @@ export const fetchHighlightsForArticle = async (
console.log('🔍 Event ID:', eventId || 'none')
console.log('🔍 From relays:', highlightRelays)
const seenIds = new Set<string>()
const processEvent = (event: NostrEvent): Highlight | null => {
if (seenIds.has(event.id)) return null
seenIds.add(event.id)
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
}
}
// Query for highlights that reference this article via the 'a' tag
console.log('🔍 Filter 1 (a-tag):', JSON.stringify({ kinds: [9802], '#a': [articleCoordinate] }, null, 2))
const aTagEvents = await lastValueFrom(
relayPool
.req(highlightRelays, { kinds: [9802], '#a': [articleCoordinate] })
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) {
onHighlight(highlight)
}
}),
completeOnEose(),
takeUntil(timer(10000)),
toArray()
)
)
console.log('📊 Highlights via a-tag:', aTagEvents.length)
@@ -67,11 +109,21 @@ export const fetchHighlightsForArticle = async (
// If we have an event ID, also query for highlights that reference via the 'e' tag
let eTagEvents: NostrEvent[] = []
if (eventId) {
console.log('🔍 Filter 2 (e-tag):', JSON.stringify({ kinds: [9802], '#e': [eventId] }, null, 2))
eTagEvents = await lastValueFrom(
relayPool
.req(highlightRelays, { kinds: [9802], '#e': [eventId] })
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) {
onHighlight(highlight)
}
}),
completeOnEose(),
takeUntil(timer(10000)),
toArray()
)
)
console.log('📊 Highlights via e-tag:', eTagEvents.length)
}
@@ -135,30 +187,71 @@ export const fetchHighlightsForArticle = async (
* Fetches highlights created by a specific user
* @param relayPool - The relay pool to query
* @param pubkey - The user's public key
* @param onHighlight - Optional callback to receive highlights as they arrive
*/
export const fetchHighlights = async (
relayPool: RelayPool,
pubkey: string
pubkey: string,
onHighlight?: (highlight: Highlight) => void
): Promise<Highlight[]> => {
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
console.log('🔍 Fetching highlights (kind 9802) by author:', pubkey)
const seenIds = new Set<string>()
const rawEvents = await lastValueFrom(
relayPool
.req(relayUrls, { kinds: [9802], authors: [pubkey] })
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
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)
const highlight: Highlight = {
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
content: highlightText,
tags: event.tags,
eventReference,
urlReference: sourceUrl,
author,
context,
comment
}
if (onHighlight) {
onHighlight(highlight)
}
}
}),
completeOnEose(),
takeUntil(timer(10000)),
toArray()
)
)
console.log('📊 Raw highlight events fetched:', rawEvents.length)
// Deduplicate events by ID
// Deduplicate and process events
const uniqueEvents = dedupeHighlights(rawEvents)
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
const highlights: Highlight[] = uniqueEvents.map((event: NostrEvent) => {
// Use applesauce helpers to extract highlight data
const highlightText = getHighlightText(event)
const context = getHighlightContext(event)
const comment = getHighlightComment(event)
@@ -167,10 +260,7 @@ export const fetchHighlights = async (
const sourceUrl = getHighlightSourceUrl(event)
const attributions = getHighlightAttributions(event)
// Get author from attributions
const author = attributions.find(a => a.role === 'author')?.pubkey
// Get event reference (prefer event pointer, fallback to address pointer)
const eventReference = sourceEventPointer?.id ||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)

View File

@@ -11,13 +11,17 @@ const SETTINGS_IDENTIFIER = 'com.dergigi.boris.user-settings'
export interface UserSettings {
collapseOnArticleOpen?: boolean
defaultViewMode?: 'compact' | 'cards' | 'large'
showUnderlines?: boolean
showHighlights?: boolean
sidebarCollapsed?: boolean
highlightsCollapsed?: boolean
readingFont?: string
fontSize?: number
highlightStyle?: 'marker' | 'underline'
highlightColor?: string
// Three-level highlight colors
highlightColorNostrverse?: string
highlightColorFriends?: string
highlightColorMine?: string
}
export async function loadSettings(

View File

@@ -1,4 +1,6 @@
// NIP-84 Highlight types
export type HighlightLevel = 'nostrverse' | 'friends' | 'mine'
export interface Highlight {
id: string
pubkey: string
@@ -11,5 +13,7 @@ export interface Highlight {
author?: string // 'p' tag with 'author' role
context?: string // surrounding text context
comment?: string // optional comment about the highlight
// Level classification (computed based on user's context)
level?: HighlightLevel
}

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

@@ -73,11 +73,13 @@ export function applyHighlightsToText(
// Add the highlighted text
const highlightedText = text.substring(match.startIndex, match.endIndex)
const levelClass = match.highlight.level ? ` level-${match.highlight.level}` : ''
result.push(
<mark
key={`highlight-${match.highlight.id}-${match.startIndex}`}
className="content-highlight"
className={`content-highlight${levelClass}`}
data-highlight-id={match.highlight.id}
data-highlight-level={match.highlight.level || 'nostrverse'}
title={`Highlighted ${new Date(match.highlight.created_at * 1000).toLocaleDateString()}`}
>
{highlightedText}
@@ -101,8 +103,10 @@ const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim()
// Helper to create a mark element for a highlight
function createMarkElement(highlight: Highlight, matchText: string, highlightStyle: 'marker' | 'underline' = 'marker'): HTMLElement {
const mark = document.createElement('mark')
mark.className = `content-highlight-${highlightStyle}`
const levelClass = highlight.level ? ` level-${highlight.level}` : ''
mark.className = `content-highlight-${highlightStyle}${levelClass}`
mark.setAttribute('data-highlight-id', highlight.id)
mark.setAttribute('data-highlight-level', highlight.level || 'nostrverse')
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
mark.textContent = matchText
return mark