mirror of
https://github.com/dergigi/boris.git
synced 2026-02-17 21:15:02 +01:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84001d1b83 | ||
|
|
b7a390cf89 | ||
|
|
59d9179642 | ||
|
|
68301cd20f | ||
|
|
4d6b7e1a46 | ||
|
|
95fe9b548f | ||
|
|
e86ae9f05e | ||
|
|
2124be83c3 | ||
|
|
a8bb17d4cd | ||
|
|
a886a68822 | ||
|
|
76bdbc670d | ||
|
|
c16ce1fc7e | ||
|
|
a578d67b1e | ||
|
|
25d1ead9f5 | ||
|
|
ae5ea66dd2 | ||
|
|
cf5f8fae16 | ||
|
|
d9c46e602a | ||
|
|
4d980bf91c | ||
|
|
cb3b0e38e9 | ||
|
|
fbf5c455ca | ||
|
|
ed5decf3e9 | ||
|
|
44a7e6ae2c | ||
|
|
f52b94d72a | ||
|
|
d0833b5ed4 | ||
|
|
2f20b393bc | ||
|
|
13fa6cd485 | ||
|
|
e6e7240cd5 | ||
|
|
c1ff3b44d1 | ||
|
|
0577f862fd | ||
|
|
883cb352ff | ||
|
|
238cc9bc00 |
@@ -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.
|
||||
|
||||
90
CHANGELOG.md
90
CHANGELOG.md
@@ -7,6 +7,96 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.10.21] - 2025-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reading position tracking for internal event URLs
|
||||
- Prevents tracking for `nostr-event:` sentinel URLs (internal convention, not a valid Nostr URI per NIP-21)
|
||||
- Fixes "String must be lowercase or uppercase" error when loading base64-encoded event URLs
|
||||
- These internal sentinels are no longer saved to reading position data
|
||||
- Compact bookmark view display
|
||||
- Articles now show title instead of summary in compact view
|
||||
- Extracts article title from NIP-23 metadata tags
|
||||
- Removed unused `articleSummary` prop to keep code DRY
|
||||
- Bookmark deduplication in profile view
|
||||
- Same article appearing in multiple bookmark lists/sets now displays only once
|
||||
- Uses coordinate-based deduplication (`kind:pubkey:identifier`) for articles
|
||||
- Applies `dedupeBookmarksById` when flattening bookmarks from different sources
|
||||
|
||||
## [0.10.20] - 2025-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- Web Bookmarks section now appears first when bookmarks are grouped by source
|
||||
- Provides quicker access to external URL bookmarks
|
||||
- Better organization for mixed bookmark collections
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mobile scroll position preservation when toggling highlights panel
|
||||
- Opening/closing the highlights sidebar no longer resets scroll to top
|
||||
- Scroll position is saved before locking and restored after unlocking
|
||||
- Uses `requestAnimationFrame` to ensure DOM updates before restoring
|
||||
- Infinite loop in reading position tracking
|
||||
- Fixed "Maximum update depth exceeded" error in `useReadingPosition` hook
|
||||
- Callbacks now stored in refs to avoid dependency array issues
|
||||
- Prevents unnecessary re-renders during scroll tracking
|
||||
- Skeleton loading state for articles with zero highlights
|
||||
- Articles without highlights no longer show infinite loading skeletons
|
||||
- Properly transitions to empty state message
|
||||
- Navigation to bookmarked articles
|
||||
- Clicking bookmarked kind:30023 articles now navigates to `/a/:naddr` route
|
||||
- Fixes issue where clicking showed "Select a bookmark" message instead
|
||||
- Applies to both compact view and bookmark item interactions
|
||||
|
||||
## [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
|
||||
|
||||
@@ -147,7 +147,7 @@ function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
|
||||
const baseUrl = 'https://read.withboris.com'
|
||||
const articleUrl = `${baseUrl}/a/${naddr}`
|
||||
|
||||
const title = meta?.title || 'Boris – Nostr Bookmarks'
|
||||
const title = meta?.title || 'Boris – Read, Highlight, Explore'
|
||||
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
|
||||
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
|
||||
const author = meta?.author || 'Boris'
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
<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>
|
||||
<title>Boris - Read, Highlight, Explore</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/" />
|
||||
|
||||
<!-- Open Graph / Social Media -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://read.withboris.com/" />
|
||||
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta property="og:title" content="Boris - Read, Highlight, Explore" />
|
||||
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<meta property="og:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||
<meta property="og:site_name" content="Boris" />
|
||||
@@ -24,7 +24,7 @@
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta name="twitter:title" content="Boris - Read, Highlight, Explore" />
|
||||
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||
|
||||
|
||||
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.19",
|
||||
"version": "0.10.22",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Boris - Nostr Bookmarks",
|
||||
"name": "Boris - Read, Highlight, Explore",
|
||||
"short_name": "Boris",
|
||||
"description": "Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.",
|
||||
"start_url": "/",
|
||||
|
||||
@@ -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)}`
|
||||
@@ -40,10 +42,11 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : null
|
||||
|
||||
// For kind:30023 articles, extract image and summary tags (per NIP-23)
|
||||
// For kind:30023 articles, extract title, image and summary tags (per NIP-23)
|
||||
// Note: We extract directly from tags here since we don't have the full event.
|
||||
// When we have full events, we use getArticleImage() helper (see articleService.ts)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const articleTitle = isArticle ? bookmark.tags.find(t => t[0] === 'title')?.[1] : undefined
|
||||
const articleImage = isArticle ? bookmark.tags.find(t => t[0] === 'image')?.[1] : undefined
|
||||
const articleSummary = isArticle ? bookmark.tags.find(t => t[0] === 'summary')?.[1] : undefined
|
||||
|
||||
@@ -108,10 +111,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
|
||||
}
|
||||
@@ -148,10 +157,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
authorNpub,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary,
|
||||
articleTitle,
|
||||
contentTypeIcon: getContentTypeIcon(),
|
||||
readingProgress
|
||||
}
|
||||
|
||||
@@ -105,7 +105,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
// For web bookmarks and other types, try to use URL if available
|
||||
|
||||
// For web bookmarks (kind:39701), URL is in the 'd' tag
|
||||
if (bookmark.kind === 39701) {
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
// Ensure URL has protocol
|
||||
const url = dTag.startsWith('http') ? dTag : `https://${dTag}`
|
||||
return readingProgressMap.get(url)
|
||||
}
|
||||
}
|
||||
|
||||
// For other bookmark types, try to extract URL from content
|
||||
const urls = extractUrlsFromContent(bookmark.content)
|
||||
if (urls.length > 0) {
|
||||
return readingProgressMap.get(urls[0])
|
||||
@@ -170,11 +181,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)
|
||||
|
||||
@@ -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
|
||||
@@ -12,7 +13,7 @@ interface CompactViewProps {
|
||||
hasUrls: boolean
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
articleSummary?: string
|
||||
articleTitle?: string
|
||||
contentTypeIcon: IconDefinition
|
||||
readingProgress?: number
|
||||
}
|
||||
@@ -23,7 +24,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
articleSummary,
|
||||
articleTitle,
|
||||
contentTypeIcon,
|
||||
readingProgress
|
||||
}) => {
|
||||
@@ -33,7 +34,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
const isNote = bookmark.kind === 1
|
||||
const isClickable = hasUrls || isArticle || isWebBookmark || isNote
|
||||
|
||||
const displayText = isArticle && articleSummary ? articleSummary : bookmark.content
|
||||
const displayText = isArticle && articleTitle ? articleTitle : bookmark.content
|
||||
|
||||
// Calculate progress color
|
||||
let progressColor = '#6366f1' // Default blue (reading)
|
||||
@@ -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) {
|
||||
|
||||
@@ -155,6 +155,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
const isTextContent = useMemo(() => {
|
||||
if (loading) return false
|
||||
if (!markdown && !html) return false
|
||||
// Don't track internal sentinel URLs (nostr-event: is not a real Nostr URI per NIP-21)
|
||||
if (selectedUrl?.startsWith('nostr-event:')) return false
|
||||
if (selectedUrl?.includes('youtube') || selectedUrl?.includes('vimeo')) return false
|
||||
if (!shouldTrackReadingProgress(html, markdown)) return false
|
||||
|
||||
|
||||
@@ -82,11 +82,28 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
|
||||
|
||||
|
||||
// Visibility filters (defaults from settings or nostrverse when logged out)
|
||||
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
||||
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
|
||||
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||
mine: settings?.defaultExploreScopeMine ?? false
|
||||
// Visibility filters - load from localStorage first, fallback to settings
|
||||
const [visibility, setVisibility] = useState<HighlightVisibility>(() => {
|
||||
// Try to load from localStorage first
|
||||
try {
|
||||
const saved = localStorage.getItem('exploreScopeVisibility')
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved)
|
||||
// Validate that at least one scope is enabled
|
||||
if (parsed.nostrverse || parsed.friends || parsed.mine) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load explore scope from localStorage:', err)
|
||||
}
|
||||
|
||||
// Fallback to settings or defaults
|
||||
return {
|
||||
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
|
||||
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||
mine: settings?.defaultExploreScopeMine ?? false
|
||||
}
|
||||
})
|
||||
|
||||
// Ensure at least one scope remains active
|
||||
@@ -96,6 +113,12 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
if (!next.nostrverse && !next.friends && !next.mine) {
|
||||
return prev // ignore toggle that would disable all scopes
|
||||
}
|
||||
// Persist to localStorage
|
||||
try {
|
||||
localStorage.setItem('exploreScopeVisibility', JSON.stringify(next))
|
||||
} catch (err) {
|
||||
console.warn('Failed to save explore scope to localStorage:', err)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
@@ -224,18 +247,44 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
|
||||
// Update visibility when settings/login state changes
|
||||
useEffect(() => {
|
||||
// Check if user has a saved preference
|
||||
const hasSavedPreference = (() => {
|
||||
try {
|
||||
return localStorage.getItem('exploreScopeVisibility') !== null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
|
||||
// Only reset to defaults if no saved preference exists
|
||||
if (hasSavedPreference) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeAccount) {
|
||||
// When logged out, show nostrverse by default
|
||||
setVisibility(prev => ({ ...prev, nostrverse: true, friends: false, mine: false }))
|
||||
const defaultVisibility = { nostrverse: true, friends: false, mine: false }
|
||||
setVisibility(defaultVisibility)
|
||||
try {
|
||||
localStorage.setItem('exploreScopeVisibility', JSON.stringify(defaultVisibility))
|
||||
} catch (err) {
|
||||
console.warn('Failed to save explore scope to localStorage:', err)
|
||||
}
|
||||
setHasLoadedNostrverse(true) // logged out path loads nostrverse immediately
|
||||
setHasLoadedNostrverseHighlights(true)
|
||||
} else {
|
||||
// When logged in, use settings defaults immediately
|
||||
setVisibility({
|
||||
const defaultVisibility = {
|
||||
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
|
||||
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||
mine: settings?.defaultExploreScopeMine ?? false
|
||||
})
|
||||
}
|
||||
setVisibility(defaultVisibility)
|
||||
try {
|
||||
localStorage.setItem('exploreScopeVisibility', JSON.stringify(defaultVisibility))
|
||||
} catch (err) {
|
||||
console.warn('Failed to save explore scope to localStorage:', err)
|
||||
}
|
||||
setHasLoadedNostrverse(false)
|
||||
setHasLoadedNostrverseHighlights(false)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export const HighlightButton = React.forwardRef<HighlightButtonRef, HighlightBut
|
||||
className="highlight-fab"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '32px',
|
||||
bottom: '80px',
|
||||
right: '32px',
|
||||
zIndex: 1000,
|
||||
width: '56px',
|
||||
|
||||
@@ -25,6 +25,7 @@ import { faBooks } from '../icons/customIcons'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { groupIndividualBookmarks, hasContent, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
|
||||
import { dedupeBookmarksById } from '../services/bookmarkHelpers'
|
||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
||||
@@ -394,8 +395,7 @@ const Me: React.FC<MeProps> = ({
|
||||
}
|
||||
|
||||
const getReadItemUrl = (item: ReadItem) => {
|
||||
if (item.type === 'article') {
|
||||
// ID is already in naddr format
|
||||
if (item.type === 'article' && item.id.startsWith('naddr1')) {
|
||||
return `/a/${item.id}`
|
||||
} else if (item.url) {
|
||||
return `/r/${encodeURIComponent(item.url)}`
|
||||
@@ -438,19 +438,16 @@ const Me: React.FC<MeProps> = ({
|
||||
|
||||
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,
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: bookmark.pubkey,
|
||||
}
|
||||
const naddr = nip19.naddrEncode(pointer)
|
||||
identifier: dTag
|
||||
})
|
||||
navigate(`/a/${naddr}`)
|
||||
}
|
||||
} else if (url) {
|
||||
// For regular URLs, navigate to the reader route
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
}
|
||||
}
|
||||
@@ -491,8 +488,10 @@ const Me: React.FC<MeProps> = ({
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Merge and flatten all individual bookmarks
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
// Merge and flatten all individual bookmarks with deduplication
|
||||
const allIndividualBookmarks = dedupeBookmarksById(
|
||||
bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
)
|
||||
.filter(hasContent)
|
||||
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
|
||||
const handleMenuItemClick = (action: () => void) => {
|
||||
setShowProfileMenu(false)
|
||||
// Close mobile sidebar when navigating on mobile
|
||||
if (isMobile) {
|
||||
onToggleCollapse()
|
||||
}
|
||||
action()
|
||||
}
|
||||
|
||||
@@ -127,23 +131,38 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
<div className="sidebar-header-right">
|
||||
<IconButton
|
||||
icon={faHome}
|
||||
onClick={() => navigate('/')}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
onToggleCollapse()
|
||||
}
|
||||
navigate('/')
|
||||
}}
|
||||
title="Home"
|
||||
ariaLabel="Home"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
ariaLabel="Settings"
|
||||
icon={faPersonHiking}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
onToggleCollapse()
|
||||
}
|
||||
navigate('/explore')
|
||||
}}
|
||||
title="Explore"
|
||||
ariaLabel="Explore"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faPersonHiking}
|
||||
onClick={() => navigate('/explore')}
|
||||
title="Explore"
|
||||
ariaLabel="Explore"
|
||||
icon={faGear}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
onToggleCollapse()
|
||||
}
|
||||
onOpenSettings()
|
||||
}}
|
||||
title="Settings"
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
{!isMobile && (
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -306,7 +321,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={`pane sidebar ${isMobile && props.isSidebarOpen ? 'mobile-open' : ''}`}
|
||||
aria-hidden={isMobile && !props.isSidebarOpen}
|
||||
{...(isMobile && !props.isSidebarOpen ? { inert: '' } : {})}
|
||||
>
|
||||
<BookmarkList
|
||||
bookmarks={props.bookmarks}
|
||||
@@ -398,7 +413,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
<div
|
||||
ref={highlightsRef}
|
||||
className={`pane highlights ${isMobile && !props.isHighlightsCollapsed ? 'mobile-open' : ''}`}
|
||||
aria-hidden={isMobile && props.isHighlightsCollapsed}
|
||||
{...(isMobile && props.isHighlightsCollapsed ? { inert: '' } : {})}
|
||||
>
|
||||
<HighlightsPanel
|
||||
highlights={props.highlights}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -28,7 +28,7 @@ export const fetchHighlightsFromAuthors = async (
|
||||
const seenIds = new Set<string>()
|
||||
const rawEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [9802], authors: pubkeys, limit: 200 },
|
||||
{ kinds: [9802], authors: pubkeys, limit: 1000 },
|
||||
{
|
||||
onEvent: (event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
|
||||
@@ -81,13 +81,21 @@ class NostrverseHighlightsController {
|
||||
const currentGeneration = this.generation
|
||||
this.setLoading(true)
|
||||
|
||||
try {
|
||||
const seenIds = new Set<string>()
|
||||
const highlightsMap = new Map<string, Highlight>()
|
||||
try {
|
||||
const seenIds = new Set<string>()
|
||||
// Start with existing highlights when doing incremental sync
|
||||
const highlightsMap = new Map<string, Highlight>(
|
||||
this.currentHighlights.map(h => [h.id, h])
|
||||
)
|
||||
|
||||
const lastSyncedAt = force ? null : this.getLastSyncedAt()
|
||||
const filter: { kinds: number[]; since?: number } = { kinds: [KINDS.Highlights] }
|
||||
if (lastSyncedAt) filter.since = lastSyncedAt
|
||||
const lastSyncedAt = force ? null : this.getLastSyncedAt()
|
||||
const filter: { kinds: number[]; since?: number; limit?: number } = { kinds: [KINDS.Highlights] }
|
||||
if (lastSyncedAt) {
|
||||
filter.since = lastSyncedAt
|
||||
} else {
|
||||
// On initial load, fetch more highlights
|
||||
filter.limit = 1000
|
||||
}
|
||||
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
@@ -111,22 +119,24 @@ class NostrverseHighlightsController {
|
||||
|
||||
if (currentGeneration !== this.generation) return
|
||||
|
||||
events.forEach(evt => eventStore.add(evt))
|
||||
events.forEach(evt => eventStore.add(evt))
|
||||
|
||||
const highlights = events.map(eventToHighlight)
|
||||
const unique = Array.from(new Map(highlights.map(h => [h.id, h])).values())
|
||||
const sorted = sortHighlights(unique)
|
||||
const highlights = events.map(eventToHighlight)
|
||||
// Merge new highlights with existing ones
|
||||
highlights.forEach(h => highlightsMap.set(h.id, h))
|
||||
const sorted = sortHighlights(Array.from(highlightsMap.values()))
|
||||
|
||||
this.currentHighlights = sorted
|
||||
this.loaded = true
|
||||
this.emitHighlights(sorted)
|
||||
this.currentHighlights = sorted
|
||||
this.loaded = true
|
||||
this.emitHighlights(sorted)
|
||||
|
||||
if (sorted.length > 0) {
|
||||
const newest = Math.max(...sorted.map(h => h.created_at))
|
||||
this.setLastSyncedAt(newest)
|
||||
}
|
||||
} catch (err) {
|
||||
this.currentHighlights = []
|
||||
// On error, keep existing highlights instead of clearing them
|
||||
console.error('[nostrverse-highlights] Failed to sync:', err)
|
||||
this.emitHighlights(this.currentHighlights)
|
||||
} finally {
|
||||
if (currentGeneration === this.generation) this.setLoading(false)
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface ReadingProgressContent {
|
||||
progress: number // 0-1 scroll progress
|
||||
ts?: number // Unix timestamp (optional, for display)
|
||||
loc?: number // Optional: pixel position
|
||||
ver?: string // Schema version
|
||||
}
|
||||
|
||||
// Helper to extract and parse reading progress from event (kind 39802)
|
||||
@@ -117,8 +116,7 @@ export async function saveReadingPosition(
|
||||
const progressContent: ReadingProgressContent = {
|
||||
progress: position.position,
|
||||
ts: position.timestamp,
|
||||
loc: position.scrollTop,
|
||||
ver: '1'
|
||||
loc: position.scrollTop
|
||||
}
|
||||
|
||||
const tags = generateProgressTags(articleIdentifier)
|
||||
|
||||
@@ -13,7 +13,7 @@ type ProgressMapCallback = (progressMap: Map<string, number>) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
|
||||
const LAST_SYNCED_KEY = 'reading_progress_last_synced'
|
||||
const PROGRESS_CACHE_KEY = 'reading_progress_cache_v1'
|
||||
const PROGRESS_CACHE_KEY = 'reading_progress_cache'
|
||||
|
||||
/**
|
||||
* Shared reading progress controller
|
||||
|
||||
@@ -81,7 +81,15 @@ export function applyRelaySetToPool(
|
||||
for (const url of toRemove) {
|
||||
const relay = relayPool.relays.get(url)
|
||||
if (relay) {
|
||||
relay.close()
|
||||
try {
|
||||
// Only close if relay is actually connected or attempting to connect
|
||||
// This helps avoid WebSocket warnings for connections that never started
|
||||
relay.close()
|
||||
} catch (error) {
|
||||
// Suppress errors when closing relays that haven't fully connected yet
|
||||
// This can happen when switching relay sets before connections establish
|
||||
console.debug('[relay-manager] Ignoring error when closing relay:', url, error)
|
||||
}
|
||||
relayPool.relays.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,18 @@
|
||||
.me-highlights-list { padding-left: 0; padding-right: 0; }
|
||||
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
|
||||
|
||||
/* Hide tab labels on mobile to save space */
|
||||
@media (max-width: 768px) {
|
||||
.me-tab .tab-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.me-tab {
|
||||
padding: 0.75rem;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmarks list */
|
||||
.bookmarks-list {
|
||||
display: flex;
|
||||
|
||||
@@ -104,7 +104,7 @@ export default defineConfig({
|
||||
filename: 'sw.ts',
|
||||
injectRegister: null,
|
||||
manifest: {
|
||||
name: 'Boris - Nostr Bookmarks',
|
||||
name: 'Boris - Read, Highlight, Explore',
|
||||
short_name: 'Boris',
|
||||
description: 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.',
|
||||
start_url: '/',
|
||||
|
||||
Reference in New Issue
Block a user