Compare commits

...

108 Commits

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

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

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

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

Result: Highlights now show both in the panel AND underlined in
the article text. Users can switch between viewing their own
highlights vs highlights from others.
2025-10-05 12:57:09 +01:00
Gigi
7a3dd421fb chore: bump version to 0.1.7 2025-10-05 12:55:10 +01:00
Gigi
4d95657bca refactor: keep Bookmarks.tsx under 210 lines by extracting logic
Extracted large functions into separate modules to follow DRY principles
and keep files manageable:

- Created useArticleLoader.ts hook (92 lines)
  - Handles article loading from naddr
  - Fetches article content and highlights
  - Sets up article coordinate for refresh

- Created contentLoader.ts utility (44 lines)
  - Handles both Nostr articles and web URLs
  - Unified content loading logic
  - Reusable across components

Result: Bookmarks.tsx reduced from 282 to 208 lines 

All files now under 210 line limit while maintaining functionality.
2025-10-05 12:47:32 +01:00
Gigi
6f28c3906c fix: show highlights for nostr articles by skipping URL filter
The HighlightsPanel was filtering out ALL highlights that didn't have
a urlReference. But Nostr article highlights reference the article via
the 'a' tag (article coordinate), not a URL.

Since we already fetch highlights specifically for the current article
using fetchHighlightsForArticle(), we don't need to filter them again.

Solution:
- Skip URL filtering when selectedUrl starts with 'nostr:'
- Keep URL filtering for web articles (backwards compatible)
- Highlights are already pre-filtered by the fetch query

This fixes the issue where 101 highlights existed for the default
article but weren't being displayed in the UI.
2025-10-05 12:37:28 +01:00
Gigi
fafe378585 fix: remove ImportMeta interface redeclaration
- ImportMeta is already defined as built-in global by vite/client
- Keep only ImportMetaEnv extension for custom env variables
- Fixes eslint no-redeclare error
2025-10-05 12:23:50 +01:00
Gigi
70b85b0cf0 fix: refresh button now works without login for article highlights
- Track current article coordinate and event ID in state
- Update handleFetchHighlights to refresh article highlights if viewing article
- Fall back to fetching user's highlights only if logged in and not viewing article
- Refresh button now works for anonymous article viewing
- No longer requires activeAccount to refresh highlights

Previously the refresh button only worked when logged in because it tried
to fetch highlights BY the user. Now it intelligently fetches highlights
FOR the current article, or falls back to user highlights if logged in.
2025-10-05 12:23:08 +01:00
Gigi
2297d8ae96 fix: query highlights using both a-tag and e-tag
- Highlights on replaceable events include BOTH 'a' and 'e' tags
- Query for highlights using article coordinate (#a tag)
- Also query using event ID (#e tag) for comprehensive results
- Combine and deduplicate results from both queries
- Add detailed logging to help diagnose why highlights aren't found
- Suggest checking highlighter.com if no highlights found

Per NIP-84 and applesauce implementation, highlights on kind:30023
articles include both an addressable reference ('a' tag) and an event
reference ('e' tag).
2025-10-05 09:19:43 +01:00
Gigi
343f176f06 debug: add detailed logging for highlight fetching
- Log article details (event ID, author, kind, d-tag, coordinate)
- Log filter being used for highlight queries
- Log sample highlight tags when found
- This will help debug why highlights aren't showing
2025-10-05 09:18:55 +01:00
Gigi
ee788cffb0 feat: add caching for nostr-native articles
- Add localStorage caching for kind:30023 articles (same as web articles)
- Cache TTL: 7 days
- Cache key prefix: article_cache_
- Add bypassCache parameter to fetchArticleByNaddr()
- Log cache hits and misses for debugging
- Gracefully handle storage errors

Articles are now cached locally after first fetch, making subsequent
loads instant and reducing relay queries.
2025-10-05 09:17:07 +01:00
Gigi
ca46feb80f fix: fetch highlights by article reference instead of author
- Add fetchHighlightsForArticle() to query highlights by article coordinate
- Use #a tag filter to find highlights that reference the article
- Query well-known relays for highlights even without authentication
- Extract article's d-tag and construct coordinate (kind:pubkey:identifier)
- Keep original fetchHighlights() for fetching user's own highlights
- Add detailed logging for debugging highlight fetching

This fixes the issue where no highlights were shown because we were
querying for highlights created BY the article author rather than
highlights created ABOUT the article.
2025-10-05 09:12:01 +01:00
Gigi
82ab07e606 feat: configure default article via environment variable
- Add VITE_DEFAULT_ARTICLE_NADDR env variable support
- Create .env with default article naddr
- Create .env.example for documentation
- Add vite-env.d.ts for TypeScript type support
- Fallback to hardcoded value if env var not set
- Using Vite's built-in env variable support (no dotenv needed)
2025-10-05 09:08:10 +01:00
Gigi
1f5e3f82b0 feat: load default article on startup with collapsed sidebars
- Redirect root path to default article (naddr)
- Start with both sidebars (bookmarks and highlights) collapsed
- Auto-fetch and show highlights for the article author
- No authentication required to view articles
- Highlights panel auto-expands when article loads
- Login page moved to /login route
2025-10-05 09:06:54 +01:00
Gigi
6265af74f2 docs: clarify why we extract image tag directly in BookmarkItem
Add comment explaining that we extract the image tag directly from
bookmark.tags since we don't have the full NostrEvent here. When we
do have full events (like in articleService), we use getArticleImage()
helper from applesauce-core as intended.
2025-10-05 08:24:05 +01:00
Gigi
e8f44986da feat: display article hero images in bookmark views and reader
- Add image prop to ContentPanel to display hero images
- Extract image tag from kind:30023 bookmark tags
- Display article images in Card, Large, and Compact views
- Show hero image at top of article reader view
- Add CSS styling for article-hero-image and reader-hero-image
- Article images clickable to open article in reader
- Per NIP-23: image tag contains header/preview image URL
2025-10-05 08:22:46 +01:00
Gigi
3d304dab15 fix: use bookmark pubkey for article author instead of tag lookup
- Pass pubkey along with bookmark data to handleSelectUrl
- Use bookmark.pubkey directly when constructing naddr
- More reliable article loading with correct author attribution
- Update type signatures across all components
2025-10-05 08:20:38 +01:00
Gigi
0f7a4d7877 feat: enable clicking on kind:30023 articles to open in reader
- Update handleSelectUrl to detect kind:30023 bookmarks
- Construct naddr from article event data (pubkey, d tag)
- Fetch and render articles using article service
- Update all bookmark views (Compact, Card, Large) to handle articles
- Show 'Read Article' button for kind:30023 bookmarks
- Articles load in the existing ContentPanel with full reader features
2025-10-05 08:19:50 +01:00
Gigi
d5e847e515 feat: show article titles for kind:30023 bookmarks
- Update hydrateItems to detect long-form articles (kind:30023)
- Extract and display article title using getArticleTitle helper
- Article titles now appear as bookmark content in lists
- Provides better context for bookmarked articles
2025-10-05 08:17:34 +01:00
Gigi
edd4e20e22 refactor: integrate long-form article rendering into existing reader view
- Create articleService to fetch articles by naddr
- Update Bookmarks component to detect naddr in URL params
- Articles now render in the existing ContentPanel with highlight support
- Remove standalone Article component
- Articles work seamlessly within the existing three-pane layout
- Support for article metadata (title, image, published date, summary)
2025-10-05 08:12:55 +01:00
Gigi
9b0c59b1ae feat: add native support for rendering Nostr long-form articles (NIP-23)
- Install react-router-dom for routing support
- Create Article component to decode naddr and fetch/render articles
- Add /a/:naddr route to App.tsx for article viewing
- Use applesauce relay pool patterns for event fetching
- Render articles with markdown using ReactMarkdown
- Support article metadata (title, image, published date, summary)
2025-10-05 08:08:34 +01:00
Gigi
8faa2e2de0 chore: bump version to 0.1.6 2025-10-05 04:17:44 +01:00
Gigi
07a5826774 refactor: extract components to keep files under 210 lines
- Extract ColorPicker component from Settings
- Extract FontSelector component from Settings
- Move hexToRgb helper to colorHelpers utils
- Export HIGHLIGHT_COLORS constant from colorHelpers
- Settings.tsx now 209 lines (was 242)
- ContentPanel.tsx now 197 lines (was 204)

Keeps code DRY and improves maintainability
2025-10-05 04:17:03 +01:00
Gigi
21d6916ae3 fix: ensure highlight color CSS variable inherits from parent
Remove local --highlight-rgb declarations that were preventing color inheritance in preview
2025-10-05 04:14:11 +01:00
Gigi
482ba9b2df style: make font size and color buttons match icon button size (33px) 2025-10-05 04:13:28 +01:00
Gigi
e4b6d1a122 feat: add configurable highlight colors
- Add highlightColor setting with 6 preset colors (yellow, orange, pink, green, blue, purple)
- Implement color picker UI with square color swatches
- Use CSS variables to dynamically apply highlight colors
- Add hex to RGB conversion for color transparency support
- Update both marker and underline styles to use selected color
2025-10-05 04:12:31 +01:00
Gigi
b59a295ad3 feat: add highlight style setting (marker & underline) 2025-10-05 04:08:58 +01:00
45 changed files with 2483 additions and 588 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.

View File

@@ -0,0 +1,9 @@
---
description: nostr highlights spec and docs
alwaysApply: false
---
Here's the spec for nostr-native highlights:
- https://github.com/nostr-protocol/nips/blob/master/84.md
- https://nostrbook.dev/kinds/9802

View File

@@ -0,0 +1,11 @@
---
description: anything that has to do with kind:30023 aka nostr blog posts aka nostr-native long-form content
alwaysApply: false
---
Always stick to NIPs. Do everything with applesauce (getArticleTitle, getArticleSummary, getHashtags, getMentions).
- https://github.com/hzrd149/applesauce/blob/17c9dbb0f2c263e2ebd01729ea2fa138eca12bd1/packages/docs/tutorial/02-helpers.md
- https://github.com/nostr-protocol/nips/blob/master/19.md
- https://github.com/nostr-protocol/nips/blob/master/23.md
- https://nostrbook.dev/kinds/30023

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Default article to display on app load
# This should be a valid naddr1... string (NIP-19 encoded address pointer to a kind:30023 long-form article)
VITE_DEFAULT_ARTICLE_NADDR=naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew

206
README.md
View File

@@ -1,187 +1,57 @@
# 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 youll 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
## What Boris does
## Getting Started
- 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
### Prerequisites
## How it works
- Node.js 18+
- npm, pnpm, or yarn
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.
### Installation
## Why people like Boris
1. Clone the repository:
```bash
git clone <your-repo-url>
cd boris
```
- 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
2. Install dependencies:
```bash
npm install
# or
pnpm install
# or
yarn install
```
## Tips
3. Start the development server:
```bash
npm run dev
# or
pnpm dev
# or
yarn dev
```
- 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.
4. Open your browser and navigate to `http://localhost:3000`
## Privacy and data
## Usage
- 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.
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
## Troubleshooting
## Technical Details
- 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
## Development
### Project Structure
```
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
```
### Private (hidden) bookmarks (Amethyst-style)
We support Amethyst-style private (hidden) bookmark lists alongside public ones (NIP51):
- **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

6
dist/index.html vendored
View File

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

55
node_modules/.package-lock.json generated vendored
View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.1.4",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -2299,6 +2299,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4830,6 +4839,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.3.tgz",
"integrity": "sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.3"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/reading-time-estimator": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/reading-time-estimator/-/reading-time-estimator-1.14.0.tgz",
@@ -5070,6 +5117,12 @@
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

58
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "boris",
"version": "0.1.4",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boris",
"version": "0.1.4",
"version": "0.1.6",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
@@ -23,6 +23,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.9.3",
"reading-time-estimator": "^1.14.0",
"remark-gfm": "^4.0.1"
},
@@ -2291,6 +2292,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4822,6 +4832,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.3.tgz",
"integrity": "sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.3"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/reading-time-estimator": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/reading-time-estimator/-/reading-time-estimator-1.14.0.tgz",
@@ -5062,6 +5110,12 @@
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.1.5",
"version": "0.2.0",
"description": "A minimal nostr client for bookmark management",
"type": "module",
"scripts": {
@@ -25,6 +25,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.9.3",
"reading-time-estimator": "^1.14.0",
"remark-gfm": "^4.0.1"
},

View File

@@ -1,22 +1,69 @@
import { useState, useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { EventStoreProvider, AccountsProvider } from 'applesauce-react'
import { 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'
function App() {
const [eventStore, setEventStore] = useState<EventStore | null>(null)
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
const [relayPool, setRelayPool] = useState<RelayPool | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
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 () => {
try {
const json = JSON.parse(localStorage.getItem('accounts') || '[]')
await accounts.fromJSON(json)
console.log('Loaded', accounts.accounts.length, 'accounts from storage')
// Load active account from storage
const activeId = localStorage.getItem('active')
if (activeId && accounts.getAccount(activeId)) {
accounts.setActive(activeId)
console.log('Restored active account:', activeId)
}
} catch (err) {
console.error('Failed to load accounts from storage:', err)
}
}
loadAccounts()
// Subscribe to accounts changes and persist to localStorage
const accountsSub = accounts.accounts$.subscribe(() => {
localStorage.setItem('accounts', JSON.stringify(accounts.toJSON()))
})
// Subscribe to active account changes and persist to localStorage
const activeSub = accounts.active$.subscribe((account) => {
if (account) {
localStorage.setItem('active', account.id)
} else {
localStorage.removeItem('active')
}
})
const pool = new RelayPool()
// Define relay URLs for bookmark fetching
@@ -53,25 +100,56 @@ function App() {
setEventStore(store)
setAccountManager(accounts)
setRelayPool(pool)
// Cleanup subscriptions on unmount
return () => {
accountsSub.unsubscribe()
activeSub.unsubscribe()
}
}, [])
if (!eventStore || !accountManager || !relayPool) {
return <div>Loading...</div>
return (
<div className="loading">
<FontAwesomeIcon icon={faSpinner} spin />
</div>
)
}
return (
<EventStoreProvider eventStore={eventStore}>
<AccountsProvider manager={accountManager}>
<div className="app">
{!isAuthenticated ? (
<Login onLogin={() => setIsAuthenticated(true)} />
) : (
<Bookmarks
relayPool={relayPool}
onLogout={() => setIsAuthenticated(false)}
/>
)}
</div>
<BrowserRouter>
<div className="app">
<Routes>
<Route
path="/a/:naddr"
element={
<Bookmarks
relayPool={relayPool}
onLogout={() => {
if (accountManager) {
accountManager.setActive(undefined as never)
localStorage.removeItem('active')
showToast('Logged out successfully')
console.log('Logged out')
}
}}
/>
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
<Route path="/login" element={<Login onLogin={() => showToast('Logged in successfully')} />} />
</Routes>
</div>
</BrowserRouter>
{toastMessage && (
<Toast
message={toastMessage}
type={toastType}
onClose={clearToast}
/>
)}
</AccountsProvider>
</EventStoreProvider>
)

View File

@@ -15,7 +15,7 @@ import { CardView } from './BookmarkViews/CardView'
interface BookmarkItemProps {
bookmark: IndividualBookmark
index: number
onSelectUrl?: (url: string) => void
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
viewMode?: ViewMode
}
@@ -30,13 +30,19 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
const firstUrl = hasUrls ? extractedUrls[0] : null
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : null
// For kind:30023 articles, extract image tag (per NIP-23)
// Note: We extract directly from tags here since we don't have the full event.
// When we have full events, we use getArticleImage() helper (see articleService.ts)
const isArticle = bookmark.kind === 30023
const articleImage = isArticle ? bookmark.tags.find(t => t[0] === 'image')?.[1] : undefined
// Fetch OG image for large view (hook must be at top level)
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassification?.type || '') : null
React.useEffect(() => {
if (viewMode === 'large' && firstUrl && !instantPreview && !ogImage) {
if (viewMode === 'large' && firstUrl && !instantPreview && !ogImage && !articleImage) {
fetchOgImage(firstUrl).then(setOgImage)
}
}, [viewMode, firstUrl, instantPreview, ogImage])
}, [viewMode, firstUrl, instantPreview, ogImage, articleImage])
// Resolve author profile using applesauce
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
@@ -68,10 +74,20 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
}
const handleReadNow = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
// For kind:30023 articles, pass the bookmark data instead of URL
if (bookmark.kind === 30023) {
if (onSelectUrl) {
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
}
return
}
// For regular bookmarks with URLs
if (!hasUrls) return
const firstUrl = extractedUrls[0]
if (onSelectUrl) {
event.preventDefault()
onSelectUrl(firstUrl)
} else {
window.open(firstUrl, '_blank')
@@ -89,7 +105,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow
handleReadNow,
articleImage
}
if (viewMode === 'compact') {
@@ -97,9 +114,9 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
}
if (viewMode === 'large') {
const previewImage = instantPreview || ogImage
const previewImage = articleImage || instantPreview || ogImage
return <LargeView {...sharedProps} previewImage={previewImage} />
}
return <CardView {...sharedProps} />
return <CardView {...sharedProps} articleImage={articleImage} />
}

View File

@@ -1,15 +1,16 @@
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 {
bookmarks: Bookmark[]
onSelectUrl?: (url: string) => void
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
isCollapsed: boolean
onToggleCollapse: () => void
onLogout: () => void
@@ -17,10 +18,13 @@ interface BookmarkListProps {
onViewModeChange: (mode: ViewMode) => void
selectedUrl?: string
onOpenSettings: () => void
onRefresh?: () => void
isRefreshing?: boolean
loading?: boolean
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
bookmarks,
export const BookmarkList: React.FC<BookmarkListProps> = ({
bookmarks,
onSelectUrl,
isCollapsed,
onToggleCollapse,
@@ -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

@@ -13,13 +13,14 @@ interface CardViewProps {
index: number
hasUrls: boolean
extractedUrls: string[]
onSelectUrl?: (url: string) => void
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
getIconForUrlType: IconGetter
firstUrlClassification: { buttonText: string } | null
authorNpub: string
eventNevent?: string
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleImage?: string
}
export const CardView: React.FC<CardViewProps> = ({
@@ -33,15 +34,24 @@ export const CardView: React.FC<CardViewProps> = ({
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow
handleReadNow,
articleImage
}) => {
const [expanded, setExpanded] = useState(false)
const [urlsExpanded, setUrlsExpanded] = useState(false)
const contentLength = (bookmark.content || '').length
const shouldTruncate = !expanded && contentLength > 210
const isArticle = bookmark.kind === 30023
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
{isArticle && articleImage && (
<div
className="article-hero-image"
style={{ backgroundImage: `url(${articleImage})` }}
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
/>
)}
<div className="bookmark-header">
<span className="bookmark-type">
{bookmark.isPrivate ? (
@@ -141,11 +151,11 @@ export const CardView: React.FC<CardViewProps> = ({
{getAuthorDisplayName()}
</a>
</div>
{hasUrls && firstUrlClassification && (
{(hasUrls && firstUrlClassification) || bookmark.kind === 30023 ? (
<button className="read-now-button-minimal" onClick={handleReadNow}>
{firstUrlClassification.buttonText}
{bookmark.kind === 30023 ? 'Read Article' : firstUrlClassification?.buttonText}
</button>
)}
) : null}
</div>
</div>
)

View File

@@ -11,9 +11,10 @@ interface CompactViewProps {
index: number
hasUrls: boolean
extractedUrls: string[]
onSelectUrl?: (url: string) => void
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
getIconForUrlType: IconGetter
firstUrlClassification: { buttonText: string } | null
articleImage?: string
}
export const CompactView: React.FC<CompactViewProps> = ({
@@ -25,8 +26,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
getIconForUrlType,
firstUrlClassification
}) => {
const isArticle = bookmark.kind === 30023
const isClickable = hasUrls || isArticle
const handleCompactClick = () => {
if (hasUrls && onSelectUrl) {
if (!onSelectUrl) return
if (isArticle) {
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
} else if (hasUrls) {
onSelectUrl(extractedUrls[0])
}
}
@@ -34,10 +42,10 @@ export const CompactView: React.FC<CompactViewProps> = ({
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
<div
className={`compact-row ${hasUrls ? 'clickable' : ''}`}
className={`compact-row ${isClickable ? 'clickable' : ''}`}
onClick={handleCompactClick}
role={hasUrls ? 'button' : undefined}
tabIndex={hasUrls ? 0 : undefined}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
>
<span className="bookmark-type-compact">
{bookmark.isPrivate ? (
@@ -55,13 +63,20 @@ export const CompactView: React.FC<CompactViewProps> = ({
</div>
)}
<span className="bookmark-date-compact">{formatDate(bookmark.created_at)}</span>
{hasUrls && (
{isClickable && (
<button
className="compact-read-btn"
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(extractedUrls[0]) }}
title={firstUrlClassification?.buttonText}
onClick={(e) => {
e.stopPropagation()
if (isArticle) {
onSelectUrl?.('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
} else {
onSelectUrl?.(extractedUrls[0])
}
}}
title={isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
>
<FontAwesomeIcon icon={getIconForUrlType(extractedUrls[0])} />
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
</button>
)}
</div>

View File

@@ -10,7 +10,7 @@ interface LargeViewProps {
index: number
hasUrls: boolean
extractedUrls: string[]
onSelectUrl?: (url: string) => void
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
getIconForUrlType: IconGetter
firstUrlClassification: { buttonText: string } | null
previewImage: string | null
@@ -34,15 +34,23 @@ export const LargeView: React.FC<LargeViewProps> = ({
getAuthorDisplayName,
handleReadNow
}) => {
const isArticle = bookmark.kind === 30023
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
{hasUrls && (
{(hasUrls || (isArticle && previewImage)) && (
<div
className="large-preview-image"
onClick={() => onSelectUrl?.(extractedUrls[0])}
onClick={() => {
if (isArticle) {
handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
} else {
onSelectUrl?.(extractedUrls[0])
}
}}
style={previewImage ? { backgroundImage: `url(${previewImage})` } : undefined}
>
{!previewImage && (
{!previewImage && hasUrls && (
<div className="preview-placeholder">
<FontAwesomeIcon icon={getIconForUrlType(extractedUrls[0])} />
</div>
@@ -80,12 +88,12 @@ export const LargeView: React.FC<LargeViewProps> = ({
</a>
)}
{hasUrls && firstUrlClassification && (
{(hasUrls && firstUrlClassification) || isArticle ? (
<button className="large-read-button" onClick={handleReadNow}>
<FontAwesomeIcon icon={getIconForUrlType(extractedUrls[0])} />
{firstUrlClassification.buttonText}
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
{isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
</button>
)}
) : null}
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
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'
import { RelayPool } from 'applesauce-relay'
@@ -6,13 +7,21 @@ import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights'
import { BookmarkList } from './BookmarkList'
import { fetchBookmarks } from '../services/bookmarkService'
import { fetchHighlights } from '../services/highlightService'
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
import { fetchContacts } from '../services/contactService'
import ContentPanel from './ContentPanel'
import { HighlightsPanel } from './HighlightsPanel'
import { fetchReadableContent, ReadableContent } from '../services/readerService'
import { ReadableContent } from '../services/readerService'
import Settings from './Settings'
import Toast from './Toast'
import { useSettings } from '../hooks/useSettings'
import { useArticleLoader } from '../hooks/useArticleLoader'
import { loadContent, BookmarkReference } from '../utils/contentLoader'
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 {
@@ -21,21 +30,34 @@ 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)
const [readerLoading, setReaderLoading] = useState(false)
const [readerContent, setReaderContent] = useState<ReadableContent | undefined>(undefined)
const [isCollapsed, setIsCollapsed] = useState(false)
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(false)
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 [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,
@@ -44,33 +66,83 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
accountManager
})
// Load article if naddr is in URL
useArticleLoader({
naddr,
relayPool,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed,
setHighlights,
setHighlightsLoading,
setCurrentArticleCoordinate,
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 () => {
if (!relayPool || !activeAccount) return
if (!relayPool) return
setHighlightsLoading(true)
try {
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey)
setHighlights(fetchedHighlights)
// If we're viewing an article, fetch highlights for that article
if (currentArticleCoordinate) {
const highlightsList: Highlight[] = []
await fetchHighlightsForArticle(
relayPool,
currentArticleCoordinate,
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 ${highlightsList.length} highlights for article`)
}
// Otherwise, if logged in, fetch user's own highlights
else if (activeAccount) {
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey)
setHighlights(fetchedHighlights)
}
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
@@ -78,22 +150,105 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
}
}
const handleSelectUrl = async (url: string) => {
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 fetchReadableContent(url)
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 readable content:', err)
console.warn('Failed to fetch content:', err)
} finally {
setReaderLoading(false)
}
}
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' : ''}`}>
@@ -112,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">
@@ -127,14 +285,22 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
title={readerContent?.title}
html={readerContent?.html}
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) => {
setSelectedHighlightId(id)
if (isHighlightsCollapsed) setIsHighlightsCollapsed(false)
}}
selectedHighlightId={selectedHighlightId}
highlightVisibility={highlightVisibility}
onTextSelection={handleTextSelection}
onClearSelection={handleClearSelection}
currentUserPubkey={activeAccount?.pubkey}
followedPubkeys={followedPubkeys}
/>
)}
</div>
@@ -146,13 +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}
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

@@ -0,0 +1,26 @@
import React from 'react'
import { HIGHLIGHT_COLORS } from '../utils/colorHelpers'
interface ColorPickerProps {
selectedColor: string
onColorChange: (color: string) => void
}
const ColorPicker: React.FC<ColorPickerProps> = ({ selectedColor, onColorChange }) => {
return (
<div className="color-picker">
{HIGHLIGHT_COLORS.map(color => (
<button
key={color.value}
onClick={() => onColorChange(color.value)}
className={`color-swatch ${selectedColor === color.value ? 'active' : ''}`}
style={{ backgroundColor: color.value }}
title={color.name}
aria-label={`${color.name} highlight color`}
/>
))}
</div>
)
}
export default ColorPicker

View File

@@ -1,12 +1,15 @@
import React, { useMemo, useEffect, useRef } 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
@@ -14,10 +17,19 @@ interface ContentPanelProps {
html?: string
markdown?: string
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> = ({
@@ -26,78 +38,84 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
html,
markdown,
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 originalHtmlRef = useRef<string>('')
const markdownPreviewRef = useRef<HTMLDivElement>(null)
const [renderedHtml, setRenderedHtml] = useState<string>('')
// Scroll to selected highlight in article when clicked from sidebar
useEffect(() => {
if (!selectedHighlightId || !contentRef.current) return
// Filter highlights by URL and visibility settings
const relevantHighlights = useMemo(() => {
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
const markElement = contentRef.current.querySelector(`mark.content-highlight[data-highlight-id="${selectedHighlightId}"]`)
if (markElement) {
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
// Add pulsing animation after scroll completes
const htmlElement = markElement as HTMLElement
setTimeout(() => {
htmlElement.classList.add('highlight-pulse')
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
}, 500)
}
}, [selectedHighlightId])
const relevantHighlights = useMemo(() => filterHighlightsByUrl(highlights, selectedUrl), [selectedUrl, highlights])
// 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])
// Store original HTML when content changes
// Convert markdown to HTML when markdown content changes
useEffect(() => {
if (!contentRef.current) return
// Store the fresh HTML content
originalHtmlRef.current = contentRef.current.innerHTML
}, [html, markdown, selectedUrl])
// Apply highlights after DOM is rendered
useEffect(() => {
// Skip if no content or underlines are hidden
if ((!html && !markdown) || !showUnderlines) {
// If underlines are hidden, restore original HTML
if (!showUnderlines && contentRef.current && originalHtmlRef.current) {
contentRef.current.innerHTML = originalHtmlRef.current
}
if (!markdown) {
setRenderedHtml('')
return
}
// Skip if no relevant highlights
if (relevantHighlights.length === 0) {
// Restore original HTML if no highlights
if (contentRef.current && originalHtmlRef.current) {
contentRef.current.innerHTML = originalHtmlRef.current
}
return
}
// Use requestAnimationFrame to ensure DOM is fully rendered
// Use requestAnimationFrame to ensure ReactMarkdown has rendered
const rafId = requestAnimationFrame(() => {
if (!contentRef.current || !originalHtmlRef.current) return
// Always apply highlights to the ORIGINAL HTML, not already-highlighted content
const highlightedHTML = applyHighlightsToHTML(originalHtmlRef.current, relevantHighlights)
contentRef.current.innerHTML = highlightedHTML
if (markdownPreviewRef.current) {
setRenderedHtml(markdownPreviewRef.current.innerHTML)
}
})
return () => cancelAnimationFrame(rafId)
}, [relevantHighlights, html, markdown, showUnderlines])
// Attach click handlers separately (only when handler changes)
return () => cancelAnimationFrame(rafId)
}, [markdown])
// Prepare the final HTML with highlights applied
const finalHtml = useMemo(() => {
const sourceHtml = markdown ? renderedHtml : html
if (!sourceHtml) return ''
// Apply highlights if we have them and highlights are enabled
if (showHighlights && relevantHighlights.length > 0) {
return applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
}
return sourceHtml
}, [html, renderedHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
// Attach click handlers to highlight marks
useEffect(() => {
if (!onHighlightClick || !contentRef.current) return
const marks = contentRef.current.querySelectorAll('mark.content-highlight')
const marks = contentRef.current.querySelectorAll('mark[data-highlight-id]')
const handlers = new Map<Element, () => void>()
marks.forEach(mark => {
@@ -115,9 +133,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
mark.removeEventListener('click', handler)
})
}
}, [onHighlightClick, relevantHighlights])
}, [onHighlightClick, finalHtml])
const highlightedMarkdown = useMemo(() => markdown, [markdown])
// Scroll to selected highlight in article when clicked from sidebar
useEffect(() => {
if (!selectedHighlightId || !contentRef.current) return
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
if (markElement) {
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
// Add pulsing animation after scroll completes
const htmlElement = markElement as HTMLElement
setTimeout(() => {
htmlElement.classList.add('highlight-pulse')
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
}, 500)
}
}, [selectedHighlightId, finalHtml])
// Calculate reading time from content (must be before early returns)
const readingStats = useMemo(() => {
@@ -130,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">
@@ -143,41 +197,59 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<div className="reader loading">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin />
<span>Loading content</span>
</div>
</div>
)
}
const highlightRgb = hexToRgb(highlightColor)
return (
<div className="reader">
{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>
)}
{markdown ? (
<div ref={contentRef} className="reader-markdown">
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
{/* Hidden markdown preview to convert markdown to HTML */}
{markdown && (
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{highlightedMarkdown}
{markdown}
</ReactMarkdown>
</div>
) : html ? (
<div ref={contentRef} className="reader-html" dangerouslySetInnerHTML={{ __html: html }} />
)}
<ReaderHeader
title={title}
image={image}
readingTimeText={readingStats ? readingStats.text : null}
hasHighlights={hasHighlights}
highlightCount={relevantHighlights.length}
/>
{markdown || html ? (
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
ref={contentRef}
className="reader-html"
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
onMouseUp={handleMouseUp}
/>
)
) : (
<div className="reader empty">
<p>No readable content found for this URL.</p>

View File

@@ -0,0 +1,38 @@
import React from 'react'
interface FontSelectorProps {
value: string
onChange: (font: string) => void
}
const FONTS = [
{ value: 'system', label: 'System Default', family: 'system-ui, -apple-system, sans-serif' },
{ value: 'inter', label: 'Inter', family: 'Inter, sans-serif' },
{ value: 'lora', label: 'Lora', family: 'Lora, serif' },
{ value: 'merriweather', label: 'Merriweather', family: 'Merriweather, serif' },
{ value: 'open-sans', label: 'Open Sans', family: 'Open Sans, sans-serif' },
{ value: 'roboto', label: 'Roboto', family: 'Roboto, sans-serif' },
{ value: 'source-serif-4', label: 'Source Serif 4', family: 'Source Serif 4, serif' },
{ value: 'crimson-text', label: 'Crimson Text', family: 'Crimson Text, serif' },
{ value: 'libre-baskerville', label: 'Libre Baskerville', family: 'Libre Baskerville, serif' },
{ value: 'pt-serif', label: 'PT Serif', family: 'PT Serif, serif' }
]
const FontSelector: React.FC<FontSelectorProps> = ({ value, onChange }) => {
return (
<select
id="readingFont"
value={value || 'system'}
onChange={(e) => onChange(e.target.value)}
className="setting-select font-select"
>
{FONTS.map(font => (
<option key={font.value} value={font.value} style={{ fontFamily: font.family }}>
{font.label}
</option>
))}
</select>
)
}
export default FontSelector

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,9 +1,15 @@
import React, { useMemo, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faGlobe } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
export interface HighlightVisibility {
nostrverse: boolean
friends: boolean
mine: boolean
}
interface HighlightsPanelProps {
highlights: Highlight[]
loading: boolean
@@ -11,10 +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
highlightVisibility?: HighlightVisibility
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
followedPubkeys?: Set<string>
}
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
@@ -24,42 +34,72 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onToggleCollapse,
onSelectUrl,
selectedUrl,
onToggleUnderlines,
onToggleHighlights,
selectedHighlightId,
onRefresh,
onHighlightClick
onHighlightClick,
currentUserPubkey,
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 to show only those relevant to the current URL
// Filter highlights based on visibility levels and URL
const filteredHighlights = useMemo(() => {
if (!selectedUrl) return highlights
const normalizeUrl = (url: string) => {
try {
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`)
return `${urlObj.hostname.replace(/^www\./, '')}${urlObj.pathname}`.replace(/\/$/, '').toLowerCase()
} catch {
return url.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '').toLowerCase()
let urlFiltered = highlights
// For Nostr articles (URL starts with "nostr:"), we don't need to filter by URL
// because we already fetched highlights specifically for this article
if (!selectedUrl.startsWith('nostr:')) {
// For web URLs, filter by URL matching
const normalizeUrl = (url: string) => {
try {
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`)
return `${urlObj.hostname.replace(/^www\./, '')}${urlObj.pathname}`.replace(/\/$/, '').toLowerCase()
} catch {
return url.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '').toLowerCase()
}
}
const normalizedSelected = normalizeUrl(selectedUrl)
urlFiltered = highlights.filter(h => {
if (!h.urlReference) return false
const normalizedRef = normalizeUrl(h.urlReference)
return normalizedSelected === normalizedRef ||
normalizedSelected.includes(normalizedRef) ||
normalizedRef.includes(normalizedSelected)
})
}
const normalizedSelected = normalizeUrl(selectedUrl)
return highlights.filter(h => {
if (!h.urlReference) return false
const normalizedRef = normalizeUrl(h.urlReference)
return normalizedSelected === normalizedRef ||
normalizedSelected.includes(normalizedRef) ||
normalizedRef.includes(normalizedSelected)
})
}, [highlights, selectedUrl])
// 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
@@ -82,33 +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">
{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 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={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={handleToggleHighlights}
className="toggle-highlight-display-btn"
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
aria-label={showHighlights ? 'Hide highlights' : 'Show highlights'}
>
<FontAwesomeIcon icon={showHighlights ? faEye : faEyeSlash} />
</button>
)}
</div>
<button
onClick={onToggleCollapse}
className="toggle-highlights-btn"
@@ -120,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

@@ -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,8 +1,11 @@
import React, { useState, useEffect, useRef } from 'react'
import { faTimes, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
import { faTimes, faList, faThLarge, faImage, faUnderline, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../services/settingsService'
import IconButton from './IconButton'
import ColorPicker from './ColorPicker'
import FontSelector from './FontSelector'
import { loadFont, getFontFamily } from '../utils/fontLoader'
import { hexToRgb } from '../utils/colorHelpers'
interface SettingsProps {
settings: UserSettings
@@ -26,9 +29,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)
@@ -41,7 +43,7 @@ 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')
return (
<div className="settings-view">
@@ -62,23 +64,10 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
<div className="setting-group setting-inline">
<label htmlFor="readingFont">Reading Font</label>
<select
id="readingFont"
value={localSettings.readingFont || 'system'}
onChange={(e) => setLocalSettings({ ...localSettings, readingFont: e.target.value })}
className="setting-select font-select"
>
<option value="system" style={{ fontFamily: 'system-ui, -apple-system, sans-serif' }}>System Default</option>
<option value="inter" style={{ fontFamily: 'Inter, sans-serif' }}>Inter</option>
<option value="lora" style={{ fontFamily: 'Lora, serif' }}>Lora</option>
<option value="merriweather" style={{ fontFamily: 'Merriweather, serif' }}>Merriweather</option>
<option value="open-sans" style={{ fontFamily: 'Open Sans, sans-serif' }}>Open Sans</option>
<option value="roboto" style={{ fontFamily: 'Roboto, sans-serif' }}>Roboto</option>
<option value="source-serif-4" style={{ fontFamily: 'Source Serif 4, serif' }}>Source Serif 4</option>
<option value="crimson-text" style={{ fontFamily: 'Crimson Text, serif' }}>Crimson Text</option>
<option value="libre-baskerville" style={{ fontFamily: 'Libre Baskerville, serif' }}>Libre Baskerville</option>
<option value="pt-serif" style={{ fontFamily: 'PT Serif, serif' }}>PT Serif</option>
</select>
<FontSelector
value={localSettings.readingFont || 'source-serif-4'}
onChange={(font) => setLocalSettings({ ...localSettings, readingFont: font })}
/>
</div>
<div className="setting-group setting-inline">
@@ -88,7 +77,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` }}
>
@@ -99,30 +88,84 @@ 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>
</label>
</div>
<div className="setting-group setting-inline">
<label>Highlight Style</label>
<div className="setting-buttons">
<IconButton
icon={faHighlighter}
onClick={() => setLocalSettings({ ...localSettings, highlightStyle: 'marker' })}
title="Text marker style"
ariaLabel="Text marker style"
variant={(localSettings.highlightStyle || 'marker') === 'marker' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faUnderline}
onClick={() => setLocalSettings({ ...localSettings, highlightStyle: 'underline' })}
title="Underline style"
ariaLabel="Underline style"
variant={localSettings.highlightStyle === 'underline' ? 'primary' : 'ghost'}
/>
</div>
</div>
<div className="setting-group setting-inline">
<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">
<div className="preview-label">Preview</div>
<div
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" : ""}>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>
@@ -161,7 +204,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"
/>
@@ -174,7 +217,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,24 +1,40 @@
import React from 'react'
import React, { useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faUser, faList, faThLarge, faImage, faGear } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faRightFromBracket, faRightToBracket, 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()
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
const handleLogin = async () => {
try {
setIsConnecting(true)
const account = await Accounts.ExtensionAccount.fromExtension()
accountManager.addAccount(account)
accountManager.setActive(account)
} catch (error) {
console.error('Login failed:', error)
alert('Login failed. Please install a nostr browser extension and try again.')
} finally {
setIsConnecting(false)
}
}
const getProfileImage = () => {
return profile?.picture || null
}
@@ -44,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}
@@ -58,36 +79,36 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Settings"
variant="ghost"
/>
<IconButton
icon={faRightFromBracket}
onClick={onLogout}
title="Logout"
ariaLabel="Logout"
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
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}
onClick={onLogout}
title="Logout"
ariaLabel="Logout"
variant="ghost"
/>
) : (
<IconButton
icon={faRightToBracket}
onClick={isConnecting ? () => {} : handleLogin}
title={isConnecting ? "Connecting..." : "Login"}
ariaLabel="Login"
variant="ghost"
/>
)}
</div>
</div>
</>
)

View File

@@ -0,0 +1,105 @@
import { useEffect } from 'react'
import { RelayPool } from 'applesauce-relay'
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
relayPool: RelayPool | null
setSelectedUrl: (url: string) => void
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
setIsCollapsed: (collapsed: boolean) => void
setHighlights: (highlights: Highlight[]) => void
setHighlightsLoading: (loading: boolean) => void
setCurrentArticleCoordinate: (coord: string | undefined) => void
setCurrentArticleEventId: (id: string | undefined) => void
setCurrentArticle?: (article: NostrEvent) => void
}
export function useArticleLoader({
naddr,
relayPool,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed,
setHighlights,
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId,
setCurrentArticle
}: UseArticleLoaderProps) {
useEffect(() => {
if (!relayPool || !naddr) return
const loadArticle = async () => {
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
// Keep highlights panel collapsed by default - only open on user interaction
try {
const article = await fetchArticleByNaddr(relayPool, naddr)
setReaderContent({
title: article.title,
markdown: article.markdown,
image: article.image,
url: `nostr:${naddr}`
})
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
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)
setHighlights([]) // Clear old highlights
const highlightsList: Highlight[] = []
await fetchHighlightsForArticle(
relayPool,
articleCoordinate,
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 ${highlightsList.length} highlights`)
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
setHighlightsLoading(false)
}
} catch (err) {
console.error('Failed to load article:', err)
setReaderContent({
title: 'Error Loading Article',
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url: `nostr:${naddr}`
})
setReaderLoading(false)
}
}
loadArticle()
}, [naddr, relayPool])
}

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 {
@@ -98,7 +105,32 @@ body {
/* 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 +141,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 +218,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 +227,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 +463,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 +475,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 +501,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 +525,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 +752,8 @@ body {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
max-width: 100%;
}
.bookmarks-grid.bookmarks-compact {
@@ -711,7 +766,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 +783,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 +803,9 @@ body {
align-items: center;
gap: 0.75rem;
height: 28px;
justify-content: space-between;
width: 100%;
min-width: 0;
}
.compact-row.clickable {
@@ -766,7 +826,7 @@ body {
}
.compact-text {
flex: 1;
flex: 1 1 0;
min-width: 0;
color: #ccc;
font-size: 0.85rem;
@@ -774,6 +834,7 @@ body {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.bookmark-date-compact {
@@ -784,8 +845,8 @@ body {
}
.compact-read-btn {
background: #28a745;
color: white;
background: transparent;
color: #888;
border: none;
padding: 0;
border-radius: 4px;
@@ -797,11 +858,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 {
@@ -1023,6 +1085,48 @@ body {
background: #218838;
}
/* Article hero image in card view */
.article-hero-image {
width: 100%;
height: 200px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 8px 8px 0 0;
position: relative;
}
.article-hero-image:hover {
opacity: 0.9;
}
.article-hero-image::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.4) 100%);
pointer-events: none;
border-radius: 8px 8px 0 0;
}
/* Hero image in reader view */
.reader-hero-image {
width: 100%;
margin: 0 0 2rem 0;
border-radius: 8px;
overflow: hidden;
}
.reader-hero-image img {
width: 100%;
height: auto;
max-height: 500px;
object-fit: cover;
display: block;
}
/* Private Bookmark Styles */
.private-bookmark {
background: #2a2a2a;
@@ -1129,13 +1233,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;
}
@@ -1143,9 +1248,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;
@@ -1191,10 +1296,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;
@@ -1218,8 +1336,79 @@ body {
gap: 0.5rem;
}
.highlight-mode-toggle {
display: flex;
gap: 0.25rem;
padding: 0.25rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.highlight-mode-toggle .mode-btn {
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 0.375rem 0.5rem;
border-radius: 3px;
transition: all 0.2s;
font-size: 0.9rem;
}
.highlight-mode-toggle .mode-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.highlight-mode-toggle .mode-btn.active {
background: #646cff;
color: #fff;
}
/* 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;
@@ -1236,14 +1425,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);
}
@@ -1309,6 +1498,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;
@@ -1316,6 +1521,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;
@@ -1344,41 +1562,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;
}
@@ -1402,60 +1604,215 @@ body {
}
/* Inline content highlights - fluorescent marker style */
.content-highlight {
background: rgba(255, 255, 0, 0.35);
.content-highlight,
.content-highlight-marker {
background: rgba(var(--highlight-rgb, 255, 255, 0), 0.35);
padding: 0.125rem 0.25rem;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
border-radius: 2px;
box-shadow: 0 0 8px rgba(255, 255, 0, 0.2);
box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2);
contain: layout style;
}
.content-highlight:hover {
background: rgba(255, 255, 0, 0.5);
box-shadow: 0 0 12px rgba(255, 255, 0, 0.3);
.content-highlight:hover,
.content-highlight-marker:hover {
background: rgba(var(--highlight-rgb, 255, 255, 0), 0.5);
box-shadow: 0 0 12px rgba(var(--highlight-rgb, 255, 255, 0), 0.3);
}
.content-highlight.highlight-pulse {
/* Underline style for highlights */
.content-highlight-underline {
background: transparent;
padding: 0;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
text-decoration: underline;
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 {
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 1);
text-decoration-thickness: 3px;
}
.content-highlight.highlight-pulse,
.content-highlight-marker.highlight-pulse,
.content-highlight-underline.highlight-pulse {
animation: highlight-pulse-animation 1.5s ease-in-out;
}
@keyframes highlight-pulse-animation {
0%, 100% {
box-shadow: 0 0 8px rgba(255, 255, 0, 0.2);
box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2);
transform: scale(1);
}
25% {
box-shadow: 0 0 20px rgba(255, 255, 0, 0.6);
box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6);
transform: scale(1.02);
}
50% {
box-shadow: 0 0 8px rgba(255, 255, 0, 0.2);
box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2);
transform: scale(1);
}
75% {
box-shadow: 0 0 20px rgba(255, 255, 0, 0.6);
box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6);
transform: scale(1.02);
}
}
.reader-html .content-highlight,
.reader-markdown .content-highlight {
.reader-markdown .content-highlight,
.reader-html .content-highlight-marker,
.reader-markdown .content-highlight-marker,
.reader-html .content-highlight-underline,
.reader-markdown .content-highlight-underline {
color: inherit;
}
.reader-html .content-highlight,
.reader-markdown .content-highlight,
.reader-html .content-highlight-marker,
.reader-markdown .content-highlight-marker {
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 {
background: rgba(255, 255, 0, 0.4);
box-shadow: 0 0 6px rgba(255, 255, 0, 0.15);
.content-highlight,
.content-highlight-marker {
background: rgba(var(--highlight-rgb, 255, 255, 0), 0.4);
box-shadow: 0 0 6px rgba(var(--highlight-rgb, 255, 255, 0), 0.15);
}
.content-highlight:hover {
background: rgba(255, 255, 0, 0.55);
box-shadow: 0 0 10px rgba(255, 255, 0, 0.25);
.content-highlight:hover,
.content-highlight-marker:hover {
background: rgba(var(--highlight-rgb, 255, 255, 0), 0.55);
box-shadow: 0 0 10px rgba(var(--highlight-rgb, 255, 255, 0), 0.25);
}
.content-highlight-underline {
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.9);
}
.content-highlight-underline:hover {
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 {
@@ -1527,6 +1884,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;
}
@@ -1545,13 +1913,50 @@ body {
gap: 0.5rem;
}
.color-picker {
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-swatch {
width: 33px;
height: 33px;
border: 1px solid #444;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.color-swatch:hover {
border-color: #888;
}
.color-swatch.active {
border-color: #646cff;
box-shadow: 0 0 0 2px #646cff;
}
.color-swatch.active::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #000;
font-size: 0.875rem;
font-weight: bold;
text-shadow: 0 0 2px #fff;
}
.font-size-btn {
min-width: 2.5rem;
height: 2.5rem;
padding: 0.5rem;
min-width: 33px;
height: 33px;
padding: 0;
background: transparent;
border: 1px solid #444;
border-radius: 4px;
border-radius: 6px;
color: #ccc;
cursor: pointer;
transition: all 0.2s;

View File

@@ -0,0 +1,165 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools'
import {
getArticleTitle,
getArticleImage,
getArticlePublished,
getArticleSummary
} from 'applesauce-core/helpers'
export interface ArticleContent {
title: string
markdown: string
image?: string
published?: number
summary?: string
author: string
event: NostrEvent
}
interface CachedArticle {
content: ArticleContent
timestamp: number
}
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000 // 7 days in milliseconds
const CACHE_PREFIX = 'article_cache_'
function getCacheKey(naddr: string): string {
return `${CACHE_PREFIX}${naddr}`
}
function getFromCache(naddr: string): ArticleContent | null {
try {
const cacheKey = getCacheKey(naddr)
const cached = localStorage.getItem(cacheKey)
if (!cached) return null
const { content, timestamp }: CachedArticle = JSON.parse(cached)
const age = Date.now() - timestamp
if (age > CACHE_TTL) {
localStorage.removeItem(cacheKey)
return null
}
console.log('📦 Loaded article from cache:', naddr)
return content
} catch {
return null
}
}
function saveToCache(naddr: string, content: ArticleContent): void {
try {
const cacheKey = getCacheKey(naddr)
const cached: CachedArticle = {
content,
timestamp: Date.now()
}
localStorage.setItem(cacheKey, JSON.stringify(cached))
console.log('💾 Saved article to cache:', naddr)
} catch (err) {
console.warn('Failed to cache article:', err)
// Silently fail if storage is full or unavailable
}
}
/**
* Fetches a Nostr long-form article (NIP-23) by naddr
* @param relayPool - The relay pool to query
* @param naddr - The article's naddr
* @param bypassCache - If true, skip cache and fetch fresh from relays
*/
export async function fetchArticleByNaddr(
relayPool: RelayPool,
naddr: string,
bypassCache = false
): Promise<ArticleContent> {
try {
// Check cache first unless bypassed
if (!bypassCache) {
const cached = getFromCache(naddr)
if (cached) return cached
}
// Decode the naddr
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
throw new Error('Invalid naddr format')
}
const pointer = decoded.data as AddressPointer
// Define relays to query
const relays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.primal.net'
]
// Fetch the article event
const filter = {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier]
}
// Use applesauce relay pool pattern
const events = await lastValueFrom(
relayPool
.req(relays, filter)
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
)
if (events.length === 0) {
throw new Error('Article not found')
}
// Sort by created_at and take the most recent
events.sort((a, b) => b.created_at - a.created_at)
const article = events[0]
const title = getArticleTitle(article) || 'Untitled Article'
const image = getArticleImage(article)
const published = getArticlePublished(article)
const summary = getArticleSummary(article)
const content: ArticleContent = {
title,
markdown: article.content,
image,
published,
summary,
author: article.pubkey,
event: article
}
// Save to cache before returning
saveToCache(naddr, content)
return content
} catch (err) {
console.error('Failed to fetch article:', err)
throw err
}
}
/**
* Checks if a string is a valid naddr
*/
export function isNaddr(str: string): boolean {
try {
const decoded = nip19.decode(str)
return decoded.type === 'naddr'
} catch {
return false
}
}

View File

@@ -1,4 +1,5 @@
import { getParsedContent } from 'applesauce-content/text'
import { getArticleTitle } from 'applesauce-core/helpers'
import { ActiveAccount, IndividualBookmark, ParsedContent } from '../types/bookmarks'
import type { NostrEvent } from './bookmarkEvents'
@@ -94,14 +95,24 @@ export function hydrateItems(
return items.map(item => {
const ev = idToEvent.get(item.id)
if (!ev) return item
// For long-form articles (kind:30023), use the article title as content
let content = ev.content || item.content || ''
if (ev.kind === 30023) {
const articleTitle = getArticleTitle(ev)
if (articleTitle) {
content = articleTitle
}
}
return {
...item,
pubkey: ev.pubkey || item.pubkey,
content: ev.content || item.content || '',
content,
created_at: ev.created_at || item.created_at,
kind: ev.kind || item.kind,
tags: ev.tags || item.tags,
parsedContent: ev.content ? (getParsedContent(ev.content) as ParsedContent) : item.parsedContent
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
}
})
}

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,
@@ -29,22 +29,116 @@ function dedupeHighlights(events: NostrEvent[]): NostrEvent[] {
return Array.from(byId.values())
}
export const fetchHighlights = async (
/**
* Fetches highlights for a specific article by its address coordinate and/or event ID
* @param relayPool - The relay pool to query
* @param articleCoordinate - The article's address in format "kind:pubkey:identifier" (e.g., "30023:abc...def:my-article")
* @param eventId - Optional event ID to also query by 'e' tag
*/
export const fetchHighlightsForArticle = async (
relayPool: RelayPool,
pubkey: string
articleCoordinate: string,
eventId?: string,
onHighlight?: (highlight: Highlight) => void
): Promise<Highlight[]> => {
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
// Use well-known relays for highlights even if user isn't logged in
const highlightRelays = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.snort.social',
'wss://purplepag.es'
]
console.log('🔍 Fetching highlights (kind 9802) from relays:', relayUrls)
console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate)
console.log('🔍 Event ID:', eventId || 'none')
console.log('🔍 From relays:', highlightRelays)
const rawEvents = await lastValueFrom(
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
const aTagEvents = await lastValueFrom(
relayPool
.req(relayUrls, { kinds: [9802], authors: [pubkey] })
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
.req(highlightRelays, { kinds: [9802], '#a': [articleCoordinate] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) {
onHighlight(highlight)
}
}),
completeOnEose(),
takeUntil(timer(10000)),
toArray()
)
)
console.log('📊 Raw highlight events fetched:', rawEvents.length)
console.log('📊 Highlights via a-tag:', aTagEvents.length)
// If we have an event ID, also query for highlights that reference via the 'e' tag
let eTagEvents: NostrEvent[] = []
if (eventId) {
eTagEvents = await lastValueFrom(
relayPool
.req(highlightRelays, { kinds: [9802], '#e': [eventId] })
.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)
}
// Combine results from both queries
const rawEvents = [...aTagEvents, ...eTagEvents]
console.log('📊 Total raw highlight events fetched:', rawEvents.length)
if (rawEvents.length > 0) {
console.log('📄 Sample highlight tags:', JSON.stringify(rawEvents[0].tags, null, 2))
} else {
console.log('❌ No highlights found. Article coordinate:', articleCoordinate)
console.log('❌ Event ID:', eventId || 'none')
console.log('💡 Try checking if there are any highlights on this article at https://highlighter.com')
}
// Deduplicate events by ID
const uniqueEvents = dedupeHighlights(rawEvents)
@@ -84,7 +178,110 @@ export const fetchHighlights = async (
// Sort by creation time (newest first)
return highlights.sort((a, b) => b.created_at - a.created_at)
} catch (error) {
console.error('Failed to fetch highlights:', error)
console.error('Failed to fetch highlights for article:', error)
return []
}
}
/**
* 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,
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(
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 and process events
const uniqueEvents = dedupeHighlights(rawEvents)
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
const highlights: Highlight[] = uniqueEvents.map((event: NostrEvent) => {
const highlightText = getHighlightText(event)
const context = getHighlightContext(event)
const comment = getHighlightComment(event)
const sourceEventPointer = getHighlightSourceEventPointer(event)
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
const sourceUrl = getHighlightSourceUrl(event)
const attributions = getHighlightAttributions(event)
const author = attributions.find(a => a.role === 'author')?.pubkey
const eventReference = sourceEventPointer?.id ||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
return {
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
content: highlightText,
tags: event.tags,
eventReference,
urlReference: sourceUrl,
author,
context,
comment
}
})
// Sort by creation time (newest first)
return highlights.sort((a, b) => b.created_at - a.created_at)
} catch (error) {
console.error('Failed to fetch highlights by author:', error)
return []
}
}

View File

@@ -6,6 +6,7 @@ export interface ReadableContent {
title?: string
html?: string
markdown?: string
image?: string
}
interface CachedContent {

View File

@@ -11,11 +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
}

16
src/utils/colorHelpers.ts Normal file
View File

@@ -0,0 +1,16 @@
// Helper to convert hex color to RGB values
export function hexToRgb(hex: string): string {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}`
: '255, 255, 0'
}
export const HIGHLIGHT_COLORS = [
{ name: 'Yellow', value: '#ffff00' },
{ name: 'Orange', value: '#ff9500' },
{ name: 'Pink', value: '#ff69b4' },
{ name: 'Green', value: '#00ff7f' },
{ name: 'Blue', value: '#4da6ff' },
{ name: 'Purple', value: '#b19cd9' }
]

View File

@@ -0,0 +1,44 @@
import { nip19 } from 'nostr-tools'
import { RelayPool } from 'applesauce-relay'
import { fetchArticleByNaddr } from '../services/articleService'
import { fetchReadableContent, ReadableContent } from '../services/readerService'
export interface BookmarkReference {
id: string
kind: number
tags: string[][]
pubkey: string
}
export async function loadContent(
url: string,
relayPool: RelayPool,
bookmark?: BookmarkReference
): Promise<ReadableContent> {
// Check if this is a kind:30023 article
if (bookmark && bookmark.kind === 30023) {
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
if (dTag !== undefined && bookmark.pubkey) {
const pointer = {
identifier: dTag,
kind: 30023,
pubkey: bookmark.pubkey,
}
const naddr = nip19.naddrEncode(pointer)
const article = await fetchArticleByNaddr(relayPool, naddr)
return {
title: article.title,
markdown: article.markdown,
image: article.image,
url: `nostr:${naddr}`
}
} else {
throw new Error('Invalid article reference - missing d tag or pubkey')
}
} else {
// For regular URLs, fetch readable content
return await fetchReadableContent(url)
}
}

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}
@@ -99,10 +101,12 @@ export function applyHighlightsToText(
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim()
// Helper to create a mark element for a highlight
function createMarkElement(highlight: Highlight, matchText: string): HTMLElement {
function createMarkElement(highlight: Highlight, matchText: string, highlightStyle: 'marker' | 'underline' = 'marker'): HTMLElement {
const mark = document.createElement('mark')
mark.className = 'content-highlight'
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
@@ -127,7 +131,8 @@ function tryMarkInTextNodes(
textNodes: Text[],
searchText: string,
highlight: Highlight,
useNormalized: boolean
useNormalized: boolean,
highlightStyle: 'marker' | 'underline' = 'marker'
): boolean {
const normalizedSearch = normalizeWhitespace(searchText)
@@ -154,7 +159,7 @@ function tryMarkInTextNodes(
const before = text.substring(0, actualIndex)
const match = text.substring(actualIndex, actualIndex + searchText.length)
const after = text.substring(actualIndex + searchText.length)
const mark = createMarkElement(highlight, match)
const mark = createMarkElement(highlight, match, highlightStyle)
replaceTextWithMark(textNode, before, after, mark)
return true
@@ -166,7 +171,7 @@ function tryMarkInTextNodes(
/**
* Apply highlights to HTML content by injecting mark tags using DOM manipulation
*/
export function applyHighlightsToHTML(html: string, highlights: Highlight[]): string {
export function applyHighlightsToHTML(html: string, highlights: Highlight[], highlightStyle: 'marker' | 'underline' = 'marker'): string {
if (!html || highlights.length === 0) return html
const tempDiv = document.createElement('div')
@@ -183,8 +188,8 @@ export function applyHighlightsToHTML(html: string, highlights: Highlight[]): st
while ((node = walker.nextNode())) textNodes.push(node as Text)
// Try exact match first, then normalized match
tryMarkInTextNodes(textNodes, searchText, highlight, false) ||
tryMarkInTextNodes(textNodes, searchText, highlight, true)
tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
}
return tempDiv.innerHTML

View File

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

5
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_DEFAULT_ARTICLE_NADDR: string
}