mirror of
https://github.com/dergigi/boris.git
synced 2026-02-23 16:04:29 +01:00
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84b0339505 | ||
|
|
12fa1db0db | ||
|
|
0919091f19 | ||
|
|
e1c04b4e7f | ||
|
|
b9642067a1 | ||
|
|
ceca37df08 | ||
|
|
dfdc5d0946 | ||
|
|
3619cd2585 | ||
|
|
f93e52611e | ||
|
|
ecb81cb151 | ||
|
|
adf73cb9d1 | ||
|
|
4202807777 | ||
|
|
1c21615103 | ||
|
|
732070e89b | ||
|
|
d9a00dd157 | ||
|
|
103be75f6e | ||
|
|
8dd4e358b4 | ||
|
|
2e8dfaee09 | ||
|
|
db3084b373 | ||
|
|
83e4a2ad4c | ||
|
|
c1d23fac7b | ||
|
|
de32310801 | ||
|
|
5c82dff8df | ||
|
|
abe2d6528a | ||
|
|
8b56fe3d6e | ||
|
|
bdce7c9358 | ||
|
|
81a4ae392f | ||
|
|
6e438b8ee2 | ||
|
|
31974e7271 | ||
|
|
676be1a932 | ||
|
|
9883f2eb1a | ||
|
|
87e46be86f | ||
|
|
b745a92a7e | ||
|
|
5a79da4024 | ||
|
|
a7d05a29f5 | ||
|
|
0740d53d37 | ||
|
|
914738abb4 | ||
|
|
4fac5f42c9 | ||
|
|
16b3668e73 | ||
|
|
f3a83256a8 | ||
|
|
0e98ddeef4 | ||
|
|
1ba375e93e | ||
|
|
5d14d25d0e | ||
|
|
616038a23a | ||
|
|
14fce2c3dc | ||
|
|
7c511de474 | ||
|
|
3a10ac8691 | ||
|
|
205879f948 | ||
|
|
bff43f4a28 | ||
|
|
2a7fffd594 | ||
|
|
50a4161e16 | ||
|
|
5fd8976097 | ||
|
|
80b26abff2 | ||
|
|
c0638851c6 | ||
|
|
9b6b14cfe8 | ||
|
|
b6ad62a3ab | ||
|
|
85d87bac29 | ||
|
|
3b31eceeab | ||
|
|
442c138d6a | ||
|
|
61e6027252 | ||
|
|
7d373015b4 | ||
|
|
32b1286079 | ||
|
|
17fdd92827 | ||
|
|
aa6aeb2723 | ||
|
|
4b0f275f57 |
70
CHANGELOG.md
70
CHANGELOG.md
@@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.8.0] - 2025-10-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Centralized reading progress controller for non-blocking reading position sync
|
||||||
|
- Progressive loading with caching from event store
|
||||||
|
- Streaming updates from relays with proper merging
|
||||||
|
- 2-second completion hold at 100% reading position to prevent UI jitter
|
||||||
|
- Configurable auto-mark-as-read at 100% reading progress
|
||||||
|
- Reading progress indicators on blog post cards
|
||||||
|
- Visual progress bars on article cards in Explore and bookmarks sidebar
|
||||||
|
- Persistent reading position synced across devices via NIP-85
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reading position sync now enabled by default in runtime paths
|
||||||
|
- Improved auto-mark-as-read behavior with reliable completion detection
|
||||||
|
- Reading progress events use proper NIP-85 specification (kind 39802)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Reading position saves with proper validation and event store integration
|
||||||
|
- Profile page writings loading now fetches all writings without limits
|
||||||
|
- Consistent reading progress calculation and event publishing
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Non-blocking reading progress controller with streaming updates
|
||||||
|
- Cache-first loading strategy with local event store before relay queries
|
||||||
|
- Efficient progress merging and deduplication
|
||||||
|
|
||||||
|
## [0.7.4] - 2025-10-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Profile page data preloading for instant tab switching
|
||||||
|
- Automatically preloads all highlights and writings when viewing a profile (`/p/` pages)
|
||||||
|
- Non-blocking background fetch stores all events in event store
|
||||||
|
- Tab switching becomes instant after initial preload
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `/me/bookmarks` tab now displays in cards view only
|
||||||
|
- Removed view mode toggle buttons (compact, large) from bookmarks tab
|
||||||
|
- Cards view provides optimal bookmark browsing experience
|
||||||
|
- Grouping toggle (grouped/flat) still available
|
||||||
|
- Highlights sidebar filters simplified when logged out
|
||||||
|
- Only nostrverse filter button shown when not logged in
|
||||||
|
- Friends and personal highlight filters hidden when logged out
|
||||||
|
- Cleaner UX showing only available options
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Profile page tabs now display cached content instantly
|
||||||
|
- Highlights and writings show immediately from event store cache
|
||||||
|
- Network fetches happen in background without blocking UI
|
||||||
|
- Matches Explore and Debug page non-blocking loading pattern
|
||||||
|
- Eliminated loading delays when switching between tabs
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Cache-first profile loading strategy
|
||||||
|
- Instant display of cached highlights and writings from event store
|
||||||
|
- Background refresh updates data without blocking
|
||||||
|
- Tab switches show content immediately without loading states
|
||||||
|
|
||||||
## [0.7.3] - 2025-10-18
|
## [0.7.3] - 2025-10-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1978,7 +2044,9 @@ 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.7.3...HEAD
|
[Unreleased]: https://github.com/dergigi/boris/compare/v0.8.0...HEAD
|
||||||
|
[0.8.0]: https://github.com/dergigi/boris/compare/v0.7.4...v0.8.0
|
||||||
|
[0.7.4]: https://github.com/dergigi/boris/compare/v0.7.3...v0.7.4
|
||||||
[0.7.3]: https://github.com/dergigi/boris/compare/v0.7.2...v0.7.3
|
[0.7.3]: https://github.com/dergigi/boris/compare/v0.7.2...v0.7.3
|
||||||
[0.7.2]: https://github.com/dergigi/boris/compare/v0.7.0...v0.7.2
|
[0.7.2]: https://github.com/dergigi/boris/compare/v0.7.0...v0.7.2
|
||||||
[0.7.0]: https://github.com/dergigi/boris/compare/v0.6.24...v0.7.0
|
[0.7.0]: https://github.com/dergigi/boris/compare/v0.6.24...v0.7.0
|
||||||
|
|||||||
@@ -215,12 +215,6 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|||||||
|
|
||||||
const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
|
const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
|
||||||
if (debugEnabled) {
|
if (debugEnabled) {
|
||||||
console.log('[article-og] request', JSON.stringify({
|
|
||||||
naddr,
|
|
||||||
ua: userAgent || null,
|
|
||||||
isCrawlerRequest,
|
|
||||||
path: req.url || null
|
|
||||||
}))
|
|
||||||
res.setHeader('X-Boris-Debug', '1')
|
res.setHeader('X-Boris-Debug', '1')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +251,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||||
if (debugEnabled) {
|
if (debugEnabled) {
|
||||||
console.log('[article-og] response', JSON.stringify({ mode: 'browser', naddr }))
|
// Debug mode enabled
|
||||||
}
|
}
|
||||||
return res.status(200).send(html)
|
return res.status(200).send(html)
|
||||||
}
|
}
|
||||||
@@ -268,7 +262,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|||||||
if (cached && cached.expires > now) {
|
if (cached && cached.expires > now) {
|
||||||
setCacheHeaders(res)
|
setCacheHeaders(res)
|
||||||
if (debugEnabled) {
|
if (debugEnabled) {
|
||||||
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: true }))
|
// Debug mode enabled
|
||||||
}
|
}
|
||||||
return res.status(200).send(cached.html)
|
return res.status(200).send(cached.html)
|
||||||
}
|
}
|
||||||
@@ -286,7 +280,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|||||||
// Send response
|
// Send response
|
||||||
setCacheHeaders(res)
|
setCacheHeaders(res)
|
||||||
if (debugEnabled) {
|
if (debugEnabled) {
|
||||||
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: false }))
|
// Debug mode enabled
|
||||||
}
|
}
|
||||||
return res.status(200).send(html)
|
return res.status(200).send(html)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -296,7 +290,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|||||||
const html = generateHtml(naddr, null)
|
const html = generateHtml(naddr, null)
|
||||||
setCacheHeaders(res, 3600)
|
setCacheHeaders(res, 3600)
|
||||||
if (debugEnabled) {
|
if (debugEnabled) {
|
||||||
console.log('[article-og] response', JSON.stringify({ mode: 'bot-fallback', naddr }))
|
// Debug mode enabled
|
||||||
}
|
}
|
||||||
return res.status(200).send(html)
|
return res.status(200).send(html)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.7.4",
|
"version": "0.8.2",
|
||||||
"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",
|
||||||
|
|||||||
75
public/md/NIP-85.md
Normal file
75
public/md/NIP-85.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# NIP-85
|
||||||
|
|
||||||
|
## Reading Progress
|
||||||
|
|
||||||
|
`draft` `optional`
|
||||||
|
|
||||||
|
This NIP defines kind `39802`, a parameterized replaceable event for tracking reading progress across articles and web content.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
* [Format](#format)
|
||||||
|
* [Tags](#tags)
|
||||||
|
* [Content](#content)
|
||||||
|
* [Examples](#examples)
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
Reading progress events use NIP-33 parameterized replaceable semantics. The `d` tag serves as the unique identifier per author and target content.
|
||||||
|
|
||||||
|
### Tags
|
||||||
|
|
||||||
|
Events SHOULD tag the source of the reading progress, whether nostr-native or not. `a` tags should be used for nostr events and `r` tags for URLs.
|
||||||
|
|
||||||
|
When tagging a URL, clients generating these events SHOULD do a best effort of cleaning the URL from trackers or obvious non-useful information from the query string.
|
||||||
|
|
||||||
|
- `d` (required): Unique identifier for the target content
|
||||||
|
- For Nostr articles: `30023:<pubkey>:<identifier>` (matching the article's coordinate)
|
||||||
|
- For external URLs: `url:<base64url-encoded-url>`
|
||||||
|
- `a` (optional but recommended for Nostr articles): Article coordinate `30023:<pubkey>:<identifier>`
|
||||||
|
- `r` (optional but recommended for URLs): Raw URL of the external content
|
||||||
|
|
||||||
|
### Content
|
||||||
|
|
||||||
|
The content is a JSON object with the following fields:
|
||||||
|
|
||||||
|
- `progress` (required): Number between 0 and 1 representing reading progress (0 = not started, 1 = completed)
|
||||||
|
- `loc` (optional): Number representing a location marker (e.g., pixel scroll position, page number, etc.)
|
||||||
|
- `ts` (optional): Unix timestamp (seconds) when the progress was recorded
|
||||||
|
- `ver` (optional): Schema version string
|
||||||
|
|
||||||
|
The latest event by `created_at` per (`pubkey`, `d`) pair is authoritative (NIP-33 semantics).
|
||||||
|
|
||||||
|
Clients SHOULD implement rate limiting to avoid excessive relay traffic (debounce writes, only save significant changes).
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Nostr Article
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 39802,
|
||||||
|
"pubkey": "<user-pubkey>",
|
||||||
|
"created_at": 1734635012,
|
||||||
|
"content": "{\"progress\":0.66,\"loc\":1432,\"ts\":1734635012,\"ver\":\"1\"}",
|
||||||
|
"tags": [
|
||||||
|
["d", "30023:<author-pubkey>:<article-identifier>"],
|
||||||
|
["a", "30023:<author-pubkey>:<article-identifier>"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### External URL
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 39802,
|
||||||
|
"pubkey": "<user-pubkey>",
|
||||||
|
"created_at": 1734635999,
|
||||||
|
"content": "{\"progress\":1,\"ts\":1734635999,\"ver\":\"1\"}",
|
||||||
|
"tags": [
|
||||||
|
["d", "url:aHR0cHM6Ly9leGFtcGxlLmNvbS9wb3N0"],
|
||||||
|
["r", "https://example.com/post"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
97
src/App.tsx
97
src/App.tsx
@@ -24,6 +24,7 @@ import { bookmarkController } from './services/bookmarkController'
|
|||||||
import { contactsController } from './services/contactsController'
|
import { contactsController } from './services/contactsController'
|
||||||
import { highlightsController } from './services/highlightsController'
|
import { highlightsController } from './services/highlightsController'
|
||||||
import { writingsController } from './services/writingsController'
|
import { writingsController } from './services/writingsController'
|
||||||
|
import { readingProgressController } from './services/readingProgressController'
|
||||||
// import { fetchNostrverseHighlights } from './services/nostrverseService'
|
// import { fetchNostrverseHighlights } from './services/nostrverseService'
|
||||||
import { nostrverseHighlightsController } from './services/nostrverseHighlightsController'
|
import { nostrverseHighlightsController } from './services/nostrverseHighlightsController'
|
||||||
import { nostrverseWritingsController } from './services/nostrverseWritingsController'
|
import { nostrverseWritingsController } from './services/nostrverseWritingsController'
|
||||||
@@ -54,18 +55,14 @@ function AppRoutes({
|
|||||||
|
|
||||||
// Subscribe to bookmark controller
|
// Subscribe to bookmark controller
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('[bookmark] 🎧 Subscribing to bookmark controller')
|
|
||||||
const unsubBookmarks = bookmarkController.onBookmarks((bookmarks) => {
|
const unsubBookmarks = bookmarkController.onBookmarks((bookmarks) => {
|
||||||
console.log('[bookmark] 📥 Received bookmarks:', bookmarks.length)
|
|
||||||
setBookmarks(bookmarks)
|
setBookmarks(bookmarks)
|
||||||
})
|
})
|
||||||
const unsubLoading = bookmarkController.onLoading((loading) => {
|
const unsubLoading = bookmarkController.onLoading((loading) => {
|
||||||
console.log('[bookmark] 📥 Loading state:', loading)
|
|
||||||
setBookmarksLoading(loading)
|
setBookmarksLoading(loading)
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log('[bookmark] 🔇 Unsubscribing from bookmark controller')
|
|
||||||
unsubBookmarks()
|
unsubBookmarks()
|
||||||
unsubLoading()
|
unsubLoading()
|
||||||
}
|
}
|
||||||
@@ -73,18 +70,14 @@ function AppRoutes({
|
|||||||
|
|
||||||
// Subscribe to contacts controller
|
// Subscribe to contacts controller
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('[contacts] 🎧 Subscribing to contacts controller')
|
|
||||||
const unsubContacts = contactsController.onContacts((contacts) => {
|
const unsubContacts = contactsController.onContacts((contacts) => {
|
||||||
console.log('[contacts] 📥 Received contacts:', contacts.size)
|
|
||||||
setContacts(contacts)
|
setContacts(contacts)
|
||||||
})
|
})
|
||||||
const unsubLoading = contactsController.onLoading((loading) => {
|
const unsubLoading = contactsController.onLoading((loading) => {
|
||||||
console.log('[contacts] 📥 Loading state:', loading)
|
|
||||||
setContactsLoading(loading)
|
setContactsLoading(loading)
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log('[contacts] 🔇 Unsubscribing from contacts controller')
|
|
||||||
unsubContacts()
|
unsubContacts()
|
||||||
unsubLoading()
|
unsubLoading()
|
||||||
}
|
}
|
||||||
@@ -98,28 +91,29 @@ function AppRoutes({
|
|||||||
|
|
||||||
// Load bookmarks
|
// Load bookmarks
|
||||||
if (bookmarks.length === 0 && !bookmarksLoading) {
|
if (bookmarks.length === 0 && !bookmarksLoading) {
|
||||||
console.log('[bookmark] 🚀 Auto-loading bookmarks on mount/login')
|
|
||||||
bookmarkController.start({ relayPool, activeAccount, accountManager })
|
bookmarkController.start({ relayPool, activeAccount, accountManager })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load contacts
|
// Load contacts
|
||||||
if (pubkey && contacts.size === 0 && !contactsLoading) {
|
if (pubkey && contacts.size === 0 && !contactsLoading) {
|
||||||
console.log('[contacts] 🚀 Auto-loading contacts on mount/login')
|
|
||||||
contactsController.start({ relayPool, pubkey })
|
contactsController.start({ relayPool, pubkey })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load highlights (controller manages its own state)
|
// Load highlights (controller manages its own state)
|
||||||
if (pubkey && eventStore && !highlightsController.isLoadedFor(pubkey)) {
|
if (pubkey && eventStore && !highlightsController.isLoadedFor(pubkey)) {
|
||||||
console.log('[highlights] 🚀 Auto-loading highlights on mount/login')
|
|
||||||
highlightsController.start({ relayPool, eventStore, pubkey })
|
highlightsController.start({ relayPool, eventStore, pubkey })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load writings (controller manages its own state)
|
// Load writings (controller manages its own state)
|
||||||
if (pubkey && eventStore && !writingsController.isLoadedFor(pubkey)) {
|
if (pubkey && eventStore && !writingsController.isLoadedFor(pubkey)) {
|
||||||
console.log('[writings] 🚀 Auto-loading writings on mount/login')
|
|
||||||
writingsController.start({ relayPool, eventStore, pubkey })
|
writingsController.start({ relayPool, eventStore, pubkey })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load reading progress (controller manages its own state)
|
||||||
|
if (pubkey && eventStore && !readingProgressController.isLoadedFor(pubkey)) {
|
||||||
|
readingProgressController.start({ relayPool, eventStore, pubkey })
|
||||||
|
}
|
||||||
|
|
||||||
// Start centralized nostrverse highlights controller (non-blocking)
|
// Start centralized nostrverse highlights controller (non-blocking)
|
||||||
if (eventStore) {
|
if (eventStore) {
|
||||||
nostrverseHighlightsController.start({ relayPool, eventStore })
|
nostrverseHighlightsController.start({ relayPool, eventStore })
|
||||||
@@ -139,10 +133,8 @@ function AppRoutes({
|
|||||||
// Manual refresh (for sidebar button)
|
// Manual refresh (for sidebar button)
|
||||||
const handleRefreshBookmarks = useCallback(async () => {
|
const handleRefreshBookmarks = useCallback(async () => {
|
||||||
if (!relayPool || !activeAccount) {
|
if (!relayPool || !activeAccount) {
|
||||||
console.warn('[bookmark] Cannot refresh: missing relayPool or activeAccount')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.log('[bookmark] 🔄 Manual refresh triggered')
|
|
||||||
bookmarkController.reset()
|
bookmarkController.reset()
|
||||||
await bookmarkController.start({ relayPool, activeAccount, accountManager })
|
await bookmarkController.start({ relayPool, activeAccount, accountManager })
|
||||||
}, [relayPool, activeAccount, accountManager])
|
}, [relayPool, activeAccount, accountManager])
|
||||||
@@ -152,6 +144,7 @@ function AppRoutes({
|
|||||||
bookmarkController.reset() // Clear bookmarks via controller
|
bookmarkController.reset() // Clear bookmarks via controller
|
||||||
contactsController.reset() // Clear contacts via controller
|
contactsController.reset() // Clear contacts via controller
|
||||||
highlightsController.reset() // Clear highlights via controller
|
highlightsController.reset() // Clear highlights via controller
|
||||||
|
readingProgressController.reset() // Clear reading progress via controller
|
||||||
showToast('Logged out successfully')
|
showToast('Logged out successfully')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,40 +378,31 @@ function App() {
|
|||||||
// Return an already-resolved promise so upstream await finishes immediately
|
// Return an already-resolved promise so upstream await finishes immediately
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
console.log('[bunker] ✅ Wired NostrConnectSigner to RelayPool publish/subscription (before account load)')
|
|
||||||
|
|
||||||
// Create a relay group for better event deduplication and management
|
// Create a relay group for better event deduplication and management
|
||||||
pool.group(RELAYS)
|
pool.group(RELAYS)
|
||||||
console.log('[bunker] Created relay group with', RELAYS.length, 'relays (including local)')
|
|
||||||
|
|
||||||
// Load persisted accounts from localStorage
|
// Load persisted accounts from localStorage
|
||||||
try {
|
try {
|
||||||
const accountsJson = localStorage.getItem('accounts')
|
const accountsJson = localStorage.getItem('accounts')
|
||||||
console.log('[bunker] Raw accounts from localStorage:', accountsJson)
|
|
||||||
|
|
||||||
const json = JSON.parse(accountsJson || '[]')
|
const json = JSON.parse(accountsJson || '[]')
|
||||||
console.log('[bunker] Parsed accounts:', json.length, 'accounts')
|
|
||||||
|
|
||||||
await accounts.fromJSON(json)
|
await accounts.fromJSON(json)
|
||||||
console.log('[bunker] Loaded', accounts.accounts.length, 'accounts from storage')
|
|
||||||
console.log('[bunker] Account types:', accounts.accounts.map(a => ({ id: a.id, type: a.type })))
|
|
||||||
|
|
||||||
// Load active account from storage
|
// Load active account from storage
|
||||||
const activeId = localStorage.getItem('active')
|
const activeId = localStorage.getItem('active')
|
||||||
console.log('[bunker] Active ID from localStorage:', activeId)
|
|
||||||
|
|
||||||
if (activeId) {
|
if (activeId) {
|
||||||
const account = accounts.getAccount(activeId)
|
const account = accounts.getAccount(activeId)
|
||||||
console.log('[bunker] Found account for ID?', !!account, account?.type)
|
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
accounts.setActive(activeId)
|
accounts.setActive(activeId)
|
||||||
console.log('[bunker] ✅ Restored active account:', activeId, 'type:', account.type)
|
|
||||||
} else {
|
} else {
|
||||||
console.warn('[bunker] ⚠️ Active ID found but account not in list')
|
console.warn('[bunker] ⚠️ Active ID found but account not in list')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('[bunker] No active account ID in localStorage')
|
// No active account ID in localStorage
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[bunker] ❌ Failed to load accounts from storage:', err)
|
console.error('[bunker] ❌ Failed to load accounts from storage:', err)
|
||||||
@@ -443,11 +427,6 @@ function App() {
|
|||||||
const reconnectedAccounts = new Set<string>()
|
const reconnectedAccounts = new Set<string>()
|
||||||
|
|
||||||
const bunkerReconnectSub = accounts.active$.subscribe(async (account) => {
|
const bunkerReconnectSub = accounts.active$.subscribe(async (account) => {
|
||||||
console.log('[bunker] Active account changed:', {
|
|
||||||
hasAccount: !!account,
|
|
||||||
type: account?.type,
|
|
||||||
id: account?.id
|
|
||||||
})
|
|
||||||
|
|
||||||
if (account && account.type === 'nostr-connect') {
|
if (account && account.type === 'nostr-connect') {
|
||||||
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
|
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
|
||||||
@@ -455,23 +434,17 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
if (!(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue) {
|
if (!(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue) {
|
||||||
(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue = true
|
(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue = true
|
||||||
console.log('[bunker] ⚙️ Disabled account request queueing for nostr-connect')
|
|
||||||
}
|
}
|
||||||
} catch (err) { console.warn('[bunker] failed to disable queue', err) }
|
} catch (err) {
|
||||||
|
// Ignore queue disable errors
|
||||||
|
}
|
||||||
// Note: for Amber bunker, the remote signer pubkey is the user's pubkey. This is expected.
|
// Note: for Amber bunker, the remote signer pubkey is the user's pubkey. This is expected.
|
||||||
|
|
||||||
// Skip if we've already reconnected this account
|
// Skip if we've already reconnected this account
|
||||||
if (reconnectedAccounts.has(account.id)) {
|
if (reconnectedAccounts.has(account.id)) {
|
||||||
console.log('[bunker] ⏭️ Already reconnected this account, skipping')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[bunker] Account detected. Status:', {
|
|
||||||
listening: nostrConnectAccount.signer.listening,
|
|
||||||
isConnected: nostrConnectAccount.signer.isConnected,
|
|
||||||
hasRemote: !!nostrConnectAccount.signer.remote,
|
|
||||||
bunkerRelays: nostrConnectAccount.signer.relays
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// For restored signers, ensure they have the pool's subscription methods
|
// For restored signers, ensure they have the pool's subscription methods
|
||||||
@@ -485,10 +458,9 @@ function App() {
|
|||||||
const newBunkerRelays = bunkerRelays.filter(url => !existingRelayUrls.has(url))
|
const newBunkerRelays = bunkerRelays.filter(url => !existingRelayUrls.has(url))
|
||||||
|
|
||||||
if (newBunkerRelays.length > 0) {
|
if (newBunkerRelays.length > 0) {
|
||||||
console.log('[bunker] Adding bunker relays to pool BEFORE signer recreation:', newBunkerRelays)
|
|
||||||
pool.group(newBunkerRelays)
|
pool.group(newBunkerRelays)
|
||||||
} else {
|
} else {
|
||||||
console.log('[bunker] Bunker relays already in pool')
|
// Bunker relays already in pool
|
||||||
}
|
}
|
||||||
|
|
||||||
const recreatedSigner = new NostrConnectSigner({
|
const recreatedSigner = new NostrConnectSigner({
|
||||||
@@ -502,13 +474,11 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
|
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
|
||||||
recreatedSigner.relays = mergedRelays
|
recreatedSigner.relays = mergedRelays
|
||||||
console.log('[bunker] 🔗 Signer relays merged with app RELAYS:', mergedRelays)
|
|
||||||
} catch (err) { console.warn('[bunker] failed to merge signer relays', err) }
|
} catch (err) { console.warn('[bunker] failed to merge signer relays', err) }
|
||||||
|
|
||||||
// Replace the signer on the account
|
// Replace the signer on the account
|
||||||
nostrConnectAccount.signer = recreatedSigner
|
nostrConnectAccount.signer = recreatedSigner
|
||||||
console.log('[bunker] ✅ Signer recreated with pool context')
|
|
||||||
|
|
||||||
// Debug: log publish/subscription calls made by signer (decrypt/sign requests)
|
// Debug: log publish/subscription calls made by signer (decrypt/sign requests)
|
||||||
// IMPORTANT: bind originals to preserve `this` context used internally by the signer
|
// IMPORTANT: bind originals to preserve `this` context used internally by the signer
|
||||||
const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner)
|
const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner)
|
||||||
@@ -530,7 +500,6 @@ function App() {
|
|||||||
tags: (event as { tags?: unknown })?.tags,
|
tags: (event as { tags?: unknown })?.tags,
|
||||||
contentLength: typeof content === 'string' ? content.length : undefined
|
contentLength: typeof content === 'string' ? content.length : undefined
|
||||||
}
|
}
|
||||||
console.log('[bunker] publish via signer:', summary)
|
|
||||||
try { DebugBus.info('bunker', 'publish', summary) } catch (err) { console.warn('[bunker] failed to log to DebugBus', err) }
|
try { DebugBus.info('bunker', 'publish', summary) } catch (err) { console.warn('[bunker] failed to log to DebugBus', err) }
|
||||||
} catch (err) { console.warn('[bunker] failed to log publish summary', err) }
|
} catch (err) { console.warn('[bunker] failed to log publish summary', err) }
|
||||||
// Fire-and-forget publish: trigger the publish but do not return the
|
// Fire-and-forget publish: trigger the publish but do not return the
|
||||||
@@ -548,7 +517,6 @@ function App() {
|
|||||||
const originalSubscribe = (recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod.bind(recreatedSigner)
|
const originalSubscribe = (recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod.bind(recreatedSigner)
|
||||||
;(recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod = (relays: string[], filters: unknown[]) => {
|
;(recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod = (relays: string[], filters: unknown[]) => {
|
||||||
try {
|
try {
|
||||||
console.log('[bunker] subscribe via signer:', { relays, filters })
|
|
||||||
try { DebugBus.info('bunker', 'subscribe', { relays, filters }) } catch (err) { console.warn('[bunker] failed to log subscribe to DebugBus', err) }
|
try { DebugBus.info('bunker', 'subscribe', { relays, filters }) } catch (err) { console.warn('[bunker] failed to log subscribe to DebugBus', err) }
|
||||||
} catch (err) { console.warn('[bunker] failed to log subscribe summary', err) }
|
} catch (err) { console.warn('[bunker] failed to log subscribe summary', err) }
|
||||||
return originalSubscribe(relays, filters)
|
return originalSubscribe(relays, filters)
|
||||||
@@ -558,20 +526,16 @@ function App() {
|
|||||||
// Just ensure the signer is listening for responses - don't call connect() again
|
// Just ensure the signer is listening for responses - don't call connect() again
|
||||||
// The fromBunkerURI already connected with permissions during login
|
// The fromBunkerURI already connected with permissions during login
|
||||||
if (!nostrConnectAccount.signer.listening) {
|
if (!nostrConnectAccount.signer.listening) {
|
||||||
console.log('[bunker] Opening signer subscription...')
|
|
||||||
await nostrConnectAccount.signer.open()
|
await nostrConnectAccount.signer.open()
|
||||||
console.log('[bunker] ✅ Signer subscription opened')
|
|
||||||
} else {
|
} else {
|
||||||
console.log('[bunker] ✅ Signer already listening')
|
// Signer already listening
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
|
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
|
||||||
try {
|
try {
|
||||||
if (nostrConnectAccount.signer.remote && !reconnectedAccounts.has(account.id)) {
|
if (nostrConnectAccount.signer.remote && !reconnectedAccounts.has(account.id)) {
|
||||||
const permissions = getDefaultBunkerPermissions()
|
const permissions = getDefaultBunkerPermissions()
|
||||||
console.log('[bunker] Attempting guarded connect() with permissions to ensure decrypt perms', { count: permissions.length })
|
|
||||||
await nostrConnectAccount.signer.connect(undefined, permissions)
|
await nostrConnectAccount.signer.connect(undefined, permissions)
|
||||||
console.log('[bunker] ✅ Guarded connect() succeeded with permissions')
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[bunker] ⚠️ Guarded connect() failed:', e)
|
console.warn('[bunker] ⚠️ Guarded connect() failed:', e)
|
||||||
@@ -580,7 +544,6 @@ function App() {
|
|||||||
// Give the subscription a moment to fully establish before allowing decrypt operations
|
// Give the subscription a moment to fully establish before allowing decrypt operations
|
||||||
// This ensures the signer is ready to handle and receive responses
|
// This ensures the signer is ready to handle and receive responses
|
||||||
await new Promise(resolve => setTimeout(resolve, 100))
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
console.log("[bunker] Subscription ready after startup delay")
|
|
||||||
// Fire-and-forget: probe decrypt path to verify Amber responds to NIP-46 decrypt
|
// Fire-and-forget: probe decrypt path to verify Amber responds to NIP-46 decrypt
|
||||||
try {
|
try {
|
||||||
const withTimeout = async <T,>(p: Promise<T>, ms = 10000): Promise<T> => {
|
const withTimeout = async <T,>(p: Promise<T>, ms = 10000): Promise<T> => {
|
||||||
@@ -593,38 +556,27 @@ function App() {
|
|||||||
const self = nostrConnectAccount.pubkey
|
const self = nostrConnectAccount.pubkey
|
||||||
// Try a roundtrip so the bunker can respond successfully
|
// Try a roundtrip so the bunker can respond successfully
|
||||||
try {
|
try {
|
||||||
console.log('[bunker] 🔎 Probe nip44 roundtrip (encrypt→decrypt)…')
|
await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44'))
|
||||||
const cipher44 = await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44'))
|
await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, ''))
|
||||||
const plain44 = await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, cipher44))
|
} catch (_err) {
|
||||||
console.log('[bunker] 🔎 Probe nip44 responded:', typeof plain44 === 'string' ? plain44 : typeof plain44)
|
// Ignore probe errors
|
||||||
} catch (err) {
|
|
||||||
console.log('[bunker] 🔎 Probe nip44 result:', err instanceof Error ? err.message : err)
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
console.log('[bunker] 🔎 Probe nip04 roundtrip (encrypt→decrypt)…')
|
await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04'))
|
||||||
const cipher04 = await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04'))
|
await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, ''))
|
||||||
const plain04 = await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, cipher04))
|
} catch (_err) {
|
||||||
console.log('[bunker] 🔎 Probe nip04 responded:', typeof plain04 === 'string' ? plain04 : typeof plain04)
|
// Ignore probe errors
|
||||||
} catch (err) {
|
|
||||||
console.log('[bunker] 🔎 Probe nip04 result:', err instanceof Error ? err.message : err)
|
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
console.log('[bunker] 🔎 Probe setup failed:', err)
|
// Ignore signer setup errors
|
||||||
}
|
}
|
||||||
// The bunker remembers the permissions from the initial connection
|
// The bunker remembers the permissions from the initial connection
|
||||||
nostrConnectAccount.signer.isConnected = true
|
nostrConnectAccount.signer.isConnected = true
|
||||||
|
|
||||||
console.log('[bunker] Final signer status:', {
|
|
||||||
listening: nostrConnectAccount.signer.listening,
|
|
||||||
isConnected: nostrConnectAccount.signer.isConnected,
|
|
||||||
remote: nostrConnectAccount.signer.remote,
|
|
||||||
relays: nostrConnectAccount.signer.relays
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mark this account as reconnected
|
// Mark this account as reconnected
|
||||||
reconnectedAccounts.add(account.id)
|
reconnectedAccounts.add(account.id)
|
||||||
console.log('[bunker] 🎉 Signer ready for signing')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[bunker] ❌ Failed to open signer:', error)
|
console.error('[bunker] ❌ Failed to open signer:', error)
|
||||||
}
|
}
|
||||||
@@ -638,7 +590,6 @@ function App() {
|
|||||||
next: () => {}, // No-op, we don't care about events
|
next: () => {}, // No-op, we don't care about events
|
||||||
error: (err) => console.warn('Keep-alive subscription error:', err)
|
error: (err) => console.warn('Keep-alive subscription error:', err)
|
||||||
})
|
})
|
||||||
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
|
|
||||||
|
|
||||||
// Store subscription for cleanup
|
// Store subscription for cleanup
|
||||||
;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub
|
;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
|
|||||||
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debug log - reading progress shown as visual indicator
|
||||||
|
if (readingProgress !== undefined) {
|
||||||
|
// Reading progress display
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ interface BookmarkItemProps {
|
|||||||
index: number
|
index: number
|
||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
viewMode?: ViewMode
|
viewMode?: ViewMode
|
||||||
|
readingProgress?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
|
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
|
||||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||||
|
|
||||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||||
@@ -139,7 +140,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
contentTypeIcon: getContentTypeIcon()
|
contentTypeIcon: getContentTypeIcon(),
|
||||||
|
readingProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === 'compact') {
|
if (viewMode === 'compact') {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { ViewMode } from './Bookmarks'
|
|||||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { BookmarkSkeleton } from './Skeletons'
|
import { BookmarkSkeleton } from './Skeletons'
|
||||||
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet } from '../utils/bookmarkUtils'
|
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate } from '../utils/bookmarkUtils'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
import AddBookmarkModal from './AddBookmarkModal'
|
import AddBookmarkModal from './AddBookmarkModal'
|
||||||
import { createWebBookmark } from '../services/webBookmarkService'
|
import { createWebBookmark } from '../services/webBookmarkService'
|
||||||
@@ -22,6 +22,10 @@ import { Hooks } from 'applesauce-react'
|
|||||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
import LoginOptions from './LoginOptions'
|
import LoginOptions from './LoginOptions'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||||
|
|
||||||
interface BookmarkListProps {
|
interface BookmarkListProps {
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
@@ -70,6 +74,45 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
return saved === 'flat' ? 'flat' : 'grouped'
|
return saved === 'flat' ? 'flat' : 'grouped'
|
||||||
})
|
})
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
|
// Subscribe to reading progress updates
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial progress map
|
||||||
|
setReadingProgressMap(readingProgressController.getProgressMap())
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
const unsubProgress = readingProgressController.onProgress(setReadingProgressMap)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubProgress()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Helper to get reading progress for a bookmark
|
||||||
|
const getBookmarkReadingProgress = (bookmark: IndividualBookmark): number | undefined => {
|
||||||
|
if (bookmark.kind === 30023) {
|
||||||
|
// For articles, use naddr as key
|
||||||
|
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) return undefined
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: bookmark.pubkey,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
return readingProgressMap.get(naddr)
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For web bookmarks and other types, try to use URL if available
|
||||||
|
const urls = extractUrlsFromContent(bookmark.content)
|
||||||
|
if (urls.length > 0) {
|
||||||
|
return readingProgressMap.get(urls[0])
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
const toggleGroupingMode = () => {
|
const toggleGroupingMode = () => {
|
||||||
const newMode = groupingMode === 'grouped' ? 'flat' : 'grouped'
|
const newMode = groupingMode === 'grouped' ? 'flat' : 'grouped'
|
||||||
@@ -100,6 +143,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
// Merge and flatten all individual bookmarks from all lists
|
// Merge and flatten all individual bookmarks from all lists
|
||||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
.filter(hasContent)
|
.filter(hasContent)
|
||||||
|
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
|
||||||
|
|
||||||
// Apply filter
|
// Apply filter
|
||||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
||||||
@@ -116,8 +160,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
: [
|
: [
|
||||||
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
||||||
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
||||||
{ key: 'amethyst-private', title: 'Amethyst Private', items: groups.amethystPrivate },
|
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
|
||||||
{ key: 'amethyst-public', title: 'Amethyst Lists', items: groups.amethystPublic },
|
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
|
||||||
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
|
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -220,6 +264,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
index={index}
|
index={index}
|
||||||
onSelectUrl={onSelectUrl}
|
onSelectUrl={onSelectUrl}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
|
readingProgress={getBookmarkReadingProgress(individualBookmark)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface CardViewProps {
|
|||||||
articleImage?: string
|
articleImage?: string
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
contentTypeIcon: IconDefinition
|
contentTypeIcon: IconDefinition
|
||||||
|
readingProgress?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CardView: React.FC<CardViewProps> = ({
|
export const CardView: React.FC<CardViewProps> = ({
|
||||||
@@ -38,7 +39,8 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
contentTypeIcon
|
contentTypeIcon,
|
||||||
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||||
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
|
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
|
||||||
@@ -52,6 +54,14 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
const shouldTruncate = !expanded && contentLength > 210
|
const shouldTruncate = !expanded && contentLength > 210
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
|
|
||||||
|
// Calculate progress color (matching BlogPostCard logic)
|
||||||
|
let progressColor = '#6366f1' // Default blue (reading)
|
||||||
|
if (readingProgress && readingProgress >= 0.95) {
|
||||||
|
progressColor = '#10b981' // Green (completed)
|
||||||
|
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||||
|
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||||
|
}
|
||||||
|
|
||||||
// Determine which image to use (article image, instant preview, or OG image)
|
// Determine which image to use (article image, instant preview, or OG image)
|
||||||
const previewImage = articleImage || instantPreview || ogImage
|
const previewImage = articleImage || instantPreview || ogImage
|
||||||
const cachedImage = useImageCache(previewImage || undefined)
|
const cachedImage = useImageCache(previewImage || undefined)
|
||||||
@@ -163,6 +173,28 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Reading progress indicator for articles */}
|
||||||
|
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '3px',
|
||||||
|
width: '100%',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginTop: '0.75rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${Math.round(readingProgress * 100)}%`,
|
||||||
|
background: progressColor,
|
||||||
|
transition: 'width 0.3s ease, background 0.3s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="bookmark-footer">
|
<div className="bookmark-footer">
|
||||||
<div className="bookmark-meta-minimal">
|
<div className="bookmark-meta-minimal">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface CompactViewProps {
|
|||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
contentTypeIcon: IconDefinition
|
contentTypeIcon: IconDefinition
|
||||||
|
readingProgress?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CompactView: React.FC<CompactViewProps> = ({
|
export const CompactView: React.FC<CompactViewProps> = ({
|
||||||
@@ -22,12 +23,21 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
contentTypeIcon
|
contentTypeIcon,
|
||||||
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
const isWebBookmark = bookmark.kind === 39701
|
const isWebBookmark = bookmark.kind === 39701
|
||||||
const isClickable = hasUrls || isArticle || isWebBookmark
|
const isClickable = hasUrls || isArticle || isWebBookmark
|
||||||
|
|
||||||
|
// Calculate progress color (matching BlogPostCard logic)
|
||||||
|
let progressColor = '#6366f1' // Default blue (reading)
|
||||||
|
if (readingProgress && readingProgress >= 0.95) {
|
||||||
|
progressColor = '#10b981' // Green (completed)
|
||||||
|
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||||
|
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||||
|
}
|
||||||
|
|
||||||
const handleCompactClick = () => {
|
const handleCompactClick = () => {
|
||||||
if (!onSelectUrl) return
|
if (!onSelectUrl) return
|
||||||
|
|
||||||
@@ -62,6 +72,29 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
||||||
{/* CTA removed */}
|
{/* CTA removed */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reading progress indicator for all bookmark types with reading data */}
|
||||||
|
{readingProgress !== undefined && readingProgress > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '1px',
|
||||||
|
width: '100%',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
margin: '0',
|
||||||
|
marginLeft: '1.5rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${Math.round(readingProgress * 100)}%`,
|
||||||
|
background: progressColor,
|
||||||
|
transition: 'width 0.3s ease, background 0.3s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Bookmark } from '../types/bookmarks'
|
|||||||
import ThreePaneLayout from './ThreePaneLayout'
|
import ThreePaneLayout from './ThreePaneLayout'
|
||||||
import Explore from './Explore'
|
import Explore from './Explore'
|
||||||
import Me from './Me'
|
import Me from './Me'
|
||||||
|
import Profile from './Profile'
|
||||||
import Support from './Support'
|
import Support from './Support'
|
||||||
import { classifyHighlights } from '../utils/highlightClassification'
|
import { classifyHighlights } from '../utils/highlightClassification'
|
||||||
|
|
||||||
@@ -330,7 +331,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
profile={showProfile && profilePubkey ? (
|
profile={showProfile && profilePubkey ? (
|
||||||
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={profileTab} pubkey={profilePubkey} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
relayPool ? <Profile relayPool={relayPool} eventStore={eventStore} pubkey={profilePubkey} activeTab={profileTab} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
support={showSupport ? (
|
support={showSupport ? (
|
||||||
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
import AuthorCard from './AuthorCard'
|
import AuthorCard from './AuthorCard'
|
||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
||||||
import { classifyUrl } from '../utils/helpers'
|
import { classifyUrl, shouldTrackReadingProgress } 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'
|
||||||
@@ -151,20 +151,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
// Callback to save reading position
|
// Callback to save reading position
|
||||||
const handleSavePosition = useCallback(async (position: number) => {
|
const handleSavePosition = useCallback(async (position: number) => {
|
||||||
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||||
console.log('⏭️ [ContentPanel] Skipping save - missing requirements:', {
|
|
||||||
hasAccount: !!activeAccount,
|
|
||||||
hasRelayPool: !!relayPool,
|
|
||||||
hasEventStore: !!eventStore,
|
|
||||||
hasIdentifier: !!articleIdentifier
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!settings?.syncReadingPosition) {
|
if (!settings?.syncReadingPosition) {
|
||||||
console.log('⏭️ [ContentPanel] Sync disabled in settings')
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if content is long enough to track reading progress
|
||||||
|
if (!shouldTrackReadingProgress(html, markdown)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('💾 [ContentPanel] Saving position:', Math.round(position * 100) + '%', 'for article:', selectedUrl?.slice(0, 50))
|
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const factory = new EventFactory({ signer: activeAccount })
|
const factory = new EventFactory({ signer: activeAccount })
|
||||||
@@ -176,45 +174,39 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
{
|
{
|
||||||
position,
|
position,
|
||||||
timestamp: Math.floor(Date.now() / 1000),
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
scrollTop: window.pageYOffset || document.documentElement.scrollTop
|
scrollTop
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ [ContentPanel] Failed to save reading position:', error)
|
console.error('[progress] ❌ ContentPanel: Failed to save reading position:', error)
|
||||||
}
|
}
|
||||||
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, html, markdown])
|
||||||
|
|
||||||
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
|
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
|
||||||
enabled: isTextContent,
|
enabled: isTextContent,
|
||||||
syncEnabled: settings?.syncReadingPosition,
|
syncEnabled: settings?.syncReadingPosition !== false,
|
||||||
onSave: handleSavePosition,
|
onSave: handleSavePosition,
|
||||||
onReadingComplete: () => {
|
onReadingComplete: () => {
|
||||||
// Optional: Auto-mark as read when reading is complete
|
// Auto-mark as read when reading is complete (if enabled in settings)
|
||||||
if (activeAccount && !isMarkedAsRead) {
|
if (activeAccount && !isMarkedAsRead && settings?.autoMarkAsReadOnCompletion) {
|
||||||
// Could trigger auto-mark as read here if desired
|
handleMarkAsRead()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Log sync status when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
|
||||||
|
|
||||||
// Load saved reading position when article loads
|
// Load saved reading position when article loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||||
console.log('⏭️ [ContentPanel] Skipping position restore - missing requirements:', {
|
|
||||||
isTextContent,
|
|
||||||
hasAccount: !!activeAccount,
|
|
||||||
hasRelayPool: !!relayPool,
|
|
||||||
hasEventStore: !!eventStore,
|
|
||||||
hasIdentifier: !!articleIdentifier
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!settings?.syncReadingPosition) {
|
if (settings?.syncReadingPosition === false) {
|
||||||
console.log('⏭️ [ContentPanel] Sync disabled - not restoring position')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📖 [ContentPanel] Loading position for article:', selectedUrl?.slice(0, 50))
|
|
||||||
|
|
||||||
const loadPosition = async () => {
|
const loadPosition = async () => {
|
||||||
try {
|
try {
|
||||||
const savedPosition = await loadReadingPosition(
|
const savedPosition = await loadReadingPosition(
|
||||||
@@ -225,7 +217,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
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
|
// Wait for content to be fully rendered before scrolling
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const documentHeight = document.documentElement.scrollHeight
|
const documentHeight = document.documentElement.scrollHeight
|
||||||
@@ -236,14 +227,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
top: scrollTop,
|
top: scrollTop,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
|
|
||||||
}, 500) // Give content time to render
|
}, 500) // Give content time to render
|
||||||
} else if (savedPosition) {
|
} else if (savedPosition) {
|
||||||
if (savedPosition.position === 1) {
|
if (savedPosition.position === 1) {
|
||||||
console.log('✅ [ContentPanel] Article completed (100%), starting from top')
|
// Article was completed, start from top
|
||||||
} else {
|
} else {
|
||||||
console.log('⏭️ [ContentPanel] Position too early (<5%):', Math.round(savedPosition.position * 100) + '%')
|
// Position was too early, skip restore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -620,14 +609,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
activeAccount,
|
activeAccount,
|
||||||
relayPool
|
relayPool
|
||||||
)
|
)
|
||||||
console.log('✅ Marked nostr article as read')
|
|
||||||
} else if (selectedUrl) {
|
} else if (selectedUrl) {
|
||||||
await createWebsiteReaction(
|
await createWebsiteReaction(
|
||||||
selectedUrl,
|
selectedUrl,
|
||||||
activeAccount,
|
activeAccount,
|
||||||
relayPool
|
relayPool
|
||||||
)
|
)
|
||||||
console.log('✅ Marked website as read')
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to mark as read:', error)
|
console.error('Failed to mark as read:', error)
|
||||||
|
|||||||
@@ -310,10 +310,6 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
|
|
||||||
// Subscribe to decrypt complete events for Debug UI display
|
// Subscribe to decrypt complete events for Debug UI display
|
||||||
const unsubscribeDecrypt = bookmarkController.onDecryptComplete((eventId, publicCount, privateCount) => {
|
const unsubscribeDecrypt = bookmarkController.onDecryptComplete((eventId, publicCount, privateCount) => {
|
||||||
console.log('[bunker] ✅ Auto-decrypted:', eventId.slice(0, 8), {
|
|
||||||
public: publicCount,
|
|
||||||
private: privateCount
|
|
||||||
})
|
|
||||||
setDecryptedEvents(prev => new Map(prev).set(eventId, {
|
setDecryptedEvents(prev => new Map(prev).set(eventId, {
|
||||||
public: publicCount,
|
public: publicCount,
|
||||||
private: privateCount
|
private: privateCount
|
||||||
@@ -742,7 +738,6 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
|
|
||||||
// Subscribe to controller updates to see streaming
|
// Subscribe to controller updates to see streaming
|
||||||
const unsubscribe = contactsController.onContacts((contacts) => {
|
const unsubscribe = contactsController.onContacts((contacts) => {
|
||||||
console.log('[debug] Received contacts update:', contacts.size)
|
|
||||||
setFriendsPubkeys(new Set(contacts))
|
setFriendsPubkeys(new Set(contacts))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
|||||||
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
|
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
|
||||||
import { writingsController } from '../services/writingsController'
|
import { writingsController } from '../services/writingsController'
|
||||||
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
|
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
|
||||||
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
@@ -59,6 +60,9 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||||
// Remove unused loading state to avoid warnings
|
// Remove unused loading state to avoid warnings
|
||||||
|
|
||||||
|
// Reading progress state (naddr -> progress 0-1)
|
||||||
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
// Load cached content from event store (instant display)
|
// Load cached content from event store (instant display)
|
||||||
const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
|
const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
|
||||||
|
|
||||||
@@ -169,6 +173,36 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
|
|
||||||
return () => unsub()
|
return () => unsub()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to reading progress controller
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial state immediately
|
||||||
|
const initialMap = readingProgressController.getProgressMap()
|
||||||
|
setReadingProgressMap(initialMap)
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
const unsubProgress = readingProgressController.onProgress((newMap) => {
|
||||||
|
setReadingProgressMap(newMap)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubProgress()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load reading progress data when logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAccount?.pubkey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
readingProgressController.start({
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
pubkey: activeAccount.pubkey,
|
||||||
|
force: refreshTrigger > 0
|
||||||
|
})
|
||||||
|
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
// Update visibility when settings/login state changes
|
// Update visibility when settings/login state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -571,6 +605,28 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
return { ...post, level }
|
return { ...post, level }
|
||||||
})
|
})
|
||||||
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility])
|
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility])
|
||||||
|
|
||||||
|
// Helper to get reading progress for a post
|
||||||
|
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: post.author,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
const progress = readingProgressMap.get(naddr)
|
||||||
|
|
||||||
|
return progress
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[progress] ❌ Error encoding naddr:', err)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}, [readingProgressMap])
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
@@ -596,6 +652,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
post={post}
|
post={post}
|
||||||
href={getPostUrl(post)}
|
href={getPostUrl(post)}
|
||||||
level={post.level}
|
level={post.level}
|
||||||
|
readingProgress={getReadingProgress(post)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
|||||||
// Fallback: extract directly from p tag
|
// Fallback: extract directly from p tag
|
||||||
const pTag = highlight.tags.find(t => t[0] === 'p')
|
const pTag = highlight.tags.find(t => t[0] === 'p')
|
||||||
if (pTag && pTag[1]) {
|
if (pTag && pTag[1]) {
|
||||||
console.log('📝 Found author from p tag:', pTag[1])
|
|
||||||
return pTag[1]
|
return pTag[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -348,11 +348,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
// Publish to all configured relays - let the relay pool handle connection state
|
// Publish to all configured relays - let the relay pool handle connection state
|
||||||
const targetRelays = RELAYS
|
const targetRelays = RELAYS
|
||||||
|
|
||||||
console.log('📡 Rebroadcasting highlight to', targetRelays.length, 'relay(s):', targetRelays)
|
|
||||||
|
|
||||||
await relayPool.publish(targetRelays, event)
|
await relayPool.publish(targetRelays, event)
|
||||||
|
|
||||||
console.log('✅ Rebroadcast successful!')
|
|
||||||
|
|
||||||
// Update the highlight with new relay info
|
// Update the highlight with new relay info
|
||||||
const isLocalOnly = areAllRelaysLocal(targetRelays)
|
const isLocalOnly = areAllRelaysLocal(targetRelays)
|
||||||
@@ -449,7 +447,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
relayPool
|
relayPool
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log('✅ Highlight deletion request published')
|
|
||||||
|
|
||||||
// Notify parent to remove this highlight from the list
|
// Notify parent to remove this highlight from the list
|
||||||
if (onHighlightDelete) {
|
if (onHighlightDelete) {
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { IEventStore, Helpers } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { nip19, NostrEvent } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { fetchHighlights } from '../services/highlightService'
|
|
||||||
import { highlightsController } from '../services/highlightsController'
|
import { highlightsController } from '../services/highlightsController'
|
||||||
import { writingsController } from '../services/writingsController'
|
import { writingsController } from '../services/writingsController'
|
||||||
import { fetchAllReads, ReadItem } from '../services/readsService'
|
import { fetchAllReads, ReadItem } from '../services/readsService'
|
||||||
import { fetchLinks } from '../services/linksService'
|
import { fetchLinks } from '../services/linksService'
|
||||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
import { BlogPostPreview } from '../services/exploreService'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
import AuthorCard from './AuthorCard'
|
import AuthorCard from './AuthorCard'
|
||||||
import BlogPostCard from './BlogPostCard'
|
import BlogPostCard from './BlogPostCard'
|
||||||
@@ -33,17 +31,12 @@ import { filterByReadingProgress } from '../utils/readingProgressUtils'
|
|||||||
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
|
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
|
||||||
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
|
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
|
||||||
import { mergeReadItem } from '../utils/readItemMerge'
|
import { mergeReadItem } from '../utils/readItemMerge'
|
||||||
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
|
||||||
import { KINDS } from '../config/kinds'
|
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
|
||||||
|
|
||||||
interface MeProps {
|
interface MeProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
eventStore: IEventStore
|
eventStore: IEventStore
|
||||||
activeTab?: TabType
|
activeTab?: TabType
|
||||||
pubkey?: string // Optional pubkey for viewing other users' profiles
|
|
||||||
bookmarks: Bookmark[] // From centralized App.tsx state
|
bookmarks: Bookmark[] // From centralized App.tsx state
|
||||||
bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use)
|
bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use)
|
||||||
}
|
}
|
||||||
@@ -51,13 +44,12 @@ interface MeProps {
|
|||||||
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
||||||
|
|
||||||
// Valid reading progress filters
|
// Valid reading progress filters
|
||||||
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed']
|
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted']
|
||||||
|
|
||||||
const Me: React.FC<MeProps> = ({
|
const Me: React.FC<MeProps> = ({
|
||||||
relayPool,
|
relayPool,
|
||||||
eventStore,
|
eventStore,
|
||||||
activeTab: propActiveTab,
|
activeTab: propActiveTab,
|
||||||
pubkey: propPubkey,
|
|
||||||
bookmarks
|
bookmarks
|
||||||
}) => {
|
}) => {
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
@@ -65,9 +57,8 @@ const Me: React.FC<MeProps> = ({
|
|||||||
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
||||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||||
|
|
||||||
// Use provided pubkey or fall back to active account
|
// Only for own profile
|
||||||
const viewingPubkey = propPubkey || activeAccount?.pubkey
|
const viewingPubkey = activeAccount?.pubkey
|
||||||
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
|
||||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||||
const [reads, setReads] = useState<ReadItem[]>([])
|
const [reads, setReads] = useState<ReadItem[]>([])
|
||||||
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
|
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
|
||||||
@@ -85,29 +76,6 @@ const Me: React.FC<MeProps> = ({
|
|||||||
const [myWritings, setMyWritings] = useState<BlogPostPreview[]>([])
|
const [myWritings, setMyWritings] = useState<BlogPostPreview[]>([])
|
||||||
const [myWritingsLoading, setMyWritingsLoading] = useState(false)
|
const [myWritingsLoading, setMyWritingsLoading] = useState(false)
|
||||||
|
|
||||||
// Load cached data from event store for OTHER profiles (not own)
|
|
||||||
const cachedHighlights = useStoreTimeline(
|
|
||||||
eventStore,
|
|
||||||
!isOwnProfile && viewingPubkey ? { kinds: [KINDS.Highlights], authors: [viewingPubkey] } : { kinds: [KINDS.Highlights], limit: 0 },
|
|
||||||
eventToHighlight,
|
|
||||||
[viewingPubkey, isOwnProfile]
|
|
||||||
)
|
|
||||||
|
|
||||||
const toBlogPostPreview = useMemo(() => (event: NostrEvent): BlogPostPreview => ({
|
|
||||||
event,
|
|
||||||
title: getArticleTitle(event) || 'Untitled',
|
|
||||||
summary: getArticleSummary(event),
|
|
||||||
image: getArticleImage(event),
|
|
||||||
published: getArticlePublished(event),
|
|
||||||
author: event.pubkey
|
|
||||||
}), [])
|
|
||||||
|
|
||||||
const cachedWritings = useStoreTimeline(
|
|
||||||
eventStore,
|
|
||||||
!isOwnProfile && viewingPubkey ? { kinds: [30023], authors: [viewingPubkey] } : { kinds: [30023], limit: 0 },
|
|
||||||
toBlogPostPreview,
|
|
||||||
[viewingPubkey, isOwnProfile]
|
|
||||||
)
|
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
||||||
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
||||||
@@ -126,6 +94,9 @@ const Me: React.FC<MeProps> = ({
|
|||||||
? (urlFilter as ReadingProgressFilterType)
|
? (urlFilter as ReadingProgressFilterType)
|
||||||
: 'all'
|
: 'all'
|
||||||
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
|
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
|
||||||
|
|
||||||
|
// Reading progress state for writings tab (naddr -> progress 0-1)
|
||||||
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
// Subscribe to highlights controller
|
// Subscribe to highlights controller
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -181,93 +152,56 @@ const Me: React.FC<MeProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscribe to reading progress controller
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial state immediately
|
||||||
|
setReadingProgressMap(readingProgressController.getProgressMap())
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
const unsubProgress = readingProgressController.onProgress(setReadingProgressMap)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubProgress()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load reading progress data for writings tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (!viewingPubkey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
readingProgressController.start({
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
pubkey: viewingPubkey,
|
||||||
|
force: refreshTrigger > 0
|
||||||
|
})
|
||||||
|
}, [viewingPubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
// Tab-specific loading functions
|
// Tab-specific loading functions
|
||||||
const loadHighlightsTab = async () => {
|
const loadHighlightsTab = async () => {
|
||||||
if (!viewingPubkey) return
|
if (!viewingPubkey) return
|
||||||
|
|
||||||
// Only show loading skeleton if tab hasn't been loaded yet AND no cached data
|
// Highlights come from controller subscription (sync effect handles it)
|
||||||
const hasBeenLoaded = loadedTabs.has('highlights')
|
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
||||||
const hasCachedData = cachedHighlights.length > 0
|
setLoading(false)
|
||||||
|
|
||||||
try {
|
|
||||||
// For own profile, highlights come from controller subscription (sync effect handles it)
|
|
||||||
if (isOwnProfile) {
|
|
||||||
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// For viewing other users, seed with cached data immediately (non-blocking)
|
|
||||||
if (hasCachedData) {
|
|
||||||
setHighlights(cachedHighlights.sort((a, b) => b.created_at - a.created_at))
|
|
||||||
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
|
||||||
setLoading(false)
|
|
||||||
} else if (!hasBeenLoaded) {
|
|
||||||
setLoading(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch fresh highlights in background and merge
|
|
||||||
fetchHighlights(relayPool, viewingPubkey)
|
|
||||||
.then(userHighlights => {
|
|
||||||
setHighlights(userHighlights)
|
|
||||||
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('Failed to load highlights:', err)
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load highlights:', err)
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadWritingsTab = async () => {
|
const loadWritingsTab = async () => {
|
||||||
if (!viewingPubkey) return
|
if (!viewingPubkey) return
|
||||||
|
|
||||||
const hasBeenLoaded = loadedTabs.has('writings')
|
|
||||||
const hasCachedData = cachedWritings.length > 0
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// For own profile, use centralized controller
|
// Use centralized controller
|
||||||
if (isOwnProfile) {
|
await writingsController.start({
|
||||||
await writingsController.start({
|
relayPool,
|
||||||
relayPool,
|
eventStore,
|
||||||
eventStore,
|
pubkey: viewingPubkey,
|
||||||
pubkey: viewingPubkey,
|
force: refreshTrigger > 0
|
||||||
force: refreshTrigger > 0
|
})
|
||||||
})
|
setLoadedTabs(prev => new Set(prev).add('writings'))
|
||||||
setLoadedTabs(prev => new Set(prev).add('writings'))
|
setLoading(false)
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other profiles, seed with cached writings immediately (non-blocking)
|
|
||||||
if (hasCachedData) {
|
|
||||||
setWritings(cachedWritings.sort((a, b) => {
|
|
||||||
const timeA = a.published || a.event.created_at
|
|
||||||
const timeB = b.published || b.event.created_at
|
|
||||||
return timeB - timeA
|
|
||||||
}))
|
|
||||||
setLoadedTabs(prev => new Set(prev).add('writings'))
|
|
||||||
setLoading(false)
|
|
||||||
} else if (!hasBeenLoaded) {
|
|
||||||
setLoading(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch fresh writings in background and merge
|
|
||||||
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
|
||||||
.then(userWritings => {
|
|
||||||
setWritings(userWritings)
|
|
||||||
setLoadedTabs(prev => new Set(prev).add('writings'))
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('Failed to load writings:', err)
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load writings:', err)
|
console.error('Failed to load writings:', err)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -275,7 +209,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadReadingListTab = async () => {
|
const loadReadingListTab = async () => {
|
||||||
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
if (!viewingPubkey || !activeAccount) return
|
||||||
|
|
||||||
const hasBeenLoaded = loadedTabs.has('reading-list')
|
const hasBeenLoaded = loadedTabs.has('reading-list')
|
||||||
|
|
||||||
@@ -291,7 +225,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadReadsTab = async () => {
|
const loadReadsTab = async () => {
|
||||||
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
if (!viewingPubkey || !activeAccount) return
|
||||||
|
|
||||||
const hasBeenLoaded = loadedTabs.has('reads')
|
const hasBeenLoaded = loadedTabs.has('reads')
|
||||||
|
|
||||||
@@ -309,23 +243,15 @@ const Me: React.FC<MeProps> = ({
|
|||||||
// Background enrichment: merge reading progress and mark-as-read
|
// Background enrichment: merge reading progress and mark-as-read
|
||||||
// Only update items that are already in our map
|
// Only update items that are already in our map
|
||||||
fetchAllReads(relayPool, viewingPubkey, bookmarks, (item) => {
|
fetchAllReads(relayPool, viewingPubkey, bookmarks, (item) => {
|
||||||
console.log('📈 [Reads] Enrichment item received:', {
|
|
||||||
id: item.id.slice(0, 20) + '...',
|
|
||||||
progress: item.readingProgress,
|
|
||||||
hasProgress: item.readingProgress !== undefined && item.readingProgress > 0
|
|
||||||
})
|
|
||||||
|
|
||||||
setReadsMap(prevMap => {
|
setReadsMap(prevMap => {
|
||||||
// Only update if item exists in our current map
|
// Only update if item exists in our current map
|
||||||
if (!prevMap.has(item.id)) {
|
if (!prevMap.has(item.id)) {
|
||||||
console.log('⚠️ [Reads] Item not in map, skipping:', item.id.slice(0, 20) + '...')
|
|
||||||
return prevMap
|
return prevMap
|
||||||
}
|
}
|
||||||
|
|
||||||
const newMap = new Map(prevMap)
|
const newMap = new Map(prevMap)
|
||||||
const merged = mergeReadItem(newMap, item)
|
const merged = mergeReadItem(newMap, item)
|
||||||
if (merged) {
|
if (merged) {
|
||||||
console.log('✅ [Reads] Merged progress:', item.id.slice(0, 20) + '...', item.readingProgress)
|
|
||||||
// Update reads array after map is updated
|
// Update reads array after map is updated
|
||||||
setReads(Array.from(newMap.values()))
|
setReads(Array.from(newMap.values()))
|
||||||
return newMap
|
return newMap
|
||||||
@@ -341,7 +267,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadLinksTab = async () => {
|
const loadLinksTab = async () => {
|
||||||
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
if (!viewingPubkey || !activeAccount) return
|
||||||
|
|
||||||
const hasBeenLoaded = loadedTabs.has('links')
|
const hasBeenLoaded = loadedTabs.has('links')
|
||||||
|
|
||||||
@@ -387,14 +313,12 @@ const Me: React.FC<MeProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load cached data immediately if available
|
// Load cached data immediately if available
|
||||||
if (isOwnProfile) {
|
const cached = getCachedMeData(viewingPubkey)
|
||||||
const cached = getCachedMeData(viewingPubkey)
|
if (cached) {
|
||||||
if (cached) {
|
setHighlights(cached.highlights)
|
||||||
setHighlights(cached.highlights)
|
// Bookmarks come from App.tsx centralized state, no local caching needed
|
||||||
// Bookmarks come from App.tsx centralized state, no local caching needed
|
setReads(cached.reads || [])
|
||||||
setReads(cached.reads || [])
|
setLinks(cached.links || [])
|
||||||
setLinks(cached.links || [])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load data for active tab (refresh in background if already loaded)
|
// Load data for active tab (refresh in background if already loaded)
|
||||||
@@ -416,49 +340,17 @@ const Me: React.FC<MeProps> = ({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeTab, viewingPubkey, refreshTrigger])
|
}, [activeTab, viewingPubkey, refreshTrigger, bookmarks])
|
||||||
|
|
||||||
// Sync myHighlights from controller when viewing own profile
|
// Sync myHighlights from controller
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOwnProfile) {
|
setHighlights(myHighlights)
|
||||||
setHighlights(myHighlights)
|
}, [myHighlights])
|
||||||
}
|
|
||||||
}, [isOwnProfile, myHighlights])
|
|
||||||
|
|
||||||
// Sync myWritings from controller when viewing own profile
|
// Sync myWritings from controller
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOwnProfile) {
|
setWritings(myWritings)
|
||||||
setWritings(myWritings)
|
}, [myWritings])
|
||||||
}
|
|
||||||
}, [isOwnProfile, myWritings])
|
|
||||||
|
|
||||||
// Preload all highlights and writings for profile pages (non-blocking)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOwnProfile && viewingPubkey && relayPool && eventStore) {
|
|
||||||
// Fire and forget - non-blocking background fetch
|
|
||||||
console.log('🔄 [Profile] Preloading highlights and writings for', viewingPubkey.slice(0, 8))
|
|
||||||
|
|
||||||
// Fetch highlights in background
|
|
||||||
fetchHighlights(relayPool, viewingPubkey, undefined, undefined, false, eventStore)
|
|
||||||
.then(highlights => {
|
|
||||||
console.log('✅ [Profile] Preloaded', highlights.length, 'highlights into event store')
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.warn('⚠️ [Profile] Failed to preload highlights:', err)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fetch writings in background
|
|
||||||
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
|
||||||
.then(writings => {
|
|
||||||
// Store writings in event store
|
|
||||||
writings.forEach(w => eventStore.add(w.event))
|
|
||||||
console.log('✅ [Profile] Preloaded', writings.length, 'writings into event store')
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.warn('⚠️ [Profile] Failed to preload writings:', err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [isOwnProfile, viewingPubkey, relayPool, eventStore])
|
|
||||||
|
|
||||||
// Pull-to-refresh - reload active tab without clearing state
|
// Pull-to-refresh - reload active tab without clearing state
|
||||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
@@ -474,8 +366,8 @@ const Me: React.FC<MeProps> = ({
|
|||||||
const handleHighlightDelete = (highlightId: string) => {
|
const handleHighlightDelete = (highlightId: string) => {
|
||||||
setHighlights(prev => {
|
setHighlights(prev => {
|
||||||
const updated = prev.filter(h => h.id !== highlightId)
|
const updated = prev.filter(h => h.id !== highlightId)
|
||||||
// Update cache when highlight is deleted (own profile only)
|
// Update cache when highlight is deleted
|
||||||
if (isOwnProfile && viewingPubkey) {
|
if (viewingPubkey) {
|
||||||
updateCachedHighlights(viewingPubkey, updated)
|
updateCachedHighlights(viewingPubkey, updated)
|
||||||
}
|
}
|
||||||
return updated
|
return updated
|
||||||
@@ -553,6 +445,42 @@ const Me: React.FC<MeProps> = ({
|
|||||||
navigate(`/r/${encodeURIComponent(url)}`)
|
navigate(`/r/${encodeURIComponent(url)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to get reading progress for a post
|
||||||
|
const getWritingReadingProgress = (post: BlogPostPreview): number | undefined => {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) return undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: post.author,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
return readingProgressMap.get(naddr)
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get reading progress for a bookmark
|
||||||
|
const getBookmarkReadingProgress = (bookmark: IndividualBookmark): number | undefined => {
|
||||||
|
if (bookmark.kind === 30023) {
|
||||||
|
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) return undefined
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: bookmark.pubkey,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
return readingProgressMap.get(naddr)
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
// 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 || [])
|
||||||
@@ -563,23 +491,44 @@ const Me: React.FC<MeProps> = ({
|
|||||||
|
|
||||||
const groups = groupIndividualBookmarks(filteredBookmarks)
|
const groups = groupIndividualBookmarks(filteredBookmarks)
|
||||||
|
|
||||||
|
// Enrich reads and links with reading progress from controller
|
||||||
|
const readsWithProgress = reads.map(item => {
|
||||||
|
if (item.type === 'article' && item.author) {
|
||||||
|
const progress = readingProgressMap.get(item.id)
|
||||||
|
if (progress !== undefined) {
|
||||||
|
return { ...item, readingProgress: progress }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
|
||||||
|
const linksWithProgress = links.map(item => {
|
||||||
|
if (item.url) {
|
||||||
|
const progress = readingProgressMap.get(item.url)
|
||||||
|
if (progress !== undefined) {
|
||||||
|
return { ...item, readingProgress: progress }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
|
||||||
// Apply reading progress filter
|
// Apply reading progress filter
|
||||||
const filteredReads = filterByReadingProgress(reads, readingProgressFilter)
|
const filteredReads = filterByReadingProgress(readsWithProgress, readingProgressFilter, highlights)
|
||||||
const filteredLinks = filterByReadingProgress(links, readingProgressFilter)
|
const filteredLinks = filterByReadingProgress(linksWithProgress, readingProgressFilter, highlights)
|
||||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||||
groupingMode === 'flat'
|
groupingMode === 'flat'
|
||||||
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
|
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
|
||||||
: [
|
: [
|
||||||
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
||||||
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
||||||
{ key: 'amethyst-private', title: 'Amethyst Private', items: groups.amethystPrivate },
|
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
|
||||||
{ key: 'amethyst-public', title: 'Amethyst Lists', items: groups.amethystPublic },
|
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
|
||||||
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
|
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
|
||||||
]
|
]
|
||||||
|
|
||||||
// Show content progressively - no blocking error screens
|
// Show content progressively - no blocking error screens
|
||||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
|
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
|
||||||
const showSkeletons = (loading || (isOwnProfile && myHighlightsLoading)) && !hasData
|
const showSkeletons = (loading || myHighlightsLoading) && !hasData
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
@@ -593,7 +542,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return highlights.length === 0 && !loading && !(isOwnProfile && myHighlightsLoading) ? (
|
return highlights.length === 0 && !loading && !myHighlightsLoading ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
No highlights yet.
|
No highlights yet.
|
||||||
</div>
|
</div>
|
||||||
@@ -650,6 +599,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
index={index}
|
index={index}
|
||||||
viewMode="cards"
|
viewMode="cards"
|
||||||
onSelectUrl={handleSelectUrl}
|
onSelectUrl={handleSelectUrl}
|
||||||
|
readingProgress={getBookmarkReadingProgress(individualBookmark)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -778,7 +728,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return writings.length === 0 && !loading && !(isOwnProfile && myWritingsLoading) ? (
|
return writings.length === 0 && !loading && !myWritingsLoading ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
No articles written yet.
|
No articles written yet.
|
||||||
</div>
|
</div>
|
||||||
@@ -789,6 +739,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
key={post.event.id}
|
key={post.event.id}
|
||||||
post={post}
|
post={post}
|
||||||
href={getPostUrl(post)}
|
href={getPostUrl(post)}
|
||||||
|
readingProgress={getWritingReadingProgress(post)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -812,43 +763,39 @@ const Me: React.FC<MeProps> = ({
|
|||||||
<button
|
<button
|
||||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||||
data-tab="highlights"
|
data-tab="highlights"
|
||||||
onClick={() => navigate(isOwnProfile ? '/me/highlights' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}`)}
|
onClick={() => navigate('/me/highlights')}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faHighlighter} />
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
<span className="tab-label">Highlights</span>
|
<span className="tab-label">Highlights</span>
|
||||||
</button>
|
</button>
|
||||||
{isOwnProfile && (
|
<button
|
||||||
<>
|
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
|
||||||
<button
|
data-tab="reading-list"
|
||||||
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
|
onClick={() => navigate('/me/reading-list')}
|
||||||
data-tab="reading-list"
|
>
|
||||||
onClick={() => navigate('/me/reading-list')}
|
<FontAwesomeIcon icon={faBookmark} />
|
||||||
>
|
<span className="tab-label">Bookmarks</span>
|
||||||
<FontAwesomeIcon icon={faBookmark} />
|
</button>
|
||||||
<span className="tab-label">Bookmarks</span>
|
<button
|
||||||
</button>
|
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
|
||||||
<button
|
data-tab="reads"
|
||||||
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
|
onClick={() => navigate('/me/reads')}
|
||||||
data-tab="reads"
|
>
|
||||||
onClick={() => navigate('/me/reads')}
|
<FontAwesomeIcon icon={faBooks} />
|
||||||
>
|
<span className="tab-label">Reads</span>
|
||||||
<FontAwesomeIcon icon={faBooks} />
|
</button>
|
||||||
<span className="tab-label">Reads</span>
|
<button
|
||||||
</button>
|
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
|
||||||
<button
|
data-tab="links"
|
||||||
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
|
onClick={() => navigate('/me/links')}
|
||||||
data-tab="links"
|
>
|
||||||
onClick={() => navigate('/me/links')}
|
<FontAwesomeIcon icon={faLink} />
|
||||||
>
|
<span className="tab-label">Links</span>
|
||||||
<FontAwesomeIcon icon={faLink} />
|
</button>
|
||||||
<span className="tab-label">Links</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||||
data-tab="writings"
|
data-tab="writings"
|
||||||
onClick={() => navigate(isOwnProfile ? '/me/writings' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}/writings`)}
|
onClick={() => navigate('/me/writings')}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPenToSquare} />
|
<FontAwesomeIcon icon={faPenToSquare} />
|
||||||
<span className="tab-label">Writings</span>
|
<span className="tab-label">Writings</span>
|
||||||
|
|||||||
271
src/components/Profile.tsx
Normal file
271
src/components/Profile.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faHighlighter, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { HighlightItem } from './HighlightItem'
|
||||||
|
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||||
|
import { fetchHighlights } from '../services/highlightService'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import AuthorCard from './AuthorCard'
|
||||||
|
import BlogPostCard from './BlogPostCard'
|
||||||
|
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||||
|
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||||
|
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
|
import { toBlogPostPreview } from '../utils/toBlogPostPreview'
|
||||||
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
|
|
||||||
|
interface ProfileProps {
|
||||||
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
|
pubkey: string
|
||||||
|
activeTab?: 'highlights' | 'writings'
|
||||||
|
}
|
||||||
|
|
||||||
|
const Profile: React.FC<ProfileProps> = ({
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
pubkey,
|
||||||
|
activeTab: propActiveTab
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights')
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
|
|
||||||
|
// Reading progress state (naddr -> progress 0-1)
|
||||||
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
|
// Load cached data from event store instantly
|
||||||
|
const cachedHighlights = useStoreTimeline(
|
||||||
|
eventStore,
|
||||||
|
{ kinds: [KINDS.Highlights], authors: [pubkey] },
|
||||||
|
eventToHighlight,
|
||||||
|
[pubkey]
|
||||||
|
)
|
||||||
|
|
||||||
|
const cachedWritings = useStoreTimeline(
|
||||||
|
eventStore,
|
||||||
|
{ kinds: [30023], authors: [pubkey] },
|
||||||
|
toBlogPostPreview,
|
||||||
|
[pubkey]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update local state when prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (propActiveTab) {
|
||||||
|
setActiveTab(propActiveTab)
|
||||||
|
}
|
||||||
|
}, [propActiveTab])
|
||||||
|
|
||||||
|
// Subscribe to reading progress controller
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial state immediately
|
||||||
|
const initialMap = readingProgressController.getProgressMap()
|
||||||
|
setReadingProgressMap(initialMap)
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
const unsubProgress = readingProgressController.onProgress((newMap) => {
|
||||||
|
setReadingProgressMap(newMap)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubProgress()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load reading progress data when logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAccount?.pubkey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
readingProgressController.start({
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
pubkey: activeAccount.pubkey,
|
||||||
|
force: refreshTrigger > 0
|
||||||
|
})
|
||||||
|
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
|
// Background fetch to populate event store (non-blocking)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pubkey || !relayPool || !eventStore) return
|
||||||
|
|
||||||
|
|
||||||
|
// Fetch highlights in background
|
||||||
|
fetchHighlights(relayPool, pubkey, undefined, undefined, false, eventStore)
|
||||||
|
.then(() => {
|
||||||
|
// Highlights fetched
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.warn('⚠️ [Profile] Failed to fetch highlights:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch writings in background (no limit for single user profile)
|
||||||
|
fetchBlogPostsFromAuthors(relayPool, [pubkey], RELAYS, undefined, null)
|
||||||
|
.then(writings => {
|
||||||
|
writings.forEach(w => eventStore.add(w.event))
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.warn('⚠️ [Profile] Failed to fetch writings:', err)
|
||||||
|
})
|
||||||
|
}, [pubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
|
// Pull-to-refresh
|
||||||
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
|
onRefresh: () => {
|
||||||
|
setRefreshTrigger(prev => prev + 1)
|
||||||
|
},
|
||||||
|
maximumPullLength: 240,
|
||||||
|
refreshThreshold: 80,
|
||||||
|
isDisabled: !pubkey
|
||||||
|
})
|
||||||
|
|
||||||
|
const getPostUrl = (post: BlogPostPreview) => {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: post.author,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
return `/a/${naddr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get reading progress for a post
|
||||||
|
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) return undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: post.author,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
const progress = readingProgressMap.get(naddr)
|
||||||
|
|
||||||
|
// Only log when found or map is empty
|
||||||
|
if (progress || readingProgressMap.size === 0) {
|
||||||
|
// Progress found or map is empty
|
||||||
|
}
|
||||||
|
|
||||||
|
return progress
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}, [readingProgressMap])
|
||||||
|
|
||||||
|
const handleHighlightDelete = () => {
|
||||||
|
// Not allowed to delete other users' highlights
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const npub = nip19.npubEncode(pubkey)
|
||||||
|
const showSkeletons = cachedHighlights.length === 0 && cachedWritings.length === 0
|
||||||
|
|
||||||
|
const renderTabContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'highlights':
|
||||||
|
if (showSkeletons) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<HighlightSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return cachedHighlights.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No highlights yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="highlights-list me-highlights-list">
|
||||||
|
{cachedHighlights.map((highlight) => (
|
||||||
|
<HighlightItem
|
||||||
|
key={highlight.id}
|
||||||
|
highlight={{ ...highlight, level: 'mine' }}
|
||||||
|
relayPool={relayPool}
|
||||||
|
onHighlightDelete={handleHighlightDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'writings':
|
||||||
|
if (showSkeletons) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BlogPostSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return cachedWritings.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No articles written yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{cachedWritings.map((post) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={post.event.id}
|
||||||
|
post={post}
|
||||||
|
href={getPostUrl(post)}
|
||||||
|
readingProgress={getReadingProgress(post)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="explore-container">
|
||||||
|
<RefreshIndicator
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
pullPosition={pullPosition}
|
||||||
|
/>
|
||||||
|
<div className="explore-header">
|
||||||
|
<AuthorCard authorPubkey={pubkey} clickable={false} />
|
||||||
|
|
||||||
|
<div className="me-tabs">
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||||
|
data-tab="highlights"
|
||||||
|
onClick={() => navigate(`/p/${npub}`)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
|
<span className="tab-label">Highlights</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||||
|
data-tab="writings"
|
||||||
|
onClick={() => navigate(`/p/${npub}/writings`)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPenToSquare} />
|
||||||
|
<span className="tab-label">Writings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="me-tab-content">
|
||||||
|
{renderTabContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Profile
|
||||||
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
import { faBookOpen, faCheckCircle, faAsterisk, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
|
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
|
||||||
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed'
|
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted'
|
||||||
|
|
||||||
interface ReadingProgressFiltersProps {
|
interface ReadingProgressFiltersProps {
|
||||||
selectedFilter: ReadingProgressFilterType
|
selectedFilter: ReadingProgressFilterType
|
||||||
@@ -16,6 +16,7 @@ const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selecte
|
|||||||
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
|
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
|
||||||
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
|
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
|
||||||
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||||
|
{ type: 'highlighted' as const, icon: faHighlighter, label: 'Highlighted' },
|
||||||
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
|
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -23,8 +24,15 @@ const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selecte
|
|||||||
<div className="bookmark-filters">
|
<div className="bookmark-filters">
|
||||||
{filters.map(filter => {
|
{filters.map(filter => {
|
||||||
const isActive = selectedFilter === filter.type
|
const isActive = selectedFilter === filter.type
|
||||||
// Only "completed" gets green color, everything else uses default blue
|
// Only "completed" gets green color, "highlighted" gets yellow, everything else uses default blue
|
||||||
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
|
let activeStyle: Record<string, string> | undefined = undefined
|
||||||
|
if (isActive) {
|
||||||
|
if (filter.type === 'completed') {
|
||||||
|
activeStyle = { color: '#10b981' } // green
|
||||||
|
} else if (filter.type === 'highlighted') {
|
||||||
|
activeStyle = { color: '#fde047' } // yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -50,16 +50,8 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
|
|||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('🔌 Relay Status Indicator:', {
|
// Mode and relay status determined
|
||||||
mode: isConnecting ? 'CONNECTING' : offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
|
}, [isConnecting, offlineMode, localOnlyMode, relayStatuses, hasLocalRelay, hasRemoteRelay])
|
||||||
totalStatuses: relayStatuses.length,
|
|
||||||
connectedCount: connectedUrls.length,
|
|
||||||
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
|
|
||||||
hasLocalRelay,
|
|
||||||
hasRemoteRelay,
|
|
||||||
isConnecting
|
|
||||||
})
|
|
||||||
}, [offlineMode, localOnlyMode, connectedUrls, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
|
|
||||||
|
|
||||||
// Don't show indicator when fully connected (but show when connecting)
|
// Don't show indicator when fully connected (but show when connecting)
|
||||||
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
||||||
@@ -156,7 +148,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
|
|||||||
fontWeight: 400
|
fontWeight: 400
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}
|
Local relays only
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
useLocalRelayAsCache: true,
|
useLocalRelayAsCache: true,
|
||||||
rebroadcastToAllRelays: false,
|
rebroadcastToAllRelays: false,
|
||||||
paragraphAlignment: 'justify',
|
paragraphAlignment: 'justify',
|
||||||
syncReadingPosition: false,
|
syncReadingPosition: true,
|
||||||
|
autoMarkAsReadOnCompletion: false,
|
||||||
|
hideBookmarksWithoutCreationDate: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
|
|||||||
@@ -117,6 +117,32 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
|||||||
<span>Sync reading position across devices</span>
|
<span>Sync reading position across devices</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="autoMarkAsReadOnCompletion" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="autoMarkAsReadOnCompletion"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.autoMarkAsReadOnCompletion ?? false}
|
||||||
|
onChange={(e) => onUpdate({ autoMarkAsReadOnCompletion: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Automatically mark as read at 100%</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="hideBookmarksWithoutCreationDate" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="hideBookmarksWithoutCreationDate"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.hideBookmarksWithoutCreationDate ?? false}
|
||||||
|
onChange={(e) => onUpdate({ hideBookmarksWithoutCreationDate: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Hide bookmarks missing a creation date</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
|||||||
if (isInstalled) return
|
if (isInstalled) return
|
||||||
const success = await installApp()
|
const success = await installApp()
|
||||||
if (success) {
|
if (success) {
|
||||||
console.log('App installed successfully')
|
// Installation successful
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Nostr event kinds used throughout the application
|
// Nostr event kinds used throughout the application
|
||||||
export const KINDS = {
|
export const KINDS = {
|
||||||
Highlights: 9802, // NIP-?? user highlights
|
Highlights: 9802, // NIP-84 user highlights
|
||||||
BlogPost: 30023, // NIP-23 long-form article
|
BlogPost: 30023, // NIP-23 long-form article
|
||||||
AppData: 30078, // NIP-78 application data (reading positions)
|
AppData: 30078, // NIP-78 application data
|
||||||
|
ReadingProgress: 39802, // NIP-85 reading progress
|
||||||
List: 30001, // NIP-51 list (addressable)
|
List: 30001, // NIP-51 list (addressable)
|
||||||
ListReplaceable: 30003, // NIP-51 replaceable list
|
ListReplaceable: 30003, // NIP-51 replaceable list
|
||||||
ListSimple: 10003, // NIP-51 simple list
|
ListSimple: 10003, // NIP-51 simple list
|
||||||
@@ -13,3 +14,9 @@ export const KINDS = {
|
|||||||
|
|
||||||
export type KindValue = typeof KINDS[keyof typeof KINDS]
|
export type KindValue = typeof KINDS[keyof typeof KINDS]
|
||||||
|
|
||||||
|
// Reading progress tracking configuration
|
||||||
|
export const READING_PROGRESS = {
|
||||||
|
// Minimum character count to track reading progress (roughly 150 words)
|
||||||
|
MIN_CONTENT_LENGTH: 1000
|
||||||
|
} as const
|
||||||
|
|
||||||
|
|||||||
@@ -43,21 +43,14 @@ export function useAdaptiveTextColor(imageUrl: string | undefined): AdaptiveText
|
|||||||
height: Math.floor(height * 0.25)
|
height: Math.floor(height * 0.25)
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('Adaptive color detected:', {
|
// Color analysis complete
|
||||||
hex: color.hex,
|
|
||||||
rgb: color.rgb,
|
|
||||||
isLight: color.isLight,
|
|
||||||
isDark: color.isDark
|
|
||||||
})
|
|
||||||
|
|
||||||
// Use library's built-in isLight check for optimal contrast
|
// Use library's built-in isLight check for optimal contrast
|
||||||
if (color.isLight) {
|
if (color.isLight) {
|
||||||
console.log('Light background detected, using black text')
|
|
||||||
setColors({
|
setColors({
|
||||||
textColor: '#000000'
|
textColor: '#000000'
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
console.log('Dark background detected, using white text')
|
|
||||||
setColors({
|
setColors({
|
||||||
textColor: '#ffffff'
|
textColor: '#ffffff'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ export function useArticleLoader({
|
|||||||
setCurrentArticleEventId(article.event.id)
|
setCurrentArticleEventId(article.event.id)
|
||||||
setCurrentArticle?.(article.event)
|
setCurrentArticle?.(article.event)
|
||||||
|
|
||||||
console.log('📰 Article loaded:', article.title)
|
|
||||||
console.log('📍 Coordinate:', articleCoordinate)
|
|
||||||
|
|
||||||
// Set reader loading to false immediately after article content is ready
|
// Set reader loading to false immediately after article content is ready
|
||||||
// Don't wait for highlights to finish loading
|
// Don't wait for highlights to finish loading
|
||||||
@@ -92,7 +90,6 @@ export function useArticleLoader({
|
|||||||
},
|
},
|
||||||
settings
|
settings
|
||||||
)
|
)
|
||||||
console.log(`📌 Found ${highlightsMap.size} highlights`)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch highlights:', err)
|
console.error('Failed to fetch highlights:', err)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ export function useExternalUrlLoader({
|
|||||||
const content = await fetchReadableContent(url)
|
const content = await fetchReadableContent(url)
|
||||||
setReaderContent(content)
|
setReaderContent(content)
|
||||||
|
|
||||||
console.log('🌐 External URL loaded:', content.title)
|
|
||||||
|
|
||||||
// Set reader loading to false immediately after content is ready
|
// Set reader loading to false immediately after content is ready
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export const useHighlightCreation = ({
|
|||||||
? currentArticle.content
|
? currentArticle.content
|
||||||
: readerContent?.markdown || readerContent?.html
|
: readerContent?.markdown || readerContent?.html
|
||||||
|
|
||||||
console.log('🎯 Creating highlight...', { text: text.substring(0, 50) + '...' })
|
|
||||||
|
|
||||||
const newHighlight = await createHighlight(
|
const newHighlight = await createHighlight(
|
||||||
text,
|
text,
|
||||||
@@ -73,12 +72,7 @@ export const useHighlightCreation = ({
|
|||||||
settings
|
settings
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log('✅ Highlight created successfully!', {
|
// Highlight created successfully
|
||||||
id: newHighlight.id,
|
|
||||||
isLocalOnly: newHighlight.isLocalOnly,
|
|
||||||
isOfflineCreated: newHighlight.isOfflineCreated,
|
|
||||||
publishedRelays: newHighlight.publishedRelays
|
|
||||||
})
|
|
||||||
|
|
||||||
// Clear the browser's text selection immediately to allow DOM update
|
// Clear the browser's text selection immediately to allow DOM update
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
|
|||||||
@@ -32,14 +32,7 @@ export const useHighlightedContent = ({
|
|||||||
}: UseHighlightedContentParams) => {
|
}: UseHighlightedContentParams) => {
|
||||||
// Filter highlights by URL and visibility settings
|
// Filter highlights by URL and visibility settings
|
||||||
const relevantHighlights = useMemo(() => {
|
const relevantHighlights = useMemo(() => {
|
||||||
console.log('🔍 ContentPanel: Processing highlights', {
|
|
||||||
totalHighlights: highlights.length,
|
|
||||||
selectedUrl,
|
|
||||||
showHighlights
|
|
||||||
})
|
|
||||||
|
|
||||||
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
|
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
|
||||||
console.log('📌 URL filtered highlights:', urlFiltered.length)
|
|
||||||
|
|
||||||
// Apply visibility filtering
|
// Apply visibility filtering
|
||||||
const classified = classifyHighlights(urlFiltered, currentUserPubkey, followedPubkeys)
|
const classified = classifyHighlights(urlFiltered, currentUserPubkey, followedPubkeys)
|
||||||
@@ -49,37 +42,25 @@ export const useHighlightedContent = ({
|
|||||||
return highlightVisibility.nostrverse
|
return highlightVisibility.nostrverse
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('✅ Relevant highlights after filtering:', filtered.length, filtered.map(h => h.content.substring(0, 30)))
|
|
||||||
return filtered
|
return filtered
|
||||||
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys, showHighlights])
|
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys])
|
||||||
|
|
||||||
// Prepare the final HTML with highlights applied
|
// Prepare the final HTML with highlights applied
|
||||||
const finalHtml = useMemo(() => {
|
const finalHtml = useMemo(() => {
|
||||||
const sourceHtml = markdown ? renderedMarkdownHtml : html
|
const sourceHtml = markdown ? renderedMarkdownHtml : html
|
||||||
|
|
||||||
console.log('🎨 Preparing final HTML:', {
|
// Prepare final HTML
|
||||||
hasMarkdown: !!markdown,
|
|
||||||
hasHtml: !!html,
|
|
||||||
renderedHtmlLength: renderedMarkdownHtml.length,
|
|
||||||
sourceHtmlLength: sourceHtml?.length || 0,
|
|
||||||
showHighlights,
|
|
||||||
relevantHighlightsCount: relevantHighlights.length
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!sourceHtml) {
|
if (!sourceHtml) {
|
||||||
console.warn('⚠️ No source HTML available')
|
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showHighlights && relevantHighlights.length > 0) {
|
if (showHighlights && relevantHighlights.length > 0) {
|
||||||
console.log('✨ Applying', relevantHighlights.length, 'highlights to HTML')
|
|
||||||
const highlightedHtml = applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
|
const highlightedHtml = applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
|
||||||
console.log('✅ Highlights applied, result length:', highlightedHtml.length)
|
|
||||||
return highlightedHtml
|
return highlightedHtml
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📄 Returning source HTML without highlights')
|
|
||||||
return sourceHtml
|
return sourceHtml
|
||||||
|
|
||||||
}, [html, renderedMarkdownHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
|
}, [html, renderedMarkdownHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
|
||||||
|
|
||||||
return { finalHtml, relevantHighlights }
|
return { finalHtml, relevantHighlights }
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export const useMarkdownToHTML = (
|
|||||||
|
|
||||||
// Replace nostr URIs with resolved titles
|
// Replace nostr URIs with resolved titles
|
||||||
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
|
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
|
||||||
console.log(`📚 Resolved ${articleTitles.size} article titles`)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to fetch article titles:', error)
|
console.warn('Failed to fetch article titles:', error)
|
||||||
// Fall back to basic replacement
|
// Fall back to basic replacement
|
||||||
@@ -58,12 +57,10 @@ export const useMarkdownToHTML = (
|
|||||||
|
|
||||||
setProcessedMarkdown(processed)
|
setProcessedMarkdown(processed)
|
||||||
|
|
||||||
console.log('📝 Converting markdown to HTML...')
|
|
||||||
|
|
||||||
const rafId = requestAnimationFrame(() => {
|
const rafId = requestAnimationFrame(() => {
|
||||||
if (previewRef.current && !isCancelled) {
|
if (previewRef.current && !isCancelled) {
|
||||||
const html = previewRef.current.innerHTML
|
const html = previewRef.current.innerHTML
|
||||||
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
|
||||||
setRenderedHtml(html)
|
setRenderedHtml(html)
|
||||||
} else if (!isCancelled) {
|
} else if (!isCancelled) {
|
||||||
console.warn('⚠️ markdownPreviewRef.current is null')
|
console.warn('⚠️ markdownPreviewRef.current is null')
|
||||||
|
|||||||
@@ -50,16 +50,10 @@ export function useOfflineSync({
|
|||||||
const isNowOnline = hasRemoteRelays
|
const isNowOnline = hasRemoteRelays
|
||||||
|
|
||||||
if (wasLocalOnly && isNowOnline) {
|
if (wasLocalOnly && isNowOnline) {
|
||||||
console.log('✈️ Detected transition: Flight Mode → Online')
|
// Coming back online, sync events
|
||||||
console.log('📊 Relay state:', {
|
|
||||||
connectedRelays: connectedRelays.length,
|
|
||||||
remoteRelays: connectedRelays.filter(r => !isLocalRelay(r.url)).length,
|
|
||||||
localRelays: connectedRelays.filter(r => isLocalRelay(r.url)).length
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait a moment for relays to fully establish connections
|
// Wait a moment for relays to fully establish connections
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log('🚀 Starting sync after delay...')
|
|
||||||
syncLocalEventsToRemote(relayPool, eventStore)
|
syncLocalEventsToRemote(relayPool, eventStore)
|
||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ export function useOnlineStatus() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
console.log('🌐 Back online')
|
|
||||||
setIsOnline(true)
|
setIsOnline(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOffline = () => {
|
const handleOffline = () => {
|
||||||
console.log('📴 Gone offline')
|
|
||||||
setIsOnline(false)
|
setIsOnline(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,12 +51,10 @@ export function usePWAInstall() {
|
|||||||
const choiceResult = await deferredPrompt.userChoice
|
const choiceResult = await deferredPrompt.userChoice
|
||||||
|
|
||||||
if (choiceResult.outcome === 'accepted') {
|
if (choiceResult.outcome === 'accepted') {
|
||||||
console.log('✅ PWA installed')
|
|
||||||
setIsInstallable(false)
|
setIsInstallable(false)
|
||||||
setDeferredPrompt(null)
|
setDeferredPrompt(null)
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ PWA installation dismissed')
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,40 +4,47 @@ 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.95 (95%) - matches filter threshold
|
||||||
syncEnabled?: boolean // Whether to sync positions to Nostr
|
syncEnabled?: boolean // Whether to sync positions to Nostr
|
||||||
onSave?: (position: number) => void // Callback for saving position
|
onSave?: (position: number) => void // Callback for saving position
|
||||||
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
|
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
|
||||||
|
completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useReadingPosition = ({
|
export const useReadingPosition = ({
|
||||||
enabled = true,
|
enabled = true,
|
||||||
onPositionChange,
|
onPositionChange,
|
||||||
onReadingComplete,
|
onReadingComplete,
|
||||||
readingCompleteThreshold = 0.9,
|
readingCompleteThreshold = 0.95, // Match filter threshold for consistency
|
||||||
syncEnabled = false,
|
syncEnabled = false,
|
||||||
onSave,
|
onSave,
|
||||||
autoSaveInterval = 5000
|
autoSaveInterval = 5000,
|
||||||
|
completionHoldMs = 2000
|
||||||
}: 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 lastSavedPosition = useRef(0)
|
||||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const hasSavedOnce = useRef(false)
|
||||||
|
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
// Debounced save function
|
// Debounced save function
|
||||||
const scheduleSave = useCallback((currentPosition: number) => {
|
const scheduleSave = useCallback((currentPosition: number) => {
|
||||||
if (!syncEnabled || !onSave) return
|
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%)
|
// Don't save if position hasn't changed significantly (less than 1%)
|
||||||
// But always save if we've reached 100% (completion)
|
// But always save if we've reached 100% (completion)
|
||||||
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
|
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
|
||||||
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
|
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
|
||||||
|
const isInitialSave = !hasSavedOnce.current
|
||||||
|
|
||||||
if (!hasSignificantChange && !hasReachedCompletion) return
|
if (!hasSignificantChange && !hasReachedCompletion && !isInitialSave) {
|
||||||
|
// Not significant enough to save
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Clear existing timer
|
// Clear existing timer
|
||||||
if (saveTimerRef.current) {
|
if (saveTimerRef.current) {
|
||||||
@@ -47,6 +54,7 @@ export const useReadingPosition = ({
|
|||||||
// Schedule new save
|
// Schedule new save
|
||||||
saveTimerRef.current = setTimeout(() => {
|
saveTimerRef.current = setTimeout(() => {
|
||||||
lastSavedPosition.current = currentPosition
|
lastSavedPosition.current = currentPosition
|
||||||
|
hasSavedOnce.current = true
|
||||||
onSave(currentPosition)
|
onSave(currentPosition)
|
||||||
}, autoSaveInterval)
|
}, autoSaveInterval)
|
||||||
}, [syncEnabled, onSave, autoSaveInterval])
|
}, [syncEnabled, onSave, autoSaveInterval])
|
||||||
@@ -61,11 +69,10 @@ export const useReadingPosition = ({
|
|||||||
saveTimerRef.current = null
|
saveTimerRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save if position is meaningful (>= 5%)
|
// Always allow immediate save (including 0%)
|
||||||
if (position >= 0.05) {
|
lastSavedPosition.current = position
|
||||||
lastSavedPosition.current = position
|
hasSavedOnce.current = true
|
||||||
onSave(position)
|
onSave(position)
|
||||||
}
|
|
||||||
}, [syncEnabled, onSave, position])
|
}, [syncEnabled, onSave, position])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,17 +96,46 @@ export const useReadingPosition = ({
|
|||||||
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
|
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
|
||||||
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
||||||
|
|
||||||
|
// Only log on significant changes (every 5%) to avoid flooding console
|
||||||
|
const prevPercent = Math.floor(position * 20) // Groups by 5%
|
||||||
|
const newPercent = Math.floor(clampedProgress * 20)
|
||||||
|
if (prevPercent !== newPercent) {
|
||||||
|
// Position threshold crossed
|
||||||
|
}
|
||||||
|
|
||||||
setPosition(clampedProgress)
|
setPosition(clampedProgress)
|
||||||
onPositionChange?.(clampedProgress)
|
onPositionChange?.(clampedProgress)
|
||||||
|
|
||||||
// Schedule auto-save if sync is enabled
|
// Schedule auto-save if sync is enabled
|
||||||
scheduleSave(clampedProgress)
|
scheduleSave(clampedProgress)
|
||||||
|
|
||||||
// Check if reading is complete
|
// Completion detection with 2s hold at 100%
|
||||||
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
if (!hasTriggeredComplete.current) {
|
||||||
setIsReadingComplete(true)
|
// If at exact 100%, start a hold timer; cancel if we scroll up
|
||||||
hasTriggeredComplete.current = true
|
if (clampedProgress === 1) {
|
||||||
onReadingComplete?.()
|
if (!completionTimerRef.current) {
|
||||||
|
completionTimerRef.current = setTimeout(() => {
|
||||||
|
if (!hasTriggeredComplete.current && position === 1) {
|
||||||
|
setIsReadingComplete(true)
|
||||||
|
hasTriggeredComplete.current = true
|
||||||
|
onReadingComplete?.()
|
||||||
|
}
|
||||||
|
completionTimerRef.current = null
|
||||||
|
}, completionHoldMs)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If we moved off 100%, cancel any pending completion hold
|
||||||
|
if (completionTimerRef.current) {
|
||||||
|
clearTimeout(completionTimerRef.current)
|
||||||
|
completionTimerRef.current = null
|
||||||
|
// still allow threshold-based completion for near-bottom if configured
|
||||||
|
if (clampedProgress >= readingCompleteThreshold) {
|
||||||
|
setIsReadingComplete(true)
|
||||||
|
hasTriggeredComplete.current = true
|
||||||
|
onReadingComplete?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +154,12 @@ export const useReadingPosition = ({
|
|||||||
if (saveTimerRef.current) {
|
if (saveTimerRef.current) {
|
||||||
clearTimeout(saveTimerRef.current)
|
clearTimeout(saveTimerRef.current)
|
||||||
}
|
}
|
||||||
|
if (completionTimerRef.current) {
|
||||||
|
clearTimeout(completionTimerRef.current)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// position is intentionally not in deps - it's computed from scroll and would cause infinite re-renders
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
|
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
|
||||||
|
|
||||||
// Reset reading complete state when enabled changes
|
// Reset reading complete state when enabled changes
|
||||||
@@ -126,6 +167,12 @@ export const useReadingPosition = ({
|
|||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
setIsReadingComplete(false)
|
setIsReadingComplete(false)
|
||||||
hasTriggeredComplete.current = false
|
hasTriggeredComplete.current = false
|
||||||
|
hasSavedOnce.current = false
|
||||||
|
lastSavedPosition.current = 0
|
||||||
|
if (completionTimerRef.current) {
|
||||||
|
clearTimeout(completionTimerRef.current)
|
||||||
|
completionTimerRef.current = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [enabled])
|
}, [enabled])
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
const root = document.documentElement.style
|
const root = document.documentElement.style
|
||||||
const fontKey = settings.readingFont || 'system'
|
const fontKey = settings.readingFont || 'system'
|
||||||
|
|
||||||
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize, theme: settings.theme })
|
|
||||||
|
|
||||||
// Apply theme with color variants (defaults to 'system' if not set)
|
// Apply theme with color variants (defaults to 'system' if not set)
|
||||||
applyTheme(
|
applyTheme(
|
||||||
@@ -59,9 +58,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
|
|
||||||
// Load font first and wait for it to be ready
|
// Load font first and wait for it to be ready
|
||||||
if (fontKey !== 'system') {
|
if (fontKey !== 'system') {
|
||||||
console.log('⏳ Waiting for font to load...')
|
|
||||||
await loadFont(fontKey)
|
await loadFont(fontKey)
|
||||||
console.log('✅ Font loaded, applying styles')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply font settings after font is loaded
|
// Apply font settings after font is loaded
|
||||||
@@ -76,7 +73,6 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
// Set paragraph alignment
|
// Set paragraph alignment
|
||||||
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
||||||
|
|
||||||
console.log('✅ All styles applied')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyStyles()
|
applyStyles()
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ if ('serviceWorker' in navigator) {
|
|||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register('/sw.js', { type: 'module' })
|
.register('/sw.js', { type: 'module' })
|
||||||
.then(registration => {
|
.then(registration => {
|
||||||
console.log('✅ Service Worker registered:', registration.scope)
|
|
||||||
|
|
||||||
// Check for updates periodically
|
// Check for updates periodically
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
@@ -25,7 +24,6 @@ if ('serviceWorker' in navigator) {
|
|||||||
newWorker.addEventListener('statechange', () => {
|
newWorker.addEventListener('statechange', () => {
|
||||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
// New service worker available
|
// New service worker available
|
||||||
console.log('🔄 New version available! Reload to update.')
|
|
||||||
|
|
||||||
// Optionally show a toast notification
|
// Optionally show a toast notification
|
||||||
const updateAvailable = new CustomEvent('sw-update-available')
|
const updateAvailable = new CustomEvent('sw-update-available')
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ function getFromCache(naddr: string): ArticleContent | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📦 Loaded article from cache:', naddr)
|
|
||||||
return content
|
return content
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
@@ -63,7 +62,6 @@ function saveToCache(naddr: string, content: ArticleContent): void {
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
}
|
}
|
||||||
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
||||||
console.log('💾 Saved article to cache:', naddr)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to cache article:', err)
|
console.warn('Failed to cache article:', err)
|
||||||
// Silently fail if storage is full or unavailable
|
// Silently fail if storage is full or unavailable
|
||||||
|
|||||||
@@ -126,18 +126,14 @@ class BookmarkController {
|
|||||||
generation: number
|
generation: number
|
||||||
): void {
|
): void {
|
||||||
if (!this.eventLoader) {
|
if (!this.eventLoader) {
|
||||||
console.warn('[bookmark] ⚠️ EventLoader not initialized')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter to unique IDs not already hydrated
|
// Filter to unique IDs not already hydrated
|
||||||
const unique = Array.from(new Set(ids)).filter(id => !idToEvent.has(id))
|
const unique = Array.from(new Set(ids)).filter(id => !idToEvent.has(id))
|
||||||
if (unique.length === 0) {
|
if (unique.length === 0) {
|
||||||
console.log('[bookmark] 🔧 All IDs already hydrated, skipping')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[bookmark] 🔧 Hydrating', unique.length, 'IDs using EventLoader')
|
|
||||||
|
|
||||||
// Convert IDs to EventPointers
|
// Convert IDs to EventPointers
|
||||||
const pointers: EventPointer[] = unique.map(id => ({ id }))
|
const pointers: EventPointer[] = unique.map(id => ({ id }))
|
||||||
@@ -159,8 +155,8 @@ class BookmarkController {
|
|||||||
|
|
||||||
onProgress()
|
onProgress()
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: () => {
|
||||||
console.error('[bookmark] ❌ EventLoader error:', error)
|
// Silent error - EventLoader handles retries
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -175,14 +171,11 @@ class BookmarkController {
|
|||||||
generation: number
|
generation: number
|
||||||
): void {
|
): void {
|
||||||
if (!this.addressLoader) {
|
if (!this.addressLoader) {
|
||||||
console.warn('[bookmark] ⚠️ AddressLoader not initialized')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coords.length === 0) return
|
if (coords.length === 0) return
|
||||||
|
|
||||||
console.log('[bookmark] 🔧 Hydrating', coords.length, 'coordinates using AddressLoader')
|
|
||||||
|
|
||||||
// Convert coordinates to AddressPointers
|
// Convert coordinates to AddressPointers
|
||||||
const pointers = coords.map(c => ({
|
const pointers = coords.map(c => ({
|
||||||
kind: c.kind,
|
kind: c.kind,
|
||||||
@@ -203,8 +196,8 @@ class BookmarkController {
|
|||||||
|
|
||||||
onProgress()
|
onProgress()
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: () => {
|
||||||
console.error('[bookmark] ❌ AddressLoader error:', error)
|
// Silent error - AddressLoader handles retries
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -223,10 +216,6 @@ class BookmarkController {
|
|||||||
return this.decryptedResults.has(getEventKey(evt))
|
return this.decryptedResults.has(getEventKey(evt))
|
||||||
})
|
})
|
||||||
|
|
||||||
const unencryptedCount = allEvents.filter(evt => !hasEncryptedContent(evt)).length
|
|
||||||
const decryptedCount = readyEvents.length - unencryptedCount
|
|
||||||
console.log('[bookmark] 📋 Building bookmarks:', unencryptedCount, 'unencrypted,', decryptedCount, 'decrypted, of', allEvents.length, 'total')
|
|
||||||
|
|
||||||
if (readyEvents.length === 0) {
|
if (readyEvents.length === 0) {
|
||||||
this.bookmarksListeners.forEach(cb => cb([]))
|
this.bookmarksListeners.forEach(cb => cb([]))
|
||||||
return
|
return
|
||||||
@@ -237,17 +226,14 @@ class BookmarkController {
|
|||||||
const unencryptedEvents = readyEvents.filter(evt => !hasEncryptedContent(evt))
|
const unencryptedEvents = readyEvents.filter(evt => !hasEncryptedContent(evt))
|
||||||
const decryptedEvents = readyEvents.filter(evt => hasEncryptedContent(evt))
|
const decryptedEvents = readyEvents.filter(evt => hasEncryptedContent(evt))
|
||||||
|
|
||||||
console.log('[bookmark] 🔧 Processing', unencryptedEvents.length, 'unencrypted events')
|
|
||||||
// Process unencrypted events
|
// Process unencrypted events
|
||||||
const { publicItemsAll: publicUnencrypted, privateItemsAll: privateUnencrypted, newestCreatedAt, latestContent, allTags } =
|
const { publicItemsAll: publicUnencrypted, privateItemsAll: privateUnencrypted, newestCreatedAt, latestContent, allTags } =
|
||||||
await collectBookmarksFromEvents(unencryptedEvents, activeAccount, signerCandidate)
|
await collectBookmarksFromEvents(unencryptedEvents, activeAccount, signerCandidate)
|
||||||
console.log('[bookmark] 🔧 Unencrypted returned:', publicUnencrypted.length, 'public,', privateUnencrypted.length, 'private')
|
|
||||||
|
|
||||||
// Merge in decrypted results
|
// Merge in decrypted results
|
||||||
let publicItemsAll = [...publicUnencrypted]
|
let publicItemsAll = [...publicUnencrypted]
|
||||||
let privateItemsAll = [...privateUnencrypted]
|
let privateItemsAll = [...privateUnencrypted]
|
||||||
|
|
||||||
console.log('[bookmark] 🔧 Merging', decryptedEvents.length, 'decrypted events')
|
|
||||||
decryptedEvents.forEach(evt => {
|
decryptedEvents.forEach(evt => {
|
||||||
const eventKey = getEventKey(evt)
|
const eventKey = getEventKey(evt)
|
||||||
const decrypted = this.decryptedResults.get(eventKey)
|
const decrypted = this.decryptedResults.get(eventKey)
|
||||||
@@ -256,11 +242,8 @@ class BookmarkController {
|
|||||||
privateItemsAll = [...privateItemsAll, ...decrypted.privateItems]
|
privateItemsAll = [...privateItemsAll, ...decrypted.privateItems]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('[bookmark] 🔧 Total after merge:', publicItemsAll.length, 'public,', privateItemsAll.length, 'private')
|
|
||||||
|
|
||||||
const allItems = [...publicItemsAll, ...privateItemsAll]
|
const allItems = [...publicItemsAll, ...privateItemsAll]
|
||||||
console.log('[bookmark] 🔧 Total items to process:', allItems.length)
|
|
||||||
|
|
||||||
// Separate hex IDs from coordinates
|
// Separate hex IDs from coordinates
|
||||||
const noteIds: string[] = []
|
const noteIds: string[] = []
|
||||||
@@ -276,14 +259,11 @@ class BookmarkController {
|
|||||||
|
|
||||||
// Helper to build and emit bookmarks
|
// Helper to build and emit bookmarks
|
||||||
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
||||||
console.log('[bookmark] 🔧 Building final bookmarks list...')
|
|
||||||
const allBookmarks = dedupeBookmarksById([
|
const allBookmarks = dedupeBookmarksById([
|
||||||
...hydrateItems(publicItemsAll, idToEvent),
|
...hydrateItems(publicItemsAll, idToEvent),
|
||||||
...hydrateItems(privateItemsAll, idToEvent)
|
...hydrateItems(privateItemsAll, idToEvent)
|
||||||
])
|
])
|
||||||
console.log('[bookmark] 🔧 After hydration and dedup:', allBookmarks.length, 'bookmarks')
|
|
||||||
|
|
||||||
console.log('[bookmark] 🔧 Enriching and sorting...')
|
|
||||||
const enriched = allBookmarks.map(b => ({
|
const enriched = allBookmarks.map(b => ({
|
||||||
...b,
|
...b,
|
||||||
tags: b.tags || [],
|
tags: b.tags || [],
|
||||||
@@ -293,9 +273,7 @@ class BookmarkController {
|
|||||||
const sortedBookmarks = enriched
|
const sortedBookmarks = enriched
|
||||||
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
|
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
|
||||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||||
console.log('[bookmark] 🔧 Sorted:', sortedBookmarks.length, 'bookmarks')
|
|
||||||
|
|
||||||
console.log('[bookmark] 🔧 Creating final Bookmark object...')
|
|
||||||
const bookmark: Bookmark = {
|
const bookmark: Bookmark = {
|
||||||
id: `${activeAccount.pubkey}-bookmarks`,
|
id: `${activeAccount.pubkey}-bookmarks`,
|
||||||
title: `Bookmarks (${sortedBookmarks.length})`,
|
title: `Bookmarks (${sortedBookmarks.length})`,
|
||||||
@@ -310,18 +288,14 @@ class BookmarkController {
|
|||||||
encryptedContent: undefined
|
encryptedContent: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[bookmark] 📋 Built bookmark with', sortedBookmarks.length, 'items')
|
|
||||||
console.log('[bookmark] 📤 Emitting to', this.bookmarksListeners.length, 'listeners')
|
|
||||||
this.bookmarksListeners.forEach(cb => cb([bookmark]))
|
this.bookmarksListeners.forEach(cb => cb([bookmark]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit immediately with empty metadata (show placeholders)
|
// Emit immediately with empty metadata (show placeholders)
|
||||||
const idToEvent: Map<string, NostrEvent> = new Map()
|
const idToEvent: Map<string, NostrEvent> = new Map()
|
||||||
console.log('[bookmark] 🚀 Emitting initial bookmarks with placeholders (IDs only)...')
|
|
||||||
emitBookmarks(idToEvent)
|
emitBookmarks(idToEvent)
|
||||||
|
|
||||||
// Now fetch events progressively in background using batched hydrators
|
// Now fetch events progressively in background using batched hydrators
|
||||||
console.log('[bookmark] 🔧 Background hydration:', noteIds.length, 'note IDs and', coordinates.length, 'coordinates')
|
|
||||||
|
|
||||||
const generation = this.hydrationGeneration
|
const generation = this.hydrationGeneration
|
||||||
const onProgress = () => emitBookmarks(idToEvent)
|
const onProgress = () => emitBookmarks(idToEvent)
|
||||||
@@ -341,9 +315,7 @@ class BookmarkController {
|
|||||||
this.hydrateByIds(noteIds, idToEvent, onProgress, generation)
|
this.hydrateByIds(noteIds, idToEvent, onProgress, generation)
|
||||||
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation)
|
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[bookmark] ❌ Failed to build bookmarks:', error)
|
console.error('Failed to build bookmarks:', error)
|
||||||
console.error('[bookmark] ❌ Error details:', error instanceof Error ? error.message : String(error))
|
|
||||||
console.error('[bookmark] ❌ Stack:', error instanceof Error ? error.stack : 'no stack')
|
|
||||||
this.bookmarksListeners.forEach(cb => cb([]))
|
this.bookmarksListeners.forEach(cb => cb([]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,7 +328,6 @@ class BookmarkController {
|
|||||||
const { relayPool, activeAccount, accountManager } = options
|
const { relayPool, activeAccount, accountManager } = options
|
||||||
|
|
||||||
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
||||||
console.error('[bookmark] Invalid activeAccount')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +337,6 @@ class BookmarkController {
|
|||||||
this.hydrationGeneration++
|
this.hydrationGeneration++
|
||||||
|
|
||||||
// Initialize loaders for this session
|
// Initialize loaders for this session
|
||||||
console.log('[bookmark] 🔧 Initializing EventLoader and AddressLoader with', RELAYS.length, 'relays')
|
|
||||||
this.eventLoader = createEventLoader(relayPool, {
|
this.eventLoader = createEventLoader(relayPool, {
|
||||||
eventStore: this.eventStore,
|
eventStore: this.eventStore,
|
||||||
extraRelays: RELAYS
|
extraRelays: RELAYS
|
||||||
@@ -377,7 +347,6 @@ class BookmarkController {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.setLoading(true)
|
this.setLoading(true)
|
||||||
console.log('[bookmark] 🔍 Starting bookmark load for', account.pubkey.slice(0, 8))
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get signer for auto-decryption
|
// Get signer for auto-decryption
|
||||||
@@ -405,7 +374,6 @@ class BookmarkController {
|
|||||||
|
|
||||||
// Add/update event
|
// Add/update event
|
||||||
this.currentEvents.set(key, evt)
|
this.currentEvents.set(key, evt)
|
||||||
console.log('[bookmark] 📨 Event:', evt.kind, evt.id.slice(0, 8), 'encrypted:', hasEncryptedContent(evt))
|
|
||||||
|
|
||||||
// Emit raw event for Debug UI
|
// Emit raw event for Debug UI
|
||||||
this.emitRawEvent(evt)
|
this.emitRawEvent(evt)
|
||||||
@@ -415,12 +383,13 @@ class BookmarkController {
|
|||||||
if (!isEncrypted) {
|
if (!isEncrypted) {
|
||||||
// For unencrypted events, build bookmarks immediately (progressive update)
|
// For unencrypted events, build bookmarks immediately (progressive update)
|
||||||
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
||||||
.catch(err => console.error('[bookmark] ❌ Failed to update after event:', err))
|
.catch(() => {
|
||||||
|
// Silent error - will retry on next event
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-decrypt if event has encrypted content (fire-and-forget, non-blocking)
|
// Auto-decrypt if event has encrypted content (fire-and-forget, non-blocking)
|
||||||
if (isEncrypted) {
|
if (isEncrypted) {
|
||||||
console.log('[bookmark] 🔓 Auto-decrypting event', evt.id.slice(0, 8))
|
|
||||||
// Don't await - let it run in background
|
// Don't await - let it run in background
|
||||||
collectBookmarksFromEvents([evt], account, signerCandidate)
|
collectBookmarksFromEvents([evt], account, signerCandidate)
|
||||||
.then(({ publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags }) => {
|
.then(({ publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags }) => {
|
||||||
@@ -433,10 +402,6 @@ class BookmarkController {
|
|||||||
latestContent,
|
latestContent,
|
||||||
allTags
|
allTags
|
||||||
})
|
})
|
||||||
console.log('[bookmark] ✅ Auto-decrypted:', evt.id.slice(0, 8), {
|
|
||||||
public: publicItemsAll.length,
|
|
||||||
private: privateItemsAll.length
|
|
||||||
})
|
|
||||||
|
|
||||||
// Emit decrypt complete for Debug UI
|
// Emit decrypt complete for Debug UI
|
||||||
this.decryptCompleteListeners.forEach(cb =>
|
this.decryptCompleteListeners.forEach(cb =>
|
||||||
@@ -445,10 +410,12 @@ class BookmarkController {
|
|||||||
|
|
||||||
// Rebuild bookmarks with newly decrypted content (progressive update)
|
// Rebuild bookmarks with newly decrypted content (progressive update)
|
||||||
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
||||||
.catch(err => console.error('[bookmark] ❌ Failed to update after decrypt:', err))
|
.catch(() => {
|
||||||
|
// Silent error - will retry on next event
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(() => {
|
||||||
console.error('[bookmark] ❌ Auto-decrypt failed:', evt.id.slice(0, 8), error)
|
// Silent error - decrypt failed
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -457,9 +424,8 @@ class BookmarkController {
|
|||||||
|
|
||||||
// Final update after EOSE
|
// Final update after EOSE
|
||||||
await this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
await this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
||||||
console.log('[bookmark] ✅ Bookmark load complete')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[bookmark] ❌ Failed to load bookmarks:', error)
|
console.error('Failed to load bookmarks:', error)
|
||||||
this.bookmarksListeners.forEach(cb => cb([]))
|
this.bookmarksListeners.forEach(cb => cb([]))
|
||||||
} finally {
|
} finally {
|
||||||
this.setLoading(false)
|
this.setLoading(false)
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ async function decryptEvent(
|
|||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
|
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
|
// Ignore unlock errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (evt.content && evt.content.length > 0) {
|
} else if (evt.content && evt.content.length > 0) {
|
||||||
@@ -45,8 +45,8 @@ async function decryptEvent(
|
|||||||
if (looksLikeNip44 && hasNip44Decrypt(signerCandidate)) {
|
if (looksLikeNip44 && hasNip44Decrypt(signerCandidate)) {
|
||||||
try {
|
try {
|
||||||
decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(evt.pubkey, evt.content)
|
decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(evt.pubkey, evt.content)
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
|
// Ignore NIP-44 decryption errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,8 +54,8 @@ async function decryptEvent(
|
|||||||
if (!decryptedContent && hasNip04Decrypt(signerCandidate)) {
|
if (!decryptedContent && hasNip04Decrypt(signerCandidate)) {
|
||||||
try {
|
try {
|
||||||
decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(evt.pubkey, evt.content)
|
decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(evt.pubkey, evt.content)
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
console.log("[bunker] ❌ nip04.decrypt failed:", err instanceof Error ? err.message : String(err))
|
// Ignore NIP-04 decryption errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export const fetchContacts = async (
|
|||||||
): Promise<Set<string>> => {
|
): Promise<Set<string>> => {
|
||||||
try {
|
try {
|
||||||
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
|
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
|
||||||
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
|
|
||||||
|
|
||||||
const partialFollowed = new Set<string>()
|
const partialFollowed = new Set<string>()
|
||||||
const events = await queryEvents(
|
const events = await queryEvents(
|
||||||
@@ -51,9 +50,7 @@ export const fetchContacts = async (
|
|||||||
}
|
}
|
||||||
// merged already via streams
|
// merged already via streams
|
||||||
|
|
||||||
console.log('📊 Contact events fetched:', events.length)
|
|
||||||
|
|
||||||
console.log('👥 Followed contacts:', followed.size)
|
|
||||||
return followed
|
return followed
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch contacts:', error)
|
console.error('Failed to fetch contacts:', error)
|
||||||
|
|||||||
@@ -73,13 +73,11 @@ class ContactsController {
|
|||||||
|
|
||||||
// Skip if already loaded for this pubkey (unless forced)
|
// Skip if already loaded for this pubkey (unless forced)
|
||||||
if (!force && this.isLoadedFor(pubkey)) {
|
if (!force && this.isLoadedFor(pubkey)) {
|
||||||
console.log('[contacts] ✅ Already loaded for', pubkey.slice(0, 8))
|
|
||||||
this.emitContacts(this.currentContacts)
|
this.emitContacts(this.currentContacts)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setLoading(true)
|
this.setLoading(true)
|
||||||
console.log('[contacts] 🔍 Loading contacts for', pubkey.slice(0, 8))
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const contacts = await fetchContacts(
|
const contacts = await fetchContacts(
|
||||||
@@ -89,7 +87,6 @@ class ContactsController {
|
|||||||
// Stream partial updates
|
// Stream partial updates
|
||||||
this.currentContacts = new Set(partial)
|
this.currentContacts = new Set(partial)
|
||||||
this.emitContacts(this.currentContacts)
|
this.emitContacts(this.currentContacts)
|
||||||
console.log('[contacts] 📥 Partial contacts:', partial.size)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -98,7 +95,6 @@ class ContactsController {
|
|||||||
this.lastLoadedPubkey = pubkey
|
this.lastLoadedPubkey = pubkey
|
||||||
this.emitContacts(this.currentContacts)
|
this.emitContacts(this.currentContacts)
|
||||||
|
|
||||||
console.log('[contacts] ✅ Loaded', contacts.size, 'contacts')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[contacts] ❌ Failed to load contacts:', error)
|
console.error('[contacts] ❌ Failed to load contacts:', error)
|
||||||
this.currentContacts.clear()
|
this.currentContacts.clear()
|
||||||
|
|||||||
@@ -36,12 +36,10 @@ export async function createDeletionRequest(
|
|||||||
|
|
||||||
const signed = await factory.sign(draft)
|
const signed = await factory.sign(draft)
|
||||||
|
|
||||||
console.log('🗑️ Created kind:5 deletion request for event:', eventId.slice(0, 8))
|
|
||||||
|
|
||||||
// Publish to relays
|
// Publish to relays
|
||||||
await relayPool.publish(RELAYS, signed)
|
await relayPool.publish(RELAYS, signed)
|
||||||
|
|
||||||
console.log('✅ Deletion request published to', RELAYS.length, 'relay(s)')
|
|
||||||
|
|
||||||
return signed
|
return signed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,29 +20,34 @@ export interface BlogPostPreview {
|
|||||||
* @param relayPool - The relay pool to query
|
* @param relayPool - The relay pool to query
|
||||||
* @param pubkeys - Array of pubkeys to fetch posts from
|
* @param pubkeys - Array of pubkeys to fetch posts from
|
||||||
* @param relayUrls - Array of relay URLs to query
|
* @param relayUrls - Array of relay URLs to query
|
||||||
|
* @param onPost - Optional callback for streaming posts
|
||||||
|
* @param limit - Limit for number of events to fetch (default: 100, pass null for no limit)
|
||||||
* @returns Array of blog post previews
|
* @returns Array of blog post previews
|
||||||
*/
|
*/
|
||||||
export const fetchBlogPostsFromAuthors = async (
|
export const fetchBlogPostsFromAuthors = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
pubkeys: string[],
|
pubkeys: string[],
|
||||||
relayUrls: string[],
|
relayUrls: string[],
|
||||||
onPost?: (post: BlogPostPreview) => void
|
onPost?: (post: BlogPostPreview) => void,
|
||||||
|
limit: number | null = 100
|
||||||
): Promise<BlogPostPreview[]> => {
|
): Promise<BlogPostPreview[]> => {
|
||||||
try {
|
try {
|
||||||
if (pubkeys.length === 0) {
|
if (pubkeys.length === 0) {
|
||||||
console.log('⚠️ No pubkeys to fetch blog posts from')
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
|
|
||||||
|
|
||||||
// Deduplicate replaceable events by keeping the most recent version
|
// Deduplicate replaceable events by keeping the most recent version
|
||||||
// Group by author + d-tag identifier
|
// Group by author + d-tag identifier
|
||||||
const uniqueEvents = new Map<string, NostrEvent>()
|
const uniqueEvents = new Map<string, NostrEvent>()
|
||||||
|
|
||||||
|
const filter = limit !== null
|
||||||
|
? { kinds: [KINDS.BlogPost], authors: pubkeys, limit }
|
||||||
|
: { kinds: [KINDS.BlogPost], authors: pubkeys }
|
||||||
|
|
||||||
await queryEvents(
|
await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
{ kinds: [KINDS.BlogPost], authors: pubkeys, limit: 100 },
|
filter,
|
||||||
{
|
{
|
||||||
relayUrls,
|
relayUrls,
|
||||||
onEvent: (event: NostrEvent) => {
|
onEvent: (event: NostrEvent) => {
|
||||||
@@ -68,7 +73,6 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log('📊 Blog post events fetched (unique):', uniqueEvents.size)
|
|
||||||
|
|
||||||
// Convert to blog post previews and sort by published date (most recent first)
|
// Convert to blog post previews and sort by published date (most recent first)
|
||||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||||
@@ -90,7 +94,6 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
return timeB - timeA // Most recent first
|
return timeB - timeA // Most recent first
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('📰 Processed', blogPosts.length, 'unique blog posts')
|
|
||||||
|
|
||||||
return blogPosts
|
return blogPosts
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export async function createHighlight(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create EventFactory with the account as signer
|
// Create EventFactory with the account as signer
|
||||||
console.log("[bunker] Creating EventFactory with signer:", { signerType: account.signer?.constructor?.name })
|
|
||||||
const factory = new EventFactory({ signer: account.signer })
|
const factory = new EventFactory({ signer: account.signer })
|
||||||
|
|
||||||
let blueprintSource: NostrEvent | AddressPointer | string
|
let blueprintSource: NostrEvent | AddressPointer | string
|
||||||
@@ -117,9 +116,7 @@ export async function createHighlight(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sign the event
|
// Sign the event
|
||||||
console.log('[bunker] Signing highlight event...', { kind: highlightEvent.kind, tags: highlightEvent.tags.length })
|
|
||||||
const signedEvent = await factory.sign(highlightEvent)
|
const signedEvent = await factory.sign(highlightEvent)
|
||||||
console.log('[bunker] ✅ Highlight signed successfully!', { id: signedEvent.id.slice(0, 8) })
|
|
||||||
|
|
||||||
// Use unified write service to store and publish
|
// Use unified write service to store and publish
|
||||||
await publishEvent(relayPool, eventStore, signedEvent)
|
await publishEvent(relayPool, eventStore, signedEvent)
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export const fetchHighlights = async (
|
|||||||
const cacheKey = highlightCache.authorKey(pubkey)
|
const cacheKey = highlightCache.authorKey(pubkey)
|
||||||
const cached = highlightCache.get(cacheKey)
|
const cached = highlightCache.get(cacheKey)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
console.log(`📌 Using cached highlights for author (${cached.length} items)`)
|
|
||||||
// Stream cached highlights if callback provided
|
// Stream cached highlights if callback provided
|
||||||
if (onHighlight) {
|
if (onHighlight) {
|
||||||
cached.forEach(h => onHighlight(h))
|
cached.forEach(h => onHighlight(h))
|
||||||
@@ -50,7 +49,6 @@ export const fetchHighlights = async (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log(`📌 Fetched ${rawEvents.length} highlight events for author:`, pubkey.slice(0, 8))
|
|
||||||
|
|
||||||
// Store all events in event store if provided
|
// Store all events in event store if provided
|
||||||
if (eventStore) {
|
if (eventStore) {
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export const fetchHighlightsForArticle = async (
|
|||||||
const cacheKey = highlightCache.articleKey(articleCoordinate)
|
const cacheKey = highlightCache.articleKey(articleCoordinate)
|
||||||
const cached = highlightCache.get(cacheKey)
|
const cached = highlightCache.get(cacheKey)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
console.log(`📌 Using cached highlights for article (${cached.length} items)`)
|
|
||||||
// Stream cached highlights if callback provided
|
// Stream cached highlights if callback provided
|
||||||
if (onHighlight) {
|
if (onHighlight) {
|
||||||
cached.forEach(h => onHighlight(h))
|
cached.forEach(h => onHighlight(h))
|
||||||
@@ -54,7 +53,6 @@ export const fetchHighlightsForArticle = async (
|
|||||||
])
|
])
|
||||||
|
|
||||||
const rawEvents = [...aTagEvents, ...eTagEvents]
|
const rawEvents = [...aTagEvents, ...eTagEvents]
|
||||||
console.log(`📌 Fetched ${rawEvents.length} highlight events for article:`, articleCoordinate)
|
|
||||||
|
|
||||||
// Store all events in event store if provided
|
// Store all events in event store if provided
|
||||||
if (eventStore) {
|
if (eventStore) {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export const fetchHighlightsForUrl = async (
|
|||||||
const cacheKey = highlightCache.urlKey(url)
|
const cacheKey = highlightCache.urlKey(url)
|
||||||
const cached = highlightCache.get(cacheKey)
|
const cached = highlightCache.get(cacheKey)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
console.log(`📌 Using cached highlights for URL (${cached.length} items)`)
|
|
||||||
// Stream cached highlights if callback provided
|
// Stream cached highlights if callback provided
|
||||||
if (onHighlight) {
|
if (onHighlight) {
|
||||||
cached.forEach(h => onHighlight(h))
|
cached.forEach(h => onHighlight(h))
|
||||||
@@ -50,7 +49,6 @@ export const fetchHighlightsForUrl = async (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log(`📌 Fetched ${rawEvents.length} highlight events for URL:`, url)
|
|
||||||
|
|
||||||
// Store all events in event store if provided
|
// Store all events in event store if provided
|
||||||
if (eventStore) {
|
if (eventStore) {
|
||||||
|
|||||||
@@ -21,11 +21,9 @@ export const fetchHighlightsFromAuthors = async (
|
|||||||
): Promise<Highlight[]> => {
|
): Promise<Highlight[]> => {
|
||||||
try {
|
try {
|
||||||
if (pubkeys.length === 0) {
|
if (pubkeys.length === 0) {
|
||||||
console.log('⚠️ No pubkeys to fetch highlights from')
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('💡 Fetching highlights (kind 9802) from', pubkeys.length, 'authors')
|
|
||||||
|
|
||||||
const seenIds = new Set<string>()
|
const seenIds = new Set<string>()
|
||||||
const rawEvents = await queryEvents(
|
const rawEvents = await queryEvents(
|
||||||
@@ -55,7 +53,6 @@ export const fetchHighlightsFromAuthors = async (
|
|||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
const highlights = uniqueEvents.map(eventToHighlight)
|
const highlights = uniqueEvents.map(eventToHighlight)
|
||||||
|
|
||||||
console.log('💡 Processed', highlights.length, 'unique highlights')
|
|
||||||
|
|
||||||
return sortHighlights(highlights)
|
return sortHighlights(highlights)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ class HighlightsController {
|
|||||||
|
|
||||||
// Skip if already loaded for this pubkey (unless forced)
|
// Skip if already loaded for this pubkey (unless forced)
|
||||||
if (!force && this.isLoadedFor(pubkey)) {
|
if (!force && this.isLoadedFor(pubkey)) {
|
||||||
console.log('[highlights] ✅ Already loaded for', pubkey.slice(0, 8))
|
|
||||||
this.emitHighlights(this.currentHighlights)
|
this.emitHighlights(this.currentHighlights)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -120,7 +119,6 @@ class HighlightsController {
|
|||||||
const currentGeneration = this.generation
|
const currentGeneration = this.generation
|
||||||
|
|
||||||
this.setLoading(true)
|
this.setLoading(true)
|
||||||
console.log('[highlights] 🔍 Loading highlights for', pubkey.slice(0, 8))
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const seenIds = new Set<string>()
|
const seenIds = new Set<string>()
|
||||||
@@ -134,7 +132,6 @@ class HighlightsController {
|
|||||||
}
|
}
|
||||||
if (lastSyncedAt) {
|
if (lastSyncedAt) {
|
||||||
filter.since = lastSyncedAt
|
filter.since = lastSyncedAt
|
||||||
console.log('[highlights] 📅 Incremental sync since', new Date(lastSyncedAt * 1000).toISOString())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = await queryEvents(
|
const events = await queryEvents(
|
||||||
@@ -165,7 +162,6 @@ class HighlightsController {
|
|||||||
|
|
||||||
// Check if still active after async operation
|
// Check if still active after async operation
|
||||||
if (currentGeneration !== this.generation) {
|
if (currentGeneration !== this.generation) {
|
||||||
console.log('[highlights] ⚠️ Load cancelled (generation mismatch)')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +185,6 @@ class HighlightsController {
|
|||||||
this.setLastSyncedAt(pubkey, newestTimestamp)
|
this.setLastSyncedAt(pubkey, newestTimestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[highlights] ✅ Loaded', sorted.length, 'highlights')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[highlights] ❌ Failed to load highlights:', error)
|
console.error('[highlights] ❌ Failed to load highlights:', error)
|
||||||
this.currentHighlights = []
|
this.currentHighlights = []
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ const CACHE_NAME = 'boris-image-cache-v1'
|
|||||||
export async function clearImageCache(): Promise<void> {
|
export async function clearImageCache(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await caches.delete(CACHE_NAME)
|
await caches.delete(CACHE_NAME)
|
||||||
console.log('🗑️ Cleared all cached images')
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to clear image cache:', err)
|
console.error('Failed to clear image cache:', err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { queryEvents } from './dataFetch'
|
|||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
import { ReadItem } from './readsService'
|
import { ReadItem } from './readsService'
|
||||||
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
||||||
import { mergeReadItem } from '../utils/readItemMerge'
|
import { mergeReadItem } from '../utils/readItemMerge'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches external URL links with reading progress from:
|
* Fetches external URL links with reading progress from:
|
||||||
* - URLs with reading progress (kind:30078)
|
* - URLs with reading progress (kind:39802)
|
||||||
* - Manually marked as read URLs (kind:7, kind:17)
|
* - Manually marked as read URLs (kind:7, kind:17)
|
||||||
*/
|
*/
|
||||||
export async function fetchLinks(
|
export async function fetchLinks(
|
||||||
@@ -17,7 +17,6 @@ export async function fetchLinks(
|
|||||||
userPubkey: string,
|
userPubkey: string,
|
||||||
onItem?: (item: ReadItem) => void
|
onItem?: (item: ReadItem) => void
|
||||||
): Promise<ReadItem[]> {
|
): Promise<ReadItem[]> {
|
||||||
console.log('🔗 [Links] Fetching external links for user:', userPubkey.slice(0, 8))
|
|
||||||
|
|
||||||
const linksMap = new Map<string, ReadItem>()
|
const linksMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
@@ -32,18 +31,13 @@ export async function fetchLinks(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch all data sources in parallel
|
// Fetch all data sources in parallel
|
||||||
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
|
const [progressEvents, markedAsReadArticles] = await Promise.all([
|
||||||
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||||
fetchReadArticles(relayPool, userPubkey)
|
fetchReadArticles(relayPool, userPubkey)
|
||||||
])
|
])
|
||||||
|
|
||||||
console.log('📊 [Links] Data fetched:', {
|
// Process reading progress events (kind 39802)
|
||||||
readingPositions: readingPositionEvents.length,
|
processReadingProgress(progressEvents, linksMap)
|
||||||
markedAsRead: markedAsReadArticles.length
|
|
||||||
})
|
|
||||||
|
|
||||||
// Process reading positions and emit external items
|
|
||||||
processReadingPositions(readingPositionEvents, linksMap)
|
|
||||||
if (onItem) {
|
if (onItem) {
|
||||||
linksMap.forEach(item => {
|
linksMap.forEach(item => {
|
||||||
if (item.type === 'external') {
|
if (item.type === 'external') {
|
||||||
@@ -79,7 +73,6 @@ export async function fetchLinks(
|
|||||||
const validLinks = filterValidItems(links)
|
const validLinks = filterValidItems(links)
|
||||||
const sortedLinks = sortByReadingActivity(validLinks)
|
const sortedLinks = sortByReadingActivity(validLinks)
|
||||||
|
|
||||||
console.log('✅ [Links] Processed', sortedLinks.length, 'total links')
|
|
||||||
return sortedLinks
|
return sortedLinks
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export const fetchNostrverseBlogPosts = async (
|
|||||||
onPost?: (post: BlogPostPreview) => void
|
onPost?: (post: BlogPostPreview) => void
|
||||||
): Promise<BlogPostPreview[]> => {
|
): Promise<BlogPostPreview[]> => {
|
||||||
try {
|
try {
|
||||||
console.log('[NOSTRVERSE] 📚 Fetching blog posts (kind 30023), limit:', limit)
|
|
||||||
|
|
||||||
// Deduplicate replaceable events by keeping the most recent version
|
// Deduplicate replaceable events by keeping the most recent version
|
||||||
const uniqueEvents = new Map<string, NostrEvent>()
|
const uniqueEvents = new Map<string, NostrEvent>()
|
||||||
@@ -63,7 +62,6 @@ export const fetchNostrverseBlogPosts = async (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log('[NOSTRVERSE] 📊 Blog post events fetched (unique):', uniqueEvents.size)
|
|
||||||
|
|
||||||
// Convert to blog post previews and sort by published date (most recent first)
|
// Convert to blog post previews and sort by published date (most recent first)
|
||||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||||
@@ -81,7 +79,6 @@ export const fetchNostrverseBlogPosts = async (
|
|||||||
return timeB - timeA // Most recent first
|
return timeB - timeA // Most recent first
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('[NOSTRVERSE] 📰 Processed', blogPosts.length, 'unique blog posts')
|
|
||||||
|
|
||||||
return blogPosts
|
return blogPosts
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -103,7 +100,6 @@ export const fetchNostrverseHighlights = async (
|
|||||||
eventStore?: IEventStore
|
eventStore?: IEventStore
|
||||||
): Promise<Highlight[]> => {
|
): Promise<Highlight[]> => {
|
||||||
try {
|
try {
|
||||||
console.log('[NOSTRVERSE] 💡 Fetching highlights (kind 9802), limit:', limit)
|
|
||||||
|
|
||||||
const seenIds = new Set<string>()
|
const seenIds = new Set<string>()
|
||||||
// Collect but do not block callers awaiting network completion
|
// Collect but do not block callers awaiting network completion
|
||||||
@@ -133,7 +129,6 @@ export const fetchNostrverseHighlights = async (
|
|||||||
const uniqueEvents = dedupeHighlights([...collected, ...rawEvents])
|
const uniqueEvents = dedupeHighlights([...collected, ...rawEvents])
|
||||||
const highlights = uniqueEvents.map(eventToHighlight)
|
const highlights = uniqueEvents.map(eventToHighlight)
|
||||||
|
|
||||||
console.log('[NOSTRVERSE] 💡 Processed', highlights.length, 'unique highlights')
|
|
||||||
|
|
||||||
return sortHighlights(highlights)
|
return sortHighlights(highlights)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const syncStateListeners: Array<(eventId: string, isSyncing: boolean) => void> =
|
|||||||
*/
|
*/
|
||||||
export function markEventAsOfflineCreated(eventId: string): void {
|
export function markEventAsOfflineCreated(eventId: string): void {
|
||||||
offlineCreatedEvents.add(eventId)
|
offlineCreatedEvents.add(eventId)
|
||||||
console.log(`📝 Marked event ${eventId.slice(0, 8)} as offline-created. Total: ${offlineCreatedEvents.size}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,49 +56,35 @@ export async function syncLocalEventsToRemote(
|
|||||||
eventStore: IEventStore
|
eventStore: IEventStore
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (isSyncing) {
|
if (isSyncing) {
|
||||||
console.log('⏳ Sync already in progress, skipping...')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔄 Coming back online - syncing local events to remote relays...')
|
|
||||||
console.log(`📦 Offline events tracked: ${offlineCreatedEvents.size}`)
|
|
||||||
isSyncing = true
|
isSyncing = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const remoteRelays = RELAYS.filter(url => !isLocalRelay(url))
|
const remoteRelays = RELAYS.filter(url => !isLocalRelay(url))
|
||||||
|
|
||||||
console.log(`📡 Remote relays: ${remoteRelays.length}`)
|
|
||||||
|
|
||||||
if (remoteRelays.length === 0) {
|
if (remoteRelays.length === 0) {
|
||||||
console.log('⚠️ No remote relays available for sync')
|
|
||||||
isSyncing = false
|
isSyncing = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (offlineCreatedEvents.size === 0) {
|
if (offlineCreatedEvents.size === 0) {
|
||||||
console.log('✅ No offline events to sync')
|
|
||||||
isSyncing = false
|
isSyncing = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get events from EventStore using the tracked IDs
|
// Get events from EventStore using the tracked IDs
|
||||||
const eventsToSync: NostrEvent[] = []
|
const eventsToSync: NostrEvent[] = []
|
||||||
console.log(`🔍 Querying EventStore for ${offlineCreatedEvents.size} offline events...`)
|
|
||||||
|
|
||||||
for (const eventId of offlineCreatedEvents) {
|
for (const eventId of offlineCreatedEvents) {
|
||||||
const event = eventStore.getEvent(eventId)
|
const event = eventStore.getEvent(eventId)
|
||||||
if (event) {
|
if (event) {
|
||||||
console.log(`📥 Found event ${eventId.slice(0, 8)} (kind ${event.kind}) in EventStore`)
|
|
||||||
eventsToSync.push(event)
|
eventsToSync.push(event)
|
||||||
} else {
|
|
||||||
console.warn(`⚠️ Event ${eventId.slice(0, 8)} not found in EventStore`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`📊 Total events to sync: ${eventsToSync.length}`)
|
|
||||||
|
|
||||||
if (eventsToSync.length === 0) {
|
if (eventsToSync.length === 0) {
|
||||||
console.log('✅ No events found in EventStore to sync')
|
|
||||||
isSyncing = false
|
isSyncing = false
|
||||||
offlineCreatedEvents.clear()
|
offlineCreatedEvents.clear()
|
||||||
return
|
return
|
||||||
@@ -110,8 +95,6 @@ export async function syncLocalEventsToRemote(
|
|||||||
new Map(eventsToSync.map(e => [e.id, e])).values()
|
new Map(eventsToSync.map(e => [e.id, e])).values()
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log(`📤 Syncing ${uniqueEvents.length} event(s) to remote relays...`)
|
|
||||||
|
|
||||||
// Mark all events as syncing
|
// Mark all events as syncing
|
||||||
uniqueEvents.forEach(event => {
|
uniqueEvents.forEach(event => {
|
||||||
syncingEvents.add(event.id)
|
syncingEvents.add(event.id)
|
||||||
@@ -119,21 +102,16 @@ export async function syncLocalEventsToRemote(
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Publish to remote relays
|
// Publish to remote relays
|
||||||
let successCount = 0
|
|
||||||
const successfulIds: string[] = []
|
const successfulIds: string[] = []
|
||||||
|
|
||||||
for (const event of uniqueEvents) {
|
for (const event of uniqueEvents) {
|
||||||
try {
|
try {
|
||||||
await relayPool.publish(remoteRelays, event)
|
await relayPool.publish(remoteRelays, event)
|
||||||
successCount++
|
|
||||||
successfulIds.push(event.id)
|
successfulIds.push(event.id)
|
||||||
console.log(`✅ Synced event ${event.id.slice(0, 8)}`)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`⚠️ Failed to sync event ${event.id.slice(0, 8)}:`, error)
|
// Silently fail for individual events
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ Synced ${successCount}/${uniqueEvents.length} events to remote relays`)
|
|
||||||
|
|
||||||
// Clear syncing state and offline tracking for successful events
|
// Clear syncing state and offline tracking for successful events
|
||||||
successfulIds.forEach(eventId => {
|
successfulIds.forEach(eventId => {
|
||||||
@@ -150,7 +128,7 @@ export async function syncLocalEventsToRemote(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error during offline sync:', error)
|
// Silently fail
|
||||||
} finally {
|
} finally {
|
||||||
isSyncing = false
|
isSyncing = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export const fetchProfiles = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uniquePubkeys = Array.from(new Set(pubkeys))
|
const uniquePubkeys = Array.from(new Set(pubkeys))
|
||||||
console.log('👤 Fetching profiles (kind:0) for', uniquePubkeys.length, 'authors')
|
|
||||||
|
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
const prioritized = prioritizeLocalRelays(relayUrls)
|
const prioritized = prioritizeLocalRelays(relayUrls)
|
||||||
@@ -65,7 +64,6 @@ export const fetchProfiles = async (
|
|||||||
await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||||
|
|
||||||
const profiles = Array.from(profilesByPubkey.values())
|
const profiles = Array.from(profilesByPubkey.values())
|
||||||
console.log('✅ Fetched', profiles.length, 'unique profiles')
|
|
||||||
|
|
||||||
// Rebroadcast profiles to local/all relays based on settings
|
// Rebroadcast profiles to local/all relays based on settings
|
||||||
if (profiles.length > 0) {
|
if (profiles.length > 0) {
|
||||||
|
|||||||
@@ -42,12 +42,10 @@ export async function createEventReaction(
|
|||||||
|
|
||||||
const signed = await factory.sign(draft)
|
const signed = await factory.sign(draft)
|
||||||
|
|
||||||
console.log('📚 Created kind:7 reaction (mark as read) for event:', eventId.slice(0, 8))
|
|
||||||
|
|
||||||
// Publish to relays
|
// Publish to relays
|
||||||
await relayPool.publish(RELAYS, signed)
|
await relayPool.publish(RELAYS, signed)
|
||||||
|
|
||||||
console.log('✅ Reaction published to', RELAYS.length, 'relay(s)')
|
|
||||||
|
|
||||||
return signed
|
return signed
|
||||||
}
|
}
|
||||||
@@ -94,12 +92,10 @@ export async function createWebsiteReaction(
|
|||||||
|
|
||||||
const signed = await factory.sign(draft)
|
const signed = await factory.sign(draft)
|
||||||
|
|
||||||
console.log('📚 Created kind:17 reaction (mark as read) for URL:', normalizedUrl)
|
|
||||||
|
|
||||||
// Publish to relays
|
// Publish to relays
|
||||||
await relayPool.publish(RELAYS, signed)
|
await relayPool.publish(RELAYS, signed)
|
||||||
|
|
||||||
console.log('✅ Website reaction published to', RELAYS.length, 'relay(s)')
|
|
||||||
|
|
||||||
return signed
|
return signed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent, nip19 } from 'nostr-tools'
|
||||||
import { ReadItem } from './readsService'
|
import { ReadItem } from './readsService'
|
||||||
import { fallbackTitleFromUrl } from '../utils/readItemMerge'
|
import { fallbackTitleFromUrl } from '../utils/readItemMerge'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
|
||||||
const READING_POSITION_PREFIX = 'boris:reading-position:'
|
const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-85
|
||||||
|
|
||||||
interface ReadArticle {
|
interface ReadArticle {
|
||||||
id: string
|
id: string
|
||||||
@@ -13,44 +14,81 @@ interface ReadArticle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes reading position events into ReadItems
|
* Processes reading progress events (kind 39802) into ReadItems
|
||||||
|
*
|
||||||
|
* Test scenarios:
|
||||||
|
* - Kind 39802 with d="30023:..." → article ReadItem with naddr id
|
||||||
|
* - Kind 39802 with d="url:..." → external ReadItem with decoded URL
|
||||||
|
* - Newer event.created_at overwrites older timestamp
|
||||||
|
* - Invalid d tag format → skip event
|
||||||
|
* - Malformed JSON content → skip event
|
||||||
*/
|
*/
|
||||||
export function processReadingPositions(
|
export function processReadingProgress(
|
||||||
events: NostrEvent[],
|
events: NostrEvent[],
|
||||||
readsMap: Map<string, ReadItem>
|
readsMap: Map<string, ReadItem>
|
||||||
): void {
|
): void {
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
|
if (event.kind !== READING_PROGRESS_KIND) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1]
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1]
|
||||||
if (!dTag || !dTag.startsWith(READING_POSITION_PREFIX)) continue
|
if (!dTag) {
|
||||||
|
continue
|
||||||
const identifier = dTag.replace(READING_POSITION_PREFIX, '')
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const positionData = JSON.parse(event.content)
|
const content = JSON.parse(event.content)
|
||||||
const position = positionData.position
|
const position = content.progress || 0
|
||||||
const timestamp = positionData.timestamp
|
|
||||||
|
// Validate progress is between 0 and 1 (NIP-85 requirement)
|
||||||
|
if (position < 0 || position > 1) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use event.created_at as authoritative timestamp (NIP-85 spec)
|
||||||
|
const timestamp = event.created_at
|
||||||
|
|
||||||
let itemId: string
|
let itemId: string
|
||||||
let itemUrl: string | undefined
|
let itemUrl: string | undefined
|
||||||
let itemType: 'article' | 'external' = 'external'
|
let itemType: 'article' | 'external' = 'external'
|
||||||
|
|
||||||
// Check if it's a nostr article (naddr format)
|
// Check if d tag is a coordinate (30023:pubkey:identifier)
|
||||||
if (identifier.startsWith('naddr1')) {
|
if (dTag.startsWith('30023:')) {
|
||||||
itemId = identifier
|
// It's a nostr article coordinate
|
||||||
itemType = 'article'
|
const parts = dTag.split(':')
|
||||||
} else {
|
if (parts.length === 3) {
|
||||||
// It's a base64url-encoded URL
|
// Convert to naddr for consistency with the rest of the app
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: parseInt(parts[0]),
|
||||||
|
pubkey: parts[1],
|
||||||
|
identifier: parts[2]
|
||||||
|
})
|
||||||
|
itemId = naddr
|
||||||
|
itemType = 'article'
|
||||||
|
} catch (e) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if (dTag.startsWith('url:')) {
|
||||||
|
// It's a URL with base64url encoding
|
||||||
|
const encoded = dTag.replace('url:', '')
|
||||||
try {
|
try {
|
||||||
itemUrl = atob(identifier.replace(/-/g, '+').replace(/_/g, '/'))
|
itemUrl = atob(encoded.replace(/-/g, '+').replace(/_/g, '/'))
|
||||||
itemId = itemUrl
|
itemId = itemUrl
|
||||||
itemType = 'external'
|
itemType = 'external'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to decode URL identifier:', identifier)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add or update the item
|
// Add or update the item, preferring newer timestamps
|
||||||
const existing = readsMap.get(itemId)
|
const existing = readsMap.get(itemId)
|
||||||
if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) {
|
if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) {
|
||||||
readsMap.set(itemId, {
|
readsMap.set(itemId, {
|
||||||
@@ -64,7 +102,7 @@ export function processReadingPositions(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to parse reading position:', error)
|
// Silently fail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { IEventStore, mapEventsToStore } from 'applesauce-core'
|
import { IEventStore, mapEventsToStore } from 'applesauce-core'
|
||||||
import { EventFactory } from 'applesauce-factory'
|
import { EventFactory } from 'applesauce-factory'
|
||||||
import { RelayPool, onlyEvents } from 'applesauce-relay'
|
import { RelayPool, onlyEvents } from 'applesauce-relay'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent, nip19 } from 'nostr-tools'
|
||||||
import { firstValueFrom } from 'rxjs'
|
import { firstValueFrom } from 'rxjs'
|
||||||
import { publishEvent } from './writeService'
|
import { publishEvent } from './writeService'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
|
||||||
const APP_DATA_KIND = 30078 // NIP-78 Application Data
|
const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-85 Reading Progress
|
||||||
const READING_POSITION_PREFIX = 'boris:reading-position:'
|
|
||||||
|
|
||||||
export interface ReadingPosition {
|
export interface ReadingPosition {
|
||||||
position: number // 0-1 scroll progress
|
position: number // 0-1 scroll progress
|
||||||
@@ -15,16 +15,79 @@ export interface ReadingPosition {
|
|||||||
scrollTop?: number // Optional: pixel position
|
scrollTop?: number // Optional: pixel position
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to extract and parse reading position from an event
|
export interface ReadingProgressContent {
|
||||||
function getReadingPositionContent(event: NostrEvent): ReadingPosition | undefined {
|
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)
|
||||||
|
function getReadingProgressContent(event: NostrEvent): ReadingPosition | undefined {
|
||||||
if (!event.content || event.content.length === 0) return undefined
|
if (!event.content || event.content.length === 0) return undefined
|
||||||
try {
|
try {
|
||||||
return JSON.parse(event.content) as ReadingPosition
|
const content = JSON.parse(event.content) as ReadingProgressContent
|
||||||
|
return {
|
||||||
|
position: content.progress,
|
||||||
|
timestamp: content.ts || event.created_at,
|
||||||
|
scrollTop: content.loc
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate d tag for kind 39802 based on target
|
||||||
|
// Test cases:
|
||||||
|
// - naddr1... → "30023:<pubkey>:<identifier>"
|
||||||
|
// - https://example.com/post → "url:<base64url>"
|
||||||
|
// - Invalid naddr → "url:<base64url>" (fallback)
|
||||||
|
function generateDTag(naddrOrUrl: string): string {
|
||||||
|
// If it's a nostr article (naddr format), decode and build coordinate
|
||||||
|
if (naddrOrUrl.startsWith('naddr1')) {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(naddrOrUrl)
|
||||||
|
if (decoded.type === 'naddr') {
|
||||||
|
const dTag = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier || ''}`
|
||||||
|
return dTag
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore decode errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For URLs, use url: prefix with base64url encoding
|
||||||
|
const base64url = btoa(naddrOrUrl)
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '')
|
||||||
|
return `url:${base64url}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tags for kind 39802 event
|
||||||
|
function generateProgressTags(naddrOrUrl: string): string[][] {
|
||||||
|
const dTag = generateDTag(naddrOrUrl)
|
||||||
|
const tags: string[][] = [['d', dTag]]
|
||||||
|
|
||||||
|
// Add 'a' tag for nostr articles
|
||||||
|
if (naddrOrUrl.startsWith('naddr1')) {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(naddrOrUrl)
|
||||||
|
if (decoded.type === 'naddr') {
|
||||||
|
const coordinate = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier || ''}`
|
||||||
|
tags.push(['a', coordinate])
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore decode errors
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add 'r' tag for URLs
|
||||||
|
tags.push(['r', naddrOrUrl])
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique identifier for an article
|
* Generate a unique identifier for an article
|
||||||
* For Nostr articles: use the naddr directly
|
* For Nostr articles: use the naddr directly
|
||||||
@@ -43,7 +106,7 @@ export function generateArticleIdentifier(naddrOrUrl: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save reading position to Nostr (Kind 30078)
|
* Save reading position to Nostr (kind 39802)
|
||||||
*/
|
*/
|
||||||
export async function saveReadingPosition(
|
export async function saveReadingPosition(
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
@@ -52,36 +115,31 @@ export async function saveReadingPosition(
|
|||||||
articleIdentifier: string,
|
articleIdentifier: string,
|
||||||
position: ReadingPosition
|
position: ReadingPosition
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log('💾 [ReadingPosition] Saving position:', {
|
const now = Math.floor(Date.now() / 1000)
|
||||||
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 progressContent: ReadingProgressContent = {
|
||||||
|
progress: position.position,
|
||||||
|
ts: position.timestamp,
|
||||||
|
loc: position.scrollTop,
|
||||||
|
ver: '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = generateProgressTags(articleIdentifier)
|
||||||
|
|
||||||
const draft = await factory.create(async () => ({
|
const draft = await factory.create(async () => ({
|
||||||
kind: APP_DATA_KIND,
|
kind: READING_PROGRESS_KIND,
|
||||||
content: JSON.stringify(position),
|
content: JSON.stringify(progressContent),
|
||||||
tags: [
|
tags,
|
||||||
['d', dTag],
|
created_at: now
|
||||||
['client', 'boris']
|
|
||||||
],
|
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const signed = await factory.sign(draft)
|
const signed = await factory.sign(draft)
|
||||||
|
|
||||||
// Use unified write service
|
|
||||||
await publishEvent(relayPool, eventStore, signed)
|
await publishEvent(relayPool, eventStore, signed)
|
||||||
|
|
||||||
console.log('✅ [ReadingPosition] Position saved successfully, event ID:', signed.id.slice(0, 8))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load reading position from Nostr
|
* Load reading position from Nostr (kind 39802)
|
||||||
*/
|
*/
|
||||||
export async function loadReadingPosition(
|
export async function loadReadingPosition(
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
@@ -89,32 +147,20 @@ export async function loadReadingPosition(
|
|||||||
pubkey: string,
|
pubkey: string,
|
||||||
articleIdentifier: string
|
articleIdentifier: string
|
||||||
): Promise<ReadingPosition | null> {
|
): Promise<ReadingPosition | null> {
|
||||||
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
|
const dTag = generateDTag(articleIdentifier)
|
||||||
|
|
||||||
console.log('📖 [ReadingPosition] Loading position:', {
|
// Check local event store first
|
||||||
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 {
|
try {
|
||||||
const localEvent = await firstValueFrom(
|
const localEvent = await firstValueFrom(
|
||||||
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
|
eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
|
||||||
)
|
)
|
||||||
if (localEvent) {
|
if (localEvent) {
|
||||||
const content = getReadingPositionContent(localEvent)
|
const content = getReadingProgressContent(localEvent)
|
||||||
if (content) {
|
if (content) {
|
||||||
console.log('✅ [ReadingPosition] Loaded from local store:', {
|
// Fetch from relays in background to get any updates
|
||||||
position: content.position,
|
|
||||||
positionPercent: Math.round(content.position * 100) + '%',
|
|
||||||
timestamp: content.timestamp
|
|
||||||
})
|
|
||||||
|
|
||||||
// Still fetch from relays in the background to get any updates
|
|
||||||
relayPool
|
relayPool
|
||||||
.subscription(RELAYS, {
|
.subscription(RELAYS, {
|
||||||
kinds: [APP_DATA_KIND],
|
kinds: [READING_PROGRESS_KIND],
|
||||||
authors: [pubkey],
|
authors: [pubkey],
|
||||||
'#d': [dTag]
|
'#d': [dTag]
|
||||||
})
|
})
|
||||||
@@ -125,23 +171,43 @@ export async function loadReadingPosition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('📭 No cached reading position found, fetching from relays...')
|
// Ignore errors and fetch from relays
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in local store, fetch from relays
|
// Fetch from relays
|
||||||
|
const result = await fetchFromRelays(
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
pubkey,
|
||||||
|
READING_PROGRESS_KIND,
|
||||||
|
dTag,
|
||||||
|
getReadingProgressContent
|
||||||
|
)
|
||||||
|
|
||||||
|
return result || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to fetch from relays with timeout
|
||||||
|
async function fetchFromRelays(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
eventStore: IEventStore,
|
||||||
|
pubkey: string,
|
||||||
|
kind: number,
|
||||||
|
dTag: string,
|
||||||
|
parser: (event: NostrEvent) => ReadingPosition | undefined
|
||||||
|
): Promise<ReadingPosition | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let hasResolved = false
|
let hasResolved = false
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (!hasResolved) {
|
if (!hasResolved) {
|
||||||
console.log('⏱️ Reading position load timeout - no position found')
|
|
||||||
hasResolved = true
|
hasResolved = true
|
||||||
resolve(null)
|
resolve(null)
|
||||||
}
|
}
|
||||||
}, 3000) // Shorter timeout for reading positions
|
}, 3000)
|
||||||
|
|
||||||
const sub = relayPool
|
const sub = relayPool
|
||||||
.subscription(RELAYS, {
|
.subscription(RELAYS, {
|
||||||
kinds: [APP_DATA_KIND],
|
kinds: [kind],
|
||||||
authors: [pubkey],
|
authors: [pubkey],
|
||||||
'#d': [dTag]
|
'#d': [dTag]
|
||||||
})
|
})
|
||||||
@@ -153,33 +219,20 @@ export async function loadReadingPosition(
|
|||||||
hasResolved = true
|
hasResolved = true
|
||||||
try {
|
try {
|
||||||
const event = await firstValueFrom(
|
const event = await firstValueFrom(
|
||||||
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
|
eventStore.replaceable(kind, pubkey, dTag)
|
||||||
)
|
)
|
||||||
if (event) {
|
if (event) {
|
||||||
const content = getReadingPositionContent(event)
|
const content = parser(event)
|
||||||
if (content) {
|
resolve(content || null)
|
||||||
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 {
|
} else {
|
||||||
console.log('📭 [ReadingPosition] No position found on relays')
|
|
||||||
resolve(null)
|
resolve(null)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('❌ Error loading reading position:', err)
|
|
||||||
resolve(null)
|
resolve(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: () => {
|
||||||
console.error('❌ Reading position subscription error:', err)
|
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
if (!hasResolved) {
|
if (!hasResolved) {
|
||||||
hasResolved = true
|
hasResolved = true
|
||||||
|
|||||||
277
src/services/readingProgressController.ts
Normal file
277
src/services/readingProgressController.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { Filter, NostrEvent } from 'nostr-tools'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { processReadingProgress } from './readingDataProcessor'
|
||||||
|
import { ReadItem } from './readsService'
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared reading progress controller
|
||||||
|
* Manages the user's reading progress (kind:39802) centrally
|
||||||
|
*/
|
||||||
|
class ReadingProgressController {
|
||||||
|
private progressListeners: ProgressMapCallback[] = []
|
||||||
|
private loadingListeners: LoadingCallback[] = []
|
||||||
|
|
||||||
|
private currentProgressMap: Map<string, number> = new Map()
|
||||||
|
private lastLoadedPubkey: string | null = null
|
||||||
|
private generation = 0
|
||||||
|
private timelineSubscription: { unsubscribe: () => void } | null = null
|
||||||
|
|
||||||
|
onProgress(cb: ProgressMapCallback): () => void {
|
||||||
|
this.progressListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.progressListeners = this.progressListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoading(cb: LoadingCallback): () => void {
|
||||||
|
this.loadingListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLoading(loading: boolean): void {
|
||||||
|
this.loadingListeners.forEach(cb => cb(loading))
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitProgress(progressMap: Map<string, number>): void {
|
||||||
|
this.progressListeners.forEach(cb => cb(new Map(progressMap)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current reading progress map without triggering a reload
|
||||||
|
*/
|
||||||
|
getProgressMap(): Map<string, number> {
|
||||||
|
return new Map(this.currentProgressMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load cached progress from localStorage for a pubkey
|
||||||
|
*/
|
||||||
|
private loadCachedProgress(pubkey: string): Map<string, number> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PROGRESS_CACHE_KEY)
|
||||||
|
if (!raw) return new Map()
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, Record<string, number>>
|
||||||
|
const forUser = parsed[pubkey] || {}
|
||||||
|
return new Map(Object.entries(forUser))
|
||||||
|
} catch {
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save current progress map to localStorage for the active pubkey
|
||||||
|
*/
|
||||||
|
private persistProgress(pubkey: string, progressMap: Map<string, number>): void {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PROGRESS_CACHE_KEY)
|
||||||
|
const parsed: Record<string, Record<string, number>> = raw ? JSON.parse(raw) : {}
|
||||||
|
parsed[pubkey] = Object.fromEntries(progressMap.entries())
|
||||||
|
localStorage.setItem(PROGRESS_CACHE_KEY, JSON.stringify(parsed))
|
||||||
|
} catch (err) {
|
||||||
|
// Silently fail cache persistence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get progress for a specific article by naddr
|
||||||
|
*/
|
||||||
|
getProgress(naddr: string): number | undefined {
|
||||||
|
return this.currentProgressMap.get(naddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if reading progress is loaded for a specific pubkey
|
||||||
|
*/
|
||||||
|
isLoadedFor(pubkey: string): boolean {
|
||||||
|
return this.lastLoadedPubkey === pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset state (for logout or manual refresh)
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
this.generation++
|
||||||
|
// Unsubscribe from any active timeline subscription
|
||||||
|
if (this.timelineSubscription) {
|
||||||
|
try {
|
||||||
|
this.timelineSubscription.unsubscribe()
|
||||||
|
} catch (err) {
|
||||||
|
// Silently fail on unsubscribe
|
||||||
|
}
|
||||||
|
this.timelineSubscription = null
|
||||||
|
}
|
||||||
|
this.currentProgressMap = new Map()
|
||||||
|
this.lastLoadedPubkey = null
|
||||||
|
this.emitProgress(this.currentProgressMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get last synced timestamp for incremental loading
|
||||||
|
*/
|
||||||
|
private getLastSyncedAt(pubkey: string): number | null {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||||
|
if (!data) return null
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
return parsed[pubkey] || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last synced timestamp
|
||||||
|
*/
|
||||||
|
private updateLastSyncedAt(pubkey: string, timestamp: number): void {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||||
|
const parsed = data ? JSON.parse(data) : {}
|
||||||
|
parsed[pubkey] = timestamp
|
||||||
|
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
|
||||||
|
} catch (err) {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and watch reading progress for a user
|
||||||
|
*/
|
||||||
|
async start(params: {
|
||||||
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
|
pubkey: string
|
||||||
|
force?: boolean
|
||||||
|
}): Promise<void> {
|
||||||
|
const { relayPool, eventStore, pubkey, force = false } = params
|
||||||
|
const startGeneration = this.generation
|
||||||
|
|
||||||
|
// Skip if already loaded for this pubkey and not forcing
|
||||||
|
if (!force && this.isLoadedFor(pubkey)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setLoading(true)
|
||||||
|
this.lastLoadedPubkey = pubkey
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Seed from local cache immediately (survives refresh/flight mode)
|
||||||
|
const cached = this.loadCachedProgress(pubkey)
|
||||||
|
if (cached.size > 0) {
|
||||||
|
this.currentProgressMap = cached
|
||||||
|
this.emitProgress(this.currentProgressMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to local timeline for immediate and reactive updates
|
||||||
|
// Clean up any previous subscription first
|
||||||
|
if (this.timelineSubscription) {
|
||||||
|
try {
|
||||||
|
this.timelineSubscription.unsubscribe()
|
||||||
|
} catch (err) {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
this.timelineSubscription = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeline$ = eventStore.timeline({
|
||||||
|
kinds: [KINDS.ReadingProgress],
|
||||||
|
authors: [pubkey]
|
||||||
|
})
|
||||||
|
const generationAtSubscribe = this.generation
|
||||||
|
this.timelineSubscription = timeline$.subscribe((localEvents: NostrEvent[]) => {
|
||||||
|
// Ignore if controller generation has changed (e.g., logout/login)
|
||||||
|
if (generationAtSubscribe !== this.generation) return
|
||||||
|
if (!Array.isArray(localEvents) || localEvents.length === 0) return
|
||||||
|
this.processEvents(localEvents)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Query events from relays
|
||||||
|
// Force full sync if map is empty (first load) or if explicitly forced
|
||||||
|
const needsFullSync = force || this.currentProgressMap.size === 0
|
||||||
|
const lastSynced = needsFullSync ? null : this.getLastSyncedAt(pubkey)
|
||||||
|
|
||||||
|
const filter: Filter = {
|
||||||
|
kinds: [KINDS.ReadingProgress],
|
||||||
|
authors: [pubkey]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastSynced && !needsFullSync) {
|
||||||
|
filter.since = lastSynced
|
||||||
|
}
|
||||||
|
|
||||||
|
const relayEvents = await queryEvents(relayPool, filter, { relayUrls: RELAYS })
|
||||||
|
|
||||||
|
if (startGeneration !== this.generation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relayEvents.length > 0) {
|
||||||
|
// Add to event store
|
||||||
|
relayEvents.forEach(e => eventStore.add(e))
|
||||||
|
|
||||||
|
// Process and emit (merge with existing)
|
||||||
|
this.processEvents(relayEvents)
|
||||||
|
|
||||||
|
// Update last synced
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
this.updateLastSyncedAt(pubkey, now)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('📊 [ReadingProgress] Failed to load:', err)
|
||||||
|
} finally {
|
||||||
|
if (startGeneration === this.generation) {
|
||||||
|
this.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process events and update progress map
|
||||||
|
*/
|
||||||
|
private processEvents(events: NostrEvent[]): void {
|
||||||
|
const readsMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
|
// Merge with existing progress
|
||||||
|
for (const [id, progress] of this.currentProgressMap.entries()) {
|
||||||
|
readsMap.set(id, {
|
||||||
|
id,
|
||||||
|
source: 'reading-progress',
|
||||||
|
type: 'article',
|
||||||
|
readingProgress: progress
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process new events
|
||||||
|
processReadingProgress(events, readsMap)
|
||||||
|
|
||||||
|
// Convert back to progress map (naddr -> progress)
|
||||||
|
const newProgressMap = new Map<string, number>()
|
||||||
|
for (const [id, item] of readsMap.entries()) {
|
||||||
|
if (item.readingProgress !== undefined && item.type === 'article') {
|
||||||
|
newProgressMap.set(id, item.readingProgress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentProgressMap = newProgressMap
|
||||||
|
this.emitProgress(this.currentProgressMap)
|
||||||
|
|
||||||
|
// Persist for current user so it survives refresh/flight mode
|
||||||
|
if (this.lastLoadedPubkey) {
|
||||||
|
this.persistProgress(this.lastLoadedPubkey, this.currentProgressMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readingProgressController = new ReadingProgressController()
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ import { RELAYS } from '../config/relays'
|
|||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
|
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
||||||
import { mergeReadItem } from '../utils/readItemMerge'
|
import { mergeReadItem } from '../utils/readItemMerge'
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
@@ -37,7 +37,7 @@ export interface ReadItem {
|
|||||||
/**
|
/**
|
||||||
* Fetches all reads from multiple sources:
|
* Fetches all reads from multiple sources:
|
||||||
* - Bookmarked articles (kind:30023) and article/website URLs
|
* - Bookmarked articles (kind:30023) and article/website URLs
|
||||||
* - Articles/URLs with reading progress (kind:30078)
|
* - Articles/URLs with reading progress (kind:39802)
|
||||||
* - Manually marked as read articles/URLs (kind:7, kind:17)
|
* - Manually marked as read articles/URLs (kind:7, kind:17)
|
||||||
*/
|
*/
|
||||||
export async function fetchAllReads(
|
export async function fetchAllReads(
|
||||||
@@ -46,7 +46,6 @@ export async function fetchAllReads(
|
|||||||
bookmarks: Bookmark[],
|
bookmarks: Bookmark[],
|
||||||
onItem?: (item: ReadItem) => void
|
onItem?: (item: ReadItem) => void
|
||||||
): Promise<ReadItem[]> {
|
): Promise<ReadItem[]> {
|
||||||
console.log('📚 [Reads] Fetching all reads for user:', userPubkey.slice(0, 8))
|
|
||||||
|
|
||||||
const readsMap = new Map<string, ReadItem>()
|
const readsMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
@@ -61,24 +60,13 @@ export async function fetchAllReads(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch all data sources in parallel
|
// Fetch all data sources in parallel
|
||||||
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
|
const [progressEvents, markedAsReadArticles] = await Promise.all([
|
||||||
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||||
fetchReadArticles(relayPool, userPubkey)
|
fetchReadArticles(relayPool, userPubkey)
|
||||||
])
|
])
|
||||||
|
|
||||||
console.log('📊 [Reads] Data fetched:', {
|
// Process reading progress events (kind 39802)
|
||||||
readingPositions: readingPositionEvents.length,
|
processReadingProgress(progressEvents, readsMap)
|
||||||
markedAsRead: markedAsReadArticles.length,
|
|
||||||
bookmarks: bookmarks.length
|
|
||||||
})
|
|
||||||
|
|
||||||
// Process reading positions and emit items
|
|
||||||
processReadingPositions(readingPositionEvents, readsMap)
|
|
||||||
if (onItem) {
|
|
||||||
readsMap.forEach(item => {
|
|
||||||
if (item.type === 'article') onItem(item)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process marked-as-read and emit items
|
// Process marked-as-read and emit items
|
||||||
processMarkedAsRead(markedAsReadArticles, readsMap)
|
processMarkedAsRead(markedAsReadArticles, readsMap)
|
||||||
@@ -120,7 +108,6 @@ export async function fetchAllReads(
|
|||||||
.map(item => item.id)
|
.map(item => item.id)
|
||||||
|
|
||||||
if (articleCoordinates.length > 0) {
|
if (articleCoordinates.length > 0) {
|
||||||
console.log('📖 [Reads] Fetching article events for', articleCoordinates.length, 'articles')
|
|
||||||
|
|
||||||
// Parse coordinates and fetch events
|
// Parse coordinates and fetch events
|
||||||
const articlesToFetch: Array<{ pubkey: string; identifier: string }> = []
|
const articlesToFetch: Array<{ pubkey: string; identifier: string }> = []
|
||||||
@@ -187,7 +174,6 @@ export async function fetchAllReads(
|
|||||||
const validArticles = filterValidItems(articles)
|
const validArticles = filterValidItems(articles)
|
||||||
const sortedReads = sortByReadingActivity(validArticles)
|
const sortedReads = sortByReadingActivity(validArticles)
|
||||||
|
|
||||||
console.log('✅ [Reads] Processed', sortedReads.length, 'total reads')
|
|
||||||
return sortedReads
|
return sortedReads
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export async function rebroadcastEvents(
|
|||||||
|
|
||||||
// If we're in flight mode (only local relays connected) and user wants to broadcast to all relays, skip
|
// If we're in flight mode (only local relays connected) and user wants to broadcast to all relays, skip
|
||||||
if (broadcastToAll && !hasRemoteConnection) {
|
if (broadcastToAll && !hasRemoteConnection) {
|
||||||
console.log('✈️ Flight mode: skipping rebroadcast to remote relays')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +49,6 @@ export async function rebroadcastEvents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (targetRelays.length === 0) {
|
if (targetRelays.length === 0) {
|
||||||
console.log('📡 No target relays for rebroadcast')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +56,6 @@ export async function rebroadcastEvents(
|
|||||||
const rebroadcastPromises = events.map(async (event) => {
|
const rebroadcastPromises = events.map(async (event) => {
|
||||||
try {
|
try {
|
||||||
await relayPool.publish(targetRelays, event)
|
await relayPool.publish(targetRelays, event)
|
||||||
console.log('📡 Rebroadcast event', event.id?.slice(0, 8), 'to', targetRelays.length, 'relay(s)')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('⚠️ Failed to rebroadcast event', event.id?.slice(0, 8), error)
|
console.warn('⚠️ Failed to rebroadcast event', event.id?.slice(0, 8), error)
|
||||||
}
|
}
|
||||||
@@ -68,11 +65,5 @@ export async function rebroadcastEvents(
|
|||||||
Promise.all(rebroadcastPromises).catch((err) => {
|
Promise.all(rebroadcastPromises).catch((err) => {
|
||||||
console.warn('⚠️ Some rebroadcasts failed:', err)
|
console.warn('⚠️ Some rebroadcasts failed:', err)
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(`📡 Rebroadcasting ${events.length} event(s) to ${targetRelays.length} relay(s)`, {
|
|
||||||
broadcastToAll,
|
|
||||||
useLocalCache,
|
|
||||||
targetRelays
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,11 +40,7 @@ export function updateAndGetRelayStatuses(relayPool: RelayPool): RelayStatus[] {
|
|||||||
const connectedCount = statuses.filter(s => s.isInPool).length
|
const connectedCount = statuses.filter(s => s.isInPool).length
|
||||||
const disconnectedCount = statuses.filter(s => !s.isInPool).length
|
const disconnectedCount = statuses.filter(s => !s.isInPool).length
|
||||||
if (connectedCount === 0 || disconnectedCount > 0) {
|
if (connectedCount === 0 || disconnectedCount > 0) {
|
||||||
console.log(`🔌 Relay status: ${connectedCount} connected, ${disconnectedCount} disconnected`)
|
// Debug: relay status changed, but we're not logging it
|
||||||
const connected = statuses.filter(s => s.isInPool).map(s => s.url.replace(/^wss?:\/\//, ''))
|
|
||||||
const disconnected = statuses.filter(s => !s.isInPool).map(s => s.url.replace(/^wss?:\/\//, ''))
|
|
||||||
if (connected.length > 0) console.log('✅ Connected:', connected.join(', '))
|
|
||||||
if (disconnected.length > 0) console.log('❌ Disconnected:', disconnected.join(', '))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add recently seen relays that are no longer connected
|
// Add recently seen relays that are no longer connected
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ export interface UserSettings {
|
|||||||
paragraphAlignment?: 'left' | 'justify' // default: justify
|
paragraphAlignment?: 'left' | 'justify' // default: justify
|
||||||
// Reading position sync
|
// Reading position sync
|
||||||
syncReadingPosition?: boolean // default: false (opt-in)
|
syncReadingPosition?: boolean // default: false (opt-in)
|
||||||
|
autoMarkAsReadOnCompletion?: boolean // default: false (opt-in)
|
||||||
|
// Bookmark filtering
|
||||||
|
hideBookmarksWithoutCreationDate?: boolean // default: false
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSettings(
|
export async function loadSettings(
|
||||||
@@ -68,7 +71,6 @@ export async function loadSettings(
|
|||||||
pubkey: string,
|
pubkey: string,
|
||||||
relays: string[]
|
relays: string[]
|
||||||
): Promise<UserSettings | null> {
|
): Promise<UserSettings | null> {
|
||||||
console.log('⚙️ Loading settings from nostr...', { pubkey: pubkey.slice(0, 8) + '...', relays })
|
|
||||||
|
|
||||||
// First, check if we already have settings in the local event store
|
// First, check if we already have settings in the local event store
|
||||||
try {
|
try {
|
||||||
@@ -77,7 +79,6 @@ export async function loadSettings(
|
|||||||
)
|
)
|
||||||
if (localEvent) {
|
if (localEvent) {
|
||||||
const content = getAppDataContent<UserSettings>(localEvent)
|
const content = getAppDataContent<UserSettings>(localEvent)
|
||||||
console.log('✅ Settings loaded from local store (cached):', content)
|
|
||||||
|
|
||||||
// Still fetch from relays in the background to get any updates
|
// Still fetch from relays in the background to get any updates
|
||||||
relayPool
|
relayPool
|
||||||
@@ -91,8 +92,8 @@ export async function loadSettings(
|
|||||||
|
|
||||||
return content || null
|
return content || null
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
console.log('📭 No cached settings found, fetching from relays...')
|
// Ignore local store errors
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in local store, fetch from relays
|
// If not in local store, fetch from relays
|
||||||
@@ -124,10 +125,8 @@ export async function loadSettings(
|
|||||||
)
|
)
|
||||||
if (event) {
|
if (event) {
|
||||||
const content = getAppDataContent<UserSettings>(event)
|
const content = getAppDataContent<UserSettings>(event)
|
||||||
console.log('✅ Settings loaded from relays:', content)
|
|
||||||
resolve(content || null)
|
resolve(content || null)
|
||||||
} else {
|
} else {
|
||||||
console.log('📭 No settings event found - using defaults')
|
|
||||||
resolve(null)
|
resolve(null)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -158,7 +157,6 @@ export async function saveSettings(
|
|||||||
factory: EventFactory,
|
factory: EventFactory,
|
||||||
settings: UserSettings
|
settings: UserSettings
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log('💾 Saving settings to nostr:', settings)
|
|
||||||
|
|
||||||
// Create NIP-78 application data event manually
|
// Create NIP-78 application data event manually
|
||||||
// Note: AppDataBlueprint is not available in the npm package
|
// Note: AppDataBlueprint is not available in the npm package
|
||||||
@@ -174,7 +172,6 @@ export async function saveSettings(
|
|||||||
// Use unified write service
|
// Use unified write service
|
||||||
await publishEvent(relayPool, eventStore, signed)
|
await publishEvent(relayPool, eventStore, signed)
|
||||||
|
|
||||||
console.log('✅ Settings published successfully')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function watchSettings(
|
export function watchSettings(
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ export async function createWebBookmark(
|
|||||||
// Publish to relays in the background (don't block UI)
|
// Publish to relays in the background (don't block UI)
|
||||||
relayPool.publish(relays, signedEvent)
|
relayPool.publish(relays, signedEvent)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('✅ Web bookmark published to', relays.length, 'relays:', signedEvent)
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.warn('⚠️ Some relays failed to publish bookmark:', err)
|
console.warn('⚠️ Some relays failed to publish bookmark:', err)
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ export async function publishEvent(
|
|||||||
eventStore: IEventStore,
|
eventStore: IEventStore,
|
||||||
event: NostrEvent
|
event: NostrEvent
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const isProgressEvent = event.kind === 39802
|
||||||
|
const logPrefix = isProgressEvent ? '[progress]' : ''
|
||||||
|
|
||||||
// Store the event in the local EventStore FIRST for immediate UI display
|
// Store the event in the local EventStore FIRST for immediate UI display
|
||||||
eventStore.add(event)
|
eventStore.add(event)
|
||||||
console.log('💾 Stored event in EventStore:', event.id.slice(0, 8), `(kind ${event.kind})`)
|
|
||||||
|
|
||||||
// Check current connection status - are we online or in flight mode?
|
// Check current connection status - are we online or in flight mode?
|
||||||
const connectedRelays = Array.from(relayPool.relays.values())
|
const connectedRelays = Array.from(relayPool.relays.values())
|
||||||
@@ -32,13 +34,7 @@ export async function publishEvent(
|
|||||||
|
|
||||||
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
|
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
|
||||||
|
|
||||||
console.log('📍 Event relay status:', {
|
// Publishing event
|
||||||
targetRelays: RELAYS.length,
|
|
||||||
expectedSuccessRelays: expectedSuccessRelays.length,
|
|
||||||
isLocalOnly,
|
|
||||||
hasRemoteConnection,
|
|
||||||
eventId: event.id.slice(0, 8)
|
|
||||||
})
|
|
||||||
|
|
||||||
// If we're in local-only mode, mark this event for later sync
|
// If we're in local-only mode, mark this event for later sync
|
||||||
if (isLocalOnly) {
|
if (isLocalOnly) {
|
||||||
@@ -48,10 +44,9 @@ export async function publishEvent(
|
|||||||
// Publish to all configured relays in the background (non-blocking)
|
// Publish to all configured relays in the background (non-blocking)
|
||||||
relayPool.publish(RELAYS, event)
|
relayPool.publish(RELAYS, event)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('✅ Event published to', RELAYS.length, 'relay(s):', event.id.slice(0, 8))
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.warn('⚠️ Failed to publish event to relays (event still saved locally):', error)
|
console.warn(`${logPrefix} ⚠️ Failed to publish event to relays (event still saved locally):`, error)
|
||||||
|
|
||||||
// Surface common bunker signing errors for debugging
|
// Surface common bunker signing errors for debugging
|
||||||
if (error instanceof Error && error.message.includes('permission')) {
|
if (error instanceof Error && error.message.includes('permission')) {
|
||||||
|
|||||||
@@ -138,7 +138,6 @@ class WritingsController {
|
|||||||
|
|
||||||
// Skip if already loaded for this pubkey (unless forced)
|
// Skip if already loaded for this pubkey (unless forced)
|
||||||
if (!force && this.isLoadedFor(pubkey)) {
|
if (!force && this.isLoadedFor(pubkey)) {
|
||||||
console.log('[writings] ✅ Already loaded for', pubkey.slice(0, 8))
|
|
||||||
this.emitWritings(this.currentPosts)
|
this.emitWritings(this.currentPosts)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -148,7 +147,6 @@ class WritingsController {
|
|||||||
const currentGeneration = this.generation
|
const currentGeneration = this.generation
|
||||||
|
|
||||||
this.setLoading(true)
|
this.setLoading(true)
|
||||||
console.log('[writings] 🔍 Loading writings for', pubkey.slice(0, 8))
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const seenIds = new Set<string>()
|
const seenIds = new Set<string>()
|
||||||
@@ -162,7 +160,6 @@ class WritingsController {
|
|||||||
}
|
}
|
||||||
if (lastSyncedAt) {
|
if (lastSyncedAt) {
|
||||||
filter.since = lastSyncedAt
|
filter.since = lastSyncedAt
|
||||||
console.log('[writings] 📅 Incremental sync since', new Date(lastSyncedAt * 1000).toISOString())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = await queryEvents(
|
const events = await queryEvents(
|
||||||
@@ -201,7 +198,6 @@ class WritingsController {
|
|||||||
|
|
||||||
// Check if still active after async operation
|
// Check if still active after async operation
|
||||||
if (currentGeneration !== this.generation) {
|
if (currentGeneration !== this.generation) {
|
||||||
console.log('[writings] ⚠️ Load cancelled (generation mismatch)')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +227,6 @@ class WritingsController {
|
|||||||
this.setLastSyncedAt(pubkey, newestTimestamp)
|
this.setLastSyncedAt(pubkey, newestTimestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[writings] ✅ Loaded', sorted.length, 'writings')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[writings] ❌ Failed to load writings:', error)
|
console.error('[writings] ❌ Failed to load writings:', error)
|
||||||
this.currentPosts = []
|
this.currentPosts = []
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export async function fetchBorisZappers(
|
|||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
): Promise<ZapSender[]> {
|
): Promise<ZapSender[]> {
|
||||||
try {
|
try {
|
||||||
console.log('⚡ Fetching zap receipts for Boris...', BORIS_PUBKEY)
|
|
||||||
|
|
||||||
// Use all configured relays plus specific zap-heavy relays
|
// Use all configured relays plus specific zap-heavy relays
|
||||||
const zapRelays = [
|
const zapRelays = [
|
||||||
@@ -63,23 +62,18 @@ export async function fetchBorisZappers(
|
|||||||
merge(local$, remote$).pipe(toArray())
|
merge(local$, remote$).pipe(toArray())
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log(`📊 Fetched ${zapReceipts.length} raw zap receipts`)
|
|
||||||
|
|
||||||
// Dedupe by event ID and validate
|
// Dedupe by event ID and validate
|
||||||
const uniqueReceipts = new Map<string, NostrEvent>()
|
const uniqueReceipts = new Map<string, NostrEvent>()
|
||||||
let invalidCount = 0
|
|
||||||
|
|
||||||
zapReceipts.forEach(receipt => {
|
zapReceipts.forEach(receipt => {
|
||||||
if (!uniqueReceipts.has(receipt.id)) {
|
if (!uniqueReceipts.has(receipt.id)) {
|
||||||
if (isValidZap(receipt)) {
|
if (isValidZap(receipt)) {
|
||||||
uniqueReceipts.set(receipt.id, receipt)
|
uniqueReceipts.set(receipt.id, receipt)
|
||||||
} else {
|
|
||||||
invalidCount++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(`✅ ${uniqueReceipts.size} valid zap receipts (${invalidCount} invalid)`)
|
|
||||||
|
|
||||||
// Aggregate by sender using applesauce helpers
|
// Aggregate by sender using applesauce helpers
|
||||||
const senderTotals = new Map<string, { totalSats: number; zapCount: number }>()
|
const senderTotals = new Map<string, { totalSats: number; zapCount: number }>()
|
||||||
@@ -102,7 +96,6 @@ export async function fetchBorisZappers(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`👥 Found ${senderTotals.size} unique senders`)
|
|
||||||
|
|
||||||
// Filter >= 2100 sats, mark whales >= 69420 sats, sort by total desc
|
// Filter >= 2100 sats, mark whales >= 69420 sats, sort by total desc
|
||||||
const zappers: ZapSender[] = Array.from(senderTotals.entries())
|
const zappers: ZapSender[] = Array.from(senderTotals.entries())
|
||||||
@@ -115,7 +108,6 @@ export async function fetchBorisZappers(
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => b.totalSats - a.totalSats)
|
.sort((a, b) => b.totalSats - a.totalSats)
|
||||||
|
|
||||||
console.log(`✅ Found ${zappers.length} supporters (${zappers.filter(z => z.isWhale).length} whales)`)
|
|
||||||
|
|
||||||
return zappers
|
return zappers
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
.individual-bookmarks h4 { margin: 0 0 1rem 0; font-size: 1rem; color: var(--color-text); }
|
.individual-bookmarks h4 { margin: 0 0 1rem 0; font-size: 1rem; color: var(--color-text); }
|
||||||
|
|
||||||
.bookmarks-grid { display: flex; flex-direction: column; gap: 1rem; width: 100%; max-width: 100%; }
|
.bookmarks-grid { display: flex; flex-direction: column; gap: 1rem; width: 100%; max-width: 100%; }
|
||||||
.bookmarks-grid.bookmarks-compact { gap: 0.5rem; }
|
.bookmarks-grid.bookmarks-compact { gap: 0.25rem; }
|
||||||
.bookmarks-grid.bookmarks-large { gap: 1.5rem; }
|
.bookmarks-grid.bookmarks-large { gap: 1.5rem; }
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.bookmarks-grid { gap: 0.75rem; }
|
.bookmarks-grid { gap: 0.75rem; }
|
||||||
@@ -44,9 +44,9 @@
|
|||||||
.individual-bookmark:hover { border-color: var(--color-border); background: var(--color-bg-elevated); }
|
.individual-bookmark:hover { border-color: var(--color-border); background: var(--color-bg-elevated); }
|
||||||
|
|
||||||
/* Compact view */
|
/* Compact view */
|
||||||
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none !important; border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
|
.individual-bookmark.compact { padding: 0.25rem 0.5rem; background: transparent; border: none !important; border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
|
||||||
.individual-bookmark.compact:hover { background: var(--color-bg-elevated); transform: none; box-shadow: none; border: none !important; }
|
.individual-bookmark.compact:hover { background: var(--color-bg-elevated); transform: none; box-shadow: none; border: none !important; }
|
||||||
.compact-row { display: flex; align-items: center; gap: 0.5rem; height: 28px; width: 100%; min-width: 0; overflow: hidden; }
|
.compact-row { display: flex; align-items: center; gap: 0.5rem; height: 24px; width: 100%; min-width: 0; overflow: hidden; }
|
||||||
.compact-thumbnail { width: 24px; height: 24px; flex-shrink: 0; border-radius: 4px; overflow: hidden; background: var(--color-bg-elevated); display: flex; align-items: center; justify-content: center; }
|
.compact-thumbnail { width: 24px; height: 24px; flex-shrink: 0; border-radius: 4px; overflow: hidden; background: var(--color-bg-elevated); display: flex; align-items: center; justify-content: center; }
|
||||||
.compact-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
|
.compact-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
.compact-row.clickable { cursor: pointer; }
|
.compact-row.clickable { cursor: pointer; }
|
||||||
|
|||||||
@@ -109,6 +109,16 @@
|
|||||||
background: var(--color-bg-elevated) !important;
|
background: var(--color-bg-elevated) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bookmarks-list .individual-bookmark.compact {
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-list .individual-bookmark.compact:hover {
|
||||||
|
border-color: transparent !important;
|
||||||
|
background: var(--color-bg-elevated) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.bookmark-item {
|
.bookmark-item {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ cleanupOutdatedCaches()
|
|||||||
sw.skipWaiting()
|
sw.skipWaiting()
|
||||||
clientsClaim()
|
clientsClaim()
|
||||||
|
|
||||||
console.log('[SW] Boris service worker loaded')
|
|
||||||
|
|
||||||
// Runtime cache: Cross-origin images
|
// Runtime cache: Cross-origin images
|
||||||
// This preserves the existing image caching behavior
|
// This preserves the existing image caching behavior
|
||||||
|
|||||||
@@ -118,6 +118,16 @@ export function hasContent(bookmark: IndividualBookmark): boolean {
|
|||||||
return hasValidContent || hasId
|
return hasValidContent || hasId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if bookmark has a real creation date (not "Now" / current time)
|
||||||
|
export function hasCreationDate(bookmark: IndividualBookmark): boolean {
|
||||||
|
if (!bookmark.created_at) return false
|
||||||
|
// If timestamp is missing or equals current time (within 1 second), consider it invalid
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const createdAt = Math.floor(bookmark.created_at)
|
||||||
|
// If created_at is within 1 second of now, it's likely missing/placeholder
|
||||||
|
return Math.abs(createdAt - now) > 1
|
||||||
|
}
|
||||||
|
|
||||||
// Bookmark sets helpers (kind 30003)
|
// Bookmark sets helpers (kind 30003)
|
||||||
export interface BookmarkSet {
|
export interface BookmarkSet {
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -16,18 +16,15 @@ const loadingFonts = new Map<string, Promise<void>>()
|
|||||||
|
|
||||||
export async function loadFont(fontKey: string): Promise<void> {
|
export async function loadFont(fontKey: string): Promise<void> {
|
||||||
if (fontKey === 'system') {
|
if (fontKey === 'system') {
|
||||||
console.log('📝 Using system font')
|
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadedFonts.has(fontKey)) {
|
if (loadedFonts.has(fontKey)) {
|
||||||
console.log('✅ Font already loaded:', fontKey)
|
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
// If font is currently loading, return the existing promise
|
// If font is currently loading, return the existing promise
|
||||||
if (loadingFonts.has(fontKey)) {
|
if (loadingFonts.has(fontKey)) {
|
||||||
console.log('⏳ Font already loading:', fontKey)
|
|
||||||
return loadingFonts.get(fontKey)!
|
return loadingFonts.get(fontKey)!
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +34,6 @@ export async function loadFont(fontKey: string): Promise<void> {
|
|||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔤 Loading font:', fontFamily)
|
|
||||||
|
|
||||||
// Create a promise for this font loading
|
// Create a promise for this font loading
|
||||||
const loadPromise = new Promise<void>((resolve) => {
|
const loadPromise = new Promise<void>((resolve) => {
|
||||||
@@ -48,7 +44,6 @@ export async function loadFont(fontKey: string): Promise<void> {
|
|||||||
|
|
||||||
// Wait for the stylesheet to load
|
// Wait for the stylesheet to load
|
||||||
link.onload = () => {
|
link.onload = () => {
|
||||||
console.log('📄 Stylesheet loaded for:', fontFamily)
|
|
||||||
|
|
||||||
// Use Font Loading API to wait for the actual font to be ready
|
// Use Font Loading API to wait for the actual font to be ready
|
||||||
if ('fonts' in document) {
|
if ('fonts' in document) {
|
||||||
@@ -56,7 +51,6 @@ export async function loadFont(fontKey: string): Promise<void> {
|
|||||||
document.fonts.load(`400 16px "${fontFamily}"`),
|
document.fonts.load(`400 16px "${fontFamily}"`),
|
||||||
document.fonts.load(`700 16px "${fontFamily}"`)
|
document.fonts.load(`700 16px "${fontFamily}"`)
|
||||||
]).then(() => {
|
]).then(() => {
|
||||||
console.log('✅ Font ready:', fontFamily)
|
|
||||||
loadedFonts.add(fontKey)
|
loadedFonts.add(fontKey)
|
||||||
loadingFonts.delete(fontKey)
|
loadingFonts.delete(fontKey)
|
||||||
resolve()
|
resolve()
|
||||||
@@ -69,7 +63,6 @@ export async function loadFont(fontKey: string): Promise<void> {
|
|||||||
} else {
|
} else {
|
||||||
// Fallback: just wait a bit for older browsers
|
// Fallback: just wait a bit for older browsers
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log('✅ Font assumed ready (no Font Loading API):', fontFamily)
|
|
||||||
loadedFonts.add(fontKey)
|
loadedFonts.add(fontKey)
|
||||||
loadingFonts.delete(fontKey)
|
loadingFonts.delete(fontKey)
|
||||||
resolve()
|
resolve()
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// Extract pubkeys from nprofile strings in content
|
// Extract pubkeys from nprofile strings in content
|
||||||
|
import { READING_PROGRESS } from '../config/kinds'
|
||||||
|
|
||||||
export const extractNprofilePubkeys = (content: string): string[] => {
|
export const extractNprofilePubkeys = (content: string): string[] => {
|
||||||
const nprofileRegex = /nprofile1[a-z0-9]+/gi
|
const nprofileRegex = /nprofile1[a-z0-9]+/gi
|
||||||
const matches = content.match(nprofileRegex) || []
|
const matches = content.match(nprofileRegex) || []
|
||||||
@@ -123,3 +125,14 @@ export function createParallelReqStreams(
|
|||||||
return { local$, remote$ }
|
return { local$, remote$ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if content is long enough to track reading progress
|
||||||
|
* Minimum 1000 characters (roughly 150 words)
|
||||||
|
*/
|
||||||
|
export const shouldTrackReadingProgress = (html: string | undefined, markdown: string | undefined): boolean => {
|
||||||
|
const content = (html || markdown || '').trim()
|
||||||
|
// Strip HTML tags to get character count
|
||||||
|
const plainText = content.replace(/<[^>]*>/g, '').trim()
|
||||||
|
return plainText.length >= READING_PROGRESS.MIN_CONTENT_LENGTH
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,9 @@ export function applyHighlightsToHTML(
|
|||||||
highlightStyle: 'marker' | 'underline' = 'marker'
|
highlightStyle: 'marker' | 'underline' = 'marker'
|
||||||
): string {
|
): string {
|
||||||
if (!html || highlights.length === 0) {
|
if (!html || highlights.length === 0) {
|
||||||
console.log('⚠️ applyHighlightsToHTML: No HTML or highlights', {
|
|
||||||
htmlLength: html?.length,
|
|
||||||
highlightsCount: highlights.length
|
|
||||||
})
|
|
||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔨 applyHighlightsToHTML: Processing', highlights.length, 'highlights')
|
|
||||||
|
|
||||||
const tempDiv = document.createElement('div')
|
const tempDiv = document.createElement('div')
|
||||||
tempDiv.innerHTML = html
|
tempDiv.innerHTML = html
|
||||||
@@ -31,9 +26,6 @@ export function applyHighlightsToHTML(
|
|||||||
mark.parentNode?.replaceChild(textNode, mark)
|
mark.parentNode?.replaceChild(textNode, mark)
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('🧹 Removed', existingMarks.length, 'existing highlight marks')
|
|
||||||
|
|
||||||
let appliedCount = 0
|
|
||||||
|
|
||||||
for (const highlight of highlights) {
|
for (const highlight of highlights) {
|
||||||
const searchText = highlight.content.trim()
|
const searchText = highlight.content.trim()
|
||||||
@@ -42,7 +34,6 @@ export function applyHighlightsToHTML(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔍 Searching for highlight:', searchText.substring(0, 50) + '...')
|
|
||||||
|
|
||||||
// Collect all text nodes
|
// Collect all text nodes
|
||||||
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
|
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
|
||||||
@@ -50,21 +41,16 @@ export function applyHighlightsToHTML(
|
|||||||
let node: Node | null
|
let node: Node | null
|
||||||
while ((node = walker.nextNode())) textNodes.push(node as Text)
|
while ((node = walker.nextNode())) textNodes.push(node as Text)
|
||||||
|
|
||||||
console.log('📄 Found', textNodes.length, 'text nodes to search')
|
|
||||||
|
|
||||||
// Try exact match first, then normalized match
|
// Try exact match first, then normalized match
|
||||||
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
|
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
|
||||||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
|
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
|
||||||
|
|
||||||
if (found) {
|
if (!found) {
|
||||||
appliedCount++
|
|
||||||
console.log('✅ Highlight applied successfully')
|
|
||||||
} else {
|
|
||||||
console.warn('❌ Could not find match for highlight:', searchText.substring(0, 50))
|
console.warn('❌ Could not find match for highlight:', searchText.substring(0, 50))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🎉 Applied', appliedCount, '/', highlights.length, 'highlights')
|
|
||||||
|
|
||||||
return tempDiv.innerHTML
|
return tempDiv.innerHTML
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,58 @@
|
|||||||
import { ReadItem } from '../services/readsService'
|
import { ReadItem } from '../services/readsService'
|
||||||
import { ReadingProgressFilterType } from '../components/ReadingProgressFilters'
|
import { ReadingProgressFilterType } from '../components/ReadingProgressFilters'
|
||||||
|
import { Highlight } from '../types/highlights'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters ReadItems by reading progress
|
* Filters ReadItems by reading progress
|
||||||
*/
|
*/
|
||||||
export function filterByReadingProgress(
|
export function filterByReadingProgress(
|
||||||
items: ReadItem[],
|
items: ReadItem[],
|
||||||
filter: ReadingProgressFilterType
|
filter: ReadingProgressFilterType,
|
||||||
|
highlights?: Highlight[]
|
||||||
): ReadItem[] {
|
): ReadItem[] {
|
||||||
|
// Build a map of article references to highlight count
|
||||||
|
// Normalize both coordinate and naddr formats for matching
|
||||||
|
const articleHighlightCount = new Map<string, number>()
|
||||||
|
if (highlights) {
|
||||||
|
highlights.forEach(h => {
|
||||||
|
if (h.eventReference) {
|
||||||
|
// eventReference could be a hex ID or a coordinate (30023:pubkey:identifier)
|
||||||
|
let normalizedRef = h.eventReference
|
||||||
|
|
||||||
|
// If it's a coordinate, convert to naddr format for matching
|
||||||
|
if (h.eventReference.includes(':')) {
|
||||||
|
const parts = h.eventReference.split(':')
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const [kind, pubkey, identifier] = parts
|
||||||
|
try {
|
||||||
|
normalizedRef = nip19.naddrEncode({
|
||||||
|
kind: parseInt(kind),
|
||||||
|
pubkey,
|
||||||
|
identifier
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// If conversion fails, use the original reference
|
||||||
|
normalizedRef = h.eventReference
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = articleHighlightCount.get(normalizedRef) || 0
|
||||||
|
articleHighlightCount.set(normalizedRef, count + 1)
|
||||||
|
}
|
||||||
|
if (h.urlReference) {
|
||||||
|
const count = articleHighlightCount.get(h.urlReference) || 0
|
||||||
|
articleHighlightCount.set(h.urlReference, count + 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
const progress = item.readingProgress || 0
|
const progress = item.readingProgress || 0
|
||||||
const isMarked = item.markedAsRead || false
|
const isMarked = item.markedAsRead || false
|
||||||
|
const hasHighlights = (articleHighlightCount.get(item.id) || 0) > 0 ||
|
||||||
|
(item.url && (articleHighlightCount.get(item.url) || 0) > 0)
|
||||||
|
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
case 'unopened':
|
case 'unopened':
|
||||||
@@ -21,6 +63,8 @@ export function filterByReadingProgress(
|
|||||||
return progress > 0.10 && progress <= 0.94 && !isMarked
|
return progress > 0.10 && progress <= 0.94 && !isMarked
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return progress >= 0.95 || isMarked
|
return progress >= 0.95 || isMarked
|
||||||
|
case 'highlighted':
|
||||||
|
return hasHighlights
|
||||||
case 'all':
|
case 'all':
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -43,8 +43,7 @@ export function applyTheme(
|
|||||||
root.classList.add(`light-${lightColorTheme}`)
|
root.classList.add(`light-${lightColorTheme}`)
|
||||||
|
|
||||||
// Listen for system theme changes
|
// Listen for system theme changes
|
||||||
mediaQueryListener = (e: MediaQueryListEvent) => {
|
mediaQueryListener = () => {
|
||||||
console.log('🎨 System theme changed to:', e.matches ? 'dark' : 'light')
|
|
||||||
// The CSS media query handles the color changes automatically
|
// The CSS media query handles the color changes automatically
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,5 +58,4 @@ export function applyTheme(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🎨 Applied theme:', theme, 'with colors:', { dark: darkColorTheme, light: lightColorTheme })
|
|
||||||
}
|
}
|
||||||
|
|||||||
15
src/utils/toBlogPostPreview.ts
Normal file
15
src/utils/toBlogPostPreview.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { Helpers } from 'applesauce-core'
|
||||||
|
import { BlogPostPreview } from '../services/exploreService'
|
||||||
|
|
||||||
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
|
export const toBlogPostPreview = (event: NostrEvent): BlogPostPreview => ({
|
||||||
|
event,
|
||||||
|
title: getArticleTitle(event) || 'Untitled',
|
||||||
|
summary: getArticleSummary(event),
|
||||||
|
image: getArticleImage(event),
|
||||||
|
published: getArticlePublished(event),
|
||||||
|
author: event.pubkey
|
||||||
|
})
|
||||||
|
|
||||||
@@ -11,26 +11,21 @@ export function normalizeUrl(url: string): string {
|
|||||||
|
|
||||||
export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: string | undefined): Highlight[] {
|
export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: string | undefined): Highlight[] {
|
||||||
if (!selectedUrl || highlights.length === 0) {
|
if (!selectedUrl || highlights.length === 0) {
|
||||||
console.log('🔍 filterHighlightsByUrl: No URL or highlights', { selectedUrl, count: highlights.length })
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔍 filterHighlightsByUrl:', { selectedUrl, totalHighlights: highlights.length })
|
|
||||||
|
|
||||||
// For Nostr articles, we already fetched highlights specifically for this article
|
// For Nostr articles, we already fetched highlights specifically for this article
|
||||||
// So we don't need to filter them - they're all relevant
|
// So we don't need to filter them - they're all relevant
|
||||||
if (selectedUrl.startsWith('nostr:')) {
|
if (selectedUrl.startsWith('nostr:')) {
|
||||||
console.log('📌 Nostr article - returning all', highlights.length, 'highlights')
|
|
||||||
return highlights
|
return highlights
|
||||||
}
|
}
|
||||||
|
|
||||||
// For web URLs, filter by URL matching
|
// For web URLs, filter by URL matching
|
||||||
const normalizedSelected = normalizeUrl(selectedUrl)
|
const normalizedSelected = normalizeUrl(selectedUrl)
|
||||||
console.log('🔗 Normalized selected URL:', normalizedSelected)
|
|
||||||
|
|
||||||
const filtered = highlights.filter(h => {
|
const filtered = highlights.filter(h => {
|
||||||
if (!h.urlReference) {
|
if (!h.urlReference) {
|
||||||
console.log('⚠️ Highlight has no urlReference:', h.id, 'eventReference:', h.eventReference)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const normalizedRef = normalizeUrl(h.urlReference)
|
const normalizedRef = normalizeUrl(h.urlReference)
|
||||||
@@ -39,14 +34,13 @@ export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: stri
|
|||||||
normalizedRef.includes(normalizedSelected)
|
normalizedRef.includes(normalizedSelected)
|
||||||
|
|
||||||
if (matches) {
|
if (matches) {
|
||||||
console.log('✅ URL match:', normalizedRef)
|
// URLs match
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ URL mismatch:', normalizedRef, 'vs', normalizedSelected)
|
// URLs do not match
|
||||||
}
|
}
|
||||||
|
|
||||||
return matches
|
return matches
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('📊 Filtered to', filtered.length, 'highlights')
|
|
||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user