mirror of
https://github.com/dergigi/boris.git
synced 2026-02-17 04:54:56 +01:00
Compare commits
41 Commits
v0.6.17
...
sync-readi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63f58e010f | ||
|
|
d0b814e39d | ||
|
|
f4a227e40a | ||
|
|
6ef0a6dd71 | ||
|
|
5502d71ac4 | ||
|
|
5e1146b015 | ||
|
|
8f89165711 | ||
|
|
674634326f | ||
|
|
30eaec5770 | ||
|
|
0ff3c864a9 | ||
|
|
ab2ca1f5e7 | ||
|
|
cf2d227f61 | ||
|
|
2c9e6cc54e | ||
|
|
8da0a06711 | ||
|
|
be8d857223 | ||
|
|
d50bcd700e | ||
|
|
820ab1d902 | ||
|
|
f5e9e5bf61 | ||
|
|
40b43532e8 | ||
|
|
51a3008730 | ||
|
|
e30cbc72c3 | ||
|
|
6f913262f4 | ||
|
|
0f0462e6ac | ||
|
|
e353f0e2d6 | ||
|
|
ee1365d3ca | ||
|
|
a215d0b026 | ||
|
|
b8d76c0bd8 | ||
|
|
233169b082 | ||
|
|
72b9a04cd2 | ||
|
|
432715efb6 | ||
|
|
8b2b954dde | ||
|
|
c2d2bd8106 | ||
|
|
a5c3085c59 | ||
|
|
c0332f08d6 | ||
|
|
38a1d6caec | ||
|
|
39dd607e7b | ||
|
|
9dc0db3e06 | ||
|
|
b1eb58a385 | ||
|
|
f3c6404f76 | ||
|
|
1a42a6422d | ||
|
|
2e2de4ccda |
146
CHANGELOG.md
146
CHANGELOG.md
@@ -7,6 +7,146 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.6.20] - 2025-10-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 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
|
||||||
|
- Separate filter for external articles with link icon
|
||||||
|
- Multiple filters can be active simultaneously
|
||||||
|
- Private Bookmarks section for encrypted legacy bookmarks
|
||||||
|
- Encrypted legacy bookmarks now grouped in separate section
|
||||||
|
- Better organization and clarity for different bookmark types
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Bookmark section labels improved for clarity
|
||||||
|
- More descriptive section headings throughout
|
||||||
|
- 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
|
||||||
|
- Lock icon removed from individual bookmarks
|
||||||
|
- Encryption status now indicated by section grouping
|
||||||
|
- Cleaner bookmark item appearance
|
||||||
|
- External article icon changed to link icon (`faLink`)
|
||||||
|
- More intuitive icon for external content
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Highlight button positioning and visibility
|
||||||
|
- Fixed to viewport for consistent placement
|
||||||
|
- Sticky and always visible when needed
|
||||||
|
- Properly positioned inside reader pane
|
||||||
|
|
||||||
|
## [0.6.19] - 2025-10-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Highlights disappearing on external URLs after a few seconds
|
||||||
|
- Fixed `useBookmarksData` from fetching general highlights when viewing external URLs
|
||||||
|
- External URL highlights now managed exclusively by `useExternalUrlLoader`
|
||||||
|
- Removed redundant `setHighlights` call that was overwriting streamed highlights
|
||||||
|
- Improved error handling in `fetchHighlightsForUrl` to prevent silent failures
|
||||||
|
- Isolated rebroadcast errors so they don't break highlight display
|
||||||
|
- Added logging to help diagnose highlight fetching issues
|
||||||
|
|
||||||
|
## [0.6.18] - 2025-10-15
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Zap split labels simplified and terminology updated
|
||||||
|
- Removed redundant "Weight: xy" label to save space
|
||||||
|
- Changed "Author(s) Share" to "Author's Share" (possessive singular)
|
||||||
|
- Changed "Support Boris" to "Boris' Share" for consistency
|
||||||
|
- Weight value now shown directly in label (e.g., "Your Share: 50")
|
||||||
|
- Share and percentage now displayed on same line for cleaner layout
|
||||||
|
- Zap preset buttons on desktop now expand to match slider width
|
||||||
|
- Added `flex: 1` to buttons for equal width distribution
|
||||||
|
- Buttons still wrap properly on smaller screens
|
||||||
|
- PWA install section now always visible in settings
|
||||||
|
- Section shows regardless of installation or device capability status
|
||||||
|
- Button adapts with proper disabled states and visual feedback
|
||||||
|
- "Installed" state shows checkmark icon and disabled button
|
||||||
|
- Non-installable state shows disabled button
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- PWA install button now properly disabled when installation is not possible on device
|
||||||
|
- Button only enabled when browser fires `beforeinstallprompt` event
|
||||||
|
- Removed hardcoded testing state that always showed button as installable
|
||||||
|
- App & Airplane Mode section now always visible regardless of PWA status
|
||||||
|
- Image cache and local relay settings always accessible
|
||||||
|
- Previously entire section was hidden if PWA not installable/installed
|
||||||
|
- Only PWA-specific install button is conditionally affected
|
||||||
|
|
||||||
|
## [0.6.17] - 2025-10-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- PWA settings illustration (`pwa.svg`) displayed on right side of section
|
||||||
|
- Responsive design: hidden on mobile, 30% width on desktop
|
||||||
|
- Visual enhancement for App & Airplane Mode section
|
||||||
|
- Zaps illustration (`zaps.svg`) displayed on right side of Zap Splits section
|
||||||
|
- Matching responsive layout and styling as PWA illustration
|
||||||
|
- Visual 50% indicators on zap split sliders
|
||||||
|
- Linear gradient background using highlight colors (yellow/orange) at 50% opacity
|
||||||
|
- Datalist tick marks at 50% for "Your Share" and "Author(s) Share" sliders
|
||||||
|
- Tick mark at 5 for "Support Boris" slider
|
||||||
|
- Lightning bolt icons as slider thumbs for zap splits
|
||||||
|
- Replaces default circular slider handles
|
||||||
|
- White lightning bolt SVG embedded in slider thumb background
|
||||||
|
- 24px square thumb with 4px border radius
|
||||||
|
- Offline-first description paragraph at beginning of App & Airplane Mode section
|
||||||
|
- Explains Boris's offline capabilities upfront
|
||||||
|
- Settings page width constraint (900px max-width)
|
||||||
|
- Matches article view max-width for consistent reading experience
|
||||||
|
- Centered layout with proper margins
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Settings section reorganization
|
||||||
|
- "PWA & Flight Mode" merged into single "App & Airplane Mode" section
|
||||||
|
- "Layout & Navigation" and "Startup & Behavior" merged into "Layout & Behavior"
|
||||||
|
- Section order: Theme → Reading & Display → Zap Splits → Layout & Behavior → App & Airplane Mode → Relays
|
||||||
|
- "Startup & Behavior" moved after "Zap Splits"
|
||||||
|
- "Layout & Navigation" moved below "Zap Splits"
|
||||||
|
- PWA settings section restructure
|
||||||
|
- Checkboxes moved to top (image cache, local relays)
|
||||||
|
- Descriptive paragraphs in middle
|
||||||
|
- Install button at bottom
|
||||||
|
- Note about local relays moved before install paragraph
|
||||||
|
- Zap split sliders styling
|
||||||
|
- Left side (0-50%): highlight color (yellow) at 50% opacity
|
||||||
|
- Right side (50-100%): friend-highlight color (orange) at 50% opacity
|
||||||
|
- Creates visual distinction tied to app's highlight color scheme
|
||||||
|
- Zap split description text styling
|
||||||
|
- Now matches offline-first paragraph style with secondary color and smaller font size
|
||||||
|
- Clear cache button styling
|
||||||
|
- Replaced `IconButton` with plain `FontAwesomeIcon` for subtler appearance
|
||||||
|
- No border or background, just icon with opacity
|
||||||
|
- Font Size buttons alignment
|
||||||
|
- Now properly align to the right using `setting-control` wrapper
|
||||||
|
- Matches alignment of highlight color picker buttons
|
||||||
|
- Default Highlight Visibility position
|
||||||
|
- Moved back to original position after "Paragraph Alignment"
|
||||||
|
- Grouped with other reading display controls
|
||||||
|
- Spacing adjustments in App & Airplane Mode section
|
||||||
|
- Reduced gap between elements from 1rem → 0.5rem → 0.25rem for tighter layout
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- PWA settings paragraph wrapping
|
||||||
|
- Moved offline-first paragraph inside flex container to prevent extending above image
|
||||||
|
- Font Size buttons alignment issues
|
||||||
|
- Properly implemented `setting-control` wrapper for right alignment
|
||||||
|
- Previously attempted alignment didn't work correctly
|
||||||
|
- Slider thumb icon centering
|
||||||
|
- Lightning bolt icons properly centered vertically on slider
|
||||||
|
- Added `position: relative`, `top: 0`, `margin-top: 0` for accurate positioning
|
||||||
|
|
||||||
## [0.6.16] - 2025-10-15
|
## [0.6.16] - 2025-10-15
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -1501,7 +1641,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Optimize relay usage following applesauce-relay best practices
|
- Optimize relay usage following applesauce-relay best practices
|
||||||
- Use applesauce-react event models for better profile handling
|
- Use applesauce-react event models for better profile handling
|
||||||
|
|
||||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.16...HEAD
|
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.20...HEAD
|
||||||
|
[0.6.20]: https://github.com/dergigi/boris/compare/v0.6.19...v0.6.20
|
||||||
|
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
|
||||||
|
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18
|
||||||
|
[0.6.17]: https://github.com/dergigi/boris/compare/v0.6.16...v0.6.17
|
||||||
[0.6.16]: https://github.com/dergigi/boris/compare/v0.6.15...v0.6.16
|
[0.6.16]: https://github.com/dergigi/boris/compare/v0.6.15...v0.6.16
|
||||||
[0.6.15]: https://github.com/dergigi/boris/compare/v0.6.14...v0.6.15
|
[0.6.15]: https://github.com/dergigi/boris/compare/v0.6.14...v0.6.15
|
||||||
[0.6.14]: https://github.com/dergigi/boris/compare/v0.6.13...v0.6.14
|
[0.6.14]: https://github.com/dergigi/boris/compare/v0.6.13...v0.6.14
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.6.17",
|
"version": "0.6.20",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://read.withboris.com/",
|
"homepage": "https://read.withboris.com/",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
41
src/components/ArchiveFilters.tsx
Normal file
41
src/components/ArchiveFilters.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faBookmark } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
import { faBooks } from '../icons/customIcons'
|
||||||
|
|
||||||
|
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
|
||||||
|
|
||||||
|
interface ArchiveFiltersProps {
|
||||||
|
selectedFilter: ArchiveFilterType
|
||||||
|
onFilterChange: (filter: ArchiveFilterType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||||
|
const filters = [
|
||||||
|
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||||
|
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
|
||||||
|
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||||
|
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
||||||
|
{ type: 'marked' as const, icon: faBooks, label: 'Marked as Read' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bookmark-filters">
|
||||||
|
{filters.map(filter => (
|
||||||
|
<button
|
||||||
|
key={filter.type}
|
||||||
|
onClick={() => onFilterChange(filter.type)}
|
||||||
|
className={`filter-btn ${selectedFilter === filter.type ? 'active' : ''}`}
|
||||||
|
title={filter.label}
|
||||||
|
aria-label={`Filter by ${filter.label}`}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={filter.icon} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArchiveFilters
|
||||||
|
|
||||||
@@ -11,9 +11,10 @@ interface BlogPostCardProps {
|
|||||||
post: BlogPostPreview
|
post: BlogPostPreview
|
||||||
href: string
|
href: string
|
||||||
level?: 'mine' | 'friends' | 'nostrverse'
|
level?: 'mine' | 'friends' | 'nostrverse'
|
||||||
|
readingProgress?: number // 0-1 reading progress (optional)
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress }) => {
|
||||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||||
const displayName = profile?.name || profile?.display_name ||
|
const displayName = profile?.name || profile?.display_name ||
|
||||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||||
@@ -23,6 +24,10 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
|||||||
addSuffix: true
|
addSuffix: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Calculate progress percentage and determine color
|
||||||
|
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
||||||
|
const progressColor = progressPercent >= 95 ? '#10b981' : '#6366f1' // green if >=95%, blue otherwise
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={href}
|
to={href}
|
||||||
@@ -47,7 +52,37 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
|||||||
{post.summary && (
|
{post.summary && (
|
||||||
<p className="blog-post-card-summary">{post.summary}</p>
|
<p className="blog-post-card-summary">{post.summary}</p>
|
||||||
)}
|
)}
|
||||||
<div className="blog-post-card-meta">
|
|
||||||
|
{/* Reading progress indicator - replaces the dividing line */}
|
||||||
|
{readingProgress !== undefined && readingProgress > 0 ? (
|
||||||
|
<div
|
||||||
|
className="blog-post-reading-progress"
|
||||||
|
style={{
|
||||||
|
height: '3px',
|
||||||
|
width: '100%',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginTop: '1rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${progressPercent}%`,
|
||||||
|
background: progressColor,
|
||||||
|
transition: 'width 0.3s ease, background 0.3s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
height: '1px',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
marginTop: '1rem'
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="blog-post-card-meta" style={{ borderTop: 'none', paddingTop: '0.75rem' }}>
|
||||||
<span className="blog-post-card-author">
|
<span className="blog-post-card-author">
|
||||||
<FontAwesomeIcon icon={faUser} />
|
<FontAwesomeIcon icon={faUser} />
|
||||||
{displayName}
|
{displayName}
|
||||||
|
|||||||
44
src/components/BookmarkFilters.tsx
Normal file
44
src/components/BookmarkFilters.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faNewspaper, faStickyNote, faCirclePlay } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
import { faGlobe, faAsterisk, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
|
export type BookmarkFilterType = 'all' | 'article' | 'external' | 'video' | 'note' | 'web'
|
||||||
|
|
||||||
|
interface BookmarkFiltersProps {
|
||||||
|
selectedFilter: BookmarkFilterType
|
||||||
|
onFilterChange: (filter: BookmarkFilterType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BookmarkFilters: React.FC<BookmarkFiltersProps> = ({
|
||||||
|
selectedFilter,
|
||||||
|
onFilterChange
|
||||||
|
}) => {
|
||||||
|
const filters = [
|
||||||
|
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||||
|
{ type: 'article' as const, icon: faNewspaper, label: 'Articles' },
|
||||||
|
{ type: 'external' as const, icon: faLink, label: 'External Articles' },
|
||||||
|
{ type: 'video' as const, icon: faCirclePlay, label: 'Videos' },
|
||||||
|
{ type: 'note' as const, icon: faStickyNote, label: 'Notes' },
|
||||||
|
{ type: 'web' as const, icon: faGlobe, label: 'Web' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bookmark-filters">
|
||||||
|
{filters.map(filter => (
|
||||||
|
<button
|
||||||
|
key={filter.type}
|
||||||
|
onClick={() => onFilterChange(filter.type)}
|
||||||
|
className={`filter-btn ${selectedFilter === filter.type ? 'active' : ''}`}
|
||||||
|
title={filter.label}
|
||||||
|
aria-label={`Filter by ${filter.label}`}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={filter.icon} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BookmarkFilters
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
||||||
import { faGlobe } from '@fortawesome/free-solid-svg-icons'
|
import { faGlobe, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
@@ -70,7 +70,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
|
|
||||||
// Get content type icon based on bookmark kind and URL classification
|
// Get content type icon based on bookmark kind and URL classification
|
||||||
const getContentTypeIcon = (): IconDefinition => {
|
const getContentTypeIcon = (): IconDefinition => {
|
||||||
if (isArticle) return faNewspaper
|
if (isArticle) return faNewspaper // Nostr-native article
|
||||||
|
|
||||||
// For web bookmarks, classify the URL to determine icon
|
// For web bookmarks, classify the URL to determine icon
|
||||||
if (isWebBookmark && firstUrlClassification) {
|
if (isWebBookmark && firstUrlClassification) {
|
||||||
@@ -81,7 +81,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
case 'image':
|
case 'image':
|
||||||
return faCamera
|
return faCamera
|
||||||
case 'article':
|
case 'article':
|
||||||
return faNewspaper
|
return faLink // External article
|
||||||
default:
|
default:
|
||||||
return faGlobe
|
return faGlobe
|
||||||
}
|
}
|
||||||
@@ -89,6 +89,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
|
|
||||||
if (!hasUrls) return faStickyNote // Just a text note
|
if (!hasUrls) return faStickyNote // Just a text note
|
||||||
if (firstUrlClassification?.type === 'youtube' || firstUrlClassification?.type === 'video') return faCirclePlay
|
if (firstUrlClassification?.type === 'youtube' || firstUrlClassification?.type === 'video') return faCirclePlay
|
||||||
|
if (firstUrlClassification?.type === 'article') return faLink // External article
|
||||||
return faFileLines
|
return faFileLines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import AddBookmarkModal from './AddBookmarkModal'
|
|||||||
import { createWebBookmark } from '../services/webBookmarkService'
|
import { createWebBookmark } from '../services/webBookmarkService'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
|
|
||||||
interface BookmarkListProps {
|
interface BookmarkListProps {
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
@@ -61,6 +63,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
||||||
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
|
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
|
||||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||||
@@ -87,17 +90,20 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
.filter(hasContent)
|
.filter(hasContent)
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
||||||
|
|
||||||
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
||||||
const bookmarksWithoutSet = getBookmarksWithoutSet(allIndividualBookmarks)
|
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
||||||
const bookmarkSets = getBookmarkSets(allIndividualBookmarks)
|
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
||||||
|
|
||||||
// Group non-set bookmarks as before
|
// Group non-set bookmarks as before
|
||||||
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
||||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||||
{ key: 'private', title: 'Private bookmarks', items: groups.privateItems },
|
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
||||||
{ key: 'public', title: 'Public bookmarks', items: groups.publicItems },
|
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
||||||
{ key: 'web', title: 'Web bookmarks', items: groups.web },
|
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
|
||||||
{ key: 'amethyst', title: 'Old Bookmarks (Legacy)', items: groups.amethyst }
|
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
|
||||||
]
|
]
|
||||||
|
|
||||||
// Add bookmark sets as additional sections
|
// Add bookmark sets as additional sections
|
||||||
@@ -140,7 +146,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{allIndividualBookmarks.length === 0 ? (
|
{allIndividualBookmarks.length > 0 && (
|
||||||
|
<BookmarkFilters
|
||||||
|
selectedFilter={selectedFilter}
|
||||||
|
onFilterChange={setSelectedFilter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>No bookmarks match this filter.</p>
|
||||||
|
</div>
|
||||||
|
) : allIndividualBookmarks.length === 0 ? (
|
||||||
loading ? (
|
loading ? (
|
||||||
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
|
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
|
||||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faUserLock, faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
||||||
@@ -91,9 +91,6 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
<div className="bookmark-header">
|
<div className="bookmark-header">
|
||||||
<span className="bookmark-type">
|
<span className="bookmark-type">
|
||||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
{bookmark.isPrivate && (
|
|
||||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{eventNevent ? (
|
{eventNevent ? (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faUserLock } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||||
@@ -54,9 +53,6 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
>
|
>
|
||||||
<span className="bookmark-type-compact">
|
<span className="bookmark-type-compact">
|
||||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
{bookmark.isPrivate && (
|
|
||||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
{displayText && (
|
{displayText && (
|
||||||
<div className="compact-text">
|
<div className="compact-text">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faUserLock } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDate } from '../../utils/bookmarkUtils'
|
import { formatDate } from '../../utils/bookmarkUtils'
|
||||||
@@ -96,9 +95,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
<div className="large-footer">
|
<div className="large-footer">
|
||||||
<span className="bookmark-type-large">
|
<span className="bookmark-type-large">
|
||||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
{bookmark.isPrivate && (
|
|
||||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="large-author">
|
<span className="large-author">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
activeAccount,
|
activeAccount,
|
||||||
accountManager,
|
accountManager,
|
||||||
naddr,
|
naddr,
|
||||||
|
externalUrl,
|
||||||
currentArticleCoordinate,
|
currentArticleCoordinate,
|
||||||
currentArticleEventId,
|
currentArticleEventId,
|
||||||
settings
|
settings
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useState, useEffect, useRef } from 'react'
|
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import ReactPlayer from 'react-player'
|
import ReactPlayer from 'react-player'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
@@ -36,6 +36,13 @@ import { classifyUrl } from '../utils/helpers'
|
|||||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||||
import { useReadingPosition } from '../hooks/useReadingPosition'
|
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||||
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
||||||
|
import { EventFactory } from 'applesauce-factory'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import {
|
||||||
|
generateArticleIdentifier,
|
||||||
|
loadReadingPosition,
|
||||||
|
saveReadingPosition
|
||||||
|
} from '../services/readingPositionService'
|
||||||
|
|
||||||
interface ContentPanelProps {
|
interface ContentPanelProps {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -129,10 +136,58 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
onClearSelection
|
onClearSelection
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Get event store for reading position service
|
||||||
|
const eventStore = Hooks.useEventStore()
|
||||||
|
|
||||||
// Reading position tracking - only for text content, not videos
|
// Reading position tracking - only for text content, not videos
|
||||||
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
||||||
const { isReadingComplete, progressPercentage } = useReadingPosition({
|
|
||||||
|
// Generate article identifier for saving/loading position
|
||||||
|
const articleIdentifier = useMemo(() => {
|
||||||
|
if (!selectedUrl) return null
|
||||||
|
return generateArticleIdentifier(selectedUrl)
|
||||||
|
}, [selectedUrl])
|
||||||
|
|
||||||
|
// Callback to save reading position
|
||||||
|
const handleSavePosition = useCallback(async (position: number) => {
|
||||||
|
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||||
|
console.log('⏭️ [ContentPanel] Skipping save - missing requirements:', {
|
||||||
|
hasAccount: !!activeAccount,
|
||||||
|
hasRelayPool: !!relayPool,
|
||||||
|
hasEventStore: !!eventStore,
|
||||||
|
hasIdentifier: !!articleIdentifier
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!settings?.syncReadingPosition) {
|
||||||
|
console.log('⏭️ [ContentPanel] Sync disabled in settings')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('💾 [ContentPanel] Saving position:', Math.round(position * 100) + '%', 'for article:', selectedUrl?.slice(0, 50))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const factory = new EventFactory({ signer: activeAccount })
|
||||||
|
await saveReadingPosition(
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
factory,
|
||||||
|
articleIdentifier,
|
||||||
|
{
|
||||||
|
position,
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
scrollTop: window.pageYOffset || document.documentElement.scrollTop
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [ContentPanel] Failed to save reading position:', error)
|
||||||
|
}
|
||||||
|
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||||
|
|
||||||
|
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
|
||||||
enabled: isTextContent,
|
enabled: isTextContent,
|
||||||
|
syncEnabled: settings?.syncReadingPosition,
|
||||||
|
onSave: handleSavePosition,
|
||||||
onReadingComplete: () => {
|
onReadingComplete: () => {
|
||||||
// Optional: Auto-mark as read when reading is complete
|
// Optional: Auto-mark as read when reading is complete
|
||||||
if (activeAccount && !isMarkedAsRead) {
|
if (activeAccount && !isMarkedAsRead) {
|
||||||
@@ -141,6 +196,73 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Load saved reading position when article loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||||
|
console.log('⏭️ [ContentPanel] Skipping position restore - missing requirements:', {
|
||||||
|
isTextContent,
|
||||||
|
hasAccount: !!activeAccount,
|
||||||
|
hasRelayPool: !!relayPool,
|
||||||
|
hasEventStore: !!eventStore,
|
||||||
|
hasIdentifier: !!articleIdentifier
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!settings?.syncReadingPosition) {
|
||||||
|
console.log('⏭️ [ContentPanel] Sync disabled - not restoring position')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📖 [ContentPanel] Loading position for article:', selectedUrl?.slice(0, 50))
|
||||||
|
|
||||||
|
const loadPosition = async () => {
|
||||||
|
try {
|
||||||
|
const savedPosition = await loadReadingPosition(
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
activeAccount.pubkey,
|
||||||
|
articleIdentifier
|
||||||
|
)
|
||||||
|
|
||||||
|
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
||||||
|
console.log('🎯 [ContentPanel] Restoring position:', Math.round(savedPosition.position * 100) + '%')
|
||||||
|
// Wait for content to be fully rendered before scrolling
|
||||||
|
setTimeout(() => {
|
||||||
|
const documentHeight = document.documentElement.scrollHeight
|
||||||
|
const windowHeight = window.innerHeight
|
||||||
|
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: scrollTop,
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
|
||||||
|
}, 500) // Give content time to render
|
||||||
|
} else if (savedPosition) {
|
||||||
|
if (savedPosition.position === 1) {
|
||||||
|
console.log('✅ [ContentPanel] Article completed (100%), starting from top')
|
||||||
|
} else {
|
||||||
|
console.log('⏭️ [ContentPanel] Position too early (<5%):', Math.round(savedPosition.position * 100) + '%')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [ContentPanel] Failed to load reading position:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPosition()
|
||||||
|
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||||
|
|
||||||
|
// Save position before unmounting or changing article
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveNow) {
|
||||||
|
saveNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [saveNow, selectedUrl])
|
||||||
|
|
||||||
// Close menu when clicking outside
|
// Close menu when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
|||||||
@@ -237,35 +237,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
return `/a/${naddr}`
|
return `/a/${naddr}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleHighlightClick = (highlightId: string) => {
|
|
||||||
const highlight = highlights.find(h => h.id === highlightId)
|
|
||||||
if (!highlight) return
|
|
||||||
|
|
||||||
// For nostr-native articles
|
|
||||||
if (highlight.eventReference) {
|
|
||||||
// Convert eventReference to naddr
|
|
||||||
if (highlight.eventReference.includes(':')) {
|
|
||||||
const parts = highlight.eventReference.split(':')
|
|
||||||
const kind = parseInt(parts[0])
|
|
||||||
const pubkey = parts[1]
|
|
||||||
const identifier = parts[2] || ''
|
|
||||||
|
|
||||||
const naddr = nip19.naddrEncode({
|
|
||||||
kind,
|
|
||||||
pubkey,
|
|
||||||
identifier
|
|
||||||
})
|
|
||||||
navigate(`/a/${naddr}`, { state: { highlightId, openHighlights: true } })
|
|
||||||
} else {
|
|
||||||
// Already an naddr
|
|
||||||
navigate(`/a/${highlight.eventReference}`, { state: { highlightId, openHighlights: true } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// For web URLs
|
|
||||||
else if (highlight.urlReference) {
|
|
||||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, { state: { highlightId, openHighlights: true } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Classify highlights with levels based on user context and apply visibility filters
|
// Classify highlights with levels based on user context and apply visibility filters
|
||||||
const classifiedHighlights = useMemo(() => {
|
const classifiedHighlights = useMemo(() => {
|
||||||
@@ -357,7 +328,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
key={highlight.id}
|
key={highlight.id}
|
||||||
highlight={highlight}
|
highlight={highlight}
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onHighlightClick={handleHighlightClick}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { createDeletionRequest } from '../services/deletionService'
|
|||||||
import { getNostrUrl } from '../config/nostrGateways'
|
import { getNostrUrl } from '../config/nostrGateways'
|
||||||
import CompactButton from './CompactButton'
|
import CompactButton from './CompactButton'
|
||||||
import { HighlightCitation } from './HighlightCitation'
|
import { HighlightCitation } from './HighlightCitation'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
// Helper to detect if a URL is an image
|
// Helper to detect if a URL is an image
|
||||||
const isImageUrl = (url: string): boolean => {
|
const isImageUrl = (url: string): boolean => {
|
||||||
@@ -206,6 +207,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
const [showMenu, setShowMenu] = useState(false)
|
const [showMenu, setShowMenu] = useState(false)
|
||||||
|
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
// Resolve the profile of the user who made the highlight
|
// Resolve the profile of the user who made the highlight
|
||||||
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
||||||
@@ -274,8 +276,34 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}, [showMenu, showDeleteConfirm])
|
}, [showMenu, showDeleteConfirm])
|
||||||
|
|
||||||
const handleItemClick = () => {
|
const handleItemClick = () => {
|
||||||
|
// If onHighlightClick is provided, use it (legacy behavior)
|
||||||
if (onHighlightClick) {
|
if (onHighlightClick) {
|
||||||
onHighlightClick(highlight.id)
|
onHighlightClick(highlight.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, navigate to the article that this highlight references
|
||||||
|
if (highlight.eventReference) {
|
||||||
|
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
|
||||||
|
const parts = highlight.eventReference.split(':')
|
||||||
|
|
||||||
|
// If it's an article coordinate (3 parts) and kind is 30023, navigate to it
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const [kind, pubkey, identifier] = parts
|
||||||
|
|
||||||
|
if (kind === '30023') {
|
||||||
|
// Encode as naddr and navigate
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey,
|
||||||
|
identifier
|
||||||
|
})
|
||||||
|
navigate(`/a/${naddr}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (highlight.urlReference) {
|
||||||
|
// Navigate to external URL
|
||||||
|
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,7 +501,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
||||||
data-highlight-id={highlight.id}
|
data-highlight-id={highlight.id}
|
||||||
onClick={handleItemClick}
|
onClick={handleItemClick}
|
||||||
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
style={{ cursor: (onHighlightClick || highlight.eventReference || highlight.urlReference) ? 'pointer' : 'default' }}
|
||||||
>
|
>
|
||||||
<div className="highlight-header">
|
<div className="highlight-header">
|
||||||
<CompactButton
|
<CompactButton
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import { faBooks } from '../icons/customIcons'
|
|||||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
||||||
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
|
import { generateArticleIdentifier, loadReadingPosition } from '../services/readingPositionService'
|
||||||
|
import ArchiveFilters, { ArchiveFilterType } from './ArchiveFilters'
|
||||||
|
|
||||||
interface MeProps {
|
interface MeProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -35,6 +39,7 @@ type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
|
|||||||
|
|
||||||
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const eventStore = Hooks.useEventStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||||
|
|
||||||
@@ -48,6 +53,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
|
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
||||||
|
const [archiveFilter, setArchiveFilter] = useState<ArchiveFilterType>('all')
|
||||||
|
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
// Update local state when prop changes
|
// Update local state when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -119,6 +127,65 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
loadData()
|
loadData()
|
||||||
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
||||||
|
|
||||||
|
// Load reading positions for read articles (only for own profile)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPositions = async () => {
|
||||||
|
if (!isOwnProfile || !activeAccount || !relayPool || !eventStore || readArticles.length === 0) {
|
||||||
|
console.log('🔍 [Archive] Skipping position load:', {
|
||||||
|
isOwnProfile,
|
||||||
|
hasAccount: !!activeAccount,
|
||||||
|
hasRelayPool: !!relayPool,
|
||||||
|
hasEventStore: !!eventStore,
|
||||||
|
articlesCount: readArticles.length
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📊 [Archive] Loading reading positions for', readArticles.length, 'articles')
|
||||||
|
|
||||||
|
const positions = new Map<string, number>()
|
||||||
|
|
||||||
|
// Load positions for all read articles
|
||||||
|
await Promise.all(
|
||||||
|
readArticles.map(async (post) => {
|
||||||
|
try {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: post.author,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
const articleUrl = `nostr:${naddr}`
|
||||||
|
const identifier = generateArticleIdentifier(articleUrl)
|
||||||
|
|
||||||
|
console.log('🔍 [Archive] Loading position for:', post.title?.slice(0, 50), 'identifier:', identifier.slice(0, 32))
|
||||||
|
|
||||||
|
const savedPosition = await loadReadingPosition(
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
activeAccount.pubkey,
|
||||||
|
identifier
|
||||||
|
)
|
||||||
|
|
||||||
|
if (savedPosition && savedPosition.position > 0) {
|
||||||
|
console.log('✅ [Archive] Found position:', Math.round(savedPosition.position * 100) + '%', 'for', post.title?.slice(0, 50))
|
||||||
|
positions.set(post.event.id, savedPosition.position)
|
||||||
|
} else {
|
||||||
|
console.log('❌ [Archive] No position found for:', post.title?.slice(0, 50))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ [Archive] Failed to load reading position for article:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('📊 [Archive] Loaded positions for', positions.size, '/', readArticles.length, 'articles')
|
||||||
|
setReadingPositions(positions)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPositions()
|
||||||
|
}, [readArticles, isOwnProfile, activeAccount, relayPool, eventStore])
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Pull-to-refresh
|
||||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
onRefresh: () => {
|
onRefresh: () => {
|
||||||
@@ -172,12 +239,40 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
// Merge and flatten all individual bookmarks
|
// Merge and flatten all individual bookmarks
|
||||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
.filter(hasContent)
|
.filter(hasContent)
|
||||||
const groups = groupIndividualBookmarks(allIndividualBookmarks)
|
|
||||||
|
// Apply bookmark filter
|
||||||
|
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter)
|
||||||
|
|
||||||
|
const groups = groupIndividualBookmarks(filteredBookmarks)
|
||||||
|
|
||||||
|
// Apply archive filter
|
||||||
|
const filteredReadArticles = readArticles.filter(post => {
|
||||||
|
const position = readingPositions.get(post.event.id)
|
||||||
|
|
||||||
|
switch (archiveFilter) {
|
||||||
|
case 'to-read':
|
||||||
|
// No position or 0% progress
|
||||||
|
return !position || position === 0
|
||||||
|
case 'reading':
|
||||||
|
// Has some progress but not completed (0 < position < 1)
|
||||||
|
return position !== undefined && position > 0 && position < 0.95
|
||||||
|
case 'completed':
|
||||||
|
// 95% or more read (we consider 95%+ as completed)
|
||||||
|
return position !== undefined && position >= 0.95
|
||||||
|
case 'marked':
|
||||||
|
// Manually marked as read (in archive but no reading position data)
|
||||||
|
// These are articles that were marked via the emoji reaction
|
||||||
|
return !position || position === 0
|
||||||
|
case 'all':
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||||
{ key: 'private', title: 'Private bookmarks', items: groups.privateItems },
|
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
||||||
{ key: 'public', title: 'Public bookmarks', items: groups.publicItems },
|
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
||||||
{ key: 'web', title: 'Web bookmarks', items: groups.web },
|
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
|
||||||
{ key: 'amethyst', title: 'Old Bookmarks (Legacy)', items: groups.amethyst }
|
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
|
||||||
]
|
]
|
||||||
|
|
||||||
// Show content progressively - no blocking error screens
|
// Show content progressively - no blocking error screens
|
||||||
@@ -231,7 +326,18 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bookmarks-list">
|
<div className="bookmarks-list">
|
||||||
{sections.filter(s => s.items.length > 0).map(section => (
|
{allIndividualBookmarks.length > 0 && (
|
||||||
|
<BookmarkFilters
|
||||||
|
selectedFilter={bookmarkFilter}
|
||||||
|
onFilterChange={setBookmarkFilter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filteredBookmarks.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No bookmarks match this filter.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sections.filter(s => s.items.length > 0).map(section => (
|
||||||
<div key={section.key} className="bookmarks-section">
|
<div key={section.key} className="bookmarks-section">
|
||||||
<h3 className="bookmarks-section-title">{section.title}</h3>
|
<h3 className="bookmarks-section-title">{section.title}</h3>
|
||||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||||
@@ -246,7 +352,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)))}
|
||||||
<div className="view-mode-controls" style={{
|
<div className="view-mode-controls" style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -295,15 +401,30 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<>
|
||||||
{readArticles.map((post) => (
|
{readArticles.length > 0 && (
|
||||||
<BlogPostCard
|
<ArchiveFilters
|
||||||
key={post.event.id}
|
selectedFilter={archiveFilter}
|
||||||
post={post}
|
onFilterChange={setArchiveFilter}
|
||||||
href={getPostUrl(post)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</div>
|
{filteredReadArticles.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No articles match this filter.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{filteredReadArticles.map((post) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={post.event.id}
|
||||||
|
post={post}
|
||||||
|
href={getPostUrl(post)}
|
||||||
|
readingProgress={readingPositions.get(post.event.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'writings':
|
case 'writings':
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
useLocalRelayAsCache: true,
|
useLocalRelayAsCache: true,
|
||||||
rebroadcastToAllRelays: false,
|
rebroadcastToAllRelays: false,
|
||||||
paragraphAlignment: 'justify',
|
paragraphAlignment: 'justify',
|
||||||
|
syncReadingPosition: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
|
|||||||
@@ -104,6 +104,19 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
|||||||
<span>Auto-collapse sidebar on small screens</span>
|
<span>Auto-collapse sidebar on small screens</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="syncReadingPosition" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="syncReadingPosition"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.syncReadingPosition ?? false}
|
||||||
|
onChange={(e) => onUpdate({ syncReadingPosition: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Sync reading position across devices</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,10 +56,6 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
|||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!isInstallable && !isInstalled) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3 className="section-title">App & Airplane Mode</h3>
|
<h3 className="section-title">App & Airplane Mode</h3>
|
||||||
@@ -190,7 +186,7 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
|||||||
onClick={handleInstall}
|
onClick={handleInstall}
|
||||||
className="zap-preset-btn"
|
className="zap-preset-btn"
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||||
disabled={isInstalled}
|
disabled={isInstalled || !isInstallable}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={isInstalled ? faCheckCircle : faDownload} />
|
<FontAwesomeIcon icon={isInstalled ? faCheckCircle : faDownload} />
|
||||||
{isInstalled ? 'Installed' : 'Install App'}
|
{isInstalled ? 'Installed' : 'Install App'}
|
||||||
|
|||||||
@@ -81,10 +81,9 @@ const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="setting-group">
|
<div className="setting-group">
|
||||||
<label className="setting-label">Your Share</label>
|
|
||||||
<div className="zap-split-container">
|
<div className="zap-split-container">
|
||||||
<div className="zap-split-labels">
|
<div className="zap-split-labels">
|
||||||
<span className="zap-split-label">Weight: {highlighterWeight}</span>
|
<span className="zap-split-label">Your Share: {highlighterWeight}</span>
|
||||||
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
|
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -103,10 +102,9 @@ const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="setting-group">
|
<div className="setting-group">
|
||||||
<label className="setting-label">Author(s) Share</label>
|
|
||||||
<div className="zap-split-container">
|
<div className="zap-split-container">
|
||||||
<div className="zap-split-labels">
|
<div className="zap-split-labels">
|
||||||
<span className="zap-split-label">Weight: {authorWeight}</span>
|
<span className="zap-split-label">Author's Share: {authorWeight}</span>
|
||||||
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
|
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -125,10 +123,9 @@ const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="setting-group">
|
<div className="setting-group">
|
||||||
<label className="setting-label">Support Boris</label>
|
|
||||||
<div className="zap-split-container">
|
<div className="zap-split-container">
|
||||||
<div className="zap-split-labels">
|
<div className="zap-split-labels">
|
||||||
<span className="zap-split-label">Weight: {borisWeight.toFixed(1)}</span>
|
<span className="zap-split-label">Boris' Share: {borisWeight.toFixed(1)}</span>
|
||||||
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
|
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface UseBookmarksDataParams {
|
|||||||
activeAccount: IAccount | undefined
|
activeAccount: IAccount | undefined
|
||||||
accountManager: AccountManager
|
accountManager: AccountManager
|
||||||
naddr?: string
|
naddr?: string
|
||||||
|
externalUrl?: string
|
||||||
currentArticleCoordinate?: string
|
currentArticleCoordinate?: string
|
||||||
currentArticleEventId?: string
|
currentArticleEventId?: string
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
@@ -23,6 +24,7 @@ export const useBookmarksData = ({
|
|||||||
activeAccount,
|
activeAccount,
|
||||||
accountManager,
|
accountManager,
|
||||||
naddr,
|
naddr,
|
||||||
|
externalUrl,
|
||||||
currentArticleCoordinate,
|
currentArticleCoordinate,
|
||||||
currentArticleEventId,
|
currentArticleEventId,
|
||||||
settings
|
settings
|
||||||
@@ -115,11 +117,13 @@ export const useBookmarksData = ({
|
|||||||
// Fetch highlights/contacts independently to avoid disturbing bookmarks
|
// Fetch highlights/contacts independently to avoid disturbing bookmarks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!relayPool || !activeAccount) return
|
if (!relayPool || !activeAccount) return
|
||||||
if (!naddr) {
|
// Only fetch general highlights when not viewing an article (naddr) or external URL
|
||||||
|
// External URLs have their highlights fetched by useExternalUrlLoader
|
||||||
|
if (!naddr && !externalUrl) {
|
||||||
handleFetchHighlights()
|
handleFetchHighlights()
|
||||||
}
|
}
|
||||||
handleFetchContacts()
|
handleFetchContacts()
|
||||||
}, [relayPool, activeAccount, naddr, handleFetchHighlights, handleFetchContacts])
|
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookmarks,
|
bookmarks,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function useExternalUrlLoader({
|
|||||||
// Check if fetchHighlightsForUrl exists, otherwise skip
|
// Check if fetchHighlightsForUrl exists, otherwise skip
|
||||||
if (typeof fetchHighlightsForUrl === 'function') {
|
if (typeof fetchHighlightsForUrl === 'function') {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const highlightsList = await fetchHighlightsForUrl(
|
await fetchHighlightsForUrl(
|
||||||
relayPool,
|
relayPool,
|
||||||
url,
|
url,
|
||||||
(highlight) => {
|
(highlight) => {
|
||||||
@@ -84,9 +84,9 @@ export function useExternalUrlLoader({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
// Ensure final list is sorted and contains all items
|
// Highlights are already set via the streaming callback
|
||||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
// No need to set them again as that could cause a flash/disappearance
|
||||||
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
|
console.log(`📌 Finished fetching highlights for URL`)
|
||||||
} else {
|
} else {
|
||||||
console.log('📌 Highlight fetching for URLs not yet implemented')
|
console.log('📌 Highlight fetching for URLs not yet implemented')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ interface BeforeInstallPromptEvent extends Event {
|
|||||||
|
|
||||||
export function usePWAInstall() {
|
export function usePWAInstall() {
|
||||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
|
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
|
||||||
// TODO: Remove this - temporarily always showing for testing/styling
|
const [isInstallable, setIsInstallable] = useState(false)
|
||||||
const [isInstallable, setIsInstallable] = useState(true)
|
|
||||||
const [isInstalled, setIsInstalled] = useState(false)
|
const [isInstalled, setIsInstalled] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,21 +1,72 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
|
||||||
interface UseReadingPositionOptions {
|
interface UseReadingPositionOptions {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
onPositionChange?: (position: number) => void
|
onPositionChange?: (position: number) => void
|
||||||
onReadingComplete?: () => void
|
onReadingComplete?: () => void
|
||||||
readingCompleteThreshold?: number // Default 0.9 (90%)
|
readingCompleteThreshold?: number // Default 0.9 (90%)
|
||||||
|
syncEnabled?: boolean // Whether to sync positions to Nostr
|
||||||
|
onSave?: (position: number) => void // Callback for saving position
|
||||||
|
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useReadingPosition = ({
|
export const useReadingPosition = ({
|
||||||
enabled = true,
|
enabled = true,
|
||||||
onPositionChange,
|
onPositionChange,
|
||||||
onReadingComplete,
|
onReadingComplete,
|
||||||
readingCompleteThreshold = 0.9
|
readingCompleteThreshold = 0.9,
|
||||||
|
syncEnabled = false,
|
||||||
|
onSave,
|
||||||
|
autoSaveInterval = 5000
|
||||||
}: UseReadingPositionOptions = {}) => {
|
}: UseReadingPositionOptions = {}) => {
|
||||||
const [position, setPosition] = useState(0)
|
const [position, setPosition] = useState(0)
|
||||||
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||||
const hasTriggeredComplete = useRef(false)
|
const hasTriggeredComplete = useRef(false)
|
||||||
|
const lastSavedPosition = useRef(0)
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
// Debounced save function
|
||||||
|
const scheduleSave = useCallback((currentPosition: number) => {
|
||||||
|
if (!syncEnabled || !onSave) return
|
||||||
|
|
||||||
|
// Don't save if position is too low (< 5%)
|
||||||
|
if (currentPosition < 0.05) return
|
||||||
|
|
||||||
|
// Don't save if position hasn't changed significantly (less than 1%)
|
||||||
|
// But always save if we've reached 100% (completion)
|
||||||
|
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
|
||||||
|
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
|
||||||
|
|
||||||
|
if (!hasSignificantChange && !hasReachedCompletion) return
|
||||||
|
|
||||||
|
// Clear existing timer
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule new save
|
||||||
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
lastSavedPosition.current = currentPosition
|
||||||
|
onSave(currentPosition)
|
||||||
|
}, autoSaveInterval)
|
||||||
|
}, [syncEnabled, onSave, autoSaveInterval])
|
||||||
|
|
||||||
|
// Immediate save function
|
||||||
|
const saveNow = useCallback(() => {
|
||||||
|
if (!syncEnabled || !onSave) return
|
||||||
|
|
||||||
|
// Cancel any pending saves
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current)
|
||||||
|
saveTimerRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save if position is meaningful (>= 5%)
|
||||||
|
if (position >= 0.05) {
|
||||||
|
lastSavedPosition.current = position
|
||||||
|
onSave(position)
|
||||||
|
}
|
||||||
|
}, [syncEnabled, onSave, position])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return
|
if (!enabled) return
|
||||||
@@ -30,12 +81,20 @@ export const useReadingPosition = ({
|
|||||||
const documentHeight = document.documentElement.scrollHeight
|
const documentHeight = document.documentElement.scrollHeight
|
||||||
|
|
||||||
// Calculate position based on how much of the content has been scrolled through
|
// Calculate position based on how much of the content has been scrolled through
|
||||||
const scrollProgress = Math.min(scrollTop / (documentHeight - windowHeight), 1)
|
// Add a small threshold (5px) to account for rounding and make it easier to reach 100%
|
||||||
const clampedProgress = Math.max(0, Math.min(1, scrollProgress))
|
const maxScroll = documentHeight - windowHeight
|
||||||
|
const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0
|
||||||
|
|
||||||
|
// If we're within 5px of the bottom, consider it 100%
|
||||||
|
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
|
||||||
|
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
||||||
|
|
||||||
setPosition(clampedProgress)
|
setPosition(clampedProgress)
|
||||||
onPositionChange?.(clampedProgress)
|
onPositionChange?.(clampedProgress)
|
||||||
|
|
||||||
|
// Schedule auto-save if sync is enabled
|
||||||
|
scheduleSave(clampedProgress)
|
||||||
|
|
||||||
// Check if reading is complete
|
// Check if reading is complete
|
||||||
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
||||||
setIsReadingComplete(true)
|
setIsReadingComplete(true)
|
||||||
@@ -54,8 +113,13 @@ export const useReadingPosition = ({
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('scroll', handleScroll)
|
window.removeEventListener('scroll', handleScroll)
|
||||||
window.removeEventListener('resize', handleScroll)
|
window.removeEventListener('resize', handleScroll)
|
||||||
|
|
||||||
|
// Clear save timer on unmount
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold])
|
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
|
||||||
|
|
||||||
// Reset reading complete state when enabled changes
|
// Reset reading complete state when enabled changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,6 +132,7 @@ export const useReadingPosition = ({
|
|||||||
return {
|
return {
|
||||||
position,
|
position,
|
||||||
isReadingComplete,
|
isReadingComplete,
|
||||||
progressPercentage: Math.round(position * 100)
|
progressPercentage: Math.round(position * 100),
|
||||||
|
saveNow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ export const fetchHighlightsForUrl = async (
|
|||||||
onHighlight?: (highlight: Highlight) => void,
|
onHighlight?: (highlight: Highlight) => void,
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
): Promise<Highlight[]> => {
|
): Promise<Highlight[]> => {
|
||||||
|
const seenIds = new Set<string>()
|
||||||
|
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
|
||||||
|
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const seenIds = new Set<string>()
|
|
||||||
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
|
|
||||||
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
|
|
||||||
const local$ = localRelaysUrl.length > 0
|
const local$ = localRelaysUrl.length > 0
|
||||||
? relayPool
|
? relayPool
|
||||||
.req(localRelaysUrl, { kinds: [9802], '#r': [url] })
|
.req(localRelaysUrl, { kinds: [9802], '#r': [url] })
|
||||||
@@ -45,11 +46,23 @@ export const fetchHighlightsForUrl = async (
|
|||||||
)
|
)
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
|
||||||
|
console.log(`📌 Fetched ${rawEvents.length} highlight events for URL:`, url)
|
||||||
|
|
||||||
|
// Rebroadcast events - but don't let errors here break the highlight display
|
||||||
|
try {
|
||||||
|
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to rebroadcast highlight events:', err)
|
||||||
|
}
|
||||||
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
||||||
return sortHighlights(highlights)
|
return sortHighlights(highlights)
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Error fetching highlights for URL:', err)
|
||||||
|
// Return highlights that were already streamed via callback
|
||||||
|
// Don't return empty array as that would clear already-displayed highlights
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
196
src/services/readingPositionService.ts
Normal file
196
src/services/readingPositionService.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { IEventStore, mapEventsToStore } from 'applesauce-core'
|
||||||
|
import { EventFactory } from 'applesauce-factory'
|
||||||
|
import { RelayPool, onlyEvents } from 'applesauce-relay'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { firstValueFrom } from 'rxjs'
|
||||||
|
import { publishEvent } from './writeService'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
|
||||||
|
const APP_DATA_KIND = 30078 // NIP-78 Application Data
|
||||||
|
const READING_POSITION_PREFIX = 'boris:reading-position:'
|
||||||
|
|
||||||
|
export interface ReadingPosition {
|
||||||
|
position: number // 0-1 scroll progress
|
||||||
|
timestamp: number // Unix timestamp
|
||||||
|
scrollTop?: number // Optional: pixel position
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract and parse reading position from an event
|
||||||
|
function getReadingPositionContent(event: NostrEvent): ReadingPosition | undefined {
|
||||||
|
if (!event.content || event.content.length === 0) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.parse(event.content) as ReadingPosition
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique identifier for an article
|
||||||
|
* For Nostr articles: use the naddr directly
|
||||||
|
* For external URLs: use base64url encoding of the URL
|
||||||
|
*/
|
||||||
|
export function generateArticleIdentifier(naddrOrUrl: string): string {
|
||||||
|
// If it starts with "nostr:", extract the naddr
|
||||||
|
if (naddrOrUrl.startsWith('nostr:')) {
|
||||||
|
return naddrOrUrl.replace('nostr:', '')
|
||||||
|
}
|
||||||
|
// For URLs, use base64url encoding (URL-safe)
|
||||||
|
return btoa(naddrOrUrl)
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save reading position to Nostr (Kind 30078)
|
||||||
|
*/
|
||||||
|
export async function saveReadingPosition(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
eventStore: IEventStore,
|
||||||
|
factory: EventFactory,
|
||||||
|
articleIdentifier: string,
|
||||||
|
position: ReadingPosition
|
||||||
|
): Promise<void> {
|
||||||
|
console.log('💾 [ReadingPosition] Saving position:', {
|
||||||
|
identifier: articleIdentifier.slice(0, 32) + '...',
|
||||||
|
position: position.position,
|
||||||
|
positionPercent: Math.round(position.position * 100) + '%',
|
||||||
|
timestamp: position.timestamp,
|
||||||
|
scrollTop: position.scrollTop
|
||||||
|
})
|
||||||
|
|
||||||
|
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
|
||||||
|
|
||||||
|
const draft = await factory.create(async () => ({
|
||||||
|
kind: APP_DATA_KIND,
|
||||||
|
content: JSON.stringify(position),
|
||||||
|
tags: [
|
||||||
|
['d', dTag],
|
||||||
|
['client', 'boris']
|
||||||
|
],
|
||||||
|
created_at: Math.floor(Date.now() / 1000)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const signed = await factory.sign(draft)
|
||||||
|
|
||||||
|
// Use unified write service
|
||||||
|
await publishEvent(relayPool, eventStore, signed)
|
||||||
|
|
||||||
|
console.log('✅ [ReadingPosition] Position saved successfully, event ID:', signed.id.slice(0, 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load reading position from Nostr
|
||||||
|
*/
|
||||||
|
export async function loadReadingPosition(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
eventStore: IEventStore,
|
||||||
|
pubkey: string,
|
||||||
|
articleIdentifier: string
|
||||||
|
): Promise<ReadingPosition | null> {
|
||||||
|
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
|
||||||
|
|
||||||
|
console.log('📖 [ReadingPosition] Loading position:', {
|
||||||
|
pubkey: pubkey.slice(0, 8) + '...',
|
||||||
|
identifier: articleIdentifier.slice(0, 32) + '...',
|
||||||
|
dTag: dTag.slice(0, 50) + '...'
|
||||||
|
})
|
||||||
|
|
||||||
|
// First, check if we already have the position in the local event store
|
||||||
|
try {
|
||||||
|
const localEvent = await firstValueFrom(
|
||||||
|
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
|
||||||
|
)
|
||||||
|
if (localEvent) {
|
||||||
|
const content = getReadingPositionContent(localEvent)
|
||||||
|
if (content) {
|
||||||
|
console.log('✅ [ReadingPosition] Loaded from local store:', {
|
||||||
|
position: content.position,
|
||||||
|
positionPercent: Math.round(content.position * 100) + '%',
|
||||||
|
timestamp: content.timestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
// Still fetch from relays in the background to get any updates
|
||||||
|
relayPool
|
||||||
|
.subscription(RELAYS, {
|
||||||
|
kinds: [APP_DATA_KIND],
|
||||||
|
authors: [pubkey],
|
||||||
|
'#d': [dTag]
|
||||||
|
})
|
||||||
|
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||||
|
.subscribe()
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('📭 No cached reading position found, fetching from relays...')
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not in local store, fetch from relays
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let hasResolved = false
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (!hasResolved) {
|
||||||
|
console.log('⏱️ Reading position load timeout - no position found')
|
||||||
|
hasResolved = true
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}, 3000) // Shorter timeout for reading positions
|
||||||
|
|
||||||
|
const sub = relayPool
|
||||||
|
.subscription(RELAYS, {
|
||||||
|
kinds: [APP_DATA_KIND],
|
||||||
|
authors: [pubkey],
|
||||||
|
'#d': [dTag]
|
||||||
|
})
|
||||||
|
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||||
|
.subscribe({
|
||||||
|
complete: async () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
if (!hasResolved) {
|
||||||
|
hasResolved = true
|
||||||
|
try {
|
||||||
|
const event = await firstValueFrom(
|
||||||
|
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
|
||||||
|
)
|
||||||
|
if (event) {
|
||||||
|
const content = getReadingPositionContent(event)
|
||||||
|
if (content) {
|
||||||
|
console.log('✅ [ReadingPosition] Loaded from relays:', {
|
||||||
|
position: content.position,
|
||||||
|
positionPercent: Math.round(content.position * 100) + '%',
|
||||||
|
timestamp: content.timestamp
|
||||||
|
})
|
||||||
|
resolve(content)
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ [ReadingPosition] Event found but no valid content')
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('📭 [ReadingPosition] No position found on relays')
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error loading reading position:', err)
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('❌ Reading position subscription error:', err)
|
||||||
|
clearTimeout(timeout)
|
||||||
|
if (!hasResolved) {
|
||||||
|
hasResolved = true
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
sub.unsubscribe()
|
||||||
|
}, 3000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -54,6 +54,8 @@ export interface UserSettings {
|
|||||||
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
|
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
|
||||||
// Reading settings
|
// Reading settings
|
||||||
paragraphAlignment?: 'left' | 'justify' // default: justify
|
paragraphAlignment?: 'left' | 'justify' // default: justify
|
||||||
|
// Reading position sync
|
||||||
|
syncReadingPosition?: boolean // default: false (opt-in)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSettings(
|
export async function loadSettings(
|
||||||
|
|||||||
@@ -67,6 +67,10 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.me-tab-content:has(.bookmark-filters) {
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Align highlight list width with profile card width on /me */
|
/* Align highlight list width with profile card width on /me */
|
||||||
.me-highlights-list { padding-left: 0; padding-right: 0; }
|
.me-highlights-list { padding-left: 0; padding-right: 0; }
|
||||||
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
|
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
|
||||||
@@ -79,6 +83,15 @@
|
|||||||
text-align: left; /* Override center alignment from .app */
|
text-align: left; /* Override center alignment from .app */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bookmark filters in Me page */
|
||||||
|
.me-tab-content .bookmark-filters {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Ensure all reading list elements are left-aligned */
|
/* Ensure all reading list elements are left-aligned */
|
||||||
.bookmarks-list .individual-bookmark,
|
.bookmarks-list .individual-bookmark,
|
||||||
.bookmarks-list .individual-bookmark * {
|
.bookmarks-list .individual-bookmark * {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
/* Zap splits preset buttons */
|
/* Zap splits preset buttons */
|
||||||
.zap-preset-buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
.zap-preset-buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
.zap-preset-btn {
|
.zap-preset-btn {
|
||||||
|
flex: 1;
|
||||||
padding: 0.625rem 1.25rem;
|
padding: 0.625rem 1.25rem;
|
||||||
background: var(--color-bg-elevated);
|
background: var(--color-bg-elevated);
|
||||||
border: 1px solid var(--color-border-subtle);
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
|||||||
@@ -176,3 +176,38 @@
|
|||||||
.read-inline-btn { background: rgb(34 197 94); /* green-500 */ color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; }
|
.read-inline-btn { background: rgb(34 197 94); /* green-500 */ color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; }
|
||||||
.read-inline-btn:hover { background: rgb(22 163 74); /* green-600 */ }
|
.read-inline-btn:hover { background: rgb(22 163 74); /* green-600 */ }
|
||||||
|
|
||||||
|
/* Bookmark filters */
|
||||||
|
.bookmark-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-filters .filter-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border: none;
|
||||||
|
padding: 0.375rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-filters .filter-btn:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-filters .filter-btn.active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
42
src/utils/bookmarkTypeClassifier.ts
Normal file
42
src/utils/bookmarkTypeClassifier.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { IndividualBookmark } from '../types/bookmarks'
|
||||||
|
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||||
|
import { classifyUrl } from './helpers'
|
||||||
|
|
||||||
|
export type BookmarkType = 'article' | 'external' | 'video' | 'note' | 'web'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies a bookmark into one of the content types
|
||||||
|
*/
|
||||||
|
export function classifyBookmarkType(bookmark: IndividualBookmark): BookmarkType {
|
||||||
|
// Kind 30023 is always a nostr-native article
|
||||||
|
if (bookmark.kind === 30023) return 'article'
|
||||||
|
|
||||||
|
const isWebBookmark = bookmark.kind === 39701
|
||||||
|
const webBookmarkUrl = isWebBookmark ? bookmark.tags.find(t => t[0] === 'd')?.[1] : null
|
||||||
|
|
||||||
|
const extractedUrls = webBookmarkUrl
|
||||||
|
? [webBookmarkUrl.startsWith('http') ? webBookmarkUrl : `https://${webBookmarkUrl}`]
|
||||||
|
: extractUrlsFromContent(bookmark.content)
|
||||||
|
|
||||||
|
const firstUrl = extractedUrls[0]
|
||||||
|
if (!firstUrl) return 'note'
|
||||||
|
|
||||||
|
const urlType = classifyUrl(firstUrl)?.type
|
||||||
|
|
||||||
|
if (urlType === 'youtube' || urlType === 'video') return 'video'
|
||||||
|
if (urlType === 'article') return 'external' // External article links
|
||||||
|
|
||||||
|
return 'web'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters bookmarks by type
|
||||||
|
*/
|
||||||
|
export function filterBookmarksByType(
|
||||||
|
bookmarks: IndividualBookmark[],
|
||||||
|
filterType: 'all' | BookmarkType
|
||||||
|
): IndividualBookmark[] {
|
||||||
|
if (filterType === 'all') return bookmarks
|
||||||
|
return bookmarks.filter(bookmark => classifyBookmarkType(bookmark) === filterType)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -92,10 +92,12 @@ export const sortIndividualBookmarks = (items: IndividualBookmark[]) => {
|
|||||||
|
|
||||||
export function groupIndividualBookmarks(items: IndividualBookmark[]) {
|
export function groupIndividualBookmarks(items: IndividualBookmark[]) {
|
||||||
const sorted = sortIndividualBookmarks(items)
|
const sorted = sortIndividualBookmarks(items)
|
||||||
const amethyst = sorted.filter(i => i.sourceKind === 30001)
|
|
||||||
const web = sorted.filter(i => i.kind === 39701 || i.type === 'web')
|
const web = sorted.filter(i => i.kind === 39701 || i.type === 'web')
|
||||||
|
// Only non-encrypted legacy bookmarks go to the amethyst section
|
||||||
|
const amethyst = sorted.filter(i => i.sourceKind === 30001 && !i.isPrivate)
|
||||||
const isIn = (list: IndividualBookmark[], x: IndividualBookmark) => list.some(i => i.id === x.id)
|
const isIn = (list: IndividualBookmark[], x: IndividualBookmark) => list.some(i => i.id === x.id)
|
||||||
const privateItems = sorted.filter(i => i.isPrivate && !isIn(amethyst, i) && !isIn(web, i))
|
// Private items include encrypted legacy bookmarks
|
||||||
|
const privateItems = sorted.filter(i => i.isPrivate && !isIn(web, i))
|
||||||
const publicItems = sorted.filter(i => !i.isPrivate && !isIn(amethyst, i) && !isIn(web, i))
|
const publicItems = sorted.filter(i => !i.isPrivate && !isIn(amethyst, i) && !isIn(web, i))
|
||||||
return { privateItems, publicItems, web, amethyst }
|
return { privateItems, publicItems, web, amethyst }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user