Compare commits

..

37 Commits

Author SHA1 Message Date
Gigi
5d22819ae3 chore: bump version to 0.10.23 2025-10-25 01:03:57 +02:00
Gigi
6761b1861e Merge pull request #26 from dergigi/tts-fixes-and-stuff
Fix highlight loading issues and improve article performance
2025-10-25 01:03:35 +02:00
Gigi
1d989eae76 fix: improve article loading performance and error handling 2025-10-25 01:02:42 +02:00
Gigi
33d6e5882d refactor: simplify highlight loading code
- Remove redundant fallback mechanisms and backup effects
- Remove unnecessary parameters from useArticleLoader interface
- Keep only essential highlight loading logic
- Maintain DRY principle by eliminating duplicate code
- Simplify the codebase while preserving functionality
2025-10-25 01:00:36 +02:00
Gigi
0a62924b78 feat: implement robust highlight loading with fallback mechanisms
- Add detailed logging to track highlight loading process
- Implement fallback timeout mechanism to retry highlight loading after 2 seconds
- Add backup effect that triggers when article coordinate changes
- Ensure highlights are loaded reliably after article content is fully loaded
- Add console logging to help debug highlight loading issues
2025-10-25 00:59:26 +02:00
Gigi
e2472606dd fix: properly filter Nostr article highlights in sidebar
- Extract article coordinate from nostr: URLs using nip19.decode
- Filter highlights by eventReference matching the article coordinate
- Fix issue where unrelated highlights were showing in sidebar
- Apply same filtering logic to both useFilteredHighlights and filterHighlightsByUrl
2025-10-25 00:56:19 +02:00
Gigi
6f04b8f513 chore: update bookmark components and remove migration docs
- Update BookmarkItem.tsx with latest changes
- Update CardView.tsx and CompactView.tsx bookmark view components
- Update ThreePaneLayout.tsx with latest modifications
- Remove TAILWIND_MIGRATION.md as migration is complete
2025-10-25 00:55:02 +02:00
Gigi
a8ad346c5d feat: implement smart highlight clearing for articles
- Preserve highlights that belong to the current article when switching articles
- Only clear highlights that don't match the current article coordinate or event ID
- Improve user experience by maintaining relevant highlights during navigation
2025-10-25 00:54:02 +02:00
Gigi
465c24ed3a fix: resolve highlight loading issues for articles
- Add missing eventStore parameter to fetchHighlightsForArticle call
- Clear highlights immediately when starting to load new article
- Fix infinite loading spinners when articles have zero highlights
- Ensure highlights are properly stored and persisted
2025-10-25 00:52:49 +02:00
Gigi
04dea350a4 fix: consolidate multiple skeleton loaders in article view
Remove duplicate ContentSkeleton components that were showing simultaneously.
Now uses a single skeleton for both loading and no-content states.

This follows DRY principles and prevents multiple skeletons from appearing
at the same time in the article view.
2025-10-25 00:47:50 +02:00
Gigi
29c4bcb69b fix: replace markdown loading spinner with skeleton
Replace the small spinner used for markdown content loading with a proper
ContentSkeleton for better visual consistency and user experience.

This ensures all content loading states use skeleton loaders instead of
spinners where appropriate.
2025-10-25 00:47:15 +02:00
Gigi
23ea7f352b fix: replace 'No readable content found' with skeleton loader
Replace the confusing 'No readable content found for this URL' message that
appears during loading states with a skeleton loader for better UX.

This prevents users from seeing error messages while content is still loading.
2025-10-25 00:46:27 +02:00
Gigi
36b35367f1 fix: prevent race conditions between content loaders
Add coordination logic to ensure only one content loader (article/external/event)
runs at a time. This prevents state conflicts that caused 'No readable content found'
errors and stale content from previous articles appearing.

The existing instant-load + background-refresh flow is preserved.
2025-10-25 00:45:13 +02:00
Gigi
183463c817 feat: align home button to left next to profile button
- Move home button from right side to left side in sidebar header
- Add sidebar-header-left container for left-aligned elements
- Update CSS to support new layout with flex positioning
- Home button now appears next to profile button when logged in
2025-10-25 00:32:14 +02:00
Gigi
7fb91e71f1 fix: add missing relayPool dependency to useEffect
- Add relayPool to dependency array in VideoView useEffect
- Fixes React hooks exhaustive-deps linting warning
- Ensures effect runs when relayPool changes
2025-10-25 00:31:15 +02:00
Gigi
717f094984 feat: use note content as title for direct video URLs
- Extract first 100 characters of note content as video title
- Truncate with ellipsis if content is longer than 100 characters
- Fallback to YouTube metadata title or original title if no note content
- Improves user experience by showing meaningful titles for direct videos from Nostr notes
2025-10-25 00:30:32 +02:00
Gigi
c69e50d3bb feat: add note content support for direct video URLs
- Add noteContent prop to VideoView component for displaying note text
- Update VideoView to prioritize note content over metadata when available
- Detect direct video URLs from Nostr notes (nostr.build, nostr.video domains)
- Pass bookmark information through URL selection in bookmark components
- Show placeholder message for direct videos from Nostr notes
- Maintains backward compatibility with existing video metadata extraction
2025-10-25 00:30:07 +02:00
Gigi
4e4d719d94 feat: add video thumbnail support for cover images
- Add YouTube thumbnail extraction using existing getYouTubeThumbnail utility
- Add Vimeo thumbnail support using vumbnail.com service
- Update VideoView to use video thumbnails as cover images in ReaderHeader
- Update Vimeo API to include thumbnail_url in response
- Fallback to original image prop if no video thumbnail available
- Supports both YouTube and Vimeo video thumbnails
2025-10-25 00:28:07 +02:00
Gigi
d453a6439c fix: improve video metadata extraction for YouTube and Vimeo
- Add actual YouTube title and description fetching via web scraping
- Fix syntax error in video-meta.ts (missing opening brace)
- Complete Vimeo metadata implementation
- Both APIs now properly extract title and description from video pages
- Caption extraction remains functional for supported videos
2025-10-25 00:24:30 +02:00
Gigi
5dfa6ba3ae feat: extract video functionality into dedicated VideoView component
- Create VideoView component with dedicated video player, metadata, and menu
- Remove video-specific logic from ContentPanel for better separation of concerns
- Update ThreePaneLayout to conditionally render VideoView vs ContentPanel
- Maintain all existing video features: YouTube metadata, transcripts, mark as watched
- Improve code organization and maintainability
2025-10-25 00:19:16 +02:00
Gigi
f2d2883eee docs: update CHANGELOG for v0.10.22
- Added mobile-optimized tab interface improvements
- Documented brand tagline update and UI reordering
- Listed mobile sidebar navigation fixes
- Recorded reading progress versioning removal
2025-10-25 00:11:19 +02:00
Gigi
84001d1b83 chore: bump version to 0.10.22 2025-10-25 00:10:49 +02:00
Gigi
b7a390cf89 Merge pull request #25 from dergigi/trying-to-fix-annoying-bugs
Mobile UX improvements and bug fixes
2025-10-25 00:10:31 +02:00
Gigi
59d9179642 ui: hide tab text labels on mobile for /my and /p/ pages
- Add CSS media query to hide .tab-label on screens ≤768px
- Adjust tab padding and gap for mobile to show only icons
- Saves space on mobile for highlights, bookmarks, reads, links, writings tabs
- Affects Me, Profile, and Explore components
2025-10-25 00:08:40 +02:00
Gigi
68301cd20f brand: update tagline from 'Nostr Bookmarks' to 'Read, Highlight, Explore'
- Update HTML title and meta tags in index.html
- Update PWA manifest in public/manifest.webmanifest
- Update Vite PWA manifest in vite.config.ts
- Update article OG title fallback in api/article-og.ts
- Reflects the core functionality: reading, highlighting, and exploring content
2025-10-25 00:06:19 +02:00
Gigi
4d6b7e1a46 ui: reorder bookmarks bar buttons
- Change button order to: Home, Explore, Settings
- Maintains all existing functionality
2025-10-25 00:05:23 +02:00
Gigi
95fe9b548f fix: close mobile sidebar when clicking navigation items
- Add mobile sidebar close logic to handleMenuItemClick for profile menu items
- Add mobile sidebar close logic to Home, Settings, and Explore buttons
- Fixes issue where mobile bookmarks bar didn't close when navigating to My Reads, My Highlights, etc.
2025-10-25 00:05:04 +02:00
Gigi
e86ae9f05e ui: move highlight button higher up
- Change bottom position from 32px to 80px
- Provides more space from bottom edge for better positioning
2025-10-25 00:04:02 +02:00
Gigi
2124be83c3 refactor: remove versioning from reading progress implementation
- Remove 'ver' field from ReadingProgressContent interface
- Remove ver: '1' from saveReadingPosition function
- Update PROGRESS_CACHE_KEY to remove v1 suffix
2025-10-25 00:02:40 +02:00
Gigi
a8bb17d4cd fix: replace any types with proper type-safe inert attribute handling 2025-10-23 22:06:35 +02:00
Gigi
a886a68822 feat(highlights): increase fetch limits for better explore page coverage
- Increase friends highlights limit from 200 to 1000
- Add 1000 limit to nostrverse highlights on initial load
- Ensures users see more highlights from friends and nostrverse
- Incremental syncs continue to use 'since' filter without limit
2025-10-23 21:59:58 +02:00
Gigi
76bdbc670d fix(nostrverse): merge incremental highlights instead of replacing
- Initialize highlightsMap with existing highlights on incremental sync
- Merge new highlights with existing ones instead of replacing entire list
- Keep existing highlights on error instead of clearing them
- Fixes issue where nostrverse highlights would disappear on page refresh
2025-10-23 21:54:53 +02:00
Gigi
c16ce1fc7e feat(explore): persist scope visibility to localStorage
- Load explore scope visibility from localStorage on mount
- Save user's scope toggles to localStorage when changed
- Only reset to settings defaults if no saved preference exists
- Ensures user's scope selection persists across page refreshes
2025-10-23 21:43:48 +02:00
Gigi
a578d67b1e fix: load reading positions for web bookmarks (kind:39701)
Web bookmarks store their URL in the 'd' tag, not in content.
The getBookmarkReadingProgress function was only extracting URLs from
content, which meant reading progress indicators weren't showing for
web bookmarks. Now it properly extracts URLs from the 'd' tag for
kind:39701 bookmarks.
2025-10-23 21:14:15 +02:00
Gigi
25d1ead9f5 fix: add error handling when closing relay connections
Wrap relay.close() in try-catch to gracefully handle cases where WebSocket
connections are closed before they're fully established. This can occur when
relay sets change rapidly during app initialization (e.g., when loading user
relay lists).
2025-10-23 20:52:42 +02:00
Gigi
ae5ea66dd2 fix(a11y): replace aria-hidden with inert to prevent focus issues
Replaced aria-hidden with inert attribute on mobile sidebar and highlights panes. The inert attribute both hides from assistive technology AND prevents focus, eliminating the accessibility warning about focused elements being hidden.
2025-10-23 20:48:36 +02:00
Gigi
cf5f8fae16 docs: update CHANGELOG for v0.10.21 2025-10-23 20:43:15 +02:00
27 changed files with 789 additions and 539 deletions

View File

@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.10.22] - 2025-01-27
### Added
- Mobile-optimized tab interface for `/my` and `/p/` pages
- Tab text labels hidden on mobile screens (≤768px) to save space
- Shows only icons for highlights, bookmarks, reads, links, and writings tabs
- Maintains full functionality while improving mobile UX
### Changed
- Updated brand tagline from "Nostr Bookmarks" to "Read, Highlight, Explore"
- Updated across HTML meta tags, PWA manifest, and Open Graph metadata
- Better reflects core functionality: reading, highlighting, and exploring content
- Reordered bookmarks bar navigation buttons
- New order: Home, Explore, Settings (previously: Home, Settings, Explore)
- Moved highlight button higher up on screen
- Changed position from `bottom: 32px` to `bottom: 80px` for better accessibility
### Fixed
- Mobile sidebar not closing when navigating to profile sections
- Fixed issue where clicking "My Reads", "My Highlights", etc. didn't close mobile sidebar
- Added mobile sidebar close logic to all navigation buttons
- Improved mobile navigation experience
- Removed unnecessary versioning from reading progress implementation
- Simplified reading progress events by removing `ver` field
- Updated cache key to remove version suffix
- Cleaner, more maintainable code
## [0.10.21] - 2025-10-23
### Fixed
- Reading position tracking for internal event URLs
- Prevents tracking for `nostr-event:` sentinel URLs (internal convention, not a valid Nostr URI per NIP-21)
- Fixes "String must be lowercase or uppercase" error when loading base64-encoded event URLs
- These internal sentinels are no longer saved to reading position data
- Compact bookmark view display
- Articles now show title instead of summary in compact view
- Extracts article title from NIP-23 metadata tags
- Removed unused `articleSummary` prop to keep code DRY
- Bookmark deduplication in profile view
- Same article appearing in multiple bookmark lists/sets now displays only once
- Uses coordinate-based deduplication (`kind:pubkey:identifier`) for articles
- Applies `dedupeBookmarksById` when flattening bookmarks from different sources
## [0.10.20] - 2025-10-23
### Added

View File

@@ -1,188 +0,0 @@
# Tailwind CSS Migration Status
## ✅ Completed (Core Infrastructure)
### Phase 1: Setup & Foundation
- [x] Install Tailwind CSS with PostCSS and Autoprefixer
- [x] Configure `tailwind.config.js` with content globs and custom keyframes
- [x] Create `src/styles/tailwind.css` with base/components/utilities
- [x] Import Tailwind before existing CSS in `main.tsx`
- [x] Enable Tailwind preflight (CSS reset)
### Phase 2: Base Styles Reconciliation
- [x] Add CSS variables for user-settable theme colors
- `--highlight-color-mine`, `--highlight-color-friends`, `--highlight-color-nostrverse`
- `--reading-font`, `--reading-font-size`
- [x] Simplify `global.css` to work with Tailwind preflight
- [x] Remove redundant base styles handled by Tailwind
- [x] Keep app-specific overrides (mobile sidebar lock, loading states)
### Phase 3: Layout System Refactor ⭐ **CRITICAL FIX**
- [x] Switch from pane-scrolling to document-scrolling
- [x] Make sidebars sticky on desktop (`position: sticky`)
- [x] Update `app.css` to remove fixed container heights
- [x] Update `ThreePaneLayout.tsx` to use window scroll
- [x] Fix reading position tracking to work with document scroll
- [x] Maintain mobile overlay behavior
### Phase 4: Component Migrations
- [x] **ReadingProgressIndicator**: Full Tailwind conversion
- Removed 80+ lines of CSS
- Added shimmer animation to Tailwind config
- Z-index layering maintained (1102)
- [x] **Mobile UI Elements**: Tailwind utilities
- Mobile hamburger button
- Mobile highlights button
- Mobile backdrop
- Removed 60+ lines of CSS
- [x] **App Container**: Tailwind utilities
- Responsive padding (p-0 md:p-4)
- Min-height viewport support
## 📊 Impact & Metrics
### Lines of CSS Removed
- `global.css`: ~50 lines removed
- `reader.css`: ~80 lines removed (progress indicator)
- `app.css`: ~30 lines removed (mobile buttons/backdrop)
- `sidebar.css`: ~30 lines removed (mobile hamburger)
- **Total**: ~190 lines removed
### Key Achievements
1. **Fixed Core Issue**: Reading position tracking now works correctly with document scroll
2. **Tailwind Integration**: Fully functional with preflight enabled
3. **No Breaking Changes**: All existing functionality preserved
4. **Type Safety**: TypeScript checks passing
5. **Lint Clean**: ESLint checks passing
6. **Responsive**: Mobile/tablet/desktop layouts working
## 🔄 Remaining Work (Incremental)
The following migrations are **optional enhancements** that can be done as components are touched:
### High-Value Components
- [ ] **ContentPanel** - Large component, high impact
- Reader header, meta info, loading states
- Mark as read button
- Article/video menus
- [ ] **BookmarkList & BookmarkItem** - Core UI
- Card layouts (compact/cards/large views)
- Bookmark metadata display
- Interactive states
- [ ] **HighlightsPanel** - Feature-rich
- Header with toggles
- Highlight items
- Level-based styling
- [ ] **Settings Components** - Forms & controls
- Color pickers
- Font selectors
- Toggle switches
- Sliders
### CSS Files to Prune
- `src/index.css` - Contains many inline bookmark/highlight styles (~3000+ lines)
- `src/styles/components/cards.css` - Bookmark card styles
- `src/styles/components/modals.css` - Modal dialogs
- `src/styles/layout/highlights.css` - Highlight panel layout
## 🎯 Migration Strategy
### For New Components
Use Tailwind utilities from the start. Reference:
```tsx
// Good: Tailwind utilities
<div className="flex items-center gap-2 p-4 bg-gray-800 rounded-lg">
// Avoid: New CSS classes
<div className="custom-component">
```
### For Existing Components
Migrate incrementally when touching files:
1. Replace layout utilities (flex, grid, spacing, sizing)
2. Replace color/background utilities
3. Replace typography utilities
4. Replace responsive variants
5. Remove old CSS rules
6. Keep file under 210 lines
### CSS Variable Usage
Dynamic values should still use CSS variables or inline styles:
```tsx
// User-settable colors
style={{ backgroundColor: settings.highlightColorMine }}
// Or reference CSS variable
className="bg-[var(--highlight-color-mine)]"
```
## 📝 Technical Notes
### Z-Index Layering
- Mobile sidepanes: `z-[1001]`
- Mobile backdrop: `z-[999]`
- Progress indicator: `z-[1102]`
- Mobile buttons: `z-[900]`
- Relay status: `z-[999]`
- Modals: `z-[10000]`
### Responsive Breakpoints
- Mobile: `< 768px`
- Tablet: `768px - 1024px`
- Desktop: `> 1024px`
Use Tailwind: `md:` (768px), `lg:` (1024px)
### Safe Area Insets
Mobile notch support:
```tsx
style={{
top: 'calc(1rem + env(safe-area-inset-top))',
left: 'calc(1rem + env(safe-area-inset-left))'
}}
```
### Custom Animations
Add to `tailwind.config.js`:
```js
keyframes: {
shimmer: {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(100%)' },
},
}
```
## ✅ Success Criteria Met
- [x] Tailwind CSS fully integrated and functional
- [x] Document scrolling working correctly
- [x] Reading position tracking accurate
- [x] Progress indicator always visible
- [x] No TypeScript errors
- [x] No linting errors
- [x] Mobile responsiveness maintained
- [x] Theme colors (user settings) working
- [x] All existing features functional
## 🚀 Next Steps
1. **Ship It**: Current state is production-ready
2. **Incremental Migration**: Convert components as you touch them
3. **Monitor**: Watch for any CSS conflicts
4. **Cleanup**: Eventually remove unused CSS files
5. **Document**: Update component docs with Tailwind patterns
---
**Status**: ✅ **CORE MIGRATION COMPLETE**
**Date**: 2025-01-14
**Commits**: 8 conventional commits
**Lines Removed**: ~190 lines of CSS
**Breaking Changes**: None

View File

@@ -147,7 +147,7 @@ function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
const baseUrl = 'https://read.withboris.com'
const articleUrl = `${baseUrl}/a/${naddr}`
const title = meta?.title || 'Boris Nostr Bookmarks'
const title = meta?.title || 'Boris Read, Highlight, Explore'
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
const author = meta?.author || 'Boris'

View File

@@ -94,7 +94,7 @@ async function pickCaptions(videoID: string, preferredLangs: string[], manualFir
return null
}
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string; thumbnail_url?: string }> {
const vimeoUrl = `https://vimeo.com/${videoId}`
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
@@ -107,7 +107,8 @@ async function getVimeoMetadata(videoId: string): Promise<{ title: string; descr
return {
title: data.title || '',
description: data.description || ''
description: data.description || '',
thumbnail_url: data.thumbnail_url || ''
}
}
@@ -147,9 +148,28 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
try {
if (videoInfo.source === 'youtube') {
// YouTube handling
// Note: getVideoDetails doesn't exist in the library, so we use a simplified approach
const title = ''
const description = ''
// Fetch basic metadata from YouTube page
let title = ''
let description = ''
try {
const response = await fetch(`https://www.youtube.com/watch?v=${videoInfo.id}`)
if (response.ok) {
const html = await response.text()
// Extract title from HTML
const titleMatch = html.match(/<title>([^<]+)<\/title>/)
if (titleMatch) {
title = titleMatch[1].replace(' - YouTube', '').trim()
}
// Extract description from meta tag
const descMatch = html.match(/<meta name="description" content="([^"]+)"/)
if (descMatch) {
description = descMatch[1].trim()
}
}
} catch (error) {
console.warn('Failed to fetch YouTube metadata:', error)
}
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
@@ -178,11 +198,12 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
return ok(res, response)
} else if (videoInfo.source === 'vimeo') {
// Vimeo handling
const { title, description } = await getVimeoMetadata(videoInfo.id)
const { title, description, thumbnail_url } = await getVimeoMetadata(videoInfo.id)
const response = {
title,
description,
thumbnail_url,
captions: [], // Vimeo doesn't provide captions through oEmbed API
transcript: '', // No transcript available
lang: 'en', // Default language

View File

@@ -63,10 +63,28 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
}
try {
// Since getVideoDetails doesn't exist, we'll use a simple approach
// In a real implementation, you might want to use YouTube's API or other methods
const title = '' // Will be populated from captions or other sources
const description = ''
// Fetch basic metadata from YouTube page
let title = ''
let description = ''
try {
const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`)
if (response.ok) {
const html = await response.text()
// Extract title from HTML
const titleMatch = html.match(/<title>([^<]+)<\/title>/)
if (titleMatch) {
title = titleMatch[1].replace(' - YouTube', '').trim()
}
// Extract description from meta tag
const descMatch = html.match(/<meta name="description" content="([^"]+)"/)
if (descMatch) {
description = descMatch[1].trim()
}
}
} catch (error) {
console.warn('Failed to fetch YouTube metadata:', error)
}
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))

View File

@@ -9,14 +9,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f172a" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Boris - Nostr Bookmarks</title>
<title>Boris - Read, Highlight, Explore</title>
<meta name="description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<link rel="canonical" href="https://read.withboris.com/" />
<!-- Open Graph / Social Media -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://read.withboris.com/" />
<meta property="og:title" content="Boris - Nostr Bookmarks" />
<meta property="og:title" content="Boris - Read, Highlight, Explore" />
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta property="og:image" content="https://read.withboris.com/boris-social-1200.png" />
<meta property="og:site_name" content="Boris" />
@@ -24,7 +24,7 @@
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://read.withboris.com/" />
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
<meta name="twitter:title" content="Boris - Read, Highlight, Explore" />
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.10.21",
"version": "0.10.23",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",

View File

@@ -1,5 +1,5 @@
{
"name": "Boris - Nostr Bookmarks",
"name": "Boris - Read, Highlight, Explore",
"short_name": "Boris",
"description": "Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.",
"start_url": "/",

View File

@@ -105,7 +105,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
return undefined
}
}
// For web bookmarks and other types, try to use URL if available
// For web bookmarks (kind:39701), URL is in the 'd' tag
if (bookmark.kind === 39701) {
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
// Ensure URL has protocol
const url = dTag.startsWith('http') ? dTag : `https://${dTag}`
return readingProgressMap.get(url)
}
}
// For other bookmark types, try to extract URL from content
const urls = extractUrlsFromContent(bookmark.content)
if (urls.length > 0) {
return readingProgressMap.get(urls[0])

View File

@@ -226,9 +226,15 @@ const Bookmarks: React.FC<BookmarksProps> = ({
settings
})
// Determine which loader should be active based on route
// Only one loader should run at a time to prevent state conflicts
const shouldLoadArticle = !!naddr && !externalUrl && !eventId
const shouldLoadExternal = !!externalUrl && !naddr && !eventId
const shouldLoadEvent = !!eventId && !naddr && !externalUrl
// Load nostr-native article if naddr is in URL
useArticleLoader({
naddr,
naddr: shouldLoadArticle ? naddr : undefined,
relayPool,
eventStore,
setSelectedUrl,
@@ -245,7 +251,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
// Load external URL if /r/* route is used
useExternalUrlLoader({
url: externalUrl,
url: shouldLoadExternal ? externalUrl : undefined,
relayPool,
eventStore,
setSelectedUrl,
@@ -260,7 +266,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
// Load event if /e/:eventId route is used
useEventLoader({
eventId,
eventId: shouldLoadEvent ? eventId : undefined,
relayPool,
eventStore,
setSelectedUrl,

View File

@@ -1,5 +1,4 @@
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
import ReactPlayer from 'react-player'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
@@ -34,9 +33,7 @@ import { unarchiveEvent, unarchiveWebsite } from '../services/unarchiveService'
import { archiveController } from '../services/archiveController'
import AuthorCard from './AuthorCard'
import { faBooks } from '../icons/customIcons'
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
import { classifyUrl, shouldTrackReadingProgress } from '../utils/helpers'
import { buildNativeVideoUrl } from '../utils/videoHelpers'
import { shouldTrackReadingProgress } from '../utils/helpers'
import { useReadingPosition } from '../hooks/useReadingPosition'
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
import { EventFactory } from 'applesauce-factory'
@@ -111,15 +108,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
const [showArticleMenu, setShowArticleMenu] = useState(false)
const [showVideoMenu, setShowVideoMenu] = useState(false)
const [showExternalMenu, setShowExternalMenu] = useState(false)
const [articleMenuOpenUpward, setArticleMenuOpenUpward] = useState(false)
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
const [externalMenuOpenUpward, setExternalMenuOpenUpward] = useState(false)
const articleMenuRef = useRef<HTMLDivElement>(null)
const videoMenuRef = useRef<HTMLDivElement>(null)
const externalMenuRef = useRef<HTMLDivElement>(null)
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
const { finalHtml, relevantHighlights } = useHighlightedContent({
@@ -343,21 +336,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (articleMenuRef.current && !articleMenuRef.current.contains(target)) {
setShowArticleMenu(false)
}
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
setShowVideoMenu(false)
}
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
setShowExternalMenu(false)
}
}
if (showArticleMenu || showVideoMenu || showExternalMenu) {
if (showArticleMenu || showExternalMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [showArticleMenu, showVideoMenu, showExternalMenu])
}, [showArticleMenu, showExternalMenu])
// Check available space and position menu upward if needed
useEffect(() => {
@@ -380,13 +370,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (showArticleMenu) {
checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward)
}
if (showVideoMenu) {
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
}
if (showExternalMenu) {
checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward)
}
}, [showArticleMenu, showVideoMenu, showExternalMenu])
}, [showArticleMenu, showExternalMenu])
const readingStats = useMemo(() => {
const content = markdown || html || ''
@@ -418,34 +405,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
// Track external video duration (in seconds) for display in header
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
// Load YouTube metadata/captions when applicable
useEffect(() => {
(async () => {
try {
if (!selectedUrl) return setYtMeta(null)
const id = extractYouTubeId(selectedUrl)
if (!id) return setYtMeta(null)
const locale = navigator?.language?.split('-')[0] || 'en'
const data = await getYouTubeMeta(id, locale)
if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript })
} catch {
setYtMeta(null)
}
})()
}, [selectedUrl])
const formatDuration = (totalSeconds: number): string => {
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = Math.floor(totalSeconds % 60)
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
const ss = String(seconds).padStart(2, '0')
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
}
// Get article links for menu
@@ -483,7 +444,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
setShowArticleMenu(!showArticleMenu)
}
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
const handleOpenPortal = () => {
if (articleLinks) {
@@ -571,46 +531,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
setShowArticleMenu(false)
}
// Video actions
const handleOpenVideoExternal = () => {
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
setShowVideoMenu(false)
}
const handleOpenVideoNative = () => {
if (!selectedUrl) return
const native = buildNativeVideoUrl(selectedUrl)
if (native) {
window.location.href = native
} else {
window.location.href = selectedUrl
}
setShowVideoMenu(false)
}
const handleCopyVideoUrl = async () => {
try {
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
} catch (e) {
console.warn('Clipboard copy failed', e)
} finally {
setShowVideoMenu(false)
}
}
const handleShareVideoUrl = async () => {
try {
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Video', url: selectedUrl })
} else if (selectedUrl) {
await navigator.clipboard.writeText(selectedUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowVideoMenu(false)
}
}
// External article actions
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
@@ -808,13 +728,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
)
}
if (loading) {
return (
<div className="reader" aria-busy="true">
<ContentSkeleton />
</div>
)
}
const highlightRgb = hexToRgb(highlightColor)
@@ -854,11 +767,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
)}
<ReaderHeader
title={ytMeta?.title || title}
title={title}
image={image}
summary={summary}
published={published}
readingTimeText={isExternalVideo ? (videoDurationSec !== null ? formatDuration(videoDurationSec) : null) : (readingStats ? readingStats.text : null)}
readingTimeText={readingStats ? readingStats.text : null}
hasHighlights={hasHighlights}
highlightCount={relevantHighlights.length}
settings={settings}
@@ -871,86 +784,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<TTSControls text={articleText} defaultLang={navigator?.language} settings={settings} />
</div>
)}
{isExternalVideo ? (
<>
<div className="reader-video">
<ReactPlayer
url={selectedUrl as string}
controls
width="100%"
height="auto"
style={{
width: '100%',
height: 'auto',
aspectRatio: '16/9'
}}
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
/>
</div>
{ytMeta?.description && (
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
{ytMeta.description}
</div>
)}
{ytMeta?.transcript && (
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
<h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3>
<div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}>
{ytMeta.transcript}
</div>
</div>
)}
<div className="article-menu-container">
<div className="article-menu-wrapper" ref={videoMenuRef}>
<button
className="article-menu-btn"
onClick={toggleVideoMenu}
title="More options"
>
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showVideoMenu && (
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Link</span>
</button>
<button className="article-menu-item" onClick={handleOpenVideoNative}>
<FontAwesomeIcon icon={faMobileAlt} />
<span>Open in Native App</span>
</button>
<button className="article-menu-item" onClick={handleCopyVideoUrl}>
<FontAwesomeIcon icon={faCopy} />
<span>Copy URL</span>
</button>
<button className="article-menu-item" onClick={handleShareVideoUrl}>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
</div>
)}
</div>
</div>
{activeAccount && (
<div className="mark-as-read-container">
<button
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
onClick={handleMarkAsRead}
disabled={isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
>
<FontAwesomeIcon
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
spin={isCheckingReadStatus}
/>
<span>
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Watched' : 'Mark as Watched'}
</span>
</button>
</div>
)}
</>
{loading || !markdown && !html ? (
<div className="reader" aria-busy="true">
<ContentSkeleton />
</div>
) : markdown || html ? (
<>
{markdown ? (
@@ -959,16 +796,14 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
key={`content:${contentKey}`}
ref={contentRef}
html={finalHtml}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
className="reader-markdown"
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
) : (
<div className="reader-markdown">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
</div>
<ContentSkeleton />
</div>
)
) : (
@@ -976,7 +811,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
key={`content:${contentKey}`}
ref={contentRef}
html={finalHtml || html || ''}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
className="reader-html"
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
@@ -984,7 +819,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
)}
{/* Article menu for external URLs */}
{!isNostrArticle && !isExternalVideo && selectedUrl && (
{!isNostrArticle && selectedUrl && (
<div className="article-menu-container">
<div className="article-menu-wrapper" ref={externalMenuRef}>
<button
@@ -1135,11 +970,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</div>
)}
</>
) : (
<div className="reader empty">
<p>No readable content found for this URL.</p>
</div>
)}
) : null}
</div>
</>
)

View File

@@ -82,11 +82,28 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
// Visibility filters (defaults from settings or nostrverse when logged out)
const [visibility, setVisibility] = useState<HighlightVisibility>({
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
friends: settings?.defaultExploreScopeFriends ?? true,
mine: settings?.defaultExploreScopeMine ?? false
// Visibility filters - load from localStorage first, fallback to settings
const [visibility, setVisibility] = useState<HighlightVisibility>(() => {
// Try to load from localStorage first
try {
const saved = localStorage.getItem('exploreScopeVisibility')
if (saved) {
const parsed = JSON.parse(saved)
// Validate that at least one scope is enabled
if (parsed.nostrverse || parsed.friends || parsed.mine) {
return parsed
}
}
} catch (err) {
console.warn('Failed to load explore scope from localStorage:', err)
}
// Fallback to settings or defaults
return {
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
friends: settings?.defaultExploreScopeFriends ?? true,
mine: settings?.defaultExploreScopeMine ?? false
}
})
// Ensure at least one scope remains active
@@ -96,6 +113,12 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
if (!next.nostrverse && !next.friends && !next.mine) {
return prev // ignore toggle that would disable all scopes
}
// Persist to localStorage
try {
localStorage.setItem('exploreScopeVisibility', JSON.stringify(next))
} catch (err) {
console.warn('Failed to save explore scope to localStorage:', err)
}
return next
})
}, [])
@@ -224,18 +247,44 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
// Update visibility when settings/login state changes
useEffect(() => {
// Check if user has a saved preference
const hasSavedPreference = (() => {
try {
return localStorage.getItem('exploreScopeVisibility') !== null
} catch {
return false
}
})()
// Only reset to defaults if no saved preference exists
if (hasSavedPreference) {
return
}
if (!activeAccount) {
// When logged out, show nostrverse by default
setVisibility(prev => ({ ...prev, nostrverse: true, friends: false, mine: false }))
const defaultVisibility = { nostrverse: true, friends: false, mine: false }
setVisibility(defaultVisibility)
try {
localStorage.setItem('exploreScopeVisibility', JSON.stringify(defaultVisibility))
} catch (err) {
console.warn('Failed to save explore scope to localStorage:', err)
}
setHasLoadedNostrverse(true) // logged out path loads nostrverse immediately
setHasLoadedNostrverseHighlights(true)
} else {
// When logged in, use settings defaults immediately
setVisibility({
const defaultVisibility = {
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
friends: settings?.defaultExploreScopeFriends ?? true,
mine: settings?.defaultExploreScopeMine ?? false
})
}
setVisibility(defaultVisibility)
try {
localStorage.setItem('exploreScopeVisibility', JSON.stringify(defaultVisibility))
} catch (err) {
console.warn('Failed to save explore scope to localStorage:', err)
}
setHasLoadedNostrverse(false)
setHasLoadedNostrverseHighlights(false)
}

View File

@@ -45,7 +45,7 @@ export const HighlightButton = React.forwardRef<HighlightButtonRef, HighlightBut
className="highlight-fab"
style={{
position: 'fixed',
bottom: '32px',
bottom: '80px',
right: '32px',
zIndex: 1000,
width: '56px',

View File

@@ -55,95 +55,116 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
const handleMenuItemClick = (action: () => void) => {
setShowProfileMenu(false)
// Close mobile sidebar when navigating on mobile
if (isMobile) {
onToggleCollapse()
}
action()
}
return (
<>
<div className="sidebar-header-bar">
{activeAccount && (
<div className="profile-menu-wrapper" ref={menuRef}>
<button
className="profile-avatar-button"
title={getUserDisplayName()}
onClick={() => setShowProfileMenu(!showProfileMenu)}
aria-label={`Profile: ${getUserDisplayName()}`}
>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
<div className="sidebar-header-left">
{activeAccount && (
<div className="profile-menu-wrapper" ref={menuRef}>
<button
className="profile-avatar-button"
title={getUserDisplayName()}
onClick={() => setShowProfileMenu(!showProfileMenu)}
aria-label={`Profile: ${getUserDisplayName()}`}
>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
)}
</button>
{showProfileMenu && (
<div className="profile-dropdown-menu">
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/highlights'))}
>
<FontAwesomeIcon icon={faHighlighter} />
<span>My Highlights</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/bookmarks'))}
>
<FontAwesomeIcon icon={faBookmark} />
<span>My Bookmarks</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/reads'))}
>
<FontAwesomeIcon icon={faBooks} />
<span>My Reads</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/links'))}
>
<FontAwesomeIcon icon={faLink} />
<span>My Links</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/writings'))}
>
<FontAwesomeIcon icon={faPenToSquare} />
<span>My Writings</span>
</button>
<div className="profile-menu-separator"></div>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(onLogout)}
>
<FontAwesomeIcon icon={faRightFromBracket} />
<span>Logout</span>
</button>
</div>
)}
</button>
{showProfileMenu && (
<div className="profile-dropdown-menu">
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/highlights'))}
>
<FontAwesomeIcon icon={faHighlighter} />
<span>My Highlights</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/bookmarks'))}
>
<FontAwesomeIcon icon={faBookmark} />
<span>My Bookmarks</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/reads'))}
>
<FontAwesomeIcon icon={faBooks} />
<span>My Reads</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/links'))}
>
<FontAwesomeIcon icon={faLink} />
<span>My Links</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/writings'))}
>
<FontAwesomeIcon icon={faPenToSquare} />
<span>My Writings</span>
</button>
<div className="profile-menu-separator"></div>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(onLogout)}
>
<FontAwesomeIcon icon={faRightFromBracket} />
<span>Logout</span>
</button>
</div>
)}
</div>
)}
<div className="sidebar-header-right">
</div>
)}
<IconButton
icon={faHome}
onClick={() => navigate('/')}
onClick={() => {
if (isMobile) {
onToggleCollapse()
}
navigate('/')
}}
title="Home"
ariaLabel="Home"
variant="ghost"
/>
</div>
<div className="sidebar-header-right">
<IconButton
icon={faGear}
onClick={onOpenSettings}
title="Settings"
ariaLabel="Settings"
icon={faPersonHiking}
onClick={() => {
if (isMobile) {
onToggleCollapse()
}
navigate('/explore')
}}
title="Explore"
ariaLabel="Explore"
variant="ghost"
/>
<IconButton
icon={faPersonHiking}
onClick={() => navigate('/explore')}
title="Explore"
ariaLabel="Explore"
icon={faGear}
onClick={() => {
if (isMobile) {
onToggleCollapse()
}
onOpenSettings()
}}
title="Settings"
ariaLabel="Settings"
variant="ghost"
/>
{!isMobile && (

View File

@@ -5,6 +5,7 @@ import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { BookmarkList } from './BookmarkList'
import ContentPanel from './ContentPanel'
import VideoView from './VideoView'
import { HighlightsPanel } from './HighlightsPanel'
import Settings from './Settings'
import Toast from './Toast'
@@ -19,6 +20,7 @@ import { HighlightVisibility } from './HighlightsPanel'
import { HighlightButtonRef } from './HighlightButton'
import { BookmarkReference } from '../utils/contentLoader'
import { useIsMobile } from '../hooks/useMediaQuery'
import { classifyUrl } from '../utils/helpers'
import { useScrollDirection } from '../hooks/useScrollDirection'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
@@ -321,7 +323,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
<div
ref={sidebarRef}
className={`pane sidebar ${isMobile && props.isSidebarOpen ? 'mobile-open' : ''}`}
aria-hidden={isMobile && !props.isSidebarOpen}
{...(isMobile && !props.isSidebarOpen ? { inert: '' } : {})}
>
<BookmarkList
bookmarks={props.bookmarks}
@@ -373,47 +375,73 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
<>
{props.support}
</>
) : (
<ContentPanel
loading={props.readerLoading}
title={props.readerContent?.title}
html={props.readerContent?.html}
markdown={props.readerContent?.markdown}
image={props.readerContent?.image}
summary={props.readerContent?.summary}
published={props.readerContent?.published}
selectedUrl={props.selectedUrl}
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
? props.highlights // article-specific highlights only
: props.classifiedHighlights}
showHighlights={props.showHighlights}
highlightStyle={props.settings.highlightStyle || 'marker'}
highlightColor={props.settings.highlightColor || '#ffff00'}
onHighlightClick={props.onHighlightClick}
selectedHighlightId={props.selectedHighlightId}
highlightVisibility={props.highlightVisibility}
onTextSelection={props.onTextSelection}
onClearSelection={props.onClearSelection}
currentUserPubkey={props.currentUserPubkey}
followedPubkeys={props.followedPubkeys}
settings={props.settings}
relayPool={props.relayPool}
activeAccount={props.activeAccount}
currentArticle={props.currentArticle}
isSidebarCollapsed={props.isCollapsed}
isHighlightsCollapsed={props.isHighlightsCollapsed}
onOpenHighlights={() => {
if (props.isHighlightsCollapsed) {
props.onToggleHighlightsPanel()
}
}}
/>
)}
) : (() => {
// Determine if this is a video URL
const isNostrArticle = props.selectedUrl && props.selectedUrl.startsWith('nostr:')
const isExternalVideo = !isNostrArticle && !!props.selectedUrl && ['youtube', 'video'].includes(classifyUrl(props.selectedUrl).type)
if (isExternalVideo) {
return (
<VideoView
videoUrl={props.selectedUrl!}
title={props.readerContent?.title}
image={props.readerContent?.image}
summary={props.readerContent?.summary}
published={props.readerContent?.published}
settings={props.settings}
relayPool={props.relayPool}
activeAccount={props.activeAccount}
onOpenHighlights={() => {
if (props.isHighlightsCollapsed) {
props.onToggleHighlightsPanel()
}
}}
/>
)
}
return (
<ContentPanel
loading={props.readerLoading}
title={props.readerContent?.title}
html={props.readerContent?.html}
markdown={props.readerContent?.markdown}
image={props.readerContent?.image}
summary={props.readerContent?.summary}
published={props.readerContent?.published}
selectedUrl={props.selectedUrl}
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
? props.highlights // article-specific highlights only
: props.classifiedHighlights}
showHighlights={props.showHighlights}
highlightStyle={props.settings.highlightStyle || 'marker'}
highlightColor={props.settings.highlightColor || '#ffff00'}
onHighlightClick={props.onHighlightClick}
selectedHighlightId={props.selectedHighlightId}
highlightVisibility={props.highlightVisibility}
onTextSelection={props.onTextSelection}
onClearSelection={props.onClearSelection}
currentUserPubkey={props.currentUserPubkey}
followedPubkeys={props.followedPubkeys}
settings={props.settings}
relayPool={props.relayPool}
activeAccount={props.activeAccount}
currentArticle={props.currentArticle}
isSidebarCollapsed={props.isCollapsed}
isHighlightsCollapsed={props.isHighlightsCollapsed}
onOpenHighlights={() => {
if (props.isHighlightsCollapsed) {
props.onToggleHighlightsPanel()
}
}}
/>
)
})()}
</div>
<div
ref={highlightsRef}
className={`pane highlights ${isMobile && !props.isHighlightsCollapsed ? 'mobile-open' : ''}`}
aria-hidden={isMobile && props.isHighlightsCollapsed}
{...(isMobile && props.isHighlightsCollapsed ? { inert: '' } : {})}
>
<HighlightsPanel
highlights={props.highlights}

View File

@@ -0,0 +1,320 @@
import React, { useState, useEffect, useRef } from 'react'
import ReactPlayer from 'react-player'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faCheckCircle } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { UserSettings } from '../services/settingsService'
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
import { buildNativeVideoUrl } from '../utils/videoHelpers'
import { getYouTubeThumbnail } from '../utils/imagePreview'
// Helper function to get Vimeo thumbnail
const getVimeoThumbnail = (url: string): string | null => {
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/)
if (!vimeoMatch) return null
const videoId = vimeoMatch[1]
return `https://vumbnail.com/${videoId}.jpg`
}
import {
createWebsiteReaction,
hasMarkedWebsiteAsRead
} from '../services/reactionService'
import { unarchiveWebsite } from '../services/unarchiveService'
import ReaderHeader from './ReaderHeader'
interface VideoViewProps {
videoUrl: string
title?: string
image?: string
summary?: string
published?: number
settings?: UserSettings
relayPool?: RelayPool | null
activeAccount?: IAccount | null
onOpenHighlights?: () => void
}
const VideoView: React.FC<VideoViewProps> = ({
videoUrl,
title,
image,
summary,
published,
settings,
relayPool,
activeAccount,
onOpenHighlights
}) => {
const [isMarkedAsWatched, setIsMarkedAsWatched] = useState(false)
const [isCheckingWatchedStatus, setIsCheckingWatchedStatus] = useState(false)
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
const [showVideoMenu, setShowVideoMenu] = useState(false)
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
const videoMenuRef = useRef<HTMLDivElement>(null)
// Load YouTube metadata when applicable
useEffect(() => {
(async () => {
try {
if (!videoUrl) return setYtMeta(null)
const id = extractYouTubeId(videoUrl)
if (!id) return setYtMeta(null)
const locale = navigator?.language?.split('-')[0] || 'en'
const data = await getYouTubeMeta(id, locale)
if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript })
} catch {
setYtMeta(null)
}
})()
}, [videoUrl])
// Check if video is marked as watched
useEffect(() => {
const checkWatchedStatus = async () => {
if (!activeAccount || !videoUrl) return
setIsCheckingWatchedStatus(true)
try {
const isWatched = relayPool ? await hasMarkedWebsiteAsRead(videoUrl, activeAccount.pubkey, relayPool) : false
setIsMarkedAsWatched(isWatched)
} catch (error) {
console.warn('Failed to check watched status:', error)
} finally {
setIsCheckingWatchedStatus(false)
}
}
checkWatchedStatus()
}, [activeAccount, videoUrl, relayPool])
// Handle click outside to close menu
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
setShowVideoMenu(false)
}
}
if (showVideoMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [showVideoMenu])
// Check menu position for upward opening
useEffect(() => {
const checkMenuPosition = (menuRef: React.RefObject<HTMLDivElement>, setOpenUpward: (upward: boolean) => void) => {
if (!menuRef.current) return
const rect = menuRef.current.getBoundingClientRect()
const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - rect.bottom
const spaceAbove = rect.top
// Open upward if there's more space above and less space below
setOpenUpward(spaceAbove > spaceBelow && spaceBelow < 200)
}
if (showVideoMenu) {
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
}
}, [showVideoMenu])
const formatDuration = (totalSeconds: number): string => {
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = Math.floor(totalSeconds % 60)
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
const ss = String(seconds).padStart(2, '0')
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
}
const handleMarkAsWatched = async () => {
if (!activeAccount || !videoUrl || isCheckingWatchedStatus) return
setIsCheckingWatchedStatus(true)
setShowCheckAnimation(true)
try {
if (isMarkedAsWatched) {
// Unmark as watched
if (relayPool) {
await unarchiveWebsite(videoUrl, activeAccount, relayPool)
}
setIsMarkedAsWatched(false)
} else {
// Mark as watched
if (relayPool) {
await createWebsiteReaction(videoUrl, activeAccount, relayPool)
}
setIsMarkedAsWatched(true)
}
} catch (error) {
console.warn('Failed to update watched status:', error)
} finally {
setIsCheckingWatchedStatus(false)
setTimeout(() => setShowCheckAnimation(false), 1000)
}
}
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
const handleOpenVideoExternal = () => {
window.open(videoUrl, '_blank', 'noopener,noreferrer')
setShowVideoMenu(false)
}
const handleOpenVideoNative = () => {
const native = buildNativeVideoUrl(videoUrl)
if (native) {
window.location.href = native
} else {
window.location.href = videoUrl
}
setShowVideoMenu(false)
}
const handleCopyVideoUrl = async () => {
try {
await navigator.clipboard.writeText(videoUrl)
} catch (e) {
console.warn('Clipboard copy failed', e)
} finally {
setShowVideoMenu(false)
}
}
const handleShareVideoUrl = async () => {
try {
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
title: ytMeta?.title || title || 'Video',
url: videoUrl
})
} else {
await navigator.clipboard.writeText(videoUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowVideoMenu(false)
}
}
const displayTitle = ytMeta?.title || title
const displaySummary = ytMeta?.description || summary
const durationText = videoDurationSec !== null ? formatDuration(videoDurationSec) : null
// Get video thumbnail for cover image
const youtubeThumbnail = getYouTubeThumbnail(videoUrl)
const vimeoThumbnail = getVimeoThumbnail(videoUrl)
const videoThumbnail = youtubeThumbnail || vimeoThumbnail
const displayImage = videoThumbnail || image
return (
<>
<ReaderHeader
title={displayTitle}
image={displayImage}
summary={displaySummary}
published={published}
readingTimeText={durationText}
hasHighlights={false}
highlightCount={0}
settings={settings}
highlights={[]}
highlightVisibility={{ nostrverse: true, friends: true, mine: true }}
onHighlightCountClick={onOpenHighlights}
/>
<div className="reader-video">
<ReactPlayer
url={videoUrl}
controls
width="100%"
height="auto"
style={{
width: '100%',
height: 'auto',
aspectRatio: '16/9'
}}
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
/>
</div>
{displaySummary && (
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
{displaySummary}
</div>
)}
{ytMeta?.transcript && (
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
<h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3>
<div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}>
{ytMeta.transcript}
</div>
</div>
)}
<div className="article-menu-container">
<div className="article-menu-wrapper" ref={videoMenuRef}>
<button
className="article-menu-btn"
onClick={toggleVideoMenu}
title="More options"
>
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showVideoMenu && (
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Link</span>
</button>
<button className="article-menu-item" onClick={handleOpenVideoNative}>
<FontAwesomeIcon icon={faMobileAlt} />
<span>Open in Native App</span>
</button>
<button className="article-menu-item" onClick={handleCopyVideoUrl}>
<FontAwesomeIcon icon={faCopy} />
<span>Copy URL</span>
</button>
<button className="article-menu-item" onClick={handleShareVideoUrl}>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
</div>
)}
</div>
</div>
{activeAccount && (
<div className="mark-as-read-container">
<button
className={`mark-as-read-btn ${isMarkedAsWatched ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
onClick={handleMarkAsWatched}
disabled={isCheckingWatchedStatus}
title={isMarkedAsWatched ? 'Already Marked as Watched' : 'Mark as Watched'}
style={isMarkedAsWatched ? { opacity: 0.85 } : undefined}
>
<FontAwesomeIcon
icon={faCheckCircle}
className={isMarkedAsWatched ? 'check-icon' : 'check-icon-empty'}
/>
<span>{isMarkedAsWatched ? 'Watched' : 'Mark as Watched'}</span>
</button>
</div>
)}
</>
)
}
export default VideoView

View File

@@ -76,6 +76,10 @@ export function useArticleLoader({
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
// Don't clear highlights yet - let the smart filtering logic handle it
// when we know the article coordinate
setHighlightsLoading(false) // Don't show loading yet
// If we have preview data from navigation, show it immediately (no skeleton!)
if (previewData) {
setReaderContent({
@@ -237,7 +241,13 @@ export function useArticleLoader({
if (coord && eventId) {
setHighlightsLoading(true)
setHighlights([])
// Clear highlights that don't belong to this article coordinate
setHighlights((prev) => {
return prev.filter(h => {
// Keep highlights that match this article coordinate or event ID
return h.eventReference === coord || h.eventReference === eventId
})
})
await fetchHighlightsForArticle(
relayPool,
coord,
@@ -251,7 +261,9 @@ export function useArticleLoader({
return next.sort((a, b) => b.created_at - a.created_at)
})
},
settingsRef.current
settingsRef.current,
false, // force
eventStore || undefined
)
} else {
// No article event to fetch highlights for - clear and don't show loading

View File

@@ -3,6 +3,7 @@ import { Highlight } from '../types/highlights'
import { HighlightVisibility } from '../components/HighlightsPanel'
import { normalizeUrl } from '../utils/urlHelpers'
import { classifyHighlights } from '../utils/highlightClassification'
import { nip19 } from 'nostr-tools'
interface UseFilteredHighlightsParams {
highlights: Highlight[]
@@ -24,8 +25,29 @@ export const useFilteredHighlights = ({
let urlFiltered = highlights
// For Nostr articles, we already fetched highlights specifically for this article
if (!selectedUrl.startsWith('nostr:')) {
// Filter highlights based on URL type
if (selectedUrl.startsWith('nostr:')) {
// For Nostr articles, extract the article coordinate and filter by eventReference
try {
const decoded = nip19.decode(selectedUrl.replace('nostr:', ''))
if (decoded.type === 'naddr') {
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
const articleCoordinate = `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
urlFiltered = highlights.filter(h => {
// Keep highlights that match this article coordinate
return h.eventReference === articleCoordinate
})
} else {
// Not a valid naddr, clear all highlights
urlFiltered = []
}
} catch {
// Invalid naddr, clear all highlights
urlFiltered = []
}
} else {
// For web URLs, filter by URL matching
const normalizedSelected = normalizeUrl(selectedUrl)
urlFiltered = highlights.filter(h => {

View File

@@ -28,7 +28,7 @@ export const fetchHighlightsFromAuthors = async (
const seenIds = new Set<string>()
const rawEvents = await queryEvents(
relayPool,
{ kinds: [9802], authors: pubkeys, limit: 200 },
{ kinds: [9802], authors: pubkeys, limit: 1000 },
{
onEvent: (event: NostrEvent) => {
if (!seenIds.has(event.id)) {

View File

@@ -81,13 +81,21 @@ class NostrverseHighlightsController {
const currentGeneration = this.generation
this.setLoading(true)
try {
const seenIds = new Set<string>()
const highlightsMap = new Map<string, Highlight>()
try {
const seenIds = new Set<string>()
// Start with existing highlights when doing incremental sync
const highlightsMap = new Map<string, Highlight>(
this.currentHighlights.map(h => [h.id, h])
)
const lastSyncedAt = force ? null : this.getLastSyncedAt()
const filter: { kinds: number[]; since?: number } = { kinds: [KINDS.Highlights] }
if (lastSyncedAt) filter.since = lastSyncedAt
const lastSyncedAt = force ? null : this.getLastSyncedAt()
const filter: { kinds: number[]; since?: number; limit?: number } = { kinds: [KINDS.Highlights] }
if (lastSyncedAt) {
filter.since = lastSyncedAt
} else {
// On initial load, fetch more highlights
filter.limit = 1000
}
const events = await queryEvents(
relayPool,
@@ -111,22 +119,24 @@ class NostrverseHighlightsController {
if (currentGeneration !== this.generation) return
events.forEach(evt => eventStore.add(evt))
events.forEach(evt => eventStore.add(evt))
const highlights = events.map(eventToHighlight)
const unique = Array.from(new Map(highlights.map(h => [h.id, h])).values())
const sorted = sortHighlights(unique)
const highlights = events.map(eventToHighlight)
// Merge new highlights with existing ones
highlights.forEach(h => highlightsMap.set(h.id, h))
const sorted = sortHighlights(Array.from(highlightsMap.values()))
this.currentHighlights = sorted
this.loaded = true
this.emitHighlights(sorted)
this.currentHighlights = sorted
this.loaded = true
this.emitHighlights(sorted)
if (sorted.length > 0) {
const newest = Math.max(...sorted.map(h => h.created_at))
this.setLastSyncedAt(newest)
}
} catch (err) {
this.currentHighlights = []
// On error, keep existing highlights instead of clearing them
console.error('[nostrverse-highlights] Failed to sync:', err)
this.emitHighlights(this.currentHighlights)
} finally {
if (currentGeneration === this.generation) this.setLoading(false)

View File

@@ -19,7 +19,6 @@ export interface ReadingProgressContent {
progress: number // 0-1 scroll progress
ts?: number // Unix timestamp (optional, for display)
loc?: number // Optional: pixel position
ver?: string // Schema version
}
// Helper to extract and parse reading progress from event (kind 39802)
@@ -117,8 +116,7 @@ export async function saveReadingPosition(
const progressContent: ReadingProgressContent = {
progress: position.position,
ts: position.timestamp,
loc: position.scrollTop,
ver: '1'
loc: position.scrollTop
}
const tags = generateProgressTags(articleIdentifier)

View File

@@ -13,7 +13,7 @@ type ProgressMapCallback = (progressMap: Map<string, number>) => void
type LoadingCallback = (loading: boolean) => void
const LAST_SYNCED_KEY = 'reading_progress_last_synced'
const PROGRESS_CACHE_KEY = 'reading_progress_cache_v1'
const PROGRESS_CACHE_KEY = 'reading_progress_cache'
/**
* Shared reading progress controller

View File

@@ -81,7 +81,15 @@ export function applyRelaySetToPool(
for (const url of toRemove) {
const relay = relayPool.relays.get(url)
if (relay) {
relay.close()
try {
// Only close if relay is actually connected or attempting to connect
// This helps avoid WebSocket warnings for connections that never started
relay.close()
} catch (error) {
// Suppress errors when closing relays that haven't fully connected yet
// This can happen when switching relay sets before connections establish
console.debug('[relay-manager] Ignoring error when closing relay:', url, error)
}
relayPool.relays.delete(url)
}
}

View File

@@ -75,6 +75,18 @@
.me-highlights-list { padding-left: 0; padding-right: 0; }
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
/* Hide tab labels on mobile to save space */
@media (max-width: 768px) {
.me-tab .tab-label {
display: none;
}
.me-tab {
padding: 0.75rem;
gap: 0;
}
}
/* Bookmarks list */
.bookmarks-list {
display: flex;

View File

@@ -54,6 +54,13 @@
}
}
.sidebar-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.sidebar-header-right {
display: flex;
align-items: center;

View File

@@ -1,4 +1,5 @@
import { Highlight } from '../types/highlights'
import { nip19 } from 'nostr-tools'
export function normalizeUrl(url: string): string {
try {
@@ -15,10 +16,26 @@ export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: stri
}
// For Nostr articles, we already fetched highlights specifically for this article
// So we don't need to filter them - they're all relevant
// For Nostr articles, filter by article coordinate
if (selectedUrl.startsWith('nostr:')) {
return highlights
try {
const decoded = nip19.decode(selectedUrl.replace('nostr:', ''))
if (decoded.type === 'naddr') {
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
const articleCoordinate = `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
return highlights.filter(h => {
// Keep highlights that match this article coordinate
return h.eventReference === articleCoordinate
})
} else {
// Not a valid naddr, return empty array
return []
}
} catch {
// Invalid naddr, return empty array
return []
}
}
// For web URLs, filter by URL matching

View File

@@ -104,7 +104,7 @@ export default defineConfig({
filename: 'sw.ts',
injectRegister: null,
manifest: {
name: 'Boris - Nostr Bookmarks',
name: 'Boris - Read, Highlight, Explore',
short_name: 'Boris',
description: 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.',
start_url: '/',