Compare commits

...

62 Commits

Author SHA1 Message Date
Gigi
d96ee50f5a chore: bump version to 0.6.9 2025-10-14 16:48:32 +02:00
Gigi
d4a172ba7e docs: update CHANGELOG.md for v0.6.8 2025-10-14 16:40:08 +02:00
Gigi
52ddb8dd7d chore: bump version to 0.6.8 2025-10-14 16:39:08 +02:00
Gigi
8c16614752 chore: update favicon and app icons to purple theme 2025-10-14 16:38:21 +02:00
Gigi
700d7cc5fa chore: update favicon and app icons 2025-10-14 16:34:21 +02:00
Gigi
017703dab2 fix: use consistent yellow color (#fde047) for default highlight settings 2025-10-14 16:31:55 +02:00
Gigi
c59fdb14f1 docs: update CHANGELOG.md for v0.6.7 2025-10-14 15:46:21 +02:00
Gigi
0c104f95d9 chore: bump version to 0.6.7 2025-10-14 15:44:25 +02:00
Gigi
acbefae501 Merge pull request #7 from dergigi/loading-placeholders
Remove loading spinners in favor of skeleton placeholders
2025-10-14 15:43:55 +02:00
Gigi
2ce83ef88a fix: use React.ReactElement instead of JSX.Element type
Change return type from JSX.Element to React.ReactElement to fix ESLint no-undef error
2025-10-14 15:42:54 +02:00
Gigi
dab3412ecd refactor: remove loading spinner from explore page
Remove incremental loading spinner as pull-to-refresh indicator already provides visual feedback for refresh state. Initial loading continues to use skeleton placeholders.
2025-10-14 15:41:34 +02:00
Gigi
988b3164d2 docs: add loading placeholder guideline to fontawesome rule 2025-10-14 15:40:32 +02:00
Gigi
4161053821 fix: Me - handle undefined viewingPubkey in skeleton loading state 2025-10-14 15:37:03 +02:00
Gigi
60054c4865 feat: ContentPanel - replace spinner with skeleton loaders 2025-10-14 15:36:57 +02:00
Gigi
f4e8aa576c feat: HighlightsPanel - replace spinner with skeleton loaders 2025-10-14 15:35:28 +02:00
Gigi
30a495bcd1 feat: Me - replace spinner with skeleton loaders 2025-10-14 15:35:23 +02:00
Gigi
6dde0eb220 feat: Explore - replace spinner with skeleton loaders 2025-10-14 15:35:17 +02:00
Gigi
90d8ef3423 feat: BookmarkList - replace spinner with skeleton loaders 2025-10-14 15:35:10 +02:00
Gigi
f626a8ec9b feat: add skeleton components and theme provider 2025-10-14 15:35:03 +02:00
Gigi
a7c7535236 feat: add react-loading-skeleton package 2025-10-14 14:53:40 +02:00
Gigi
5b0f2821d6 feat: parse and render nostr identifiers in highlight comments
- Detect and decode nostr: URIs (npub, nprofile, naddr, note, nevent) in comments
- Render profiles as clickable links with shortened pubkeys (@abc12345...)
- Render blog posts (kind:30023) as clickable article links
- Shorten other event identifiers to prevent layout breaks
- Add monospace styling for shortened nostr IDs
- Maintains DRY principles by extending existing CommentContent component
2025-10-14 12:58:01 +02:00
Gigi
be045557b8 feat: add nostrverse content and visibility filters to explore page
- Add visibility filter state and UI (mine/friends/nostrverse toggles)
- Create nostrverseService to fetch public content from the entire network
- Fetch both friends content and nostrverse content in parallel
- Apply visibility filters to both highlights and blog posts
- Filter buttons match highlight sidebar styling
- Users can now discover content beyond their friend network
- Maintains performance with sensible limits (50 posts, 100 highlights)
2025-10-14 12:09:12 +02:00
Gigi
a0c92182f9 docs: update CHANGELOG.md for v0.6.6 release 2025-10-14 12:03:53 +02:00
Gigi
f33d33556b chore: bump version to 0.6.6 2025-10-14 12:02:20 +02:00
Gigi
9aff889835 fix: correct profile fetching implementation and dependencies
- Use eventStore.add() directly instead of mapEventsToStore
- Use tap() operator to process and store events as they arrive
- Add eventStore and settings to useEffect dependencies
- Fixes TypeScript and ESLint errors
2025-10-14 12:00:52 +02:00
Gigi
420df1fbdd feat: fetch and cache author profiles in explore page
- Create profileService to fetch and cache kind:0 metadata
- Fetch profiles for all blog post authors on explore page
- Store profiles in event store for immediate access
- Rebroadcast profiles to local/all relays per user settings
- Fixes 'Unknown' author names by ensuring profiles are cached
- Uses mapEventsToStore to automatically populate event store
2025-10-14 11:59:28 +02:00
Gigi
2946ede5ac fix: filter out blog posts with far-future publication dates
- Add filteredBlogPosts useMemo to exclude posts with unreasonable dates
- Allow 1 day into future for clock skew tolerance
- Prevents spam/error posts with dates like '53585 years from now'
- Uses published_at tag or event.created_at as fallback
2025-10-14 11:57:04 +02:00
Gigi
6ec28e6a9d feat: render links and images in highlight comments
- Parse URLs in comment text and render as clickable links
- Detect image URLs and render inline images
- Add CommentContent component for smart URL rendering
- Style links with primary color and underline
- Style images with border and rounded corners
- Images lazy-load and respect max-width
- Links open in new tab with noopener/noreferrer
2025-10-14 11:54:41 +02:00
Gigi
820daa489e feat: hide citation in highlights sidebar for current article
- Add showCitation prop to HighlightItem (defaults to true)
- Set showCitation={false} in HighlightsPanel
- Reduces redundancy since all sidebar highlights are from same article
- Citation still shown in Explore and Me pages where context is needed
2025-10-14 11:52:29 +02:00
Gigi
b162596013 fix: prevent layout breaks from long URLs in highlight comments
- Add word-wrap, overflow-wrap, and word-break to comments
- Set min-width: 0 to allow flex child to shrink
- Prevents horizontal overflow from long URLs or text
- Maintains readable layout with line wrapping
2025-10-14 11:51:16 +02:00
Gigi
e581237e16 docs: update CHANGELOG.md for v0.6.5 release 2025-10-14 11:49:43 +02:00
Gigi
fcc329cc7c chore: bump version to 0.6.5 2025-10-14 11:48:25 +02:00
Gigi
c9544e0fd2 feat: open highlight in native app when clicking timestamp
- Click timestamp to open highlight event in user's native Nostr app
- Reuses existing native link logic (nostr:nevent)
- Simple and DRY implementation
2025-10-14 11:40:30 +02:00
Gigi
d7906cfb95 fix: use article text color for highlight counter
- Change highlight indicator to var(--color-text)
- Matches main article text color for better readability
- More prominent and consistent with content
2025-10-14 11:38:59 +02:00
Gigi
13cd6aeb11 fix: use consistent text color for highlight counter
- Change highlight indicator color to var(--color-text-secondary)
- Matches reading time color for visual consistency
- Better readability in both light and dark modes
2025-10-14 11:38:19 +02:00
Gigi
d4821d18fb fix: improve highlight counter readability in light mode
- Make highlight indicator color theme-aware
- Only force white text color in overlay context (with hero image)
- Let CSS handle text color in regular header for better light mode support
- Fixes hard-to-read white text on light backgrounds
2025-10-14 11:37:13 +02:00
Gigi
b86bf48382 deps: add @fortawesome/free-regular-svg-icons
- Install FontAwesome regular icons package
- Required for faComments icon in HighlightItem component
2025-10-14 11:34:52 +02:00
Gigi
c595f94567 style: switch to regular comments icon
- Use faComments from @fortawesome/free-regular-svg-icons
- Replace solid faComment with regular faComments
- Provides lighter, outlined icon style per FontAwesome regular variant
2025-10-14 11:33:02 +02:00
Gigi
82058c0ef4 style: remove extra indent from highlight comments
- Remove margin-left from comment container
- Icon alone provides sufficient visual indentation
- Cleaner alignment with highlight content
2025-10-14 11:32:16 +02:00
Gigi
a1f3424b38 style: remove background from highlight comments
- Remove background color from comment boxes
- Keep only colored icon for visual distinction
- Cleaner, simpler appearance
2025-10-14 11:31:32 +02:00
Gigi
14ab749ef1 style: color comment icon by highlight level and remove border
- Remove border-left from highlight comments
- Color comment icon based on highlight level (mine/friends/nostrverse)
- Remove opacity from icon for clearer color representation
- Yellow for mine, orange for friends, purple for nostrverse
2025-10-14 11:30:07 +02:00
Gigi
61dd4b2089 style: flip comment icon horizontally
- Add flip='horizontal' prop to comment icon
- Better visual alignment with comment text
2025-10-14 11:29:26 +02:00
Gigi
fb2fe1cc63 feat(highlights): add comment icon to highlight comments
- Import and use faComment icon
- Display comment icon next to comment text
- Style with flexbox layout and slight opacity
- Icon aligns to top with comment text
- Visual indicator that distinguishes comments from highlights
2025-10-14 11:28:51 +02:00
Gigi
720f12ce1c feat(explore): color highlights by author level (mine/friends/nostrverse)
- Import and use classifyHighlights utility
- Track followed pubkeys from contact fetching
- Classify highlights using same logic as highlights sidebar
- Pass classified highlights with level to HighlightItem
- Highlights now show colored borders based on author:
  - Yellow for own highlights (mine)
  - Orange for friends' highlights
  - Purple for nostrverse highlights
- Keep code DRY by reusing existing classification logic
2025-10-14 11:26:19 +02:00
Gigi
423ebb403f fix: add retry mechanism for scroll-to-highlight in article content
- Replace single 100ms delay with retry mechanism
- Try up to 20 times (2 seconds total) to find highlight mark element
- Fixes timing issue when content is still loading from explore page
- Mark elements need time to be rendered after article loads
- Retry every 100ms until element is found or max attempts reached
- Improves reliability of highlight scrolling from external navigation
2025-10-14 11:24:35 +02:00
Gigi
c90fb66bb8 feat(explore): scroll to highlight and open sidebar on click
- Pass highlight ID and openHighlights flag via navigation state
- Add useEffect in Bookmarks to handle navigation state
- Open highlights sidebar when clicking highlight from explore
- Auto-scroll to selected highlight (handled by useHighlightInteractions)
- Clear state after handling to prevent re-triggering
- Enhanced UX for discovering and reading highlighted content
2025-10-14 11:22:34 +02:00
Gigi
188de7ab1d feat(explore): clicking highlight opens source article in reader
- Add handleHighlightClick handler in explore page
- For nostr-native articles: convert eventReference to naddr and navigate to /a/{naddr}
- For web URLs: navigate to /r/{encoded-url}
- Pass onHighlightClick to HighlightItem component
- Users can now click highlights to read the full source content
2025-10-14 11:20:17 +02:00
Gigi
0b1cf267a7 refactor: reorder explore tabs - highlights first, writings second
- Change default tab to highlights on /explore
- Reorder tab buttons in UI (highlights, then writings)
- Update route: /explore shows highlights, /explore/writings shows writings
- Update route detection logic in Bookmarks component
- Highlights are now the primary content on explore page
2025-10-14 11:08:42 +02:00
Gigi
19f68612a5 style: use highlighter icon instead of server icon in highlight items
- Replace faServer with faHighlighter in bottom left indicator
- Update import statement
- Keep plane icon for offline/local-only highlights
- More semantically appropriate icon for highlight items
2025-10-14 11:08:03 +02:00
Gigi
1b1600d6f2 fix: make explore tabs actually span full width
- Set width: 100% and max-width: 100% on tabs
- Add justify-content: flex-start for left alignment
- Tabs now properly extend to match grid width
2025-10-14 11:07:00 +02:00
Gigi
ce67c19ece style: make explore tabs extend to grid width
- Add specific styling for tabs in explore-header
- Tabs now span full width to match the grid below
- Maintain left alignment for consistency with grid layout
2025-10-14 11:04:59 +02:00
Gigi
f754ce3cfe fix: extract author pubkey directly from p tag in highlights
- Pass full highlight object to HighlightCitation component
- Extract author pubkey from p tag as fallback if highlight.author not set
- Add debug logging to track author resolution
- Fix TypeScript type errors with proper guards
- Ensure author profile resolution works correctly
2025-10-14 11:04:24 +02:00
Gigi
19a86525cb debug: add logging to track author pubkey and profile resolution 2025-10-14 11:03:30 +02:00
Gigi
29213ceb1c feat(highlights): add citation attribution to highlight items
- Create HighlightCitation component to show source attribution
- For nostr-native content: display as '— Author, Article Title'
- For web URLs: display hostname as '— domain.com'
- Automatically resolves article titles from event references
- Resolves author names from profile data
- Add styling for citation line below highlight text
- Keep code DRY by reusing existing articleTitleResolver service
2025-10-14 11:01:02 +02:00
Gigi
d25a9b1735 refactor: use existing HighlightItem component for consistency
- Remove custom HighlightCard component
- Use the same HighlightItem component used throughout the app
- Remove custom highlight card styles
- Keep code DRY and UI consistent
2025-10-14 10:57:00 +02:00
Gigi
0f03706166 style: add proper styling for highlight cards in explore grid
- Add gradient header with quote icon for visual distinction
- Style highlight text and comments for card view
- Ensure cards work well in grid layout
- Add mobile responsive styling for highlight cards
2025-10-14 10:56:18 +02:00
Gigi
b1f79e3844 fix: resolve type errors and remove unused code
- Remove unused handleHighlightDelete function
- Fix all TypeScript type errors by using correct Highlight properties
- Use created_at instead of timestamp
- Use content instead of text
- Use urlReference instead of url
- All lint checks and type checks now pass
2025-10-14 10:54:51 +02:00
Gigi
243d9b17ef chore(explore): update subtitle text
- Change subtitle to mention both highlights and blog posts
- Include 'friends and others' to reflect broader content scope
2025-10-14 10:50:51 +02:00
Gigi
50a6cf6499 fix(explore): remove max-width constraint for grid layout
- Remove me-tab-content wrapper that was limiting width to 600px
- Allow explore-grid to use full width for proper multi-column layout
- Blog posts now display in proper grid format
2025-10-14 10:49:36 +02:00
Gigi
8f7991e971 refactor(explore): use grid layout for highlights tab
- Change highlights from list view to grid/card view
- Match the visual style of the writings tab
- Keep tab structure at the top
- Explore page now shows more content at once
2025-10-14 10:46:15 +02:00
Gigi
0aba54bd23 feat(explore): add highlights tab to explore page
- Create fetchHighlightsFromAuthors function for fetching highlights from multiple contacts
- Add tab structure to Explore page (Writings and Highlights tabs)
- Update explore cache to handle both blog posts and highlights
- Add /explore/highlights route
- Keep UI consistent with /me page tab structure
- Implement pull-to-refresh for both tabs
- Add proper caching and streaming for highlights
2025-10-14 10:45:23 +02:00
Gigi
23833b2cff docs: update CHANGELOG.md for v0.6.4 release 2025-10-14 10:40:40 +02:00
41 changed files with 1595 additions and 114 deletions

View File

@@ -5,4 +5,4 @@ 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.
Never write "Loading" - always show a spinner, and just a spinner.
Never write "Loading" - always show a loading placeholder (or a loading spinner, when appropriate).

View File

@@ -7,6 +7,203 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.8] - 2025-10-14
### Changed
- Updated favicon and app icons to purple theme
- Replaced all 8 icon files (apple-touch-icon, favicon variants, and Android Chrome icons)
- New purple color scheme for better brand recognition
## [0.6.7] - 2025-10-14
### Added
- Skeleton loading placeholders using `react-loading-skeleton` package
- Replaced loading spinners with skeleton loaders across all major components
- BookmarkList, Explore, Me, ContentPanel, and HighlightsPanel now use skeleton placeholders
- Theme-aware skeleton animations matching app color scheme
- Nostr identifier parsing and rendering in highlight comments
- Support for `nostr:npub`, `nostr:nprofile`, `nostr:naddr`, `nostr:note`, `nostr:nevent`
- Clickable links to profiles and articles from highlight comments
- Shortened display format for better readability
- Visibility filters for explore page content
- Toggle filters for nostrverse, friends, and own content
- Icon buttons with color coding matching highlight levels
- Filter state persists across tab switches
### Changed
- Loading states now use skeleton placeholders instead of spinners for more polished UX
- Removed incremental loading spinner from explore page refresh
- Pull-to-refresh indicator provides refresh state feedback
### Fixed
- Type error in `HighlightItem.tsx` using `React.ReactElement` instead of `JSX.Element`
- Me page skeleton loading now handles undefined `viewingPubkey` gracefully
### Documentation
- Updated FontAwesome rule to prefer skeleton placeholders over loading text or spinners
## [0.6.6] - 2025-10-14
### Added
- Profile fetching and caching for explore page
- Automatically fetches kind:0 metadata for all blog post authors
- Stores profiles in event store for instant access across app
- Rebroadcasts profiles to local/all relays per user settings
- Fixes "Unknown" author names by proactively caching profiles
- Rich content rendering in highlight comments
- URLs automatically detected and rendered as clickable links
- Image URLs (jpg, png, gif, webp, etc.) render as inline images
- Images lazy-load with responsive sizing and rounded borders
- Links open in new tab with security attributes
### Changed
- Hide citation in highlights sidebar when viewing article
- Citation removed from sidebar since all highlights are from same source
- Citation still shown in Explore and Me pages where context is needed
- Reduces visual clutter and redundant information
### Fixed
- Blog posts with far-future publication dates no longer appear in explore
- Filter excludes posts with dates more than 1 day in future
- Allows 1 day tolerance for clock skew between systems
- Prevents spam posts with unrealistic dates (e.g., "53585 years from now")
- Layout breaks from long URLs in highlight comments
- Added word-wrap, overflow-wrap, and word-break CSS properties
- Set min-width: 0 to allow flex child to shrink properly
- Long URLs now wrap correctly instead of causing horizontal overflow
- Profile fetching implementation
- Use eventStore.add() directly for immediate profile storage
- Use tap() operator to process events as they arrive
- Correct TypeScript types and dependency array
## [0.6.5] - 2025-10-14
### Added
- Highlights tab on `/explore` page
- View highlights from friends and followed users
- Tab structure matching `/me` and profile pages
- Grid layout for highlights with cards
- Highlights shown first, writings second
- Clicking highlight opens source article and scrolls to position
- Opens highlights sidebar automatically when clicking from explore
- Citation attribution on highlight items
- Shows "— Author, Article Title" for Nostr-native content
- Shows "— domain.com" for web URLs
- Resolves author profiles and article titles automatically
- Comment icon (fa-comments) for highlights with comments
- Flipped horizontally for better visual alignment
- Colored based on highlight level (mine/friends/nostrverse)
- No background or extra indent for cleaner look
- Click timestamp to open highlight in native Nostr app
- Uses nostr:nevent links for native app integration
### Changed
- Highlight counter text color now matches article text (var(--color-text))
- Better readability in both light and dark modes
- Only forces white in overlay context (hero images)
- Highlight level colors applied to explore page highlights
- Yellow for own highlights
- Orange for friends' highlights
- Purple for nostrverse highlights
- Explore page tab order: Highlights first, Writings second
- Explore page tabs now extend full width to match content grid
### Fixed
- Highlight counter readability in light mode
- Theme-aware text color instead of hardcoded blue
- Consistent with reading time indicator styling
- Scroll-to-highlight reliability in article view
- Added retry mechanism for asynchronous content loading
- Attempts to find highlight element up to 20 times over 2 seconds
- Author attribution in highlight citations
- Correctly extracts author pubkey from highlight's p tag
- No more "Unknown" author names
- Explore page grid layout
- Removed max-width constraint blocking full-width display
- Tabs and content now properly aligned
### Style
- Replaced server icon with highlighter icon in highlight items
- Switch from solid comment icon to outlined comments icon (fa-regular)
- Removed background from highlight comments for cleaner appearance
- Removed extra left margin from comments (icon provides sufficient indent)
- Comment icon colored by highlight level with no opacity
### Dependencies
- Added @fortawesome/free-regular-svg-icons package for outlined icons
## [0.6.4] - 2025-10-14
### Added
- Color theme variants for light and dark modes
- Sepia, Classic (white/black), Rose, Sky, Mint, and Lavender themes
- Color swatches shown in theme selector instead of text labels
- CSS variable tokens and theme classes for consistent theming
- Playful empty state message for other users' profiles
- Profile links now open within app instead of external portals
### Changed
- Default light theme changed to sepia for better readability
- Theme setting labels renamed from 'Colors' to 'Theme'
- Highlight text now aligns properly with footer icons
- Increased spacing between highlight cards for better visual separation
- Increased bottom padding in highlight cards
- Simplified Me page tab labels for cleaner UI
- Highlight marker style applied to active Highlights tab
- All profile links open internally instead of via external Nostr portals
- Match highlight comment color to highlight level color
### Fixed
- Consistent yellow-300 highlight color across all themes
- Highlight contrast improved in light themes
- Text contrast improved in dark color themes
- Darker background for app body in dark themes
- Reading progress indicator now uses theme colors
- Highlights tab readability improved in light mode with proper background
- Empty state text color changed from red to gray for better aesthetics
- Replaced 'any' types with proper type definitions for better type safety
### Refactored
- Migrated entire codebase to semantic token system
- Pull-to-refresh components updated to use semantic tokens
- Cards, forms, and layout components migrated to semantic tokens
- All remaining components converted to semantic token usage
- Removed localStorage for theme persistence, using only Nostr (NIP-78)
- Theme colors applied to body element for consistent theming
## [0.6.3] - 2025-10-14
### Added
- Ants link to empty writings state for other users
### Changed
- Empty state text color from red to gray
### Fixed
- Match highlight comment color to highlight level color
- Open all profile links within app instead of external portals
- Playful empty state message for other users' profiles
## [0.6.2] - 2025-01-27
### Added
@@ -1096,7 +1293,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.2...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.8...HEAD
[0.6.8]: https://github.com/dergigi/boris/compare/v0.6.7...v0.6.8
[0.6.7]: https://github.com/dergigi/boris/compare/v0.6.6...v0.6.7
[0.6.6]: https://github.com/dergigi/boris/compare/v0.6.5...v0.6.6
[0.6.5]: https://github.com/dergigi/boris/compare/v0.6.4...v0.6.5
[0.6.4]: https://github.com/dergigi/boris/compare/v0.6.3...v0.6.4
[0.6.3]: https://github.com/dergigi/boris/compare/v0.6.2...v0.6.3
[0.6.2]: https://github.com/dergigi/boris/compare/v0.6.1...v0.6.2
[0.6.1]: https://github.com/dergigi/boris/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/dergigi/boris/compare/v0.5.7...v0.6.0

27
package-lock.json generated
View File

@@ -1,14 +1,15 @@
{
"name": "boris",
"version": "0.5.7",
"version": "0.6.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boris",
"version": "0.5.7",
"version": "0.6.6",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.0.2",
"@treeee/youtube-caption-extractor": "^1.5.5",
@@ -25,6 +26,7 @@
"prismjs": "^1.30.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-loading-skeleton": "^3.5.0",
"react-markdown": "^10.1.0",
"react-player": "^2.16.0",
"react-router-dom": "^7.9.3",
@@ -2263,6 +2265,18 @@
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-7.1.0.tgz",
"integrity": "sha512-0e2fdEyB4AR+e6kU4yxwA/MonnYcw/CsMEP9lH82ORFi9svA6/RhDyhxIv5mlJaldmaHLLYVTb+3iEr+PDSZuQ==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.1.0.tgz",
@@ -9807,6 +9821,15 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-loading-skeleton": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/react-loading-skeleton/-/react-loading-skeleton-3.5.0.tgz",
"integrity": "sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.6.4",
"version": "0.6.9",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",
@@ -12,6 +12,7 @@
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.0.2",
"@treeee/youtube-caption-extractor": "^1.5.5",
@@ -28,6 +29,7 @@
"prismjs": "^1.30.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-loading-skeleton": "^3.5.0",
"react-markdown": "^10.1.0",
"react-player": "^2.16.0",
"react-router-dom": "^7.9.3",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 465 B

After

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -13,6 +13,7 @@ import Toast from './components/Toast'
import { useToast } from './hooks/useToast'
import { useOnlineStatus } from './hooks/useOnlineStatus'
import { RELAYS } from './config/relays'
import { SkeletonThemeProvider } from './components/Skeletons'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
@@ -70,6 +71,15 @@ function AppRoutes({
/>
}
/>
<Route
path="/explore/writings"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/me"
element={<Navigate to="/me/highlights" replace />}
@@ -262,22 +272,24 @@ function App() {
}
return (
<EventStoreProvider eventStore={eventStore}>
<AccountsProvider manager={accountManager}>
<BrowserRouter>
<div className="min-h-screen p-0 max-w-none m-0 relative">
<AppRoutes relayPool={relayPool} showToast={showToast} />
</div>
</BrowserRouter>
{toastMessage && (
<Toast
message={toastMessage}
type={toastType}
onClose={clearToast}
/>
)}
</AccountsProvider>
</EventStoreProvider>
<SkeletonThemeProvider>
<EventStoreProvider eventStore={eventStore}>
<AccountsProvider manager={accountManager}>
<BrowserRouter>
<div className="min-h-screen p-0 max-w-none m-0 relative">
<AppRoutes relayPool={relayPool} showToast={showToast} />
</div>
</BrowserRouter>
{toastMessage && (
<Toast
message={toastMessage}
type={toastType}
onClose={clearToast}
/>
)}
</AccountsProvider>
</EventStoreProvider>
</SkeletonThemeProvider>
)
}

View File

@@ -1,6 +1,6 @@
import React, { useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
import { formatDistanceToNow } from 'date-fns'
import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
@@ -12,6 +12,7 @@ import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { UserSettings } from '../services/settingsService'
import { usePullToRefresh } from '../hooks/usePullToRefresh'
import PullToRefreshIndicator from './PullToRefreshIndicator'
import { BookmarkSkeleton } from './Skeletons'
interface BookmarkListProps {
bookmarks: Bookmark[]
@@ -128,8 +129,12 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
{allIndividualBookmarks.length === 0 ? (
loading ? (
<div className="loading">
<FontAwesomeIcon icon={faSpinner} spin />
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{Array.from({ length: viewMode === 'large' ? 4 : viewMode === 'cards' ? 6 : 8 }).map((_, i) => (
<BookmarkSkeleton key={i} viewMode={viewMode} />
))}
</div>
</div>
) : (
<div className="empty-state">

View File

@@ -31,15 +31,21 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const navigate = useNavigate()
const previousLocationRef = useRef<string>()
// Check for highlight navigation state
const navigationState = location.state as { highlightId?: string; openHighlights?: boolean } | null
const externalUrl = location.pathname.startsWith('/r/')
? decodeURIComponent(location.pathname.slice(3))
: undefined
const showSettings = location.pathname === '/settings'
const showExplore = location.pathname === '/explore'
const showExplore = location.pathname.startsWith('/explore')
const showMe = location.pathname.startsWith('/me')
const showProfile = location.pathname.startsWith('/p/')
// Extract tab from explore routes
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
// Extract tab from me routes
const meTab = location.pathname === '/me' ? 'highlights' :
location.pathname === '/me/highlights' ? 'highlights' :
@@ -125,6 +131,19 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname])
// Handle highlight navigation from explore page
useEffect(() => {
if (navigationState?.highlightId && navigationState?.openHighlights) {
// Open the highlights sidebar
setIsHighlightsCollapsed(false)
// Select the highlight (scroll happens automatically in useHighlightInteractions)
setSelectedHighlightId(navigationState.highlightId)
// Clear the state after handling to avoid re-triggering
navigate(location.pathname, { replace: true, state: {} })
}
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
const {
bookmarks,
bookmarksLoading,
@@ -286,7 +305,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
onCreateHighlight={handleCreateHighlight}
hasActiveAccount={!!(activeAccount && relayPool)}
explore={showExplore ? (
relayPool ? <Explore relayPool={relayPool} /> : null
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
) : undefined}
me={showMe ? (
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null

View File

@@ -7,6 +7,7 @@ import rehypePrism from 'rehype-prism-plus'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import 'prismjs/themes/prism-tomorrow.css'
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare } from '@fortawesome/free-solid-svg-icons'
import { ContentSkeleton } from './Skeletons'
import { nip19 } from 'nostr-tools'
import { getNostrUrl } from '../config/nostrGateways'
import { RELAYS } from '../config/relays'
@@ -406,10 +407,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (loading) {
return (
<div className="reader loading">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin />
</div>
<div className="reader" aria-busy="true">
<ContentSkeleton />
</div>
)
}

View File

@@ -1,30 +1,65 @@
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
import { faExclamationCircle, faNewspaper, faPenToSquare, faHighlighter, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
import { fetchContacts } from '../services/contactService'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import { fetchHighlightsFromAuthors } from '../services/highlightService'
import { fetchProfiles } from '../services/profileService'
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
import { Highlight } from '../types/highlights'
import { UserSettings } from '../services/settingsService'
import BlogPostCard from './BlogPostCard'
import { getCachedPosts, upsertCachedPost, setCachedPosts } from '../services/exploreCache'
import { HighlightItem } from './HighlightItem'
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
import { usePullToRefresh } from '../hooks/usePullToRefresh'
import PullToRefreshIndicator from './PullToRefreshIndicator'
import { classifyHighlights } from '../utils/highlightClassification'
import { HighlightVisibility } from './HighlightsPanel'
interface ExploreProps {
relayPool: RelayPool
eventStore: IEventStore
settings?: UserSettings
activeTab?: TabType
}
const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
type TabType = 'writings' | 'highlights'
const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, activeTab: propActiveTab }) => {
const activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
const [highlights, setHighlights] = useState<Highlight[]>([])
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const exploreContainerRef = useRef<HTMLDivElement>(null)
const [refreshTrigger, setRefreshTrigger] = useState(0)
// Visibility filters (defaults from settings)
const [visibility, setVisibility] = useState<HighlightVisibility>({
nostrverse: settings?.defaultHighlightVisibilityNostrverse !== false,
friends: settings?.defaultHighlightVisibilityFriends !== false,
mine: settings?.defaultHighlightVisibilityMine !== false
})
// Update local state when prop changes
useEffect(() => {
if (propActiveTab) {
setActiveTab(propActiveTab)
}
}, [propActiveTab])
useEffect(() => {
const loadBlogPosts = async () => {
const loadData = async () => {
if (!activeAccount) {
setError('Please log in to explore content from your friends')
setLoading(false)
@@ -32,14 +67,18 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
}
try {
// show spinner but keep existing posts
// show spinner but keep existing data
setLoading(true)
setError(null)
// Seed from in-memory cache if available to avoid empty flash
const cached = getCachedPosts(activeAccount.pubkey)
if (cached && cached.length > 0 && blogPosts.length === 0) {
setBlogPosts(cached)
const cachedPosts = getCachedPosts(activeAccount.pubkey)
if (cachedPosts && cachedPosts.length > 0 && blogPosts.length === 0) {
setBlogPosts(cachedPosts)
}
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
if (cachedHighlights && cachedHighlights.length > 0 && highlights.length === 0) {
setHighlights(cachedHighlights)
}
// Fetch the user's contacts (friends)
@@ -47,15 +86,19 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
relayPool,
activeAccount.pubkey,
(partial) => {
// When local contacts are available, kick off early posts fetch
// Store followed pubkeys for highlight classification
setFollowedPubkeys(partial)
// When local contacts are available, kick off early fetch
if (partial.size > 0) {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const partialArray = Array.from(partial)
// Fetch blog posts
fetchBlogPostsFromAuthors(
relayPool,
Array.from(partial),
partialArray,
relayUrls,
(post) => {
// merge into UI and cache as we stream
setBlogPosts((prev) => {
const exists = prev.some(p => p.event.id === post.event.id)
if (exists) return prev
@@ -69,7 +112,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
}
).then((all) => {
// Ensure union of streamed + final is displayed
setBlogPosts((prev) => {
const byId = new Map(prev.map(p => [p.event.id, p]))
for (const post of all) byId.set(post.event.id, post)
@@ -82,45 +124,103 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
return merged
})
})
// Fetch highlights
fetchHighlightsFromAuthors(
relayPool,
partialArray,
(highlight) => {
setHighlights((prev) => {
const exists = prev.some(h => h.id === highlight.id)
if (exists) return prev
const next = [...prev, highlight]
return next.sort((a, b) => b.created_at - a.created_at)
})
setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
}
).then((all) => {
setHighlights((prev) => {
const byId = new Map(prev.map(h => [h.id, h]))
for (const highlight of all) byId.set(highlight.id, highlight)
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
setCachedHighlights(activeAccount.pubkey, merged)
return merged
})
})
}
}
)
if (contacts.size === 0) {
setError('You are not following anyone yet. Follow some people to see their blog posts!')
setError('You are not following anyone yet. Follow some people to see their content!')
setLoading(false)
return
}
// After full contacts, do a final pass for completeness
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const posts = await fetchBlogPostsFromAuthors(relayPool, Array.from(contacts), relayUrls)
// Store final followed pubkeys
setFollowedPubkeys(contacts)
if (posts.length === 0) {
setError('No blog posts found from your friends yet')
// Fetch both friends content and nostrverse content in parallel
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const contactsArray = Array.from(contacts)
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
fetchHighlightsFromAuthors(relayPool, contactsArray),
fetchNostrverseBlogPosts(relayPool, relayUrls, 50),
fetchNostrverseHighlights(relayPool, 100)
])
// Merge and deduplicate all posts
const allPosts = [...friendsPosts, ...nostrversePosts]
const postsByKey = new Map<string, BlogPostPreview>()
for (const post of allPosts) {
const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}`
const existing = postsByKey.get(key)
if (!existing || post.event.created_at > existing.event.created_at) {
postsByKey.set(key, post)
}
}
const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
// Merge and deduplicate all highlights
const allHighlights = [...friendsHighlights, ...nostriverseHighlights]
const highlightsByKey = new Map<string, Highlight>()
for (const highlight of allHighlights) {
highlightsByKey.set(highlight.id, highlight)
}
const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at)
// Fetch profiles for all blog post authors to cache them
if (uniquePosts.length > 0) {
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author)))
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
console.error('Failed to fetch author profiles:', err)
})
}
setBlogPosts((prev) => {
const byId = new Map(prev.map(p => [p.event.id, p]))
for (const post of posts) byId.set(post.event.id, post)
const merged = Array.from(byId.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setCachedPosts(activeAccount.pubkey, merged)
return merged
})
if (uniquePosts.length === 0 && uniqueHighlights.length === 0) {
setError('No content found yet')
}
setBlogPosts(uniquePosts)
setCachedPosts(activeAccount.pubkey, uniquePosts)
setHighlights(uniqueHighlights)
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
} catch (err) {
console.error('Failed to load blog posts:', err)
setError('Failed to load blog posts. Please try again.')
console.error('Failed to load data:', err)
setError('Failed to load content. Please try again.')
} finally {
setLoading(false)
}
}
loadBlogPosts()
}, [relayPool, activeAccount, blogPosts.length, refreshTrigger])
loadData()
}, [relayPool, activeAccount, blogPosts.length, highlights.length, refreshTrigger, eventStore, settings])
// Pull-to-refresh
const pullToRefreshState = usePullToRefresh(exploreContainerRef, {
@@ -144,6 +244,137 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
return `/a/${naddr}`
}
const handleHighlightClick = (highlightId: string) => {
const highlight = highlights.find(h => h.id === highlightId)
if (!highlight) return
// For nostr-native articles
if (highlight.eventReference) {
// Convert eventReference to naddr
if (highlight.eventReference.includes(':')) {
const parts = highlight.eventReference.split(':')
const kind = parseInt(parts[0])
const pubkey = parts[1]
const identifier = parts[2] || ''
const naddr = nip19.naddrEncode({
kind,
pubkey,
identifier
})
navigate(`/a/${naddr}`, { state: { highlightId, openHighlights: true } })
} else {
// Already an naddr
navigate(`/a/${highlight.eventReference}`, { state: { highlightId, openHighlights: true } })
}
}
// For web URLs
else if (highlight.urlReference) {
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, { state: { highlightId, openHighlights: true } })
}
}
// Classify highlights with levels based on user context and apply visibility filters
const classifiedHighlights = useMemo(() => {
const classified = classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
return classified.filter(h => {
if (h.level === 'mine' && !visibility.mine) return false
if (h.level === 'friends' && !visibility.friends) return false
if (h.level === 'nostrverse' && !visibility.nostrverse) return false
return true
})
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
// Filter blog posts by future dates and visibility
const filteredBlogPosts = useMemo(() => {
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
return blogPosts.filter(post => {
// Filter out future dates
const publishedTime = post.published || post.event.created_at
if (publishedTime > maxFutureTime) return false
// Apply visibility filters
const isMine = activeAccount && post.author === activeAccount.pubkey
const isFriend = followedPubkeys.has(post.author)
const isNostrverse = !isMine && !isFriend
if (isMine && !visibility.mine) return false
if (isFriend && !visibility.friends) return false
if (isNostrverse && !visibility.nostrverse) return false
return true
})
}, [blogPosts, activeAccount, followedPubkeys, visibility])
const renderTabContent = () => {
switch (activeTab) {
case 'writings':
return filteredBlogPosts.length === 0 ? (
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)' }}>
<p>No blog posts found yet.</p>
</div>
) : (
<div className="explore-grid">
{filteredBlogPosts.map((post) => (
<BlogPostCard
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
post={post}
href={getPostUrl(post)}
/>
))}
</div>
)
case 'highlights':
return classifiedHighlights.length === 0 ? (
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)' }}>
<p>No highlights yet. Your friends should start highlighting content!</p>
</div>
) : (
<div className="explore-grid">
{classifiedHighlights.map((highlight) => (
<HighlightItem
key={highlight.id}
highlight={highlight}
relayPool={relayPool}
onHighlightClick={handleHighlightClick}
/>
))}
</div>
)
default:
return null
}
}
// Only show full loading screen if we don't have any data yet
const hasData = highlights.length > 0 || blogPosts.length > 0
if (loading && !hasData) {
return (
<div className="explore-container" aria-busy="true">
<div className="explore-header">
<h1>
<FontAwesomeIcon icon={faNewspaper} />
Explore
</h1>
</div>
<div className="explore-grid">
{activeTab === 'writings' ? (
Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))
) : (
Array.from({ length: 8 }).map((_, i) => (
<HighlightSkeleton key={i} />
))
)}
</div>
</div>
)
}
if (error) {
return (
<div className="explore-container">
@@ -172,28 +403,69 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
Explore
</h1>
<p className="explore-subtitle">
Discover blog posts from your friends on Nostr
Discover highlights and blog posts from your friends and others
</p>
</div>
{loading && (
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
<FontAwesomeIcon icon={faSpinner} spin />
<div className="me-tabs">
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights"
onClick={() => navigate('/explore')}
>
<FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span>
</button>
<button
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings"
onClick={() => navigate('/explore/writings')}
>
<FontAwesomeIcon icon={faPenToSquare} />
<span className="tab-label">Writings</span>
</button>
</div>
)}
<div className="explore-grid">
{blogPosts.map((post) => (
<BlogPostCard
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
post={post}
href={getPostUrl(post)}
{/* Visibility filters */}
<div className="highlight-level-toggles" style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem' }}>
<IconButton
icon={faNetworkWired}
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
title="Toggle nostrverse content"
ariaLabel="Toggle nostrverse content"
variant="ghost"
style={{
color: visibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
opacity: visibility.nostrverse ? 1 : 0.4
}}
/>
))}
{!loading && blogPosts.length === 0 && (
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)' }}>
<p>No blog posts found yet.</p>
</div>
)}
<IconButton
icon={faUserGroup}
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
ariaLabel="Toggle friends content"
variant="ghost"
disabled={!activeAccount}
style={{
color: visibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: visibility.friends ? 1 : 0.4
}}
/>
<IconButton
icon={faUser}
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
title={activeAccount ? "Toggle my content" : "Login to see your content"}
ariaLabel="Toggle my content"
variant="ghost"
disabled={!activeAccount}
style={{
color: visibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: visibility.mine ? 1 : 0.4
}}
/>
</div>
</div>
{renderTabContent()}
</div>
)
}

View File

@@ -0,0 +1,104 @@
import React, { useState, useEffect, useMemo } from 'react'
import { RelayPool } from 'applesauce-relay'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { fetchArticleTitle } from '../services/articleTitleResolver'
import { Highlight } from '../types/highlights'
interface HighlightCitationProps {
highlight: Highlight
relayPool?: RelayPool | null
}
export const HighlightCitation: React.FC<HighlightCitationProps> = ({
highlight,
relayPool
}) => {
const [articleTitle, setArticleTitle] = useState<string>()
// Extract author pubkey from p tag directly
const authorPubkey = useMemo(() => {
// First try the extracted author from highlight.author
if (highlight.author) {
return highlight.author
}
// Fallback: extract directly from p tag
const pTag = highlight.tags.find(t => t[0] === 'p')
if (pTag && pTag[1]) {
console.log('📝 Found author from p tag:', pTag[1])
return pTag[1]
}
return undefined
}, [highlight.author, highlight.tags])
const authorProfile = useEventModel(Models.ProfileModel, authorPubkey ? [authorPubkey] : null)
useEffect(() => {
if (!highlight.eventReference || !relayPool) {
return
}
const loadTitle = async () => {
try {
if (!highlight.eventReference) return
// Convert eventReference to naddr if needed
let naddr: string
if (highlight.eventReference.includes(':')) {
const parts = highlight.eventReference.split(':')
const kind = parseInt(parts[0])
const pubkey = parts[1]
const identifier = parts[2] || ''
naddr = nip19.naddrEncode({
kind,
pubkey,
identifier
})
} else {
naddr = highlight.eventReference
}
const title = await fetchArticleTitle(relayPool, naddr)
if (title) {
setArticleTitle(title)
}
} catch (error) {
console.error('Failed to load article title:', error)
}
}
loadTitle()
}, [highlight.eventReference, relayPool])
const authorName = authorProfile?.name || authorProfile?.display_name
// For nostr-native content with article reference
if (highlight.eventReference && (authorName || articleTitle)) {
return (
<div className="highlight-citation">
{authorName || 'Unknown'}{articleTitle ? `, ${articleTitle}` : ''}
</div>
)
}
// For web URLs
if (highlight.urlReference) {
try {
const url = new URL(highlight.urlReference)
return (
<div className="highlight-citation">
{url.hostname}
</div>
)
} catch {
return null
}
}
return null
}

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import { faComments } from '@fortawesome/free-regular-svg-icons'
import { Highlight } from '../types/highlights'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core'
@@ -15,6 +16,159 @@ import { createDeletionRequest } from '../services/deletionService'
import ConfirmDialog from './ConfirmDialog'
import { getNostrUrl } from '../config/nostrGateways'
import CompactButton from './CompactButton'
import { HighlightCitation } from './HighlightCitation'
// Helper to detect if a URL is an image
const isImageUrl = (url: string): boolean => {
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname.toLowerCase()
return /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/.test(pathname)
} catch {
return false
}
}
// Helper to render a nostr identifier
const renderNostrId = (nostrUri: string, index: number): React.ReactElement => {
try {
// Remove nostr: prefix
const identifier = nostrUri.replace(/^nostr:/, '')
const decoded = nip19.decode(identifier)
switch (decoded.type) {
case 'npub': {
const pubkey = decoded.data
return (
<a
key={index}
href={`/p/${nip19.npubEncode(pubkey)}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
@{pubkey.slice(0, 8)}...
</a>
)
}
case 'nprofile': {
const { pubkey } = decoded.data
const npub = nip19.npubEncode(pubkey)
return (
<a
key={index}
href={`/p/${npub}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
@{pubkey.slice(0, 8)}...
</a>
)
}
case 'naddr': {
const { kind, pubkey, identifier } = decoded.data
// Check if it's a blog post (kind:30023)
if (kind === 30023) {
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
return (
<a
key={index}
href={`/a/${naddr}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
{identifier || 'Article'}
</a>
)
}
// For other kinds, show shortened identifier
return (
<span key={index} className="highlight-comment-nostr-id">
nostr:{identifier.slice(0, 12)}...
</span>
)
}
case 'note': {
const eventId = decoded.data
return (
<span key={index} className="highlight-comment-nostr-id">
note:{eventId.slice(0, 12)}...
</span>
)
}
case 'nevent': {
const { id } = decoded.data
return (
<span key={index} className="highlight-comment-nostr-id">
event:{id.slice(0, 12)}...
</span>
)
}
default:
// Fallback for unrecognized types
return (
<span key={index} className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
} catch (error) {
// If decoding fails, show shortened identifier
const identifier = nostrUri.replace(/^nostr:/, '')
return (
<span key={index} className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
}
// Component to render comment with links, inline images, and nostr identifiers
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
// Pattern to match both http(s) URLs and nostr: URIs
const urlPattern = /((?:https?:\/\/|nostr:)[^\s]+)/g
const parts = text.split(urlPattern)
return (
<>
{parts.map((part, index) => {
// Handle nostr: URIs
if (part.startsWith('nostr:')) {
return renderNostrId(part, index)
}
// Handle http(s) URLs
if (part.match(/^https?:\/\//)) {
if (isImageUrl(part)) {
return (
<img
key={index}
src={part}
alt="Comment attachment"
className="highlight-comment-image"
loading="lazy"
/>
)
} else {
return (
<a
key={index}
href={part}
target="_blank"
rel="noopener noreferrer"
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
{part}
</a>
)
}
}
return <span key={index}>{part}</span>
})}
</>
)
}
interface HighlightWithLevel extends Highlight {
level?: 'mine' | 'friends' | 'nostrverse'
@@ -29,6 +183,7 @@ interface HighlightItemProps {
eventStore?: IEventStore | null
onHighlightUpdate?: (highlight: Highlight) => void
onHighlightDelete?: (highlightId: string) => void
showCitation?: boolean
}
export const HighlightItem: React.FC<HighlightItemProps> = ({
@@ -39,7 +194,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
relayPool,
eventStore,
onHighlightUpdate,
onHighlightDelete
onHighlightDelete,
showCitation = true
}) => {
const itemRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
@@ -208,13 +364,13 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
// Always show relay list, use plane icon for local-only
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
// Show server icon with relay info if available
// Show highlighter icon with relay info if available
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
const relayNames = highlight.publishedRelays.map(url =>
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: isLocalOrOffline ? faPlane : faServer,
icon: isLocalOrOffline ? faPlane : faHighlighter,
tooltip: relayNames.join('\n'),
spin: false
}
@@ -225,7 +381,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: faServer,
icon: faHighlighter,
tooltip: relayNames.join('\n'),
spin: false
}
@@ -236,7 +392,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: faServer,
icon: faHighlighter,
tooltip: relayNames.join('\n'),
spin: false
}
@@ -318,7 +474,10 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
<CompactButton
className="highlight-timestamp"
title={new Date(highlight.created_at * 1000).toLocaleString()}
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
window.location.href = highlightLinks.native
}}
>
{formatDateCompact(highlight.created_at)}
</CompactButton>
@@ -338,9 +497,19 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
{highlight.content}
</blockquote>
{showCitation && (
<HighlightCitation
highlight={highlight}
relayPool={relayPool}
/>
)}
{highlight.comment && (
<div className="highlight-comment">
{highlight.comment}
<FontAwesomeIcon icon={faComments} flip="horizontal" className="highlight-comment-icon" />
<div className="highlight-comment-text">
<CommentContent text={highlight.comment} />
</div>
</div>
)}

View File

@@ -11,6 +11,7 @@ import PullToRefreshIndicator from './PullToRefreshIndicator'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { UserSettings } from '../services/settingsService'
import { HighlightSkeleton } from './Skeletons'
export interface HighlightVisibility {
nostrverse: boolean
@@ -127,8 +128,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
/>
{loading && filteredHighlights.length === 0 ? (
<div className="highlights-loading">
<FontAwesomeIcon icon={faHighlighter} spin />
<div className="highlights-list" aria-busy="true">
{Array.from({ length: 4 }).map((_, i) => (
<HighlightSkeleton key={i} />
))}
</div>
) : filteredHighlights.length === 0 ? (
<div className="highlights-empty">
@@ -162,6 +165,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
eventStore={eventStore}
onHighlightUpdate={handleHighlightUpdate}
onHighlightDelete={handleHighlightDelete}
showCitation={false}
/>
))}
</div>

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
@@ -198,9 +199,26 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
if (loading && !hasData) {
return (
<div className="explore-container">
<div className="explore-loading">
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<div className="explore-container" aria-busy="true">
{viewingPubkey && (
<div className="explore-header">
<AuthorCard authorPubkey={viewingPubkey} />
</div>
)}
<div className="explore-grid">
{activeTab === 'writings' ? (
Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))
) : activeTab === 'highlights' ? (
Array.from({ length: 8 }).map((_, i) => (
<HighlightSkeleton key={i} />
))
) : (
Array.from({ length: 6 }).map((_, i) => (
<BookmarkSkeleton key={i} viewMode={viewMode} />
))
)}
</div>
</div>
)

View File

@@ -38,7 +38,7 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
const isLongSummary = summary && summary.length > 150
// Determine the dominant highlight color based on visibility and priority
const highlightIndicatorStyles = useMemo(() => {
const getHighlightIndicatorStyles = useMemo(() => (isOverlay: boolean) => {
if (!highlights.length) return undefined
// Count highlights by level that are visible
@@ -65,7 +65,8 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
return {
backgroundColor: `rgba(${rgb}, 0.1)`,
borderColor: `rgba(${rgb}, 0.3)`,
color: '#fff'
// Only force white color in overlay context, otherwise let CSS handle it
...(isOverlay && { color: '#fff' })
}
}, [highlights, highlightVisibility, settings])
@@ -93,7 +94,7 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
{hasHighlights && (
<div
className="highlight-indicator"
style={highlightIndicatorStyles}
style={getHighlightIndicatorStyles(true)}
>
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
@@ -133,7 +134,7 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
{hasHighlights && (
<div
className="highlight-indicator"
style={highlightIndicatorStyles}
style={getHighlightIndicatorStyles(false)}
>
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>

View File

@@ -23,10 +23,10 @@ const DEFAULT_SETTINGS: UserSettings = {
readingFont: 'source-serif-4',
fontSize: 21,
highlightStyle: 'marker',
highlightColor: '#ffff00',
highlightColor: '#fde047',
highlightColorNostrverse: '#9333ea',
highlightColorFriends: '#f97316',
highlightColorMine: '#ffff00',
highlightColorMine: '#fde047',
defaultHighlightVisibilityNostrverse: true,
defaultHighlightVisibilityFriends: true,
defaultHighlightVisibilityMine: true,

View File

@@ -0,0 +1,42 @@
import React from 'react'
import Skeleton from 'react-loading-skeleton'
export const BlogPostSkeleton: React.FC = () => {
return (
<div
className="blog-post-card"
style={{
textDecoration: 'none',
color: 'inherit',
display: 'block'
}}
aria-hidden="true"
>
<div className="blog-post-card-image">
<Skeleton height={200} style={{ display: 'block' }} />
</div>
<div className="blog-post-card-content">
<Skeleton
height={24}
width="85%"
style={{ marginBottom: '0.75rem' }}
className="blog-post-card-title"
/>
<Skeleton
count={2}
style={{ marginBottom: '0.5rem' }}
className="blog-post-card-summary"
/>
<div className="blog-post-card-meta" style={{ display: 'flex', gap: '1rem' }}>
<span className="blog-post-card-author" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Skeleton width={100} height={14} />
</span>
<span className="blog-post-card-date" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Skeleton width={80} height={14} />
</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import React from 'react'
import Skeleton from 'react-loading-skeleton'
import { ViewMode } from '../Bookmarks'
interface BookmarkSkeletonProps {
viewMode: ViewMode
}
export const BookmarkSkeleton: React.FC<BookmarkSkeletonProps> = ({ viewMode }) => {
if (viewMode === 'compact') {
return (
<div
className="bookmark-item-compact"
style={{ padding: '0.75rem', marginBottom: '0.5rem' }}
aria-hidden="true"
>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'flex-start' }}>
<Skeleton width={40} height={40} />
<div style={{ flex: 1, minWidth: 0 }}>
<Skeleton width="80%" height={16} style={{ marginBottom: '0.25rem' }} />
<Skeleton width="60%" height={14} />
</div>
</div>
</div>
)
}
if (viewMode === 'cards') {
return (
<div
className="bookmark-card"
style={{
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'var(--color-bg-elevated)',
marginBottom: '1rem'
}}
aria-hidden="true"
>
<Skeleton height={160} style={{ display: 'block' }} />
<div style={{ padding: '1rem' }}>
<Skeleton height={20} width="90%" style={{ marginBottom: '0.5rem' }} />
<Skeleton count={2} style={{ marginBottom: '0.5rem' }} />
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem' }}>
<Skeleton width={80} height={14} />
<Skeleton width={60} height={14} />
</div>
</div>
</div>
)
}
// large view
return (
<div
className="bookmark-large"
style={{
marginBottom: '1.5rem',
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'var(--color-bg-elevated)'
}}
aria-hidden="true"
>
<Skeleton height={240} style={{ display: 'block' }} />
<div style={{ padding: '1.5rem' }}>
<Skeleton height={24} width="85%" style={{ marginBottom: '0.75rem' }} />
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
<Skeleton circle width={32} height={32} />
<div style={{ flex: 1 }}>
<Skeleton width={120} height={14} style={{ marginBottom: '0.25rem' }} />
<Skeleton width={100} height={12} />
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import React from 'react'
import Skeleton from 'react-loading-skeleton'
export const ContentSkeleton: React.FC = () => {
return (
<div
className="reader-content"
style={{
maxWidth: '900px',
margin: '0 auto',
padding: '2rem 1rem'
}}
aria-hidden="true"
>
{/* Title */}
<Skeleton
height={48}
width="90%"
style={{ marginBottom: '1rem' }}
/>
{/* Byline / Meta */}
<div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem', alignItems: 'center' }}>
<Skeleton circle width={40} height={40} />
<div style={{ flex: 1 }}>
<Skeleton width={150} height={16} style={{ marginBottom: '0.25rem' }} />
<Skeleton width={200} height={14} />
</div>
</div>
{/* Cover image */}
<Skeleton
height={400}
style={{ marginBottom: '2rem', display: 'block', borderRadius: '8px' }}
/>
{/* Paragraphs */}
<div style={{ marginBottom: '1.5rem' }}>
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
<Skeleton width="80%" />
</div>
<div style={{ marginBottom: '1.5rem' }}>
<Skeleton count={4} style={{ marginBottom: '0.5rem' }} />
<Skeleton width="65%" />
</div>
<div style={{ marginBottom: '1.5rem' }}>
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
<Skeleton width="90%" />
</div>
{/* Another image placeholder */}
<Skeleton
height={300}
style={{ marginBottom: '2rem', display: 'block', borderRadius: '8px' }}
/>
<div style={{ marginBottom: '1.5rem' }}>
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
<Skeleton width="75%" />
</div>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import React from 'react'
import Skeleton from 'react-loading-skeleton'
export const HighlightSkeleton: React.FC = () => {
return (
<div
className="highlight-item"
style={{
padding: '1rem',
marginBottom: '0.75rem',
borderRadius: '8px',
backgroundColor: 'var(--color-bg-elevated)'
}}
aria-hidden="true"
>
{/* Author line with avatar */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
<Skeleton circle width={24} height={24} />
<Skeleton width={120} height={14} />
<Skeleton width={60} height={12} style={{ marginLeft: 'auto' }} />
</div>
{/* Highlight content */}
<div style={{ marginBottom: '0.5rem' }}>
<Skeleton count={2} style={{ marginBottom: '0.25rem' }} />
<Skeleton width="70%" />
</div>
{/* Citation/context */}
<div style={{ marginTop: '0.75rem' }}>
<Skeleton width="90%" height={12} />
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import React, { useEffect, useState } from 'react'
import { SkeletonTheme } from 'react-loading-skeleton'
interface SkeletonThemeProviderProps {
children: React.ReactNode
}
export const SkeletonThemeProvider: React.FC<SkeletonThemeProviderProps> = ({ children }) => {
const [colors, setColors] = useState({
baseColor: '#27272a',
highlightColor: '#52525b'
})
useEffect(() => {
const updateColors = () => {
const rootStyles = getComputedStyle(document.documentElement)
const baseColor = rootStyles.getPropertyValue('--color-bg-elevated').trim() || '#27272a'
const highlightColor = rootStyles.getPropertyValue('--color-border-subtle').trim() || '#52525b'
setColors({ baseColor, highlightColor })
}
// Initial update
updateColors()
// Watch for theme changes via MutationObserver
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
updateColors()
}
})
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
})
return () => observer.disconnect()
}, [])
return (
<SkeletonTheme baseColor={colors.baseColor} highlightColor={colors.highlightColor}>
{children}
</SkeletonTheme>
)
}

View File

@@ -0,0 +1,6 @@
export { SkeletonThemeProvider } from './SkeletonThemeProvider'
export { BookmarkSkeleton } from './BookmarkSkeleton'
export { BlogPostSkeleton } from './BlogPostSkeleton'
export { HighlightSkeleton } from './HighlightSkeleton'
export { ContentSkeleton } from './ContentSkeleton'

View File

@@ -58,12 +58,15 @@ export const useHighlightInteractions = ({
}
}, [onHighlightClick, contentVersion])
// Scroll to selected highlight
// Scroll to selected highlight with retry mechanism
useEffect(() => {
if (!selectedHighlightId || !contentRef.current) return
// Use a small delay to ensure DOM is updated
const timeoutId = setTimeout(() => {
let attempts = 0
const maxAttempts = 20 // Try for up to 2 seconds
const retryDelay = 100
const tryScroll = () => {
if (!contentRef.current) return
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
@@ -76,10 +79,16 @@ export const useHighlightInteractions = ({
htmlElement.classList.add('highlight-pulse')
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
}, 500)
} else if (attempts < maxAttempts) {
attempts++
setTimeout(tryScroll, retryDelay)
} else {
console.warn('Could not find mark element for highlight:', selectedHighlightId)
console.warn('Could not find mark element for highlight after', maxAttempts, 'attempts:', selectedHighlightId)
}
}, 100)
}
// Start trying after a small initial delay
const timeoutId = setTimeout(tryScroll, 100)
return () => clearTimeout(timeoutId)
}, [selectedHighlightId, contentVersion])

View File

@@ -13,6 +13,7 @@
@import './styles/components/settings.css';
@import './styles/components/me.css';
@import './styles/components/pull-to-refresh.css';
@import './styles/components/skeletons.css';
@import './styles/utils/animations.css';
@import './styles/utils/utilities.css';
@import './styles/utils/legacy.css';

View File

@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './styles/tailwind.css'
import './index.css'
import 'react-loading-skeleton/dist/skeleton.css'
// Register Service Worker for PWA functionality
if ('serviceWorker' in navigator) {

View File

@@ -1,4 +1,5 @@
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights'
export interface CachedBlogPostPreview {
event: NostrEvent
@@ -11,6 +12,7 @@ export interface CachedBlogPostPreview {
type CacheValue = {
posts: CachedBlogPostPreview[]
highlights: Highlight[]
timestamp: number
}
@@ -22,8 +24,28 @@ export function getCachedPosts(pubkey: string): CachedBlogPostPreview[] | null {
return entry.posts
}
export function getCachedHighlights(pubkey: string): Highlight[] | null {
const entry = exploreCache.get(pubkey)
if (!entry) return null
return entry.highlights
}
export function setCachedPosts(pubkey: string, posts: CachedBlogPostPreview[]): void {
exploreCache.set(pubkey, { posts, timestamp: Date.now() })
const current = exploreCache.get(pubkey)
exploreCache.set(pubkey, {
posts,
highlights: current?.highlights || [],
timestamp: Date.now()
})
}
export function setCachedHighlights(pubkey: string, highlights: Highlight[]): void {
const current = exploreCache.get(pubkey)
exploreCache.set(pubkey, {
posts: current?.posts || [],
highlights,
timestamp: Date.now()
})
}
export function upsertCachedPost(pubkey: string, post: CachedBlogPostPreview): CachedBlogPostPreview[] {
@@ -39,4 +61,13 @@ export function upsertCachedPost(pubkey: string, post: CachedBlogPostPreview): C
return merged
}
export function upsertCachedHighlight(pubkey: string, highlight: Highlight): Highlight[] {
const current = exploreCache.get(pubkey)?.highlights || []
const byId = new Map(current.map(h => [h.id, h]))
byId.set(highlight.id, highlight)
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
setCachedHighlights(pubkey, merged)
return merged
}

View File

@@ -1,5 +1,5 @@
export * from './highlights/fetchForArticle'
export * from './highlights/fetchForUrl'
export * from './highlights/fetchByAuthor'
export * from './highlights/fetchFromAuthors'

View File

@@ -0,0 +1,79 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../../types/highlights'
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
/**
* Fetches highlights (kind:9802) from a list of pubkeys (friends)
* @param relayPool - The relay pool to query
* @param pubkeys - Array of pubkeys to fetch highlights from
* @param onHighlight - Optional callback for streaming highlights as they arrive
* @returns Array of highlights
*/
export const fetchHighlightsFromAuthors = async (
relayPool: RelayPool,
pubkeys: string[],
onHighlight?: (highlight: Highlight) => void
): Promise<Highlight[]> => {
try {
if (pubkeys.length === 0) {
console.log('⚠️ No pubkeys to fetch highlights from')
return []
}
console.log('💡 Fetching highlights (kind 9802) from', pubkeys.length, 'authors')
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
const seenIds = new Set<string>()
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], authors: pubkeys, limit: 200 })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], authors: pubkeys, limit: 200 })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight)
console.log('💡 Processed', highlights.length, 'unique highlights')
return sortHighlights(highlights)
} catch (error) {
console.error('Failed to fetch highlights from authors:', error)
return []
}
}

View File

@@ -0,0 +1,126 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { Helpers } from 'applesauce-core'
import { BlogPostPreview } from './exploreService'
import { Highlight } from '../types/highlights'
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
/**
* Fetches public blog posts (kind:30023) from the nostrverse (not filtered by author)
* @param relayPool - The relay pool to query
* @param relayUrls - Array of relay URLs to query
* @param limit - Maximum number of posts to fetch (default: 50)
* @returns Array of blog post previews
*/
export const fetchNostrverseBlogPosts = async (
relayPool: RelayPool,
relayUrls: string[],
limit = 50
): Promise<BlogPostPreview[]> => {
try {
console.log('📚 Fetching nostrverse blog posts (kind 30023), limit:', limit)
const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
// Deduplicate replaceable events by keeping the most recent version
const uniqueEvents = new Map<string, NostrEvent>()
const processEvents = (incoming: NostrEvent[]) => {
for (const event of incoming) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
if (!existing || event.created_at > existing.created_at) {
uniqueEvents.set(key, event)
}
}
}
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [30023], limit })
.pipe(completeOnEose(), takeUntil(timer(1200)), onlyEvents())
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [30023], limit })
.pipe(completeOnEose(), takeUntil(timer(6000)), onlyEvents())
: new Observable<NostrEvent>((sub) => sub.complete())
const events = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
processEvents(events)
console.log('📊 Nostrverse blog post events fetched (unique):', uniqueEvents.size)
// Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
.map(event => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}))
.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA // Most recent first
})
console.log('📰 Processed', blogPosts.length, 'unique nostrverse blog posts')
return blogPosts
} catch (error) {
console.error('Failed to fetch nostrverse blog posts:', error)
return []
}
}
/**
* Fetches public highlights (kind:9802) from the nostrverse (not filtered by author)
* @param relayPool - The relay pool to query
* @param limit - Maximum number of highlights to fetch (default: 100)
* @returns Array of highlights
*/
export const fetchNostrverseHighlights = async (
relayPool: RelayPool,
limit = 100
): Promise<Highlight[]> => {
try {
console.log('💡 Fetching nostrverse highlights (kind 9802), limit:', limit)
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], limit })
.pipe(completeOnEose(), takeUntil(timer(1200)), onlyEvents())
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], limit })
.pipe(completeOnEose(), takeUntil(timer(6000)), onlyEvents())
: new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight)
console.log('💡 Processed', highlights.length, 'unique nostrverse highlights')
return sortHighlights(highlights)
} catch (error) {
console.error('Failed to fetch nostrverse highlights:', error)
return []
}
}

View File

@@ -0,0 +1,81 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray, tap } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { rebroadcastEvents } from './rebroadcastService'
import { UserSettings } from './settingsService'
/**
* Fetches profile metadata (kind:0) for a list of pubkeys
* Stores profiles in the event store and optionally to local relays
*/
export const fetchProfiles = async (
relayPool: RelayPool,
eventStore: IEventStore,
pubkeys: string[],
settings?: UserSettings
): Promise<NostrEvent[]> => {
try {
if (pubkeys.length === 0) {
return []
}
const uniquePubkeys = Array.from(new Set(pubkeys))
console.log('👤 Fetching profiles (kind:0) for', uniquePubkeys.length, 'authors')
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
// Keep only the most recent profile for each pubkey
const profilesByPubkey = new Map<string, NostrEvent>()
const processEvent = (event: NostrEvent) => {
const existing = profilesByPubkey.get(event.pubkey)
if (!existing || event.created_at > existing.created_at) {
profilesByPubkey.set(event.pubkey, event)
// Store in event store immediately
eventStore.add(event)
}
}
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [0], authors: uniquePubkeys })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => processEvent(event)),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [0], authors: uniquePubkeys })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => processEvent(event)),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
await lastValueFrom(merge(local$, remote$).pipe(toArray()))
const profiles = Array.from(profilesByPubkey.values())
console.log('✅ Fetched', profiles.length, 'unique profiles')
// Rebroadcast profiles to local/all relays based on settings
if (profiles.length > 0) {
await rebroadcastEvents(profiles, relayPool, settings)
}
return profiles
} catch (error) {
console.error('Failed to fetch profiles:', error)
return []
}
}

View File

@@ -73,6 +73,7 @@
.explore-header { text-align: center; margin-bottom: 3rem; }
.explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: var(--color-primary); display: flex; align-items: center; justify-content: center; gap: 1rem; }
.explore-subtitle { font-size: 1.125rem; color: var(--color-text-secondary); margin: 0; }
.explore-header .me-tabs { text-align: left; margin-top: 2rem; width: 100%; max-width: 100%; justify-content: flex-start; }
.explore-loading, .explore-error, .explore-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; color: var(--color-text-secondary); }
.explore-loading { min-height: 0; padding: 0.25rem 0; }
.explore-error { color: rgb(239 68 68); /* red-500 */ }

View File

@@ -33,7 +33,7 @@
.publish-date-topright { position: absolute; top: 1rem; right: 1rem; font-size: 0.813rem; color: var(--color-text); padding: 0.4rem 0.75rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); z-index: 10; }
.reading-time { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border); border-radius: 6px; font-size: 0.875rem; color: var(--color-text-secondary); }
.reading-time svg { font-size: 0.875rem; }
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); border-radius: 6px; font-size: 0.875rem; color: var(--color-primary); }
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); border-radius: 6px; font-size: 0.875rem; color: var(--color-text); }
.highlight-indicator svg { font-size: 0.875rem; }
.reader-html { color: var(--color-text); line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-family: var(--reading-font); font-size: var(--reading-font-size); }
.reader-markdown { color: var(--color-text); line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }

View File

@@ -0,0 +1,35 @@
/* Skeleton loading animations - respects prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
.react-loading-skeleton {
animation: none !important;
}
}
/* Ensure skeletons have proper border radius to match design */
.react-loading-skeleton {
border-radius: 4px;
line-height: 1.2;
}
/* Image skeleton aspect ratio boxes to prevent CLS */
.blog-post-card-image .react-loading-skeleton,
.bookmark-card .react-loading-skeleton:first-child {
aspect-ratio: 16 / 9;
}
/* Skeleton spacing adjustments */
.highlights-list .react-loading-skeleton,
.bookmarks-list .react-loading-skeleton {
margin-bottom: 0.5rem;
}
/* Ensure skeletons inherit theme colors properly */
.react-loading-skeleton::after {
background: linear-gradient(
90deg,
transparent,
var(--color-border-subtle, rgba(255, 255, 255, 0.05)),
transparent
);
}

View File

@@ -128,12 +128,19 @@
.highlight-content { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 2.25rem 0.75rem 2.5rem; }
.highlight-text { margin: 0; padding: 0 0 0 1.25rem; font-style: italic; color: var(--color-text); line-height: 1.6; border-left: none; font-size: 0.95rem; }
.highlight-comment { margin-top: 0.5rem; margin-left: 1.25rem; padding: 0.75rem; border-left: 3px solid; border-radius: 4px; font-size: 0.875rem; color: var(--color-text); line-height: 1.5; }
.highlight-citation { margin-left: 1.25rem; font-size: 0.8rem; color: var(--color-text-secondary); font-style: normal; padding-top: 0.25rem; }
.highlight-comment { margin-top: 0.5rem; padding: 0.75rem; border-radius: 4px; font-size: 0.875rem; color: var(--color-text); line-height: 1.5; display: flex; gap: 0.5rem; align-items: flex-start; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; min-width: 0; }
.highlight-comment-icon { flex-shrink: 0; margin-top: 0.125rem; }
.highlight-comment-text { flex: 1; min-width: 0; }
.highlight-comment-link { color: var(--color-primary); text-decoration: underline; word-wrap: break-word; overflow-wrap: break-word; }
.highlight-comment-link:hover { opacity: 0.8; }
.highlight-comment-nostr-id { font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; font-size: 0.8em; color: var(--color-text-secondary); background: rgba(255, 255, 255, 0.05); padding: 0.125rem 0.375rem; border-radius: 3px; word-wrap: break-word; overflow-wrap: break-word; }
.highlight-comment-image { display: block; max-width: 100%; height: auto; margin-top: 0.5rem; border-radius: 6px; border: 1px solid var(--color-border); }
/* Level-colored comments */
.highlight-item.level-mine .highlight-comment { background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 10%, transparent); border-left-color: var(--highlight-color-mine, #ffff00); }
.highlight-item.level-friends .highlight-comment { background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 10%, transparent); border-left-color: var(--highlight-color-friends, #f97316); }
.highlight-item.level-nostrverse .highlight-comment { background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 10%, transparent); border-left-color: var(--highlight-color-nostrverse, #9333ea); }
/* Level-colored comment icons */
.highlight-item.level-mine .highlight-comment-icon { color: var(--highlight-color-mine, #ffff00); }
.highlight-item.level-friends .highlight-comment-icon { color: var(--highlight-color-friends, #f97316); }
.highlight-item.level-nostrverse .highlight-comment-icon { color: var(--highlight-color-nostrverse, #9333ea); }
.highlight-footer { position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; padding: 0.25rem 0.5rem; font-size: 0.8rem; color: var(--color-text-secondary); border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; transition: border-color 0.2s ease; }
.highlight-footer-left { display: flex; align-items: center; gap: 0.4rem; min-width: 0; }