Compare commits

...

58 Commits

Author SHA1 Message Date
Gigi
ab1e964d3a chore: bump version to 0.6.10 2025-10-15 10:27:12 +02:00
Gigi
1500744a96 Merge pull request #10 from dergigi/fix-eslint-and-stuff
Improve code quality and explore page UX
2025-10-15 10:26:37 +02:00
Gigi
394311622d refactor: remove subtitle from explore page 2025-10-15 10:22:19 +02:00
Gigi
c7f3991ddd feat: move tabs below filter buttons in explore page 2025-10-15 10:21:26 +02:00
Gigi
e05efaa4f6 feat: make refresh button spin during loading and pull-to-refresh 2025-10-15 10:20:45 +02:00
Gigi
c96347a331 fix: position refresh button next to filter buttons instead of far left 2025-10-15 10:19:52 +02:00
Gigi
d721e84e42 feat: add refresh button to the left of filter buttons in explore page 2025-10-15 10:15:52 +02:00
Gigi
dcbe4bd23e feat: update writings tab icon to use newspaper icon 2025-10-15 10:11:40 +02:00
Gigi
e11184426e feat: default explore filter to friends only 2025-10-15 10:09:55 +02:00
Gigi
ebea872c72 feat: move filter icon buttons to the right in explore page 2025-10-15 10:09:08 +02:00
Gigi
8e57d3d491 refactor: remove unused settings parameter from image cache and bookmark components
- Remove settings parameter from useImageCache and useCacheImageOnLoad hooks as it was never used
- Update all call sites in CardView, CompactView, LargeView, and ReaderHeader
- Remove settings prop from BookmarkItem and its child view components
- Remove settings prop from BookmarkList component
- Update ThreePaneLayout to not pass settings to BookmarkList

This change cascades through the component tree to clean up unused props that were
introduced when we refactored the image caching to use Service Worker instead of
local storage.
2025-10-15 10:01:43 +02:00
Gigi
ca339ac0b2 refactor: remove all eslint-disable statements and fix underlying issues
- Replace @typescript-eslint/no-explicit-any with proper Filter type from nostr-tools/filter in dataFetch.ts and helpers.ts
- Replace @typescript-eslint/no-explicit-any with IAccount and AccountManager types from applesauce-accounts in hooks
- Replace @typescript-eslint/no-explicit-any with unknown type casts in App.tsx for keep-alive subscription
- Fix react-hooks/exhaustive-deps warnings by including all dependencies in useEffect hooks
- Remove unused _settings parameters in useImageCache.ts that were causing no-unused-vars warnings
2025-10-15 09:57:14 +02:00
Gigi
abb6819c40 Merge pull request #9 from dergigi/fix-explore
Fix explore page data loading and simplify fetch/write paths
2025-10-15 09:53:07 +02:00
Gigi
de314894ff fix(explore): properly fix react-hooks/exhaustive-deps warning
Instead of suppressing the warning, use functional setState updates to
check current state without creating dependencies. This allows the effect
to check if blogPosts/highlights are empty without adding them as
dependencies, which would cause infinite re-fetch loops.

The pattern prev.length === 0 ? cached : prev ensures we only seed from
cache on initial load, not on every refresh.
2025-10-15 09:44:57 +02:00
Gigi
2939747ebf fix(lint): resolve all linting and type errors
- Remove unused imports (useRef, faExclamationCircle, getProfileUrl, Observable, UserSettings)
- Remove unused error state and setError calls in Explore and Me components
- Remove unused 'events' variable from exploreService and nostrverseService
- Remove unused '_relays' parameter from saveSettings
- Remove unused '_settings' parameter from publishEvent
- Update all callers of publishEvent and saveSettings to match new signatures
- Add eslint-disable comment for intentional dependency omission in Explore
- Update BookmarkList to use new pull-to-refresh library and RefreshIndicator
- All type checks and linting now pass
2025-10-15 09:42:56 +02:00
Gigi
a4548306e7 fix(ui): update HighlightsPanel to use new pull-to-refresh library
- Replace old usePullToRefresh hook with use-pull-to-refresh library
- Update to use RefreshIndicator component
- Remove ref-based implementation in favor of simpler library API
2025-10-15 09:37:03 +02:00
Gigi
f16c1720a6 fix(ui): remove blocking error screens, show progressive loading with skeletons
- Remove full-screen error messages in Explore and Me
- Show skeletons while loading if no data cached
- Display empty states with 'Pull to refresh!' message
- Allow users to pull-to-refresh to retry on errors
- Keep content visible as data streams in progressively
2025-10-15 09:34:46 +02:00
Gigi
5b2ee94062 feat(ui): replace custom pull-to-refresh with use-pull-to-refresh library for simplicity
- Remove custom usePullToRefresh hook and PullToRefreshIndicator
- Add use-pull-to-refresh library dependency
- Create simple RefreshIndicator component
- Apply pull-to-refresh to Explore and Me screens
- Simplify implementation while maintaining functionality
2025-10-15 09:32:25 +02:00
Gigi
3091ad7fd4 feat(write): add unified publishEvent service and refactor highlight and settings to use it 2025-10-15 09:29:54 +02:00
Gigi
5b7488295c refactor(fetch): migrate nostrverseService, bookmarkService, and libraryService to use queryEvents 2025-10-15 09:28:35 +02:00
Gigi
bea62ddc4b refactor(fetch): migrate exploreService and fetchHighlightsFromAuthors to use queryEvents 2025-10-15 09:26:33 +02:00
Gigi
44d6b1fb2a fix(explore): prevent refresh loop and avoid false empty-follows error; always load nostrverse 2025-10-15 09:19:10 +02:00
Gigi
02ec8dd936 feat(fetch): add unified queryEvents helper and stream contacts partials with extended timeout 2025-10-15 09:17:51 +02:00
Gigi
765ce0ac5e feat(network): centralize relay timeouts and contacts remote timeout 2025-10-15 09:15:48 +02:00
Gigi
a1f7c3e34a Merge pull request #8 from dergigi/support
Add support page with zap receipt display
2025-10-15 01:54:47 +02:00
Gigi
2e5eb08b54 fix: simplify copy 'show up above' to 'show above' 2025-10-15 01:51:24 +02:00
Gigi
46a6d4fe0c chore: restore production thresholds (2100 sats / 69420 sats)
Removed testing values and restored proper production thresholds
2025-10-15 01:44:45 +02:00
Gigi
84ea0df550 refactor: improve support page spacing and visual hierarchy
- Increase hero section spacing (mb-16/20 instead of mb-8/12)
- Larger illustration (w-56/72 instead of w-48/64)
- Bigger heading (text-4xl/5xl instead of 3xl/4xl)
- More generous section spacing throughout
- Larger gaps between avatars (gap-8/10 for legends)
- More columns on larger screens (lg breakpoint added)
- Add subtle border-top separator before footer
- Increase footer spacing (mt-16/20)
- Larger call-to-action text (text-base)
- Change 'Absolute Legends' to 'Legends' (shorter, cleaner)
2025-10-15 01:43:12 +02:00
Gigi
0f58b166ce refactor: make regular supporter avatars smaller and remove border
- Reduced from w-12 h-12 md:w-16 md:h-16 to w-10 h-10 md:w-12 md:h-12
- Removed ring border for regular supporters (keep yellow ring for whales only)
- Simplified styling logic
2025-10-15 01:40:34 +02:00
Gigi
f65d39023c refactor: reorder footer text and make stats smaller
- Move call-to-action above stats
- Reduce stats font size from text-sm to text-xs
- Make call-to-action more prominent
2025-10-15 01:39:57 +02:00
Gigi
0b3c7efbc1 refactor: remove superfluous 'Want to show up here?' question
Make call-to-action more direct and concise
2025-10-15 01:38:58 +02:00
Gigi
ecb462562f feat: link 'Boris' to njump.me profile page
Allow users to easily navigate to Boris profile to send zaps
2025-10-15 01:38:34 +02:00
Gigi
c5a3d00371 feat: link 'meaningful amount of sats' to pricing page
Makes the call-to-action more actionable by linking to payment info
2025-10-15 01:36:29 +02:00
Gigi
d3b7a8ddde feat: add call-to-action message at bottom of support page
Encourage visitors to zap Boris to show up on the supporter list
2025-10-15 01:36:12 +02:00
Gigi
0eee203a9b feat: link 'zaps' to pricing page
Add clickable link on 'zaps' text pointing to https://www.readwithboris.com/#pricing
Helps users learn how to send zaps to support the project
2025-10-15 01:34:35 +02:00
Gigi
cd5a95dea3 refactor: reduce regular supporter avatar size
Changed from w-16 h-16 md:w-20 md:h-20 to w-12 h-12 md:w-16 md:h-16
Makes regular supporters more compact while keeping legends prominent
2025-10-15 01:34:05 +02:00
Gigi
f348ddaf73 revert: restore 'Absolute Legends' heading
Changed back from 'Legends' to 'Absolute Legends'
2025-10-15 01:33:44 +02:00
Gigi
9f09093c80 refactor: simplify whale section heading to 'Legends'
Changed from 'Absolute Legends' to 'Legends' for brevity
2025-10-15 01:30:52 +02:00
Gigi
490c6c9bdc feat: make supporter avatars clickable to view profiles
- Click avatar to navigate to /p/:npub profile page
- Add hover scale effect for visual feedback
- Convert pubkey to npub for navigation
2025-10-15 01:29:57 +02:00
Gigi
4eb0ede76b refactor: remove bolt icon from Absolute Legends header
Keep the bolt badge on whale profile pictures only
2025-10-15 01:29:11 +02:00
Gigi
02c1b6b783 chore: temporarily lower thresholds for testing (2 sats / 21 sats)
- Lower supporter threshold from 2100 to 2 sats
- Lower whale threshold from 69420 to 21 sats
- Add TODO comment to restore production values
- For testing support page with smaller zaps
2025-10-15 01:25:39 +02:00
Gigi
9eed448da6 feat: improve support page copy and add thank-you illustration
- Add exclamation mark to 'Thank You!' heading for warmth
- Simplify description text (remove redundant thank you)
- Rename 'Mega Supporters' to 'Absolute Legends' for more fun tone
- Add thank-you.svg illustration asset
2025-10-15 01:25:00 +02:00
Gigi
f8d621bcdc fix: change support page heading to 'Thank You' 2025-10-15 01:22:17 +02:00
Gigi
5cbe2246d3 fix: apply proper theme colors to support page for readability
- Use CSS variables for background, text, and border colors
- Add min-h-screen wrapper with proper background color
- Replace hardcoded zinc colors with theme-aware variables
- Ensure text is readable in both light and dark themes
2025-10-15 01:21:57 +02:00
Gigi
f29a180cbd feat: add thank-you illustration to support page 2025-10-15 01:20:05 +02:00
Gigi
0ca3771906 fix: improve zap receipt scanning with applesauce helpers and more relays
- Use isValidZap, getZapSender, getZapAmount from applesauce-core/helpers
- Add common zap relays (Mutiny, Alby) to improve coverage
- Add detailed logging to debug zap receipt fetching
- Remove custom extraction functions in favor of applesauce helpers
2025-10-15 01:04:17 +02:00
Gigi
6dab126f88 fix: resolve lint and type errors in support page implementation 2025-10-15 01:01:18 +02:00
Gigi
6c74d04984 docs: add Support page section to FEATURES.md 2025-10-15 00:54:03 +02:00
Gigi
1e00ff5e35 feat: add Support button (bolt icon) to SidebarHeader navigation 2025-10-15 00:53:34 +02:00
Gigi
71fa334f61 feat: add /support route to App routing 2025-10-15 00:53:30 +02:00
Gigi
d3ee995221 feat: wire Support component into Bookmarks with /support detection 2025-10-15 00:53:26 +02:00
Gigi
6812584b8c feat: extend ThreePaneLayout with showSupport and support slot 2025-10-15 00:53:22 +02:00
Gigi
47ddf8ebe1 feat: add Support component to display zappers with avatar grid 2025-10-15 00:53:18 +02:00
Gigi
36897e7f15 feat: add zapReceiptService to fetch and aggregate kind:9735 receipts 2025-10-15 00:53:14 +02:00
Gigi
f18315be02 feat: export BORIS_PUBKEY for reuse in support page 2025-10-15 00:53:09 +02:00
Gigi
38d77b02f5 docs: add FEATURES.md summarizing app features 2025-10-14 22:49:45 +02:00
Gigi
5b77a93bba chore: add MIT License 2025-10-14 16:53:59 +02:00
Gigi
e1c11a7450 docs: update CHANGELOG.md for v0.6.9 2025-10-14 16:50:40 +02:00
42 changed files with 1057 additions and 787 deletions

View File

@@ -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.

View File

@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.9] - 2025-10-14
### Documentation
- Minor changelog formatting updates
## [0.6.8] - 2025-10-14
### Changed
@@ -1293,7 +1299,8 @@ 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.8...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.9...HEAD
[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

89
FEATURES.md Normal file
View File

@@ -0,0 +1,89 @@
# Boris Features
## Overview
- **Purpose**: A calm, fast, Nostrfirst reader that turns your bookmarks into a focused reading app.
- **Layout**: Threepane interface — bookmarks (left), reader (center), highlights (right). Collapsible sidebars.
- **Content**: Renders both Nostr longform 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
- **Distractionfree 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 (NIP84)
- **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 (NIP57)
- **Configurable splits**: Weightbased 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 (NIP51 + Web)
- **Ingestion**: Collects list bookmarks and items from kinds 10003/30003/30001.
- **Web bookmarks**: Supports NIPB0 (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 platformspecific URL schemes.
## Settings (NIP78 Application Data)
- **Theme**: System/light/dark with color variants (dark: black/midnight/charcoal; light: paperwhite/sepia/ivory).
- **Reading**: Font family (preloaded), font size, highlight style (marker/underline), perlevel colors.
- **Layout & startup**: Default view modes, autocollapse preferences, show/hide highlights.
- **Zap Splits**: Weight sliders and presets for NIP57 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 inapp 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.
- **Multirelay**: Grouped connections with keepalive subscription; local+remote partitioning for fast queries.
- **Persistence**: Accounts restored from local storage; settings saved to NIP78 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**: Oneclick copy or share for articles and videos.
- **Open on Nostr**: Deep links to portals and `nostr:` schemes for longform articles.
- **Mobile UX**: Floating open buttons for Bookmarks/Highlights, focus trapping, and backdrop controls.

22
LICENSE Normal file
View 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.

16
package-lock.json generated
View File

@@ -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",
@@ -33,7 +33,8 @@
"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",
@@ -11695,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",

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.6.9",
"version": "0.6.10",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",
@@ -36,7 +36,8 @@
"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",

1
public/thank-you.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -62,6 +62,15 @@ function AppRoutes({
/>
}
/>
<Route
path="/support"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/explore"
element={
@@ -206,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, {
@@ -226,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()
}
}
}

View File

@@ -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()

View File

@@ -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') {

View File

@@ -9,9 +9,8 @@ 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 {
@@ -29,7 +28,6 @@ interface BookmarkListProps {
lastFetchTime?: number | null
loading?: boolean
relayPool: RelayPool | null
settings?: UserSettings
isMobile?: boolean
}
@@ -48,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
@@ -146,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) =>
@@ -162,7 +158,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
index={index}
onSelectUrl={onSelectUrl}
viewMode={viewMode}
settings={settings}
/>
)}
</div>

View File

@@ -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(() => {

View File

@@ -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

View File

@@ -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>)

View File

@@ -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'
@@ -128,8 +130,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
if (isMobile && isSidebarOpen) {
toggleSidebar()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname])
}, [location.pathname, isMobile, isSidebarOpen, toggleSidebar])
// Handle highlight navigation from explore page
useEffect(() => {
@@ -250,6 +251,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
showExplore={showExplore}
showMe={showMe}
showProfile={showProfile}
showSupport={showSupport}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
viewMode={viewMode}
@@ -313,6 +315,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}

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import React, { useState, useEffect, useMemo } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faExclamationCircle, faNewspaper, faPenToSquare, faHighlighter, faUser, faUserGroup, faNetworkWired } 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'
@@ -18,8 +18,8 @@ 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'
@@ -40,15 +40,13 @@ 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)
// Visibility filters (defaults from settings, or friends only)
const [visibility, setVisibility] = useState<HighlightVisibility>({
nostrverse: settings?.defaultHighlightVisibilityNostrverse !== false,
friends: settings?.defaultHighlightVisibilityFriends !== false,
mine: settings?.defaultHighlightVisibilityMine !== false
nostrverse: settings?.defaultHighlightVisibilityNostrverse ?? false,
friends: settings?.defaultHighlightVisibilityFriends ?? true,
mine: settings?.defaultHighlightVisibilityMine ?? false
})
// Update local state when prop changes
@@ -61,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
}
@@ -69,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)
@@ -151,11 +148,8 @@ 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)
@@ -202,10 +196,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
})
}
if (uniquePosts.length === 0 && uniqueHighlights.length === 0) {
setError('No content found yet')
}
// No blocking errors - let empty states handle messaging
setBlogPosts(uniquePosts)
setCachedPosts(activeAccount.pubkey, uniquePosts)
@@ -213,21 +204,23 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
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) => {
@@ -309,9 +302,18 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
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">
@@ -326,9 +328,18 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
)
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">
@@ -348,85 +359,33 @@ 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" aria-busy="true">
<div className="explore-header">
<h1>
<FontAwesomeIcon icon={faNewspaper} />
Explore
</h1>
</div>
<div className="explore-grid">
{activeTab === 'writings' ? (
Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))
) : (
Array.from({ length: 8 }).map((_, i) => (
<HighlightSkeleton key={i} />
))
)}
</div>
</div>
)
}
if (error) {
return (
<div className="explore-container">
<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>
<div className="me-tabs">
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights"
onClick={() => navigate('/explore')}
>
<FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span>
</button>
<button
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings"
onClick={() => navigate('/explore/writings')}
>
<FontAwesomeIcon icon={faPenToSquare} />
<span className="tab-label">Writings</span>
</button>
</div>
{/* Visibility filters */}
<div className="highlight-level-toggles" style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem' }}>
<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 })}
@@ -463,6 +422,25 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}}
/>
</div>
<div className="me-tabs">
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights"
onClick={() => navigate('/explore')}
>
<FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span>
</button>
<button
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings"
onClick={() => navigate('/explore/writings')}
>
<FontAwesomeIcon icon={faNewspaper} />
<span className="tab-label">Writings</span>
</button>
</div>
</div>
{renderTabContent()}

View File

@@ -1,13 +1,13 @@
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'
@@ -60,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
@@ -69,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
@@ -144,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

View File

@@ -1,6 +1,6 @@
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'
@@ -22,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
@@ -47,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
@@ -62,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) {
@@ -115,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)
}
@@ -125,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) => {
@@ -194,56 +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" aria-busy="true">
{viewingPubkey && (
<div className="explore-header">
<AuthorCard authorPubkey={viewingPubkey} />
</div>
)}
<div className="explore-grid">
{activeTab === 'writings' ? (
Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))
) : activeTab === 'highlights' ? (
Array.from({ length: 8 }).map((_, i) => (
<HighlightSkeleton key={i} />
))
) : (
Array.from({ length: 6 }).map((_, i) => (
<BookmarkSkeleton key={i} viewMode={viewMode} />
))
)}
</div>
</div>
)
}
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>
) : (
@@ -260,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">
@@ -311,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">
@@ -328,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>
) : (
@@ -367,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} />}

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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}

235
src/components/Support.tsx Normal file
View 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

View File

@@ -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 (not on settings/explore/me/profile/support) */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !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 (not on settings/explore/me/profile/support) */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !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
View 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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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')

View File

@@ -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)

View File

@@ -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
View 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())
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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 { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { Helpers } from 'applesauce-core'
import { BlogPostPreview } from './exploreService'
import { Highlight } from '../types/highlights'
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
import { queryEvents } from './dataFetch'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -23,36 +22,25 @@ export const fetchNostrverseBlogPosts = async (
): Promise<BlogPostPreview[]> => {
try {
console.log('📚 Fetching nostrverse blog posts (kind 30023), limit:', limit)
const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
// Deduplicate replaceable events by keeping the most recent version
const uniqueEvents = new Map<string, NostrEvent>()
const processEvents = (incoming: NostrEvent[]) => {
for (const event of incoming) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
if (!existing || event.created_at > existing.created_at) {
uniqueEvents.set(key, event)
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)
}
}
}
}
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [30023], limit })
.pipe(completeOnEose(), takeUntil(timer(1200)), onlyEvents())
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [30023], limit })
.pipe(completeOnEose(), takeUntil(timer(6000)), onlyEvents())
: new Observable<NostrEvent>((sub) => sub.complete())
const events = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
processEvents(events)
)
console.log('📊 Nostrverse blog post events fetched (unique):', uniqueEvents.size)
@@ -93,24 +81,12 @@ export const fetchNostrverseHighlights = async (
): Promise<Highlight[]> => {
try {
console.log('💡 Fetching nostrverse highlights (kind 9802), limit:', limit)
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], limit })
.pipe(completeOnEose(), takeUntil(timer(1200)), onlyEvents())
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], limit })
.pipe(completeOnEose(), takeUntil(timer(6000)), onlyEvents())
: new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
const rawEvents = await queryEvents(
relayPool,
{ kinds: [9802], limit },
{}
)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight)

View File

@@ -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')
}

View 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)
})
}

View 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 []
}
}

View File

@@ -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> } {