Compare commits

..

432 Commits

Author SHA1 Message Date
Gigi
9007b1ca71 chore: bump version to 0.6.12 2025-10-15 12:05:34 +02:00
Gigi
0b7e6145de style: set horizontal divider opacity to 69% 2025-10-15 11:44:34 +02:00
Gigi
bf1b608d96 style: increase horizontal divider opacity for better visibility 2025-10-15 11:44:04 +02:00
Gigi
7db0f2a05c style: make horizontal dividers more subtle with increased padding 2025-10-15 11:43:33 +02:00
Gigi
165b4d4b9f docs: update CHANGELOG.md for v0.6.11 release 2025-10-15 11:09:16 +02:00
Gigi
a7106138c4 chore: bump version to 0.6.11 2025-10-15 11:08:08 +02:00
Gigi
a498bfab38 feat(mobile): show sidebar buttons on explore page 2025-10-15 11:07:07 +02:00
Gigi
3dd2980283 fix(mobile): memoize toggleSidebar to prevent sidebar from closing immediately on mobile
- Wrapped toggleSidebar in useCallback to prevent function recreation on every render
- Updated route change effect to only close sidebar on actual pathname changes, not state changes
- Fixes issue where bookmarks sidebar wouldn't stay open on mobile PWA
2025-10-15 11:05:17 +02:00
Gigi
2e2a1a2c9d feat: add colored borders to blog post and highlight cards based on relationship
- Add level-based colored borders to blog post cards (mine/friends/nostrverse)
- Updated BlogPostCard to accept and apply level prop
- Modified Explore.tsx to classify blog posts by relationship level
- Added CSS styling using settings colors for visual distinction
- Highlights already had this feature, now writings have it too
2025-10-15 10:32:16 +02:00
Gigi
b9666bf037 docs: update CHANGELOG.md for v0.6.10 release 2025-10-15 10:28:18 +02:00
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
Gigi
d96ee50f5a chore: bump version to 0.6.9 2025-10-14 16:48:32 +02:00
Gigi
d4a172ba7e docs: update CHANGELOG.md for v0.6.8 2025-10-14 16:40:08 +02:00
Gigi
52ddb8dd7d chore: bump version to 0.6.8 2025-10-14 16:39:08 +02:00
Gigi
8c16614752 chore: update favicon and app icons to purple theme 2025-10-14 16:38:21 +02:00
Gigi
700d7cc5fa chore: update favicon and app icons 2025-10-14 16:34:21 +02:00
Gigi
017703dab2 fix: use consistent yellow color (#fde047) for default highlight settings 2025-10-14 16:31:55 +02:00
Gigi
c59fdb14f1 docs: update CHANGELOG.md for v0.6.7 2025-10-14 15:46:21 +02:00
Gigi
0c104f95d9 chore: bump version to 0.6.7 2025-10-14 15:44:25 +02:00
Gigi
acbefae501 Merge pull request #7 from dergigi/loading-placeholders
Remove loading spinners in favor of skeleton placeholders
2025-10-14 15:43:55 +02:00
Gigi
2ce83ef88a fix: use React.ReactElement instead of JSX.Element type
Change return type from JSX.Element to React.ReactElement to fix ESLint no-undef error
2025-10-14 15:42:54 +02:00
Gigi
dab3412ecd refactor: remove loading spinner from explore page
Remove incremental loading spinner as pull-to-refresh indicator already provides visual feedback for refresh state. Initial loading continues to use skeleton placeholders.
2025-10-14 15:41:34 +02:00
Gigi
988b3164d2 docs: add loading placeholder guideline to fontawesome rule 2025-10-14 15:40:32 +02:00
Gigi
4161053821 fix: Me - handle undefined viewingPubkey in skeleton loading state 2025-10-14 15:37:03 +02:00
Gigi
60054c4865 feat: ContentPanel - replace spinner with skeleton loaders 2025-10-14 15:36:57 +02:00
Gigi
f4e8aa576c feat: HighlightsPanel - replace spinner with skeleton loaders 2025-10-14 15:35:28 +02:00
Gigi
30a495bcd1 feat: Me - replace spinner with skeleton loaders 2025-10-14 15:35:23 +02:00
Gigi
6dde0eb220 feat: Explore - replace spinner with skeleton loaders 2025-10-14 15:35:17 +02:00
Gigi
90d8ef3423 feat: BookmarkList - replace spinner with skeleton loaders 2025-10-14 15:35:10 +02:00
Gigi
f626a8ec9b feat: add skeleton components and theme provider 2025-10-14 15:35:03 +02:00
Gigi
a7c7535236 feat: add react-loading-skeleton package 2025-10-14 14:53:40 +02:00
Gigi
5b0f2821d6 feat: parse and render nostr identifiers in highlight comments
- Detect and decode nostr: URIs (npub, nprofile, naddr, note, nevent) in comments
- Render profiles as clickable links with shortened pubkeys (@abc12345...)
- Render blog posts (kind:30023) as clickable article links
- Shorten other event identifiers to prevent layout breaks
- Add monospace styling for shortened nostr IDs
- Maintains DRY principles by extending existing CommentContent component
2025-10-14 12:58:01 +02:00
Gigi
be045557b8 feat: add nostrverse content and visibility filters to explore page
- Add visibility filter state and UI (mine/friends/nostrverse toggles)
- Create nostrverseService to fetch public content from the entire network
- Fetch both friends content and nostrverse content in parallel
- Apply visibility filters to both highlights and blog posts
- Filter buttons match highlight sidebar styling
- Users can now discover content beyond their friend network
- Maintains performance with sensible limits (50 posts, 100 highlights)
2025-10-14 12:09:12 +02:00
Gigi
a0c92182f9 docs: update CHANGELOG.md for v0.6.6 release 2025-10-14 12:03:53 +02:00
Gigi
f33d33556b chore: bump version to 0.6.6 2025-10-14 12:02:20 +02:00
Gigi
9aff889835 fix: correct profile fetching implementation and dependencies
- Use eventStore.add() directly instead of mapEventsToStore
- Use tap() operator to process and store events as they arrive
- Add eventStore and settings to useEffect dependencies
- Fixes TypeScript and ESLint errors
2025-10-14 12:00:52 +02:00
Gigi
420df1fbdd feat: fetch and cache author profiles in explore page
- Create profileService to fetch and cache kind:0 metadata
- Fetch profiles for all blog post authors on explore page
- Store profiles in event store for immediate access
- Rebroadcast profiles to local/all relays per user settings
- Fixes 'Unknown' author names by ensuring profiles are cached
- Uses mapEventsToStore to automatically populate event store
2025-10-14 11:59:28 +02:00
Gigi
2946ede5ac fix: filter out blog posts with far-future publication dates
- Add filteredBlogPosts useMemo to exclude posts with unreasonable dates
- Allow 1 day into future for clock skew tolerance
- Prevents spam/error posts with dates like '53585 years from now'
- Uses published_at tag or event.created_at as fallback
2025-10-14 11:57:04 +02:00
Gigi
6ec28e6a9d feat: render links and images in highlight comments
- Parse URLs in comment text and render as clickable links
- Detect image URLs and render inline images
- Add CommentContent component for smart URL rendering
- Style links with primary color and underline
- Style images with border and rounded corners
- Images lazy-load and respect max-width
- Links open in new tab with noopener/noreferrer
2025-10-14 11:54:41 +02:00
Gigi
820daa489e feat: hide citation in highlights sidebar for current article
- Add showCitation prop to HighlightItem (defaults to true)
- Set showCitation={false} in HighlightsPanel
- Reduces redundancy since all sidebar highlights are from same article
- Citation still shown in Explore and Me pages where context is needed
2025-10-14 11:52:29 +02:00
Gigi
b162596013 fix: prevent layout breaks from long URLs in highlight comments
- Add word-wrap, overflow-wrap, and word-break to comments
- Set min-width: 0 to allow flex child to shrink
- Prevents horizontal overflow from long URLs or text
- Maintains readable layout with line wrapping
2025-10-14 11:51:16 +02:00
Gigi
e581237e16 docs: update CHANGELOG.md for v0.6.5 release 2025-10-14 11:49:43 +02:00
Gigi
fcc329cc7c chore: bump version to 0.6.5 2025-10-14 11:48:25 +02:00
Gigi
c9544e0fd2 feat: open highlight in native app when clicking timestamp
- Click timestamp to open highlight event in user's native Nostr app
- Reuses existing native link logic (nostr:nevent)
- Simple and DRY implementation
2025-10-14 11:40:30 +02:00
Gigi
d7906cfb95 fix: use article text color for highlight counter
- Change highlight indicator to var(--color-text)
- Matches main article text color for better readability
- More prominent and consistent with content
2025-10-14 11:38:59 +02:00
Gigi
13cd6aeb11 fix: use consistent text color for highlight counter
- Change highlight indicator color to var(--color-text-secondary)
- Matches reading time color for visual consistency
- Better readability in both light and dark modes
2025-10-14 11:38:19 +02:00
Gigi
d4821d18fb fix: improve highlight counter readability in light mode
- Make highlight indicator color theme-aware
- Only force white text color in overlay context (with hero image)
- Let CSS handle text color in regular header for better light mode support
- Fixes hard-to-read white text on light backgrounds
2025-10-14 11:37:13 +02:00
Gigi
b86bf48382 deps: add @fortawesome/free-regular-svg-icons
- Install FontAwesome regular icons package
- Required for faComments icon in HighlightItem component
2025-10-14 11:34:52 +02:00
Gigi
c595f94567 style: switch to regular comments icon
- Use faComments from @fortawesome/free-regular-svg-icons
- Replace solid faComment with regular faComments
- Provides lighter, outlined icon style per FontAwesome regular variant
2025-10-14 11:33:02 +02:00
Gigi
82058c0ef4 style: remove extra indent from highlight comments
- Remove margin-left from comment container
- Icon alone provides sufficient visual indentation
- Cleaner alignment with highlight content
2025-10-14 11:32:16 +02:00
Gigi
a1f3424b38 style: remove background from highlight comments
- Remove background color from comment boxes
- Keep only colored icon for visual distinction
- Cleaner, simpler appearance
2025-10-14 11:31:32 +02:00
Gigi
14ab749ef1 style: color comment icon by highlight level and remove border
- Remove border-left from highlight comments
- Color comment icon based on highlight level (mine/friends/nostrverse)
- Remove opacity from icon for clearer color representation
- Yellow for mine, orange for friends, purple for nostrverse
2025-10-14 11:30:07 +02:00
Gigi
61dd4b2089 style: flip comment icon horizontally
- Add flip='horizontal' prop to comment icon
- Better visual alignment with comment text
2025-10-14 11:29:26 +02:00
Gigi
fb2fe1cc63 feat(highlights): add comment icon to highlight comments
- Import and use faComment icon
- Display comment icon next to comment text
- Style with flexbox layout and slight opacity
- Icon aligns to top with comment text
- Visual indicator that distinguishes comments from highlights
2025-10-14 11:28:51 +02:00
Gigi
720f12ce1c feat(explore): color highlights by author level (mine/friends/nostrverse)
- Import and use classifyHighlights utility
- Track followed pubkeys from contact fetching
- Classify highlights using same logic as highlights sidebar
- Pass classified highlights with level to HighlightItem
- Highlights now show colored borders based on author:
  - Yellow for own highlights (mine)
  - Orange for friends' highlights
  - Purple for nostrverse highlights
- Keep code DRY by reusing existing classification logic
2025-10-14 11:26:19 +02:00
Gigi
423ebb403f fix: add retry mechanism for scroll-to-highlight in article content
- Replace single 100ms delay with retry mechanism
- Try up to 20 times (2 seconds total) to find highlight mark element
- Fixes timing issue when content is still loading from explore page
- Mark elements need time to be rendered after article loads
- Retry every 100ms until element is found or max attempts reached
- Improves reliability of highlight scrolling from external navigation
2025-10-14 11:24:35 +02:00
Gigi
c90fb66bb8 feat(explore): scroll to highlight and open sidebar on click
- Pass highlight ID and openHighlights flag via navigation state
- Add useEffect in Bookmarks to handle navigation state
- Open highlights sidebar when clicking highlight from explore
- Auto-scroll to selected highlight (handled by useHighlightInteractions)
- Clear state after handling to prevent re-triggering
- Enhanced UX for discovering and reading highlighted content
2025-10-14 11:22:34 +02:00
Gigi
188de7ab1d feat(explore): clicking highlight opens source article in reader
- Add handleHighlightClick handler in explore page
- For nostr-native articles: convert eventReference to naddr and navigate to /a/{naddr}
- For web URLs: navigate to /r/{encoded-url}
- Pass onHighlightClick to HighlightItem component
- Users can now click highlights to read the full source content
2025-10-14 11:20:17 +02:00
Gigi
0b1cf267a7 refactor: reorder explore tabs - highlights first, writings second
- Change default tab to highlights on /explore
- Reorder tab buttons in UI (highlights, then writings)
- Update route: /explore shows highlights, /explore/writings shows writings
- Update route detection logic in Bookmarks component
- Highlights are now the primary content on explore page
2025-10-14 11:08:42 +02:00
Gigi
19f68612a5 style: use highlighter icon instead of server icon in highlight items
- Replace faServer with faHighlighter in bottom left indicator
- Update import statement
- Keep plane icon for offline/local-only highlights
- More semantically appropriate icon for highlight items
2025-10-14 11:08:03 +02:00
Gigi
1b1600d6f2 fix: make explore tabs actually span full width
- Set width: 100% and max-width: 100% on tabs
- Add justify-content: flex-start for left alignment
- Tabs now properly extend to match grid width
2025-10-14 11:07:00 +02:00
Gigi
ce67c19ece style: make explore tabs extend to grid width
- Add specific styling for tabs in explore-header
- Tabs now span full width to match the grid below
- Maintain left alignment for consistency with grid layout
2025-10-14 11:04:59 +02:00
Gigi
f754ce3cfe fix: extract author pubkey directly from p tag in highlights
- Pass full highlight object to HighlightCitation component
- Extract author pubkey from p tag as fallback if highlight.author not set
- Add debug logging to track author resolution
- Fix TypeScript type errors with proper guards
- Ensure author profile resolution works correctly
2025-10-14 11:04:24 +02:00
Gigi
19a86525cb debug: add logging to track author pubkey and profile resolution 2025-10-14 11:03:30 +02:00
Gigi
29213ceb1c feat(highlights): add citation attribution to highlight items
- Create HighlightCitation component to show source attribution
- For nostr-native content: display as '— Author, Article Title'
- For web URLs: display hostname as '— domain.com'
- Automatically resolves article titles from event references
- Resolves author names from profile data
- Add styling for citation line below highlight text
- Keep code DRY by reusing existing articleTitleResolver service
2025-10-14 11:01:02 +02:00
Gigi
d25a9b1735 refactor: use existing HighlightItem component for consistency
- Remove custom HighlightCard component
- Use the same HighlightItem component used throughout the app
- Remove custom highlight card styles
- Keep code DRY and UI consistent
2025-10-14 10:57:00 +02:00
Gigi
0f03706166 style: add proper styling for highlight cards in explore grid
- Add gradient header with quote icon for visual distinction
- Style highlight text and comments for card view
- Ensure cards work well in grid layout
- Add mobile responsive styling for highlight cards
2025-10-14 10:56:18 +02:00
Gigi
b1f79e3844 fix: resolve type errors and remove unused code
- Remove unused handleHighlightDelete function
- Fix all TypeScript type errors by using correct Highlight properties
- Use created_at instead of timestamp
- Use content instead of text
- Use urlReference instead of url
- All lint checks and type checks now pass
2025-10-14 10:54:51 +02:00
Gigi
243d9b17ef chore(explore): update subtitle text
- Change subtitle to mention both highlights and blog posts
- Include 'friends and others' to reflect broader content scope
2025-10-14 10:50:51 +02:00
Gigi
50a6cf6499 fix(explore): remove max-width constraint for grid layout
- Remove me-tab-content wrapper that was limiting width to 600px
- Allow explore-grid to use full width for proper multi-column layout
- Blog posts now display in proper grid format
2025-10-14 10:49:36 +02:00
Gigi
8f7991e971 refactor(explore): use grid layout for highlights tab
- Change highlights from list view to grid/card view
- Match the visual style of the writings tab
- Keep tab structure at the top
- Explore page now shows more content at once
2025-10-14 10:46:15 +02:00
Gigi
0aba54bd23 feat(explore): add highlights tab to explore page
- Create fetchHighlightsFromAuthors function for fetching highlights from multiple contacts
- Add tab structure to Explore page (Writings and Highlights tabs)
- Update explore cache to handle both blog posts and highlights
- Add /explore/highlights route
- Keep UI consistent with /me page tab structure
- Implement pull-to-refresh for both tabs
- Add proper caching and streaming for highlights
2025-10-14 10:45:23 +02:00
Gigi
23833b2cff docs: update CHANGELOG.md for v0.6.4 release 2025-10-14 10:40:40 +02:00
Gigi
d5076ff53e chore: bump version to 0.6.4 2025-10-14 10:39:44 +02:00
Gigi
41e452be1e Merge pull request #6 from dergigi/themes
Add comprehensive theme support with light/dark modes
2025-10-14 10:38:54 +02:00
Gigi
f267df8f60 feat(ui): increase bottom padding in highlight cards
- Increase bottom padding from 0.75rem to 2.5rem
- Reduces gap between cards from 1rem to 0.75rem (user edit)
- Provides more breathing room between text and footer
- Improves readability and visual balance
2025-10-14 10:35:34 +02:00
Gigi
7426c9d1fc feat(ui): increase spacing between highlight cards
- Increase gap from 0.75rem to 1rem in highlights list
- Provides better visual breathing room between cards
- Improves overall readability and card separation
2025-10-14 10:34:48 +02:00
Gigi
93d0c1052b feat(ui): align highlight text with footer icons
- Add 1.25rem left padding to highlight text content
- Add 1.25rem left margin to highlight comments
- Text now starts roughly where the fa-server icon ends
- Improves visual alignment and readability of highlight cards
2025-10-14 10:34:07 +02:00
Gigi
6537650757 feat(ui): apply highlight marker style to active Highlights tab
- Use actual highlight visual treatment (marker style) on tab
- Text remains in semantic color (--color-text) for readability
- Background uses 35% highlight color blend with glow effect
- Hover state intensifies to 50% for better interaction feedback
- Creates consistent visual language between tabs and content highlights
2025-10-14 10:32:31 +02:00
Gigi
a95f9b522b refactor(ui): simplify Me page tab labels
- Remove count numbers from all tabs (cleaner UI)
- Rename "Reading List" to "Bookmarks" (clearer naming)
- Keep tab names: Highlights, Bookmarks, Archive, Writings
- Reduces visual clutter and improves readability
2025-10-14 10:31:35 +02:00
Gigi
47d1335842 fix(ui): add background to Highlights tab for better contrast
- Add --color-bg-elevated background to active Highlights tab
- Improves contrast of yellow highlight color in light mode
- Creates visual separation while maintaining highlight color identity
- Keeps yellow text and border for consistent highlight theming
2025-10-14 10:30:27 +02:00
Gigi
168095e133 fix(ui): improve Highlights tab readability in light mode
- Use semantic text color (--color-text) for tab label in active state
- Keep highlight color for icon and bottom border as visual accent
- Ensures text is always readable regardless of theme
- Fixes contrast issues on /me page Highlights tab
2025-10-14 10:29:11 +02:00
Gigi
5c7b413a8d fix(theme): use consistent yellow-300 highlight color across all themes
- Revert to yellow-300 (#fde047) for all light and dark themes
- Use consistent Tailwind palette: yellow-300, orange-500, purple-600
- Previous darker colors were causing inconsistency with design system
- Ensures highlights use the same color values across all theme variants
2025-10-14 10:23:55 +02:00
Gigi
bca6458e44 fix(theme): improve highlight contrast in light themes
- Use darker yellow (yellow-400 instead of yellow-300) for better visibility
- Use darker orange (orange-600 instead of orange-500)
- Sepia theme uses even darker highlights (yellow-500, red-600)
- Ensures text and icons remain visible on highlighted text
- Applies to all light theme variants and system mode
2025-10-14 10:12:22 +02:00
Gigi
ebdfa3b5a3 fix(lint): replace 'any' types with proper type definitions
- Add DarkColorTheme and LightColorTheme type definitions
- Replace 'as any' with proper type assertions
- All eslint and TypeScript checks now pass
2025-10-14 10:10:57 +02:00
Gigi
17480dddbf fix(theme): improve text contrast in dark color themes
- Add text color definitions to all dark theme variants
- Ensure bright text (zinc-200) for readability on dark backgrounds
- Update --color-bg-subtle to be darker for better hierarchy
- Fixes low contrast issue where text was barely readable
2025-10-14 10:06:43 +02:00
Gigi
2a422fbeb9 fix(theme): use darker background for app body
- Change body background from --color-bg to --color-bg-subtle
- Creates visual depth and hierarchy between app bg and content
- Content panels now stand out more against the background
2025-10-14 10:05:10 +02:00
Gigi
22961ee479 fix(theme): update reading progress indicator to use theme colors
- Replace hard-coded dark background with --color-bg-elevated
- Use --color-border for progress track
- Use --color-primary for progress bar
- Use --color-text-muted for percentage text
- Indicator now adapts to light/dark themes
2025-10-14 10:02:43 +02:00
Gigi
18db905974 refactor(theme): rename labels from 'Colors' to 'Theme'
- Change 'Dark Colors' to 'Dark Theme'
- Change 'Light Colors' to 'Light Theme'
- More consistent and clearer labeling
2025-10-14 10:00:48 +02:00
Gigi
689963c041 refactor(theme): change default light theme to sepia
- Update default from paper-white to sepia for warmer reading
- Midnight remains default for dark mode
- Sepia provides warm, eye-friendly tones for light mode
2025-10-14 10:00:21 +02:00
Gigi
3f8869fd75 refactor(theme): show color swatches instead of text labels
- Replace text buttons with color swatches for theme selection
- Use actual background colors to preview each theme
- Add border for white swatch to make it visible
- Tooltips show theme names on hover
2025-10-14 09:58:47 +02:00
Gigi
129aced1a2 feat(theme): add color theme variants for light and dark modes
- Add darkColorTheme: black, midnight (default), charcoal
- Add lightColorTheme: paper-white (default), sepia, ivory
- Extend UserSettings with color theme fields
- Update ThemeSettings UI to show color options
- Add CSS variables for all color theme variants
- Sepia and Ivory have warm, reading-friendly palettes
- Black offers true black for OLED screens
- All color themes sync via Nostr (NIP-78)
2025-10-14 09:39:13 +02:00
Gigi
69febf4356 refactor(theme): remove localStorage, use only Nostr for persistence
- Remove localStorage.setItem/getItem from theme.ts
- Simplify early boot script to just default to system theme
- Theme now loads purely from NIP-78 settings
- Prevents race conditions between localStorage and Nostr settings
2025-10-14 09:34:54 +02:00
Gigi
65051c9c1f fix(theme): apply theme colors to body element
- Add background and text color to body
- Ensures page background changes with theme
2025-10-14 09:25:13 +02:00
Gigi
ba8229d464 refactor(css): update pull-to-refresh to use semantic tokens 2025-10-14 09:24:49 +02:00
Gigi
9251b017d4 refactor(css): migrate remaining components to semantic tokens
- Update icon-button.css, profile.css, me.css to use tokens
- Migrate reader.css to semantic colors for light theme
- Update toast.css with theme-aware colors
- All major UI components now support theme switching
2025-10-14 09:24:31 +02:00
Gigi
1ae76031f3 refactor(css): migrate cards/forms/layout to semantic tokens
- Replace hard-coded colors with CSS variables in cards.css
- Update forms.css, settings.css, modals.css with tokens
- Migrate sidebar.css and highlights.css to use semantic colors
- Update layout/app.css and base/global.css
- Enables proper light/dark theme switching
2025-10-14 09:13:42 +02:00
Gigi
994d834a0b feat(theme): add CSS variable tokens and theme classes
- Define semantic color tokens (--color-bg, --color-text, etc.)
- Add .theme-dark, .theme-light, .theme-system CSS classes
- Create theme.ts utility for theme application
- Add early boot theme script to prevent FOUC
- Support system preference with live updates
2025-10-14 09:11:38 +02:00
Gigi
67a4e17055 feat: add ants link to empty writings state for other users
- Update empty writings message for other users' profiles
- Show 'No articles written. You can find other stuff from this user using ants.'
- Link 'ants' to the ants.sh profile page for that user
- Keep original message for own profile
2025-10-14 01:42:29 +02:00
Gigi
1e82e3f240 fix: change empty state text color from red to gray
- Create new .explore-empty class with muted gray color (zinc-400)
- Keep .explore-error red for actual errors
- Update all empty state divs in Me.tsx to use .explore-empty
- Empty states (no highlights, no bookmarks, etc.) no longer appear as errors
2025-10-14 01:38:35 +02:00
Gigi
f973c75ff5 feat: match highlight comment color to highlight level color
- Remove hardcoded blue color from highlight comments
- Apply level-specific colors (mine/friends/nostrverse) to comment borders
- Use color-mix for subtle background tint matching highlight color
- Comment styling now respects user's highlight color settings
2025-10-14 01:36:59 +02:00
Gigi
28316a71c5 feat: open all profile links within app instead of external portals
- Update nostrUriResolver to return internal /p/:npub links for npub/nprofile
- Replace external profile links with React Router Link components
- Update ResolvedMention, LargeView, and CardView components
- Convert nprofile to npub before routing
- Keep note/nevent links as external (no internal viewer yet)
2025-10-14 01:32:28 +02:00
Gigi
cfc12e2d78 feat: add playful empty state message for other users' profiles
- Show 'You should shame them on nostr!' when viewing profiles with no highlights
- Keep original helpful message for own profile
- Conditional based on isOwnProfile flag
2025-10-14 01:29:54 +02:00
Gigi
7464a8b505 chore: bump version to 0.6.3 2025-10-14 01:27:22 +02:00
Gigi
938d79663b fix: remove unused isRefreshing parameter from PullToRefreshIndicator
- Keep prop in interface for backward compatibility
- Don't destructure unused parameter to satisfy linter
- All lint checks and type checks now pass
2025-10-14 01:13:02 +02:00
Gigi
cc0ad69275 chore: remove COLOR_SYSTEM.md documentation file 2025-10-14 01:11:32 +02:00
Gigi
810ff060f8 feat: make relay status indicator circular FAB on mobile
- Default to collapsed (icon only) on mobile
- Expand to show details when tapped on mobile
- Circular 56px FAB when collapsed, matching highlight button style
- Desktop always shows expanded with details
- Hide on scroll via showOnMobile prop (matches sidepanel buttons)
2025-10-14 01:11:14 +02:00
Gigi
5e03ef70a6 style: improve relay status indicator text layout
- Make subtitle text smaller (0.75rem) with reduced opacity
- Display text in column layout with proper line spacing
- Subtitle now appears on second line below title
- Apply consistent styling to offline and flight mode subtitles
2025-10-14 01:08:28 +02:00
Gigi
f05fb29c7b refactor: remove refresh spinner and text from pull-to-refresh
- Remove 'Refreshing...' text from indicator
- Remove spinner from pull-to-refresh (button already spins)
- Only show indicator when actively pulling, not when refreshing
- Simplify logic and improve UX consistency
2025-10-14 01:05:22 +02:00
Gigi
e737b1f7f0 fix: position relay status indicator in bottom-left corner
- Add fixed positioning (bottom-left) to match highlight button (bottom-right)
- Add modern styling with semi-transparent background, blur, and shadow
- Ensure proper visibility on mobile with smooth transitions
- Maintain responsive behavior for expanded/collapsed states
2025-10-14 01:04:18 +02:00
Gigi
21a7be2f98 fix: unify highlight visibility button styling across app
- Remove blue primary variant from highlight filter buttons
- Use opacity (1.0 active, 0.4 inactive) instead of variant change
- Update settings to use IconButton like sidebar (DRY)
- Consistent styling: ghost variant + opacity + custom colors
- Settings buttons now match sidebar buttons exactly
2025-10-14 01:02:46 +02:00
Gigi
4c720aa049 feat: add public profile pages at /p/:npub
- Make AuthorCard clickable to navigate to user profiles
- Add /p/:npub and /p/:npub/writings routes
- Reuse Me component for public profiles with pubkey prop
- Show highlights and writings tabs for any user
- Hide private tabs (reading-list, archive) on public profiles
- Public profiles show only public data (highlights, writings)
- Private data (bookmarks, read articles) only visible on own profile
- Add clickable author card hover styles with indigo border
- Decode npub to pubkey for profile viewing
- DRY: Single Me component serves both /me and /p/:npub routes
2025-10-14 01:01:10 +02:00
Gigi
6b240b01ec docs: update changelog for v0.6.2 2025-10-14 00:54:28 +02:00
Gigi
945894e3db chore: bump version to 0.6.2 2025-10-14 00:53:17 +02:00
Gigi
667397e528 fix: align title, summary, meta, and body text in reader
- Add consistent 2rem horizontal padding to reader-header on desktop
- Apply same padding to reader-summary-below-image, article-menu-container, and mark-as-read-container
- All content elements now align properly with body text
- Mobile (< 769px) retains base padding only
2025-10-14 00:52:19 +02:00
Gigi
e4b0d6d1cd refactor: unify button styles across sidebars using IconButton
- Convert HighlightsPanel buttons to use IconButton component
- Add style prop support to IconButton for custom styling
- Remove redundant CSS for old button classes (level-toggle-btn, refresh-highlights-btn, etc.)
- Keep only highlight-level-toggles container styling
- Consistent button appearance across left and right sidebars
- DRY: Single IconButton component handles all sidebar buttons
2025-10-14 00:50:52 +02:00
Gigi
3cdda2dcb7 refactor: move bookmark refresh button to footer with view controls
- Remove separate refresh section from bookmarks list
- Add refresh button to view-mode-controls footer
- Show last update time in button tooltip instead of inline text
- Cleaner UI with all controls in one footer section
2025-10-14 00:48:47 +02:00
Gigi
876ecc808d feat: add pull-to-refresh for mobile on all scrollable views
- Create reusable usePullToRefresh hook with touch gesture detection
- Add PullToRefreshIndicator component with visual feedback
- Implement pull-to-refresh on HighlightsPanel (right sidebar)
- Implement pull-to-refresh on Explore page
- Implement pull-to-refresh on Me pages (all tabs)
- Implement pull-to-refresh on BookmarkList (left sidebar)
- Only activates on touch devices for mobile-first experience
- Shows rotating arrow icon that becomes refresh spinner
- Displays contextual messages (pull/release/refreshing)
- Integrates with existing refresh handlers and loading states
2025-10-14 00:47:48 +02:00
Gigi
34671bd067 feat: add three-dot menu for external URLs in /r/ path
- Add menu button with options to open original URL, copy URL, and share
- Reuse existing menu styling for consistency
- Menu positioned at end of article content before mark-as-read button
2025-10-14 00:39:53 +02:00
Gigi
a6285f6a1d fix(highlights): precise normalized-to-original mapping to eliminate intra-word spaces
- Walk original text across node boundaries while tracking normalized positions
- Identify exact start/end nodes and offsets for the match
- Compute combined indices from node spans to create accurate DOM Range
- Eliminates artifacts like 'We b' by preventing whitespace from splitting words
- Keeps strict bounds checks and graceful failures
2025-10-14 00:37:56 +02:00
Gigi
36508d600a fix(highlights): remove existing highlight marks before applying new ones
- Strip all existing mark elements from HTML before re-highlighting
- Prevents old broken highlights from persisting in the DOM
- Ensures clean text is used as the base for new highlight application
- Fixes 'We b' spacing issue caused by corrupted marks from previous buggy renders
- Remove debug logging now that position mapping is working correctly
2025-10-14 00:34:55 +02:00
Gigi
a304bb7c26 debug: add detailed position mapping logging to diagnose spacing issues
- Log search text, match indices, and extracted text during position mapping
- Show sample of combined text around the extracted range
- Help identify where position mapping is going wrong for 'We b' issue
2025-10-14 00:31:59 +02:00
Gigi
04bab96a07 fix(highlights): improve normalized text position mapping to prevent character spacing issues
- Build explicit position map array from normalized to original text indices
- Properly handle whitespace sequences in position mapping
- Ensure each normalized character position maps to correct original position
- Validate mapped positions are within bounds before using
- Fixes spacing issues like 'We b' appearing instead of 'Web' in highlights
2025-10-14 00:27:38 +02:00
Gigi
22ebbff755 fix(highlights): add text validation before applying highlights
- Validate extracted range text matches search text before highlighting
- Check single-node matches are not empty or whitespace-only
- Compare both exact and normalized text to handle whitespace variations
- Prevent broken/corrupted highlights from being applied to DOM
- Add detailed logging for validation failures to aid debugging
2025-10-14 00:25:18 +02:00
Gigi
b43f40597f fix(highlights): add robust validation and error handling for multi-node highlighting
- Implement proper normalized-to-original text position mapping
- Add comprehensive validation for range indices and node offsets
- Verify range is not collapsed before extracting content
- Add try-catch block to handle DOM manipulation errors gracefully
- Add detailed warning logs for debugging failed highlight matches
- Prevent invalid ranges from corrupting the DOM structure
- Fix broken text nodes and visual artifacts in highlighted content
2025-10-14 00:23:43 +02:00
Gigi
fe3af25c5f docs: update changelog for v0.6.1 release 2025-10-14 00:21:31 +02:00
Gigi
ffafc6f64d chore: bump version to 0.6.1 2025-10-14 00:20:37 +02:00
Gigi
eadab9a37f fix(highlights): restore scroll-to-highlight functionality with MutationObserver
- Add MutationObserver to detect when highlights are added/removed from DOM
- Use contentVersion state to trigger re-attachment of click handlers
- Add contentVersion dependency to scroll effect so it re-runs after DOM updates
- Add 100ms delay to scroll effect to ensure DOM is fully rendered
- Add warning log when mark element cannot be found
- Fixes issue where clicking highlights in sidebar wouldn't scroll after DOM changes
2025-10-14 00:19:45 +02:00
Gigi
13b1692931 fix(highlights): create single continuous highlight element for multi-node selections
- Use DOM Range API to extract and wrap content in a single mark element
- Preserves internal DOM structure (links, formatting) within the highlight
- Eliminates visual breaks between multiple mark elements
- Ensures highlight appears as one continuous selection even across inline elements
2025-10-14 00:18:30 +02:00
Gigi
3a78289fee fix(highlights): make multi-node highlights appear seamless
- Remove border-radius (set to 0) to eliminate rounded corner breaks
- Remove horizontal padding (only 0.1rem vertical for slight breathing room)
- Remove all box-shadows that create visual separation
- Simplify hover states for cleaner appearance
- Highlights spanning multiple DOM nodes now appear as one continuous highlight
2025-10-14 00:16:37 +02:00
Gigi
12c70b06de fix(highlights): improve text matching to handle multi-node selections
- Add tryMultiNodeMatch function to find text spanning multiple DOM nodes
- Build combined text from all text nodes for comprehensive matching
- Handle highlighting across node boundaries with proper offsets
- Falls back to multi-node matching when single-node match fails
- Fixes issue where selections with inline formatting couldn't be matched
2025-10-14 00:05:43 +02:00
Gigi
c7c82954ad feat(highlights): force synchronous render for immediate highlight display
- Use flushSync to force React to render synchronously after highlight creation
- Eliminates render cycle delay for instant visual feedback
- Highlights now appear immediately in the text when created
2025-10-14 00:03:56 +02:00
Gigi
3b639e2783 feat(highlights): clear browser selection immediately after creating highlight
- Add window.getSelection().removeAllRanges() after highlight creation
- Ensures DOM can update immediately without selection interference
- Improves perceived responsiveness of highlight creation
2025-10-14 00:03:19 +02:00
Gigi
946584236d fix(mobile): prevent horizontal overflow from code blocks and wide content
- Add mobile-specific styles for pre/code elements with word-wrap
- Use pre-wrap for code blocks to wrap long lines on mobile
- Add max-width and overflow-x constraints to main pane container
- Add overflow-x: hidden to body to prevent horizontal scrolling
- Handle tables and images with max-width: 100% on mobile
- Ensure all content respects viewport width on mobile devices
2025-10-13 23:58:39 +02:00
Gigi
aadbf2084f fix(mobile): hide sidebar/highlights toggle buttons on settings, explore, and me pages
- Only show mobile floating buttons when viewing article content
- Hide buttons on settings/explore/me views to avoid UI clutter
- Update conditional rendering logic in ThreePaneLayout
2025-10-13 23:57:06 +02:00
Gigi
3d7b649cba fix(ui): prevent long relay URLs from causing horizontal overflow on mobile
- Add relay-url className to relay URL elements
- Override inline nowrap styles on mobile with word-break: break-all
- Allow relay URLs to wrap across multiple lines on mobile
- Prevent horizontal overflow from long relay names like proxy URLs
2025-10-13 23:55:35 +02:00
Gigi
caa07012a7 fix(ui): make settings view mobile-friendly
- Add max-width: 100% and overflow handling to preview content
- Add word-break and overflow-wrap to prevent text overflow
- Make inline settings stack vertically on mobile
- Reduce padding on mobile for settings view
- Make zap preset buttons flex to fit mobile width
- Ensure all setting controls respect viewport width
- Fix preview heading size on mobile
2025-10-13 23:53:43 +02:00
Gigi
ad5cd875de feat(ui): improve zap splits settings styling
- Add styled preset buttons (Default, Generous, Selfless, Boris)
- Make sliders 100% width with full-width container
- Style active preset button with indigo-500
- Add hover effects to preset buttons and slider thumbs
- Improve slider thumb appearance with rounded design
- Style description box with proper background and borders
- Use Tailwind colors throughout (zinc, indigo)
2025-10-13 23:50:00 +02:00
Gigi
0a4bc2cfbb fix(ui): show filename for videos instead of 'Error Loading Content'
- Add getFilenameFromUrl helper to extract filename from URL
- Use filename as title when content loading fails (e.g., for video files)
- Decode URI component to handle special characters in filenames
- Improves UX for video and media file viewing
2025-10-13 23:48:11 +02:00
Gigi
605dd41939 fix(ui): render AddBookmarkModal using portal to fix z-index stacking
Use React createPortal to render modal directly to document.body, bypassing the sidebar's stacking context (z-index: 1) which was preventing the modal from appearing above other elements
2025-10-13 23:46:50 +02:00
Gigi
8679ae7a37 feat(ui): increase horizontal padding for reader text content on desktop
Add 2rem left and right padding to reader-html and reader-markdown on desktop (min-width: 769px) for better text spacing from edges
2025-10-13 23:43:28 +02:00
Gigi
3c1e4312c9 feat(me): add Writings tab to display user's published articles
- Add 'writings' tab type to Me component
- Fetch articles written by logged-in user using fetchBlogPostsFromAuthors
- Display writings in same grid style as archive tab
- Add pen-to-square icon for writings tab
- Add /me/writings route
- Update Bookmarks component to handle writings tab routing
- Show article count in tab badge
- Empty state message for users with no published articles
2025-10-13 23:42:26 +02:00
Gigi
53ed6849af feat(ui): add vertical padding to blockquotes
Add 1rem top and bottom padding to blockquotes for better spacing and visual separation
2025-10-13 23:38:14 +02:00
Gigi
4b95e6c262 feat(ui): add comprehensive list styling for articles
- Add proper ul/ol styling with disc and decimal markers
- Add 2rem left padding for list indentation
- Add proper spacing between list items (0.375rem)
- Style nested lists with circle (ul) and lower-alpha (ol)
- Reduce margins for nested lists
- Handle paragraphs within list items with reduced margins
- Use zinc-200 color for list items
- Support both markdown and HTML content
2025-10-13 23:37:32 +02:00
Gigi
40ab215c4d refactor(ui): simplify blockquote styling to minimal indent and italic
- Remove colored left border
- Remove background color
- Remove border radius and padding
- Keep only 2rem left padding for indentation
- Keep italic style for differentiation
- Cleaner, more minimal appearance
2025-10-13 23:36:28 +02:00
Gigi
823927525f feat(ui): add comprehensive headline styling with Tailwind typography
- Add proper h1-h6 styling for both markdown and HTML content
- Use Tailwind font sizes: h1 (text-4xl), h2 (text-3xl), h3 (text-2xl), h4 (text-xl), h5 (text-lg), h6 (text-base)
- Apply appropriate font weights: h1 (700), h2-h6 (600)
- Use zinc-100 for h1-h3, zinc-200 for h4-h6 for proper hierarchy
- Add proper top and bottom margins for better spacing
- Set line heights for optimal readability
2025-10-13 23:35:38 +02:00
Gigi
6277824b32 feat(ui): add blockquote styling with Tailwind colors
- Add blockquote styles for both markdown and HTML content
- Use indigo-500 left border for visual distinction
- Use zinc-800 background for subtle emphasis
- Add proper spacing and rounded corners
- Apply zinc-300 color and italic style for readability
- Properly handle nested paragraph margins
2025-10-13 23:34:30 +02:00
Gigi
f94e4ba900 feat(ui): make article titles larger and show summaries
- Increase title font size to 2.5rem (desktop) and 2rem (mobile)
- Add font-weight: 700 and better line-height to titles
- Increase summary font size to 1.2rem with better line-height
- Fix missing summary display by passing summary prop to ReaderHeader
- Improve readability and visual hierarchy of article headers
2025-10-13 23:33:23 +02:00
Gigi
acf14ccee9 feat(ui): add 100vh height and drop-shadows to sidebars
- Set sidebars to always have 100vh height on desktop
- Add drop-shadow to left sidebar (2px right shadow)
- Add drop-shadow to right highlights panel (2px left shadow)
- Improves visual separation and depth perception
2025-10-13 23:28:00 +02:00
Gigi
f882b63359 fix(ui): remove padding gaps around sidebars
Remove md:p-4 padding from root container to eliminate gaps between screen edges and sidebars
2025-10-13 23:26:25 +02:00
Gigi
7b1e3be39b fix(api): resolve TypeScript errors in video-meta.ts
- Remove non-existent getVideoDetails import from youtube-caption-extractor
- Add Subtitle type definition for proper type conversion
- Remove invalid 'auto' parameter from getSubtitles call
- Convert Subtitle[] to Caption[] with proper type casting
- Simplify title/description extraction for YouTube metadata
2025-10-13 23:23:52 +02:00
Gigi
ee17018076 docs: add comprehensive color system documentation 2025-10-13 23:19:57 +02:00
Gigi
1dd2e1dc38 refactor: switch to brighter yellow-300 for highlight defaults and add semantic color aliases 2025-10-13 23:19:33 +02:00
Gigi
4cd1aa89ad chore: remove migration plan file 2025-10-13 23:18:26 +02:00
Gigi
e667cf05c2 refactor: update default highlight color to yellow-400 in useSettings 2025-10-13 23:18:13 +02:00
Gigi
7512375728 refactor: update default highlight color to yellow-400 in ThreePaneLayout 2025-10-13 23:17:46 +02:00
Gigi
f108e2e70a refactor: replace arbitrary color values with Tailwind utilities in ThreePaneLayout 2025-10-13 23:17:21 +02:00
Gigi
daa43ec4c4 refactor: migrate global.css to Tailwind color palette 2025-10-13 23:16:51 +02:00
Gigi
ab2223e739 refactor: migrate app.css to Tailwind color palette 2025-10-13 23:16:28 +02:00
Gigi
e8cbb3af4b refactor: migrate legacy.css to Tailwind color palette 2025-10-13 23:16:06 +02:00
Gigi
f374a9af28 refactor: migrate settings.css to Tailwind color palette 2025-10-13 23:15:40 +02:00
Gigi
f2cbc66a97 refactor: migrate profile.css to Tailwind color palette 2025-10-13 23:15:21 +02:00
Gigi
1627d4f53e refactor: migrate me.css to Tailwind color palette 2025-10-13 23:14:56 +02:00
Gigi
b93a4d072a refactor: migrate reader.css to Tailwind color palette 2025-10-13 23:14:02 +02:00
Gigi
3e4bc97684 refactor: migrate toast.css to Tailwind color palette 2025-10-13 23:12:48 +02:00
Gigi
3c0c20f61c refactor: migrate modals.css to Tailwind color palette 2025-10-13 23:12:26 +02:00
Gigi
dae63e210b refactor: migrate forms.css to Tailwind color palette 2025-10-13 23:11:42 +02:00
Gigi
dc500cc296 refactor: migrate cards.css to Tailwind color palette 2025-10-13 23:11:03 +02:00
Gigi
1fc1e4f102 refactor: migrate icon-button.css to Tailwind color palette 2025-10-13 23:08:31 +02:00
Gigi
524b5e1559 refactor: migrate sidebar.css to Tailwind color palette 2025-10-13 23:07:53 +02:00
Gigi
930de76d1f refactor: migrate highlights.css to Tailwind color palette 2025-10-13 23:06:09 +02:00
Gigi
b85fc820d1 refactor: setup Tailwind color foundation with semantic aliases and updated defaults 2025-10-13 23:03:07 +02:00
Gigi
b145aee29d docs: add comprehensive Tailwind color migration plan 2025-10-13 22:54:22 +02:00
Gigi
a0e65a48f1 refactor: clean up legacy.css removing unused debugging styles 2025-10-13 22:51:49 +02:00
Gigi
ccdfc54cdc style: increase spacing between highlight card header/footer and content 2025-10-13 22:49:37 +02:00
Gigi
61ce338b8c fix: show reading progress indicator on mobile at full width 2025-10-13 22:48:39 +02:00
Gigi
47de9a75b7 style: remove rounded corners from bookmark sidebar header and fix profile avatar size 2025-10-13 22:48:23 +02:00
Gigi
607f3d46f0 fix: remove sidebar margins and constrain reading progress bar to content pane 2025-10-13 22:44:59 +02:00
Gigi
bdbc08fdf1 style: make mobile sidebar buttons more subtle and refined 2025-10-13 22:40:01 +02:00
Gigi
3a28160ae8 fix: prevent icon blurriness on mobile by setting explicit sizes 2025-10-13 22:38:36 +02:00
Gigi
e03696eed7 style: make sidebars extend edge-to-edge on desktop 2025-10-13 22:37:20 +02:00
Gigi
f80fa3de7f fix: correct highlight card borders and rounded corners 2025-10-13 22:36:04 +02:00
Gigi
4518fc16a7 docs: update changelog for v0.6.0 release 2025-10-13 22:34:09 +02:00
Gigi
7f2b70779b chore: bump version to 0.6.0 2025-10-13 22:33:18 +02:00
Gigi
cc9cc47b51 Merge pull request #5 from dergigi/reading-position
feat: reading position tracking and Tailwind CSS v4 migration
2025-10-13 22:32:52 +02:00
Gigi
a19cb8b6dc fix: remove mobile content pane gap and ensure full width display 2025-10-13 22:26:13 +02:00
Gigi
c564d1608b fix: remove padding on mobile main pane for edge-to-edge content
- Changed mobile .pane.main padding from 0.5rem to 0
- Content now extends fully edge-to-edge on mobile
- Matches design expectation for mobile reading experience
2025-10-13 22:22:24 +02:00
Gigi
c146a8f7ec style: make reading progress indicator smaller and more subtle
- Reduced bar height from 4px to 2px (h-0.5)
- Made container more compact: py-1 instead of py-2
- Tiny text size: 0.625rem (10px) with tabular numbers
- Simplified background: less opacity, lighter blur
- Show just % or checkmark when complete
- Reduced reader bottom padding from 4rem to 2rem
- More minimalist and less intrusive design
2025-10-13 22:20:01 +02:00
Gigi
48cde27a5b refactor: extract legacy styles to dedicated file
- Created src/styles/utils/legacy.css for bookmark/nostr styles
- Reduced index.css from 214 lines to 17 lines
- Fixed duplicate .loading style definitions
- DRY improvement: shared word-break pattern across nostr classes
- Better organization and maintainability
2025-10-13 22:18:12 +02:00
Gigi
fdf0644bbb refactor: massive cleanup of index.css duplicates
- Reduced from 3175 lines to 213 lines (-2960 lines!)
- Removed all reader, bookmark, highlight, settings, modal styles
- These are already imported from modular CSS files
- Only kept truly unique utility classes
- Fixes CSS duplication and specificity issues
2025-10-13 22:16:32 +02:00
Gigi
ec7371c43b fix: remove duplicate pane styles from index.css
- Removed 230+ lines of duplicate layout CSS
- Old inline styles were overriding our document scroll fixes
- Styles now only defined in src/styles/layout/app.css
- This fixes panes having overflow-y: auto and height: 100%
2025-10-13 22:14:57 +02:00
Gigi
35204ee400 fix: force document scroll with !important overrides
- Add explicit overflow: visible to main pane
- Add height: auto to main pane
- Ensure three-pane container doesn't constrain height
- Force styles to override any inherited overflow
2025-10-13 22:13:47 +02:00
Gigi
d1031b3342 fix: update Tailwind CSS import syntax for v4
- Change from @tailwind directives to @import syntax
- Move shimmer keyframes to CSS file (v4 convention)
- Fix Tailwind classes not being processed
2025-10-13 22:11:44 +02:00
Gigi
db67e94b9e fix: update PostCSS config for Tailwind v4
- Install @tailwindcss/postcss for Tailwind v4 compatibility
- Update postcss.config.js to use new plugin format
- Fix dev server PostCSS errors
2025-10-13 22:03:38 +02:00
Gigi
a0e5ba3a63 docs: add comprehensive Tailwind migration documentation
- Document completed migration phases (setup, base, layout, components)
- Track metrics: 190+ lines of CSS removed
- Define strategy for incremental component migrations
- Document z-index layering and responsive breakpoints
- Provide technical notes for future development
- Mark core migration as complete and production-ready
2025-10-13 22:00:34 +02:00
Gigi
f3f80449a6 refactor(layout): migrate mobile buttons to Tailwind utilities
- Convert mobile hamburger and highlights buttons to Tailwind
- Migrate mobile backdrop to Tailwind utilities
- Remove 60+ lines of CSS from app.css and sidebar.css
- Maintain responsive behavior and z-index layering
- Keep dynamic color support for highlight button
2025-10-13 21:58:50 +02:00
Gigi
bd0b4e848f docs: update changelog with Tailwind migration progress 2025-10-13 21:38:10 +02:00
Gigi
4f5ba99214 feat(reader): convert reading progress indicator to Tailwind
- Replace CSS classes with Tailwind utilities
- Use gradient backgrounds with conditional colors
- Add shimmer animation to Tailwind config
- Remove 80+ lines of CSS from reader.css
- Maintain z-index layering (1102) above mobile overlays
- Responsive design with utility classes
2025-10-13 21:37:08 +02:00
Gigi
aab67d8375 refactor(layout): switch to document scroll with sticky sidebars
- Remove fixed container heights from three-pane layout
- Desktop: sticky sidebars with max-height, document scrolls
- Mobile: keep fixed overlays unchanged
- Update scroll direction hook to use window scroll
- Update progress indicator z-index to 1102 (above mobile overlays)
- Apply Tailwind utilities to App container
- Maintain responsive behavior across breakpoints
2025-10-13 21:36:08 +02:00
Gigi
dbc0a48194 style(global): reconcile base styles with Tailwind preflight
- Add CSS variables for user-settable highlight colors
- Add reading font and font size variables
- Simplify global.css to work with Tailwind preflight
- Remove redundant body/root styles handled by Tailwind
- Keep app-specific overrides (mobile sidebar lock, loading states)
2025-10-13 21:18:31 +02:00
Gigi
6a84646b0b chore(tailwind): setup Tailwind CSS with preflight on
- Install tailwindcss, postcss, autoprefixer
- Add tailwind.config.js and postcss.config.js
- Create src/styles/tailwind.css with base/components/utilities
- Import Tailwind before index.css in main.tsx
2025-10-13 21:17:11 +02:00
Gigi
e921967082 fix: move progress indicator outside reader and fix position tracking
- Move ReadingProgressIndicator outside reader div for true fixed positioning
- Replace position-indicator library with custom scroll tracking
- Track document scroll position instead of content scroll
- Remove unused position-indicator dependency
- Ensure progress indicator is always visible and shows correct percentage
2025-10-13 21:04:39 +02:00
Gigi
ec34bc3d04 fix: position reading progress indicator at bottom of screen
- Move progress indicator from top to bottom of viewport
- Add box shadow for better visual separation
- Update hide animation to slide up from bottom
- Add padding to reader content to prevent overlap
- Ensure indicator is always visible while scrolling
2025-10-13 21:02:52 +02:00
Gigi
96ce12b952 feat: add reading position tracking with visual progress indicator
- Install position-indicator library for scroll position tracking
- Create useReadingPosition hook for position management
- Add ReadingProgressIndicator component with animated progress bar
- Integrate reading progress in ContentPanel for text content only
- Add CSS styles for fixed progress indicator with shimmer animation
- Track reading completion at 90% threshold
- Exclude video content from position tracking
2025-10-13 21:01:44 +02:00
Gigi
1066c43d6c docs(changelog): add v0.5.7 entry with video features and improvements 2025-10-13 20:57:33 +02:00
Gigi
914557a61d chore: bump version to 0.5.7 2025-10-13 20:56:41 +02:00
Gigi
3df2f248ff fix: use negative margins to make video edge-to-edge within reader
- Add negative left/right margins (-0.75rem) to counteract reader padding
- Video now extends to the true edges of the reader container
- Maintains responsive sizing with 80vw width and aspect ratio
- Achieves true edge-to-edge video display
2025-10-13 20:53:39 +02:00
Gigi
d2770d58e2 fix: remove left/right margins from video player for edge-to-edge display
- Change margin from '0 auto 1rem auto' to '0 0 1rem 0'
- Remove auto left/right margins that were centering the video
- Keep bottom margin for spacing from content below
- Video player now aligns to left edge of card container
2025-10-13 20:27:08 +02:00
Gigi
933182567d fix: use viewport width for video container to break parent constraints
- Change width from 100% to 80vw (80% of viewport width)
- Increase min-width to 400px for better minimum size
- Increase max-width to 1000px for larger screens
- This makes video container independent of parent width constraints
- Ensures video is always properly sized regardless of title length
2025-10-13 20:23:12 +02:00
Gigi
f9fa2f05f0 feat: implement responsive video player with aspect ratio
- Update ReactPlayer to use width='100%', height='auto' with aspectRatio: '16/9'
- Replace padding-top approach with modern aspect-ratio CSS property
- Add minimum width (300px) and maximum width (800px) constraints
- Center video container with margin: 0 auto
- Ensure video player is no longer constrained by title length
- Improve video viewing experience across different screen sizes
2025-10-13 20:19:08 +02:00
Gigi
919bb8151f fix: resolve linting and type checking issues
- Replace 'any' type with proper UserSettings type in CompactView
- Fix import path for UserSettings from services/settingsService
- Resolve @typescript-eslint/no-explicit-any warning
- Ensure all TypeScript type checks pass
- Maintain strict linting rules without removing any rules
2025-10-13 20:14:54 +02:00
Gigi
6f82674c9b feat: add thumbnail images to compact view
- Add compact-thumbnail styling for small square images (24x24px)
- Update CompactView component to include thumbnail images on the left
- Use useImageCache hook to get cached article images
- Add settings prop to CompactView interface
- Position thumbnails before bookmark type icon in compact row
- Match design from screenshot with small square thumbnails on left side
- Improve visual hierarchy and content recognition in compact view
2025-10-13 20:12:54 +02:00
Gigi
8caf9988fc feat: enhance borders for reading list cards
- Add specific border styling for .bookmarks-list .individual-bookmark
- Use darker border color (#444) for better visibility
- Add background color (#1a1a1a) to make cards more distinct
- Enhance hover states with brighter border (#555) and background (#252525)
- Use !important to ensure styles override existing CSS
- Improves visual separation and card definition in reading list
2025-10-13 20:11:08 +02:00
Gigi
036ee20d98 feat: add URL routing for /me page tabs
- Add routes for /me/highlights, /me/reading-list, /me/archive
- Redirect /me to /me/highlights by default
- Update Bookmarks component to extract tab from URL path
- Pass activeTab prop to Me component based on current route
- Update Me component to use URL-based tab state instead of local state
- Update tab click handlers to navigate to appropriate URLs
- Enable deep-linking to specific tabs (e.g., /me/reading-list)
2025-10-13 20:09:46 +02:00
Gigi
b86545dcc8 fix: left-align text in reading list elements
- Add text-align: left to .bookmarks-list to override center alignment from .app
- Apply left alignment to all individual bookmark elements and their children
- Ensures reading list content is properly left-aligned for better readability
- Maintains consistent text alignment for bookmark titles, content, and metadata
2025-10-13 20:07:05 +02:00
Gigi
8bdccd9c9e feat: enable bookmark navigation in reading list
- Add useNavigate hook to Me component
- Implement handleSelectUrl function for bookmark navigation
- Pass onSelectUrl prop to BookmarkItem components in reading list
- Support both regular URLs (/r/*) and nostr articles (/a/*) navigation
- Enables clicking bookmarks in reading list to open content in main pane
2025-10-13 20:06:42 +02:00
Gigi
9a14185fa5 feat: color reading list tab blue to match bookmarks icon
- Apply #646cff color to reading-list tab when active
- Matches the blue color used throughout the app for bookmarks
- Provides visual consistency between bookmarks icon and reading list tab
- Uses same color as bookmark-type and other bookmark-related elements
2025-10-13 20:03:51 +02:00
Gigi
53a6053464 fix: prevent profile element from bleeding off screen on mobile
- Add horizontal margin (0 1rem) to author-card-container on mobile
- Set max-width to calc(100vw - 2rem) to ensure it fits within screen bounds
- Add box-sizing: border-box to both container and card for proper sizing
- Ensures profile element has equal left/right margins and doesn't exceed screen width
2025-10-13 20:03:00 +02:00
Gigi
e27d7ee26c feat: increase spacing between mobile buttons and profile element
- Increase margin-top from 2.25rem to 3.5rem for explore-header on mobile
- Provides more breathing room between floating action buttons and profile
- Improves mobile UX by preventing visual crowding
2025-10-13 20:01:22 +02:00
Gigi
98203e6b6f feat: hide tab counts on mobile for /me page
- Wrap tab labels and counts in separate spans for better control
- Hide counts on mobile devices (max-width: 768px) to save space
- Maintain counts on desktop for better UX
- Follows mobile-first design principles
2025-10-13 19:59:16 +02:00
Gigi
8469740141 fix: resolve TypeScript errors in youtube-meta.ts
- Remove non-existent getVideoDetails import and usage
- Fix getSubtitles API call to match actual package interface
- Add proper Subtitle type to replace any usage
- Convert subtitle data types to match Caption interface
- Install missing @vercel/node dependency
2025-10-13 19:57:38 +02:00
Gigi
8fff2bce52 feat(api): add Vimeo video metadata extraction support
- Create unified video-meta.ts API handler for both YouTube and Vimeo
- Add Vimeo oEmbed API integration for server-side metadata extraction
- Implement URL pattern matching for YouTube and Vimeo video detection
- Support both URL and videoId parameters for backward compatibility
- Add proper TypeScript types for Vimeo oEmbed response
- Include caching mechanism for Vimeo metadata (7-day cache)
- Remove unused @vimeo/player package dependency

The new API endpoint supports:
- YouTube: /api/video-meta?url=https://youtube.com/watch?v=ID or ?videoId=ID
- Vimeo: /api/video-meta?url=https://vimeo.com/ID
- Returns consistent response format for both platforms
2025-10-13 19:52:01 +02:00
Gigi
30b98fc744 refactor(api): improve type safety in youtube-meta handler
- Replace 'any' type with proper type annotations
- Add explicit type checking for video details response
- Improve description field extraction with better type safety
- Add comments for better code documentation
2025-10-13 19:49:04 +02:00
Gigi
7a190b7d35 fix(api): be more lenient extracting YouTube description from details fields 2025-10-13 19:43:04 +02:00
Gigi
e3149c40c7 fix(types): correct setTimeout ref type in Settings to ReturnType<typeof setTimeout> 2025-10-13 19:37:37 +02:00
Gigi
91743518bd feat(reader): use YouTube title as header title, description as body; show transcript section 2025-10-13 19:33:42 +02:00
Gigi
fd2e4079ab feat(reader): fetch YouTube title/description/captions with 7d client cache; transcript toggle 2025-10-13 19:28:46 +02:00
Gigi
ec423cad80 feat(services): youtubeMetaService with 7d localStorage cache and ID extraction 2025-10-13 19:27:34 +02:00
Gigi
8f8441b0e0 feat(api): youtube-meta endpoint with 7d in-memory cache and captions/details via extractor 2025-10-13 19:26:53 +02:00
Gigi
3c20d45dba chore(deps): add @treeee/youtube-caption-extractor 2025-10-13 19:25:59 +02:00
Gigi
75c4e20dc9 fix(video): implement proper react-player responsive pattern from docs 2025-10-13 19:12:59 +02:00
Gigi
9d27595d31 fix(video): simplify video container - remove negative margins and complex layout hacks 2025-10-13 19:10:56 +02:00
Gigi
b7d90a790b style(layout): remove max-width on main pane, constrain reader instead; full width for videos 2025-10-13 19:08:57 +02:00
Gigi
c49d850f74 refactor(video): extract buildNativeVideoUrl to reusable utility (DRY) 2025-10-13 18:31:29 +02:00
Gigi
4c11c5fc54 fix(reader): use responsive aspect-ratio container for videos to fill full width 2025-10-13 18:30:09 +02:00
Gigi
44befab6d3 style(reader): make video container break out of reader padding for full width 2025-10-13 18:28:28 +02:00
Gigi
02a2f4b85e chore(deps): update package-lock.json for version 0.5.6 and react-player 2025-10-13 18:27:43 +02:00
Gigi
43d54b5734 refactor(bookmarks): clean up unused getIconForUrlType in CompactView and fix prop passing 2025-10-13 18:27:22 +02:00
Gigi
b7896be507 fix(types): replace ShareData with inline type to fix lint errors 2025-10-13 18:26:36 +02:00
Gigi
eeb40306da style(layout): make main pane full width when displaying videos 2025-10-13 18:25:08 +02:00
Gigi
749b47ac5c feat(reader): show 'Mark as Watched' for video URLs (icon unchanged) 2025-10-13 18:24:18 +02:00
Gigi
42f59f2b19 feat(reader): add three-dot menu under videos with open/native/copy/share actions 2025-10-13 18:23:12 +02:00
Gigi
2bf6e742f1 feat(reader): show video duration for /r/ video URLs using react-player onDuration 2025-10-13 17:30:36 +02:00
Gigi
2a2049e678 style(reader): widen main pane when showing videos; add reader-video styles 2025-10-13 17:29:21 +02:00
Gigi
146aa85e76 feat(bookmarks): make entire bookmark cards clickable; stop propagation on internal controls 2025-10-13 17:27:25 +02:00
Gigi
a26c7497b5 feat(reader): embed external videos in /r/ using react-player; add vimeo/dailymotion detection 2025-10-13 17:25:34 +02:00
Gigi
da67135f5e chore(deps): add react-player for embedding videos in reader 2025-10-13 17:24:10 +02:00
Gigi
aebb6d1762 refactor(bookmarks): remove READ/VIEW/WATCH CTA buttons and texts; simplify classifyUrl 2025-10-13 17:21:59 +02:00
Gigi
8f5cf6a0b4 refactor(utils): remove CTA buttonText from classifyUrl and UrlClassification 2025-10-13 17:20:02 +02:00
Gigi
875017db96 docs(changelog): add v0.5.6 entry (Keep a Changelog format) 2025-10-13 17:15:34 +02:00
Gigi
c0f34b684d chore: bump version to 0.5.6 2025-10-13 17:12:24 +02:00
Gigi
613956bbaf fix: use round checkmark icon (faCheckCircle) in Mark as Read button 2025-10-13 17:10:29 +02:00
Gigi
041ba5c05b style: remove horizontal divider above Mark as Read button 2025-10-13 17:09:26 +02:00
Gigi
05c21cfd6d style: remove horizontal divider below article menu button 2025-10-13 17:07:02 +02:00
Gigi
4898f99ae1 style: make article menu button more subtle by removing border 2025-10-13 17:05:49 +02:00
Gigi
be920e8c44 fix: remove extra horizontal divider above article menu 2025-10-13 17:05:15 +02:00
Gigi
0fa5ac536b feat: add three-dot menu to articles and enhance highlight menus
- Add three-dot menu button at end of articles (before Mark as Read)
- Right-aligned menu with two options:
  - Open on Nostr (using nostr gateway/portal)
  - Open with Native App (using nostr: URI scheme)
- Add 'Open with Native App' option to highlight card menus
- Menu only appears for nostr-native articles (kind:30023)
- Styled consistently with highlight card menus
- Click outside to close menu functionality
2025-10-13 17:03:00 +02:00
Gigi
cef359af29 fix: ensure code blocks use monospace fonts explicitly
- Add explicit monospace font-family to all pre elements
- Include Courier New as additional cross-platform fallback
- Apply to both reader-markdown and reader-html contexts
- Ensures code always renders in monospace even if Prism theme is overridden
2025-10-13 16:58:03 +02:00
Gigi
2de72b73c1 feat: add Prism.js syntax highlighting for code blocks
- Install prismjs and rehype-prism-plus packages
- Integrate rehype-prism plugin into ReactMarkdown
- Use prism-tomorrow dark theme for syntax highlighting
- Enhanced code block styling with better padding and borders
- Inline code now has distinct styling from code blocks
- Monospace font for all code (Monaco, Menlo, Consolas)
- Improved readability with proper line-height and spacing
2025-10-13 16:55:06 +02:00
Gigi
a794331c1a feat: add image placeholders to blog post cards in /explore
- Show newspaper icon placeholder when blog posts don't have images
- Always render image container with consistent height
- Match the same placeholder style as large bookmark preview
- Improves visual consistency across the app
2025-10-13 16:52:32 +02:00
Gigi
e09be543bc feat: add caching to /me page for faster loading
- Create meCache service to store highlights, bookmarks, and read articles
- Seed Me component from cache on load to avoid empty flash
- Show small spinner while refreshing if cached data is displayed
- Update cache when highlights are deleted
- Only show full loading screen if no cached data is available
- Improves perceived performance similar to /explore page
2025-10-13 16:50:43 +02:00
Gigi
88085c48d2 style: improve bookmarks sidebar visual design
- Replace green buttons with purple/blue primary color
- Add subtle borders to card and large preview views
- Enable image previews in card view for all bookmarks (not just articles)
- Fetch OG images for regular bookmarks in card view
- Improve hover states with lighter border colors
2025-10-13 16:47:41 +02:00
Gigi
e32010771b refactor: make /me Reading List use same components as bookmark sidebar
- Reuse BookmarkItem component for rendering individual bookmarks
- Apply same filtering logic (hasContentOrUrl) as BookmarkList
- Add view mode controls (compact/cards/large) matching sidebar
- Count shows individual bookmarks not bookmark lists
- Keeps code DRY by reusing existing components and logic
2025-10-13 16:41:01 +02:00
Gigi
03e7484e71 fix: preserve reading font settings in markdown images
- Remove inline styles from custom image component
- Let CSS inheritance handle font and styling properly
- Images now respect user's reading font and size settings
2025-10-13 16:35:11 +02:00
Gigi
d9fd4ec286 feat: enable inline image rendering in nostr-native blog posts
- Install rehype-raw plugin for HTML support in ReactMarkdown
- Configure ReactMarkdown to parse and render HTML img tags
- Add responsive image styling with max-width and auto height
- Images now render inline in nostr-native blog posts with proper styling
2025-10-13 16:34:08 +02:00
Gigi
8f14f0347c docs: update CHANGELOG for v0.5.5 2025-10-13 16:33:03 +02:00
Gigi
9b5bb8496f chore: bump version to 0.5.5 2025-10-13 16:32:25 +02:00
Gigi
9264a78c95 Merge pull request #4 from dergigi/me-page
feat(me): two-pane layout, tabs, refined highlight cards, and mark-as-read
2025-10-13 15:31:44 +01:00
Gigi
326d571871 fix(content): include currentArticle in useEffect deps to satisfy lint 2025-10-13 16:27:18 +02:00
Gigi
744e86b290 style: match /me profile card width to highlight cards
- Constrain AuthorCard width via header context
- Remove extra padding on .me-highlights-list so widths align
- Keeps both at 600px max with auto centering
2025-10-13 16:20:54 +02:00
Gigi
e46b68da7e style: improve Me page mobile tabs and avoid overlap with sidebar buttons
- Add top margin to header on mobile for floating buttons
- Tighten tab paddings and content spacing
- Reduce left/right padding for more room on small screens
2025-10-13 16:15:21 +02:00
Gigi
811a962632 style: reduce margins/paddings to make highlight cards more compact
- Decrease list gap and content padding
- Bring corner buttons closer to edges (header/footer 0.5rem, quote 0.25/0.5rem)
- Keep safe spacing to avoid text/button overlap
2025-10-13 16:13:11 +02:00
Gigi
eb82e8762a style: tighten vertical spacing on highlight cards
- Reduce header/footer vertical padding (0.5rem -> 0.375rem)
- Reduce content top/bottom padding (3rem -> 2.25rem)
- Slightly reduce gaps to compact layout
2025-10-13 16:09:42 +02:00
Gigi
d919da153f feat: make quote icon a CompactButton in top-left corner
- Replace static quote icon with CompactButton
- Position top-left with same corner margin as others
- Preserve level-based coloring via CSS (
  - mine: var(--highlight-color-mine)
  - friends: var(--highlight-color-friends)
  - nostrverse: var(--highlight-color-nostrverse)
)
2025-10-13 16:08:37 +02:00
Gigi
8389d5811a fix: make relay button match menu spacing within footer
- Force footer relay indicator to be in normal flow (position: static)
- Remove margins and rely on footer gap/padding
- Ensures same visual spacing as the three-dot CompactButton
2025-10-13 15:55:18 +02:00
Gigi
0aa0c44441 fix: group relay icon and author in footer-left for consistent alignment
- Add  container (relay + author)
- Footer now uses space-between with left group and right menu
- Consistent gap and truncation behavior for author
- Matches the visual rhythm of the three-dot button
2025-10-13 15:47:22 +02:00
Gigi
49ea7504a1 style: make relay indicator match CompactButton (same look as menu)
- Remove custom opacity for relay indicator
- Rely on  styles for hover/active/spacing
- Ensures relay button visually matches three-dot menu button
2025-10-13 15:37:16 +02:00
Gigi
6602fb9359 fix: align relay indicator within footer with symmetric spacing
- Place relay indicator as first element in footer (no absolute positioning)
- Remove extra author left padding; rely on footer gap/padding
- Ensure consistent 1rem outer padding and 0.75rem gap between footer items
- Matches spacing of timestamp and menu in their corners
2025-10-13 15:35:07 +02:00
Gigi
731eb6915a fix: align corner elements symmetrically with proper margins
- Change quote icon left margin from 0.5rem to 1rem
- Change relay indicator left margin from 0.5rem to 1rem
- All corner elements now have 1rem horizontal margin (matching header/footer padding)
- Adjust author padding-left to 2.5rem to accommodate relay icon
- Creates symmetrical appearance across all four corners
2025-10-13 15:28:55 +02:00
Gigi
3459179310 refactor: move quote icon to top-left corner and make it smaller
- Position quote icon absolutely in top-left corner (0.5rem, 0.5rem)
- Reduce font size from 1.2rem to 0.85rem
- Add opacity: 0.7 to make it more subtle
- Remove quote icon from document flow
- Update content padding to be uniform (3rem 1rem)
- Remove gap from highlight-item since content is only in-flow element
- Clean up mobile styles for quote icon
2025-10-13 15:25:10 +02:00
Gigi
b1f951daf5 fix: position relay indicator in bottom-left corner
- Move relay indicator to be absolutely positioned in bottom-left corner
- Similar to timestamp in top-right corner
- Add padding-left to author name to prevent overlap with relay icon
- Relay indicator sits outside the footer content flow
- z-index: 10 ensures it's above footer background
2025-10-13 15:23:29 +02:00
Gigi
caebcec0af fix: move relay indicator to footer to prevent overlap with author
- Move relay indicator from quote icon to footer as first element
- Remove absolute positioning from relay indicator
- Update footer layout: relay icon, author name, menu button (left to right)
- Author name now sits to the right of relay icon with no overlap
- Use margin-right: auto on author to push menu to the right
2025-10-13 15:20:40 +02:00
Gigi
5f50f4b8d6 fix: improve spacing and alignment of highlight card elements
- Remove padding from highlight-item, let header/footer/content handle it
- Fix header and footer to use left: 0 and right: 0 instead of negative offsets
- Use padding on quote-icon and highlight-content for proper spacing
- Adjust relay indicator positioning to work with new padding model
- Ensure all elements have proper vertical and horizontal spacing
2025-10-13 15:17:08 +02:00
Gigi
3039208ba0 refactor: make header and footer full-width with borders and corners
- Header and footer now span 100% width of the card
- Header has top border and top rounded corners
- Footer has bottom border and bottom rounded corners
- Main item has only left/right borders
- Properly adjust padding to accommodate absolute positioned header/footer
- Border colors transition correctly for hover, selected, and level states
2025-10-13 15:03:53 +02:00
Gigi
397c956e87 refactor: create highlight-header for timestamp positioning
- Add highlight-header container for timestamp
- Position header absolutely in top-right corner
- Remove padding-right workaround from highlight-content
- Use pointer-events to allow timestamp click while preventing header clicks
- Cleaner structure prevents any text overlap naturally
2025-10-13 14:45:21 +02:00
Gigi
cf47ceb74b refactor: create highlight-footer for perfect alignment
- Rename highlight-meta to highlight-footer for semantic clarity
- Use flexbox with space-between for proper element spacing
- Ensure author name and menu button are perfectly vertically aligned
- Add min-height to author to match button height
2025-10-13 14:42:23 +02:00
Gigi
da7aa2c115 fix: add padding to prevent quote text from overlapping timestamp 2025-10-13 14:40:30 +02:00
Gigi
c0046bc04c refactor: remove clock icon from timestamp button 2025-10-13 14:38:31 +02:00
Gigi
2f8f6a0652 feat: introduce CompactButton component for highlight cards
- Create reusable CompactButton component for small, borderless buttons
- Refactor relay indicator to use CompactButton
- Refactor menu toggle button to use CompactButton
- Make timestamp clickable with CompactButton (shows full date on hover)
- Simplify CSS by removing duplicate button styles
- Improve mobile touch targets for all compact buttons
2025-10-13 14:01:51 +02:00
Gigi
9a6f788b98 feat: move highlight timestamp to top-right corner of cards 2025-10-13 12:55:00 +02:00
Gigi
c1a628260c feat(icons): import books.svg as raw and register faBooks; add *.svg?raw types 2025-10-13 12:42:56 +02:00
Gigi
7b0bd7077c feat: use faBooks icon for Mark as Read button 2025-10-13 12:29:23 +02:00
Gigi
7d47f0a86e feat: add custom FontAwesome Pro books icon for Archive tab 2025-10-13 12:28:08 +02:00
Gigi
44fcd74cbe refactor: rename Library tab to Archive 2025-10-13 12:20:18 +02:00
Gigi
5ac0e7ed87 fix: deduplicate article events in library to prevent showing duplicates 2025-10-13 12:15:16 +02:00
Gigi
743968f7fb feat: use user's custom highlight color for Highlights tab 2025-10-13 12:14:25 +02:00
Gigi
e1a3ae4b4d feat: render library articles using BlogPostCard component for consistency 2025-10-13 12:13:12 +02:00
Gigi
acf13448ae style: improve tab border styling for dark theme 2025-10-13 12:10:19 +02:00
Gigi
a5daa8b56c fix: remove incorrect useSettings hook usage in Me component 2025-10-13 12:02:15 +02:00
Gigi
267169c5c1 fix: correct fetchBookmarks usage with callback pattern in Me component 2025-10-13 10:45:46 +02:00
Gigi
89272dd9a3 style: left-align text inside author card 2025-10-13 10:37:16 +02:00
Gigi
d059212238 style: constrain /me page content width to match author card (600px) 2025-10-13 10:36:36 +02:00
Gigi
0d8a3576a6 feat: add tabbed layout to /me page with Highlights, Reading List, and Library tabs 2025-10-13 10:35:14 +02:00
Gigi
8910c2750a feat: replace username with AuthorCard component on /me page 2025-10-13 10:30:58 +02:00
Gigi
12393d6df4 feat: implement two-pane layout for /me page with article sources and highlights 2025-10-13 10:27:18 +02:00
Gigi
6c0a2439ad feat: instant mark-as-read with checkmark animation and read status checking
- Add functions to check if article/URL was already marked as read via NIP-25 reactions
- Make mark-as-read action instant with fire-and-forget publishing
- Add checkmark icon animation when marking as read
- Display read status on load by querying kind:7 (nostr events) and kind:17 (websites) reactions
- Add green styling for already-read state
- Button shows checkmark and is disabled when article is already marked as read
2025-10-13 10:17:16 +02:00
Gigi
d83712127b docs: update CHANGELOG for v0.5.4 2025-10-13 10:08:32 +02:00
Gigi
55325cd7ad chore(release): bump version to 0.5.4 2025-10-13 10:05:53 +02:00
Gigi
82e508fca6 refactor(styles): extract base, layout, components, utils CSS and use aggregator imports in src/index.css 2025-10-13 09:47:09 +02:00
Gigi
8ff32e9363 chore(styles): scaffold styles directory and empty files 2025-10-13 09:40:23 +02:00
Gigi
477308632b fix: add safe area insets for symmetrical mobile button positioning
- Add safe-area-inset-top/bottom/left/right to all mobile floating buttons
- Ensures proper spacing on devices with notches and home indicators
- Makes relay status indicator perfectly aligned with bookmark/highlights buttons
2025-10-13 09:23:17 +02:00
Gigi
9ffd06f5e3 docs: update CHANGELOG for v0.5.3 2025-10-13 08:55:59 +02:00
Gigi
a89c87819a chore: bump version to 0.5.3 2025-10-13 08:55:12 +02:00
Gigi
b09ae3bae3 fix: increase profile icon size when logged out
- Change .profile-avatar svg font-size from 1rem to 1.25rem
- Matches size of other icon buttons in sidebar header
2025-10-13 08:54:18 +02:00
Gigi
6ea8c0d40e feat: make relay status indicator smaller and hide on scroll
- Reduce overall size of indicator on desktop (smaller padding, font sizes)
- On mobile, match size with sidebar toggle buttons (var(--min-touch-target))
- Auto-collapse on mobile (collapsed by default, tap to expand)
- Hide when scrolling down on mobile, show when scrolling up
- Match behavior of other mobile UI controls for consistency
2025-10-13 08:53:17 +02:00
Gigi
079501337c fix: filter out invalid bookmarks without IDs
- Skip bookmarks that don't have a valid ID instead of generating temporary IDs
- Use bookmark's original created_at timestamp for added_at instead of Date.now()
- Fixes issue where first 2 bookmarks always showed 'Now' timestamp with no content
2025-10-13 08:50:29 +02:00
Gigi
5bf0382227 docs(changelog): add v0.5.1 and v0.5.2 entries 2025-10-13 00:14:16 +02:00
Gigi
0199c59014 chore: bump version to 0.5.2 2025-10-13 00:12:51 +02:00
Gigi
44fb63fc59 fix: resolve linting errors in HighlightItem
- Remove unused handleDeleteClick function (replaced by handleMenuDeleteClick)
- Remove onSelectUrl from destructuring (not used after menu refactor)
- Add comment explaining onSelectUrl is kept in props for API compatibility
2025-10-13 00:12:25 +02:00
Gigi
13a28d2dbd fix: make getNostrUrl detect identifier type for ants.sh
- ants.sh requires /p/ for profiles (npub/nprofile) and /e/ for events (note/nevent/naddr)
- Updated getNostrUrl to automatically detect identifier type and use correct path
2025-10-13 00:07:46 +02:00
Gigi
f87a7da32e feat: switch Nostr gateway to ants.sh
- Replace njump.me and search.dergigi.com with ants.sh
- Use ants.sh/p/ for profiles and ants.sh/e/ for events
- All existing helper functions continue to work with new gateway
2025-10-13 00:05:44 +02:00
Gigi
8fdf9938c2 refactor: centralize Nostr gateway URLs in config
- Create nostrGateways.ts config file with PRIMARY (njump.me) and SEARCH (search.dergigi.com) gateways
- Add helper functions: getProfileUrl, getEventUrl, getNostrUrl
- Update all hardcoded gateway URLs across the codebase to use the config
- Updated files: HighlightItem, nostrUriResolver, BookmarkViews (Card/Large), ResolvedMention
2025-10-13 00:05:11 +02:00
Gigi
ee4d480961 refactor: remove 'Loading your highlights' text from Me page 2025-10-13 00:01:41 +02:00
Gigi
bd866549a0 fix: open on nostr now opens the highlight event itself
- Changed 'Open on Nostr' to link to the highlight event (kind 9802)
- Previously it was linking to the article being highlighted
- Menu item is now always shown since every highlight has an event ID
- Removed unused handleLinkClick function
2025-10-13 00:01:11 +02:00
Gigi
7c39f1d821 style: change three-dot menu icon to horizontal 2025-10-13 00:00:05 +02:00
Gigi
e6a7bb4c98 feat: add three-dot menu to highlight cards
- Replace external link button with three-dot menu in highlight cards
- Move 'Open source/Nostr' and 'Delete' actions into dropdown menu
- Add click-outside functionality to close menu
- Style menu for both dark and light themes
2025-10-12 23:59:28 +02:00
Gigi
14cf3189b8 chore: remove 'Refreshing posts…' text from Explore
Only show spinner icon without text for cleaner UI
2025-10-12 23:56:23 +02:00
Gigi
66b060627a chore: bump version to 0.5.1 2025-10-12 23:54:17 +02:00
Gigi
d9bcf14baa fix: match highlight count indicator style to reading-time element
- Use rgba() with 0.1 opacity for background (subtle, not bright)
- Use rgba() with 0.3 opacity for border
- Set text and icon color to white for better visibility
- Properly converts hex colors to rgba using hexToRgb helper
2025-10-12 23:53:10 +02:00
Gigi
c571e6ebf7 fix: reduce brightness and add colored border to highlight count indicator
- Set background color to 75% opacity (bf hex) to reduce brightness
- Add border color matching the highlight group color
- Makes the indicator less overwhelming while maintaining visibility
2025-10-12 23:51:05 +02:00
Gigi
fb06a1aec3 fix: apply user highlight color to both marker and arrow icons
- Apply color style directly to both icons in collapsed highlights button
- Update CSS to use color-agnostic pulse animation (opacity/scale)
- Remove hardcoded yellow color and drop-shadow from glow effect
2025-10-12 23:50:34 +02:00
Gigi
5a0d08641b chore: remove MOBILE_IMPLEMENTATION.md
No longer needed as mobile features are now integrated
2025-10-12 23:49:12 +02:00
Gigi
8a8419385e fix: apply highlight group color to background of count indicator
- Change highlight count indicator to use backgroundColor instead of color
- Set text color to black for contrast against colored backgrounds
- Maintains priority: nostrverse > friends > mine
2025-10-12 23:48:56 +02:00
Gigi
0d5dc6e785 feat: apply 'my highlights' color to collapsed highlights button
- Update HighlightsPanelCollapsed to accept settings prop
- Apply highlightColorMine to the expand button icon and text
- Pass settings through HighlightsPanel and ThreePaneLayout
2025-10-12 23:48:18 +02:00
Gigi
1d90333803 feat: apply highlight group color to highlight count indicator
- Update ReaderHeader to receive highlights and highlightVisibility props
- Calculate dominant color based on visible highlight groups
- Apply color priority: nostrverse > friends > mine
- Highlight count indicator now reflects the active group colors
2025-10-12 23:47:05 +02:00
Gigi
91e6e62688 feat: apply 'my highlights' color to highlight buttons
- Update floating highlight button (FAB) to use highlightColorMine from settings
- Update mobile highlights button to use highlightColorMine from settings
- Both buttons now reflect the user's chosen 'my highlights' color
2025-10-12 23:44:51 +02:00
Gigi
619a8a9753 docs(changelog): add v0.5.0 changes and compare links 2025-10-12 23:42:44 +02:00
Gigi
0fe38e94d3 chore: bump version to 0.5.0 2025-10-12 23:38:51 +02:00
Gigi
722e8adbdf Merge pull request #3 from dergigi/local-first
perf: parallel local-first fetching, instant render, and non-wiping refreshes
2025-10-12 22:37:38 +01:00
Gigi
886d5ac08c chore(lint): satisfy react-hooks dependency in Explore; lints clean 2025-10-12 23:35:23 +02:00
Gigi
89d5ba4c37 ui(explore): shrink refresh spinner footprint; inline-sized loading row 2025-10-12 23:33:28 +02:00
Gigi
b8b9f82d91 ux(explore): preserve posts across navigations; seed from cache; merge streamed + final 2025-10-12 23:30:56 +02:00
Gigi
b3fc9bb5c3 ux(bookmarks): avoid clearing list when no new events; decouple refetch from route changes 2025-10-12 23:26:18 +02:00
Gigi
d2ebcd8fbe ux(explore): keep posts visible during refresh; inline spinner; no list wipe 2025-10-12 23:25:05 +02:00
Gigi
68c9623c35 ux(bookmarks): keep list visible during refresh; show spinner only; no wipe 2025-10-12 23:22:53 +02:00
Gigi
496d1df404 perf(parallel): run local+remote fetches concurrently across services; stream+dedupe 2025-10-12 23:13:26 +02:00
Gigi
ea1046fe13 perf(local-first): always follow up with remote for articles and titles 2025-10-12 23:00:23 +02:00
Gigi
6d58d6e7f3 fix(highlights): ensure nostrverse fetch merges remote results after local for article/url 2025-10-12 22:58:25 +02:00
Gigi
e1420140d1 chore(lint): fix eslint and typescript issues; no rule changes 2025-10-12 22:55:46 +02:00
Gigi
484c2e0c2f refactor(highlights): split service into smaller modules; keep files <210 lines 2025-10-12 22:54:11 +02:00
Gigi
31f7d53829 perf(explore): stream contacts + early posts from local; merge remote later 2025-10-12 22:43:35 +02:00
Gigi
e3debfa5df perf(local-first): apply local-first then remote pattern across services (titles, bookmarks, highlights) 2025-10-12 22:42:24 +02:00
Gigi
a1305fba81 fix(explore): always query remote relays after local; stream merge into UI 2025-10-12 22:38:29 +02:00
Gigi
ca95d6c7f4 perf(ui): stream results to UI; show cached/local immediately (articles, highlights, explore) 2025-10-12 22:33:46 +02:00
Gigi
5513fc9850 perf(relays): local-first queries with short timeouts; fallback to remote if needed 2025-10-12 22:06:49 +02:00
Gigi
86de98e644 Merge pull request #2 from dergigi/pwa
Upgrade to full Progressive Web App
2025-10-12 07:54:37 +01:00
Gigi
fd374cd705 docs: remove temporary PWA launch checklist and implementation summary 2025-10-12 07:18:20 +01:00
Gigi
20b4658bef docs(pwa): update implementation summary to reflect final icons and next steps 2025-10-12 07:18:02 +01:00
Gigi
0850ba250c chore(lint): fix service worker typings and satisfy ESLint for worker globals 2025-10-12 07:15:52 +01:00
Gigi
b71d188fd8 chore: remove favicon zip file after extraction 2025-10-11 21:01:27 +01:00
Gigi
579f6b9a96 docs: update PWA docs to reflect branded icons are now in place 2025-10-11 20:58:56 +01:00
Gigi
d9403a73c6 feat(icons): replace placeholder icons with branded favicons
- Replace placeholder PWA icons with proper Boris-branded icons
- Add favicon.ico, favicon-16x16.png, favicon-32x32.png
- Add apple-touch-icon.png for iOS devices
- Update index.html with proper favicon links
- All icons extracted from boris-favicon.zip
- PWA icons now use android-chrome-192x192 and android-chrome-512x512
2025-10-11 20:58:26 +01:00
Gigi
747811fa94 docs: add PWA launch checklist 2025-10-11 20:43:13 +01:00
Gigi
489e480394 docs: add PWA implementation summary and guide 2025-10-11 20:42:29 +01:00
Gigi
418bcb0295 feat(pwa): upgrade to full PWA with vite-plugin-pwa
- Add web app manifest with proper metadata and icon support
- Configure vite-plugin-pwa with injectManifest strategy
- Migrate service worker to Workbox with precaching and runtime caching
- Add runtime caching for cross-origin images (preserves existing behavior)
- Add runtime caching for cross-origin article HTML for offline reading
- Create PWA install hook and UI component in settings
- Add online/offline status monitoring and toast notifications
- Add service worker update notifications
- Add placeholder PWA icons (192x192, 512x512, maskable variants)
- Update HTML with manifest link and theme-color meta tag
- Preserve existing relay/airplane mode functionality (WebSockets not intercepted)

The app now passes PWA installability criteria while maintaining all existing
offline functionality. Icons should be replaced with proper branded designs.
2025-10-11 20:41:49 +01:00
Gigi
88f01554e7 fix: improve mobile touch targets for highlight icons
- Increase touch target size to 44x44px on mobile (relay & delete icons)
- Add proper padding to both icons for larger tap area
- Increase spacing between relay and delete icons
- Expand quote icon container width on mobile to accommodate both targets
- Increase icon font size on mobile (0.85rem) for better visibility
- Move icons slightly outward (-8px) to prevent overlap

Icons are now much easier to tap on mobile devices without
accidentally hitting the wrong button.
2025-10-11 09:06:17 +01:00
Gigi
c85092a644 fix: color /me highlights with 'my highlights' color setting
- Set level: 'mine' on all highlights in /me page
- Highlights now use the customizable --highlight-color-mine CSS variable
- Quote icons and borders automatically match user's color preference
- Consistent styling with highlights shown elsewhere in the app
2025-10-11 09:04:48 +01:00
Gigi
096478bcec feat: add author info card for nostr-native articles
- Create AuthorCard component showing profile picture and bio
- Display author card after mark as read button
- Only shown for nostr-native articles (not external URLs)
- Fetch author profile data using applesauce ProfileModel
- Card displays author name, avatar, and bio (truncated to 3 lines)
- Responsive design with smaller avatar on mobile
- Elegant card styling matching app design system

Author information helps readers learn more about article authors
directly within the reading experience.
2025-10-11 08:55:37 +01:00
Gigi
b8de4a85e0 docs: update CHANGELOG for v0.4.3 release 2025-10-11 08:40:05 +01:00
Gigi
a5b7cedfaa chore: bump version to 0.4.3 2025-10-11 08:39:16 +01:00
Gigi
0adb8d6766 feat: add highlight deletion with confirmation dialog
- Create deletionService for NIP-09 kind:5 event deletion requests
- Add ConfirmDialog component for user confirmation before deletion
- Add subtle delete button to highlight items (trash icon)
- Only show delete button for user's own highlights
- Position delete button symmetrically opposite to relay indicator
- Add confirmation dialog to prevent accidental deletions
- Remove highlights from UI immediately after deletion request
- Style delete button with red hover color
- Add comprehensive confirmation dialog styling (danger/warning/info variants)

Implements NIP-09 Event Deletion Request.
Users can now delete their own highlights after confirming the action.
2025-10-11 08:38:22 +01:00
Gigi
6a6b8c4fad feat: add mark as read button for articles
- Create reactionService for handling kind:7 and kind:17 reactions
- Add mark as read button at the end of articles (📚 emoji)
- Use kind:7 reaction for nostr-native articles (/a/ paths)
- Use kind:17 reaction for external websites (/r/ paths)
- Pass activeAccount and currentArticle props through component tree
- Add responsive styling for mark as read button
- Button shows loading state while creating reaction
- Only visible when user is logged in

Implements NIP-25 (kind:7 reactions) and NIP-25 (kind:17 website reactions).
Users can now mark articles as read, creating a permanent record on nostr.
2025-10-11 08:34:36 +01:00
Gigi
4f952816ea feat: compact relay status indicator on mobile
- Show only icon on mobile (44x44px touch target)
- Tap to expand for full details
- Smooth transition between compact and expanded states
- Maintains full display on desktop
- Reduces screen clutter on mobile while keeping info accessible

Flight mode notice now just shows airplane icon on mobile.
Tap it to see full connection details.
2025-10-11 08:09:29 +01:00
Gigi
76835e2509 feat: add /me page to show user's recent highlights
- Create Me component to display logged-in user's highlights
- Add /me route to App routing
- Update SidebarHeader to navigate to /me when clicking profile avatar
- Integrate Me page in ThreePaneLayout (same as Settings/Explore)
- Show user profile info and highlight count
- List all highlights created by the user

Clicking the profile picture now takes you to your personal highlights page.
2025-10-11 08:07:20 +01:00
Gigi
63af770c83 docs: update CHANGELOG for v0.4.2 release 2025-10-11 03:25:11 +01:00
136 changed files with 16453 additions and 4337 deletions

View File

@@ -5,4 +5,4 @@ alwaysApply: false
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon. Always strive to keep the UI modern, beautiful, and minimalistic. Shy away from using too many colors, borders, glow, and animations.
Never write "Loading" - always show a spinner, and just a spinner.
Never write "Loading" - always show a loading placeholder (or a loading spinner, when appropriate).

View File

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

File diff suppressed because it is too large Load Diff

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.

View File

@@ -1,156 +0,0 @@
# Mobile Implementation Summary
## Overview
Boris is now mobile-friendly! The app now works seamlessly on mobile devices with a responsive design that includes:
- Auto-collapsing sidebar that opens as an overlay drawer on small screens
- Touch-optimized UI with proper touch target sizes (44x44px minimum)
- Safe area insets for notched devices (iPhone X+, etc.)
- Focus trap and keyboard navigation in the mobile sidebar
- Mobile-optimized modals, toasts, and other UI elements
## Changes Made
### 1. Viewport & Base Setup
**File: `index.html`**
- Updated viewport meta tag to include `viewport-fit=cover` for proper safe area handling
### 2. Media Query Hooks
**File: `src/hooks/useMediaQuery.ts` (NEW)**
- `useMediaQuery(query)` - Generic hook for any media query
- `useIsMobile()` - Detects mobile viewport (≤768px)
- `useIsTablet()` - Detects tablet viewport (≤1024px)
- `useIsCoarsePointer()` - Detects touch devices
### 3. Mobile CSS Styles
**File: `src/index.css`**
- Added CSS custom properties for mobile breakpoints and safe areas
- Mobile-specific three-pane layout that stacks into single column
- Overlay sidebar with backdrop and transitions
- Touch target improvements (44x44px minimum)
- Disabled hover effects on touch devices
- Mobile-optimized modals (full-screen sheet style)
- Mobile-optimized toasts (bottom position with safe area)
- Dynamic viewport height support (`100dvh`)
- Overscroll behavior and body scroll locking
### 4. Sidebar State Management
**File: `src/hooks/useBookmarksUI.ts`**
- Added `isMobile` state from media query
- Added `isSidebarOpen` state for mobile overlay
- Added `toggleSidebar()` function
- Auto-collapse logic based on `autoCollapseSidebarOnMobile` setting
- Mobile sidebar defaults to closed, desktop defaults to open
### 5. Three-Pane Layout Mobile Support
**File: `src/components/ThreePaneLayout.tsx`**
- Mobile hamburger button (visible only on mobile)
- Mobile backdrop for closing sidebar
- Body scroll locking when sidebar is open
- ESC key handler to close sidebar
- Focus trap in sidebar (Tab navigation stays within sidebar)
- Focus restoration when closing sidebar
- Accessibility attributes (`aria-hidden`, `aria-expanded`, etc.)
### 6. Sidebar Header Mobile Controls
**File: `src/components/SidebarHeader.tsx`**
- Close button (X) visible on mobile instead of collapse chevron
- Hamburger button hidden in header (shown in layout instead)
### 7. Bookmark List Mobile Props
**File: `src/components/BookmarkList.tsx`**
- Added `isMobile` prop support
- Passes mobile state to SidebarHeader
### 8. Main Bookmarks Component
**File: `src/components/Bookmarks.tsx`**
- Uses mobile state from `useBookmarksUI`
- Auto-closes sidebar when selecting bookmark on mobile
- Closes sidebar when opening settings on mobile
- Proper desktop/mobile toggle behavior
### 9. Icon Button Enhancement
**File: `src/components/IconButton.tsx`**
- Added optional `className` prop for additional styling
### 10. Mobile Settings
**File: `src/services/settingsService.ts`**
- Added `autoCollapseSidebarOnMobile?: boolean` setting (default: true)
**File: `src/components/Settings/StartupPreferencesSettings.tsx`**
- Added UI toggle for "Auto-collapse sidebar on small screens"
## Accessibility Features
- Focus trap in mobile sidebar (Tab key navigation stays within drawer)
- ESC key closes mobile sidebar
- Backdrop click closes mobile sidebar
- Proper ARIA attributes (`aria-hidden`, `aria-expanded`, `aria-controls`)
- Touch target minimum size enforcement (44x44px)
- Focus restoration when closing sidebar
## Mobile Behaviors
1. **Sidebar**: Slides in from left as overlay drawer with backdrop
2. **Hamburger Menu**: Fixed position top-left when sidebar closed
3. **Selecting Content**: Auto-closes sidebar on mobile
4. **Opening Settings**: Auto-closes sidebar on mobile
5. **Highlights Panel**: Hidden on mobile (content takes full width)
6. **Modals**: Full-screen sheet style from bottom
7. **Toasts**: Bottom position with safe area padding
## Responsive Breakpoints
- **Mobile**: ≤768px (sidebar overlay, single column)
- **Tablet**: ≤1024px (defined but not actively used yet)
- **Desktop**: >768px (three-pane layout as before)
## Browser Support
- Modern browsers with CSS Grid support
- iOS Safari (including safe area insets)
- Chrome for Android
- Firefox Mobile
- Safari on iPadOS
## Safe Area Support
The app respects device safe areas (notches, home indicators) through CSS environment variables:
- `env(safe-area-inset-top)`
- `env(safe-area-inset-bottom)`
- `env(safe-area-inset-left)`
- `env(safe-area-inset-right)`
## Future Enhancements
Potential improvements for future iterations:
- Swipe gesture to open/close sidebar
- Pull-to-refresh on mobile
- Bottom sheet for highlights panel on mobile
- Optimized font sizes for mobile reading
- Mobile-specific view mode (perhaps auto-switch to compact on mobile)
- Haptic feedback on interactions (iOS/Android)
- Share sheet integration
- Install prompt for PWA
## Testing Checklist
- [x] Sidebar opens/closes on mobile
- [x] Hamburger button visible on mobile
- [x] Backdrop closes sidebar
- [x] ESC key closes sidebar
- [x] Focus trap works in sidebar
- [x] Selecting bookmark closes sidebar
- [x] No horizontal scroll
- [x] Touch targets ≥ 44px
- [x] Modals are full-screen on mobile
- [x] Toasts appear at bottom with safe area
- [x] Build completes without errors
- [ ] Test on actual iOS device (iPhone)
- [ ] Test on actual Android device
- [ ] Test with keyboard navigation
- [ ] Test with screen reader
- [ ] Test landscape orientation
- [ ] Test on various screen sizes (320px, 375px, 414px, 768px)
## Commit History
1. `feat: update viewport meta for mobile support`
2. `feat: add media query hooks for responsive design`
3. `feat: add mobile sidebar state management to useBookmarksUI`
4. `feat: add mobile-responsive CSS with breakpoints and safe areas`
5. `feat: implement mobile overlay sidebar with focus trap and ESC handling`
6. `feat: add mobile auto-collapse setting`
7. `fix: resolve TypeScript errors for mobile implementation`

188
TAILWIND_MIGRATION.md Normal file
View File

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

201
api/video-meta.ts Normal file
View File

@@ -0,0 +1,201 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import { getSubtitles } from '@treeee/youtube-caption-extractor'
type Caption = { start: number; dur: number; text: string }
type Subtitle = { start: string | number; dur: string | number; text: string }
type CacheEntry = {
body: unknown
expires: number
}
type VimeoOEmbedResponse = {
title: string
description: string
author_name: string
author_url: string
provider_name: string
provider_url: string
type: string
version: string
width: number
height: number
html: string
thumbnail_url: string
thumbnail_width: number
thumbnail_height: number
}
// In-memory cache for 7 days
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
const memoryCache = new Map<string, CacheEntry>()
function buildKey(videoId: string, lang: string, preferAuto?: string | string[], source?: string) {
return `${source || 'video'}|${videoId}|${lang}|${preferAuto ? 'auto' : 'manual'}`
}
function ok(res: VercelResponse, data: unknown) {
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
return res.status(200).json(data)
}
function bad(res: VercelResponse, code: number, message: string) {
return res.status(code).json({ error: message })
}
function extractVideoId(url: string): { id: string; source: 'youtube' | 'vimeo' } | null {
// YouTube patterns
const youtubePatterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
/youtube\.com\/v\/([^&\n?#]+)/
]
for (const pattern of youtubePatterns) {
const match = url.match(pattern)
if (match) {
return { id: match[1], source: 'youtube' }
}
}
// Vimeo patterns
const vimeoPatterns = [
/vimeo\.com\/(\d+)/,
/player\.vimeo\.com\/video\/(\d+)/
]
for (const pattern of vimeoPatterns) {
const match = url.match(pattern)
if (match) {
return { id: match[1], source: 'vimeo' }
}
}
return null
}
async function pickCaptions(videoID: string, preferredLangs: string[], manualFirst: boolean): Promise<{ caps: Caption[]; lang: string; isAuto: boolean } | null> {
for (const lang of preferredLangs) {
try {
const caps = await getSubtitles({ videoID, lang })
if (Array.isArray(caps) && caps.length > 0) {
// Convert the returned subtitles to our Caption format
const convertedCaps: Caption[] = caps.map((cap: Subtitle) => ({
start: typeof cap.start === 'string' ? parseFloat(cap.start) : cap.start,
dur: typeof cap.dur === 'string' ? parseFloat(cap.dur) : cap.dur,
text: cap.text
}))
return { caps: convertedCaps, lang, isAuto: !manualFirst }
}
} catch {
// try next
}
}
return null
}
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
const vimeoUrl = `https://vimeo.com/${videoId}`
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
const response = await fetch(oembedUrl)
if (!response.ok) {
throw new Error(`Vimeo oEmbed API returned ${response.status}`)
}
const data: VimeoOEmbedResponse = await response.json()
return {
title: data.title || '',
description: data.description || ''
}
}
export default async function handler(req: VercelRequest, res: VercelResponse) {
const url = (req.query.url as string | undefined)?.trim()
const videoId = (req.query.videoId as string | undefined)?.trim()
if (!url && !videoId) {
return bad(res, 400, 'Missing url or videoId parameter')
}
// Extract video info from URL or use provided videoId
let videoInfo: { id: string; source: 'youtube' | 'vimeo' }
if (url) {
const extracted = extractVideoId(url)
if (!extracted) {
return bad(res, 400, 'Unsupported video URL. Only YouTube and Vimeo are supported.')
}
videoInfo = extracted
} else {
// If only videoId is provided, assume YouTube for backward compatibility
videoInfo = { id: videoId!, source: 'youtube' }
}
const lang = ((req.query.lang as string | undefined) || 'en').toLowerCase()
const uiLocale = (req.headers['x-ui-locale'] as string | undefined)?.toLowerCase()
const preferAuto = req.query.preferAuto === 'true'
const cacheKey = buildKey(videoInfo.id, lang, preferAuto ? 'auto' : undefined, videoInfo.source)
const now = Date.now()
const cached = memoryCache.get(cacheKey)
if (cached && cached.expires > now) {
return ok(res, cached.body)
}
try {
if (videoInfo.source === 'youtube') {
// YouTube handling
// Note: getVideoDetails doesn't exist in the library, so we use a simplified approach
const title = ''
const description = ''
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
let selected = null as null | { caps: Caption[]; lang: string; isAuto: boolean }
// Manual first
selected = await pickCaptions(videoInfo.id, langs, true)
if (!selected) {
// Try auto
selected = await pickCaptions(videoInfo.id, langs, false)
}
const captions = selected?.caps || []
const transcript = captions.map(c => c.text).join(' ').trim()
const response = {
title,
description,
captions,
transcript,
lang: selected?.lang || lang,
isAuto: selected?.isAuto || false,
source: 'youtube'
}
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
return ok(res, response)
} else if (videoInfo.source === 'vimeo') {
// Vimeo handling
const { title, description } = await getVimeoMetadata(videoInfo.id)
const response = {
title,
description,
captions: [], // Vimeo doesn't provide captions through oEmbed API
transcript: '', // No transcript available
lang: 'en', // Default language
isAuto: false, // Not applicable for Vimeo
source: 'vimeo'
}
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
return ok(res, response)
} else {
return bad(res, 400, 'Unsupported video source')
}
} catch (e) {
return bad(res, 500, `Failed to fetch ${videoInfo.source} metadata`)
}
}

93
api/vimeo-meta.ts Normal file
View File

@@ -0,0 +1,93 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
type CacheEntry = {
body: unknown
expires: number
}
type VimeoOEmbedResponse = {
title: string
description: string
author_name: string
author_url: string
provider_name: string
provider_url: string
type: string
version: string
width: number
height: number
html: string
thumbnail_url: string
thumbnail_width: number
thumbnail_height: number
}
// In-memory cache for 7 days
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
const memoryCache = new Map<string, CacheEntry>()
function buildKey(videoId: string) {
return `vimeo|${videoId}`
}
function ok(res: VercelResponse, data: unknown) {
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
return res.status(200).json(data)
}
function bad(res: VercelResponse, code: number, message: string) {
return res.status(code).json({ error: message })
}
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
const vimeoUrl = `https://vimeo.com/${videoId}`
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
const response = await fetch(oembedUrl)
if (!response.ok) {
throw new Error(`Vimeo oEmbed API returned ${response.status}`)
}
const data: VimeoOEmbedResponse = await response.json()
return {
title: data.title || '',
description: data.description || ''
}
}
export default async function handler(req: VercelRequest, res: VercelResponse) {
const videoId = (req.query.videoId as string | undefined)?.trim()
if (!videoId) return bad(res, 400, 'Missing videoId')
// Validate that videoId is a number
if (!/^\d+$/.test(videoId)) {
return bad(res, 400, 'Invalid Vimeo video ID - must be numeric')
}
const cacheKey = buildKey(videoId)
const now = Date.now()
const cached = memoryCache.get(cacheKey)
if (cached && cached.expires > now) {
return ok(res, cached.body)
}
try {
const { title, description } = await getVimeoMetadata(videoId)
const response = {
title,
description,
captions: [], // Vimeo doesn't provide captions through oEmbed API
transcript: '', // No transcript available
lang: 'en', // Default language
isAuto: false, // Not applicable for Vimeo
source: 'vimeo'
}
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
return ok(res, response)
} catch (e) {
return bad(res, 500, 'Failed to fetch Vimeo metadata')
}
}

101
api/youtube-meta.ts Normal file
View File

@@ -0,0 +1,101 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import { getSubtitles } from '@treeee/youtube-caption-extractor'
type Caption = { start: number; dur: number; text: string }
type Subtitle = { start: string | number; dur: string | number; text: string }
type CacheEntry = {
body: unknown
expires: number
}
// In-memory cache for 7 days
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
const memoryCache = new Map<string, CacheEntry>()
function buildKey(videoId: string, lang: string, preferAuto?: string | string[]) {
return `${videoId}|${lang}|${preferAuto ? 'auto' : 'manual'}`
}
function ok(res: VercelResponse, data: unknown) {
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
return res.status(200).json(data)
}
function bad(res: VercelResponse, code: number, message: string) {
return res.status(code).json({ error: message })
}
async function pickCaptions(videoID: string, preferredLangs: string[], manualFirst: boolean): Promise<{ caps: Caption[]; lang: string; isAuto: boolean } | null> {
for (const lang of preferredLangs) {
try {
const caps = await getSubtitles({ videoID, lang })
if (Array.isArray(caps) && caps.length > 0) {
// Convert the returned subtitles to our Caption format
const convertedCaps: Caption[] = caps.map((cap: Subtitle) => ({
start: typeof cap.start === 'string' ? parseFloat(cap.start) : cap.start,
dur: typeof cap.dur === 'string' ? parseFloat(cap.dur) : cap.dur,
text: cap.text
}))
return { caps: convertedCaps, lang, isAuto: !manualFirst }
}
} catch {
// try next
}
}
return null
}
export default async function handler(req: VercelRequest, res: VercelResponse) {
const videoId = (req.query.videoId as string | undefined)?.trim()
if (!videoId) return bad(res, 400, 'Missing videoId')
const lang = ((req.query.lang as string | undefined) || 'en').toLowerCase()
const uiLocale = (req.headers['x-ui-locale'] as string | undefined)?.toLowerCase()
const preferAuto = req.query.preferAuto === 'true'
const cacheKey = buildKey(videoId, lang, preferAuto ? 'auto' : undefined)
const now = Date.now()
const cached = memoryCache.get(cacheKey)
if (cached && cached.expires > now) {
return ok(res, cached.body)
}
try {
// Since getVideoDetails doesn't exist, we'll use a simple approach
// In a real implementation, you might want to use YouTube's API or other methods
const title = '' // Will be populated from captions or other sources
const description = ''
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
let selected = null as null | { caps: Caption[]; lang: string; isAuto: boolean }
// Manual first
selected = await pickCaptions(videoId, langs, true)
if (!selected) {
// Try auto
selected = await pickCaptions(videoId, langs, false)
}
const captions = selected?.caps || []
const transcript = captions.map(c => c.text).join(' ').trim()
const response = {
title,
description,
captions,
transcript,
lang: selected?.lang || lang,
isAuto: selected?.isAuto || false,
source: 'youtube'
}
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
return ok(res, response)
} catch (e) {
return bad(res, 500, 'Failed to fetch YouTube metadata')
}
}

View File

@@ -2,8 +2,13 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f172a" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Boris - Nostr Bookmarks</title>
<meta name="description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<link rel="canonical" href="https://read.withboris.com/" />
@@ -20,6 +25,11 @@
<meta name="twitter:url" content="https://read.withboris.com/" />
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<!-- Default to system theme until settings load from Nostr -->
<script>
document.documentElement.className = 'theme-system';
</script>
</head>
<body>
<div id="root"></div>

6948
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.4.2",
"version": "0.6.12",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",
@@ -12,8 +12,11 @@
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.0.2",
"@treeee/youtube-caption-extractor": "^1.5.5",
"@vercel/node": "^5.3.26",
"applesauce-accounts": "^4.0.0",
"applesauce-content": "^4.0.0",
"applesauce-core": "^4.0.0",
@@ -23,24 +26,36 @@
"applesauce-relay": "^4.0.0",
"date-fns": "^4.1.0",
"nostr-tools": "^2.4.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-loading-skeleton": "^3.5.0",
"react-markdown": "^10.1.0",
"react-player": "^2.16.0",
"react-router-dom": "^7.9.3",
"reading-time-estimator": "^1.14.0",
"remark-gfm": "^4.0.1"
"rehype-prism-plus": "^2.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"use-pull-to-refresh": "^2.4.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.14",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.14",
"typescript": "^5.2.2",
"vite": "^5.0.8"
"vite": "^5.0.8",
"vite-plugin-pwa": "^1.0.3",
"workbox-window": "^7.3.0"
},
"eslintConfig": {
"root": true,

7
postcss.config.js Normal file
View File

@@ -0,0 +1,7 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,37 @@
{
"name": "Boris - Nostr Bookmarks",
"short_name": "Boris",
"description": "Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"theme_color": "#0f172a",
"background_color": "#0b1220",
"orientation": "any",
"categories": ["productivity", "social", "utilities"],
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@@ -1,56 +0,0 @@
// Service Worker for Boris - handles offline image caching
const CACHE_NAME = 'boris-image-cache-v1'
// Install event - activate immediately
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...')
self.skipWaiting()
})
// Activate event - take control immediately
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...')
event.waitUntil(self.clients.claim())
})
// Fetch event - intercept image requests
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
// Only intercept image requests
const isImage = event.request.destination === 'image' ||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
if (!isImage) {
return // Let other requests pass through
}
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(cachedResponse => {
if (cachedResponse) {
console.log('[SW] Serving cached image:', url.pathname)
return cachedResponse
}
// Not in cache, try to fetch
return fetch(event.request)
.then(response => {
// Only cache successful responses
if (response && response.status === 200) {
// Clone the response before caching
cache.put(event.request, response.clone())
console.log('[SW] Cached new image:', url.pathname)
}
return response
})
.catch(error => {
console.error('[SW] Fetch failed for:', url.pathname, error)
// Return a fallback or let it fail
throw error
})
})
})
)
})

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

@@ -11,7 +11,9 @@ import { createAddressLoader } from 'applesauce-loaders/loaders'
import Bookmarks from './components/Bookmarks'
import Toast from './components/Toast'
import { useToast } from './hooks/useToast'
import { useOnlineStatus } from './hooks/useOnlineStatus'
import { RELAYS } from './config/relays'
import { SkeletonThemeProvider } from './components/Skeletons'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
@@ -60,6 +62,15 @@ function AppRoutes({
/>
}
/>
<Route
path="/support"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/explore"
element={
@@ -69,6 +80,73 @@ function AppRoutes({
/>
}
/>
<Route
path="/explore/writings"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/me"
element={<Navigate to="/me/highlights" replace />}
/>
<Route
path="/me/highlights"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/me/reading-list"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/me/archive"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/me/writings"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/p/:npub"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/p/:npub/writings"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes>
)
@@ -79,6 +157,7 @@ function App() {
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
const [relayPool, setRelayPool] = useState<RelayPool | null>(null)
const { toastMessage, toastType, showToast, clearToast } = useToast()
const isOnline = useOnlineStatus()
useEffect(() => {
const initializeApp = async () => {
@@ -136,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, {
@@ -156,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()
}
}
}
@@ -174,6 +251,25 @@ function App() {
}
}, [])
// Monitor online/offline status
useEffect(() => {
if (!isOnline) {
showToast('You are offline. Some features may be limited.')
}
}, [isOnline, showToast])
// Listen for service worker updates
useEffect(() => {
const handleSWUpdate = () => {
showToast('New version available! Refresh to update.')
}
window.addEventListener('sw-update-available', handleSWUpdate)
return () => {
window.removeEventListener('sw-update-available', handleSWUpdate)
}
}, [showToast])
if (!eventStore || !accountManager || !relayPool) {
return (
<div className="loading">
@@ -183,22 +279,24 @@ function App() {
}
return (
<EventStoreProvider eventStore={eventStore}>
<AccountsProvider manager={accountManager}>
<BrowserRouter>
<div className="app">
<AppRoutes relayPool={relayPool} showToast={showToast} />
</div>
</BrowserRouter>
{toastMessage && (
<Toast
message={toastMessage}
type={toastType}
onClose={clearToast}
/>
)}
</AccountsProvider>
</EventStoreProvider>
<SkeletonThemeProvider>
<EventStoreProvider eventStore={eventStore}>
<AccountsProvider manager={accountManager}>
<BrowserRouter>
<div className="min-h-screen p-0 max-w-none m-0 relative">
<AppRoutes relayPool={relayPool} showToast={showToast} />
</div>
</BrowserRouter>
{toastMessage && (
<Toast
message={toastMessage}
type={toastType}
onClose={clearToast}
/>
)}
</AccountsProvider>
</EventStoreProvider>
</SkeletonThemeProvider>
)
}

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
@@ -139,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()
@@ -183,7 +183,7 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
}
}
return (
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
@@ -280,7 +280,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
</div>
</form>
</div>
</div>
</div>,
document.body
)
}

View File

@@ -0,0 +1,58 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUserCircle } from '@fortawesome/free-solid-svg-icons'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
interface AuthorCardProps {
authorPubkey: string
clickable?: boolean
}
const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey, clickable = true }) => {
const navigate = useNavigate()
const profile = useEventModel(Models.ProfileModel, [authorPubkey])
const getAuthorName = () => {
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
return `${authorPubkey.slice(0, 8)}...${authorPubkey.slice(-8)}`
}
const authorImage = profile?.picture || profile?.image
const authorBio = profile?.about
const handleClick = () => {
if (clickable) {
const npub = nip19.npubEncode(authorPubkey)
navigate(`/p/${npub}`)
}
}
return (
<div
className={`author-card ${clickable ? 'author-card-clickable' : ''}`}
onClick={handleClick}
style={clickable ? { cursor: 'pointer' } : undefined}
>
<div className="author-card-avatar">
{authorImage ? (
<img src={authorImage} alt={getAuthorName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
)}
</div>
<div className="author-card-content">
<div className="author-card-name">{getAuthorName()}</div>
{authorBio && (
<p className="author-card-bio">{authorBio}</p>
)}
</div>
</div>
)
}
export default AuthorCard

View File

@@ -1,7 +1,7 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons'
import { faCalendar, faUser, faNewspaper } from '@fortawesome/free-solid-svg-icons'
import { formatDistance } from 'date-fns'
import { BlogPostPreview } from '../services/exploreService'
import { useEventModel } from 'applesauce-react/hooks'
@@ -10,9 +10,10 @@ import { Models } from 'applesauce-core'
interface BlogPostCardProps {
post: BlogPostPreview
href: string
level?: 'mine' | 'friends' | 'nostrverse'
}
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
const profile = useEventModel(Models.ProfileModel, [post.author])
const displayName = profile?.name || profile?.display_name ||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
@@ -25,18 +26,22 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
return (
<Link
to={href}
className="blog-post-card"
className={`blog-post-card ${level ? `level-${level}` : ''}`}
style={{ textDecoration: 'none', color: 'inherit' }}
>
{post.image && (
<div className="blog-post-card-image">
<div className="blog-post-card-image">
{post.image ? (
<img
src={post.image}
alt={post.title}
loading="lazy"
/>
</div>
)}
) : (
<div className="blog-post-image-placeholder">
<FontAwesomeIcon icon={faNewspaper} />
</div>
)}
</div>
<div className="blog-post-card-content">
<h3 className="blog-post-card-title">{post.title}</h3>
{post.summary && (

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)}`
@@ -110,15 +108,12 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
hasUrls,
extractedUrls,
onSelectUrl,
getIconForUrlType,
firstUrlClassification,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleImage,
articleSummary,
settings
articleSummary
}
if (viewMode === 'compact') {
@@ -127,8 +122,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
if (viewMode === 'large') {
const previewImage = articleImage || instantPreview || ogImage
return <LargeView {...sharedProps} previewImage={previewImage} />
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
}
return <CardView {...sharedProps} articleImage={articleImage} />
return <CardView {...sharedProps} getIconForUrlType={getIconForUrlType} articleImage={articleImage} />
}

View File

@@ -1,6 +1,6 @@
import React from 'react'
import React, { useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
import { formatDistanceToNow } from 'date-fns'
import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
@@ -9,7 +9,9 @@ import SidebarHeader from './SidebarHeader'
import IconButton from './IconButton'
import { ViewMode } from './Bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { UserSettings } from '../services/settingsService'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { BookmarkSkeleton } from './Skeletons'
interface BookmarkListProps {
bookmarks: Bookmark[]
@@ -26,7 +28,6 @@ interface BookmarkListProps {
lastFetchTime?: number | null
loading?: boolean
relayPool: RelayPool | null
settings?: UserSettings
isMobile?: boolean
}
@@ -45,9 +46,22 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
lastFetchTime,
loading = false,
relayPool,
settings,
isMobile = false
}) => {
const bookmarksListRef = useRef<HTMLDivElement>(null)
// Pull-to-refresh for bookmarks
const { isRefreshing: isPulling, pullPosition } = usePullToRefresh({
onRefresh: () => {
if (onRefresh) {
onRefresh()
}
},
maximumPullLength: 240,
refreshThreshold: 80,
isDisabled: !onRefresh
})
// Helper to check if a bookmark has either content or a URL
const hasContentOrUrl = (ib: IndividualBookmark) => {
// Check if has content (text)
@@ -111,18 +125,31 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
isMobile={isMobile}
/>
{loading ? (
<div className="loading">
<FontAwesomeIcon icon={faSpinner} spin />
</div>
) : allIndividualBookmarks.length === 0 ? (
<div className="empty-state">
<p>No bookmarks found.</p>
<p>Add bookmarks using your nostr client to see them here.</p>
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
</div>
{allIndividualBookmarks.length === 0 ? (
loading ? (
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{Array.from({ length: viewMode === 'large' ? 4 : viewMode === 'cards' ? 6 : 8 }).map((_, i) => (
<BookmarkSkeleton key={i} viewMode={viewMode} />
))}
</div>
</div>
) : (
<div className="empty-state">
<p>No bookmarks found.</p>
<p>Add bookmarks using your nostr client to see them here.</p>
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
</div>
)
) : (
<div className="bookmarks-list">
<div
ref={bookmarksListRef}
className="bookmarks-list"
>
<RefreshIndicator
isRefreshing={isPulling || isRefreshing || false}
pullPosition={pullPosition}
/>
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{allIndividualBookmarks.map((individualBookmark, index) =>
<BookmarkItem
@@ -131,41 +158,23 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
index={index}
onSelectUrl={onSelectUrl}
viewMode={viewMode}
settings={settings}
/>
)}
</div>
{onRefresh && (
<div className="refresh-section" style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
padding: '1rem',
marginTop: '1rem',
borderTop: '1px solid var(--border-color)',
fontSize: '0.85rem',
color: 'var(--text-secondary)'
}}>
<IconButton
icon={faRotate}
onClick={onRefresh}
title="Refresh bookmarks"
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
{lastFetchTime && (
<span>
Updated {formatDistanceToNow(lastFetchTime, { addSuffix: true })}
</span>
)}
</div>
)}
</div>
)}
<div className="view-mode-controls">
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
)}
<IconButton
icon={faList}
onClick={() => onViewModeChange('compact')}

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons'
import { IndividualBookmark } from '../../types/bookmarks'
@@ -8,7 +9,8 @@ import IconButton from '../IconButton'
import { classifyUrl } from '../../utils/helpers'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { UserSettings } from '../../services/settingsService'
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
import { getEventUrl } from '../../config/nostrGateways'
interface CardViewProps {
bookmark: IndividualBookmark
@@ -17,14 +19,12 @@ interface CardViewProps {
extractedUrls: string[]
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
getIconForUrlType: IconGetter
firstUrlClassification: { buttonText: string } | null
authorNpub: string
eventNevent?: string
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleImage?: string
articleSummary?: string
settings?: UserSettings
}
export const CardView: React.FC<CardViewProps> = ({
@@ -34,26 +34,56 @@ export const CardView: React.FC<CardViewProps> = ({
extractedUrls,
onSelectUrl,
getIconForUrlType,
firstUrlClassification,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleImage,
articleSummary,
settings
articleSummary
}) => {
const cachedImage = useImageCache(articleImage, settings)
const firstUrl = hasUrls ? extractedUrls[0] : null
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassificationType || '') : null
const [ogImage, setOgImage] = useState<string | null>(null)
const [expanded, setExpanded] = useState(false)
const [urlsExpanded, setUrlsExpanded] = useState(false)
const contentLength = (bookmark.content || '').length
const shouldTruncate = !expanded && contentLength > 210
const isArticle = bookmark.kind === 30023
const isWebBookmark = bookmark.kind === 39701
// Determine which image to use (article image, instant preview, or OG image)
const previewImage = articleImage || instantPreview || ogImage
const cachedImage = useImageCache(previewImage || undefined)
// Fetch OG image if we don't have any other image
React.useEffect(() => {
if (firstUrl && !articleImage && !instantPreview && !ogImage) {
fetchOgImage(firstUrl).then(setOgImage)
}
}, [firstUrl, articleImage, instantPreview, ogImage])
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
triggerOpen()
}
}
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
{isArticle && cachedImage && (
<div
key={`${bookmark.id}-${index}`}
className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
onClick={triggerOpen}
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
>
{cachedImage && (
<div
className="article-hero-image"
style={{ backgroundImage: `url(${cachedImage})` }}
@@ -79,11 +109,12 @@ export const CardView: React.FC<CardViewProps> = ({
{eventNevent ? (
<a
href={`https://search.dergigi.com/e/${eventNevent}`}
href={getEventUrl(eventNevent)}
target="_blank"
rel="noopener noreferrer"
className="bookmark-date-link"
title="Open event in search"
onClick={(e) => e.stopPropagation()}
>
{formatDate(bookmark.created_at)}
</a>
@@ -95,23 +126,22 @@ export const CardView: React.FC<CardViewProps> = ({
{extractedUrls.length > 0 && (
<div className="bookmark-urls">
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
const classification = classifyUrl(url)
return (
<div key={urlIndex} className="url-row">
<button
className="bookmark-url"
onClick={() => onSelectUrl?.(url)}
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
title="Open in reader"
>
{url}
</button>
<IconButton
icon={getIconForUrlType(url)}
ariaLabel={classification.buttonText}
title={classification.buttonText}
ariaLabel="Open"
title="Open"
variant="success"
size={32}
onClick={(e) => { e.preventDefault(); onSelectUrl?.(url) }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onSelectUrl?.(url) }}
/>
</div>
)
@@ -119,7 +149,7 @@ export const CardView: React.FC<CardViewProps> = ({
{extractedUrls.length > 1 && (
<button
className="expand-toggle-urls"
onClick={() => setUrlsExpanded(v => !v)}
onClick={(e) => { e.stopPropagation(); setUrlsExpanded(v => !v) }}
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
>
@@ -148,7 +178,7 @@ export const CardView: React.FC<CardViewProps> = ({
{contentLength > 210 && (
<button
className="expand-toggle"
onClick={() => setExpanded(v => !v)}
onClick={(e) => { e.stopPropagation(); setExpanded(v => !v) }}
aria-label={expanded ? 'Collapse' : 'Expand'}
title={expanded ? 'Collapse' : 'Expand'}
>
@@ -158,21 +188,16 @@ export const CardView: React.FC<CardViewProps> = ({
<div className="bookmark-footer">
<div className="bookmark-meta-minimal">
<a
href={`https://search.dergigi.com/p/${authorNpub}`}
target="_blank"
rel="noopener noreferrer"
<Link
to={`/p/${authorNpub}`}
className="author-link-minimal"
title="Open author in search"
title="Open author profile"
onClick={(e) => e.stopPropagation()}
>
{getAuthorDisplayName()}
</a>
</Link>
</div>
{(hasUrls && firstUrlClassification) || bookmark.kind === 30023 ? (
<button className="read-now-button-minimal" onClick={handleReadNow}>
{bookmark.kind === 30023 ? 'Read Article' : firstUrlClassification?.buttonText}
</button>
) : null}
{/* CTA removed */}
</div>
</div>
)

View File

@@ -4,7 +4,7 @@ import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-ico
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDateCompact } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
interface CompactViewProps {
bookmark: IndividualBookmark
@@ -12,8 +12,6 @@ interface CompactViewProps {
hasUrls: boolean
extractedUrls: string[]
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
getIconForUrlType: IconGetter
firstUrlClassification: { buttonText: string } | null
articleImage?: string
articleSummary?: string
}
@@ -24,14 +22,16 @@ export const CompactView: React.FC<CompactViewProps> = ({
hasUrls,
extractedUrls,
onSelectUrl,
getIconForUrlType,
firstUrlClassification,
articleImage,
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)
const handleCompactClick = () => {
if (!onSelectUrl) return
@@ -55,6 +55,13 @@ export const CompactView: React.FC<CompactViewProps> = ({
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
>
{/* Thumbnail image */}
{cachedImage && (
<div className="compact-thumbnail">
<img src={cachedImage} alt="" />
</div>
)}
<span className="bookmark-type-compact">
{isWebBookmark ? (
<span className="fa-layers fa-fw">
@@ -76,22 +83,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
</div>
)}
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
{isClickable && (
<button
className="compact-read-btn"
onClick={(e) => {
e.stopPropagation()
if (isArticle) {
onSelectUrl?.('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
} else {
onSelectUrl?.(extractedUrls[0])
}
}}
title={isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
>
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
</button>
)}
{/* CTA removed */}
</div>
</div>
)

View File

@@ -1,11 +1,12 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { UserSettings } from '../../services/settingsService'
import { getEventUrl } from '../../config/nostrGateways'
interface LargeViewProps {
bookmark: IndividualBookmark
@@ -14,14 +15,12 @@ interface LargeViewProps {
extractedUrls: string[]
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
getIconForUrlType: IconGetter
firstUrlClassification: { buttonText: string } | null
previewImage: string | null
authorNpub: string
eventNevent?: string
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleSummary?: string
settings?: UserSettings
}
export const LargeView: React.FC<LargeViewProps> = ({
@@ -31,24 +30,38 @@ export const LargeView: React.FC<LargeViewProps> = ({
extractedUrls,
onSelectUrl,
getIconForUrlType,
firstUrlClassification,
previewImage,
authorNpub,
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>)
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
triggerOpen()
}
}
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
<div
key={`${bookmark.id}-${index}`}
className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
onClick={triggerOpen}
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
>
{(hasUrls || (isArticle && cachedImage)) && (
<div
className="large-preview-image"
onClick={() => {
onClick={(e) => {
e.stopPropagation()
if (isArticle) {
handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
} else {
@@ -78,33 +91,28 @@ export const LargeView: React.FC<LargeViewProps> = ({
<div className="large-footer">
<span className="large-author">
<a
href={`https://search.dergigi.com/p/${authorNpub}`}
target="_blank"
rel="noopener noreferrer"
<Link
to={`/p/${authorNpub}`}
className="author-link-minimal"
onClick={(e) => e.stopPropagation()}
>
{getAuthorDisplayName()}
</a>
</Link>
</span>
{eventNevent && (
<a
href={`https://search.dergigi.com/e/${eventNevent}`}
href={getEventUrl(eventNevent)}
target="_blank"
rel="noopener noreferrer"
className="bookmark-date-link"
onClick={(e) => e.stopPropagation()}
>
{formatDate(bookmark.created_at)}
</a>
)}
{(hasUrls && firstUrlClassification) || isArticle ? (
<button className="large-read-button" onClick={handleReadNow}>
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
{isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
</button>
) : null}
{/* CTA removed */}
</div>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import { useParams, useLocation, useNavigate } from 'react-router-dom'
import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { useSettings } from '../hooks/useSettings'
import { useArticleLoader } from '../hooks/useArticleLoader'
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
@@ -14,6 +15,8 @@ import { useRelayStatus } from '../hooks/useRelayStatus'
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'
@@ -24,24 +27,56 @@ interface BookmarksProps {
}
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const { naddr } = useParams<{ naddr?: string }>()
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
const location = useLocation()
const navigate = useNavigate()
const previousLocationRef = useRef<string>()
// Check for highlight navigation state
const navigationState = location.state as { highlightId?: string; openHighlights?: boolean } | null
const externalUrl = location.pathname.startsWith('/r/')
? decodeURIComponent(location.pathname.slice(3))
: undefined
const showSettings = location.pathname === '/settings'
const showExplore = location.pathname === '/explore'
const showExplore = location.pathname.startsWith('/explore')
const showMe = location.pathname.startsWith('/me')
const showProfile = location.pathname.startsWith('/p/')
const showSupport = location.pathname === '/support'
// Track previous location for going back from settings
// Extract tab from explore routes
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
// Extract tab from me routes
const meTab = location.pathname === '/me' ? 'highlights' :
location.pathname === '/me/highlights' ? 'highlights' :
location.pathname === '/me/reading-list' ? 'reading-list' :
location.pathname === '/me/archive' ? 'archive' :
location.pathname === '/me/writings' ? 'writings' : 'highlights'
// Extract tab from profile routes
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
// Decode npub to pubkey for profile view
let profilePubkey: string | undefined
if (npub && showProfile) {
try {
const decoded = nip19.decode(npub)
if (decoded.type === 'npub') {
profilePubkey = decoded.data
}
} catch (err) {
console.error('Failed to decode npub:', err)
}
}
// Track previous location for going back from settings/me/explore/profile
useEffect(() => {
if (!showSettings) {
if (!showSettings && !showMe && !showExplore && !showProfile) {
previousLocationRef.current = location.pathname
}
}, [location.pathname, showSettings])
}, [location.pathname, showSettings, showMe, showExplore, showProfile])
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
@@ -91,12 +126,27 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
} = useBookmarksUI({ settings })
// Close sidebar on mobile when route changes (e.g., clicking on blog posts in Explore)
const prevPathnameRef = useRef<string>(location.pathname)
useEffect(() => {
if (isMobile && isSidebarOpen) {
// Only close if pathname actually changed, not on initial render or other state changes
if (isMobile && isSidebarOpen && prevPathnameRef.current !== location.pathname) {
toggleSidebar()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname])
prevPathnameRef.current = location.pathname
}, [location.pathname, isMobile, isSidebarOpen, toggleSidebar])
// Handle highlight navigation from explore page
useEffect(() => {
if (navigationState?.highlightId && navigationState?.openHighlights) {
// Open the highlights sidebar
setIsHighlightsCollapsed(false)
// Select the highlight (scroll happens automatically in useHighlightInteractions)
setSelectedHighlightId(navigationState.highlightId)
// Clear the state after handling to avoid re-triggering
navigate(location.pathname, { replace: true, state: {} })
}
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
const {
bookmarks,
@@ -202,6 +252,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
isSidebarOpen={isSidebarOpen}
showSettings={showSettings}
showExplore={showExplore}
showMe={showMe}
showProfile={showProfile}
showSupport={showSupport}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
viewMode={viewMode}
@@ -244,6 +297,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
onClearSelection={handleClearSelection}
currentUserPubkey={activeAccount?.pubkey}
followedPubkeys={followedPubkeys}
activeAccount={activeAccount}
currentArticle={currentArticle}
highlights={highlights}
highlightsLoading={highlightsLoading}
onToggleHighlightsPanel={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
@@ -255,7 +310,16 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
onCreateHighlight={handleCreateHighlight}
hasActiveAccount={!!(activeAccount && relayPool)}
explore={showExplore ? (
relayPool ? <Explore relayPool={relayPool} /> : null
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
) : undefined}
me={showMe ? (
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
) : undefined}
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}

View File

@@ -0,0 +1,41 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'
interface CompactButtonProps {
icon?: IconDefinition
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
title?: string
ariaLabel?: string
disabled?: boolean
spin?: boolean
className?: string
children?: React.ReactNode
}
const CompactButton: React.FC<CompactButtonProps> = ({
icon,
onClick,
title,
ariaLabel,
disabled = false,
spin = false,
className = '',
children
}) => {
return (
<button
className={`compact-button ${className}`.trim()}
onClick={onClick}
title={title}
aria-label={ariaLabel || title}
disabled={disabled}
>
{icon && <FontAwesomeIcon icon={icon} spin={spin} />}
{children}
</button>
)
}
export default CompactButton

View File

@@ -0,0 +1,56 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
interface ConfirmDialogProps {
isOpen: boolean
title: string
message: string
confirmText?: string
cancelText?: string
onConfirm: () => void
onCancel: () => void
variant?: 'danger' | 'warning' | 'info'
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
onConfirm,
onCancel,
variant = 'warning'
}) => {
if (!isOpen) return null
return (
<div className="confirm-dialog-overlay" onClick={onCancel}>
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
<div className={`confirm-dialog-icon ${variant}`}>
<FontAwesomeIcon icon={faExclamationTriangle} />
</div>
<h3 className="confirm-dialog-title">{title}</h3>
<p className="confirm-dialog-message">{message}</p>
<div className="confirm-dialog-actions">
<button
className="confirm-dialog-btn cancel"
onClick={onCancel}
>
{cancelText}
</button>
<button
className={`confirm-dialog-btn confirm ${variant}`}
onClick={onConfirm}
>
{confirmText}
</button>
</div>
</div>
</div>
)
}
export default ConfirmDialog

View File

@@ -1,9 +1,19 @@
import React, { useMemo } from 'react'
import React, { useMemo, useState, useEffect, useRef } from 'react'
import ReactPlayer from 'react-player'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import rehypePrism from 'rehype-prism-plus'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import 'prismjs/themes/prism-tomorrow.css'
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare } from '@fortawesome/free-solid-svg-icons'
import { ContentSkeleton } from './Skeletons'
import { nip19 } from 'nostr-tools'
import { getNostrUrl } from '../config/nostrGateways'
import { RELAYS } from '../config/relays'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights'
import { readingTime } from 'reading-time-estimator'
import { hexToRgb } from '../utils/colorHelpers'
@@ -13,6 +23,19 @@ import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
import { useHighlightedContent } from '../hooks/useHighlightedContent'
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
import { UserSettings } from '../services/settingsService'
import {
createEventReaction,
createWebsiteReaction,
hasMarkedEventAsRead,
hasMarkedWebsiteAsRead
} from '../services/reactionService'
import AuthorCard from './AuthorCard'
import { faBooks } from '../icons/customIcons'
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
import { classifyUrl } from '../utils/helpers'
import { buildNativeVideoUrl } from '../utils/videoHelpers'
import { useReadingPosition } from '../hooks/useReadingPosition'
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
interface ContentPanelProps {
loading: boolean
@@ -34,9 +57,14 @@ interface ContentPanelProps {
followedPubkeys?: Set<string>
settings?: UserSettings
relayPool?: RelayPool | null
activeAccount?: IAccount | null
currentArticle?: NostrEvent | null
// For highlight creation
onTextSelection?: (text: string) => void
onClearSelection?: () => void
// For reading progress indicator positioning
isSidebarCollapsed?: boolean
isHighlightsCollapsed?: boolean
}
const ContentPanel: React.FC<ContentPanelProps> = ({
@@ -54,14 +82,28 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
highlightColor = '#ffff00',
settings,
relayPool,
activeAccount,
currentArticle,
onHighlightClick,
selectedHighlightId,
highlightVisibility = { nostrverse: true, friends: true, mine: true },
currentUserPubkey,
followedPubkeys = new Set(),
onTextSelection,
onClearSelection
onClearSelection,
isSidebarCollapsed = false,
isHighlightsCollapsed = false
}) => {
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
const [showArticleMenu, setShowArticleMenu] = useState(false)
const [showVideoMenu, setShowVideoMenu] = useState(false)
const [showExternalMenu, setShowExternalMenu] = useState(false)
const articleMenuRef = useRef<HTMLDivElement>(null)
const videoMenuRef = useRef<HTMLDivElement>(null)
const externalMenuRef = useRef<HTMLDivElement>(null)
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
const { finalHtml, relevantHighlights } = useHighlightedContent({
@@ -84,6 +126,41 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onClearSelection
})
// Reading position tracking - only for text content, not videos
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
const { isReadingComplete, progressPercentage } = useReadingPosition({
enabled: isTextContent,
onReadingComplete: () => {
// Optional: Auto-mark as read when reading is complete
if (activeAccount && !isMarkedAsRead) {
// Could trigger auto-mark as read here if desired
}
}
})
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
if (articleMenuRef.current && !articleMenuRef.current.contains(target)) {
setShowArticleMenu(false)
}
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
setShowVideoMenu(false)
}
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
setShowExternalMenu(false)
}
}
if (showArticleMenu || showVideoMenu || showExternalMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [showArticleMenu, showVideoMenu, showExternalMenu])
const readingStats = useMemo(() => {
const content = markdown || html || ''
if (!content) return null
@@ -93,6 +170,233 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const hasHighlights = relevantHighlights.length > 0
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
// Track external video duration (in seconds) for display in header
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
// Load YouTube metadata/captions when applicable
useEffect(() => {
(async () => {
try {
if (!selectedUrl) return setYtMeta(null)
const id = extractYouTubeId(selectedUrl)
if (!id) return setYtMeta(null)
const locale = navigator?.language?.split('-')[0] || 'en'
const data = await getYouTubeMeta(id, locale)
if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript })
} catch {
setYtMeta(null)
}
})()
}, [selectedUrl])
const formatDuration = (totalSeconds: number): string => {
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = Math.floor(totalSeconds % 60)
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
const ss = String(seconds).padStart(2, '0')
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
}
// Get article links for menu
const getArticleLinks = () => {
if (!currentArticle) return null
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
const relayHints = RELAYS.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3)
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: currentArticle.pubkey,
identifier: dTag,
relays: relayHints
})
return {
portal: getNostrUrl(naddr),
native: `nostr:${naddr}`
}
}
const articleLinks = getArticleLinks()
const handleMenuToggle = () => {
setShowArticleMenu(!showArticleMenu)
}
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
const handleOpenPortal = () => {
if (articleLinks) {
window.open(articleLinks.portal, '_blank', 'noopener,noreferrer')
}
setShowArticleMenu(false)
}
const handleOpenNative = () => {
if (articleLinks) {
window.location.href = articleLinks.native
}
setShowArticleMenu(false)
}
// Video actions
const handleOpenVideoExternal = () => {
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
setShowVideoMenu(false)
}
const handleOpenVideoNative = () => {
if (!selectedUrl) return
const native = buildNativeVideoUrl(selectedUrl)
if (native) {
window.location.href = native
} else {
window.location.href = selectedUrl
}
setShowVideoMenu(false)
}
const handleCopyVideoUrl = async () => {
try {
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
} catch (e) {
console.warn('Clipboard copy failed', e)
} finally {
setShowVideoMenu(false)
}
}
const handleShareVideoUrl = async () => {
try {
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Video', url: selectedUrl })
} else if (selectedUrl) {
await navigator.clipboard.writeText(selectedUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowVideoMenu(false)
}
}
// External article actions
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
const handleOpenExternalUrl = () => {
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
setShowExternalMenu(false)
}
const handleCopyExternalUrl = async () => {
try {
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
} catch (e) {
console.warn('Clipboard copy failed', e)
} finally {
setShowExternalMenu(false)
}
}
const handleShareExternalUrl = async () => {
try {
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Article', url: selectedUrl })
} else if (selectedUrl) {
await navigator.clipboard.writeText(selectedUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowExternalMenu(false)
}
}
// Check if article is already marked as read when URL/article changes
useEffect(() => {
const checkReadStatus = async () => {
if (!activeAccount || !relayPool || !selectedUrl) {
setIsMarkedAsRead(false)
return
}
setIsCheckingReadStatus(true)
try {
let hasRead = false
if (isNostrArticle && currentArticle) {
hasRead = await hasMarkedEventAsRead(
currentArticle.id,
activeAccount.pubkey,
relayPool
)
} else {
hasRead = await hasMarkedWebsiteAsRead(
selectedUrl,
activeAccount.pubkey,
relayPool
)
}
setIsMarkedAsRead(hasRead)
} catch (error) {
console.error('Failed to check read status:', error)
} finally {
setIsCheckingReadStatus(false)
}
}
checkReadStatus()
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
const handleMarkAsRead = () => {
if (!activeAccount || !relayPool || isMarkedAsRead) {
return
}
// Instantly update UI with checkmark animation
setIsMarkedAsRead(true)
setShowCheckAnimation(true)
// Reset animation after it completes
setTimeout(() => {
setShowCheckAnimation(false)
}, 600)
// Fire-and-forget: publish in background without blocking UI
;(async () => {
try {
if (isNostrArticle && currentArticle) {
await createEventReaction(
currentArticle.id,
currentArticle.pubkey,
currentArticle.kind,
activeAccount,
relayPool
)
console.log('✅ Marked nostr article as read')
} else if (selectedUrl) {
await createWebsiteReaction(
selectedUrl,
activeAccount,
relayPool
)
console.log('✅ Marked website as read')
}
} catch (error) {
console.error('Failed to mark as read:', error)
// Revert UI state on error
setIsMarkedAsRead(false)
}
})()
}
if (!selectedUrl) {
return (
<div className="reader empty">
@@ -103,10 +407,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (loading) {
return (
<div className="reader loading">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin />
</div>
<div className="reader" aria-busy="true">
<ContentSkeleton />
</div>
)
}
@@ -114,58 +416,268 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const highlightRgb = hexToRgb(highlightColor)
return (
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
{/* Hidden markdown preview to convert markdown to HTML */}
<>
{/* Reading Progress Indicator - Outside reader for fixed positioning */}
{isTextContent && (
<ReadingProgressIndicator
progress={progressPercentage}
isComplete={isReadingComplete}
showPercentage={true}
isSidebarCollapsed={isSidebarCollapsed}
isHighlightsCollapsed={isHighlightsCollapsed}
/>
)}
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
{/* Hidden markdown preview to convert markdown to HTML */}
{markdown && (
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypePrism]}
components={{
img: ({ src, alt, ...props }) => (
<img
src={src}
alt={alt}
{...props}
/>
)
}}
>
{processedMarkdown || markdown}
</ReactMarkdown>
</div>
)}
<ReaderHeader
title={title}
title={ytMeta?.title || title}
image={image}
summary={summary}
published={published}
readingTimeText={readingStats ? readingStats.text : null}
readingTimeText={isExternalVideo ? (videoDurationSec !== null ? formatDuration(videoDurationSec) : null) : (readingStats ? readingStats.text : null)}
hasHighlights={hasHighlights}
highlightCount={relevantHighlights.length}
settings={settings}
highlights={relevantHighlights}
highlightVisibility={highlightVisibility}
/>
{markdown || html ? (
markdown ? (
renderedMarkdownHtml && finalHtml ? (
{isExternalVideo ? (
<>
<div className="reader-video">
<ReactPlayer
url={selectedUrl as string}
controls
width="100%"
height="auto"
style={{
width: '100%',
height: 'auto',
aspectRatio: '16/9'
}}
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
/>
</div>
{ytMeta?.description && (
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
{ytMeta.description}
</div>
)}
{ytMeta?.transcript && (
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
<h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3>
<div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}>
{ytMeta.transcript}
</div>
</div>
)}
<div className="article-menu-container">
<div className="article-menu-wrapper" ref={videoMenuRef}>
<button
className="article-menu-btn"
onClick={toggleVideoMenu}
title="More options"
>
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showVideoMenu && (
<div className="article-menu">
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Link</span>
</button>
<button className="article-menu-item" onClick={handleOpenVideoNative}>
<FontAwesomeIcon icon={faMobileAlt} />
<span>Open in Native App</span>
</button>
<button className="article-menu-item" onClick={handleCopyVideoUrl}>
<FontAwesomeIcon icon={faCopy} />
<span>Copy URL</span>
</button>
<button className="article-menu-item" onClick={handleShareVideoUrl}>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
</div>
)}
</div>
</div>
{activeAccount && (
<div className="mark-as-read-container">
<button
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
onClick={handleMarkAsRead}
disabled={isMarkedAsRead || isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
>
<FontAwesomeIcon
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
spin={isCheckingReadStatus}
/>
<span>
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Watched' : 'Mark as Watched'}
</span>
</button>
</div>
)}
</>
) : markdown || html ? (
<>
{markdown ? (
renderedMarkdownHtml && finalHtml ? (
<div
ref={contentRef}
className="reader-markdown"
dangerouslySetInnerHTML={{ __html: finalHtml }}
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
) : (
<div className="reader-markdown">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
</div>
</div>
)
) : (
<div
ref={contentRef}
className="reader-markdown"
dangerouslySetInnerHTML={{ __html: finalHtml }}
className="reader-html"
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
) : (
<div className="reader-markdown">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
)}
{/* Article menu for external URLs */}
{!isNostrArticle && !isExternalVideo && selectedUrl && (
<div className="article-menu-container">
<div className="article-menu-wrapper" ref={externalMenuRef}>
<button
className="article-menu-btn"
onClick={toggleExternalMenu}
title="More options"
>
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showExternalMenu && (
<div className="article-menu">
<button
className="article-menu-item"
onClick={handleOpenExternalUrl}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Original URL</span>
</button>
<button
className="article-menu-item"
onClick={handleCopyExternalUrl}
>
<FontAwesomeIcon icon={faCopy} />
<span>Copy URL</span>
</button>
<button
className="article-menu-item"
onClick={handleShareExternalUrl}
>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
</div>
)}
</div>
</div>
)
) : (
<div
ref={contentRef}
className="reader-html"
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
)
)}
{/* Article menu for nostr-native articles */}
{isNostrArticle && currentArticle && articleLinks && (
<div className="article-menu-container">
<div className="article-menu-wrapper" ref={articleMenuRef}>
<button
className="article-menu-btn"
onClick={handleMenuToggle}
title="More options"
>
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showArticleMenu && (
<div className="article-menu">
<button
className="article-menu-item"
onClick={handleOpenPortal}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open on Nostr</span>
</button>
<button
className="article-menu-item"
onClick={handleOpenNative}
>
<FontAwesomeIcon icon={faMobileAlt} />
<span>Open with Native App</span>
</button>
</div>
)}
</div>
</div>
)}
{/* Mark as Read button */}
{activeAccount && (
<div className="mark-as-read-container">
<button
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
onClick={handleMarkAsRead}
disabled={isMarkedAsRead || isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
>
<FontAwesomeIcon
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
spin={isCheckingReadStatus}
/>
<span>
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
</span>
</button>
</div>
)}
{/* Author info card for nostr-native articles */}
{isNostrArticle && currentArticle && (
<div className="author-card-container">
<AuthorCard authorPubkey={currentArticle.pubkey} />
</div>
)}
</>
) : (
<div className="reader empty">
<p>No readable content found for this URL.</p>
</div>
)}
</div>
</div>
</>
)
}

View File

@@ -1,69 +1,227 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useMemo } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
import { fetchContacts } from '../services/contactService'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import { fetchHighlightsFromAuthors } from '../services/highlightService'
import { fetchProfiles } from '../services/profileService'
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
import { Highlight } from '../types/highlights'
import { UserSettings } from '../services/settingsService'
import BlogPostCard from './BlogPostCard'
import { HighlightItem } from './HighlightItem'
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { classifyHighlights } from '../utils/highlightClassification'
import { HighlightVisibility } from './HighlightsPanel'
interface ExploreProps {
relayPool: RelayPool
eventStore: IEventStore
settings?: UserSettings
activeTab?: TabType
}
const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
type TabType = 'writings' | 'highlights'
const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, activeTab: propActiveTab }) => {
const activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
const [highlights, setHighlights] = useState<Highlight[]>([])
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshTrigger, setRefreshTrigger] = useState(0)
// Visibility filters (defaults from settings, or friends only)
const [visibility, setVisibility] = useState<HighlightVisibility>({
nostrverse: settings?.defaultHighlightVisibilityNostrverse ?? false,
friends: settings?.defaultHighlightVisibilityFriends ?? true,
mine: settings?.defaultHighlightVisibilityMine ?? false
})
// Update local state when prop changes
useEffect(() => {
if (propActiveTab) {
setActiveTab(propActiveTab)
}
}, [propActiveTab])
useEffect(() => {
const loadBlogPosts = async () => {
const loadData = async () => {
if (!activeAccount) {
setError('Please log in to explore content from your friends')
setLoading(false)
return
}
try {
// show spinner but keep existing 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) {
setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev)
}
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
if (cachedHighlights && cachedHighlights.length > 0) {
setHighlights(prev => prev.length === 0 ? cachedHighlights : prev)
}
// Fetch the user's contacts (friends)
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
if (contacts.size === 0) {
setError('You are not following anyone yet. Follow some people to see their blog posts!')
setLoading(false)
return
}
// Get relay URLs from pool
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
// Fetch blog posts from friends
const posts = await fetchBlogPostsFromAuthors(
const contacts = await fetchContacts(
relayPool,
Array.from(contacts),
relayUrls
activeAccount.pubkey,
(partial) => {
// Store followed pubkeys for highlight classification
setFollowedPubkeys(partial)
// When local contacts are available, kick off early fetch
if (partial.size > 0) {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const partialArray = Array.from(partial)
// Fetch blog posts
fetchBlogPostsFromAuthors(
relayPool,
partialArray,
relayUrls,
(post) => {
setBlogPosts((prev) => {
const exists = prev.some(p => p.event.id === post.event.id)
if (exists) return prev
const next = [...prev, post]
return next.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
}
).then((all) => {
setBlogPosts((prev) => {
const byId = new Map(prev.map(p => [p.event.id, p]))
for (const post of all) byId.set(post.event.id, post)
const merged = Array.from(byId.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setCachedPosts(activeAccount.pubkey, merged)
return merged
})
})
// Fetch highlights
fetchHighlightsFromAuthors(
relayPool,
partialArray,
(highlight) => {
setHighlights((prev) => {
const exists = prev.some(h => h.id === highlight.id)
if (exists) return prev
const next = [...prev, highlight]
return next.sort((a, b) => b.created_at - a.created_at)
})
setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
}
).then((all) => {
setHighlights((prev) => {
const byId = new Map(prev.map(h => [h.id, h]))
for (const highlight of all) byId.set(highlight.id, highlight)
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
setCachedHighlights(activeAccount.pubkey, merged)
return merged
})
})
}
}
)
// Always proceed to load nostrverse content even if no contacts
// (removed blocking error for empty contacts)
if (posts.length === 0) {
setError('No blog posts found from your friends yet')
// Store final followed pubkeys
setFollowedPubkeys(contacts)
// Fetch both friends content and nostrverse content in parallel
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const contactsArray = Array.from(contacts)
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
fetchHighlightsFromAuthors(relayPool, contactsArray),
fetchNostrverseBlogPosts(relayPool, relayUrls, 50),
fetchNostrverseHighlights(relayPool, 100)
])
// Merge and deduplicate all posts
const allPosts = [...friendsPosts, ...nostrversePosts]
const postsByKey = new Map<string, BlogPostPreview>()
for (const post of allPosts) {
const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}`
const existing = postsByKey.get(key)
if (!existing || post.event.created_at > existing.event.created_at) {
postsByKey.set(key, post)
}
}
const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
// Merge and deduplicate all highlights
const allHighlights = [...friendsHighlights, ...nostriverseHighlights]
const highlightsByKey = new Map<string, Highlight>()
for (const highlight of allHighlights) {
highlightsByKey.set(highlight.id, highlight)
}
const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at)
// Fetch profiles for all blog post authors to cache them
if (uniquePosts.length > 0) {
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author)))
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
console.error('Failed to fetch author profiles:', err)
})
}
setBlogPosts(posts)
// No blocking errors - let empty states handle messaging
setBlogPosts(uniquePosts)
setCachedPosts(activeAccount.pubkey, uniquePosts)
setHighlights(uniqueHighlights)
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
} catch (err) {
console.error('Failed to load blog posts:', err)
setError('Failed to load blog posts. Please try again.')
console.error('Failed to load data:', err)
// No blocking error - user can pull-to-refresh
} finally {
setLoading(false)
}
}
loadBlogPosts()
}, [relayPool, activeAccount])
loadData()
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
// Pull-to-refresh
const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => {
setRefreshTrigger(prev => prev + 1)
},
maximumPullLength: 240,
refreshThreshold: 80,
isDisabled: !activeAccount
})
const getPostUrl = (post: BlogPostPreview) => {
// Get the d-tag identifier
@@ -79,48 +237,222 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
return `/a/${naddr}`
}
if (loading) {
return (
<div className="explore-container">
<div className="explore-loading">
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<p>Loading blog posts from your friends...</p>
</div>
</div>
)
const handleHighlightClick = (highlightId: string) => {
const highlight = highlights.find(h => h.id === highlightId)
if (!highlight) return
// For nostr-native articles
if (highlight.eventReference) {
// Convert eventReference to naddr
if (highlight.eventReference.includes(':')) {
const parts = highlight.eventReference.split(':')
const kind = parseInt(parts[0])
const pubkey = parts[1]
const identifier = parts[2] || ''
const naddr = nip19.naddrEncode({
kind,
pubkey,
identifier
})
navigate(`/a/${naddr}`, { state: { highlightId, openHighlights: true } })
} else {
// Already an naddr
navigate(`/a/${highlight.eventReference}`, { state: { highlightId, openHighlights: true } })
}
}
// For web URLs
else if (highlight.urlReference) {
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, { state: { highlightId, openHighlights: true } })
}
}
if (error) {
return (
<div className="explore-container">
<div className="explore-error">
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
<p>{error}</p>
</div>
</div>
)
// Classify highlights with levels based on user context and apply visibility filters
const classifiedHighlights = useMemo(() => {
const classified = classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
return classified.filter(h => {
if (h.level === 'mine' && !visibility.mine) return false
if (h.level === 'friends' && !visibility.friends) return false
if (h.level === 'nostrverse' && !visibility.nostrverse) return false
return true
})
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
// Filter blog posts by future dates and visibility, and add level classification
const filteredBlogPosts = useMemo(() => {
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
return blogPosts
.filter(post => {
// Filter out future dates
const publishedTime = post.published || post.event.created_at
if (publishedTime > maxFutureTime) return false
// Apply visibility filters
const isMine = activeAccount && post.author === activeAccount.pubkey
const isFriend = followedPubkeys.has(post.author)
const isNostrverse = !isMine && !isFriend
if (isMine && !visibility.mine) return false
if (isFriend && !visibility.friends) return false
if (isNostrverse && !visibility.nostrverse) return false
return true
})
.map(post => {
// Add level classification
const isMine = activeAccount && post.author === activeAccount.pubkey
const isFriend = followedPubkeys.has(post.author)
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
return { ...post, level }
})
}, [blogPosts, activeAccount, followedPubkeys, visibility])
const renderTabContent = () => {
switch (activeTab) {
case 'writings':
if (showSkeletons) {
return (
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))}
</div>
)
}
return filteredBlogPosts.length === 0 ? (
<div className="explore-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">
{filteredBlogPosts.map((post) => (
<BlogPostCard
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
post={post}
href={getPostUrl(post)}
level={post.level}
/>
))}
</div>
)
case 'highlights':
if (showSkeletons) {
return (
<div className="explore-grid">
{Array.from({ length: 8 }).map((_, i) => (
<HighlightSkeleton key={i} />
))}
</div>
)
}
return classifiedHighlights.length === 0 ? (
<div className="explore-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">
{classifiedHighlights.map((highlight) => (
<HighlightItem
key={highlight.id}
highlight={highlight}
relayPool={relayPool}
onHighlightClick={handleHighlightClick}
/>
))}
</div>
)
default:
return null
}
}
// Show content progressively - no blocking error screens
const hasData = highlights.length > 0 || blogPosts.length > 0
const showSkeletons = loading && !hasData
return (
<div className="explore-container">
<RefreshIndicator
isRefreshing={isRefreshing}
pullPosition={pullPosition}
/>
<div className="explore-header">
<h1>
<FontAwesomeIcon icon={faNewspaper} />
Explore
</h1>
<p className="explore-subtitle">
Discover blog posts from your friends on Nostr
</p>
</div>
<div className="explore-grid">
{blogPosts.map((post) => (
<BlogPostCard
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
post={post}
href={getPostUrl(post)}
{/* Visibility filters */}
<div className="highlight-level-toggles" style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<IconButton
icon={faArrowsRotate}
onClick={() => setRefreshTrigger(prev => prev + 1)}
title="Refresh content"
ariaLabel="Refresh content"
variant="ghost"
spin={loading || isRefreshing}
disabled={loading || isRefreshing}
/>
))}
<IconButton
icon={faNetworkWired}
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
title="Toggle nostrverse content"
ariaLabel="Toggle nostrverse content"
variant="ghost"
style={{
color: visibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
opacity: visibility.nostrverse ? 1 : 0.4
}}
/>
<IconButton
icon={faUserGroup}
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
ariaLabel="Toggle friends content"
variant="ghost"
disabled={!activeAccount}
style={{
color: visibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: visibility.friends ? 1 : 0.4
}}
/>
<IconButton
icon={faUser}
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
title={activeAccount ? "Toggle my content" : "Login to see your content"}
ariaLabel="Toggle my content"
variant="ghost"
disabled={!activeAccount}
style={{
color: visibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: visibility.mine ? 1 : 0.4
}}
/>
</div>
<div className="me-tabs">
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights"
onClick={() => navigate('/explore')}
>
<FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span>
</button>
<button
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings"
onClick={() => navigate('/explore/writings')}
>
<FontAwesomeIcon icon={faNewspaper} />
<span className="tab-label">Writings</span>
</button>
</div>
</div>
{renderTabContent()}
</div>
)
}

View File

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

View File

@@ -1,15 +1,174 @@
import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer } from '@fortawesome/free-solid-svg-icons'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import { faComments } from '@fortawesome/free-regular-svg-icons'
import { Highlight } from '../types/highlights'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { Hooks } from 'applesauce-react'
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
import { RELAYS } from '../config/relays'
import { areAllRelaysLocal } from '../utils/helpers'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
import { createDeletionRequest } from '../services/deletionService'
import ConfirmDialog from './ConfirmDialog'
import { getNostrUrl } from '../config/nostrGateways'
import CompactButton from './CompactButton'
import { HighlightCitation } from './HighlightCitation'
// Helper to detect if a URL is an image
const isImageUrl = (url: string): boolean => {
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname.toLowerCase()
return /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/.test(pathname)
} catch {
return false
}
}
// Helper to render a nostr identifier
const renderNostrId = (nostrUri: string, index: number): React.ReactElement => {
try {
// Remove nostr: prefix
const identifier = nostrUri.replace(/^nostr:/, '')
const decoded = nip19.decode(identifier)
switch (decoded.type) {
case 'npub': {
const pubkey = decoded.data
return (
<a
key={index}
href={`/p/${nip19.npubEncode(pubkey)}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
@{pubkey.slice(0, 8)}...
</a>
)
}
case 'nprofile': {
const { pubkey } = decoded.data
const npub = nip19.npubEncode(pubkey)
return (
<a
key={index}
href={`/p/${npub}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
@{pubkey.slice(0, 8)}...
</a>
)
}
case 'naddr': {
const { kind, pubkey, identifier } = decoded.data
// Check if it's a blog post (kind:30023)
if (kind === 30023) {
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
return (
<a
key={index}
href={`/a/${naddr}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
{identifier || 'Article'}
</a>
)
}
// For other kinds, show shortened identifier
return (
<span key={index} className="highlight-comment-nostr-id">
nostr:{identifier.slice(0, 12)}...
</span>
)
}
case 'note': {
const eventId = decoded.data
return (
<span key={index} className="highlight-comment-nostr-id">
note:{eventId.slice(0, 12)}...
</span>
)
}
case 'nevent': {
const { id } = decoded.data
return (
<span key={index} className="highlight-comment-nostr-id">
event:{id.slice(0, 12)}...
</span>
)
}
default:
// Fallback for unrecognized types
return (
<span key={index} className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
} catch (error) {
// If decoding fails, show shortened identifier
const identifier = nostrUri.replace(/^nostr:/, '')
return (
<span key={index} className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
}
// Component to render comment with links, inline images, and nostr identifiers
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
// Pattern to match both http(s) URLs and nostr: URIs
const urlPattern = /((?:https?:\/\/|nostr:)[^\s]+)/g
const parts = text.split(urlPattern)
return (
<>
{parts.map((part, index) => {
// Handle nostr: URIs
if (part.startsWith('nostr:')) {
return renderNostrId(part, index)
}
// Handle http(s) URLs
if (part.match(/^https?:\/\//)) {
if (isImageUrl(part)) {
return (
<img
key={index}
src={part}
alt="Comment attachment"
className="highlight-comment-image"
loading="lazy"
/>
)
} else {
return (
<a
key={index}
href={part}
target="_blank"
rel="noopener noreferrer"
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
{part}
</a>
)
}
}
return <span key={index}>{part}</span>
})}
</>
)
}
interface HighlightWithLevel extends Highlight {
level?: 'mine' | 'friends' | 'nostrverse'
@@ -23,21 +182,31 @@ interface HighlightItemProps {
relayPool?: RelayPool | null
eventStore?: IEventStore | null
onHighlightUpdate?: (highlight: Highlight) => void
onHighlightDelete?: (highlightId: string) => void
showCitation?: boolean
}
export const HighlightItem: React.FC<HighlightItemProps> = ({
highlight,
onSelectUrl,
// onSelectUrl is not used but kept in props for API compatibility
isSelected,
onHighlightClick,
relayPool,
eventStore,
onHighlightUpdate
onHighlightUpdate,
onHighlightDelete,
showCitation = true
}) => {
const itemRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [showMenu, setShowMenu] = useState(false)
const activeAccount = Hooks.useActiveAccount()
// Resolve the profile of the user who made the highlight
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
@@ -88,61 +257,49 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
}, [isSelected])
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setShowMenu(false)
}
}
if (showMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [showMenu])
const handleItemClick = () => {
if (onHighlightClick) {
onHighlightClick(highlight.id)
}
}
const handleLinkClick = (url: string, e: React.MouseEvent) => {
if (onSelectUrl) {
e.preventDefault()
onSelectUrl(url)
const getHighlightLinks = () => {
// Encode the highlight event itself (kind 9802) as a nevent
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
const nevent = nip19.neventEncode({
id: highlight.id,
relays: relayHints,
author: highlight.pubkey,
kind: 9802
})
return {
portal: getNostrUrl(nevent),
native: `nostr:${nevent}`
}
}
const getSourceLink = () => {
if (highlight.eventReference) {
// Check if it's a coordinate string (kind:pubkey:identifier) or a simple event ID
if (highlight.eventReference.includes(':')) {
// It's an addressable event coordinate, encode as naddr
const parts = highlight.eventReference.split(':')
if (parts.length === 3) {
const [kindStr, pubkey, identifier] = parts
const kind = parseInt(kindStr, 10)
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
const naddr = nip19.naddrEncode({
kind,
pubkey,
identifier,
relays: relayHints
})
return `https://njump.me/${naddr}`
}
} else {
// It's a simple event ID, encode as nevent
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
const nevent = nip19.neventEncode({
id: highlight.eventReference,
relays: relayHints,
author: highlight.author
})
return `https://njump.me/${nevent}`
}
}
return highlight.urlReference
}
const sourceLink = getSourceLink()
const highlightLinks = getHighlightLinks()
// Handle rebroadcast to all relays
const handleRebroadcast = async (e: React.MouseEvent) => {
@@ -207,13 +364,13 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
// Always show relay list, use plane icon for local-only
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
// Show server icon with relay info if available
// Show highlighter icon with relay info if available
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
const relayNames = highlight.publishedRelays.map(url =>
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: isLocalOrOffline ? faPlane : faServer,
icon: isLocalOrOffline ? faPlane : faHighlighter,
tooltip: relayNames.join('\n'),
spin: false
}
@@ -224,7 +381,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: faServer,
icon: faHighlighter,
tooltip: relayNames.join('\n'),
spin: false
}
@@ -235,7 +392,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: faServer,
icon: faHighlighter,
tooltip: relayNames.join('\n'),
spin: false
}
@@ -243,7 +400,69 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const relayIndicator = getRelayIndicatorInfo()
// Check if current user can delete this highlight
const canDelete = activeAccount && highlight.pubkey === activeAccount.pubkey
const handleConfirmDelete = async () => {
if (!activeAccount || !relayPool) {
console.warn('Cannot delete: no account or relay pool')
return
}
setIsDeleting(true)
setShowDeleteConfirm(false)
try {
await createDeletionRequest(
highlight.id,
9802, // kind for highlights
'Deleted by user',
activeAccount,
relayPool
)
console.log('✅ Highlight deletion request published')
// Notify parent to remove this highlight from the list
if (onHighlightDelete) {
onHighlightDelete(highlight.id)
}
} catch (error) {
console.error('Failed to delete highlight:', error)
} finally {
setIsDeleting(false)
}
}
const handleCancelDelete = () => {
setShowDeleteConfirm(false)
}
const handleMenuToggle = (e: React.MouseEvent) => {
e.stopPropagation()
setShowMenu(!showMenu)
}
const handleOpenPortal = (e: React.MouseEvent) => {
e.stopPropagation()
window.open(highlightLinks.portal, '_blank', 'noopener,noreferrer')
setShowMenu(false)
}
const handleOpenNative = (e: React.MouseEvent) => {
e.stopPropagation()
window.location.href = highlightLinks.native
setShowMenu(false)
}
const handleMenuDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation()
setShowMenu(false)
setShowDeleteConfirm(true)
}
return (
<>
<div
ref={itemRef}
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
@@ -251,56 +470,119 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
onClick={handleItemClick}
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
>
<div className="highlight-quote-icon">
<FontAwesomeIcon icon={faQuoteLeft} />
{relayIndicator && (
<div
className="highlight-relay-indicator"
title={relayIndicator.tooltip}
onClick={handleRebroadcast}
style={{ cursor: relayPool && eventStore ? 'pointer' : 'default' }}
>
<FontAwesomeIcon icon={relayIndicator.icon} spin={relayIndicator.spin} />
</div>
)}
<div className="highlight-header">
<CompactButton
className="highlight-timestamp"
title={new Date(highlight.created_at * 1000).toLocaleString()}
onClick={(e) => {
e.stopPropagation()
window.location.href = highlightLinks.native
}}
>
{formatDateCompact(highlight.created_at)}
</CompactButton>
</div>
<CompactButton
className="highlight-quote-button"
icon={faQuoteLeft}
title="Quote"
onClick={(e) => e.stopPropagation()}
/>
{/* relay indicator lives in footer for consistent padding/alignment */}
<div className="highlight-content">
<blockquote className="highlight-text">
{highlight.content}
</blockquote>
{showCitation && (
<HighlightCitation
highlight={highlight}
relayPool={relayPool}
/>
)}
{highlight.comment && (
<div className="highlight-comment">
{highlight.comment}
<FontAwesomeIcon icon={faComments} flip="horizontal" className="highlight-comment-icon" />
<div className="highlight-comment-text">
<CommentContent text={highlight.comment} />
</div>
</div>
)}
<div className="highlight-meta">
<span className="highlight-author">
{getUserDisplayName()}
</span>
<span className="highlight-meta-separator"></span>
<span className="highlight-time">
{formatDateCompact(highlight.created_at)}
</span>
<div className="highlight-footer">
<div className="highlight-footer-left">
{relayIndicator && (
<CompactButton
className="highlight-relay-indicator"
icon={relayIndicator.icon}
spin={relayIndicator.spin}
title={relayIndicator.tooltip}
onClick={handleRebroadcast}
disabled={!relayPool || !eventStore}
/>
)}
<span className="highlight-author">
{getUserDisplayName()}
</span>
</div>
{sourceLink && (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => highlight.urlReference && onSelectUrl ? handleLinkClick(highlight.urlReference, e) : undefined}
className="highlight-source"
title={highlight.eventReference ? 'Open on Nostr' : 'Open source'}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</a>
)}
<div className="highlight-menu-wrapper" ref={menuRef}>
<CompactButton
icon={faEllipsisH}
onClick={handleMenuToggle}
title="More options"
/>
{showMenu && (
<div className="highlight-menu">
<button
className="highlight-menu-item"
onClick={handleOpenPortal}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open on Nostr</span>
</button>
<button
className="highlight-menu-item"
onClick={handleOpenNative}
>
<FontAwesomeIcon icon={faMobileAlt} />
<span>Open with Native App</span>
</button>
{canDelete && (
<button
className="highlight-menu-item highlight-menu-item-danger"
onClick={handleMenuDeleteClick}
disabled={isDeleting}
>
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
<span>Delete</span>
</button>
)}
</div>
)}
</div>
</div>
</div>
</div>
<ConfirmDialog
isOpen={showDeleteConfirm}
title="Delete Highlight?"
message="This will request deletion of your highlight. It may still be visible on some relays that don't honor deletion requests."
confirmText="Delete"
cancelText="Cancel"
variant="danger"
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
</>
)
}

View File

@@ -4,10 +4,14 @@ 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 'use-pull-to-refresh'
import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
import RefreshIndicator from './RefreshIndicator'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { UserSettings } from '../services/settingsService'
import { HighlightSkeleton } from './Skeletons'
export interface HighlightVisibility {
nostrverse: boolean
@@ -32,6 +36,7 @@ interface HighlightsPanelProps {
followedPubkeys?: Set<string>
relayPool?: RelayPool | null
eventStore?: IEventStore | null
settings?: UserSettings
}
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
@@ -50,7 +55,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onHighlightVisibilityChange,
followedPubkeys = new Set(),
relayPool,
eventStore
eventStore,
settings
}) => {
const [showHighlights, setShowHighlights] = useState(true)
const [localHighlights, setLocalHighlights] = useState(highlights)
@@ -60,6 +66,18 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
setShowHighlights(newValue)
onToggleHighlights?.(newValue)
}
// Pull-to-refresh for highlights
const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => {
if (onRefresh) {
onRefresh()
}
},
maximumPullLength: 240,
refreshThreshold: 80,
isDisabled: !onRefresh
})
// Keep track of highlight updates
React.useEffect(() => {
@@ -72,6 +90,11 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
)
}
const handleHighlightDelete = (highlightId: string) => {
// Remove highlight from local state
setLocalHighlights(prev => prev.filter(h => h.id !== highlightId))
}
const filteredHighlights = useFilteredHighlights({
highlights: localHighlights,
selectedUrl,
@@ -85,6 +108,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
<HighlightsPanelCollapsed
hasHighlights={filteredHighlights.length > 0}
onToggleCollapse={onToggleCollapse}
settings={settings}
/>
)
}
@@ -104,8 +128,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
/>
{loading && filteredHighlights.length === 0 ? (
<div className="highlights-loading">
<FontAwesomeIcon icon={faHighlighter} spin />
<div className="highlights-list" aria-busy="true">
{Array.from({ length: 4 }).map((_, i) => (
<HighlightSkeleton key={i} />
))}
</div>
) : filteredHighlights.length === 0 ? (
<div className="highlights-empty">
@@ -119,6 +145,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
</div>
) : (
<div className="highlights-list">
<RefreshIndicator
isRefreshing={isRefreshing}
pullPosition={pullPosition}
/>
{filteredHighlights.map((highlight) => (
<HighlightItem
key={highlight.id}
@@ -129,6 +159,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
relayPool={relayPool}
eventStore={eventStore}
onHighlightUpdate={handleHighlightUpdate}
onHighlightDelete={handleHighlightDelete}
showCitation={false}
/>
))}
</div>

View File

@@ -1,16 +1,21 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faChevronRight } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
interface HighlightsPanelCollapsedProps {
hasHighlights: boolean
onToggleCollapse: () => void
settings?: UserSettings
}
const HighlightsPanelCollapsed: React.FC<HighlightsPanelCollapsedProps> = ({
hasHighlights,
onToggleCollapse
onToggleCollapse,
settings
}) => {
const highlightColor = settings?.highlightColorMine || '#ffff00'
return (
<div className="highlights-container collapsed">
<button
@@ -19,8 +24,12 @@ const HighlightsPanelCollapsed: React.FC<HighlightsPanelCollapsedProps> = ({
title="Expand highlights panel"
aria-label="Expand highlights panel"
>
<FontAwesomeIcon icon={faHighlighter} className={hasHighlights ? 'glow' : ''} />
<FontAwesomeIcon icon={faChevronRight} />
<FontAwesomeIcon
icon={faHighlighter}
className={hasHighlights ? 'glow' : ''}
style={{ color: highlightColor }}
/>
<FontAwesomeIcon icon={faChevronRight} style={{ color: highlightColor }} />
</button>
</div>
)

View File

@@ -1,7 +1,7 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
import { HighlightVisibility } from '../HighlightsPanel'
import IconButton from '../IconButton'
interface HighlightsPanelHeaderProps {
loading: boolean
@@ -32,76 +32,81 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
<div className="highlights-actions-left">
{onHighlightVisibilityChange && (
<div className="highlight-level-toggles">
<button
<IconButton
icon={faNetworkWired}
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
nostrverse: !highlightVisibility.nostrverse
})}
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
title="Toggle nostrverse highlights"
aria-label="Toggle nostrverse highlights"
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
>
<FontAwesomeIcon icon={faNetworkWired} />
</button>
<button
ariaLabel="Toggle nostrverse highlights"
variant="ghost"
style={{
color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
opacity: highlightVisibility.nostrverse ? 1 : 0.4
}}
/>
<IconButton
icon={faUserGroup}
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
friends: !highlightVisibility.friends
})}
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
aria-label="Toggle friends highlights"
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
ariaLabel="Toggle friends highlights"
variant="ghost"
disabled={!currentUserPubkey}
>
<FontAwesomeIcon icon={faUserGroup} />
</button>
<button
style={{
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: highlightVisibility.friends ? 1 : 0.4
}}
/>
<IconButton
icon={faUser}
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
mine: !highlightVisibility.mine
})}
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
aria-label="Toggle my highlights"
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
ariaLabel="Toggle my highlights"
variant="ghost"
disabled={!currentUserPubkey}
>
<FontAwesomeIcon icon={faUser} />
</button>
style={{
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: highlightVisibility.mine ? 1 : 0.4
}}
/>
</div>
)}
{onRefresh && (
<button
<IconButton
icon={faRotate}
onClick={onRefresh}
className="refresh-highlights-btn"
title="Refresh highlights"
aria-label="Refresh highlights"
ariaLabel="Refresh highlights"
variant="ghost"
disabled={loading}
>
<FontAwesomeIcon icon={faRotate} spin={loading} />
</button>
spin={loading}
/>
)}
{hasHighlights && (
<button
<IconButton
icon={showHighlights ? faEye : faEyeSlash}
onClick={onToggleHighlights}
className="toggle-highlight-display-btn"
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
aria-label={showHighlights ? 'Hide highlights' : 'Show highlights'}
>
<FontAwesomeIcon icon={showHighlights ? faEye : faEyeSlash} />
</button>
ariaLabel={showHighlights ? 'Hide highlights' : 'Show highlights'}
variant="ghost"
/>
)}
</div>
<button
<IconButton
icon={faChevronRight}
onClick={onToggleCollapse}
className="toggle-highlights-btn"
title="Collapse highlights panel"
aria-label="Collapse highlights panel"
>
<FontAwesomeIcon icon={faChevronRight} rotation={180} />
</button>
ariaLabel="Collapse highlights panel"
variant="ghost"
style={{ transform: 'rotate(180deg)' }}
/>
</div>
</div>
)

View File

@@ -12,6 +12,7 @@ interface IconButtonProps {
disabled?: boolean
spin?: boolean
className?: string
style?: React.CSSProperties
}
const IconButton: React.FC<IconButtonProps> = ({
@@ -23,7 +24,8 @@ const IconButton: React.FC<IconButtonProps> = ({
size = 33,
disabled = false,
spin = false,
className = ''
className = '',
style
}) => {
return (
<button
@@ -31,7 +33,7 @@ const IconButton: React.FC<IconButtonProps> = ({
onClick={onClick}
title={title}
aria-label={ariaLabel || title}
style={{ width: size, height: size }}
style={{ width: size, height: size, ...style }}
disabled={disabled}
>
<FontAwesomeIcon icon={icon} spin={spin} />

417
src/components/Me.tsx Normal file
View File

@@ -0,0 +1,417 @@
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
import { fetchHighlights } from '../services/highlightService'
import { fetchBookmarks } from '../services/bookmarkService'
import { fetchReadArticlesWithData } from '../services/libraryService'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
import { RELAYS } from '../config/relays'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import AuthorCard from './AuthorCard'
import BlogPostCard from './BlogPostCard'
import { BookmarkItem } from './BookmarkItem'
import IconButton from './IconButton'
import { ViewMode } from './Bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
interface MeProps {
relayPool: RelayPool
activeTab?: TabType
pubkey?: string // Optional pubkey for viewing other users' profiles
}
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
const activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
// Use provided pubkey or fall back to active account
const viewingPubkey = propPubkey || activeAccount?.pubkey
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
const [highlights, setHighlights] = useState<Highlight[]>([])
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
const [writings, setWritings] = useState<BlogPostPreview[]>([])
const [loading, setLoading] = useState(true)
const [viewMode, setViewMode] = useState<ViewMode>('cards')
const [refreshTrigger, setRefreshTrigger] = useState(0)
// Update local state when prop changes
useEffect(() => {
if (propActiveTab) {
setActiveTab(propActiveTab)
}
}, [propActiveTab])
useEffect(() => {
const loadData = async () => {
if (!viewingPubkey) {
setLoading(false)
return
}
try {
setLoading(true)
// Seed from cache if available to avoid empty flash (own profile only)
if (isOwnProfile) {
const cached = getCachedMeData(viewingPubkey)
if (cached) {
setHighlights(cached.highlights)
setBookmarks(cached.bookmarks)
setReadArticles(cached.readArticles)
}
}
// Fetch highlights and writings (public data)
const [userHighlights, userWritings] = await Promise.all([
fetchHighlights(relayPool, viewingPubkey),
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
])
setHighlights(userHighlights)
setWritings(userWritings)
// Only fetch private data for own profile
if (isOwnProfile && activeAccount) {
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
setReadArticles(userReadArticles)
// Fetch bookmarks using callback pattern
let fetchedBookmarks: Bookmark[] = []
try {
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
fetchedBookmarks = newBookmarks
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
setBookmarks([])
}
// Update cache with all fetched data
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
} else {
setBookmarks([])
setReadArticles([])
}
} catch (err) {
console.error('Failed to load data:', err)
// No blocking error - user can pull-to-refresh
} finally {
setLoading(false)
}
}
loadData()
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
// Pull-to-refresh
const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => {
setRefreshTrigger(prev => prev + 1)
},
maximumPullLength: 240,
refreshThreshold: 80,
isDisabled: !viewingPubkey
})
const handleHighlightDelete = (highlightId: string) => {
setHighlights(prev => {
const updated = prev.filter(h => h.id !== highlightId)
// Update cache when highlight is deleted (own profile only)
if (isOwnProfile && viewingPubkey) {
updateCachedHighlights(viewingPubkey, updated)
}
return updated
})
}
const getPostUrl = (post: BlogPostPreview) => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
return `/a/${naddr}`
}
// Helper to check if a bookmark has either content or a URL (same logic as BookmarkList)
const hasContentOrUrl = (ib: IndividualBookmark) => {
const hasContent = ib.content && ib.content.trim().length > 0
let hasUrl = false
if (ib.kind === 39701) {
const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1]
hasUrl = !!dTag && dTag.trim().length > 0
} else {
const urls = extractUrlsFromContent(ib.content || '')
hasUrl = urls.length > 0
}
if (ib.kind === 30023) return true
return hasContent || hasUrl
}
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
if (bookmark && bookmark.kind === 30023) {
// For kind:30023 articles, navigate to the article route
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
if (dTag && bookmark.pubkey) {
const pointer = {
identifier: dTag,
kind: 30023,
pubkey: bookmark.pubkey,
}
const naddr = nip19.naddrEncode(pointer)
navigate(`/a/${naddr}`)
}
} else if (url) {
// For regular URLs, navigate to the reader route
navigate(`/r/${encodeURIComponent(url)}`)
}
}
// Merge and flatten all individual bookmarks (same logic as BookmarkList)
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContentOrUrl)
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
// Show content progressively - no blocking error screens
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
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" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
<p>
{isOwnProfile
? 'No highlights yet. Pull to refresh!'
: 'No highlights yet. Pull to refresh!'}
</p>
</div>
) : (
<div className="highlights-list me-highlights-list">
{highlights.map((highlight) => (
<HighlightItem
key={highlight.id}
highlight={{ ...highlight, level: 'mine' }}
relayPool={relayPool}
onHighlightDelete={handleHighlightDelete}
/>
))}
</div>
)
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" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
<p>No bookmarks yet. Pull to refresh!</p>
</div>
) : (
<div className="bookmarks-list">
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{allIndividualBookmarks.map((individualBookmark, index) => (
<BookmarkItem
key={`${individualBookmark.id}-${index}`}
bookmark={individualBookmark}
index={index}
viewMode={viewMode}
onSelectUrl={handleSelectUrl}
/>
))}
</div>
<div className="view-mode-controls" style={{
display: 'flex',
justifyContent: 'center',
gap: '0.5rem',
padding: '1rem',
marginTop: '1rem',
borderTop: '1px solid var(--border-color)'
}}>
<IconButton
icon={faList}
onClick={() => setViewMode('compact')}
title="Compact list view"
ariaLabel="Compact list view"
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faThLarge}
onClick={() => setViewMode('cards')}
title="Cards view"
ariaLabel="Cards view"
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faImage}
onClick={() => setViewMode('large')}
title="Large preview view"
ariaLabel="Large preview view"
variant={viewMode === 'large' ? 'primary' : 'ghost'}
/>
</div>
</div>
)
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" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
<p>No read articles yet. Pull to refresh!</p>
</div>
) : (
<div className="explore-grid">
{readArticles.map((post) => (
<BlogPostCard
key={post.event.id}
post={post}
href={getPostUrl(post)}
/>
))}
</div>
)
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" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
<p>
{isOwnProfile
? 'No articles written yet. Pull to refresh!'
: 'No articles written yet. Pull to refresh!'}
</p>
</div>
) : (
<div className="explore-grid">
{writings.map((post) => (
<BlogPostCard
key={post.event.id}
post={post}
href={getPostUrl(post)}
/>
))}
</div>
)
default:
return null
}
}
return (
<div className="explore-container">
<RefreshIndicator
isRefreshing={isRefreshing}
pullPosition={pullPosition}
/>
<div className="explore-header">
{viewingPubkey && <AuthorCard authorPubkey={viewingPubkey} clickable={false} />}
{loading && hasData && (
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
<FontAwesomeIcon icon={faSpinner} spin />
</div>
)}
<div className="me-tabs">
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights"
onClick={() => navigate(isOwnProfile ? '/me/highlights' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}`)}
>
<FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span>
</button>
{isOwnProfile && (
<>
<button
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
data-tab="reading-list"
onClick={() => navigate('/me/reading-list')}
>
<FontAwesomeIcon icon={faBookmark} />
<span className="tab-label">Bookmarks</span>
</button>
<button
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
data-tab="archive"
onClick={() => navigate('/me/archive')}
>
<FontAwesomeIcon icon={faBooks} />
<span className="tab-label">Archive</span>
</button>
</>
)}
<button
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings"
onClick={() => navigate(isOwnProfile ? '/me/writings' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}/writings`)}
>
<FontAwesomeIcon icon={faPenToSquare} />
<span className="tab-label">Writings</span>
</button>
</div>
</div>
<div className="me-tab-content">
{renderTabContent()}
</div>
</div>
)
}
export default Me

View File

@@ -1,9 +1,12 @@
import React from 'react'
import React, { useMemo } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
import { format } from 'date-fns'
import { useImageCache } from '../hooks/useImageCache'
import { UserSettings } from '../services/settingsService'
import { Highlight, HighlightLevel } from '../types/highlights'
import { HighlightVisibility } from './HighlightsPanel'
import { hexToRgb } from '../utils/colorHelpers'
interface ReaderHeaderProps {
title?: string
@@ -14,6 +17,8 @@ interface ReaderHeaderProps {
hasHighlights: boolean
highlightCount: number
settings?: UserSettings
highlights?: Highlight[]
highlightVisibility?: HighlightVisibility
}
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
@@ -24,12 +29,47 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
readingTimeText,
hasHighlights,
highlightCount,
settings
settings,
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
// Determine the dominant highlight color based on visibility and priority
const getHighlightIndicatorStyles = useMemo(() => (isOverlay: boolean) => {
if (!highlights.length) return undefined
// Count highlights by level that are visible
const visibleLevels = new Set<HighlightLevel>()
highlights.forEach(h => {
if (h.level && highlightVisibility[h.level]) {
visibleLevels.add(h.level)
}
})
let hexColor: string | undefined
// Priority: nostrverse > friends > mine
if (visibleLevels.has('nostrverse') && highlightVisibility.nostrverse) {
hexColor = settings?.highlightColorNostrverse || '#9333ea'
} else if (visibleLevels.has('friends') && highlightVisibility.friends) {
hexColor = settings?.highlightColorFriends || '#f97316'
} else if (visibleLevels.has('mine') && highlightVisibility.mine) {
hexColor = settings?.highlightColorMine || '#ffff00'
}
if (!hexColor) return undefined
const rgb = hexToRgb(hexColor)
return {
backgroundColor: `rgba(${rgb}, 0.1)`,
borderColor: `rgba(${rgb}, 0.3)`,
// Only force white color in overlay context, otherwise let CSS handle it
...(isOverlay && { color: '#fff' })
}
}, [highlights, highlightVisibility, settings])
if (cachedImage) {
return (
<>
@@ -52,7 +92,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
</div>
)}
{hasHighlights && (
<div className="highlight-indicator">
<div
className="highlight-indicator"
style={getHighlightIndicatorStyles(true)}
>
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
</div>
@@ -89,7 +132,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
</div>
)}
{hasHighlights && (
<div className="highlight-indicator">
<div
className="highlight-indicator"
style={getHighlightIndicatorStyles(false)}
>
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
</div>

View File

@@ -0,0 +1,70 @@
import React from 'react'
interface ReadingProgressIndicatorProps {
progress: number // 0 to 100
isComplete?: boolean
showPercentage?: boolean
className?: string
isSidebarCollapsed?: boolean
isHighlightsCollapsed?: boolean
}
export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> = ({
progress,
isComplete = false,
showPercentage = true,
className = '',
isSidebarCollapsed = false,
isHighlightsCollapsed = false
}) => {
const clampedProgress = Math.min(100, Math.max(0, progress))
// Calculate left and right offsets based on sidebar states (desktop only)
const leftOffset = isSidebarCollapsed
? 'var(--sidebar-collapsed-width)'
: 'var(--sidebar-width)'
const rightOffset = isHighlightsCollapsed
? 'var(--highlights-collapsed-width)'
: 'var(--highlights-width)'
return (
<div
className={`reading-progress-bar fixed bottom-0 left-0 right-0 z-[1102] backdrop-blur-sm px-3 py-1 flex items-center gap-2 transition-all duration-300 ${className}`}
style={{
'--left-offset': leftOffset,
'--right-offset': rightOffset,
backgroundColor: 'var(--color-bg-elevated)',
opacity: 0.95
} as React.CSSProperties}
>
<div
className="flex-1 h-0.5 rounded-full overflow-hidden relative"
style={{ backgroundColor: 'var(--color-border)' }}
>
<div
className={`h-full rounded-full transition-all duration-300 relative ${
isComplete
? 'bg-green-500'
: ''
}`}
style={{
width: `${clampedProgress}%`,
backgroundColor: isComplete ? undefined : 'var(--color-primary)'
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
</div>
</div>
{showPercentage && (
<div
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
isComplete ? 'text-green-500' : ''
}`}
style={{ color: isComplete ? undefined : 'var(--color-text-muted)' }}
>
{isComplete ? '✓' : `${clampedProgress}%`}
</div>
)}
</div>
)
}

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

@@ -4,15 +4,22 @@ import { faPlane, faGlobe, faCircle, faSpinner } from '@fortawesome/free-solid-s
import { RelayPool } from 'applesauce-relay'
import { useRelayStatus } from '../hooks/useRelayStatus'
import { isLocalRelay } from '../utils/helpers'
import { useIsMobile } from '../hooks/useMediaQuery'
interface RelayStatusIndicatorProps {
relayPool: RelayPool | null
showOnMobile?: boolean // Control visibility based on scroll
}
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ relayPool }) => {
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
relayPool,
showOnMobile = true
}) => {
// Poll frequently for responsive offline indicator (5s instead of default 20s)
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
const [isConnecting, setIsConnecting] = useState(true)
const [isExpanded, setIsExpanded] = useState(false)
const isMobile = useIsMobile()
if (!relayPool) return null
@@ -57,36 +64,109 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
// Don't show indicator when fully connected (but show when connecting)
if (!localOnlyMode && !offlineMode && !isConnecting) return null
const handleClick = () => {
if (isMobile) {
setIsExpanded(!isExpanded)
}
}
// On mobile, default to collapsed (icon only). On desktop, always show details.
const showDetails = !isMobile || isExpanded
// On mobile when collapsed, make it circular like the highlight button
const isCollapsedOnMobile = isMobile && !isExpanded
return (
<div className={`relay-status-indicator ${isConnecting ? 'connecting' : ''}`} title={
isConnecting
? 'Connecting to relays...'
: offlineMode
? 'Offline - No relays connected'
: 'Local Relays Only - Highlights will be marked as local'
}>
<div
className={`relay-status-indicator ${isConnecting ? 'connecting' : ''} ${isMobile ? 'mobile' : ''} ${isExpanded ? 'expanded' : ''} ${isMobile && !showOnMobile ? 'hidden' : 'visible'}`}
title={
!isMobile ? (
isConnecting
? 'Connecting to relays...'
: offlineMode
? 'Offline - No relays connected'
: 'Local Relays Only - Highlights will be marked as local'
) : undefined
}
onClick={handleClick}
style={{
position: 'fixed',
bottom: '32px',
left: '32px',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
gap: showDetails ? '0.5rem' : '0',
padding: isCollapsedOnMobile ? '0.875rem' : (showDetails ? '0.75rem 1rem' : '0.75rem'),
width: isCollapsedOnMobile ? '56px' : 'auto',
height: isCollapsedOnMobile ? '56px' : 'auto',
backgroundColor: 'rgba(39, 39, 42, 0.9)',
backdropFilter: 'blur(8px)',
border: '1px solid rgb(82, 82, 91)',
borderRadius: isCollapsedOnMobile ? '50%' : '12px',
color: 'rgb(228, 228, 231)',
fontSize: '0.875rem',
fontWeight: 500,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
cursor: isMobile ? 'pointer' : 'default',
opacity: isMobile && !showOnMobile ? 0 : 1,
visibility: isMobile && !showOnMobile ? 'hidden' : 'visible',
transition: 'all 0.3s ease',
userSelect: 'none',
justifyContent: isCollapsedOnMobile ? 'center' : 'flex-start'
}}
>
<div className="relay-status-icon">
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
</div>
<div className="relay-status-text">
{isConnecting ? (
<span className="relay-status-title">Connecting</span>
) : offlineMode ? (
<>
<span className="relay-status-title">Offline</span>
<span className="relay-status-subtitle">No relays connected</span>
</>
) : (
<>
<span className="relay-status-title">Flight Mode</span>
<span className="relay-status-subtitle">{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}</span>
</>
)}
</div>
{!offlineMode && !isConnecting && (
<div className="relay-status-pulse">
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
</div>
{showDetails && (
<>
<div
className="relay-status-text"
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.125rem'
}}
>
{isConnecting ? (
<span className="relay-status-title">Connecting</span>
) : offlineMode ? (
<>
<span className="relay-status-title">Offline</span>
<span
className="relay-status-subtitle"
style={{
fontSize: '0.75rem',
opacity: 0.7,
fontWeight: 400
}}
>
No relays connected
</span>
</>
) : (
<>
<span className="relay-status-title">Flight Mode</span>
<span
className="relay-status-subtitle"
style={{
fontSize: '0.75rem',
opacity: 0.7,
fontWeight: 400
}}
>
{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}
</span>
</>
)}
</div>
{!offlineMode && !isConnecting && (
<div className="relay-status-pulse">
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
</div>
)}
</>
)}
</div>
)

View File

@@ -1,4 +1,5 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, Helpers } from 'applesauce-core'
import { decode, npubEncode } from 'nostr-tools/nip19'
@@ -24,14 +25,12 @@ const ResolvedMention: React.FC<ResolvedMentionProps> = ({ encoded }) => {
if (npub) {
return (
<a
href={`https://search.dergigi.com/p/${npub}`}
<Link
to={`/p/${npub}`}
className="nostr-mention"
target="_blank"
rel="noopener noreferrer"
>
@{display}
</a>
</Link>
)
}

View File

@@ -4,12 +4,14 @@ import { RelayPool } from 'applesauce-relay'
import { UserSettings } from '../services/settingsService'
import IconButton from './IconButton'
import { loadFont } from '../utils/fontLoader'
import ThemeSettings from './Settings/ThemeSettings'
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
import ZapSettings from './Settings/ZapSettings'
import OfflineModeSettings from './Settings/OfflineModeSettings'
import RelaySettings from './Settings/RelaySettings'
import PWASettings from './Settings/PWASettings'
import { useRelayStatus } from '../hooks/useRelayStatus'
const DEFAULT_SETTINGS: UserSettings = {
@@ -21,10 +23,10 @@ const DEFAULT_SETTINGS: UserSettings = {
readingFont: 'source-serif-4',
fontSize: 21,
highlightStyle: 'marker',
highlightColor: '#ffff00',
highlightColor: '#fde047',
highlightColorNostrverse: '#9333ea',
highlightColorFriends: '#f97316',
highlightColorMine: '#ffff00',
highlightColorMine: '#fde047',
defaultHighlightVisibilityNostrverse: true,
defaultHighlightVisibilityFriends: true,
defaultHighlightVisibilityMine: true,
@@ -57,7 +59,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
return migrated
})
const isInitialMount = useRef(true)
const saveTimeoutRef = useRef<number | null>(null)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isLocallyUpdating = useRef(false)
// Poll for relay status updates
@@ -158,12 +160,14 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
</div>
<div className="settings-content">
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
<OfflineModeSettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
<PWASettings />
</div>
</div>
)

View File

@@ -0,0 +1,84 @@
import React from 'react'
import { faDownload, faCheckCircle, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { usePWAInstall } from '../../hooks/usePWAInstall'
const PWASettings: React.FC = () => {
const { isInstallable, isInstalled, installApp } = usePWAInstall()
const handleInstall = async () => {
const success = await installApp()
if (success) {
console.log('App installed successfully')
}
}
if (isInstalled) {
return (
<div className="settings-section">
<h3>Progressive Web App</h3>
<div className="setting-item">
<div className="setting-info">
<FontAwesomeIcon icon={faCheckCircle} style={{ color: '#22c55e', marginRight: '8px' }} />
<span>Boris is installed as an app</span>
</div>
<p className="setting-description">
You can launch Boris from your home screen or app drawer.
</p>
</div>
</div>
)
}
if (!isInstallable) {
return null
}
return (
<div className="settings-section">
<h3>Progressive Web App</h3>
<div className="setting-item">
<div className="setting-info">
<FontAwesomeIcon icon={faMobileAlt} style={{ marginRight: '8px' }} />
<span>Install Boris as an app</span>
</div>
<p className="setting-description">
Install Boris on your device for a native app experience with offline support.
</p>
<button
onClick={handleInstall}
className="install-button"
style={{
marginTop: '12px',
padding: '8px 16px',
background: 'linear-gradient(135deg, #3b82f6 0%, #1e40af 100%)',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '14px',
fontWeight: '500',
transition: 'transform 0.2s, box-shadow 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.3)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = 'none'
}}
>
<FontAwesomeIcon icon={faDownload} />
Install App
</button>
</div>
</div>
)
}
export default PWASettings

View File

@@ -1,5 +1,4 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
@@ -73,7 +72,7 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
<label className="setting-label">My Highlights</label>
<div className="setting-control">
<ColorPicker
selectedColor={settings.highlightColorMine || '#ffff00'}
selectedColor={settings.highlightColorMine || '#fde047'}
onColorChange={(color) => onUpdate({ highlightColorMine: color })}
/>
</div>
@@ -102,33 +101,39 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
<div className="setting-group setting-inline">
<label>Default Highlight Visibility</label>
<div className="highlight-level-toggles">
<button
<IconButton
icon={faNetworkWired}
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityNostrverse !== false) ? 'active' : ''}`}
title="Nostrverse highlights"
aria-label="Toggle nostrverse highlights by default"
style={{ color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
>
<FontAwesomeIcon icon={faNetworkWired} />
</button>
<button
ariaLabel="Toggle nostrverse highlights by default"
variant="ghost"
style={{
color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
opacity: (settings.defaultHighlightVisibilityNostrverse !== false) ? 1 : 0.4
}}
/>
<IconButton
icon={faUserGroup}
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityFriends !== false) ? 'active' : ''}`}
title="Friends highlights"
aria-label="Toggle friends highlights by default"
style={{ color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined }}
>
<FontAwesomeIcon icon={faUserGroup} />
</button>
<button
ariaLabel="Toggle friends highlights by default"
variant="ghost"
style={{
color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: (settings.defaultHighlightVisibilityFriends !== false) ? 1 : 0.4
}}
/>
<IconButton
icon={faUser}
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityMine !== false) ? 'active' : ''}`}
title="My highlights"
aria-label="Toggle my highlights by default"
style={{ color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined }}
>
<FontAwesomeIcon icon={faUser} />
</button>
ariaLabel="Toggle my highlights by default"
variant="ghost"
style={{
color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: (settings.defaultHighlightVisibilityMine !== false) ? 1 : 0.4
}}
/>
</div>
</div>

View File

@@ -100,13 +100,16 @@ const RelaySettings: React.FC<RelaySettingsProps> = ({ relayStatuses }) => {
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '0.9rem',
fontFamily: 'var(--font-mono, monospace)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
<div
className="relay-url"
style={{
fontSize: '0.9rem',
fontFamily: 'var(--font-mono, monospace)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{formatRelayUrl(relay.url)}
</div>
</div>

View File

@@ -0,0 +1,107 @@
import React from 'react'
import { faSun, faMoon, faDesktop } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
type DarkColorTheme = 'black' | 'midnight' | 'charcoal'
type LightColorTheme = 'paper-white' | 'sepia' | 'ivory'
interface ThemeSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
}
const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdate }) => {
const currentTheme = settings.theme ?? 'system'
const currentDarkColor = settings.darkColorTheme ?? 'midnight'
const currentLightColor = settings.lightColorTheme ?? 'sepia'
// Determine which color picker to show based on current theme
const showDarkColors = currentTheme === 'dark' || currentTheme === 'system'
const showLightColors = currentTheme === 'light' || currentTheme === 'system'
// Color definitions for swatches
const darkColors = {
black: '#000000',
midnight: '#18181b',
charcoal: '#1c1c1e'
}
const lightColors = {
'paper-white': '#ffffff',
sepia: '#f4f1ea',
ivory: '#fffff0'
}
return (
<div className="settings-section">
<h3 className="section-title">Theme</h3>
<div className="setting-group setting-inline">
<label>Appearance</label>
<div className="setting-buttons">
<IconButton
icon={faSun}
onClick={() => onUpdate({ theme: 'light' })}
title="Light theme"
ariaLabel="Light theme"
variant={currentTheme === 'light' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faMoon}
onClick={() => onUpdate({ theme: 'dark' })}
title="Dark theme"
ariaLabel="Dark theme"
variant={currentTheme === 'dark' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faDesktop}
onClick={() => onUpdate({ theme: 'system' })}
title="Use system preference"
ariaLabel="Use system preference"
variant={currentTheme === 'system' ? 'primary' : 'ghost'}
/>
</div>
</div>
{showDarkColors && (
<div className="setting-group setting-inline">
<label>Dark Theme</label>
<div className="color-picker">
{Object.entries(darkColors).map(([key, color]) => (
<div
key={key}
className={`color-swatch ${currentDarkColor === key ? 'active' : ''}`}
style={{ backgroundColor: color }}
onClick={() => onUpdate({ darkColorTheme: key as DarkColorTheme })}
title={key.charAt(0).toUpperCase() + key.slice(1)}
/>
))}
</div>
</div>
)}
{showLightColors && (
<div className="setting-group setting-inline">
<label>Light Theme</label>
<div className="color-picker">
{Object.entries(lightColors).map(([key, color]) => (
<div
key={key}
className={`color-swatch ${currentLightColor === key ? 'active' : ''}`}
style={{
backgroundColor: color,
border: color === '#ffffff' ? '2px solid #e5e7eb' : '1px solid #e5e7eb'
}}
onClick={() => onUpdate({ lightColorTheme: key as LightColorTheme })}
title={key.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}
/>
))}
</div>
</div>
)}
</div>
)
}
export default ThemeSettings

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'
@@ -90,8 +90,12 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
<div
className="profile-avatar"
title={activeAccount ? getUserDisplayName() : "Login"}
onClick={!activeAccount ? (isConnecting ? () => {} : handleLogin) : undefined}
style={{ cursor: !activeAccount ? 'pointer' : 'default' }}
onClick={
activeAccount
? () => navigate('/me')
: (isConnecting ? () => {} : handleLogin)
}
style={{ cursor: 'pointer' }}
>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
@@ -113,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}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -20,6 +20,8 @@ import { HighlightButtonRef } from './HighlightButton'
import { BookmarkReference } from '../utils/contentLoader'
import { useIsMobile } from '../hooks/useMediaQuery'
import { useScrollDirection } from '../hooks/useScrollDirection'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
interface ThreePaneLayoutProps {
// Layout state
@@ -28,6 +30,9 @@ interface ThreePaneLayoutProps {
isSidebarOpen: boolean
showSettings: boolean
showExplore?: boolean
showMe?: boolean
showProfile?: boolean
showSupport?: boolean
// Bookmarks pane
bookmarks: Bookmark[]
@@ -59,6 +64,8 @@ interface ThreePaneLayoutProps {
onClearSelection: () => void
currentUserPubkey?: string
followedPubkeys: Set<string>
activeAccount?: IAccount | null
currentArticle?: NostrEvent | null
// Highlights pane
highlights: Highlight[]
@@ -81,6 +88,15 @@ interface ThreePaneLayoutProps {
// Optional Explore content
explore?: React.ReactNode
// Optional Me content
me?: React.ReactNode
// Optional Profile content
profile?: React.ReactNode
// Optional Support content
support?: React.ReactNode
}
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
@@ -90,11 +106,10 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
const mainPaneRef = useRef<HTMLDivElement>(null)
// Detect scroll direction to hide/show mobile buttons
// On mobile, scroll happens in the main pane, not on window
// Now using window scroll (document scroll) instead of pane scroll
const scrollDirection = useScrollDirection({
threshold: 10,
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed,
elementRef: mainPaneRef
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed
})
const showMobileButtons = scrollDirection !== 'down'
@@ -214,34 +229,54 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
return (
<>
{/* Mobile bookmark button - only show when viewing article */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
{/* Mobile bookmark button - only show when viewing article or explore (not on settings/me/profile/support) */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showMe && !props.showProfile && !props.showSupport && (
<button
className={`mobile-hamburger-btn ${showMobileButtons ? 'visible' : 'hidden'}`}
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'
}`}
style={{
top: 'calc(1rem + env(safe-area-inset-top))',
left: 'calc(1rem + env(safe-area-inset-left))',
width: '40px',
height: '40px'
}}
onClick={props.onToggleSidebar}
aria-label="Open bookmarks"
aria-expanded={props.isSidebarOpen}
>
<FontAwesomeIcon icon={faBookmark} />
<FontAwesomeIcon icon={faBookmark} size="sm" />
</button>
)}
{/* Mobile highlights button - only show when viewing article */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
{/* Mobile highlights button - only show when viewing article or explore (not on settings/me/profile/support) */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showMe && !props.showProfile && !props.showSupport && (
<button
className={`mobile-highlights-btn ${showMobileButtons ? 'visible' : 'hidden'}`}
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'
}`}
style={{
top: 'calc(1rem + env(safe-area-inset-top))',
right: 'calc(1rem + env(safe-area-inset-right))',
width: '40px',
height: '40px',
backgroundColor: `${props.settings.highlightColorMine || '#fde047'}B3`,
color: '#000'
}}
onClick={props.onToggleHighlightsPanel}
aria-label="Open highlights"
aria-expanded={!props.isHighlightsCollapsed}
>
<FontAwesomeIcon icon={faHighlighter} />
<FontAwesomeIcon icon={faHighlighter} size="sm" />
</button>
)}
{/* Mobile backdrop */}
{isMobile && (
<div
className={`mobile-sidebar-backdrop ${(props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'visible' : ''}`}
className={`fixed inset-0 bg-black/45 z-[999] transition-opacity duration-300 ${
(props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'block opacity-100' : 'hidden opacity-0'
}`}
onClick={handleBackdropClick}
aria-hidden="true"
/>
@@ -268,7 +303,6 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
lastFetchTime={props.lastFetchTime}
loading={props.bookmarksLoading}
relayPool={props.relayPool}
settings={props.settings}
isMobile={isMobile}
/>
</div>
@@ -288,6 +322,21 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
<>
{props.explore}
</>
) : props.showMe && props.me ? (
// Render Me inside the main pane to keep side panels
<>
{props.me}
</>
) : props.showProfile && props.profile ? (
// Render Profile inside the main pane to keep side panels
<>
{props.profile}
</>
) : props.showSupport && props.support ? (
// Render Support inside the main pane to keep side panels
<>
{props.support}
</>
) : (
<ContentPanel
loading={props.readerLoading}
@@ -311,6 +360,10 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
followedPubkeys={props.followedPubkeys}
settings={props.settings}
relayPool={props.relayPool}
activeAccount={props.activeAccount}
currentArticle={props.currentArticle}
isSidebarCollapsed={props.isCollapsed}
isHighlightsCollapsed={props.isHighlightsCollapsed}
/>
)}
</div>
@@ -336,6 +389,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
followedPubkeys={props.followedPubkeys}
relayPool={props.relayPool}
eventStore={props.eventStore}
settings={props.settings}
/>
</div>
</div>
@@ -343,10 +397,13 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
<HighlightButton
ref={props.highlightButtonRef}
onHighlight={props.onCreateHighlight}
highlightColor={props.settings.highlightColor || '#ffff00'}
highlightColor={props.settings.highlightColorMine || '#ffff00'}
/>
)}
<RelayStatusIndicator relayPool={props.relayPool} />
<RelayStatusIndicator
relayPool={props.relayPool}
showOnMobile={showMobileButtons}
/>
{props.toastMessage && (
<Toast
message={props.toastMessage}

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

@@ -0,0 +1,34 @@
/**
* Nostr gateway URLs for viewing events and profiles on the web
*/
export const NOSTR_GATEWAY = 'https://ants.sh' as const
/**
* Get a profile URL on the gateway
*/
export function getProfileUrl(npub: string): string {
return `${NOSTR_GATEWAY}/p/${npub}`
}
/**
* Get an event URL on the gateway
*/
export function getEventUrl(nevent: string): string {
return `${NOSTR_GATEWAY}/e/${nevent}`
}
/**
* Get a general nostr link on the gateway
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
*/
export function getNostrUrl(identifier: string): string {
// Check the prefix to determine if it's a profile or event
if (identifier.startsWith('npub') || identifier.startsWith('nprofile')) {
return `${NOSTR_GATEWAY}/p/${identifier}`
}
// Everything else (note, nevent, naddr) goes to /e/
return `${NOSTR_GATEWAY}/e/${identifier}`
}

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
@@ -44,10 +43,14 @@ export const useBookmarksData = ({
const handleFetchBookmarks = useCallback(async () => {
if (!relayPool || !activeAccount) return
// don't clear existing bookmarks: we keep UI stable and show spinner unobtrusively
setBookmarksLoading(true)
try {
const fullAccount = accountManager.getActive()
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks, settings)
// merge-friendly: updater form that preserves visible list until replacement
await fetchBookmarks(relayPool, fullAccount || activeAccount, (next) => {
setBookmarks(() => next)
}, settings)
} finally {
setBookmarksLoading(false)
}
@@ -102,15 +105,21 @@ export const useBookmarksData = ({
}
}, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
// Load initial data
// Load initial data (avoid clearing on route-only changes)
useEffect(() => {
if (!relayPool || !activeAccount) return
// Only (re)fetch bookmarks when account or relayPool changes, not on naddr route changes
handleFetchBookmarks()
}, [relayPool, activeAccount, handleFetchBookmarks])
// Fetch highlights/contacts independently to avoid disturbing bookmarks
useEffect(() => {
if (!relayPool || !activeAccount) return
if (!naddr) {
handleFetchHighlights()
}
handleFetchContacts()
}, [relayPool, activeAccount, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
}, [relayPool, activeAccount, naddr, handleFetchHighlights, handleFetchContacts])
return {
bookmarks,

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { NostrEvent } from 'nostr-tools'
import { HighlightVisibility } from '../components/HighlightsPanel'
import { UserSettings } from '../services/settingsService'
@@ -47,9 +47,9 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
})
}, [settings])
const toggleSidebar = () => {
const toggleSidebar = useCallback(() => {
setIsSidebarOpen(prev => !prev)
}
}, [])
return {
isMobile,

View File

@@ -4,6 +4,19 @@ import { fetchReadableContent, ReadableContent } from '../services/readerService
import { fetchHighlightsForUrl } from '../services/highlightService'
import { Highlight } from '../types/highlights'
// Helper to extract filename from URL
function getFilenameFromUrl(url: string): string {
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname
const filename = pathname.substring(pathname.lastIndexOf('/') + 1)
// Decode URI component to handle special characters
return decodeURIComponent(filename) || url
} catch {
return url
}
}
interface UseExternalUrlLoaderProps {
url: string | undefined
relayPool: RelayPool | null
@@ -11,7 +24,7 @@ interface UseExternalUrlLoaderProps {
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
setIsCollapsed: (collapsed: boolean) => void
setHighlights: (highlights: Highlight[]) => void
setHighlights: (highlights: Highlight[] | ((prev: Highlight[]) => Highlight[])) => void
setHighlightsLoading: (loading: boolean) => void
setCurrentArticleCoordinate: (coord: string | undefined) => void
setCurrentArticleEventId: (id: string | undefined) => void
@@ -57,7 +70,21 @@ export function useExternalUrlLoader({
// Check if fetchHighlightsForUrl exists, otherwise skip
if (typeof fetchHighlightsForUrl === 'function') {
const highlightsList = await fetchHighlightsForUrl(relayPool, url)
const seen = new Set<string>()
const highlightsList = await fetchHighlightsForUrl(
relayPool,
url,
(highlight) => {
if (seen.has(highlight.id)) return
seen.add(highlight.id)
setHighlights((prev) => {
if (prev.some(h => h.id === highlight.id)) return prev
const next = [...prev, highlight]
return next.sort((a, b) => b.created_at - a.created_at)
})
}
)
// Ensure final list is sorted and contains all items
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
} else {
@@ -70,8 +97,10 @@ export function useExternalUrlLoader({
}
} catch (err) {
console.error('Failed to load external URL:', err)
// For videos and other media files, use the filename as the title
const filename = getFilenameFromUrl(url)
setReaderContent({
title: 'Error Loading Content',
title: filename,
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url
})

View File

@@ -1,7 +1,9 @@
import { useCallback, useRef } from 'react'
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'
@@ -9,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
@@ -77,8 +78,18 @@ export const useHighlightCreation = ({
publishedRelays: newHighlight.publishedRelays
})
// Clear the browser's text selection immediately to allow DOM update
const selection = window.getSelection()
if (selection) {
selection.removeAllRanges()
}
highlightButtonRef.current?.clearSelection()
onHighlightCreated(newHighlight)
// Force synchronous render to show highlight immediately
flushSync(() => {
onHighlightCreated(newHighlight)
})
} catch (error) {
console.error('❌ Failed to create highlight:', error)
// Re-throw to allow parent to handle

View File

@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef } from 'react'
import { useEffect, useCallback, useRef, useState } from 'react'
interface UseHighlightInteractionsParams {
onHighlightClick?: (highlightId: string) => void
@@ -14,6 +14,25 @@ export const useHighlightInteractions = ({
onClearSelection
}: UseHighlightInteractionsParams) => {
const contentRef = useRef<HTMLDivElement>(null)
const [contentVersion, setContentVersion] = useState(0)
// Watch for DOM changes (highlights being added/removed)
useEffect(() => {
if (!contentRef.current) return
const observer = new MutationObserver(() => {
// Increment version to trigger re-attachment of handlers
setContentVersion(prev => prev + 1)
})
observer.observe(contentRef.current, {
childList: true,
subtree: true,
characterData: false
})
return () => observer.disconnect()
}, [])
// Attach click handlers to highlight marks
useEffect(() => {
@@ -37,24 +56,42 @@ export const useHighlightInteractions = ({
mark.removeEventListener('click', handler)
})
}
}, [onHighlightClick])
}, [onHighlightClick, contentVersion])
// Scroll to selected highlight
// Scroll to selected highlight with retry mechanism
useEffect(() => {
if (!selectedHighlightId || !contentRef.current) return
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
let attempts = 0
const maxAttempts = 20 // Try for up to 2 seconds
const retryDelay = 100
if (markElement) {
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
const tryScroll = () => {
if (!contentRef.current) return
const htmlElement = markElement as HTMLElement
setTimeout(() => {
htmlElement.classList.add('highlight-pulse')
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
}, 500)
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
if (markElement) {
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
const htmlElement = markElement as HTMLElement
setTimeout(() => {
htmlElement.classList.add('highlight-pulse')
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
}, 500)
} else if (attempts < maxAttempts) {
attempts++
setTimeout(tryScroll, retryDelay)
} else {
console.warn('Could not find mark element for highlight after', maxAttempts, 'attempts:', selectedHighlightId)
}
}
}, [selectedHighlightId])
// Start trying after a small initial delay
const timeoutId = setTimeout(tryScroll, 100)
return () => clearTimeout(timeoutId)
}, [selectedHighlightId, contentVersion])
// Handle text selection (works for both mouse and touch)
const handleSelectionEnd = useCallback(() => {

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

@@ -0,0 +1,28 @@
import { useState, useEffect } from 'react'
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
const handleOnline = () => {
console.log('🌐 Back online')
setIsOnline(true)
}
const handleOffline = () => {
console.log('📴 Gone offline')
setIsOnline(false)
}
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return isOnline
}

View File

@@ -0,0 +1,74 @@
import { useState, useEffect } from 'react'
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
export function usePWAInstall() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
const [isInstallable, setIsInstallable] = useState(false)
const [isInstalled, setIsInstalled] = useState(false)
useEffect(() => {
// Check if app is already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstalled(true)
return
}
// Listen for the beforeinstallprompt event
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault()
const installPromptEvent = e as BeforeInstallPromptEvent
setDeferredPrompt(installPromptEvent)
setIsInstallable(true)
}
// Listen for successful installation
const handleAppInstalled = () => {
setIsInstalled(true)
setIsInstallable(false)
setDeferredPrompt(null)
}
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('appinstalled', handleAppInstalled)
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('appinstalled', handleAppInstalled)
}
}, [])
const installApp = async () => {
if (!deferredPrompt) {
return false
}
try {
await deferredPrompt.prompt()
const choiceResult = await deferredPrompt.userChoice
if (choiceResult.outcome === 'accepted') {
console.log('✅ PWA installed')
setIsInstallable(false)
setDeferredPrompt(null)
return true
} else {
console.log('❌ PWA installation dismissed')
return false
}
} catch (error) {
console.error('Error installing PWA:', error)
return false
}
}
return {
isInstallable,
isInstalled,
installApp,
}
}

View File

@@ -0,0 +1,73 @@
import { useEffect, useRef, useState } from 'react'
interface UseReadingPositionOptions {
enabled?: boolean
onPositionChange?: (position: number) => void
onReadingComplete?: () => void
readingCompleteThreshold?: number // Default 0.9 (90%)
}
export const useReadingPosition = ({
enabled = true,
onPositionChange,
onReadingComplete,
readingCompleteThreshold = 0.9
}: UseReadingPositionOptions = {}) => {
const [position, setPosition] = useState(0)
const [isReadingComplete, setIsReadingComplete] = useState(false)
const hasTriggeredComplete = useRef(false)
useEffect(() => {
if (!enabled) return
const handleScroll = () => {
// Get the main content area (reader content)
const readerContent = document.querySelector('.reader-html, .reader-markdown')
if (!readerContent) return
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
const windowHeight = window.innerHeight
const documentHeight = document.documentElement.scrollHeight
// Calculate position based on how much of the content has been scrolled through
const scrollProgress = Math.min(scrollTop / (documentHeight - windowHeight), 1)
const clampedProgress = Math.max(0, Math.min(1, scrollProgress))
setPosition(clampedProgress)
onPositionChange?.(clampedProgress)
// Check if reading is complete
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
onReadingComplete?.()
}
}
// Initial calculation
handleScroll()
// Add scroll listener
window.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', handleScroll, { passive: true })
return () => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleScroll)
}
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold])
// Reset reading complete state when enabled changes
useEffect(() => {
if (!enabled) {
setIsReadingComplete(false)
hasTriggeredComplete.current = false
}
}, [enabled])
return {
position,
isReadingComplete,
progressPercentage: Math.round(position * 100)
}
}

View File

@@ -5,6 +5,7 @@ import { EventFactory } from 'applesauce-factory'
import { AccountManager } from 'applesauce-accounts'
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
import { loadFont, getFontFamily } from '../utils/fontLoader'
import { applyTheme } from '../utils/theme'
import { RELAYS } from '../config/relays'
interface UseSettingsParams {
@@ -47,7 +48,14 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
const root = document.documentElement.style
const fontKey = settings.readingFont || 'system'
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize })
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize, theme: settings.theme })
// Apply theme with color variants (defaults to 'system' if not set)
applyTheme(
settings.theme ?? 'system',
settings.darkColorTheme ?? 'midnight',
settings.lightColorTheme ?? 'sepia'
)
// Load font first and wait for it to be ready
if (fontKey !== 'system') {
@@ -61,7 +69,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
root.setProperty('--reading-font-size', `${settings.fontSize || 21}px`)
// Set highlight colors for three levels
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00')
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#fde047')
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
@@ -77,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')

1
src/icons/books.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Pro 6.0.0-alpha2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M510.354 435.363L402.686 35.422C396.939 14.078 377.547 0 356.354 0C352.242 0 348.059 0.531 343.896 1.641L282.078 18.125C276.193 19.695 270.939 22.383 266.295 25.758C258.254 10.508 242.436 0 224 0H160C151.213 0 143.084 2.531 136 6.656C128.916 2.531 120.787 0 112 0H48C21.49 0 0 21.492 0 48V464C0 490.508 21.49 512 48 512H112C120.787 512 128.916 509.469 136 505.344C143.084 509.469 151.213 512 160 512H224C250.51 512 272 490.508 272 464V165.281L355.805 476.578C361.553 497.926 380.945 512 402.139 512C406.25 512 410.432 511.469 414.594 510.359L476.412 493.875C502.018 487.043 517.215 460.848 510.354 435.363ZM224 48V96H160V48H224ZM160 144H224V368H160V144ZM112 368H48V144H112V368ZM112 48V96H48V48H112ZM48 464V416H112V464H48ZM160 464V416H224V464H160ZM294.445 64.504L356.271 48.02L356.361 48L368.742 93.93L306.828 110.445L294.445 64.504ZM319.266 156.586L381.18 140.074L439.223 355.41L377.309 371.922L319.266 156.586ZM402.154 464.102L389.746 418.066L451.66 401.555L464.045 447.496L402.154 464.102Z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

52
src/icons/customIcons.ts Normal file
View File

@@ -0,0 +1,52 @@
import { IconDefinition, IconPrefix, IconName } from '@fortawesome/fontawesome-svg-core'
import booksSvg from './books.svg?raw'
/**
* Custom icon definitions for FontAwesome Pro icons
* or any custom SVG icons that aren't in the free tier
*/
function parseSvgToIconDefinition(svg: string, options: { prefix: IconPrefix, iconName: IconName, unicode?: string }): IconDefinition {
const { prefix, iconName, unicode = 'e002' } = options
// Extract viewBox first; fallback to width/height
const viewBoxMatch = svg.match(/viewBox\s*=\s*"([^"]+)"/i)
let width = 512
let height = 512
if (viewBoxMatch) {
const parts = viewBoxMatch[1].trim().split(/\s+/)
if (parts.length === 4) {
const w = Number(parts[2])
const h = Number(parts[3])
if (!Number.isNaN(w)) width = Math.round(w)
if (!Number.isNaN(h)) height = Math.round(h)
}
} else {
const widthMatch = svg.match(/\bwidth\s*=\s*"(\d+(?:\.\d+)?)"/i)
const heightMatch = svg.match(/\bheight\s*=\s*"(\d+(?:\.\d+)?)"/i)
if (widthMatch) width = Math.round(Number(widthMatch[1]))
if (heightMatch) height = Math.round(Number(heightMatch[1]))
}
// Collect all path d attributes
const pathDs: string[] = []
const pathRegex = /<path[^>]*\sd=\s*"([^"]+)"[^>]*>/gi
let m: RegExpExecArray | null
while ((m = pathRegex.exec(svg)) !== null) {
pathDs.push(m[1])
}
const pathData = pathDs.length <= 1 ? (pathDs[0] || '') : pathDs
return {
prefix,
iconName,
icon: [width, height, [], unicode, pathData]
}
}
export const faBooks: IconDefinition = parseSvgToIconDefinition(booksSvg, {
prefix: 'far',
iconName: 'books'
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,35 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './styles/tailwind.css'
import './index.css'
import 'react-loading-skeleton/dist/skeleton.css'
// Register Service Worker for offline image caching
// Register Service Worker for PWA functionality
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.register('/sw.js', { type: 'module' })
.then(registration => {
console.log('✅ Service Worker registered:', registration.scope)
// Update service worker when a new version is available
// Check for updates periodically
setInterval(() => {
registration.update()
}, 60 * 60 * 1000) // Check every hour
// Handle service worker updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
console.log('🔄 Service Worker updated, page may need reload')
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New service worker available
console.log('🔄 New version available! Reload to update.')
// Optionally show a toast notification
const updateAvailable = new CustomEvent('sw-update-available')
window.dispatchEvent(updateAvailable)
}
})
}

View File

@@ -1,10 +1,12 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RelayPool } from 'applesauce-relay'
import { lastValueFrom, take } from 'rxjs'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
import { merge, toArray as rxToArray } from 'rxjs'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
@@ -98,9 +100,11 @@ export async function fetchArticleByNaddr(
const pointer = decoded.data as AddressPointer
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local)
const relays = pointer.relays && pointer.relays.length > 0
const baseRelays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: RELAYS
const orderedRelays = prioritizeLocalRelays(baseRelays)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
// Fetch the article event
const filter = {
@@ -109,12 +113,10 @@ export async function fetchArticleByNaddr(
'#d': [pointer.identifier]
}
// Use applesauce relay pool pattern
const events = await lastValueFrom(
relayPool
.req(relays, filter)
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
)
// Parallel local+remote, stream immediate, collect up to first from each
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000)
const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()))
const events = collected as NostrEvent[]
if (events.length === 0) {
throw new Error('Article not found')

View File

@@ -1,9 +1,11 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RelayPool } from 'applesauce-relay'
import { lastValueFrom, take } from 'rxjs'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
import { merge, toArray as rxToArray } from 'rxjs'
const { getArticleTitle } = Helpers
@@ -25,9 +27,11 @@ export async function fetchArticleTitle(
const pointer = decoded.data as AddressPointer
// Define relays to query
const relays = pointer.relays && pointer.relays.length > 0
const baseRelays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: RELAYS
const orderedRelays = prioritizeLocalRelays(baseRelays)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
// Fetch the article event
const filter = {
@@ -36,11 +40,11 @@ export async function fetchArticleTitle(
'#d': [pointer.identifier]
}
// Parallel local+remote: collect up to one event from each
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 5000)
const events = await lastValueFrom(
relayPool
.req(relays, filter)
.pipe(completeOnEose(), takeUntil(timer(5000)), toArray())
)
merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray())
) as unknown as { created_at: number }[]
if (events.length === 0) {
return null
@@ -48,7 +52,7 @@ export async function fetchArticleTitle(
// Sort by created_at and take the most recent
events.sort((a, b) => b.created_at - a.created_at)
const article = events[0]
const article = events[0] as unknown as Parameters<typeof getArticleTitle>[0]
return getArticleTitle(article) || null
} catch (err) {

View File

@@ -60,8 +60,27 @@ export const processApplesauceBookmarks = (
if (applesauceBookmarks.articles) allItems.push(...applesauceBookmarks.articles)
if (applesauceBookmarks.hashtags) allItems.push(...applesauceBookmarks.hashtags)
if (applesauceBookmarks.urls) allItems.push(...applesauceBookmarks.urls)
return allItems.map((bookmark: BookmarkData) => ({
id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`,
return allItems
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
.map((bookmark: BookmarkData) => ({
id: bookmark.id!,
content: bookmark.content || '',
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
pubkey: activeAccount.pubkey,
kind: bookmark.kind || 30001,
tags: bookmark.tags || [],
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate,
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
}))
}
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
return bookmarkArray
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
.map((bookmark: BookmarkData) => ({
id: bookmark.id!,
content: bookmark.content || '',
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
pubkey: activeAccount.pubkey,
@@ -70,23 +89,8 @@ export const processApplesauceBookmarks = (
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
}))
}
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
return bookmarkArray.map((bookmark: BookmarkData) => ({
id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`,
content: bookmark.content || '',
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
pubkey: activeAccount.pubkey,
kind: bookmark.kind || 30001,
tags: bookmark.tags || [],
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
}))
}
// Types and guards around signer/decryption APIs

View File

@@ -1,5 +1,4 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RelayPool } from 'applesauce-relay'
import {
AccountWithExtension,
NostrEvent,
@@ -16,6 +15,7 @@ import { Bookmark } from '../types/bookmarks'
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
import { queryEvents } from './dataFetch'
@@ -30,14 +30,13 @@ export const fetchBookmarks = async (
if (!isAccountWithExtension(activeAccount)) {
throw new Error('Invalid account object provided')
}
// Get relay URLs from the pool
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
// Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0)
console.log('🔍 Fetching bookmark events from relays:', relayUrls)
const rawEvents = await lastValueFrom(
relayPool
.req(relayUrls, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
.pipe(completeOnEose(), takeUntil(timer(20000)), 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')
@@ -64,7 +63,7 @@ export const fetchBookmarks = async (
const bookmarkListEvents = dedupeNip51Events(rawEvents)
console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events')
if (bookmarkListEvents.length === 0) {
setBookmarks([])
// Keep existing bookmarks visible; do not clear list if nothing new found
return
}
// Aggregate across events
@@ -102,8 +101,10 @@ export const fetchBookmarks = async (
let idToEvent: Map<string, NostrEvent> = new Map()
if (noteIds.length > 0) {
try {
const events = await lastValueFrom(
relayPool.req(relayUrls, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
const events = await queryEvents(
relayPool,
{ ids: noteIds },
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
)
idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e]))
} catch (error) {

View File

@@ -1,5 +1,7 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, 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
@@ -9,40 +11,52 @@ import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
*/
export const fetchContacts = async (
relayPool: RelayPool,
pubkey: string
pubkey: string,
onPartial?: (contacts: Set<string>) => void
): Promise<Set<string>> => {
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
const events = await lastValueFrom(
relayPool
.req(relayUrls, { kinds: [3], authors: [pubkey] })
.pipe(completeOnEose(), takeUntil(timer(10000)), 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) {
// Get the most recent contact list
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const contactList = sortedEvents[0]
// Extract pubkeys from 'p' tags
for (const tag of contactList.tags) {
if (tag[0] === 'p' && tag[1]) {
followed.add(tag[1])
}
}
if (onPartial) onPartial(new Set(followed))
}
// merged already via streams
console.log('📊 Contact events fetched:', events.length)
if (events.length === 0) {
return new Set()
}
// Get the most recent contact list
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const contactList = sortedEvents[0]
// Extract pubkeys from 'p' tags
const followedPubkeys = new Set<string>()
for (const tag of contactList.tags) {
if (tag[0] === 'p' && tag[1]) {
followedPubkeys.add(tag[1])
}
}
console.log('👥 Followed contacts:', followedPubkeys.size)
return followedPubkeys
console.log('👥 Followed contacts:', followed.size)
return followed
} catch (error) {
console.error('Failed to fetch contacts:', error)
return new Set()

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

@@ -0,0 +1,48 @@
import { EventFactory } from 'applesauce-factory'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { RELAYS } from '../config/relays'
/**
* Creates a kind:5 event deletion request (NIP-09)
* @param eventId The ID of the event to delete
* @param eventKind The kind of the event being deleted
* @param reason Optional reason for deletion
* @param account The user's account for signing
* @param relayPool The relay pool for publishing
* @returns The signed deletion request event
*/
export async function createDeletionRequest(
eventId: string,
eventKind: number,
reason: string | undefined,
account: IAccount,
relayPool: RelayPool
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
const tags: string[][] = [
['e', eventId],
['k', eventKind.toString()]
]
const draft = await factory.create(async () => ({
kind: 5, // Event Deletion Request
content: reason || '',
tags,
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
console.log('🗑️ Created kind:5 deletion request for event:', eventId.slice(0, 8))
// Publish to relays
await relayPool.publish(RELAYS, signed)
console.log('✅ Deletion request published to', RELAYS.length, 'relay(s)')
return signed
}

View File

@@ -0,0 +1,73 @@
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights'
export interface CachedBlogPostPreview {
event: NostrEvent
title: string
summary?: string
image?: string
published?: number
author: string
}
type CacheValue = {
posts: CachedBlogPostPreview[]
highlights: Highlight[]
timestamp: number
}
const exploreCache = new Map<string, CacheValue>() // key: pubkey
export function getCachedPosts(pubkey: string): CachedBlogPostPreview[] | null {
const entry = exploreCache.get(pubkey)
if (!entry) return null
return entry.posts
}
export function getCachedHighlights(pubkey: string): Highlight[] | null {
const entry = exploreCache.get(pubkey)
if (!entry) return null
return entry.highlights
}
export function setCachedPosts(pubkey: string, posts: CachedBlogPostPreview[]): void {
const current = exploreCache.get(pubkey)
exploreCache.set(pubkey, {
posts,
highlights: current?.highlights || [],
timestamp: Date.now()
})
}
export function setCachedHighlights(pubkey: string, highlights: Highlight[]): void {
const current = exploreCache.get(pubkey)
exploreCache.set(pubkey, {
posts: current?.posts || [],
highlights,
timestamp: Date.now()
})
}
export function upsertCachedPost(pubkey: string, post: CachedBlogPostPreview): CachedBlogPostPreview[] {
const current = exploreCache.get(pubkey)?.posts || []
const byId = new Map(current.map(p => [p.event.id, p]))
byId.set(post.event.id, post)
const merged = Array.from(byId.values()).sort((a, b) => {
const ta = a.published || a.event.created_at
const tb = b.published || b.event.created_at
return tb - ta
})
setCachedPosts(pubkey, merged)
return merged
}
export function upsertCachedHighlight(pubkey: string, highlight: Highlight): Highlight[] {
const current = exploreCache.get(pubkey)?.highlights || []
const byId = new Map(current.map(h => [h.id, h]))
byId.set(highlight.id, highlight)
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
setCachedHighlights(pubkey, merged)
return merged
}

View File

@@ -1,7 +1,7 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
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
@@ -24,7 +24,8 @@ export interface BlogPostPreview {
export const fetchBlogPostsFromAuthors = async (
relayPool: RelayPool,
pubkeys: string[],
relayUrls: string[]
relayUrls: string[],
onPost?: (post: BlogPostPreview) => void
): Promise<BlogPostPreview[]> => {
try {
if (pubkeys.length === 0) {
@@ -33,43 +34,55 @@ export const fetchBlogPostsFromAuthors = async (
}
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
const events = await lastValueFrom(
relayPool
.req(relayUrls, {
kinds: [30023],
authors: pubkeys,
limit: 100 // Fetch up to 100 recent posts
})
.pipe(completeOnEose(), takeUntil(timer(15000)), toArray())
)
console.log('📊 Blog post events fetched:', events.length)
// Deduplicate replaceable events by keeping the most recent version
// Group by author + d-tag identifier
const uniqueEvents = new Map<string, NostrEvent>()
for (const event of events) {
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], 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)
}
}
}
}
}
)
console.log('📊 Blog post events fetched (unique):', uniqueEvents.size)
// Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
.map(event => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}))
.map(event => {
const post: BlogPostPreview = {
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}
if (onPost) onPost(post)
return post
})
.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at

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,204 +1,5 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights'
import { RELAYS } from '../config/relays'
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
/**
* Fetches highlights for a specific article by its address coordinate and/or event ID
* @param relayPool - The relay pool to query
* @param articleCoordinate - The article's address in format "kind:pubkey:identifier" (e.g., "30023:abc...def:my-article")
* @param eventId - Optional event ID to also query by 'e' tag
* @param onHighlight - Optional callback to receive highlights as they arrive
* @param settings - User settings for rebroadcast options
*/
export const fetchHighlightsForArticle = async (
relayPool: RelayPool,
articleCoordinate: string,
eventId?: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate)
console.log('🔍 Event ID:', eventId || 'none')
console.log('🔍 From relays (including local):', RELAYS)
const seenIds = new Set<string>()
const processEvent = (event: NostrEvent): Highlight | null => {
if (seenIds.has(event.id)) return null
seenIds.add(event.id)
return eventToHighlight(event)
}
// Query for highlights that reference this article via the 'a' tag
const aTagEvents = await lastValueFrom(
relayPool
.req(RELAYS, { kinds: [9802], '#a': [articleCoordinate] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) {
onHighlight(highlight)
}
}),
completeOnEose(),
takeUntil(timer(10000)),
toArray()
)
)
console.log('📊 Highlights via a-tag:', aTagEvents.length)
// If we have an event ID, also query for highlights that reference via the 'e' tag
let eTagEvents: NostrEvent[] = []
if (eventId) {
eTagEvents = await lastValueFrom(
relayPool
.req(RELAYS, { kinds: [9802], '#e': [eventId] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) {
onHighlight(highlight)
}
}),
completeOnEose(),
takeUntil(timer(10000)),
toArray()
)
)
console.log('📊 Highlights via e-tag:', eTagEvents.length)
}
// Combine results from both queries
const rawEvents = [...aTagEvents, ...eTagEvents]
console.log('📊 Total raw highlight events fetched:', rawEvents.length)
// Rebroadcast highlight events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
if (rawEvents.length > 0) {
console.log('📄 Sample highlight tags:', JSON.stringify(rawEvents[0].tags, null, 2))
} else {
console.log('❌ No highlights found. Article coordinate:', articleCoordinate)
console.log('❌ Event ID:', eventId || 'none')
console.log('💡 Try checking if there are any highlights on this article at https://highlighter.com')
}
// Deduplicate events by ID
const uniqueEvents = dedupeHighlights(rawEvents)
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch (error) {
console.error('Failed to fetch highlights for article:', error)
return []
}
}
/**
* Fetches highlights for a specific URL
* @param relayPool - The relay pool to query
* @param url - The external URL to find highlights for
* @param settings - User settings for rebroadcast options
*/
export const fetchHighlightsForUrl = async (
relayPool: RelayPool,
url: string,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
console.log('🔍 Fetching highlights (kind 9802) for URL:', url)
const seenIds = new Set<string>()
const rawEvents = await lastValueFrom(
relayPool
.req(RELAYS, { kinds: [9802], '#r': [url] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
seenIds.add(event.id)
}),
completeOnEose(),
takeUntil(timer(10000)),
toArray()
)
)
console.log('📊 Highlights for URL:', rawEvents.length)
// Rebroadcast highlight events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch (error) {
console.error('Failed to fetch highlights for URL:', error)
return []
}
}
/**
* Fetches highlights created by a specific user
* @param relayPool - The relay pool to query
* @param pubkey - The user's public key
* @param onHighlight - Optional callback to receive highlights as they arrive
* @param settings - User settings for rebroadcast options
*/
export const fetchHighlights = async (
relayPool: RelayPool,
pubkey: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
console.log('🔍 Fetching highlights (kind 9802) by author:', pubkey)
const seenIds = new Set<string>()
const rawEvents = await lastValueFrom(
relayPool
.req(relayUrls, { kinds: [9802], authors: [pubkey] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
const highlight = eventToHighlight(event)
if (onHighlight) {
onHighlight(highlight)
}
}
}),
completeOnEose(),
takeUntil(timer(10000)),
toArray()
)
)
console.log('📊 Raw highlight events fetched:', rawEvents.length)
// Rebroadcast highlight events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
// Deduplicate and process events
const uniqueEvents = dedupeHighlights(rawEvents)
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch (error) {
console.error('Failed to fetch highlights by author:', error)
return []
}
}
export * from './highlights/fetchForArticle'
export * from './highlights/fetchForUrl'
export * from './highlights/fetchByAuthor'
export * from './highlights/fetchFromAuthors'

View File

@@ -0,0 +1,63 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../../types/highlights'
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService'
export const fetchHighlights = async (
relayPool: RelayPool,
pubkey: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const ordered = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered)
const seenIds = new Set<string>()
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], authors: [pubkey] })
.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: [pubkey] })
.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()))
await rebroadcastEvents(rawEvents, relayPool, settings)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch {
return []
}
}

View File

@@ -0,0 +1,98 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../../types/highlights'
import { RELAYS } from '../../config/relays'
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService'
export const fetchHighlightsForArticle = async (
relayPool: RelayPool,
articleCoordinate: string,
eventId?: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
const seenIds = new Set<string>()
const processEvent = (event: NostrEvent): Highlight | null => {
if (seenIds.has(event.id)) return null
seenIds.add(event.id)
return eventToHighlight(event)
}
const orderedRelays = prioritizeLocalRelays(RELAYS)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
const aLocal$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], '#a': [articleCoordinate] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const aRemote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], '#a': [articleCoordinate] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const aTagEvents: NostrEvent[] = await lastValueFrom(merge(aLocal$, aRemote$).pipe(toArray()))
let eTagEvents: NostrEvent[] = []
if (eventId) {
const eLocal$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], '#e': [eventId] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const eRemote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], '#e': [eventId] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
eTagEvents = await lastValueFrom(merge(eLocal$, eRemote$).pipe(toArray()))
}
const rawEvents = [...aTagEvents, ...eTagEvents]
await rebroadcastEvents(rawEvents, relayPool, settings)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch {
return []
}
}

View File

@@ -0,0 +1,57 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../../types/highlights'
import { RELAYS } from '../../config/relays'
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService'
export const fetchHighlightsForUrl = async (
relayPool: RelayPool,
url: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
const seenIds = new Set<string>()
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
const local$ = localRelaysUrl.length > 0
? relayPool
.req(localRelaysUrl, { kinds: [9802], '#r': [url] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelaysUrl.length > 0
? relayPool
.req(remoteRelaysUrl, { kinds: [9802], '#r': [url] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
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()))
await rebroadcastEvents(rawEvents, relayPool, settings)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch {
return []
}
}

View File

@@ -0,0 +1,52 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../../types/highlights'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { queryEvents } from '../dataFetch'
/**
* Fetches highlights (kind:9802) from a list of pubkeys (friends)
* @param relayPool - The relay pool to query
* @param pubkeys - Array of pubkeys to fetch highlights from
* @param onHighlight - Optional callback for streaming highlights as they arrive
* @returns Array of highlights
*/
export const fetchHighlightsFromAuthors = async (
relayPool: RelayPool,
pubkeys: string[],
onHighlight?: (highlight: Highlight) => void
): Promise<Highlight[]> => {
try {
if (pubkeys.length === 0) {
console.log('⚠️ No pubkeys to fetch highlights from')
return []
}
console.log('💡 Fetching highlights (kind 9802) from', pubkeys.length, 'authors')
const seenIds = new Set<string>()
const rawEvents = await queryEvents(
relayPool,
{ kinds: [9802], authors: pubkeys, limit: 200 },
{
onEvent: (event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}
}
}
)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight)
console.log('💡 Processed', highlights.length, 'unique highlights')
return sortHighlights(highlights)
} catch (error) {
console.error('Failed to fetch highlights from authors:', error)
return []
}
}

View File

@@ -0,0 +1,153 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { MARK_AS_READ_EMOJI } from './reactionService'
import { BlogPostPreview } from './exploreService'
import { queryEvents } from './dataFetch'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
export interface ReadArticle {
id: string
url?: string
eventId?: string
eventAuthor?: string
eventKind?: number
markedAt: number
reactionId: string
}
/**
* Fetches all articles that the user has marked as read
* Returns both nostr-native articles (kind:7) and external URLs (kind:17)
*/
export async function fetchReadArticles(
relayPool: RelayPool,
userPubkey: string
): Promise<ReadArticle[]> {
try {
// 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[] = []
// Process kind:7 reactions (nostr-native articles)
for (const event of kind7Events) {
if (event.content === MARK_AS_READ_EMOJI) {
const eTag = event.tags.find((t) => t[0] === 'e')
const pTag = event.tags.find((t) => t[0] === 'p')
const kTag = event.tags.find((t) => t[0] === 'k')
if (eTag && eTag[1]) {
readArticles.push({
id: eTag[1],
eventId: eTag[1],
eventAuthor: pTag?.[1],
eventKind: kTag?.[1] ? parseInt(kTag[1]) : undefined,
markedAt: event.created_at,
reactionId: event.id
})
}
}
}
// Process kind:17 reactions (external URLs)
for (const event of kind17Events) {
if (event.content === MARK_AS_READ_EMOJI) {
const rTag = event.tags.find((t) => t[0] === 'r')
if (rTag && rTag[1]) {
readArticles.push({
id: rTag[1],
url: rTag[1],
markedAt: event.created_at,
reactionId: event.id
})
}
}
}
// Sort by markedAt (most recent first) and dedupe
const deduped = new Map<string, ReadArticle>()
readArticles
.sort((a, b) => b.markedAt - a.markedAt)
.forEach((article) => {
if (!deduped.has(article.id)) {
deduped.set(article.id, article)
}
})
return Array.from(deduped.values())
} catch (error) {
console.error('Failed to fetch read articles:', error)
return []
}
}
/**
* Fetches full article data for read nostr-native articles
* and converts them to BlogPostPreview format for rendering
*/
export async function fetchReadArticlesWithData(
relayPool: RelayPool,
userPubkey: string
): Promise<BlogPostPreview[]> {
try {
// First get all read articles
const readArticles = await fetchReadArticles(relayPool, userPubkey)
// Filter to only nostr-native articles (kind 30023)
const nostrArticles = readArticles.filter(
article => article.eventKind === 30023 && article.eventId
)
if (nostrArticles.length === 0) {
return []
}
// Fetch the actual article events
const eventIds = nostrArticles.map(a => a.eventId!).filter(Boolean)
const articleEvents = await queryEvents(
relayPool,
{ kinds: [30023], ids: eventIds },
{ relayUrls: RELAYS }
)
// Deduplicate article events by ID
const uniqueArticleEvents = new Map<string, NostrEvent>()
articleEvents.forEach(event => {
if (!uniqueArticleEvents.has(event.id)) {
uniqueArticleEvents.set(event.id, event)
}
})
// Convert to BlogPostPreview format
const blogPosts: BlogPostPreview[] = Array.from(uniqueArticleEvents.values()).map(event => ({
event,
title: getArticleTitle(event) || 'Untitled Article',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}))
// Sort by when they were marked as read (most recent first)
const articlesMap = new Map(nostrArticles.map(a => [a.eventId, a]))
blogPosts.sort((a, b) => {
const markedAtA = articlesMap.get(a.event.id)?.markedAt || 0
const markedAtB = articlesMap.get(b.event.id)?.markedAt || 0
return markedAtB - markedAtA
})
return blogPosts
} catch (error) {
console.error('Failed to fetch read articles with data:', error)
return []
}
}

54
src/services/meCache.ts Normal file
View File

@@ -0,0 +1,54 @@
import { Highlight } from '../types/highlights'
import { Bookmark } from '../types/bookmarks'
import { BlogPostPreview } from './exploreService'
export interface MeCache {
highlights: Highlight[]
bookmarks: Bookmark[]
readArticles: BlogPostPreview[]
timestamp: number
}
const meCache = new Map<string, MeCache>() // key: pubkey
export function getCachedMeData(pubkey: string): MeCache | null {
const entry = meCache.get(pubkey)
if (!entry) return null
return entry
}
export function setCachedMeData(
pubkey: string,
highlights: Highlight[],
bookmarks: Bookmark[],
readArticles: BlogPostPreview[]
): void {
meCache.set(pubkey, {
highlights,
bookmarks,
readArticles,
timestamp: Date.now()
})
}
export function updateCachedHighlights(pubkey: string, highlights: Highlight[]): void {
const existing = meCache.get(pubkey)
if (existing) {
meCache.set(pubkey, { ...existing, highlights, timestamp: Date.now() })
}
}
export function updateCachedBookmarks(pubkey: string, bookmarks: Bookmark[]): void {
const existing = meCache.get(pubkey)
if (existing) {
meCache.set(pubkey, { ...existing, bookmarks, timestamp: Date.now() })
}
}
export function updateCachedReadArticles(pubkey: string, readArticles: BlogPostPreview[]): void {
const existing = meCache.get(pubkey)
if (existing) {
meCache.set(pubkey, { ...existing, readArticles, timestamp: Date.now() })
}
}

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More