Compare commits
184 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
384c16e29d | ||
|
|
789982bd76 | ||
|
|
8bccc9de48 | ||
|
|
ec8584b4d2 | ||
|
|
54bd59fa2d | ||
|
|
b19f5f55f7 | ||
|
|
0964f25f97 | ||
|
|
5f3e6335c1 | ||
|
|
f30c894c87 | ||
|
|
bec769ac1b | ||
|
|
cb3748e06f | ||
|
|
d5a24f0a46 | ||
|
|
401a8241bd | ||
|
|
2193a7a863 | ||
|
|
e6bc4d7fda | ||
|
|
aee9f73316 | ||
|
|
aef7b4cea4 | ||
|
|
c9a8a3b91e | ||
|
|
0c7b11bdf8 | ||
|
|
8c151a5855 | ||
|
|
9b54fa9c14 | ||
|
|
99d7705404 | ||
|
|
eaa590b8e2 | ||
|
|
715fd8cf10 | ||
|
|
99a9709605 | ||
|
|
65d330d5ed | ||
|
|
1d1d389a03 | ||
|
|
0392389355 | ||
|
|
cf2a500a07 | ||
|
|
7d3748202e | ||
|
|
d7f90faea9 | ||
|
|
cb0066aac9 | ||
|
|
b48397b7a6 | ||
|
|
82ab8419e3 | ||
|
|
142a2414d3 | ||
|
|
081bd95f60 | ||
|
|
300aed0589 | ||
|
|
b2b23c66cf | ||
|
|
838bb6aa3d | ||
|
|
f14ecc5acb | ||
|
|
d533e23dc0 | ||
|
|
eefcf99364 | ||
|
|
1c0790bfb6 | ||
|
|
29e351ba78 | ||
|
|
7592c5c327 | ||
|
|
f5018204ab | ||
|
|
7ae74268fd | ||
|
|
52e959a7f5 | ||
|
|
4f03a2c276 | ||
|
|
bc4c96ee35 | ||
|
|
a866040fc1 | ||
|
|
c90fad268a | ||
|
|
8ef1f775f9 | ||
|
|
90af87339c | ||
|
|
9007b1ca71 | ||
|
|
0b7e6145de | ||
|
|
bf1b608d96 | ||
|
|
7db0f2a05c | ||
|
|
165b4d4b9f | ||
|
|
a7106138c4 | ||
|
|
a498bfab38 | ||
|
|
3dd2980283 | ||
|
|
2e2a1a2c9d | ||
|
|
b9666bf037 | ||
|
|
ab1e964d3a | ||
|
|
1500744a96 | ||
|
|
394311622d | ||
|
|
c7f3991ddd | ||
|
|
e05efaa4f6 | ||
|
|
c96347a331 | ||
|
|
d721e84e42 | ||
|
|
dcbe4bd23e | ||
|
|
e11184426e | ||
|
|
ebea872c72 | ||
|
|
8e57d3d491 | ||
|
|
ca339ac0b2 | ||
|
|
abb6819c40 | ||
|
|
de314894ff | ||
|
|
2939747ebf | ||
|
|
a4548306e7 | ||
|
|
f16c1720a6 | ||
|
|
5b2ee94062 | ||
|
|
3091ad7fd4 | ||
|
|
5b7488295c | ||
|
|
bea62ddc4b | ||
|
|
44d6b1fb2a | ||
|
|
02ec8dd936 | ||
|
|
765ce0ac5e | ||
|
|
a1f7c3e34a | ||
|
|
2e5eb08b54 | ||
|
|
46a6d4fe0c | ||
|
|
84ea0df550 | ||
|
|
0f58b166ce | ||
|
|
f65d39023c | ||
|
|
0b3c7efbc1 | ||
|
|
ecb462562f | ||
|
|
c5a3d00371 | ||
|
|
d3b7a8ddde | ||
|
|
0eee203a9b | ||
|
|
cd5a95dea3 | ||
|
|
f348ddaf73 | ||
|
|
9f09093c80 | ||
|
|
490c6c9bdc | ||
|
|
4eb0ede76b | ||
|
|
02c1b6b783 | ||
|
|
9eed448da6 | ||
|
|
f8d621bcdc | ||
|
|
5cbe2246d3 | ||
|
|
f29a180cbd | ||
|
|
0ca3771906 | ||
|
|
6dab126f88 | ||
|
|
6c74d04984 | ||
|
|
1e00ff5e35 | ||
|
|
71fa334f61 | ||
|
|
d3ee995221 | ||
|
|
6812584b8c | ||
|
|
47ddf8ebe1 | ||
|
|
36897e7f15 | ||
|
|
f18315be02 | ||
|
|
38d77b02f5 | ||
|
|
5b77a93bba | ||
|
|
e1c11a7450 | ||
|
|
d96ee50f5a | ||
|
|
d4a172ba7e | ||
|
|
52ddb8dd7d | ||
|
|
8c16614752 | ||
|
|
700d7cc5fa | ||
|
|
017703dab2 | ||
|
|
c59fdb14f1 | ||
|
|
0c104f95d9 | ||
|
|
acbefae501 | ||
|
|
2ce83ef88a | ||
|
|
dab3412ecd | ||
|
|
988b3164d2 | ||
|
|
4161053821 | ||
|
|
60054c4865 | ||
|
|
f4e8aa576c | ||
|
|
30a495bcd1 | ||
|
|
6dde0eb220 | ||
|
|
90d8ef3423 | ||
|
|
f626a8ec9b | ||
|
|
a7c7535236 | ||
|
|
5b0f2821d6 | ||
|
|
be045557b8 | ||
|
|
a0c92182f9 | ||
|
|
f33d33556b | ||
|
|
9aff889835 | ||
|
|
420df1fbdd | ||
|
|
2946ede5ac | ||
|
|
6ec28e6a9d | ||
|
|
820daa489e | ||
|
|
b162596013 | ||
|
|
e581237e16 | ||
|
|
fcc329cc7c | ||
|
|
c9544e0fd2 | ||
|
|
d7906cfb95 | ||
|
|
13cd6aeb11 | ||
|
|
d4821d18fb | ||
|
|
b86bf48382 | ||
|
|
c595f94567 | ||
|
|
82058c0ef4 | ||
|
|
a1f3424b38 | ||
|
|
14ab749ef1 | ||
|
|
61dd4b2089 | ||
|
|
fb2fe1cc63 | ||
|
|
720f12ce1c | ||
|
|
423ebb403f | ||
|
|
c90fb66bb8 | ||
|
|
188de7ab1d | ||
|
|
0b1cf267a7 | ||
|
|
19f68612a5 | ||
|
|
1b1600d6f2 | ||
|
|
ce67c19ece | ||
|
|
f754ce3cfe | ||
|
|
19a86525cb | ||
|
|
29213ceb1c | ||
|
|
d25a9b1735 | ||
|
|
0f03706166 | ||
|
|
b1f79e3844 | ||
|
|
243d9b17ef | ||
|
|
50a6cf6499 | ||
|
|
8f7991e971 | ||
|
|
0aba54bd23 | ||
|
|
23833b2cff |
@@ -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).
|
||||
|
||||
@@ -4,3 +4,5 @@ alwaysApply: false
|
||||
---
|
||||
|
||||
This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.)
|
||||
|
||||
Let's not show too many error messages, and more importantly: let's not make them red. Nothing is ever this tragic.
|
||||
|
||||
3
.gitignore
vendored
@@ -8,6 +8,7 @@ dist
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# Applesauce Reference
|
||||
# Reference Projects
|
||||
applesauce
|
||||
primal-web-app
|
||||
|
||||
|
||||
332
CHANGELOG.md
@@ -7,6 +7,325 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.13] - 2025-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Support for `nprofile` identifiers on `/p/` profile pages (NIP-19)
|
||||
- Profile pages now accept both `npub` and `nprofile` identifiers
|
||||
- Extracts pubkey from nprofile data structure
|
||||
- Users can share profiles with relay metadata included
|
||||
- Gradient placeholder images for articles without cover images
|
||||
- Blog post cards show subtle diagonal gradient using theme colors
|
||||
- Reader view displays gradient background with newspaper icon
|
||||
- Placeholders adapt automatically to light/dark themes
|
||||
- Large view bookmarks use matching gradient backgrounds
|
||||
|
||||
### Changed
|
||||
|
||||
- PWA install section styling in settings
|
||||
- Heading now matches other section headings with proper styling
|
||||
- Install button uses standard app button styling instead of custom gradient
|
||||
- Consistent with app's design system and theme colors
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mobile bookmark button visibility across all pages
|
||||
- Now visible on `/p/` (profile), `/explore`, `/me`, and `/support` pages
|
||||
- Only hidden on settings page or when scrolling down while reading
|
||||
- Prevents users from getting stuck without navigation options
|
||||
- Mobile highlights button behavior at page top
|
||||
- Hidden when scrolled to the very top of the page
|
||||
- Appears when scrolling up from below
|
||||
- Bookmark button remains visible at top (only hides on scroll down)
|
||||
- Separate visibility logic for each button improves UX
|
||||
|
||||
## [0.6.12] - 2025-10-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Horizontal dividers (`<hr>`) in blog posts now display with more subtle styling
|
||||
- Reduced visual weight with 69% opacity for better readability
|
||||
- Added increased vertical padding (2.5rem) above and below dividers
|
||||
- Improved visual separation without disrupting reading flow
|
||||
|
||||
## [0.6.11] - 2025-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Colored borders to blog post and highlight cards based on relationship
|
||||
- Mine: yellow border
|
||||
- Friends: orange border
|
||||
- Nostrverse: purple border
|
||||
- Visual distinction helps identify content source at a glance
|
||||
- Mobile sidebar toggle buttons on explore page
|
||||
- Bookmark and highlights buttons now visible on explore page
|
||||
- Improves mobile navigation UX
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mobile bookmarks sidebar opening and closing immediately
|
||||
- Memoized `toggleSidebar` function to prevent unnecessary re-renders
|
||||
- Updated route-change effect to only close sidebar on actual pathname changes
|
||||
- Sidebar now stays open when opened on mobile PWA
|
||||
|
||||
## [0.6.10] - 2025-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Support page (`/support`) displaying zappers with avatar grid
|
||||
- Shows "Absolute Legends" (69420+ sats) and regular supporters (2100+ sats)
|
||||
- Clickable supporter avatars link to profiles
|
||||
- Bolt icon button in sidebar navigation
|
||||
- Thank-you illustration and call-to-action
|
||||
- Links to pricing page and Boris profile
|
||||
- Refresh button to explore page
|
||||
- Positioned next to filter buttons
|
||||
- Spinning animation during loading and pull-to-refresh
|
||||
- Unified event publishing and querying services
|
||||
- `publishEvent` service for highlights and settings
|
||||
- `queryEvents` helper with local-first fetching
|
||||
- Centralized relay timeouts configuration
|
||||
- FEATURES.md documentation file
|
||||
- MIT License
|
||||
|
||||
### Changed
|
||||
|
||||
- Explore page improvements
|
||||
- Filter defaults to friends only (instead of all)
|
||||
- Tabs moved below filter buttons
|
||||
- Filter buttons positioned on the right
|
||||
- Writings tab now uses newspaper icon
|
||||
- Subtitle removed for cleaner layout
|
||||
- Pull-to-refresh library
|
||||
- Replaced custom implementation with `use-pull-to-refresh`
|
||||
- Updated HighlightsPanel to use new library
|
||||
- Loading states now show progressive loading with skeletons instead of blocking error screens
|
||||
- All event fetching services migrated to unified `queryEvents` helper
|
||||
- `nostrverseService`, `bookmarkService`, `libraryService`
|
||||
- `exploreService`, `fetchHighlightsFromAuthors`
|
||||
- Contact streaming with extended timeout and partial results
|
||||
|
||||
### Fixed
|
||||
|
||||
- All ESLint and TypeScript linting errors
|
||||
- Removed all `eslint-disable` statements
|
||||
- Fixed `react-hooks/exhaustive-deps` warnings
|
||||
- Resolved all type errors
|
||||
- Explore page refresh loop and false empty-follows error
|
||||
- Zap receipt scanning with applesauce helpers and more relays
|
||||
- Support page theme colors for proper readability
|
||||
|
||||
### Refactored
|
||||
|
||||
- Event publishing to use unified `publishEvent` service
|
||||
- Event fetching to use unified `queryEvents` helper
|
||||
- Image cache and bookmark components (removed unused settings parameter)
|
||||
- Support page spacing and visual hierarchy
|
||||
|
||||
## [0.6.9] - 2025-10-14
|
||||
|
||||
### Documentation
|
||||
|
||||
- Minor changelog formatting updates
|
||||
|
||||
## [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 +1415,18 @@ 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.13...HEAD
|
||||
[0.6.13]: https://github.com/dergigi/boris/compare/v0.6.12...v0.6.13
|
||||
[0.6.12]: https://github.com/dergigi/boris/compare/v0.6.11...v0.6.12
|
||||
[0.6.11]: https://github.com/dergigi/boris/compare/v0.6.10...v0.6.11
|
||||
[0.6.10]: https://github.com/dergigi/boris/compare/v0.6.9...v0.6.10
|
||||
[0.6.9]: https://github.com/dergigi/boris/compare/v0.6.8...v0.6.9
|
||||
[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
|
||||
|
||||
89
FEATURES.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Boris Features
|
||||
## Overview
|
||||
|
||||
- **Purpose**: A calm, fast, Nostr‑first reader that turns your bookmarks into a focused reading app.
|
||||
- **Layout**: Three‑pane interface — bookmarks (left), reader (center), highlights (right). Collapsible sidebars.
|
||||
- **Content**: Renders both Nostr long‑form posts (kind:30023) and regular web URLs.
|
||||
- **Social layer**: Highlights shown by level — mine, friends, nostrverse — each with its own color and visibility toggle.
|
||||
|
||||
## Reader Experience
|
||||
|
||||
- **Distraction‑free view**: Clean typography, optional hero image, summary, and published date.
|
||||
- **Reading time**: Displays estimated reading time for text or duration for supported videos.
|
||||
- **Progress**: Reading progress indicator with completion state.
|
||||
- **Menus**: Quick actions to open, share, or copy links (for both Nostr and web content).
|
||||
- **Performance**: Lightweight fetching and caching for speed; skeleton loaders to avoid empty flashes.
|
||||
|
||||
## Highlights (NIP‑84)
|
||||
|
||||
- **Levels**: Mine, friends, nostrverse; toggle per level; colors configurable in settings.
|
||||
- **Interactions**: Click a highlight to scroll to its position; count indicator in the header.
|
||||
- **Creation**: Select text and use the floating highlighter button to publish a highlight.
|
||||
- **Attribution**: Automatically tags article authors for Nostr posts so they can see highlights.
|
||||
|
||||
## Zap Splits (NIP‑57)
|
||||
|
||||
- **Configurable splits**: Weight‑based sliders for highlighter, author(s), and Boris (defaults 50/50/2.1).
|
||||
- **Presets**: Quick buttons for common split configurations.
|
||||
- **Respect source**: If the source article has zap tags, author weights are proportionally preserved.
|
||||
|
||||
## Bookmarks & Reading List (NIP‑51 + Web)
|
||||
|
||||
- **Ingestion**: Collects list bookmarks and items from kinds 10003/30003/30001.
|
||||
- **Web bookmarks**: Supports NIP‑B0 (kind:39701) for standalone URL bookmarks.
|
||||
- **Add Bookmark**: Modal with auto title/description extraction and keywords/tags suggestion (adds “boris” when helpful).
|
||||
- **Views**: Reading list in compact, cards, or large preview modes; quick toggles to switch.
|
||||
- **Archive**: “Read” items appear in your archive; can mark articles/web pages as read.
|
||||
|
||||
## Explore & Profiles
|
||||
|
||||
- **Explore**: Discover friends' highlights and writings, plus a "nostrverse" feed.
|
||||
- **Filters**: Visibility toggles (mine, friends, nostrverse) apply to Explore highlights.
|
||||
- **Profiles**: View your own (`/me`) or other users (`/p/:npub`) with tabs for Highlights, Bookmarks, Archive, and Writings.
|
||||
|
||||
## Support
|
||||
|
||||
- **Supporter page**: Displays avatars of users who zapped Boris (kind:9735 receipts).
|
||||
- **Thresholds**: Shows supporters who sent ≥ 2100 sats; whales (≥ 69420 sats) get special styling with a bolt badge.
|
||||
- **Profile integration**: Fetches and displays profile pictures and names for all supporters.
|
||||
- **Stats**: Total supporter count and zap count displayed at the bottom.
|
||||
|
||||
## Video
|
||||
|
||||
- **Embedded player**: Plays supported videos (e.g., YouTube) inline with duration display.
|
||||
- **Metadata**: Fetches YouTube title/description/transcript when available.
|
||||
- **Deep links**: Open in native apps via platform‑specific URL schemes.
|
||||
|
||||
## Settings (NIP‑78 Application Data)
|
||||
|
||||
- **Theme**: System/light/dark with color variants (dark: black/midnight/charcoal; light: paper‑white/sepia/ivory).
|
||||
- **Reading**: Font family (preloaded), font size, highlight style (marker/underline), per‑level colors.
|
||||
- **Layout & startup**: Default view modes, auto‑collapse preferences, show/hide highlights.
|
||||
- **Zap Splits**: Weight sliders and presets for NIP‑57 splits.
|
||||
- **Offline/Flight Mode**: Local image cache with size limit and clear controls; “use local relay as cache”; rebroadcast preferences.
|
||||
- **Relays**: Relay overview and status in Settings; educational links.
|
||||
- **PWA**: Install prompt when available.
|
||||
|
||||
## Offline, PWA, and Sync
|
||||
|
||||
- **PWA**: Installable; service worker registered; periodic update checks with in‑app toast.
|
||||
- **Flight Mode**: Operates with local relays only; highlights created offline are stored locally and synced later.
|
||||
- **Relay indicator**: Floating status indicator shows Connecting/Offline/Flight Mode and connected counts.
|
||||
|
||||
## Relays & Accounts
|
||||
|
||||
- **Applesauce stack**: Accounts, event store, relay pool, and blueprints power Nostr interactions.
|
||||
- **Multi‑relay**: Grouped connections with keep‑alive subscription; local+remote partitioning for fast queries.
|
||||
- **Persistence**: Accounts restored from local storage; settings saved to NIP‑78 and watched for updates.
|
||||
|
||||
## Privacy
|
||||
|
||||
- **Identity**: No email or new account; uses your existing Nostr signer/identity.
|
||||
- **Data**: Bookmarks and highlights live on Nostr; reading/rendering happens locally in your browser.
|
||||
|
||||
## Conveniences
|
||||
|
||||
- **Share/copy**: One‑click copy or share for articles and videos.
|
||||
- **Open on Nostr**: Deep links to portals and `nostr:` schemes for long‑form articles.
|
||||
- **Mobile UX**: Floating open buttons for Bookmarks/Highlights, focus trapping, and backdrop controls.
|
||||
|
||||
22
LICENSE
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Gigi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
49
package-lock.json
generated
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.5.7",
|
||||
"version": "0.6.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.5.7",
|
||||
"version": "0.6.13",
|
||||
"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",
|
||||
@@ -21,17 +22,20 @@
|
||||
"applesauce-react": "^4.0.0",
|
||||
"applesauce-relay": "^4.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"fast-average-color": "^9.5.0",
|
||||
"nostr-tools": "^2.4.0",
|
||||
"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",
|
||||
"reading-time-estimator": "^1.14.0",
|
||||
"rehype-prism-plus": "^2.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
"remark-gfm": "^4.0.1",
|
||||
"use-pull-to-refresh": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.14",
|
||||
@@ -2263,6 +2267,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",
|
||||
@@ -6071,6 +6087,15 @@
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-average-color": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-average-color/-/fast-average-color-9.5.0.tgz",
|
||||
"integrity": "sha512-nC6x2YIlJ9xxgkMFMd1BNoM1ctMjNoRKfRliPmiEWW3S6rLTHiQcy9g3pt/xiKv/D0NAAkhb9VyV+WJFvTqMGg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -9807,6 +9832,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",
|
||||
@@ -11672,6 +11706,15 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-pull-to-refresh": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/use-pull-to-refresh/-/use-pull-to-refresh-2.4.1.tgz",
|
||||
"integrity": "sha512-mI3utetwSPT3ovZHUJ4LBW29EtmkrzpK/O38msP5WnI8ocFmM5boy3QZALosgeQwqwdmtQgC+8xnJIYHXeABew==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "18.x || 19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.4",
|
||||
"version": "0.6.14",
|
||||
"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",
|
||||
@@ -24,17 +25,20 @@
|
||||
"applesauce-react": "^4.0.0",
|
||||
"applesauce-relay": "^4.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"fast-average-color": "^9.5.0",
|
||||
"nostr-tools": "^2.4.0",
|
||||
"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",
|
||||
"reading-time-estimator": "^1.14.0",
|
||||
"rehype-prism-plus": "^2.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
"remark-gfm": "^4.0.1",
|
||||
"use-pull-to-refresh": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.14",
|
||||
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 465 B After Width: | Height: | Size: 564 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 25 KiB |
1
public/thank-you.svg
Normal file
|
After Width: | Height: | Size: 17 KiB |
63
src/App.tsx
@@ -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'
|
||||
@@ -61,6 +62,15 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/support"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/explore"
|
||||
element={
|
||||
@@ -70,6 +80,15 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/explore/writings"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me"
|
||||
element={<Navigate to="/me/highlights" replace />}
|
||||
@@ -196,8 +215,7 @@ function App() {
|
||||
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
|
||||
|
||||
// Store subscription for cleanup
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(pool as any)._keepAliveSubscription = keepAliveSub
|
||||
;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub
|
||||
|
||||
// Attach address/replaceable loaders so ProfileModel can fetch profiles
|
||||
const addressLoader = createAddressLoader(pool, {
|
||||
@@ -216,10 +234,9 @@ function App() {
|
||||
accountsSub.unsubscribe()
|
||||
activeSub.unsubscribe()
|
||||
// Clean up keep-alive subscription if it exists
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((pool as any)._keepAliveSubscription) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(pool as any)._keepAliveSubscription.unsubscribe()
|
||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||
if (poolWithSub._keepAliveSubscription) {
|
||||
poolWithSub._keepAliveSubscription.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,22 +279,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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -140,8 +140,7 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
||||
clearTimeout(fetchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url]) // Only depend on url - title, description, tagsInput are intentionally checked but not dependencies
|
||||
}, [url, title, description, tagsInput])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -10,9 +10,10 @@ import { Models } from 'applesauce-core'
|
||||
interface BlogPostCardProps {
|
||||
post: BlogPostPreview
|
||||
href: string
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
}
|
||||
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||
const displayName = profile?.name || profile?.display_name ||
|
||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||
@@ -25,7 +26,7 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className="blog-post-card"
|
||||
className={`blog-post-card ${level ? `level-${level}` : ''}`}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<div className="blog-post-card-image">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { faBookOpen, faPlay, faEye } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
||||
import { faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { npubEncode, neventEncode } from 'nostr-tools/nip19'
|
||||
@@ -11,17 +13,15 @@ import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
|
||||
import { CompactView } from './BookmarkViews/CompactView'
|
||||
import { LargeView } from './BookmarkViews/LargeView'
|
||||
import { CardView } from './BookmarkViews/CardView'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface BookmarkItemProps {
|
||||
bookmark: IndividualBookmark
|
||||
index: number
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
viewMode?: ViewMode
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', settings }) => {
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
|
||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||
|
||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||
@@ -68,18 +68,40 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
return short(bookmark.pubkey) // fallback to short pubkey
|
||||
}
|
||||
|
||||
// use helper from kindIcon.ts
|
||||
// Get content type icon based on bookmark kind and URL classification
|
||||
const getContentTypeIcon = (): IconDefinition => {
|
||||
if (isArticle) return faNewspaper
|
||||
|
||||
// For web bookmarks, classify the URL to determine icon
|
||||
if (isWebBookmark && firstUrlClassification) {
|
||||
switch (firstUrlClassification.type) {
|
||||
case 'youtube':
|
||||
case 'video':
|
||||
return faCirclePlay
|
||||
case 'image':
|
||||
return faCamera
|
||||
case 'article':
|
||||
return faNewspaper
|
||||
default:
|
||||
return faGlobe
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasUrls) return faStickyNote // Just a text note
|
||||
if (firstUrlClassification?.type === 'youtube' || firstUrlClassification?.type === 'video') return faCirclePlay
|
||||
return faFileLines
|
||||
}
|
||||
|
||||
const getIconForUrlType = (url: string) => {
|
||||
const classification = classifyUrl(url)
|
||||
switch (classification.type) {
|
||||
case 'youtube':
|
||||
case 'video':
|
||||
return faPlay
|
||||
return faCirclePlay
|
||||
case 'image':
|
||||
return faEye
|
||||
return faCamera
|
||||
default:
|
||||
return faBookOpen
|
||||
return faFileLines
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,11 +138,12 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary,
|
||||
settings
|
||||
contentTypeIcon: getContentTypeIcon()
|
||||
}
|
||||
|
||||
if (viewMode === 'compact') {
|
||||
return <CompactView {...sharedProps} />
|
||||
const { articleImage: _articleImage, ...compactProps } = sharedProps
|
||||
return <CompactView {...compactProps} />
|
||||
}
|
||||
|
||||
if (viewMode === 'large') {
|
||||
@@ -128,5 +151,5 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
|
||||
}
|
||||
|
||||
return <CardView {...sharedProps} getIconForUrlType={getIconForUrlType} articleImage={articleImage} />
|
||||
return <CardView {...sharedProps} articleImage={articleImage} />
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import React, { useRef } from 'react'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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, faHeart, faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import SidebarHeader from './SidebarHeader'
|
||||
import IconButton from './IconButton'
|
||||
import CompactButton from './CompactButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { BookmarkSkeleton } from './Skeletons'
|
||||
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet } from '../utils/bookmarkUtils'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import AddBookmarkModal from './AddBookmarkModal'
|
||||
import { createWebBookmark } from '../services/webBookmarkService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
|
||||
interface BookmarkListProps {
|
||||
bookmarks: Bookmark[]
|
||||
@@ -28,8 +35,8 @@ interface BookmarkListProps {
|
||||
lastFetchTime?: number | null
|
||||
loading?: boolean
|
||||
relayPool: RelayPool | null
|
||||
settings?: UserSettings
|
||||
isMobile?: boolean
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
@@ -47,52 +54,60 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
lastFetchTime,
|
||||
loading = false,
|
||||
relayPool,
|
||||
settings,
|
||||
isMobile = false
|
||||
isMobile = false,
|
||||
settings
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
||||
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
|
||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
throw new Error('Please login to create bookmarks')
|
||||
}
|
||||
|
||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
||||
}
|
||||
|
||||
// Pull-to-refresh for bookmarks
|
||||
const pullToRefreshState = usePullToRefresh(bookmarksListRef, {
|
||||
const { isRefreshing: isPulling, pullPosition } = usePullToRefresh({
|
||||
onRefresh: () => {
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
},
|
||||
isRefreshing: isRefreshing || false,
|
||||
disabled: !onRefresh
|
||||
maximumPullLength: 240,
|
||||
refreshThreshold: 80,
|
||||
isDisabled: !onRefresh
|
||||
})
|
||||
|
||||
// Helper to check if a bookmark has either content or a URL
|
||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||
// Check if has content (text)
|
||||
const hasContent = ib.content && ib.content.trim().length > 0
|
||||
|
||||
// Check if has URL
|
||||
let hasUrl = false
|
||||
|
||||
// For web bookmarks (kind:39701), URL is in the 'd' tag
|
||||
if (ib.kind === 39701) {
|
||||
const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1]
|
||||
hasUrl = !!dTag && dTag.trim().length > 0
|
||||
} else {
|
||||
// For other bookmarks, extract URLs from content
|
||||
const urls = extractUrlsFromContent(ib.content || '')
|
||||
hasUrl = urls.length > 0
|
||||
}
|
||||
|
||||
// Always show articles (kind:30023) as they have special handling
|
||||
if (ib.kind === 30023) return true
|
||||
|
||||
// Otherwise, must have either content or URL
|
||||
return hasContent || hasUrl
|
||||
}
|
||||
|
||||
// Merge and flatten all individual bookmarks from all lists
|
||||
// Re-sort after flattening to ensure newest first across all lists
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContentOrUrl)
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
.filter(hasContent)
|
||||
|
||||
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
||||
const bookmarksWithoutSet = getBookmarksWithoutSet(allIndividualBookmarks)
|
||||
const bookmarkSets = getBookmarkSets(allIndividualBookmarks)
|
||||
|
||||
// Group non-set bookmarks as before
|
||||
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||
{ key: 'private', title: 'Private bookmarks', items: groups.privateItems },
|
||||
{ key: 'public', title: 'Public bookmarks', items: groups.publicItems },
|
||||
{ key: 'web', title: 'Web bookmarks', items: groups.web },
|
||||
{ key: 'amethyst', title: 'Old Bookmarks (Legacy)', items: groups.amethyst }
|
||||
]
|
||||
|
||||
// Add bookmark sets as additional sections
|
||||
bookmarkSets.forEach(set => {
|
||||
sections.push({
|
||||
key: `set-${set.name}`,
|
||||
title: set.title || set.name,
|
||||
items: set.bookmarks
|
||||
})
|
||||
})
|
||||
|
||||
if (isCollapsed) {
|
||||
// Check if the selected URL is in bookmarks
|
||||
@@ -122,14 +137,17 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onLogout={onLogout}
|
||||
onOpenSettings={onOpenSettings}
|
||||
relayPool={relayPool}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{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">
|
||||
@@ -141,62 +159,93 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
) : (
|
||||
<div
|
||||
ref={bookmarksListRef}
|
||||
className={`bookmarks-list pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
className="bookmarks-list"
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={isRefreshing || false}
|
||||
<RefreshIndicator
|
||||
isRefreshing={isPulling || isRefreshing || false}
|
||||
pullPosition={pullPosition}
|
||||
/>
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{allIndividualBookmarks.map((individualBookmark, index) =>
|
||||
<BookmarkItem
|
||||
key={`${individualBookmark.id}-${index}`}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
settings={settings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{sections.filter(s => s.items.length > 0).map(section => (
|
||||
<div key={section.key} className="bookmarks-section">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<h3 className="bookmarks-section-title" style={{ margin: 0, padding: '1.5rem 0.5rem 0.375rem', flex: 1 }}>{section.title}</h3>
|
||||
{section.key === 'web' && activeAccount && (
|
||||
<CompactButton
|
||||
icon={faPlus}
|
||||
onClick={() => setShowAddModal(true)}
|
||||
title="Add web bookmark"
|
||||
ariaLabel="Add web bookmark"
|
||||
className="bookmark-section-action"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{section.items.map((individualBookmark, index) => (
|
||||
<BookmarkItem
|
||||
key={`${section.key}-${individualBookmark.id}-${index}`}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="view-mode-controls">
|
||||
{onRefresh && (
|
||||
<div className="view-mode-left">
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||
ariaLabel="Refresh bookmarks"
|
||||
icon={faHeart}
|
||||
onClick={() => navigate('/support')}
|
||||
title="Support Boris"
|
||||
ariaLabel="Support"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
style={{ color: friendsColor }}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => onViewModeChange('cards')}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => onViewModeChange('large')}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
<div className="view-mode-right">
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => onViewModeChange('cards')}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => onViewModeChange('large')}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showAddModal && (
|
||||
<AddBookmarkModal
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSave={handleSaveBookmark}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faUserLock, faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import IconButton from '../IconButton'
|
||||
import { classifyUrl } from '../../utils/helpers'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import { getEventUrl } from '../../config/nostrGateways'
|
||||
|
||||
interface CardViewProps {
|
||||
@@ -19,14 +17,13 @@ interface CardViewProps {
|
||||
hasUrls: boolean
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
getIconForUrlType: IconGetter
|
||||
authorNpub: string
|
||||
eventNevent?: string
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
contentTypeIcon: IconDefinition
|
||||
}
|
||||
|
||||
export const CardView: React.FC<CardViewProps> = ({
|
||||
@@ -35,14 +32,13 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
getIconForUrlType,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary,
|
||||
settings
|
||||
contentTypeIcon
|
||||
}) => {
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
|
||||
@@ -55,11 +51,10 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
const contentLength = (bookmark.content || '').length
|
||||
const shouldTruncate = !expanded && contentLength > 210
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
|
||||
// Determine which image to use (article image, instant preview, or OG image)
|
||||
const previewImage = articleImage || instantPreview || ogImage
|
||||
const cachedImage = useImageCache(previewImage || undefined, settings)
|
||||
const cachedImage = useImageCache(previewImage || undefined)
|
||||
|
||||
// Fetch OG image if we don't have any other image
|
||||
React.useEffect(() => {
|
||||
@@ -95,18 +90,9 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
)}
|
||||
<div className="bookmark-header">
|
||||
<span className="bookmark-type">
|
||||
{isWebBookmark ? (
|
||||
<span className="fa-layers fa-fw">
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
|
||||
</span>
|
||||
) : bookmark.isPrivate ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||
</>
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
{bookmark.isPrivate && (
|
||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -130,23 +116,14 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
<div className="bookmark-urls">
|
||||
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
|
||||
return (
|
||||
<div key={urlIndex} className="url-row">
|
||||
<button
|
||||
className="bookmark-url"
|
||||
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
|
||||
title="Open in reader"
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
<IconButton
|
||||
icon={getIconForUrlType(url)}
|
||||
ariaLabel="Open"
|
||||
title="Open"
|
||||
variant="success"
|
||||
size={32}
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onSelectUrl?.(url) }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
key={urlIndex}
|
||||
className="bookmark-url"
|
||||
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
|
||||
title="Open in reader"
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{extractedUrls.length > 1 && (
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faUserLock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface CompactViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -13,9 +12,8 @@ interface CompactViewProps {
|
||||
hasUrls: boolean
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
contentTypeIcon: IconDefinition
|
||||
}
|
||||
|
||||
export const CompactView: React.FC<CompactViewProps> = ({
|
||||
@@ -24,17 +22,13 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
articleImage,
|
||||
articleSummary,
|
||||
settings
|
||||
contentTypeIcon
|
||||
}) => {
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
const isClickable = hasUrls || isArticle || isWebBookmark
|
||||
|
||||
// Get cached image for thumbnail
|
||||
const cachedImage = useImageCache(articleImage || undefined, settings)
|
||||
|
||||
const handleCompactClick = () => {
|
||||
if (!onSelectUrl) return
|
||||
|
||||
@@ -58,26 +52,10 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
role={isClickable ? 'button' : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
>
|
||||
{/* Thumbnail image */}
|
||||
{cachedImage && (
|
||||
<div className="compact-thumbnail">
|
||||
<img src={cachedImage} alt="" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="bookmark-type-compact">
|
||||
{isWebBookmark ? (
|
||||
<span className="fa-layers fa-fw">
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
|
||||
</span>
|
||||
) : bookmark.isPrivate ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||
</>
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
{bookmark.isPrivate && (
|
||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||
)}
|
||||
</span>
|
||||
{displayText && (
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faUserLock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import { getEventUrl } from '../../config/nostrGateways'
|
||||
|
||||
interface LargeViewProps {
|
||||
@@ -22,7 +23,7 @@ interface LargeViewProps {
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
contentTypeIcon: IconDefinition
|
||||
}
|
||||
|
||||
export const LargeView: React.FC<LargeViewProps> = ({
|
||||
@@ -38,9 +39,9 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary,
|
||||
settings
|
||||
contentTypeIcon
|
||||
}) => {
|
||||
const cachedImage = useImageCache(previewImage || undefined, settings)
|
||||
const cachedImage = useImageCache(previewImage || undefined)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
|
||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
@@ -93,6 +94,12 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
)}
|
||||
|
||||
<div className="large-footer">
|
||||
<span className="bookmark-type-large">
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
{bookmark.isPrivate && (
|
||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||
)}
|
||||
</span>
|
||||
<span className="large-author">
|
||||
<Link
|
||||
to={`/p/${authorNpub}`}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||
import ThreePaneLayout from './ThreePaneLayout'
|
||||
import Explore from './Explore'
|
||||
import Me from './Me'
|
||||
import Support from './Support'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
|
||||
export type ViewMode = 'compact' | 'cards' | 'large'
|
||||
@@ -31,14 +32,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/')
|
||||
const showSupport = location.pathname === '/support'
|
||||
|
||||
// Extract tab from explore routes
|
||||
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
||||
|
||||
// Extract tab from me routes
|
||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||
@@ -50,16 +58,18 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
// Extract tab from profile routes
|
||||
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
|
||||
|
||||
// Decode npub to pubkey for profile view
|
||||
// Decode npub or nprofile to pubkey for profile view
|
||||
let profilePubkey: string | undefined
|
||||
if (npub && showProfile) {
|
||||
try {
|
||||
const decoded = nip19.decode(npub)
|
||||
if (decoded.type === 'npub') {
|
||||
profilePubkey = decoded.data
|
||||
} else if (decoded.type === 'nprofile') {
|
||||
profilePubkey = decoded.data.pubkey
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decode npub:', err)
|
||||
console.error('Failed to decode npub/nprofile:', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,12 +128,27 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
} = useBookmarksUI({ settings })
|
||||
|
||||
// Close sidebar on mobile when route changes (e.g., clicking on blog posts in Explore)
|
||||
const prevPathnameRef = useRef<string>(location.pathname)
|
||||
useEffect(() => {
|
||||
if (isMobile && isSidebarOpen) {
|
||||
// Only close if pathname actually changed, not on initial render or other state changes
|
||||
if (isMobile && isSidebarOpen && prevPathnameRef.current !== location.pathname) {
|
||||
toggleSidebar()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.pathname])
|
||||
prevPathnameRef.current = location.pathname
|
||||
}, [location.pathname, isMobile, isSidebarOpen, toggleSidebar])
|
||||
|
||||
// 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,
|
||||
@@ -231,6 +256,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
showExplore={showExplore}
|
||||
showMe={showMe}
|
||||
showProfile={showProfile}
|
||||
showSupport={showSupport}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
viewMode={viewMode}
|
||||
@@ -286,7 +312,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
|
||||
@@ -294,6 +320,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
profile={showProfile && profilePubkey ? (
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
|
||||
) : undefined}
|
||||
support={showSupport ? (
|
||||
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
||||
) : undefined}
|
||||
toastMessage={toastMessage ?? undefined}
|
||||
toastType={toastType}
|
||||
onClearToast={clearToast}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,45 +1,81 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } 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 { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
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, or friends only)
|
||||
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
||||
nostrverse: settings?.defaultHighlightVisibilityNostrverse ?? false,
|
||||
friends: settings?.defaultHighlightVisibilityFriends ?? true,
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
// Use functional update to check current state without creating dependency
|
||||
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||
if (cachedPosts && cachedPosts.length > 0) {
|
||||
setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev)
|
||||
}
|
||||
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
|
||||
if (cachedHighlights && cachedHighlights.length > 0) {
|
||||
setHighlights(prev => prev.length === 0 ? cachedHighlights : prev)
|
||||
}
|
||||
|
||||
// Fetch the user's contacts (friends)
|
||||
@@ -47,15 +83,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 +109,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,52 +121,106 @@ 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!')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
// Always proceed to load nostrverse content even if no contacts
|
||||
// (removed blocking error for empty contacts)
|
||||
|
||||
// After full contacts, do a final pass for completeness
|
||||
// Store final followed pubkeys
|
||||
setFollowedPubkeys(contacts)
|
||||
|
||||
// Fetch both friends content and nostrverse content in parallel
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const posts = await fetchBlogPostsFromAuthors(relayPool, Array.from(contacts), relayUrls)
|
||||
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)
|
||||
])
|
||||
|
||||
if (posts.length === 0) {
|
||||
setError('No blog posts found from your friends yet')
|
||||
// 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
|
||||
})
|
||||
// No blocking errors - let empty states handle messaging
|
||||
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)
|
||||
// No blocking error - user can pull-to-refresh
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadBlogPosts()
|
||||
}, [relayPool, activeAccount, blogPosts.length, refreshTrigger])
|
||||
loadData()
|
||||
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
|
||||
|
||||
// Pull-to-refresh
|
||||
const pullToRefreshState = usePullToRefresh(exploreContainerRef, {
|
||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||
onRefresh: () => {
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
},
|
||||
isRefreshing: loading
|
||||
maximumPullLength: 240,
|
||||
refreshThreshold: 80,
|
||||
isDisabled: !activeAccount
|
||||
})
|
||||
|
||||
const getPostUrl = (post: BlogPostPreview) => {
|
||||
@@ -144,56 +237,222 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-error">
|
||||
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
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, and add level classification
|
||||
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
|
||||
})
|
||||
.map(post => {
|
||||
// Add level classification
|
||||
const isMine = activeAccount && post.author === activeAccount.pubkey
|
||||
const isFriend = followedPubkeys.has(post.author)
|
||||
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
||||
return { ...post, level }
|
||||
})
|
||||
}, [blogPosts, activeAccount, followedPubkeys, visibility])
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'writings':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<BlogPostSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return filteredBlogPosts.length === 0 ? (
|
||||
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</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)}
|
||||
level={post.level}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'highlights':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<HighlightSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return classifiedHighlights.length === 0 ? (
|
||||
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{classifiedHighlights.map((highlight) => (
|
||||
<HighlightItem
|
||||
key={highlight.id}
|
||||
highlight={highlight}
|
||||
relayPool={relayPool}
|
||||
onHighlightClick={handleHighlightClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Show content progressively - no blocking error screens
|
||||
const hasData = highlights.length > 0 || blogPosts.length > 0
|
||||
const showSkeletons = loading && !hasData
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={exploreContainerRef}
|
||||
className={`explore-container pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={loading && pullToRefreshState.canRefresh}
|
||||
<div className="explore-container">
|
||||
<RefreshIndicator
|
||||
isRefreshing={isRefreshing}
|
||||
pullPosition={pullPosition}
|
||||
/>
|
||||
<div className="explore-header">
|
||||
<h1>
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
Explore
|
||||
</h1>
|
||||
<p className="explore-subtitle">
|
||||
Discover blog posts from your friends on Nostr
|
||||
</p>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</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', justifyContent: 'flex-end' }}>
|
||||
<IconButton
|
||||
icon={faArrowsRotate}
|
||||
onClick={() => setRefreshTrigger(prev => prev + 1)}
|
||||
title="Refresh content"
|
||||
ariaLabel="Refresh content"
|
||||
variant="ghost"
|
||||
spin={loading || isRefreshing}
|
||||
disabled={loading || isRefreshing}
|
||||
/>
|
||||
))}
|
||||
{!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={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
|
||||
}}
|
||||
/>
|
||||
<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 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={faNewspaper} />
|
||||
<span className="tab-label">Writings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
104
src/components/HighlightCitation.tsx
Normal 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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { useFilteredHighlights } from '../hooks/useFilteredHighlights'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
|
||||
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { HighlightSkeleton } from './Skeletons'
|
||||
|
||||
export interface HighlightVisibility {
|
||||
nostrverse: boolean
|
||||
@@ -59,7 +60,6 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
}) => {
|
||||
const [showHighlights, setShowHighlights] = useState(true)
|
||||
const [localHighlights, setLocalHighlights] = useState(highlights)
|
||||
const highlightsListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleToggleHighlights = () => {
|
||||
const newValue = !showHighlights
|
||||
@@ -68,14 +68,15 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
}
|
||||
|
||||
// Pull-to-refresh for highlights
|
||||
const pullToRefreshState = usePullToRefresh(highlightsListRef, {
|
||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||
onRefresh: () => {
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
},
|
||||
isRefreshing: loading,
|
||||
disabled: !onRefresh
|
||||
maximumPullLength: 240,
|
||||
refreshThreshold: 80,
|
||||
isDisabled: !onRefresh
|
||||
})
|
||||
|
||||
// Keep track of highlight updates
|
||||
@@ -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">
|
||||
@@ -141,15 +144,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={highlightsListRef}
|
||||
className={`highlights-list pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={loading}
|
||||
<div className="highlights-list">
|
||||
<RefreshIndicator
|
||||
isRefreshing={isRefreshing}
|
||||
pullPosition={pullPosition}
|
||||
/>
|
||||
{filteredHighlights.map((highlight) => (
|
||||
<HighlightItem
|
||||
@@ -162,6 +160,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
eventStore={eventStore}
|
||||
onHighlightUpdate={handleHighlightUpdate}
|
||||
onHighlightDelete={handleHighlightDelete}
|
||||
showCitation={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faSpinner, 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'
|
||||
@@ -18,12 +19,11 @@ import BlogPostCard from './BlogPostCard'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import { getProfileUrl } from '../config/nostrGateways'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
||||
|
||||
interface MeProps {
|
||||
relayPool: RelayPool
|
||||
@@ -46,9 +46,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
|
||||
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||
const meContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
|
||||
// Update local state when prop changes
|
||||
@@ -61,14 +59,12 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!viewingPubkey) {
|
||||
setError(isOwnProfile ? 'Please log in to view your data' : 'Invalid profile')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Seed from cache if available to avoid empty flash (own profile only)
|
||||
if (isOwnProfile) {
|
||||
@@ -114,7 +110,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
setError('Failed to load data. Please try again.')
|
||||
// No blocking error - user can pull-to-refresh
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -124,11 +120,13 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
||||
|
||||
// Pull-to-refresh
|
||||
const pullToRefreshState = usePullToRefresh(meContainerRef, {
|
||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||
onRefresh: () => {
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
},
|
||||
isRefreshing: loading
|
||||
maximumPullLength: 240,
|
||||
refreshThreshold: 80,
|
||||
isDisabled: !viewingPubkey
|
||||
})
|
||||
|
||||
const handleHighlightDelete = (highlightId: string) => {
|
||||
@@ -152,23 +150,6 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
// Helper to check if a bookmark has either content or a URL (same logic as BookmarkList)
|
||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||
const hasContent = ib.content && ib.content.trim().length > 0
|
||||
|
||||
let hasUrl = false
|
||||
if (ib.kind === 39701) {
|
||||
const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1]
|
||||
hasUrl = !!dTag && dTag.trim().length > 0
|
||||
} else {
|
||||
const urls = extractUrlsFromContent(ib.content || '')
|
||||
hasUrl = urls.length > 0
|
||||
}
|
||||
|
||||
if (ib.kind === 30023) return true
|
||||
return hasContent || hasUrl
|
||||
}
|
||||
|
||||
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||
if (bookmark && bookmark.kind === 30023) {
|
||||
// For kind:30023 articles, navigate to the article route
|
||||
@@ -188,45 +169,36 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
}
|
||||
}
|
||||
|
||||
// Merge and flatten all individual bookmarks (same logic as BookmarkList)
|
||||
// Merge and flatten all individual bookmarks
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContentOrUrl)
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
.filter(hasContent)
|
||||
const groups = groupIndividualBookmarks(allIndividualBookmarks)
|
||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||
{ key: 'private', title: 'Private bookmarks', items: groups.privateItems },
|
||||
{ key: 'public', title: 'Public bookmarks', items: groups.publicItems },
|
||||
{ key: 'web', title: 'Web bookmarks', items: groups.web },
|
||||
{ key: 'amethyst', title: 'Old Bookmarks (Legacy)', items: groups.amethyst }
|
||||
]
|
||||
|
||||
// Only show full loading screen if we don't have any data yet
|
||||
// Show content progressively - no blocking error screens
|
||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
|
||||
|
||||
if (loading && !hasData) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-error">
|
||||
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const showSkeletons = loading && !hasData
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'highlights':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<HighlightSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return highlights.length === 0 ? (
|
||||
<div className="explore-empty">
|
||||
<p>
|
||||
{isOwnProfile
|
||||
? 'No highlights yet. Start highlighting content to see them here!'
|
||||
: 'No highlights yet. You should shame them on nostr!'}
|
||||
</p>
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="highlights-list me-highlights-list">
|
||||
@@ -242,23 +214,39 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
)
|
||||
|
||||
case 'reading-list':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="bookmarks-list">
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<BookmarkSkeleton key={i} viewMode={viewMode} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return allIndividualBookmarks.length === 0 ? (
|
||||
<div className="explore-empty">
|
||||
<p>No bookmarks yet. Bookmark articles to see them here!</p>
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{allIndividualBookmarks.map((individualBookmark, index) => (
|
||||
<BookmarkItem
|
||||
key={`${individualBookmark.id}-${index}`}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
viewMode={viewMode}
|
||||
onSelectUrl={handleSelectUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{sections.filter(s => s.items.length > 0).map(section => (
|
||||
<div key={section.key} className="bookmarks-section">
|
||||
<h3 className="bookmarks-section-title">{section.title}</h3>
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{section.items.map((individualBookmark, index) => (
|
||||
<BookmarkItem
|
||||
key={`${section.key}-${individualBookmark.id}-${index}`}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
viewMode={viewMode}
|
||||
onSelectUrl={handleSelectUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="view-mode-controls" style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
@@ -293,9 +281,18 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
)
|
||||
|
||||
case 'archive':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<BlogPostSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return readArticles.length === 0 ? (
|
||||
<div className="explore-empty">
|
||||
<p>No read articles yet. Mark articles as read to see them here!</p>
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -310,26 +307,18 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
)
|
||||
|
||||
case 'writings':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<BlogPostSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return writings.length === 0 ? (
|
||||
<div className="explore-empty">
|
||||
<p>
|
||||
{isOwnProfile
|
||||
? 'No articles written yet. Publish your first article to see it here!'
|
||||
: (
|
||||
<>
|
||||
No articles written. You can find other stuff from this user using{' '}
|
||||
<a
|
||||
href={viewingPubkey ? getProfileUrl(nip19.npubEncode(viewingPubkey)) : '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'rgb(99 102 241)', textDecoration: 'underline' }}
|
||||
>
|
||||
ants
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -349,25 +338,14 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={meContainerRef}
|
||||
className={`explore-container pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={loading && pullToRefreshState.canRefresh}
|
||||
<div className="explore-container">
|
||||
<RefreshIndicator
|
||||
isRefreshing={isRefreshing}
|
||||
pullPosition={pullPosition}
|
||||
/>
|
||||
<div className="explore-header">
|
||||
{viewingPubkey && <AuthorCard authorPubkey={viewingPubkey} clickable={false} />}
|
||||
|
||||
{loading && hasData && (
|
||||
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="me-tabs">
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faArrowDown } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface PullToRefreshIndicatorProps {
|
||||
isPulling: boolean
|
||||
pullDistance: number
|
||||
canRefresh: boolean
|
||||
isRefreshing: boolean
|
||||
threshold?: number
|
||||
}
|
||||
|
||||
const PullToRefreshIndicator: React.FC<PullToRefreshIndicatorProps> = ({
|
||||
isPulling,
|
||||
pullDistance,
|
||||
canRefresh,
|
||||
threshold = 80
|
||||
}) => {
|
||||
// Only show when actively pulling, not when refreshing
|
||||
if (!isPulling) return null
|
||||
|
||||
const opacity = Math.min(pullDistance / threshold, 1)
|
||||
const rotation = (pullDistance / threshold) * 180
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pull-to-refresh-indicator"
|
||||
style={{
|
||||
opacity,
|
||||
transform: `translateY(${-20 + pullDistance / 2}px)`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="pull-to-refresh-icon"
|
||||
style={{
|
||||
transform: `rotate(${rotation}deg)`
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowDown}
|
||||
style={{ color: canRefresh ? 'var(--accent-color, #3b82f6)' : 'var(--text-secondary)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="pull-to-refresh-text">
|
||||
{canRefresh ? 'Release to refresh' : 'Pull to refresh'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PullToRefreshIndicator
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faHighlighter, faClock, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||
import { format } from 'date-fns'
|
||||
import { useImageCache } from '../hooks/useImageCache'
|
||||
import { useAdaptiveTextColor } from '../hooks/useAdaptiveTextColor'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { Highlight, HighlightLevel } from '../types/highlights'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
@@ -33,12 +34,13 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
highlights = [],
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true }
|
||||
}) => {
|
||||
const cachedImage = useImageCache(image, settings)
|
||||
const cachedImage = useImageCache(image)
|
||||
const { textColor } = useAdaptiveTextColor(cachedImage)
|
||||
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
||||
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,17 +67,30 @@ 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])
|
||||
|
||||
if (cachedImage) {
|
||||
// Show hero section if we have an image OR a title
|
||||
if (cachedImage || title) {
|
||||
return (
|
||||
<>
|
||||
<div className="reader-hero-image">
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
{cachedImage ? (
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
) : (
|
||||
<div className="reader-hero-placeholder">
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
</div>
|
||||
)}
|
||||
{formattedDate && (
|
||||
<div className="publish-date-topright">
|
||||
<div
|
||||
className="publish-date-topright"
|
||||
style={{
|
||||
color: textColor
|
||||
}}
|
||||
>
|
||||
{formattedDate}
|
||||
</div>
|
||||
)}
|
||||
@@ -93,7 +108,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>
|
||||
@@ -117,7 +132,12 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
{title && (
|
||||
<div className="reader-header">
|
||||
{formattedDate && (
|
||||
<div className="publish-date-topright">
|
||||
<div
|
||||
className="publish-date-topright"
|
||||
style={{
|
||||
color: textColor
|
||||
}}
|
||||
>
|
||||
{formattedDate}
|
||||
</div>
|
||||
)}
|
||||
@@ -133,7 +153,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>
|
||||
|
||||
63
src/components/RefreshIndicator.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faArrowRotateRight } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface RefreshIndicatorProps {
|
||||
isRefreshing: boolean
|
||||
pullPosition: number
|
||||
}
|
||||
|
||||
const THRESHOLD = 80
|
||||
|
||||
/**
|
||||
* Simple pull-to-refresh visual indicator
|
||||
*/
|
||||
const RefreshIndicator: React.FC<RefreshIndicatorProps> = ({
|
||||
isRefreshing,
|
||||
pullPosition
|
||||
}) => {
|
||||
const isVisible = isRefreshing || pullPosition > 0
|
||||
if (!isVisible) return null
|
||||
|
||||
const opacity = Math.min(pullPosition / THRESHOLD, 1)
|
||||
const translateY = isRefreshing ? THRESHOLD / 3 : pullPosition / 3
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${translateY}px`,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 30,
|
||||
opacity,
|
||||
transition: isRefreshing ? 'opacity 0.2s' : 'none'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--surface-secondary, #ffffff)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRotateRight}
|
||||
style={{
|
||||
transform: isRefreshing ? 'none' : `rotate(${pullPosition}deg)`,
|
||||
color: 'var(--accent-color, #3b82f6)'
|
||||
}}
|
||||
className={isRefreshing ? 'fa-spin' : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RefreshIndicator
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,7 +16,7 @@ const PWASettings: React.FC = () => {
|
||||
if (isInstalled) {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3>Progressive Web App</h3>
|
||||
<h3 className="section-title">Progressive Web App</h3>
|
||||
<div className="setting-item">
|
||||
<div className="setting-info">
|
||||
<FontAwesomeIcon icon={faCheckCircle} style={{ color: '#22c55e', marginRight: '8px' }} />
|
||||
@@ -36,41 +36,19 @@ const PWASettings: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3>Progressive Web App</h3>
|
||||
<div className="setting-item">
|
||||
<h3 className="section-title">Progressive Web App</h3>
|
||||
<div className="setting-group">
|
||||
<div className="setting-info">
|
||||
<FontAwesomeIcon icon={faMobileAlt} style={{ marginRight: '8px' }} />
|
||||
<span>Install Boris as an app</span>
|
||||
</div>
|
||||
<p className="setting-description">
|
||||
<p className="setting-description" style={{ marginTop: '0.5rem', marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||
Install Boris on your device for a native app experience with offline support.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="install-button"
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
padding: '8px 16px',
|
||||
background: 'linear-gradient(135deg, #3b82f6 0%, #1e40af 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.3)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
className="zap-preset-btn"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} />
|
||||
Install App
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { Accounts } from 'applesauce-accounts'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import IconButton from './IconButton'
|
||||
import AddBookmarkModal from './AddBookmarkModal'
|
||||
import { createWebBookmark } from '../services/webBookmarkService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
onToggleCollapse: () => void
|
||||
onLogout: () => void
|
||||
onOpenSettings: () => void
|
||||
relayPool: RelayPool | null
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool, isMobile = false }) => {
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
@@ -54,14 +48,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
|
||||
}
|
||||
|
||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
throw new Error('Please login to create bookmarks')
|
||||
}
|
||||
|
||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
||||
}
|
||||
|
||||
const profileImage = getProfileImage()
|
||||
|
||||
return (
|
||||
@@ -124,15 +110,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
{activeAccount && (
|
||||
<IconButton
|
||||
icon={faPlus}
|
||||
onClick={() => setShowAddModal(true)}
|
||||
title="Add bookmark"
|
||||
ariaLabel="Add bookmark"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
{activeAccount ? (
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
@@ -152,12 +129,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showAddModal && (
|
||||
<AddBookmarkModal
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSave={handleSaveBookmark}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
42
src/components/Skeletons/BlogPostSkeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
80
src/components/Skeletons/BookmarkSkeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
66
src/components/Skeletons/ContentSkeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
36
src/components/Skeletons/HighlightSkeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
49
src/components/Skeletons/SkeletonThemeProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
6
src/components/Skeletons/index.ts
Normal 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'
|
||||
|
||||
235
src/components/Support.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHeart, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
|
||||
import { fetchProfiles } from '../services/profileService'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
interface SupportProps {
|
||||
relayPool: RelayPool
|
||||
eventStore: IEventStore
|
||||
settings: UserSettings
|
||||
}
|
||||
|
||||
type SupporterProfile = ZapSender
|
||||
|
||||
const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) => {
|
||||
const [supporters, setSupporters] = useState<SupporterProfile[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const loadSupporters = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const zappers = await fetchBorisZappers(relayPool)
|
||||
|
||||
if (zappers.length > 0) {
|
||||
const pubkeys = zappers.map(z => z.pubkey)
|
||||
await fetchProfiles(relayPool, eventStore, pubkeys, settings)
|
||||
}
|
||||
|
||||
setSupporters(zappers)
|
||||
} catch (error) {
|
||||
console.error('Failed to load supporters:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadSupporters()
|
||||
}, [relayPool, eventStore, settings])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" className="text-zinc-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text)' }}>
|
||||
<div className="max-w-5xl mx-auto px-4 py-12 md:py-16">
|
||||
<div className="text-center mb-16 md:mb-20">
|
||||
<div className="flex justify-center mb-8">
|
||||
<img
|
||||
src="/thank-you.svg"
|
||||
alt="Thank you"
|
||||
className="w-56 h-56 md:w-72 md:h-72 opacity-90"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4" style={{ color: 'var(--color-text)' }}>
|
||||
Thank You!
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl max-w-2xl mx-auto leading-relaxed" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Your{' '}
|
||||
<a
|
||||
href="https://www.readwithboris.com/#pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:no-underline"
|
||||
style={{ color: 'var(--color-primary)' }}
|
||||
>
|
||||
zaps
|
||||
</a>
|
||||
{' '}help keep this project alive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{supporters.length === 0 ? (
|
||||
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
|
||||
<p>No supporters yet. Be the first to zap Boris!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Whales Section */}
|
||||
{supporters.filter(s => s.isWhale).length > 0 && (
|
||||
<div className="mb-16 md:mb-20">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold mb-8 md:mb-10 text-center" style={{ color: 'var(--color-text)' }}>
|
||||
Legends
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-8 md:gap-10">
|
||||
{supporters.filter(s => s.isWhale).map(supporter => (
|
||||
<SupporterCard key={supporter.pubkey} supporter={supporter} isWhale={true} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Regular Supporters Section */}
|
||||
{supporters.filter(s => !s.isWhale).length > 0 && (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl md:text-2xl font-semibold mb-8 text-center" style={{ color: 'var(--color-text)' }}>
|
||||
Supporters
|
||||
</h2>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-4 md:gap-5">
|
||||
{supporters.filter(s => !s.isWhale).map(supporter => (
|
||||
<SupporterCard key={supporter.pubkey} supporter={supporter} isWhale={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-16 md:mt-20 pt-8 border-t" style={{ borderColor: 'var(--color-border-subtle)' }}>
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-base" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Zap{' '}
|
||||
<a
|
||||
href="https://njump.me/npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:no-underline"
|
||||
style={{ color: 'var(--color-primary)' }}
|
||||
>
|
||||
Boris
|
||||
</a>
|
||||
{' '}a{' '}
|
||||
<a
|
||||
href="https://www.readwithboris.com/#pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:no-underline"
|
||||
style={{ color: 'var(--color-primary)' }}
|
||||
>
|
||||
meaningful amount of sats
|
||||
</a>
|
||||
{' '}and your avatar will show above.
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Total supporters: {supporters.length} •
|
||||
Total zaps: {supporters.reduce((sum, s) => sum + s.zapCount, 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SupporterCardProps {
|
||||
supporter: SupporterProfile
|
||||
isWhale: boolean
|
||||
}
|
||||
|
||||
const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) => {
|
||||
const navigate = useNavigate()
|
||||
const profile = useEventModel(Models.ProfileModel, [supporter.pubkey])
|
||||
|
||||
const picture = profile?.picture
|
||||
const name = profile?.name || profile?.display_name || `${supporter.pubkey.slice(0, 8)}...`
|
||||
|
||||
const handleClick = () => {
|
||||
const npub = nip19.npubEncode(supporter.pubkey)
|
||||
navigate(`/p/${npub}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative">
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`rounded-full overflow-hidden flex items-center justify-center cursor-pointer transition-transform hover:scale-105
|
||||
${isWhale ? 'w-24 h-24 md:w-28 md:h-28 ring-4 ring-yellow-400' : 'w-10 h-10 md:w-12 md:h-12'}
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-elevated)'
|
||||
}}
|
||||
title={`${name} • ${supporter.totalSats.toLocaleString()} sats`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{picture ? (
|
||||
<img
|
||||
src={picture}
|
||||
alt={name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={faUserCircle}
|
||||
className={isWhale ? 'text-5xl' : 'text-3xl'}
|
||||
style={{ color: 'var(--color-border)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Whale Badge */}
|
||||
{isWhale && (
|
||||
<div
|
||||
className="absolute -bottom-1 -right-1 w-8 h-8 bg-yellow-400 rounded-full flex items-center justify-center border-2"
|
||||
style={{ borderColor: 'var(--color-bg)' }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHeart} className="text-zinc-900 text-sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name and Total */}
|
||||
<div className="mt-2 text-center">
|
||||
<p
|
||||
className={`font-medium truncate max-w-full ${isWhale ? 'text-sm' : 'text-xs'}`}
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
<p
|
||||
className={isWhale ? 'text-xs' : 'text-[10px]'}
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{supporter.totalSats.toLocaleString()} sats
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Support
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
@@ -32,6 +32,7 @@ interface ThreePaneLayoutProps {
|
||||
showExplore?: boolean
|
||||
showMe?: boolean
|
||||
showProfile?: boolean
|
||||
showSupport?: boolean
|
||||
|
||||
// Bookmarks pane
|
||||
bookmarks: Bookmark[]
|
||||
@@ -93,6 +94,9 @@ interface ThreePaneLayoutProps {
|
||||
|
||||
// Optional Profile content
|
||||
profile?: React.ReactNode
|
||||
|
||||
// Optional Support content
|
||||
support?: React.ReactNode
|
||||
}
|
||||
|
||||
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
@@ -101,13 +105,33 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
const highlightsRef = useRef<HTMLDivElement>(null)
|
||||
const mainPaneRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Detect scroll direction to hide/show mobile buttons
|
||||
// Now using window scroll (document scroll) instead of pane scroll
|
||||
// Detect scroll direction and position to hide/show mobile buttons
|
||||
// Only hide on scroll down when viewing article content
|
||||
const isViewingArticle = !!(props.selectedUrl)
|
||||
const scrollDirection = useScrollDirection({
|
||||
threshold: 10,
|
||||
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed
|
||||
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && isViewingArticle
|
||||
})
|
||||
const showMobileButtons = scrollDirection !== 'down'
|
||||
|
||||
// Track if we're at the top of the page
|
||||
const [isAtTop, setIsAtTop] = useState(true)
|
||||
useEffect(() => {
|
||||
if (!isMobile || !isViewingArticle) return
|
||||
|
||||
const handleScroll = () => {
|
||||
setIsAtTop(window.scrollY <= 10)
|
||||
}
|
||||
|
||||
handleScroll() // Check initial position
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [isMobile, isViewingArticle])
|
||||
|
||||
// Bookmark button: hide only when scrolling down
|
||||
const showBookmarkButton = scrollDirection !== 'down'
|
||||
// Highlights button: hide when scrolling down OR at the top
|
||||
const showHighlightsButton = scrollDirection !== 'down' && !isAtTop
|
||||
|
||||
// Lock body scroll when mobile sidebar or highlights is open
|
||||
useEffect(() => {
|
||||
@@ -225,11 +249,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile bookmark button - only show when viewing article (not on settings/explore/me/profile) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && (
|
||||
{/* Mobile bookmark button - always show except on settings page */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && (
|
||||
<button
|
||||
className={`fixed z-[900] bg-zinc-800/70 border border-zinc-600/40 rounded-lg text-zinc-200 flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
showBookmarkButton ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
}`}
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
@@ -245,11 +269,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile highlights button - only show when viewing article (not on settings/explore/me/profile) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && (
|
||||
{/* Mobile highlights button - only show when viewing article content */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && isViewingArticle && (
|
||||
<button
|
||||
className={`fixed z-[900] border border-zinc-600/40 rounded-lg flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
showHighlightsButton ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
}`}
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
@@ -299,8 +323,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
lastFetchTime={props.lastFetchTime}
|
||||
loading={props.bookmarksLoading}
|
||||
relayPool={props.relayPool}
|
||||
settings={props.settings}
|
||||
isMobile={isMobile}
|
||||
settings={props.settings}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -329,6 +353,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
<>
|
||||
{props.profile}
|
||||
</>
|
||||
) : props.showSupport && props.support ? (
|
||||
// Render Support inside the main pane to keep side panels
|
||||
<>
|
||||
{props.support}
|
||||
</>
|
||||
) : (
|
||||
<ContentPanel
|
||||
loading={props.readerLoading}
|
||||
@@ -394,7 +423,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
)}
|
||||
<RelayStatusIndicator
|
||||
relayPool={props.relayPool}
|
||||
showOnMobile={showMobileButtons}
|
||||
showOnMobile={showBookmarkButton}
|
||||
/>
|
||||
{props.toastMessage && (
|
||||
<Toast
|
||||
|
||||
12
src/config/network.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Centralized network configuration for relay queries
|
||||
// Keep timeouts modest for local-first, longer for remote; tweak per use-case
|
||||
|
||||
export const LOCAL_TIMEOUT_MS = 1200
|
||||
export const REMOTE_TIMEOUT_MS = 6000
|
||||
|
||||
// Contacts often need a bit more time on mobile networks
|
||||
export const CONTACTS_REMOTE_TIMEOUT_MS = 9000
|
||||
|
||||
// Future knobs could live here (e.g., max limits per kind)
|
||||
|
||||
|
||||
90
src/hooks/useAdaptiveTextColor.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
|
||||
interface AdaptiveTextColor {
|
||||
textColor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to determine optimal text color based on image background
|
||||
* Samples the top-right corner of the image to ensure publication date is readable
|
||||
*
|
||||
* @param imageUrl - The URL of the image to analyze
|
||||
* @returns Object containing textColor for optimal contrast
|
||||
*/
|
||||
export function useAdaptiveTextColor(imageUrl: string | undefined): AdaptiveTextColor {
|
||||
const [colors, setColors] = useState<AdaptiveTextColor>({
|
||||
textColor: '#ffffff'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageUrl) {
|
||||
// No image, use default white text
|
||||
setColors({
|
||||
textColor: '#ffffff'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const fac = new FastAverageColor()
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
const width = img.naturalWidth
|
||||
const height = img.naturalHeight
|
||||
|
||||
// Sample top-right corner (last 25% width, first 25% height)
|
||||
const color = fac.getColor(img, {
|
||||
left: Math.floor(width * 0.75),
|
||||
top: 0,
|
||||
width: Math.floor(width * 0.25),
|
||||
height: Math.floor(height * 0.25)
|
||||
})
|
||||
|
||||
console.log('Adaptive color detected:', {
|
||||
hex: color.hex,
|
||||
rgb: color.rgb,
|
||||
isLight: color.isLight,
|
||||
isDark: color.isDark
|
||||
})
|
||||
|
||||
// Use library's built-in isLight check for optimal contrast
|
||||
if (color.isLight) {
|
||||
console.log('Light background detected, using black text')
|
||||
setColors({
|
||||
textColor: '#000000'
|
||||
})
|
||||
} else {
|
||||
console.log('Dark background detected, using white text')
|
||||
setColors({
|
||||
textColor: '#ffffff'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback to default on error
|
||||
console.error('Error analyzing image color:', error)
|
||||
setColors({
|
||||
textColor: '#ffffff'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
// Fallback to default if image fails to load
|
||||
setColors({
|
||||
textColor: '#ffffff'
|
||||
})
|
||||
}
|
||||
|
||||
img.src = imageUrl
|
||||
|
||||
return () => {
|
||||
fac.destroy()
|
||||
}
|
||||
}, [imageUrl])
|
||||
|
||||
return colors
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount, AccountManager } from 'applesauce-accounts'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { fetchBookmarks } from '../services/bookmarkService'
|
||||
@@ -9,10 +10,8 @@ import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface UseBookmarksDataParams {
|
||||
relayPool: RelayPool | null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
activeAccount: any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
accountManager: any
|
||||
activeAccount: IAccount | undefined
|
||||
accountManager: AccountManager
|
||||
naddr?: string
|
||||
currentArticleCoordinate?: string
|
||||
currentArticleEventId?: string
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { HighlightVisibility } from '../components/HighlightsPanel'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
@@ -47,9 +47,9 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
|
||||
})
|
||||
}, [settings])
|
||||
|
||||
const toggleSidebar = () => {
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setIsSidebarOpen(prev => !prev)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { flushSync } from 'react-dom'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
import { createHighlight } from '../services/highlightCreationService'
|
||||
@@ -10,8 +11,7 @@ import { HighlightButtonRef } from '../components/HighlightButton'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface UseHighlightCreationParams {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
activeAccount: any
|
||||
activeAccount: IAccount | undefined
|
||||
relayPool: RelayPool | null
|
||||
eventStore: IEventStore | null
|
||||
currentArticle: NostrEvent | undefined
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
/**
|
||||
* Hook to return image URL for display
|
||||
* Service Worker handles all caching transparently
|
||||
@@ -9,9 +7,7 @@ import { UserSettings } from '../services/settingsService'
|
||||
* @returns The image URL (Service Worker handles caching)
|
||||
*/
|
||||
export function useImageCache(
|
||||
imageUrl: string | undefined,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
_settings?: UserSettings
|
||||
imageUrl: string | undefined
|
||||
): string | undefined {
|
||||
// Service Worker handles everything - just return the URL as-is
|
||||
return imageUrl
|
||||
@@ -22,9 +18,7 @@ export function useImageCache(
|
||||
* Triggers a fetch so the SW can cache it even if not visible yet
|
||||
*/
|
||||
export function useCacheImageOnLoad(
|
||||
imageUrl: string | undefined,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
_settings?: UserSettings
|
||||
imageUrl: string | undefined
|
||||
): void {
|
||||
// Service Worker will cache on first fetch
|
||||
// This hook is now a no-op, kept for API compatibility
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import { useEffect, useRef, useState, RefObject } from 'react'
|
||||
import { useIsCoarsePointer } from './useMediaQuery'
|
||||
|
||||
interface UsePullToRefreshOptions {
|
||||
onRefresh: () => void | Promise<void>
|
||||
isRefreshing?: boolean
|
||||
disabled?: boolean
|
||||
threshold?: number // Distance in pixels to trigger refresh
|
||||
resistance?: number // Resistance factor (higher = harder to pull)
|
||||
}
|
||||
|
||||
interface PullToRefreshState {
|
||||
isPulling: boolean
|
||||
pullDistance: number
|
||||
canRefresh: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to enable pull-to-refresh gesture on touch devices
|
||||
* @param containerRef - Ref to the scrollable container element
|
||||
* @param options - Configuration options
|
||||
* @returns State of the pull gesture
|
||||
*/
|
||||
export function usePullToRefresh(
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
options: UsePullToRefreshOptions
|
||||
): PullToRefreshState {
|
||||
const {
|
||||
onRefresh,
|
||||
isRefreshing = false,
|
||||
disabled = false,
|
||||
threshold = 80,
|
||||
resistance = 2.5
|
||||
} = options
|
||||
|
||||
const isTouch = useIsCoarsePointer()
|
||||
const [pullState, setPullState] = useState<PullToRefreshState>({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
|
||||
const touchStartY = useRef<number>(0)
|
||||
const startScrollTop = useRef<number>(0)
|
||||
const isDragging = useRef<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || !isTouch || disabled || isRefreshing) return
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
// Only start if scrolled to top
|
||||
const scrollTop = container.scrollTop
|
||||
if (scrollTop <= 0) {
|
||||
touchStartY.current = e.touches[0].clientY
|
||||
startScrollTop.current = scrollTop
|
||||
isDragging.current = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (!isDragging.current) return
|
||||
|
||||
const currentY = e.touches[0].clientY
|
||||
const deltaY = currentY - touchStartY.current
|
||||
const scrollTop = container.scrollTop
|
||||
|
||||
// Only pull down when at top and pulling down
|
||||
if (scrollTop <= 0 && deltaY > 0) {
|
||||
// Prevent default scroll behavior
|
||||
e.preventDefault()
|
||||
|
||||
// Apply resistance to make pulling feel natural
|
||||
const distance = Math.min(deltaY / resistance, threshold * 1.5)
|
||||
const canRefresh = distance >= threshold
|
||||
|
||||
setPullState({
|
||||
isPulling: true,
|
||||
pullDistance: distance,
|
||||
canRefresh
|
||||
})
|
||||
} else {
|
||||
// Reset if scrolled or pulling up
|
||||
isDragging.current = false
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = async () => {
|
||||
if (!isDragging.current) return
|
||||
|
||||
isDragging.current = false
|
||||
|
||||
if (pullState.canRefresh && !isRefreshing) {
|
||||
// Keep the indicator visible while refreshing
|
||||
setPullState(prev => ({
|
||||
...prev,
|
||||
isPulling: false
|
||||
}))
|
||||
|
||||
// Trigger refresh
|
||||
await onRefresh()
|
||||
}
|
||||
|
||||
// Reset state
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
|
||||
const handleTouchCancel = () => {
|
||||
isDragging.current = false
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
|
||||
// Add event listeners with passive: false to allow preventDefault
|
||||
container.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||
container.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
container.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||
container.addEventListener('touchcancel', handleTouchCancel, { passive: true })
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('touchstart', handleTouchStart)
|
||||
container.removeEventListener('touchmove', handleTouchMove)
|
||||
container.removeEventListener('touchend', handleTouchEnd)
|
||||
container.removeEventListener('touchcancel', handleTouchCancel)
|
||||
}
|
||||
}, [containerRef, isTouch, disabled, isRefreshing, threshold, resistance, onRefresh, pullState.canRefresh])
|
||||
|
||||
// Reset pull state when refresh completes
|
||||
useEffect(() => {
|
||||
if (!isRefreshing && pullState.isPulling) {
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
}, [isRefreshing, pullState.isPulling])
|
||||
|
||||
return pullState
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
const fullAccount = accountManager.getActive()
|
||||
if (!fullAccount) throw new Error('No active account')
|
||||
const factory = new EventFactory({ signer: fullAccount })
|
||||
await saveSettings(relayPool, eventStore, factory, newSettings, RELAYS)
|
||||
await saveSettings(relayPool, eventStore, factory, newSettings)
|
||||
setSettings(newSettings)
|
||||
setToastType('success')
|
||||
setToastMessage('Settings saved')
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -19,7 +19,7 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
|
||||
const webBookmarks = unique.filter(e => e.kind === 39701)
|
||||
|
||||
const bookmarkLists = unique
|
||||
.filter(e => e.kind === 10003 || e.kind === 30001)
|
||||
.filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001)
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd'))
|
||||
|
||||
|
||||
@@ -16,11 +16,24 @@ export interface BookmarkData {
|
||||
tags?: string[][]
|
||||
}
|
||||
|
||||
export interface AddressPointer {
|
||||
kind: number
|
||||
pubkey: string
|
||||
identifier: string
|
||||
relays?: string[]
|
||||
}
|
||||
|
||||
export interface EventPointer {
|
||||
id: string
|
||||
relays?: string[]
|
||||
author?: string
|
||||
}
|
||||
|
||||
export interface ApplesauceBookmarks {
|
||||
notes?: BookmarkData[]
|
||||
articles?: BookmarkData[]
|
||||
hashtags?: BookmarkData[]
|
||||
urls?: BookmarkData[]
|
||||
notes?: EventPointer[]
|
||||
articles?: AddressPointer[]
|
||||
hashtags?: string[]
|
||||
urls?: string[]
|
||||
}
|
||||
|
||||
export interface AccountWithExtension {
|
||||
@@ -55,25 +68,83 @@ export const processApplesauceBookmarks = (
|
||||
|
||||
if (typeof bookmarks === 'object' && bookmarks !== null && !Array.isArray(bookmarks)) {
|
||||
const applesauceBookmarks = bookmarks as ApplesauceBookmarks
|
||||
const allItems: BookmarkData[] = []
|
||||
if (applesauceBookmarks.notes) allItems.push(...applesauceBookmarks.notes)
|
||||
if (applesauceBookmarks.articles) allItems.push(...applesauceBookmarks.articles)
|
||||
if (applesauceBookmarks.hashtags) allItems.push(...applesauceBookmarks.hashtags)
|
||||
if (applesauceBookmarks.urls) allItems.push(...applesauceBookmarks.urls)
|
||||
const allItems: IndividualBookmark[] = []
|
||||
|
||||
// Process notes (EventPointer[])
|
||||
if (applesauceBookmarks.notes) {
|
||||
applesauceBookmarks.notes.forEach((note: EventPointer) => {
|
||||
allItems.push({
|
||||
id: note.id,
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: note.author || activeAccount.pubkey,
|
||||
kind: 1, // Short note kind
|
||||
tags: [],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Process articles (AddressPointer[])
|
||||
if (applesauceBookmarks.articles) {
|
||||
applesauceBookmarks.articles.forEach((article: AddressPointer) => {
|
||||
// Convert AddressPointer to coordinate format: kind:pubkey:identifier
|
||||
const coordinate = `${article.kind}:${article.pubkey}:${article.identifier || ''}`
|
||||
allItems.push({
|
||||
id: coordinate,
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: article.pubkey,
|
||||
kind: article.kind, // Usually 30023 for long-form articles
|
||||
tags: [],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Process hashtags (string[])
|
||||
if (applesauceBookmarks.hashtags) {
|
||||
applesauceBookmarks.hashtags.forEach((hashtag: string) => {
|
||||
allItems.push({
|
||||
id: `hashtag-${hashtag}`,
|
||||
content: `#${hashtag}`,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: 1,
|
||||
tags: [['t', hashtag]],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Process URLs (string[])
|
||||
if (applesauceBookmarks.urls) {
|
||||
applesauceBookmarks.urls.forEach((url: string) => {
|
||||
allItems.push({
|
||||
id: `url-${url}`,
|
||||
content: url,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: 1,
|
||||
tags: [['r', url]],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return allItems
|
||||
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
||||
.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id!,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: bookmark.kind || 30001,
|
||||
tags: bookmark.tags || [],
|
||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
}
|
||||
|
||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||
|
||||
@@ -33,6 +33,12 @@ export async function collectBookmarksFromEvents(
|
||||
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
||||
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
|
||||
|
||||
// Extract the 'd' tag and metadata for bookmark sets (kind 30003)
|
||||
const dTag = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] : undefined
|
||||
const setTitle = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'title')?.[1] : undefined
|
||||
const setDescription = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'description')?.[1] : undefined
|
||||
const setImage = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'image')?.[1] : undefined
|
||||
|
||||
// Handle web bookmarks (kind:39701) as individual bookmarks
|
||||
if (evt.kind === 39701) {
|
||||
publicItemsAll.push({
|
||||
@@ -45,13 +51,27 @@ export async function collectBookmarksFromEvents(
|
||||
parsedContent: undefined,
|
||||
type: 'web' as const,
|
||||
isPrivate: false,
|
||||
added_at: evt.created_at || Math.floor(Date.now() / 1000)
|
||||
added_at: evt.created_at || Math.floor(Date.now() / 1000),
|
||||
sourceKind: 39701,
|
||||
setName: dTag,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setImage
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const pub = Helpers.getPublicBookmarks(evt)
|
||||
publicItemsAll.push(...processApplesauceBookmarks(pub, activeAccount, false))
|
||||
publicItemsAll.push(
|
||||
...processApplesauceBookmarks(pub, activeAccount, false).map(i => ({
|
||||
...i,
|
||||
sourceKind: evt.kind,
|
||||
setName: dTag,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setImage
|
||||
}))
|
||||
)
|
||||
|
||||
try {
|
||||
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt) && signerCandidate) {
|
||||
@@ -94,7 +114,16 @@ export async function collectBookmarksFromEvents(
|
||||
try {
|
||||
const hiddenTags = JSON.parse(decryptedContent) as string[][]
|
||||
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
|
||||
privateItemsAll.push(...processApplesauceBookmarks(manualPrivate, activeAccount, true))
|
||||
privateItemsAll.push(
|
||||
...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({
|
||||
...i,
|
||||
sourceKind: evt.kind,
|
||||
setName: dTag,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setImage
|
||||
}))
|
||||
)
|
||||
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
||||
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
||||
// Don't set latestContent to decrypted JSON - it's not user-facing content
|
||||
@@ -106,7 +135,16 @@ export async function collectBookmarksFromEvents(
|
||||
|
||||
const priv = Helpers.getHiddenBookmarks(evt)
|
||||
if (priv) {
|
||||
privateItemsAll.push(...processApplesauceBookmarks(priv, activeAccount, true))
|
||||
privateItemsAll.push(
|
||||
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
|
||||
...i,
|
||||
sourceKind: evt.kind,
|
||||
setName: dTag,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setImage
|
||||
}))
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore individual event failures
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import {
|
||||
AccountWithExtension,
|
||||
NostrEvent,
|
||||
dedupeNip51Events,
|
||||
hydrateItems,
|
||||
isAccountWithExtension,
|
||||
isHexId,
|
||||
hasNip04Decrypt,
|
||||
hasNip44Decrypt,
|
||||
dedupeBookmarksById,
|
||||
@@ -16,7 +14,7 @@ import { Bookmark } from '../types/bookmarks'
|
||||
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
|
||||
import { UserSettings } from './settingsService'
|
||||
import { rebroadcastEvents } from './rebroadcastService'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { queryEvents } from './dataFetch'
|
||||
|
||||
|
||||
|
||||
@@ -31,23 +29,14 @@ export const fetchBookmarks = async (
|
||||
if (!isAccountWithExtension(activeAccount)) {
|
||||
throw new Error('Invalid account object provided')
|
||||
}
|
||||
// Get relay URLs from the pool
|
||||
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(relayUrls)
|
||||
// Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0)
|
||||
console.log('🔍 Fetching bookmark events from relays:', relayUrls)
|
||||
// Try local-first quickly, then full set fallback
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(1200)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(6000)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const rawEvents = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
console.log('🔍 Fetching bookmark events')
|
||||
|
||||
const rawEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] },
|
||||
{}
|
||||
)
|
||||
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
||||
|
||||
// Rebroadcast bookmark events to local/all relays based on settings
|
||||
@@ -67,11 +56,28 @@ export const fetchBookmarks = async (
|
||||
rawEvents.forEach((evt, i) => {
|
||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
||||
const contentPreview = evt.content ? evt.content.slice(0, 50) + (evt.content.length > 50 ? '...' : '') : 'empty'
|
||||
console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, contentPreview=${contentPreview}`)
|
||||
const eTags = evt.tags?.filter((t: string[]) => t[0] === 'e').length || 0
|
||||
const aTags = evt.tags?.filter((t: string[]) => t[0] === 'a').length || 0
|
||||
console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, eTags=${eTags}, aTags=${aTags}, contentPreview=${contentPreview}`)
|
||||
})
|
||||
|
||||
const bookmarkListEvents = dedupeNip51Events(rawEvents)
|
||||
console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events')
|
||||
|
||||
// Log which events made it through deduplication
|
||||
bookmarkListEvents.forEach((evt, i) => {
|
||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
||||
console.log(` Dedupe ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag="${dTag}"`)
|
||||
})
|
||||
|
||||
// Check specifically for Primal's "reads" list
|
||||
const primalReads = rawEvents.find(e => e.kind === 10003 && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads'))
|
||||
if (primalReads) {
|
||||
console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8))
|
||||
} else {
|
||||
console.log('❌ No Primal reads list found (kind:10003 with d="reads")')
|
||||
}
|
||||
|
||||
if (bookmarkListEvents.length === 0) {
|
||||
// Keep existing bookmarks visible; do not clear list if nothing new found
|
||||
return
|
||||
@@ -107,23 +113,88 @@ export const fetchBookmarks = async (
|
||||
)
|
||||
|
||||
const allItems = [...publicItemsAll, ...privateItemsAll]
|
||||
const noteIds = Array.from(new Set(allItems.map(i => i.id).filter(isHexId)))
|
||||
let idToEvent: Map<string, NostrEvent> = new Map()
|
||||
|
||||
// Separate hex IDs (regular events) from coordinates (addressable events)
|
||||
const noteIds: string[] = []
|
||||
const coordinates: string[] = []
|
||||
|
||||
allItems.forEach(i => {
|
||||
// Check if it's a hex ID (64 character hex string)
|
||||
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
||||
noteIds.push(i.id)
|
||||
} else if (i.id.includes(':')) {
|
||||
// Coordinate format: kind:pubkey:identifier
|
||||
coordinates.push(i.id)
|
||||
}
|
||||
})
|
||||
|
||||
const idToEvent: Map<string, NostrEvent> = new Map()
|
||||
|
||||
// Fetch regular events by ID
|
||||
if (noteIds.length > 0) {
|
||||
try {
|
||||
const { local: localHydrate, remote: remoteHydrate } = partitionRelays(relayUrls)
|
||||
const localHydrate$ = localHydrate.length > 0
|
||||
? relayPool.req(localHydrate, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(800)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const remoteHydrate$ = remoteHydrate.length > 0
|
||||
? relayPool.req(remoteHydrate, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(2500)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const events: NostrEvent[] = await lastValueFrom(merge(localHydrate$, remoteHydrate$).pipe(toArray()))
|
||||
idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e]))
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
{ ids: Array.from(new Set(noteIds)) },
|
||||
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
|
||||
)
|
||||
events.forEach((e: NostrEvent) => {
|
||||
idToEvent.set(e.id, e)
|
||||
// Also store by coordinate if it's an addressable event
|
||||
if (e.kind && e.kind >= 30000 && e.kind < 40000) {
|
||||
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, e)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch events for hydration:', error)
|
||||
console.warn('Failed to fetch events by ID:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch addressable events by coordinates
|
||||
if (coordinates.length > 0) {
|
||||
try {
|
||||
// Group by kind for more efficient querying
|
||||
const byKind = new Map<number, Array<{ pubkey: string; identifier: string }>>()
|
||||
|
||||
coordinates.forEach(coord => {
|
||||
const parts = coord.split(':')
|
||||
const kind = parseInt(parts[0])
|
||||
const pubkey = parts[1]
|
||||
const identifier = parts[2] || ''
|
||||
|
||||
if (!byKind.has(kind)) {
|
||||
byKind.set(kind, [])
|
||||
}
|
||||
byKind.get(kind)!.push({ pubkey, identifier })
|
||||
})
|
||||
|
||||
// Query each kind group
|
||||
for (const [kind, items] of byKind.entries()) {
|
||||
const authors = Array.from(new Set(items.map(i => i.pubkey)))
|
||||
const identifiers = Array.from(new Set(items.map(i => i.identifier)))
|
||||
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [kind], authors, '#d': identifiers },
|
||||
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
|
||||
)
|
||||
|
||||
events.forEach((e: NostrEvent) => {
|
||||
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, e)
|
||||
// Also store by event ID
|
||||
idToEvent.set(e.id, e)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch addressable events:', error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📦 Hydration: fetched ${idToEvent.size} events for ${allItems.length} bookmarks (${noteIds.length} notes, ${coordinates.length} articles)`)
|
||||
const allBookmarks = dedupeBookmarksById([
|
||||
...hydrateItems(publicItemsAll, idToEvent),
|
||||
...hydrateItems(privateItemsAll, idToEvent)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { prioritizeLocalRelays } from '../utils/helpers'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { CONTACTS_REMOTE_TIMEOUT_MS } from '../config/network'
|
||||
|
||||
/**
|
||||
* Fetches the contact list (follows) for a specific user
|
||||
@@ -15,24 +16,27 @@ export const fetchContacts = async (
|
||||
): Promise<Set<string>> => {
|
||||
try {
|
||||
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
|
||||
|
||||
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
|
||||
|
||||
// Local-first quick attempt
|
||||
const localRelays = relayUrls.filter(url => url.includes('localhost') || url.includes('127.0.0.1'))
|
||||
const remoteRelays = relayUrls.filter(url => !url.includes('localhost') && !url.includes('127.0.0.1'))
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [3], authors: [pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(1200)))
|
||||
: new Observable<{ created_at: number; tags: string[][] }>((sub) => sub.complete())
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [3], authors: [pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(6000)))
|
||||
: new Observable<{ created_at: number; tags: string[][] }>((sub) => sub.complete())
|
||||
const events = await lastValueFrom(
|
||||
merge(local$, remote$).pipe(toArray())
|
||||
|
||||
const partialFollowed = new Set<string>()
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [3], authors: [pubkey] },
|
||||
{
|
||||
relayUrls,
|
||||
remoteTimeoutMs: CONTACTS_REMOTE_TIMEOUT_MS,
|
||||
onEvent: (event: { created_at: number; tags: string[][] }) => {
|
||||
// Stream partials as we see any contact list
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === 'p' && tag[1]) {
|
||||
partialFollowed.add(tag[1])
|
||||
}
|
||||
}
|
||||
if (onPartial && partialFollowed.size > 0) {
|
||||
onPartial(new Set(partialFollowed))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
const followed = new Set<string>()
|
||||
if (events.length > 0) {
|
||||
|
||||
70
src/services/dataFetch.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { Observable, merge, takeUntil, timer, toArray, tap, lastValueFrom } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Filter } from 'nostr-tools/filter'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { LOCAL_TIMEOUT_MS, REMOTE_TIMEOUT_MS } from '../config/network'
|
||||
|
||||
export interface QueryOptions {
|
||||
relayUrls?: string[]
|
||||
localTimeoutMs?: number
|
||||
remoteTimeoutMs?: number
|
||||
onEvent?: (event: NostrEvent) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified local-first query helper with optional streaming callback.
|
||||
* Returns all collected events (deduped by id) after both streams complete or time out.
|
||||
*/
|
||||
export async function queryEvents(
|
||||
relayPool: RelayPool,
|
||||
filter: Filter,
|
||||
options: QueryOptions = {}
|
||||
): Promise<NostrEvent[]> {
|
||||
const {
|
||||
relayUrls,
|
||||
localTimeoutMs = LOCAL_TIMEOUT_MS,
|
||||
remoteTimeoutMs = REMOTE_TIMEOUT_MS,
|
||||
onEvent
|
||||
} = options
|
||||
|
||||
const urls = relayUrls && relayUrls.length > 0
|
||||
? relayUrls
|
||||
: Array.from(relayPool.relays.values()).map(r => r.url)
|
||||
|
||||
const ordered = prioritizeLocalRelays(urls)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered)
|
||||
|
||||
const local$: Observable<NostrEvent> = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(localTimeoutMs))
|
||||
) as unknown as Observable<NostrEvent>
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const remote$: Observable<NostrEvent> = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(remoteTimeoutMs))
|
||||
) as unknown as Observable<NostrEvent>
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const events = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
|
||||
// Deduplicate by id (callers can perform higher-level replaceable grouping if needed)
|
||||
const byId = new Map<string, NostrEvent>()
|
||||
for (const ev of events) {
|
||||
if (!byId.has(ev.id)) byId.set(ev.id, ev)
|
||||
}
|
||||
return Array.from(byId.values())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { queryEvents } from './dataFetch'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
@@ -35,49 +34,38 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
}
|
||||
|
||||
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
|
||||
|
||||
const prioritized = prioritizeLocalRelays(relayUrls)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
|
||||
|
||||
// Deduplicate replaceable events by keeping the most recent version
|
||||
// Group by author + d-tag identifier
|
||||
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)
|
||||
// Emit as we incorporate
|
||||
if (onPost) {
|
||||
const post: BlogPostPreview = {
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [30023], authors: pubkeys, limit: 100 },
|
||||
{
|
||||
relayUrls,
|
||||
onEvent: (event: NostrEvent) => {
|
||||
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)
|
||||
// Emit as we incorporate
|
||||
if (onPost) {
|
||||
const post: BlogPostPreview = {
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}
|
||||
onPost(post)
|
||||
}
|
||||
onPost(post)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [30023], authors: pubkeys, limit: 100 })
|
||||
.pipe(completeOnEose(), takeUntil(timer(1200)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [30023], authors: pubkeys, limit: 100 })
|
||||
.pipe(completeOnEose(), takeUntil(timer(6000)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const events = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
processEvents(events)
|
||||
)
|
||||
|
||||
console.log('📊 Blog post events fetched (unique):', uniqueEvents.size)
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ import { Helpers, IEventStore } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { UserSettings } from './settingsService'
|
||||
import { areAllRelaysLocal } from '../utils/helpers'
|
||||
import { markEventAsOfflineCreated } from './offlineSyncService'
|
||||
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
|
||||
import { publishEvent } from './writeService'
|
||||
|
||||
// Boris pubkey for zap splits
|
||||
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
|
||||
const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
|
||||
export const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
|
||||
|
||||
const {
|
||||
getHighlightText,
|
||||
@@ -118,59 +118,26 @@ export async function createHighlight(
|
||||
// Sign the event
|
||||
const signedEvent = await factory.sign(highlightEvent)
|
||||
|
||||
// Publish to all configured relays - let the relay pool handle connection state
|
||||
const targetRelays = RELAYS
|
||||
|
||||
// Store the event in the local EventStore FIRST for immediate UI display
|
||||
eventStore.add(signedEvent)
|
||||
console.log('💾 Stored highlight in EventStore:', signedEvent.id.slice(0, 8))
|
||||
|
||||
// Check current connection status - are we online or in flight mode?
|
||||
// Use unified write service to store and publish
|
||||
await publishEvent(relayPool, eventStore, signedEvent)
|
||||
|
||||
// Check current connection status for UI feedback
|
||||
const connectedRelays = Array.from(relayPool.relays.values())
|
||||
.filter(relay => relay.connected)
|
||||
.map(relay => relay.url)
|
||||
|
||||
const hasRemoteConnection = connectedRelays.some(url =>
|
||||
!url.includes('localhost') && !url.includes('127.0.0.1')
|
||||
)
|
||||
|
||||
// Determine which relays we expect to succeed
|
||||
const expectedSuccessRelays = hasRemoteConnection
|
||||
? RELAYS
|
||||
: RELAYS.filter(r => r.includes('localhost') || r.includes('127.0.0.1'))
|
||||
|
||||
|
||||
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
|
||||
const expectedSuccessRelays = hasRemoteConnection
|
||||
? RELAYS
|
||||
: RELAYS.filter(isLocalRelay)
|
||||
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
|
||||
|
||||
console.log('📍 Highlight relay status:', {
|
||||
targetRelays: targetRelays.length,
|
||||
expectedSuccessRelays,
|
||||
isLocalOnly,
|
||||
hasRemoteConnection,
|
||||
eventId: signedEvent.id
|
||||
})
|
||||
|
||||
// If we're in local-only mode, mark this event for later sync
|
||||
if (isLocalOnly) {
|
||||
markEventAsOfflineCreated(signedEvent.id)
|
||||
}
|
||||
|
||||
|
||||
// Convert to Highlight with relay tracking info and return IMMEDIATELY
|
||||
const highlight = eventToHighlight(signedEvent)
|
||||
highlight.publishedRelays = expectedSuccessRelays // Show only relays we expect to succeed
|
||||
highlight.publishedRelays = expectedSuccessRelays
|
||||
highlight.isLocalOnly = isLocalOnly
|
||||
highlight.isOfflineCreated = isLocalOnly // Mark as created offline if local-only
|
||||
|
||||
// Publish to relays in the background (non-blocking)
|
||||
// This allows instant UI updates while publishing happens asynchronously
|
||||
relayPool.publish(targetRelays, signedEvent)
|
||||
.then(() => {
|
||||
console.log('✅ Highlight published to', targetRelays.length, 'relay(s):', targetRelays)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('⚠️ Failed to publish highlight to relays (event still saved locally):', error)
|
||||
})
|
||||
|
||||
// Return the highlight immediately for instant UI updates
|
||||
highlight.isOfflineCreated = isLocalOnly
|
||||
|
||||
return highlight
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from './highlights/fetchForArticle'
|
||||
export * from './highlights/fetchForUrl'
|
||||
export * from './highlights/fetchByAuthor'
|
||||
|
||||
export * from './highlights/fetchFromAuthors'
|
||||
|
||||
|
||||
52
src/services/highlights/fetchFromAuthors.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Highlight } from '../../types/highlights'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||
import { queryEvents } from '../dataFetch'
|
||||
|
||||
/**
|
||||
* 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 seenIds = new Set<string>()
|
||||
const rawEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [9802], authors: pubkeys, limit: 200 },
|
||||
{
|
||||
onEvent: (event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { MARK_AS_READ_EMOJI } from './reactionService'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
import { queryEvents } from './dataFetch'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
@@ -28,58 +27,11 @@ export async function fetchReadArticles(
|
||||
userPubkey: string
|
||||
): Promise<ReadArticle[]> {
|
||||
try {
|
||||
const orderedRelays = prioritizeLocalRelays(RELAYS)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||
|
||||
// Fetch kind:7 reactions (nostr-native articles)
|
||||
const kind7Local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [7], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind7Remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [7], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind7Events: NostrEvent[] = await lastValueFrom(
|
||||
merge(kind7Local$, kind7Remote$).pipe(toArray())
|
||||
)
|
||||
|
||||
// Fetch kind:17 reactions (external URLs)
|
||||
const kind17Local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [17], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind17Remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [17], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind17Events: NostrEvent[] = await lastValueFrom(
|
||||
merge(kind17Local$, kind17Remote$).pipe(toArray())
|
||||
)
|
||||
// Fetch kind:7 and kind:17 reactions in parallel
|
||||
const [kind7Events, kind17Events] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [7], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||
queryEvents(relayPool, { kinds: [17], authors: [userPubkey] }, { relayUrls: RELAYS })
|
||||
])
|
||||
|
||||
const readArticles: ReadArticle[] = []
|
||||
|
||||
@@ -157,34 +109,13 @@ export async function fetchReadArticlesWithData(
|
||||
return []
|
||||
}
|
||||
|
||||
const orderedRelays = prioritizeLocalRelays(RELAYS)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||
|
||||
// Fetch the actual article events
|
||||
const eventIds = nostrArticles.map(a => a.eventId!).filter(Boolean)
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [30023], ids: eventIds })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [30023], ids: eventIds })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const articleEvents: NostrEvent[] = await lastValueFrom(
|
||||
merge(local$, remote$).pipe(toArray())
|
||||
const articleEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [30023], ids: eventIds },
|
||||
{ relayUrls: RELAYS }
|
||||
)
|
||||
|
||||
// Deduplicate article events by ID
|
||||
|
||||
102
src/services/nostrverseService.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
|
||||
import { queryEvents } from './dataFetch'
|
||||
|
||||
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)
|
||||
|
||||
// Deduplicate replaceable events by keeping the most recent version
|
||||
const uniqueEvents = new Map<string, NostrEvent>()
|
||||
|
||||
await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [30023], limit },
|
||||
{
|
||||
relayUrls,
|
||||
onEvent: (event: NostrEvent) => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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 rawEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [9802], limit },
|
||||
{}
|
||||
)
|
||||
|
||||
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 []
|
||||
}
|
||||
}
|
||||
|
||||
81
src/services/profileService.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EventFactory } from 'applesauce-factory'
|
||||
import { RelayPool, onlyEvents } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { firstValueFrom } from 'rxjs'
|
||||
import { publishEvent } from './writeService'
|
||||
|
||||
const SETTINGS_IDENTIFIER = 'com.dergigi.boris.user-settings'
|
||||
const APP_DATA_KIND = 30078 // NIP-78 Application Data
|
||||
@@ -147,11 +148,10 @@ export async function saveSettings(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
factory: EventFactory,
|
||||
settings: UserSettings,
|
||||
relays: string[]
|
||||
settings: UserSettings
|
||||
): Promise<void> {
|
||||
console.log('💾 Saving settings to nostr:', settings)
|
||||
|
||||
|
||||
// Create NIP-78 application data event manually
|
||||
// Note: AppDataBlueprint is not available in the npm package
|
||||
const draft = await factory.create(async () => ({
|
||||
@@ -160,14 +160,12 @@ export async function saveSettings(
|
||||
tags: [['d', SETTINGS_IDENTIFIER]],
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
|
||||
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
console.log('📤 Publishing settings event:', signed.id, 'to', relays.length, 'relays')
|
||||
|
||||
eventStore.add(signed)
|
||||
await relayPool.publish(relays, signed)
|
||||
|
||||
|
||||
// Use unified write service
|
||||
await publishEvent(relayPool, eventStore, signed)
|
||||
|
||||
console.log('✅ Settings published successfully')
|
||||
}
|
||||
|
||||
|
||||
57
src/services/writeService.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
|
||||
import { markEventAsOfflineCreated } from './offlineSyncService'
|
||||
|
||||
/**
|
||||
* Unified write helper: add event to EventStore, detect connectivity,
|
||||
* mark for offline sync if needed, and publish in background.
|
||||
*/
|
||||
export async function publishEvent(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
event: NostrEvent
|
||||
): Promise<void> {
|
||||
// Store the event in the local EventStore FIRST for immediate UI display
|
||||
eventStore.add(event)
|
||||
console.log('💾 Stored event in EventStore:', event.id.slice(0, 8), `(kind ${event.kind})`)
|
||||
|
||||
// Check current connection status - are we online or in flight mode?
|
||||
const connectedRelays = Array.from(relayPool.relays.values())
|
||||
.filter(relay => relay.connected)
|
||||
.map(relay => relay.url)
|
||||
|
||||
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
|
||||
|
||||
// Determine which relays we expect to succeed
|
||||
const expectedSuccessRelays = hasRemoteConnection
|
||||
? RELAYS
|
||||
: RELAYS.filter(isLocalRelay)
|
||||
|
||||
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
|
||||
|
||||
console.log('📍 Event relay status:', {
|
||||
targetRelays: RELAYS.length,
|
||||
expectedSuccessRelays: expectedSuccessRelays.length,
|
||||
isLocalOnly,
|
||||
hasRemoteConnection,
|
||||
eventId: event.id.slice(0, 8)
|
||||
})
|
||||
|
||||
// If we're in local-only mode, mark this event for later sync
|
||||
if (isLocalOnly) {
|
||||
markEventAsOfflineCreated(event.id)
|
||||
}
|
||||
|
||||
// Publish to all configured relays in the background (non-blocking)
|
||||
relayPool.publish(RELAYS, event)
|
||||
.then(() => {
|
||||
console.log('✅ Event published to', RELAYS.length, 'relay(s):', event.id.slice(0, 8))
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('⚠️ Failed to publish event to relays (event still saved locally):', error)
|
||||
})
|
||||
}
|
||||
|
||||
127
src/services/zapReceiptService.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { isValidZap, getZapSender, getZapAmount } from 'applesauce-core/helpers'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { BORIS_PUBKEY } from './highlightCreationService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
export interface ZapSender {
|
||||
pubkey: string
|
||||
totalSats: number
|
||||
zapCount: number
|
||||
isWhale: boolean // >= 69420 sats
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches zap receipts (kind:9735) for Boris and aggregates by sender
|
||||
* @param relayPool - The relay pool to query
|
||||
* @returns Array of senders who zapped >= 2100 sats, sorted by total desc
|
||||
*/
|
||||
export async function fetchBorisZappers(
|
||||
relayPool: RelayPool
|
||||
): Promise<ZapSender[]> {
|
||||
try {
|
||||
console.log('⚡ Fetching zap receipts for Boris...', BORIS_PUBKEY)
|
||||
|
||||
// Use all configured relays plus specific zap-heavy relays
|
||||
const zapRelays = [
|
||||
...RELAYS,
|
||||
'wss://nostr.mutinywallet.com', // Common zap relay
|
||||
'wss://relay.getalby.com/v1', // Alby zap relay
|
||||
]
|
||||
const prioritized = prioritizeLocalRelays(zapRelays)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
|
||||
|
||||
// Fetch zap receipts with Boris as recipient
|
||||
const filter = {
|
||||
kinds: [9735],
|
||||
'#p': [BORIS_PUBKEY]
|
||||
}
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const zapReceipts = await lastValueFrom(
|
||||
merge(local$, remote$).pipe(toArray())
|
||||
)
|
||||
|
||||
console.log(`📊 Fetched ${zapReceipts.length} raw zap receipts`)
|
||||
|
||||
// Dedupe by event ID and validate
|
||||
const uniqueReceipts = new Map<string, NostrEvent>()
|
||||
let invalidCount = 0
|
||||
|
||||
zapReceipts.forEach(receipt => {
|
||||
if (!uniqueReceipts.has(receipt.id)) {
|
||||
if (isValidZap(receipt)) {
|
||||
uniqueReceipts.set(receipt.id, receipt)
|
||||
} else {
|
||||
invalidCount++
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`✅ ${uniqueReceipts.size} valid zap receipts (${invalidCount} invalid)`)
|
||||
|
||||
// Aggregate by sender using applesauce helpers
|
||||
const senderTotals = new Map<string, { totalSats: number; zapCount: number }>()
|
||||
|
||||
for (const receipt of uniqueReceipts.values()) {
|
||||
const senderPubkey = getZapSender(receipt)
|
||||
const amountMsats = getZapAmount(receipt)
|
||||
|
||||
if (!senderPubkey || !amountMsats || amountMsats === 0) {
|
||||
console.warn('Invalid zap receipt - missing sender or amount:', receipt.id)
|
||||
continue
|
||||
}
|
||||
|
||||
const amountSats = Math.floor(amountMsats / 1000)
|
||||
|
||||
const existing = senderTotals.get(senderPubkey) || { totalSats: 0, zapCount: 0 }
|
||||
senderTotals.set(senderPubkey, {
|
||||
totalSats: existing.totalSats + amountSats,
|
||||
zapCount: existing.zapCount + 1
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`👥 Found ${senderTotals.size} unique senders`)
|
||||
|
||||
// Filter >= 2100 sats, mark whales >= 69420 sats, sort by total desc
|
||||
const zappers: ZapSender[] = Array.from(senderTotals.entries())
|
||||
.filter(([, data]) => data.totalSats >= 2100)
|
||||
.map(([pubkey, data]) => ({
|
||||
pubkey,
|
||||
totalSats: data.totalSats,
|
||||
zapCount: data.zapCount,
|
||||
isWhale: data.totalSats >= 69420
|
||||
}))
|
||||
.sort((a, b) => b.totalSats - a.totalSats)
|
||||
|
||||
console.log(`✅ Found ${zappers.length} supporters (${zappers.filter(z => z.isWhale).length} whales)`)
|
||||
|
||||
return zappers
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch zap receipts:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,27 @@
|
||||
.bookmark-content { color: var(--color-text); margin: 0.5rem 0; line-height: 1.4; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
|
||||
.bookmark-meta { color: var(--color-text-secondary); font-size: 0.9rem; margin-top: 0.5rem; }
|
||||
|
||||
.bookmarks-section-title {
|
||||
font-size: 0.75rem !important;
|
||||
font-weight: 700 !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.05em !important;
|
||||
color: var(--color-text-muted) !important;
|
||||
padding: 1.5rem 0.5rem 0.375rem !important;
|
||||
margin: 0 !important;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.bookmarks-section:first-of-type .bookmarks-section-title {
|
||||
border-top: none;
|
||||
padding-top: 0.5rem !important;
|
||||
}
|
||||
.bookmark-section-action {
|
||||
padding: 1.5rem 0.5rem 0.375rem;
|
||||
}
|
||||
.bookmarks-section:first-of-type .bookmark-section-action {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.individual-bookmarks { margin: 1rem 0; }
|
||||
.individual-bookmarks h4 { margin: 0 0 1rem 0; font-size: 1rem; color: var(--color-text); }
|
||||
|
||||
@@ -23,8 +44,8 @@
|
||||
.individual-bookmark:hover { border-color: var(--color-border); background: var(--color-bg-elevated); }
|
||||
|
||||
/* Compact view */
|
||||
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none; border-bottom: 1px solid var(--color-bg-elevated); border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
|
||||
.individual-bookmark.compact:hover { background: var(--color-bg-elevated); border-bottom-color: var(--color-border); transform: none; box-shadow: none; }
|
||||
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none !important; border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
|
||||
.individual-bookmark.compact:hover { background: var(--color-bg-elevated); transform: none; box-shadow: none; border: none !important; }
|
||||
.compact-row { display: flex; align-items: center; gap: 0.5rem; height: 28px; width: 100%; min-width: 0; overflow: hidden; }
|
||||
.compact-thumbnail { width: 24px; height: 24px; flex-shrink: 0; border-radius: 4px; overflow: hidden; background: var(--color-bg-elevated); display: flex; align-items: center; justify-content: center; }
|
||||
.compact-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
|
||||
@@ -57,10 +78,10 @@
|
||||
|
||||
/* Large preview view */
|
||||
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--color-bg-elevated); }
|
||||
.large-preview-image { width: 100%; height: 180px; background: var(--color-bg); background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid var(--color-border); position: relative; }
|
||||
.large-preview-image { width: 100%; height: 180px; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 50%, var(--color-bg-elevated) 100%); background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid var(--color-border); position: relative; }
|
||||
.large-preview-image:hover { opacity: 0.9; }
|
||||
.large-preview-image::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.3) 100%); pointer-events: none; }
|
||||
.preview-placeholder { font-size: 3rem; color: var(--color-border-subtle); }
|
||||
.preview-placeholder { font-size: 3rem; color: var(--color-border-subtle); opacity: 0.4; }
|
||||
.large-content { padding: 1.25rem; }
|
||||
.large-text { color: var(--color-text); font-size: 0.95rem; line-height: 1.6; margin-bottom: 1rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.large-footer { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; font-size: 0.8rem; color: var(--color-text-secondary); padding-top: 0.75rem; border-top: 1px solid var(--color-border); }
|
||||
@@ -73,6 +94,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 */ }
|
||||
@@ -80,10 +102,13 @@
|
||||
.explore-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 2rem; margin-top: 2rem; }
|
||||
.blog-post-card { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; overflow: hidden; transition: all 0.3s ease; cursor: pointer; display: flex; flex-direction: column; height: 100%; }
|
||||
.blog-post-card:hover { border-color: var(--color-primary); transform: translateY(-4px); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); }
|
||||
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: var(--color-bg-subtle); display: flex; align-items: center; justify-content: center; }
|
||||
.blog-post-card.level-mine { border-color: color-mix(in srgb, var(--highlight-color-mine, #fde047) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #fde047) 25%, transparent); }
|
||||
.blog-post-card.level-friends { border-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent); }
|
||||
.blog-post-card.level-nostrverse { border-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent); }
|
||||
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 50%, var(--color-bg-elevated) 100%); display: flex; align-items: center; justify-content: center; position: relative; }
|
||||
.blog-post-card-image img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.3s ease; }
|
||||
.blog-post-card:hover .blog-post-card-image img { transform: scale(1.05); }
|
||||
.blog-post-image-placeholder { font-size: 3rem; color: var(--color-border-subtle); display: flex; align-items: center; justify-content: center; }
|
||||
.blog-post-image-placeholder { font-size: 3rem; color: var(--color-border-subtle); opacity: 0.4; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
|
||||
.blog-post-card-content { padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; flex: 1; }
|
||||
.blog-post-card-title { font-size: 1.25rem; font-weight: 600; margin: 0; color: var(--color-text); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.blog-post-card-summary { font-size: 0.875rem; color: var(--color-text-secondary); margin: 0; line-height: 1.6; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; flex: 1; }
|
||||
|
||||
@@ -30,10 +30,10 @@
|
||||
.reader-meta { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.publish-date { display: flex; align-items: center; gap: 0.4rem; font-size: 0.813rem; color: var(--color-text-muted); opacity: 0.85; }
|
||||
.publish-date svg { font-size: 0.75rem; opacity: 0.6; }
|
||||
.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; }
|
||||
.publish-date-topright { position: absolute; top: 1rem; right: 1rem; font-size: 0.813rem; color: var(--color-text); padding: 0.4rem 0.75rem; 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); }
|
||||
@@ -128,6 +128,13 @@
|
||||
.reader-markdown blockquote p, .reader-html blockquote p { margin: 0.5rem 0; }
|
||||
.reader-markdown blockquote p:first-child, .reader-html blockquote p:first-child { margin-top: 0; }
|
||||
.reader-markdown blockquote p:last-child, .reader-html blockquote p:last-child { margin-bottom: 0; }
|
||||
/* Horizontal rule - subtle divider */
|
||||
.reader-markdown hr, .reader-html hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
opacity: 0.69;
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
.reader-markdown a { color: var(--color-primary); text-decoration: none; }
|
||||
.reader-markdown a:hover { text-decoration: underline; }
|
||||
.reader-markdown code { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||
@@ -214,8 +221,9 @@
|
||||
.article-hero-image { width: 100%; height: 200px; background-size: cover; background-position: center; background-repeat: no-repeat; cursor: pointer; transition: all 0.2s ease; border-radius: 8px 8px 0 0; position: relative; }
|
||||
.article-hero-image:hover { opacity: 0.9; }
|
||||
.article-hero-image::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.4) 100%); pointer-events: none; border-radius: 8px 8px 0 0; }
|
||||
.reader-hero-image { width: calc(100% + 1.5rem); margin: -0.75rem -0.75rem 2rem -0.75rem; border-radius: 0; overflow: hidden; position: relative; min-height: 300px; }
|
||||
.reader-hero-image { width: calc(100% + 1.5rem); margin: -0.75rem -0.75rem 2rem -0.75rem; border-radius: 0; overflow: hidden; position: relative; min-height: 300px; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 25%, var(--color-bg-elevated) 50%, var(--color-bg-subtle) 75%, var(--color-bg-elevated) 100%); }
|
||||
.reader-hero-image img { width: 100%; height: auto; max-height: 500px; object-fit: cover; display: block; }
|
||||
.reader-hero-placeholder { width: 100%; height: 300px; display: flex; align-items: center; justify-content: center; font-size: 4rem; color: var(--color-border-subtle); opacity: 0.3; }
|
||||
.reader-header-overlay { position: absolute; bottom: 0; left: 0; right: 0; padding: 2rem 2rem 1.5rem; background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.6) 60%, rgba(0, 0, 0, 0) 100%); }
|
||||
.reader-header-overlay .reader-title { color: #fff; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); margin-bottom: 0.75rem; font-size: 2.5rem; font-weight: 700; line-height: 1.2; }
|
||||
.reader-header-overlay .reader-summary { color: rgba(255, 255, 255, 0.9); font-size: 1.2rem; line-height: 1.6; margin: 0 0 1rem 0; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); font-family: var(--reading-font); }
|
||||
|
||||
35
src/styles/components/skeletons.css
Normal 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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -81,7 +81,14 @@
|
||||
.view-mode-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.view-mode-left,
|
||||
.view-mode-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,14 @@ export interface IndividualBookmark {
|
||||
encryptedContent?: string
|
||||
// When the item was added to the bookmark list (synthetic, for sorting)
|
||||
added_at?: number
|
||||
// The kind of the source list/set that produced this bookmark (e.g., 10003, 30003, 30001, or 39701 for web)
|
||||
sourceKind?: number
|
||||
// The 'd' tag value from kind 30003 bookmark sets
|
||||
setName?: string
|
||||
// Metadata from the bookmark set event (kind 30003)
|
||||
setTitle?: string
|
||||
setDescription?: string
|
||||
setImage?: string
|
||||
}
|
||||
|
||||
export interface ActiveAccount {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { formatDistanceToNow, differenceInSeconds, differenceInMinutes, differenceInHours, differenceInDays, differenceInMonths, differenceInYears } from 'date-fns'
|
||||
import { ParsedContent, ParsedNode } from '../types/bookmarks'
|
||||
import { ParsedContent, ParsedNode, IndividualBookmark } from '../types/bookmarks'
|
||||
import ResolvedMention from '../components/ResolvedMention'
|
||||
// Note: ContentWithResolvedProfiles is imported by components directly to keep this file component-only for fast refresh
|
||||
|
||||
@@ -82,3 +82,71 @@ export const renderParsedContent = (parsedContent: ParsedContent) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Sorting and grouping for bookmarks
|
||||
export const sortIndividualBookmarks = (items: IndividualBookmark[]) => {
|
||||
return items
|
||||
.slice()
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
}
|
||||
|
||||
export function groupIndividualBookmarks(items: IndividualBookmark[]) {
|
||||
const sorted = sortIndividualBookmarks(items)
|
||||
const amethyst = sorted.filter(i => i.sourceKind === 30001)
|
||||
const web = sorted.filter(i => i.kind === 39701 || i.type === 'web')
|
||||
const isIn = (list: IndividualBookmark[], x: IndividualBookmark) => list.some(i => i.id === x.id)
|
||||
const privateItems = sorted.filter(i => i.isPrivate && !isIn(amethyst, i) && !isIn(web, i))
|
||||
const publicItems = sorted.filter(i => !i.isPrivate && !isIn(amethyst, i) && !isIn(web, i))
|
||||
return { privateItems, publicItems, web, amethyst }
|
||||
}
|
||||
|
||||
// Simple filter: only exclude bookmarks with empty/whitespace-only content
|
||||
export function hasContent(bookmark: IndividualBookmark): boolean {
|
||||
return !!(bookmark.content && bookmark.content.trim().length > 0)
|
||||
}
|
||||
|
||||
// Bookmark sets helpers (kind 30003)
|
||||
export interface BookmarkSet {
|
||||
name: string
|
||||
title?: string
|
||||
description?: string
|
||||
image?: string
|
||||
bookmarks: IndividualBookmark[]
|
||||
}
|
||||
|
||||
export function getBookmarkSets(items: IndividualBookmark[]): BookmarkSet[] {
|
||||
// Group bookmarks by setName
|
||||
const setMap = new Map<string, IndividualBookmark[]>()
|
||||
|
||||
items.forEach(bookmark => {
|
||||
if (bookmark.setName) {
|
||||
const existing = setMap.get(bookmark.setName) || []
|
||||
existing.push(bookmark)
|
||||
setMap.set(bookmark.setName, existing)
|
||||
}
|
||||
})
|
||||
|
||||
// Convert to array and extract metadata from the bookmarks
|
||||
const sets: BookmarkSet[] = []
|
||||
setMap.forEach((bookmarks, name) => {
|
||||
// Get metadata from the first bookmark (all bookmarks in a set share the same metadata)
|
||||
const firstBookmark = bookmarks[0]
|
||||
const title = firstBookmark?.setTitle
|
||||
const description = firstBookmark?.setDescription
|
||||
const image = firstBookmark?.setImage
|
||||
|
||||
sets.push({
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
bookmarks: sortIndividualBookmarks(bookmarks)
|
||||
})
|
||||
})
|
||||
|
||||
return sets.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
export function getBookmarksWithoutSet(items: IndividualBookmark[]): IndividualBookmark[] {
|
||||
return sortIndividualBookmarks(items.filter(b => !b.setName))
|
||||
}
|
||||
|
||||
@@ -102,13 +102,13 @@ export const prioritizeLocalRelays = (relayUrls: string[]): string[] => {
|
||||
// Parallel request helper
|
||||
import { completeOnEose, onlyEvents, RelayPool } from 'applesauce-relay'
|
||||
import { Observable, takeUntil, timer } from 'rxjs'
|
||||
import { Filter } from 'nostr-tools/filter'
|
||||
|
||||
export function createParallelReqStreams(
|
||||
relayPool: RelayPool,
|
||||
localRelays: string[],
|
||||
remoteRelays: string[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
filter: any,
|
||||
filter: Filter,
|
||||
localTimeoutMs = 1200,
|
||||
remoteTimeoutMs = 6000
|
||||
): { local$: Observable<unknown>; remote$: Observable<unknown> } {
|
||||
|
||||