mirror of
https://github.com/dergigi/boris.git
synced 2026-02-17 13:04:59 +01:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f52b94d72a | ||
|
|
d0833b5ed4 | ||
|
|
2f20b393bc | ||
|
|
13fa6cd485 | ||
|
|
e6e7240cd5 | ||
|
|
c1ff3b44d1 | ||
|
|
0577f862fd | ||
|
|
883cb352ff | ||
|
|
238cc9bc00 | ||
|
|
1800ee324e | ||
|
|
7d2dac2f1a | ||
|
|
7875f1d0bd | ||
|
|
d9263e07d1 | ||
|
|
9a345a7347 | ||
|
|
55d1af3bf9 | ||
|
|
feb3134b65 | ||
|
|
7d222e099f | ||
|
|
59436b5b9e | ||
|
|
2e08954e83 | ||
|
|
9cb1791a3a | ||
|
|
28ba620967 | ||
|
|
56f2d33e93 | ||
|
|
312c742969 | ||
|
|
0781c4ebfc | ||
|
|
85f4cd3590 | ||
|
|
89bc6258b1 | ||
|
|
534b628aea | ||
|
|
317d2e0b53 | ||
|
|
9ea69589fa | ||
|
|
89eaa97d30 | ||
|
|
0283405fb5 | ||
|
|
5eade913d1 |
@@ -3,8 +3,11 @@ description: fetching data from relays
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Fetching Data with Controllers
|
||||
|
||||
We fetch data from relays using controllers:
|
||||
- Start controllers immediatly; don’t await.
|
||||
|
||||
- Start controllers immediatly; don't await.
|
||||
- Stream via onEvent; dedupe replaceables; emit immediately.
|
||||
- Parallel local/remote queries; complete on EOSE.
|
||||
- Finalize and persist since after completion.
|
||||
|
||||
@@ -3,6 +3,8 @@ description: anything related to UI/UX
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Mobile-First UI/UX
|
||||
|
||||
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.
|
||||
|
||||
168
CHANGELOG.md
168
CHANGELOG.md
@@ -7,6 +7,122 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.10.19] - 2025-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- Profile dropdown menu in sidebar header
|
||||
- Click profile picture to access quick navigation menu
|
||||
- Menu items: My Highlights, My Bookmarks, My Reads, My Links, My Writings
|
||||
- Logout option included with separator
|
||||
- Click outside to close
|
||||
- Smooth slide-in animation
|
||||
|
||||
### Changed
|
||||
|
||||
- Profile picture interaction updated
|
||||
- Now triggers dropdown menu instead of navigating to profile page
|
||||
- More efficient access to all profile sections
|
||||
- Collapse buttons repositioned for symmetry
|
||||
- Highlights collapse button moved to left side of header
|
||||
- Both bookmarks and highlights collapse buttons now left-aligned
|
||||
- Consistent visual hierarchy across panels
|
||||
- Grouping toggle button repositioned
|
||||
- Moved from right/center to left side
|
||||
- Now next to support button (orange heart)
|
||||
- Better logical grouping of controls
|
||||
- Collapse button styling standardized
|
||||
- Highlights collapse button now matches bookmarks style
|
||||
- Consistent 33x33px size, border radius, and hover states
|
||||
- Uses native button element for better consistency
|
||||
|
||||
### Removed
|
||||
|
||||
- Redundant logout button from sidebar header
|
||||
- Previously next to Explore button
|
||||
- Now only accessible via profile dropdown menu
|
||||
- Refresh buttons from sidebars
|
||||
- Removed from bookmarks sidebar
|
||||
- Removed from highlights sidebar
|
||||
- Pull-to-refresh functionality remains available
|
||||
|
||||
### Fixed
|
||||
|
||||
- Cleaned up unused component props and parameters
|
||||
- Removed `lastFetchTime` from BookmarkList
|
||||
- Removed `loading` and `onRefresh` from HighlightsPanelHeader
|
||||
- All linting errors resolved
|
||||
- Type checking passing
|
||||
|
||||
## [0.10.18] - 2025-10-23
|
||||
|
||||
### Changed
|
||||
|
||||
- User profile routes renamed from `/me` to `/my`
|
||||
- `/my/highlights` - User's highlights
|
||||
- `/my/bookmarks` - User's bookmarks
|
||||
- `/my/reads` - Nostr-native articles with reading progress
|
||||
- `/my/links` - External URLs with reading progress
|
||||
- `/my/writings` - User's published articles
|
||||
- All navigation, tabs, and internal links updated
|
||||
- Documentation updated to reflect new paths
|
||||
|
||||
### Fixed
|
||||
|
||||
- `/my/writings` now displays all user writings
|
||||
- Removed incremental loading with `since` filters from writingsController
|
||||
- Controller now fetches ALL writings without limits
|
||||
- Ensures complete results on profile and my pages
|
||||
- `/my/highlights` now displays all user highlights
|
||||
- Removed incremental loading with `since` filters from highlightsController
|
||||
- Controller now fetches ALL highlights without limits
|
||||
- Consistent behavior across all profile views
|
||||
|
||||
### Refactored
|
||||
|
||||
- Centralized data fetching in controllers
|
||||
- Removed duplicate background fetch logic from Me.tsx and Profile.tsx
|
||||
- All writings fetching now handled by writingsController
|
||||
- All highlights fetching now handled by highlightsController
|
||||
- Single source of truth following controller pattern: stream, dedupe, store, emit
|
||||
- Cleaner, more maintainable code (DRY principle)
|
||||
|
||||
## [0.10.17] - 2025-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- Setting to control auto-scroll to reading position
|
||||
- New toggle in Settings > Reading Experience
|
||||
- Allows users to disable automatic scroll restoration
|
||||
- Defaults to enabled (preserves existing behavior)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Blockquote styling on mobile devices
|
||||
- Added equal right padding to match left padding (2rem on both sides)
|
||||
- Prevents awkward text cutoff on narrow screens
|
||||
- Timestamp clicks in highlight cards now navigate within app
|
||||
- Articles (kind:30023) open via `/a/{naddr}` route
|
||||
- External URLs open via `/r/{encodedUrl}` route
|
||||
- Previously opened external search portal (ants.sh)
|
||||
- Highlight automatically scrolls into view with sidebar open
|
||||
- Hero images now properly extend edge-to-edge on mobile
|
||||
- Adjusted negative margins to match new reader padding
|
||||
- Image bleeds to screen edges while text maintains comfortable margins
|
||||
- Article relay links now open via `/a/` path instead of `/r/`
|
||||
- Ensures nostr-native articles route correctly
|
||||
- External links continue to use `/r/` path
|
||||
|
||||
### Changed
|
||||
|
||||
- Mobile reader padding increased for better readability
|
||||
- Horizontal padding increased from 0.5rem to 1rem
|
||||
- Title, summary, and body text now properly aligned
|
||||
- More comfortable reading experience on small screens
|
||||
- Reading position save interval reduced from 3s to 1s
|
||||
- More frequent auto-saves during active reading
|
||||
- Better preservation of reading progress
|
||||
|
||||
## [0.10.16] - 2025-10-22
|
||||
|
||||
### Fixed
|
||||
@@ -34,7 +150,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Highlights now scroll into view when clicked from `/me/highlights` page
|
||||
- Highlights now scroll into view when clicked from `/my/highlights` page
|
||||
- Navigation state properly passes highlight ID and openHighlights flag
|
||||
- Works for both article links and external URL links
|
||||
|
||||
@@ -159,8 +275,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Default bookmark view changed to flat chronological list (newest first)
|
||||
- Bookmark URL changed from `/me/reading-list` to `/me/bookmarks`
|
||||
- Router updated to handle `/me/reading-list` → `/me/bookmarks` redirect
|
||||
- Bookmark URL changed from `/my/reading-list` to `/my/bookmarks`
|
||||
- Router updated to handle `/my/reading-list` → `/my/bookmarks` redirect
|
||||
- Me.tsx bookmarks tab now uses dynamic filter titles and chronological sorting
|
||||
- Me.tsx updated to use faClock icon instead of faBars
|
||||
- Removed bookmark count from section headings for cleaner display
|
||||
@@ -266,7 +382,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Tab switching regression on `/me` page
|
||||
- Tab switching regression on `/my` page
|
||||
- Resolved infinite update loop caused by circular dependency in `useCallback` hooks
|
||||
- Tab navigation now properly updates UI when URL changes
|
||||
- Removed `loadedTabs` from dependency arrays to prevent re-render cycles
|
||||
@@ -624,7 +740,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- `/me/bookmarks` tab now displays in cards view only
|
||||
- `/my/bookmarks` tab now displays in cards view only
|
||||
- Removed view mode toggle buttons (compact, large) from bookmarks tab
|
||||
- Cards view provides optimal bookmark browsing experience
|
||||
- Grouping toggle (grouped/flat) still available
|
||||
@@ -737,7 +853,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevent "No highlights yet" flash on `/me/highlights` page
|
||||
- Prevent "No highlights yet" flash on `/my/highlights` page
|
||||
- Force React to remount tab content when switching tabs for proper state management
|
||||
- Deduplicate blog posts by author:d-tag instead of event ID for better accuracy
|
||||
- Show skeleton placeholders while highlights are loading for better UX
|
||||
@@ -945,7 +1061,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Filter icons colored when active (blue for most, green for completed)
|
||||
- URL routing support for reading progress filters
|
||||
- Reading progress filters available in Archive tab and bookmarks sidebar
|
||||
- Reads and Links tabs on `/me` page
|
||||
- Reads and Links tabs on `/my` page
|
||||
- Reads tab shows nostr-native articles with reading progress
|
||||
- Links tab shows external URLs with reading progress
|
||||
- Both tabs populate instantly from bookmarks for fast loading
|
||||
@@ -991,7 +1107,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Bookmark filter buttons by content type (articles, videos, images, web links)
|
||||
- Filter bookmarks by their content type on bookmarks sidebar
|
||||
- Filters also available on `/me` page bookmarks tab
|
||||
- Filters also available on `/my` page bookmarks tab
|
||||
- Separate filter for external articles with link icon
|
||||
- Multiple filters can be active simultaneously
|
||||
- Private Bookmarks section for encrypted legacy bookmarks
|
||||
@@ -1005,7 +1121,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Better categorization of bookmark types
|
||||
- Bookmark filter button styling refined
|
||||
- Reduced whitespace around bookmark filters for cleaner layout
|
||||
- Dramatically reduced whitespace on both sidebar and `/me` page
|
||||
- Dramatically reduced whitespace on both sidebar and `/my` page
|
||||
- Lock icon removed from individual bookmarks
|
||||
- Encryption status now indicated by section grouping
|
||||
- Cleaner bookmark item appearance
|
||||
@@ -1172,7 +1288,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Camera icon for image bookmarks
|
||||
- Sticky note icon for text-only bookmarks without URLs
|
||||
- Bookmark grouping and sections
|
||||
- Grouped sections in sidebar and `/me` reading-list
|
||||
- Grouped sections in sidebar and `/my` reading-list
|
||||
- Web bookmarks, default bookmarks, and legacy bookmarks in separate sections
|
||||
- Grouping and sorting helpers for organizing bookmark sections
|
||||
- Adaptive text color for publication date over hero images
|
||||
@@ -1235,7 +1351,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Fixed
|
||||
|
||||
- Mobile bookmark button visibility across all pages
|
||||
- Now visible on `/p/` (profile), `/explore`, `/me`, and `/support` pages
|
||||
- Now visible on `/p/` (profile), `/explore`, `/my`, and `/support` pages
|
||||
- Only hidden on settings page or when scrolling down while reading
|
||||
- Prevents users from getting stuck without navigation options
|
||||
- Mobile highlights button behavior at page top
|
||||
@@ -1416,7 +1532,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Highlights tab on `/explore` page
|
||||
- View highlights from friends and followed users
|
||||
- Tab structure matching `/me` and profile pages
|
||||
- Tab structure matching `/my` and profile pages
|
||||
- Grid layout for highlights with cards
|
||||
- Highlights shown first, writings second
|
||||
- Clicking highlight opens source article and scrolls to position
|
||||
@@ -1573,7 +1689,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Writings tab on `/me` page to display user's published articles
|
||||
- Writings tab on `/my` page to display user's published articles
|
||||
- Comprehensive headline styling (h1-h6) with Tailwind typography
|
||||
- List styling for ordered and unordered lists in articles
|
||||
- Blockquote styling with indentation and italics
|
||||
@@ -1596,7 +1712,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Horizontal overflow from code blocks and wide content on mobile
|
||||
- Settings view now mobile-friendly with proper width constraints
|
||||
- Long relay URLs no longer cause horizontal overflow on mobile
|
||||
- Sidebar/highlights toggle buttons hidden on settings/explore/me pages
|
||||
- Sidebar/highlights toggle buttons hidden on settings/explore/my pages
|
||||
- Video titles now show filename instead of 'Error Loading Content'
|
||||
- AddBookmarkModal z-index issue fixed using React Portal
|
||||
- Highlight matching for text spanning multiple DOM nodes/inline elements
|
||||
@@ -1651,7 +1767,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- YouTube video metadata extraction with title, description, and captions
|
||||
- Responsive video player with aspect ratio support
|
||||
- Thumbnail images in compact view
|
||||
- URL routing for /me page tabs
|
||||
- URL routing for /my page tabs
|
||||
- Bookmark navigation in reading list
|
||||
- Video duration display for video URLs
|
||||
- Three-dot menu for videos with open/native/copy/share actions
|
||||
@@ -1681,7 +1797,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Style
|
||||
|
||||
- Hide tab counts on mobile for /me page
|
||||
- Hide tab counts on mobile for /my page
|
||||
- Remove max-width on main pane, constrain reader instead
|
||||
- Full width layout for videos
|
||||
- Reader-video specific styles
|
||||
@@ -1694,11 +1810,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Prism.js syntax highlighting for code blocks
|
||||
- Inline image rendering in nostr-native blog posts
|
||||
- Image placeholders on blog post cards in `/explore`
|
||||
- Caching on `/me` page for faster loading
|
||||
- Caching on `/my` page for faster loading
|
||||
|
||||
### Changed
|
||||
|
||||
- Reading List on `/me` now uses the same components as the bookmarks sidebar
|
||||
- Reading List on `/my` now uses the same components as the bookmarks sidebar
|
||||
- Improve bookmarks sidebar visual design
|
||||
- Make article menu button more subtle by removing border
|
||||
|
||||
@@ -1718,8 +1834,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- `/me` page with tabbed layout featuring Highlights, Reading List, and Library tabs
|
||||
- Two-pane layout for `/me` page with article sources and highlights
|
||||
- `/my` page with tabbed layout featuring Highlights, Reading List, and Library tabs
|
||||
- Two-pane layout for `/my` page with article sources and highlights
|
||||
- Custom FontAwesome Pro books icon for Archive tab
|
||||
- CompactButton component for highlight cards
|
||||
- Instant mark-as-read functionality with checkmark animation and read status checking
|
||||
@@ -1728,7 +1844,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Rename Library tab to Archive
|
||||
- Move highlight timestamp to top-right corner of cards
|
||||
- Replace username with AuthorCard component on `/me` page
|
||||
- Replace username with AuthorCard component on `/my` page
|
||||
- Use user's custom highlight color for Highlights tab
|
||||
- Render library articles using BlogPostCard component for consistency
|
||||
- Use faBooks icon for Mark as Read button
|
||||
@@ -1748,12 +1864,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Style
|
||||
|
||||
- Match `/me` profile card width to highlight cards
|
||||
- Improve Me page mobile tabs and avoid overlap with sidebar buttons
|
||||
- Match `/my` profile card width to highlight cards
|
||||
- Improve My page mobile tabs and avoid overlap with sidebar buttons
|
||||
- Reduce margins/paddings to make highlight cards more compact
|
||||
- Tighten vertical spacing on highlight cards
|
||||
- Left-align text inside author card
|
||||
- Constrain `/me` page content width to match author card (600px)
|
||||
- Constrain `/my` page content width to match author card (600px)
|
||||
- Improve tab border styling for dark theme
|
||||
- Make relay indicator match CompactButton (same look as menu)
|
||||
- Align relay indicator within footer with symmetric spacing
|
||||
@@ -1858,7 +1974,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Highlights: merge remote results after local for article/url
|
||||
- Explore: always query remote relays after local; stream merge into UI
|
||||
- Improve mobile touch targets for highlight icons
|
||||
- Color `/me` highlights with "my highlights" color setting
|
||||
- Color `/my` highlights with "my highlights" color setting
|
||||
|
||||
### Performance
|
||||
|
||||
@@ -1890,7 +2006,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Confirmation dialog prevents accidental deletions
|
||||
- Styled to match relay indicator (subtle, same size)
|
||||
- Removes highlights from UI immediately after deletion request
|
||||
- `/me` page showing user's recent highlights
|
||||
- `/my` page showing user's recent highlights
|
||||
- Accessible by clicking profile picture in bookmark sidebar
|
||||
- Displays all highlights created by the logged-in user
|
||||
- Uses same rendering as Settings and Explore pages
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
- **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.
|
||||
- **Profiles**: View your own (`/my`) or other users (`/p/:npub`) with tabs for Highlights, Bookmarks, Archive, and Writings.
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.10.9",
|
||||
"version": "0.10.19",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.10.9",
|
||||
"version": "0.10.19",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.10.17",
|
||||
"version": "0.10.20",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
18
src/App.tsx
18
src/App.tsx
@@ -237,11 +237,11 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me"
|
||||
element={<Navigate to="/me/highlights" replace />}
|
||||
path="/my"
|
||||
element={<Navigate to="/my/highlights" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/me/highlights"
|
||||
path="/my/highlights"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -253,7 +253,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/bookmarks"
|
||||
path="/my/bookmarks"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -265,7 +265,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/reads"
|
||||
path="/my/reads"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -277,7 +277,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/reads/:filter"
|
||||
path="/my/reads/:filter"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -289,7 +289,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/links"
|
||||
path="/my/links"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -301,7 +301,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/links/:filter"
|
||||
path="/my/links/:filter"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -313,7 +313,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/writings"
|
||||
path="/my/writings"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
||||
import { faGlobe, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { npubEncode } from 'nostr-tools/nip19'
|
||||
import { npubEncode, naddrEncode } from 'nostr-tools/nip19'
|
||||
import { IndividualBookmark } from '../types/bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { classifyUrl } from '../utils/helpers'
|
||||
@@ -23,6 +24,7 @@ interface BookmarkItemProps {
|
||||
}
|
||||
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
|
||||
const navigate = useNavigate()
|
||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||
|
||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||
@@ -108,10 +110,16 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
const handleReadNow = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
// For kind:30023 articles, pass the bookmark data instead of URL
|
||||
// For kind:30023 articles, navigate to /a/:naddr route
|
||||
if (bookmark.kind === 30023) {
|
||||
if (onSelectUrl) {
|
||||
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
const naddr = naddrEncode({
|
||||
kind: bookmark.kind,
|
||||
pubkey: bookmark.pubkey,
|
||||
identifier: dTag
|
||||
})
|
||||
navigate(`/a/${naddr}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useRef, useState, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faHeart, faPlus, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faClock } from '@fortawesome/free-regular-svg-icons'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
@@ -59,7 +58,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
onOpenSettings,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
lastFetchTime,
|
||||
loading = false,
|
||||
relayPool,
|
||||
isMobile = false,
|
||||
@@ -172,11 +170,11 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
groupingMode === 'flat'
|
||||
? [{ key: 'all', title: getFilterTitle(selectedFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
|
||||
: [
|
||||
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb },
|
||||
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
||||
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
||||
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
|
||||
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
|
||||
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
|
||||
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic }
|
||||
]
|
||||
|
||||
// Add bookmark sets as additional sections (only in grouped mode)
|
||||
@@ -314,9 +312,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
variant="ghost"
|
||||
style={{ color: friendsColor }}
|
||||
/>
|
||||
</div>
|
||||
{activeAccount && (
|
||||
<div className="view-mode-right">
|
||||
{activeAccount && (
|
||||
<IconButton
|
||||
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
|
||||
onClick={toggleGroupingMode}
|
||||
@@ -324,17 +320,10 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||
variant="ghost"
|
||||
/>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
{activeAccount && (
|
||||
<div className="view-mode-right">
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||
import RichContent from '../RichContent'
|
||||
import { naddrEncode } from 'nostr-tools/nip19'
|
||||
|
||||
interface CompactViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -45,7 +46,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
|
||||
const handleCompactClick = () => {
|
||||
if (isArticle) {
|
||||
onSelectUrl?.('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
const naddr = naddrEncode({
|
||||
kind: bookmark.kind,
|
||||
pubkey: bookmark.pubkey,
|
||||
identifier: dTag
|
||||
})
|
||||
navigate(`/a/${naddr}`)
|
||||
}
|
||||
} else if (hasUrls) {
|
||||
onSelectUrl?.(extractedUrls[0])
|
||||
} else if (isNote) {
|
||||
|
||||
@@ -53,7 +53,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
|
||||
const showSettings = location.pathname === '/settings'
|
||||
const showExplore = location.pathname.startsWith('/explore')
|
||||
const showMe = location.pathname.startsWith('/me')
|
||||
const showMe = location.pathname.startsWith('/my')
|
||||
const showProfile = location.pathname.startsWith('/p/')
|
||||
const showSupport = location.pathname === '/support'
|
||||
const eventId = eventIdParam
|
||||
@@ -62,12 +62,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
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/bookmarks' ? 'bookmarks' :
|
||||
location.pathname.startsWith('/me/reads') ? 'reads' :
|
||||
location.pathname.startsWith('/me/links') ? 'links' :
|
||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||
const meTab = location.pathname === '/my' ? 'highlights' :
|
||||
location.pathname === '/my/highlights' ? 'highlights' :
|
||||
location.pathname === '/my/bookmarks' ? 'bookmarks' :
|
||||
location.pathname.startsWith('/my/reads') ? 'reads' :
|
||||
location.pathname.startsWith('/my/links') ? 'links' :
|
||||
location.pathname === '/my/writings' ? 'writings' : 'highlights'
|
||||
|
||||
// Extract tab from profile routes
|
||||
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
|
||||
@@ -87,7 +87,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Track previous location for going back from settings/me/explore/profile
|
||||
// Track previous location for going back from settings/my/explore/profile
|
||||
useEffect(() => {
|
||||
if (!showSettings && !showMe && !showExplore && !showProfile) {
|
||||
previousLocationRef.current = location.pathname
|
||||
|
||||
@@ -118,13 +118,11 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
return (
|
||||
<div className="highlights-container">
|
||||
<HighlightsPanelHeader
|
||||
loading={loading}
|
||||
hasHighlights={filteredHighlights.length > 0}
|
||||
showHighlights={showHighlights}
|
||||
highlightVisibility={highlightVisibility}
|
||||
currentUserPubkey={currentUserPubkey}
|
||||
onToggleHighlights={handleToggleHighlights}
|
||||
onRefresh={onRefresh}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onHighlightVisibilityChange={onHighlightVisibilityChange}
|
||||
isMobile={isMobile}
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
import React from 'react'
|
||||
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faEye, faEyeSlash, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
||||
import { HighlightVisibility } from '../HighlightsPanel'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
interface HighlightsPanelHeaderProps {
|
||||
loading: boolean
|
||||
hasHighlights: boolean
|
||||
showHighlights: boolean
|
||||
highlightVisibility: HighlightVisibility
|
||||
currentUserPubkey?: string
|
||||
onToggleHighlights: () => void
|
||||
onRefresh?: () => void
|
||||
onToggleCollapse: () => void
|
||||
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
loading,
|
||||
hasHighlights,
|
||||
showHighlights,
|
||||
highlightVisibility,
|
||||
currentUserPubkey,
|
||||
onToggleHighlights,
|
||||
onRefresh,
|
||||
onToggleCollapse,
|
||||
onHighlightVisibilityChange,
|
||||
isMobile = false
|
||||
@@ -32,6 +29,16 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
<div className="highlights-header">
|
||||
<div className="highlights-actions">
|
||||
<div className="highlights-actions-left">
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-highlights-btn"
|
||||
title="Collapse highlights panel"
|
||||
aria-label="Collapse highlights panel"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} style={{ transform: 'rotate(180deg)' }} />
|
||||
</button>
|
||||
)}
|
||||
{onHighlightVisibilityChange && (
|
||||
<div className="highlight-level-toggles">
|
||||
<IconButton
|
||||
@@ -82,17 +89,8 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title="Refresh highlights"
|
||||
ariaLabel="Refresh highlights"
|
||||
variant="ghost"
|
||||
disabled={loading}
|
||||
spin={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="highlights-actions-right">
|
||||
{hasHighlights && (
|
||||
<IconButton
|
||||
icon={showHighlights ? faEye : faEyeSlash}
|
||||
@@ -103,16 +101,6 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<IconButton
|
||||
icon={faChevronRight}
|
||||
onClick={onToggleCollapse}
|
||||
title="Collapse highlights panel"
|
||||
ariaLabel="Collapse highlights panel"
|
||||
variant="ghost"
|
||||
style={{ transform: 'rotate(180deg)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faHeart } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faClock } from '@fortawesome/free-regular-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
@@ -144,15 +144,15 @@ const Me: React.FC<MeProps> = ({
|
||||
setReadingProgressFilter(filter)
|
||||
if (activeTab === 'reads') {
|
||||
if (filter === 'all') {
|
||||
navigate('/me/reads', { replace: true })
|
||||
navigate('/my/reads', { replace: true })
|
||||
} else {
|
||||
navigate(`/me/reads/${filter}`, { replace: true })
|
||||
navigate(`/my/reads/${filter}`, { replace: true })
|
||||
}
|
||||
} else if (activeTab === 'links') {
|
||||
if (filter === 'all') {
|
||||
navigate('/me/links', { replace: true })
|
||||
navigate('/my/links', { replace: true })
|
||||
} else {
|
||||
navigate(`/me/links/${filter}`, { replace: true })
|
||||
navigate(`/my/links/${filter}`, { replace: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -668,21 +668,26 @@ const Me: React.FC<MeProps> = ({
|
||||
</div>
|
||||
</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={groupingMode === 'grouped' ? faLayerGroup : faClock}
|
||||
onClick={toggleGroupingMode}
|
||||
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||
variant="ghost"
|
||||
/>
|
||||
<div className="view-mode-controls">
|
||||
<div className="view-mode-left">
|
||||
<IconButton
|
||||
icon={faHeart}
|
||||
onClick={() => navigate('/support')}
|
||||
title="Support Boris"
|
||||
ariaLabel="Support"
|
||||
variant="ghost"
|
||||
style={{ color: 'rgb(251 146 60)' }}
|
||||
/>
|
||||
<IconButton
|
||||
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
|
||||
onClick={toggleGroupingMode}
|
||||
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
<div className="view-mode-right">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -867,7 +872,7 @@ const Me: React.FC<MeProps> = ({
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||
data-tab="highlights"
|
||||
onClick={() => navigate('/me/highlights')}
|
||||
onClick={() => navigate('/my/highlights')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span className="tab-label">Highlights</span>
|
||||
@@ -875,7 +880,7 @@ const Me: React.FC<MeProps> = ({
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'bookmarks' ? 'active' : ''}`}
|
||||
data-tab="bookmarks"
|
||||
onClick={() => navigate('/me/bookmarks')}
|
||||
onClick={() => navigate('/my/bookmarks')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
<span className="tab-label">Bookmarks</span>
|
||||
@@ -883,7 +888,7 @@ const Me: React.FC<MeProps> = ({
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
|
||||
data-tab="reads"
|
||||
onClick={() => navigate('/me/reads')}
|
||||
onClick={() => navigate('/my/reads')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBooks} />
|
||||
<span className="tab-label">Reads</span>
|
||||
@@ -891,7 +896,7 @@ const Me: React.FC<MeProps> = ({
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
|
||||
data-tab="links"
|
||||
onClick={() => navigate('/me/links')}
|
||||
onClick={() => navigate('/my/links')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLink} />
|
||||
<span className="tab-label">Links</span>
|
||||
@@ -899,7 +904,7 @@ const Me: React.FC<MeProps> = ({
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||
data-tab="writings"
|
||||
onClick={() => navigate('/me/writings')}
|
||||
onClick={() => navigate('/my/writings')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
<span className="tab-label">Writings</span>
|
||||
|
||||
@@ -6,10 +6,8 @@ import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||
import { fetchHighlights } from '../services/highlightService'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { getActiveRelayUrls } from '../services/relayManager'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||
@@ -20,6 +18,8 @@ import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { readingProgressController } from '../services/readingProgressController'
|
||||
import { writingsController } from '../services/writingsController'
|
||||
import { highlightsController } from '../services/highlightsController'
|
||||
|
||||
interface ProfileProps {
|
||||
relayPool: RelayPool
|
||||
@@ -103,17 +103,16 @@ const Profile: React.FC<ProfileProps> = ({
|
||||
})
|
||||
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
|
||||
|
||||
// Background fetch to populate event store (non-blocking)
|
||||
// Background fetch via controllers to populate event store
|
||||
useEffect(() => {
|
||||
if (!pubkey || !relayPool || !eventStore) return
|
||||
|
||||
// Fetch all highlights and writings in background (no limits)
|
||||
const relayUrls = getActiveRelayUrls(relayPool)
|
||||
|
||||
fetchHighlights(relayPool, pubkey, undefined, undefined, false, eventStore)
|
||||
// Start controllers to fetch and populate event store
|
||||
// Controllers handle streaming, deduplication, and storage
|
||||
highlightsController.start({ relayPool, eventStore, pubkey })
|
||||
.catch(err => console.warn('⚠️ [Profile] Failed to fetch highlights:', err))
|
||||
|
||||
fetchBlogPostsFromAuthors(relayPool, [pubkey], relayUrls, undefined, null, eventStore)
|
||||
writingsController.start({ relayPool, eventStore, pubkey, force: refreshTrigger > 0 })
|
||||
.catch(err => console.warn('⚠️ [Profile] Failed to fetch writings:', err))
|
||||
}, [pubkey, relayPool, eventStore, refreshTrigger])
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function ShareTargetHandler({ relayPool }: ShareTargetHandlerProp
|
||||
getActiveRelayUrls(relayPool)
|
||||
)
|
||||
showToast('Bookmark saved!')
|
||||
navigate('/me/links')
|
||||
navigate('/my/links')
|
||||
} catch (err) {
|
||||
console.error('Failed to save shared bookmark:', err)
|
||||
showToast('Failed to save bookmark')
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react'
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faPersonHiking } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faPersonHiking, faHighlighter, faBookmark, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import IconButton from './IconButton'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
onToggleCollapse: () => void
|
||||
@@ -18,6 +19,8 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
const navigate = useNavigate()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const getProfileImage = () => {
|
||||
return profile?.picture || null
|
||||
@@ -33,22 +36,93 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
|
||||
const profileImage = getProfileImage()
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setShowProfileMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showProfileMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showProfileMenu])
|
||||
|
||||
const handleMenuItemClick = (action: () => void) => {
|
||||
setShowProfileMenu(false)
|
||||
action()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sidebar-header-bar">
|
||||
{activeAccount && (
|
||||
<button
|
||||
className="profile-avatar-button"
|
||||
title={getUserDisplayName()}
|
||||
onClick={() => navigate('/me')}
|
||||
aria-label={`Profile: ${getUserDisplayName()}`}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
<div className="profile-menu-wrapper" ref={menuRef}>
|
||||
<button
|
||||
className="profile-avatar-button"
|
||||
title={getUserDisplayName()}
|
||||
onClick={() => setShowProfileMenu(!showProfileMenu)}
|
||||
aria-label={`Profile: ${getUserDisplayName()}`}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</button>
|
||||
{showProfileMenu && (
|
||||
<div className="profile-dropdown-menu">
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/highlights'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>My Highlights</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/bookmarks'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
<span>My Bookmarks</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/reads'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBooks} />
|
||||
<span>My Reads</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/links'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLink} />
|
||||
<span>My Links</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/writings'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
<span>My Writings</span>
|
||||
</button>
|
||||
<div className="profile-menu-separator"></div>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(onLogout)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRightFromBracket} />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="sidebar-header-right">
|
||||
<IconButton
|
||||
@@ -72,15 +146,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
ariaLabel="Explore"
|
||||
variant="ghost"
|
||||
/>
|
||||
{activeAccount && (
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
onClick={onLogout}
|
||||
title="Logout"
|
||||
ariaLabel="Logout"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
|
||||
@@ -134,15 +134,30 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
const showHighlightsButton = scrollDirection !== 'down' && !isAtTop
|
||||
|
||||
// Lock body scroll when mobile sidebar or highlights is open
|
||||
const savedScrollPosition = useRef<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed)) {
|
||||
// Save current scroll position
|
||||
savedScrollPosition.current = window.scrollY
|
||||
document.body.style.top = `-${savedScrollPosition.current}px`
|
||||
document.body.classList.add('mobile-sidebar-open')
|
||||
} else {
|
||||
// Restore scroll position
|
||||
document.body.classList.remove('mobile-sidebar-open')
|
||||
document.body.style.top = ''
|
||||
if (savedScrollPosition.current > 0) {
|
||||
// Use requestAnimationFrame to ensure DOM has updated
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(0, savedScrollPosition.current)
|
||||
savedScrollPosition.current = 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('mobile-sidebar-open')
|
||||
document.body.style.top = ''
|
||||
}
|
||||
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed])
|
||||
|
||||
|
||||
@@ -158,7 +158,10 @@ export const useBookmarksData = ({
|
||||
|
||||
// Fetch article-specific highlights when viewing an article
|
||||
useEffect(() => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
if (!relayPool || !activeAccount) {
|
||||
setHighlightsLoading(false)
|
||||
return
|
||||
}
|
||||
// Fetch article-specific highlights when viewing an article
|
||||
// External URLs have their highlights fetched by useExternalUrlLoader
|
||||
if (effectiveArticleCoordinate && !externalUrl) {
|
||||
@@ -167,6 +170,9 @@ export const useBookmarksData = ({
|
||||
// Clear article highlights when not viewing an article
|
||||
setArticleHighlights([])
|
||||
setHighlightsLoading(false)
|
||||
} else {
|
||||
// For external URLs or other cases, loading is not needed
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}, [relayPool, activeAccount, effectiveArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
|
||||
|
||||
|
||||
@@ -28,6 +28,17 @@ export const useReadingPosition = ({
|
||||
const suppressUntilRef = useRef<number>(0)
|
||||
const pendingPositionRef = useRef<number>(0) // Track latest position for throttled save
|
||||
const lastSaved100Ref = useRef(false) // Track if we've saved 100% to avoid duplicate saves
|
||||
|
||||
// Store callbacks in refs to avoid them being dependencies
|
||||
const onPositionChangeRef = useRef(onPositionChange)
|
||||
const onReadingCompleteRef = useRef(onReadingComplete)
|
||||
const onSaveRef = useRef(onSave)
|
||||
|
||||
useEffect(() => {
|
||||
onPositionChangeRef.current = onPositionChange
|
||||
onReadingCompleteRef.current = onReadingComplete
|
||||
onSaveRef.current = onSave
|
||||
}, [onPositionChange, onReadingComplete, onSave])
|
||||
|
||||
// Suppress auto-saves for a given duration (used after programmatic restore)
|
||||
const suppressSavesFor = useCallback((ms: number) => {
|
||||
@@ -37,7 +48,7 @@ export const useReadingPosition = ({
|
||||
|
||||
// Throttled save function - saves at 1s intervals during scrolling
|
||||
const scheduleSave = useCallback((currentPosition: number) => {
|
||||
if (!syncEnabled || !onSave) {
|
||||
if (!syncEnabled || !onSaveRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -48,7 +59,7 @@ export const useReadingPosition = ({
|
||||
saveTimerRef.current = null
|
||||
}
|
||||
lastSaved100Ref.current = true
|
||||
onSave(1)
|
||||
onSaveRef.current(1)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,10 +76,10 @@ export const useReadingPosition = ({
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
// Save the latest position, not the one from when timer was scheduled
|
||||
const positionToSave = pendingPositionRef.current
|
||||
onSave(positionToSave)
|
||||
onSaveRef.current?.(positionToSave)
|
||||
saveTimerRef.current = null
|
||||
}, THROTTLE_MS)
|
||||
}, [syncEnabled, onSave])
|
||||
}, [syncEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
@@ -96,7 +107,7 @@ export const useReadingPosition = ({
|
||||
|
||||
setPosition(clampedProgress)
|
||||
positionRef.current = clampedProgress
|
||||
onPositionChange?.(clampedProgress)
|
||||
onPositionChangeRef.current?.(clampedProgress)
|
||||
|
||||
// Schedule auto-save if sync is enabled (unless suppressed)
|
||||
if (Date.now() >= suppressUntilRef.current) {
|
||||
@@ -113,7 +124,7 @@ export const useReadingPosition = ({
|
||||
if (!hasTriggeredComplete.current && positionRef.current === 1) {
|
||||
setIsReadingComplete(true)
|
||||
hasTriggeredComplete.current = true
|
||||
onReadingComplete?.()
|
||||
onReadingCompleteRef.current?.()
|
||||
}
|
||||
completionTimerRef.current = null
|
||||
}, completionHoldMs)
|
||||
@@ -127,7 +138,7 @@ export const useReadingPosition = ({
|
||||
if (clampedProgress >= readingCompleteThreshold) {
|
||||
setIsReadingComplete(true)
|
||||
hasTriggeredComplete.current = true
|
||||
onReadingComplete?.()
|
||||
onReadingCompleteRef.current?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,7 +162,7 @@ export const useReadingPosition = ({
|
||||
clearTimeout(completionTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave, completionHoldMs])
|
||||
}, [enabled, readingCompleteThreshold, scheduleSave, completionHoldMs])
|
||||
|
||||
// Reset reading complete state when enabled changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,8 +8,6 @@ import { eventToHighlight, sortHighlights } from './highlightEventProcessor'
|
||||
type HighlightsCallback = (highlights: Highlight[]) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
|
||||
const LAST_SYNCED_KEY = 'highlights_last_synced'
|
||||
|
||||
/**
|
||||
* Shared highlights controller
|
||||
* Manages the user's highlights centrally, similar to bookmarkController
|
||||
@@ -68,37 +66,10 @@ class HighlightsController {
|
||||
this.emitHighlights(this.currentHighlights)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last synced timestamp for incremental loading
|
||||
*/
|
||||
private getLastSyncedAt(pubkey: string): number | null {
|
||||
try {
|
||||
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||
if (!data) return null
|
||||
const parsed = JSON.parse(data)
|
||||
return parsed[pubkey] || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last synced timestamp
|
||||
*/
|
||||
private setLastSyncedAt(pubkey: string, timestamp: number): void {
|
||||
try {
|
||||
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||
const parsed = data ? JSON.parse(data) : {}
|
||||
parsed[pubkey] = timestamp
|
||||
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
|
||||
} catch (err) {
|
||||
console.warn('[highlights] Failed to save last synced timestamp:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load highlights for a user
|
||||
* Streams results and stores in event store
|
||||
* Always fetches ALL highlights to ensure completeness
|
||||
*/
|
||||
async start(options: {
|
||||
relayPool: RelayPool
|
||||
@@ -124,15 +95,12 @@ class HighlightsController {
|
||||
const seenIds = new Set<string>()
|
||||
const highlightsMap = new Map<string, Highlight>()
|
||||
|
||||
// Get last synced timestamp for incremental loading
|
||||
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
|
||||
const filter: { kinds: number[]; authors: string[]; since?: number } = {
|
||||
// Fetch ALL highlights without limits (no since filter)
|
||||
// This ensures we get complete results for profile/my pages
|
||||
const filter = {
|
||||
kinds: [KINDS.Highlights],
|
||||
authors: [pubkey]
|
||||
}
|
||||
if (lastSyncedAt) {
|
||||
filter.since = lastSyncedAt
|
||||
}
|
||||
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
@@ -179,12 +147,6 @@ class HighlightsController {
|
||||
this.lastLoadedPubkey = pubkey
|
||||
this.emitHighlights(sorted)
|
||||
|
||||
// Update last synced timestamp
|
||||
if (sorted.length > 0) {
|
||||
const newestTimestamp = Math.max(...sorted.map(h => h.created_at))
|
||||
this.setLastSyncedAt(pubkey, newestTimestamp)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[highlights] ❌ Failed to load highlights:', error)
|
||||
this.currentHighlights = []
|
||||
|
||||
@@ -10,8 +10,6 @@ const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished
|
||||
type WritingsCallback = (posts: BlogPostPreview[]) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
|
||||
const LAST_SYNCED_KEY = 'writings_last_synced'
|
||||
|
||||
/**
|
||||
* Shared writings controller
|
||||
* Manages the user's nostr-native long-form articles (kind:30023) centrally,
|
||||
@@ -71,34 +69,6 @@ class WritingsController {
|
||||
this.emitWritings(this.currentPosts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last synced timestamp for incremental loading
|
||||
*/
|
||||
private getLastSyncedAt(pubkey: string): number | null {
|
||||
try {
|
||||
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||
if (!data) return null
|
||||
const parsed = JSON.parse(data)
|
||||
return parsed[pubkey] || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last synced timestamp
|
||||
*/
|
||||
private setLastSyncedAt(pubkey: string, timestamp: number): void {
|
||||
try {
|
||||
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||
const parsed = data ? JSON.parse(data) : {}
|
||||
parsed[pubkey] = timestamp
|
||||
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
|
||||
} catch (err) {
|
||||
console.warn('[writings] Failed to save last synced timestamp:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert NostrEvent to BlogPostPreview using applesauce Helpers
|
||||
*/
|
||||
@@ -127,6 +97,7 @@ class WritingsController {
|
||||
/**
|
||||
* Load writings for a user (kind:30023)
|
||||
* Streams results and stores in event store
|
||||
* Always fetches ALL writings to ensure completeness
|
||||
*/
|
||||
async start(options: {
|
||||
relayPool: RelayPool
|
||||
@@ -152,15 +123,12 @@ class WritingsController {
|
||||
const seenIds = new Set<string>()
|
||||
const uniqueByReplaceable = new Map<string, BlogPostPreview>()
|
||||
|
||||
// Get last synced timestamp for incremental loading
|
||||
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
|
||||
const filter: { kinds: number[]; authors: string[]; since?: number } = {
|
||||
// Fetch ALL writings without limits (no since filter)
|
||||
// This ensures we get complete results for profile/my pages
|
||||
const filter = {
|
||||
kinds: [KINDS.BlogPost],
|
||||
authors: [pubkey]
|
||||
}
|
||||
if (lastSyncedAt) {
|
||||
filter.since = lastSyncedAt
|
||||
}
|
||||
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
@@ -221,12 +189,6 @@ class WritingsController {
|
||||
this.lastLoadedPubkey = pubkey
|
||||
this.emitWritings(sorted)
|
||||
|
||||
// Update last synced timestamp
|
||||
if (sorted.length > 0) {
|
||||
const newestTimestamp = Math.max(...sorted.map(p => p.event.created_at))
|
||||
this.setLastSyncedAt(pubkey, newestTimestamp)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[writings] ❌ Failed to load writings:', error)
|
||||
this.currentPosts = []
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Me page tabs */
|
||||
/* My page tabs */
|
||||
.me-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -71,7 +71,7 @@
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Align highlight list width with profile card width on /me */
|
||||
/* Align highlight list width with profile card width on /my */
|
||||
.me-highlights-list { padding-left: 0; padding-right: 0; }
|
||||
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
text-align: left; /* Override center alignment from .app */
|
||||
}
|
||||
|
||||
/* Bookmark filters in Me page */
|
||||
/* Bookmark filters in My page */
|
||||
.me-tab-content .bookmark-filters {
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
}
|
||||
|
||||
.reader-markdown img, .reader-html img {
|
||||
max-width: 100%;
|
||||
max-width: 100% !important;
|
||||
width: auto !important;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,28 @@
|
||||
|
||||
.highlights-actions { display: flex; align-items: center; justify-content: space-between; width: 100%; }
|
||||
.highlights-actions-left { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.highlights-actions-right { display: flex; align-items: center; gap: 0.5rem; }
|
||||
|
||||
/* Collapse button in highlights header */
|
||||
.highlights-header .toggle-highlights-btn {
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.highlights-header .toggle-highlights-btn:hover { background: var(--color-bg-elevated); color: var(--color-text); }
|
||||
.highlights-header .toggle-highlights-btn:active { transform: translateY(1px); }
|
||||
|
||||
.highlights-title { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.highlights-title h3 { margin: 0; font-size: 1rem; font-weight: 600; }
|
||||
|
||||
@@ -132,6 +132,70 @@
|
||||
.profile-avatar-button img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.profile-avatar-button svg { font-size: 1rem; }
|
||||
|
||||
/* Profile menu wrapper */
|
||||
.profile-menu-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
.profile-dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
min-width: 180px;
|
||||
padding: 0.25rem;
|
||||
z-index: 1000;
|
||||
animation: profileMenuSlideIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes profileMenuSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.profile-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-menu-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.profile-menu-item svg {
|
||||
width: 1em;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.profile-menu-separator {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.sidebar-header-bar .toggle-sidebar-btn {
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
|
||||
Reference in New Issue
Block a user