Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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.
|
||||
|
||||
144
CHANGELOG.md
@@ -7,6 +7,143 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
@@ -1216,7 +1353,12 @@ 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.5...HEAD
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.10...HEAD
|
||||
[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
|
||||
|
||||
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.
|
||||
|
||||
26
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.9",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
@@ -26,13 +26,15 @@
|
||||
"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",
|
||||
@@ -9820,6 +9822,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",
|
||||
@@ -11685,6 +11696,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.6",
|
||||
"version": "0.6.11",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
@@ -29,13 +29,15 @@
|
||||
"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 |
54
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={
|
||||
@@ -205,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, {
|
||||
@@ -225,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,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">
|
||||
|
||||
@@ -11,17 +11,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)}`
|
||||
@@ -115,8 +113,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary,
|
||||
settings
|
||||
articleSummary
|
||||
}
|
||||
|
||||
if (viewMode === 'compact') {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
@@ -9,9 +9,9 @@ import SidebarHeader from './SidebarHeader'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { BookmarkSkeleton } from './Skeletons'
|
||||
|
||||
interface BookmarkListProps {
|
||||
bookmarks: Bookmark[]
|
||||
@@ -28,7 +28,6 @@ interface BookmarkListProps {
|
||||
lastFetchTime?: number | null
|
||||
loading?: boolean
|
||||
relayPool: RelayPool | null
|
||||
settings?: UserSettings
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
@@ -47,20 +46,20 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
lastFetchTime,
|
||||
loading = false,
|
||||
relayPool,
|
||||
settings,
|
||||
isMobile = false
|
||||
}) => {
|
||||
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 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
|
||||
@@ -128,8 +127,12 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
|
||||
{allIndividualBookmarks.length === 0 ? (
|
||||
loading ? (
|
||||
<div className="loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{Array.from({ length: viewMode === 'large' ? 4 : viewMode === 'cards' ? 6 : 8 }).map((_, i) => (
|
||||
<BookmarkSkeleton key={i} viewMode={viewMode} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
@@ -141,13 +144,11 @@ 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) =>
|
||||
@@ -157,7 +158,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
settings={settings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ 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 {
|
||||
@@ -26,7 +25,6 @@ interface CardViewProps {
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const CardView: React.FC<CardViewProps> = ({
|
||||
@@ -41,8 +39,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary,
|
||||
settings
|
||||
articleSummary
|
||||
}) => {
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
|
||||
@@ -59,7 +56,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
|
||||
// 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(() => {
|
||||
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
@@ -15,7 +14,6 @@ interface CompactViewProps {
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const CompactView: React.FC<CompactViewProps> = ({
|
||||
@@ -25,15 +23,14 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
articleImage,
|
||||
articleSummary,
|
||||
settings
|
||||
articleSummary
|
||||
}) => {
|
||||
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 cachedImage = useImageCache(articleImage || undefined)
|
||||
|
||||
const handleCompactClick = () => {
|
||||
if (!onSelectUrl) return
|
||||
|
||||
@@ -6,7 +6,6 @@ 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 +21,6 @@ interface LargeViewProps {
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const LargeView: React.FC<LargeViewProps> = ({
|
||||
@@ -37,10 +35,9 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary,
|
||||
settings
|
||||
articleSummary
|
||||
}) => {
|
||||
const cachedImage = useImageCache(previewImage || undefined, settings)
|
||||
const cachedImage = useImageCache(previewImage || undefined)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
|
||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
|
||||
@@ -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'
|
||||
@@ -42,6 +43,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
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'
|
||||
@@ -124,12 +126,14 @@ 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(() => {
|
||||
@@ -250,6 +254,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
showExplore={showExplore}
|
||||
showMe={showMe}
|
||||
showProfile={showProfile}
|
||||
showSupport={showSupport}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
viewMode={viewMode}
|
||||
@@ -313,6 +318,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,6 +1,8 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faNewspaper, faPenToSquare, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate } 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'
|
||||
@@ -10,14 +12,16 @@ 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 { HighlightItem } from './HighlightItem'
|
||||
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
|
||||
interface ExploreProps {
|
||||
relayPool: RelayPool
|
||||
@@ -36,9 +40,14 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
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(() => {
|
||||
@@ -50,7 +59,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!activeAccount) {
|
||||
setError('Please log in to explore content from your friends')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -58,16 +66,16 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
try {
|
||||
// show spinner but keep existing data
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Seed from in-memory cache if available to avoid empty flash
|
||||
// Use functional update to check current state without creating dependency
|
||||
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||
if (cachedPosts && cachedPosts.length > 0 && blogPosts.length === 0) {
|
||||
setBlogPosts(cachedPosts)
|
||||
if (cachedPosts && cachedPosts.length > 0) {
|
||||
setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev)
|
||||
}
|
||||
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
|
||||
if (cachedHighlights && cachedHighlights.length > 0 && highlights.length === 0) {
|
||||
setHighlights(cachedHighlights)
|
||||
if (cachedHighlights && cachedHighlights.length > 0) {
|
||||
setHighlights(prev => prev.length === 0 ? cachedHighlights : prev)
|
||||
}
|
||||
|
||||
// Fetch the user's contacts (friends)
|
||||
@@ -140,71 +148,79 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
}
|
||||
)
|
||||
|
||||
if (contacts.size === 0) {
|
||||
setError('You are not following anyone yet. Follow some people to see their content!')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
// Always proceed to load nostrverse content even if no contacts
|
||||
// (removed blocking error for empty contacts)
|
||||
|
||||
// Store final followed pubkeys
|
||||
setFollowedPubkeys(contacts)
|
||||
|
||||
// After full contacts, do a final pass for completeness
|
||||
// Fetch both friends content and nostrverse content in parallel
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const contactsArray = Array.from(contacts)
|
||||
const [posts, userHighlights] = await Promise.all([
|
||||
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
|
||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
|
||||
fetchHighlightsFromAuthors(relayPool, contactsArray)
|
||||
fetchHighlightsFromAuthors(relayPool, contactsArray),
|
||||
fetchNostrverseBlogPosts(relayPool, relayUrls, 50),
|
||||
fetchNostrverseHighlights(relayPool, 100)
|
||||
])
|
||||
|
||||
// Merge and deduplicate all posts
|
||||
const allPosts = [...friendsPosts, ...nostrversePosts]
|
||||
const postsByKey = new Map<string, BlogPostPreview>()
|
||||
for (const post of allPosts) {
|
||||
const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}`
|
||||
const existing = postsByKey.get(key)
|
||||
if (!existing || post.event.created_at > existing.event.created_at) {
|
||||
postsByKey.set(key, post)
|
||||
}
|
||||
}
|
||||
const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
|
||||
// Merge and deduplicate all highlights
|
||||
const allHighlights = [...friendsHighlights, ...nostriverseHighlights]
|
||||
const highlightsByKey = new Map<string, Highlight>()
|
||||
for (const highlight of allHighlights) {
|
||||
highlightsByKey.set(highlight.id, highlight)
|
||||
}
|
||||
const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
// Fetch profiles for all blog post authors to cache them
|
||||
if (posts.length > 0) {
|
||||
const authorPubkeys = Array.from(new Set(posts.map(p => p.author)))
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
if (posts.length === 0 && userHighlights.length === 0) {
|
||||
setError('No content found from your friends yet')
|
||||
}
|
||||
// No blocking errors - let empty states handle messaging
|
||||
setBlogPosts(uniquePosts)
|
||||
setCachedPosts(activeAccount.pubkey, uniquePosts)
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
setHighlights((prev) => {
|
||||
const byId = new Map(prev.map(h => [h.id, h]))
|
||||
for (const highlight of userHighlights) 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
|
||||
})
|
||||
setHighlights(uniqueHighlights)
|
||||
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
setError('Failed to load content. Please try again.')
|
||||
// No blocking error - user can pull-to-refresh
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [relayPool, activeAccount, blogPosts.length, highlights.length, refreshTrigger, eventStore, settings])
|
||||
}, [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) => {
|
||||
@@ -251,26 +267,61 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
}
|
||||
}
|
||||
|
||||
// Classify highlights with levels based on user context
|
||||
// Classify highlights with levels based on user context and apply visibility filters
|
||||
const classifiedHighlights = useMemo(() => {
|
||||
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
||||
}, [highlights, activeAccount?.pubkey, followedPubkeys])
|
||||
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 out blog posts with unreasonable future dates (allow 1 day for clock skew)
|
||||
// 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 => {
|
||||
const publishedTime = post.published || post.event.created_at
|
||||
return publishedTime <= maxFutureTime
|
||||
})
|
||||
}, [blogPosts])
|
||||
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-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>No blog posts found yet.</p>
|
||||
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)', padding: '2rem' }}>
|
||||
<p>No blog posts yet. Pull to refresh!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -279,15 +330,25 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
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-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>No highlights yet. Your friends should start highlighting content!</p>
|
||||
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)', padding: '2rem' }}>
|
||||
<p>No highlights yet. Pull to refresh!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -307,55 +368,69 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
}
|
||||
}
|
||||
|
||||
// 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 || blogPosts.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
|
||||
|
||||
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 highlights and blog posts from your friends and others
|
||||
</p>
|
||||
|
||||
{loading && hasData && (
|
||||
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
)}
|
||||
{/* 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}
|
||||
/>
|
||||
<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
|
||||
@@ -371,7 +446,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
data-tab="writings"
|
||||
onClick={() => navigate('/explore/writings')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
<span className="tab-label">Writings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -29,16 +29,115 @@ const isImageUrl = (url: string): boolean => {
|
||||
}
|
||||
}
|
||||
|
||||
// Component to render comment with links and inline images
|
||||
// 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 }) => {
|
||||
// URL regex pattern
|
||||
const urlPattern = /(https?:\/\/[^\s]+)/g
|
||||
// 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) => {
|
||||
if (part.match(urlPattern)) {
|
||||
// Handle nostr: URIs
|
||||
if (part.startsWith('nostr:')) {
|
||||
return renderNostrId(part, index)
|
||||
}
|
||||
|
||||
// Handle http(s) URLs
|
||||
if (part.match(/^https?:\/\//)) {
|
||||
if (isImageUrl(part)) {
|
||||
return (
|
||||
<img
|
||||
@@ -64,6 +163,7 @@ const CommentContent: React.FC<{ text: string }> = ({ text }) => {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return <span key={index}>{part}</span>
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -21,9 +22,8 @@ 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'
|
||||
|
||||
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) => {
|
||||
@@ -193,39 +191,28 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
.filter(hasContentOrUrl)
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
|
||||
// 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">
|
||||
<div className="explore-empty" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>
|
||||
{isOwnProfile
|
||||
? 'No highlights yet. Start highlighting content to see them here!'
|
||||
: 'No highlights yet. You should shame them on nostr!'}
|
||||
? 'No highlights yet. Pull to refresh!'
|
||||
: 'No highlights yet. Pull to refresh!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -242,9 +229,20 @@ 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-empty" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>No bookmarks yet. Pull to refresh!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
@@ -293,9 +291,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-empty" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>No read articles yet. Pull to refresh!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -310,25 +317,21 @@ 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">
|
||||
<div className="explore-empty" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<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>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
? 'No articles written yet. Pull to refresh!'
|
||||
: 'No articles written yet. Pull to refresh!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -349,15 +352,10 @@ 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} />}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,7 +33,7 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
highlights = [],
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true }
|
||||
}) => {
|
||||
const cachedImage = useImageCache(image, settings)
|
||||
const cachedImage = useImageCache(image)
|
||||
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
||||
const isLongSummary = summary && summary.length > 150
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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, faPlus, faNewspaper, faTimes, faBolt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
@@ -117,6 +117,13 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
ariaLabel="Explore"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faBolt}
|
||||
onClick={() => navigate('/support')}
|
||||
title="Support"
|
||||
ariaLabel="Support"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
|
||||
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 { faBolt, 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={faBolt} 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
|
||||
|
||||
@@ -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) => {
|
||||
@@ -225,8 +229,8 @@ 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 - only show when viewing article or explore (not on settings/me/profile/support) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showMe && !props.showProfile && !props.showSupport && (
|
||||
<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'
|
||||
@@ -245,8 +249,8 @@ 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 or explore (not on settings/me/profile/support) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showMe && !props.showProfile && !props.showSupport && (
|
||||
<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'
|
||||
@@ -299,7 +303,6 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
lastFetchTime={props.lastFetchTime}
|
||||
loading={props.bookmarksLoading}
|
||||
relayPool={props.relayPool}
|
||||
settings={props.settings}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
@@ -329,6 +332,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}
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import {
|
||||
AccountWithExtension,
|
||||
NostrEvent,
|
||||
@@ -16,7 +15,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 +30,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
|
||||
@@ -111,14 +101,11 @@ export const fetchBookmarks = async (
|
||||
let idToEvent: Map<string, NostrEvent> = new Map()
|
||||
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()))
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
{ ids: noteIds },
|
||||
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
|
||||
)
|
||||
idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e]))
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch events for hydration:', error)
|
||||
|
||||
@@ -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,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,9 +1,8 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Highlight } from '../../types/highlights'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||
import { queryEvents } from '../dataFetch'
|
||||
|
||||
/**
|
||||
* Fetches highlights (kind:9802) from a list of pubkeys (friends)
|
||||
@@ -24,46 +23,20 @@ export const fetchHighlightsFromAuthors = async (
|
||||
}
|
||||
|
||||
console.log('💡 Fetching highlights (kind 9802) from', pubkeys.length, 'authors')
|
||||
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const prioritized = prioritizeLocalRelays(relayUrls)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [9802], authors: pubkeys, limit: 200 })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [9802], authors: pubkeys, limit: 200 })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
const 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)
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,9 @@
|
||||
.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.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: var(--color-bg-subtle); display: flex; align-items: center; justify-content: center; }
|
||||
.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); }
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@
|
||||
.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 comment icons */
|
||||
|
||||
@@ -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> } {
|
||||
|
||||