mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e6c8b7516 | ||
|
|
e50af42c96 | ||
|
|
73470987be | ||
|
|
31e203825d | ||
|
|
6f9c0a35e2 | ||
|
|
96f59a54f3 | ||
|
|
87c0a0454b | ||
|
|
77c2ef1794 | ||
|
|
8d08911bd3 | ||
|
|
31b005a989 | ||
|
|
337bfe5432 | ||
|
|
2f275375f7 | ||
|
|
27cbcb56ec | ||
|
|
7f150003b5 | ||
|
|
1f50d8e1b6 | ||
|
|
f53decef16 | ||
|
|
f272943b64 | ||
|
|
49745e1b8a | ||
|
|
470f4fb34e | ||
|
|
8cde36c08c | ||
|
|
c21f96f5bb | ||
|
|
c9fef5804b | ||
|
|
8337622a22 | ||
|
|
572f0fed6f | ||
|
|
27a55ec329 | ||
|
|
7ba362a3bb | ||
|
|
dc1844907e | ||
|
|
28123b5e13 | ||
|
|
d9eb87aa5c | ||
|
|
a0ff0daf9d | ||
|
|
8c3baf1416 | ||
|
|
e0c169edbc | ||
|
|
d2181ad772 | ||
|
|
8ff3f08d8c | ||
|
|
e17e1bc824 | ||
|
|
948674ae8c | ||
|
|
431f14f56d | ||
|
|
4cc9d557a0 | ||
|
|
cc60f9584a | ||
|
|
94f1f9035b | ||
|
|
e5b1594933 | ||
|
|
2bf9b9789b | ||
|
|
d3405a4029 | ||
|
|
763f7bef4d | ||
|
|
e8e629f4e1 | ||
|
|
a0829e834f | ||
|
|
ff938aa384 | ||
|
|
3991bfeeb2 | ||
|
|
e8c35c8914 | ||
|
|
46345c154b | ||
|
|
f43dae92aa | ||
|
|
99c164a5e9 | ||
|
|
569b4357f2 | ||
|
|
de287c625b | ||
|
|
1424f6ebc5 | ||
|
|
b0a368fc64 | ||
|
|
6f8cf641b7 | ||
|
|
23b4c3475f | ||
|
|
5633dc640c | ||
|
|
0f1dfa445a | ||
|
|
ab5225de50 | ||
|
|
b89705cf43 | ||
|
|
740dd53299 | ||
|
|
eb61553c20 | ||
|
|
8b708535ca | ||
|
|
f77761c002 | ||
|
|
b900666eb8 | ||
|
|
2639c78957 | ||
|
|
8320911bc9 | ||
|
|
00d6bd4c46 | ||
|
|
cd377b6f26 | ||
|
|
84b0339505 | ||
|
|
12fa1db0db | ||
|
|
0919091f19 | ||
|
|
e1c04b4e7f | ||
|
|
b9642067a1 | ||
|
|
ceca37df08 | ||
|
|
dfdc5d0946 | ||
|
|
3619cd2585 | ||
|
|
f93e52611e | ||
|
|
ecb81cb151 | ||
|
|
adf73cb9d1 | ||
|
|
4202807777 |
45
CHANGELOG.md
45
CHANGELOG.md
@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.3] - 2025-01-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Highlight creation now shows immediate UI feedback without page refresh
|
||||
- Fixed streaming highlight merge logic to preserve newly created highlights
|
||||
- Decoupled cached highlight sync from content loading to prevent unintended reloads
|
||||
- Newly created highlights appear instantly in both reader and highlights panel
|
||||
- Highlights remain visible while remote results stream in and merge properly
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved highlight creation user experience
|
||||
- Selection clearing and synchronous rendering for immediate highlight display
|
||||
- Better error handling for bunker permission issues with user-friendly messages
|
||||
|
||||
## [0.8.2] - 2025-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Reading progress indicator in compact bookmark cards
|
||||
- Shows progress bar for articles and web bookmarks with reading data
|
||||
- Progress bar aligned with bookmark text for better visual association
|
||||
- Color-coded progress (blue for reading, green for completed, gray for started)
|
||||
|
||||
### Changed
|
||||
|
||||
- Compact cards layout optimizations for more space-efficient display
|
||||
- Reduced vertical padding from 0.5rem to 0.25rem
|
||||
- Reduced compact row height from 28px to 24px
|
||||
- Reduced gap between compact cards from 0.5rem to 0.25rem
|
||||
- Reading progress bar styling for compact view
|
||||
- Bar height reduced from 2px to 1px for more subtle appearance
|
||||
- Left margin of 1.5rem aligns bar with bookmark text instead of appearing as separator
|
||||
|
||||
### Fixed
|
||||
|
||||
- Removed borders from compact bookmarks in bookmarks sidebar
|
||||
- Border styling from `.bookmarks-list` no longer applies to compact cards
|
||||
- Compact cards now display as truly borderless and transparent
|
||||
|
||||
## [0.8.0] - 2025-10-19
|
||||
|
||||
### Added
|
||||
@@ -2044,7 +2085,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.8.0...HEAD
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.8.3...HEAD
|
||||
[0.8.3]: https://github.com/dergigi/boris/compare/v0.8.2...v0.8.3
|
||||
[0.8.2]: https://github.com/dergigi/boris/compare/v0.8.0...v0.8.2
|
||||
[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
|
||||
|
||||
@@ -15,7 +15,6 @@ const RELAYS = [
|
||||
'wss://relay.dergigi.com',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.current.fyi',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.primal.net'
|
||||
@@ -215,12 +214,6 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
|
||||
const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
|
||||
if (debugEnabled) {
|
||||
console.log('[article-og] request', JSON.stringify({
|
||||
naddr,
|
||||
ua: userAgent || null,
|
||||
isCrawlerRequest,
|
||||
path: req.url || null
|
||||
}))
|
||||
res.setHeader('X-Boris-Debug', '1')
|
||||
}
|
||||
|
||||
@@ -257,7 +250,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
if (debugEnabled) {
|
||||
console.log('[article-og] response', JSON.stringify({ mode: 'browser', naddr }))
|
||||
// Debug mode enabled
|
||||
}
|
||||
return res.status(200).send(html)
|
||||
}
|
||||
@@ -268,7 +261,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
if (cached && cached.expires > now) {
|
||||
setCacheHeaders(res)
|
||||
if (debugEnabled) {
|
||||
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: true }))
|
||||
// Debug mode enabled
|
||||
}
|
||||
return res.status(200).send(cached.html)
|
||||
}
|
||||
@@ -286,7 +279,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
// Send response
|
||||
setCacheHeaders(res)
|
||||
if (debugEnabled) {
|
||||
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: false }))
|
||||
// Debug mode enabled
|
||||
}
|
||||
return res.status(200).send(html)
|
||||
} catch (err) {
|
||||
@@ -296,7 +289,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
const html = generateHtml(naddr, null)
|
||||
setCacheHeaders(res, 3600)
|
||||
if (debugEnabled) {
|
||||
console.log('[article-og] response', JSON.stringify({ mode: 'bot-fallback', naddr }))
|
||||
// Debug mode enabled
|
||||
}
|
||||
return res.status(200).send(html)
|
||||
}
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.13",
|
||||
"version": "0.8.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.6.13",
|
||||
"version": "0.8.4",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.4",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
103
src/App.tsx
103
src/App.tsx
@@ -28,6 +28,7 @@ import { readingProgressController } from './services/readingProgressController'
|
||||
// import { fetchNostrverseHighlights } from './services/nostrverseService'
|
||||
import { nostrverseHighlightsController } from './services/nostrverseHighlightsController'
|
||||
import { nostrverseWritingsController } from './services/nostrverseWritingsController'
|
||||
import { archiveController } from './services/archiveController'
|
||||
|
||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||
@@ -70,18 +71,14 @@ function AppRoutes({
|
||||
|
||||
// Subscribe to contacts controller
|
||||
useEffect(() => {
|
||||
console.log('[contacts] 🎧 Subscribing to contacts controller')
|
||||
const unsubContacts = contactsController.onContacts((contacts) => {
|
||||
console.log('[contacts] 📥 Received contacts:', contacts.size)
|
||||
setContacts(contacts)
|
||||
})
|
||||
const unsubLoading = contactsController.onLoading((loading) => {
|
||||
console.log('[contacts] 📥 Loading state:', loading)
|
||||
setContactsLoading(loading)
|
||||
})
|
||||
|
||||
return () => {
|
||||
console.log('[contacts] 🔇 Unsubscribing from contacts controller')
|
||||
unsubContacts()
|
||||
unsubLoading()
|
||||
}
|
||||
@@ -100,28 +97,29 @@ function AppRoutes({
|
||||
|
||||
// Load contacts
|
||||
if (pubkey && contacts.size === 0 && !contactsLoading) {
|
||||
console.log('[contacts] 🚀 Auto-loading contacts on mount/login')
|
||||
contactsController.start({ relayPool, pubkey })
|
||||
}
|
||||
|
||||
// Load highlights (controller manages its own state)
|
||||
if (pubkey && eventStore && !highlightsController.isLoadedFor(pubkey)) {
|
||||
console.log('[highlights] 🚀 Auto-loading highlights on mount/login')
|
||||
highlightsController.start({ relayPool, eventStore, pubkey })
|
||||
}
|
||||
|
||||
// Load writings (controller manages its own state)
|
||||
if (pubkey && eventStore && !writingsController.isLoadedFor(pubkey)) {
|
||||
console.log('[writings] 🚀 Auto-loading writings on mount/login')
|
||||
writingsController.start({ relayPool, eventStore, pubkey })
|
||||
}
|
||||
|
||||
// Load reading progress (controller manages its own state)
|
||||
if (pubkey && eventStore && !readingProgressController.isLoadedFor(pubkey)) {
|
||||
console.log('[progress] 🚀 Auto-loading reading progress on mount/login')
|
||||
readingProgressController.start({ relayPool, eventStore, pubkey })
|
||||
}
|
||||
|
||||
// Load archive (marked-as-read) controller
|
||||
if (pubkey && eventStore && !archiveController.isLoadedFor(pubkey)) {
|
||||
archiveController.start({ relayPool, eventStore, pubkey })
|
||||
}
|
||||
|
||||
// Start centralized nostrverse highlights controller (non-blocking)
|
||||
if (eventStore) {
|
||||
nostrverseHighlightsController.start({ relayPool, eventStore })
|
||||
@@ -153,6 +151,7 @@ function AppRoutes({
|
||||
contactsController.reset() // Clear contacts via controller
|
||||
highlightsController.reset() // Clear highlights via controller
|
||||
readingProgressController.reset() // Clear reading progress via controller
|
||||
archiveController.reset() // Clear archive state
|
||||
showToast('Logged out successfully')
|
||||
}
|
||||
|
||||
@@ -294,6 +293,18 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/links/:filter"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/writings"
|
||||
element={
|
||||
@@ -386,40 +397,31 @@ function App() {
|
||||
// Return an already-resolved promise so upstream await finishes immediately
|
||||
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
|
||||
pool.group(RELAYS)
|
||||
console.log('[bunker] Created relay group with', RELAYS.length, 'relays (including local)')
|
||||
|
||||
// Load persisted accounts from localStorage
|
||||
try {
|
||||
const accountsJson = localStorage.getItem('accounts')
|
||||
console.log('[bunker] Raw accounts from localStorage:', accountsJson)
|
||||
|
||||
const json = JSON.parse(accountsJson || '[]')
|
||||
console.log('[bunker] Parsed accounts:', json.length, 'accounts')
|
||||
|
||||
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
|
||||
const activeId = localStorage.getItem('active')
|
||||
console.log('[bunker] Active ID from localStorage:', activeId)
|
||||
|
||||
if (activeId) {
|
||||
const account = accounts.getAccount(activeId)
|
||||
console.log('[bunker] Found account for ID?', !!account, account?.type)
|
||||
|
||||
if (account) {
|
||||
accounts.setActive(activeId)
|
||||
console.log('[bunker] ✅ Restored active account:', activeId, 'type:', account.type)
|
||||
} else {
|
||||
console.warn('[bunker] ⚠️ Active ID found but account not in list')
|
||||
}
|
||||
} else {
|
||||
console.log('[bunker] No active account ID in localStorage')
|
||||
// No active account ID in localStorage
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[bunker] ❌ Failed to load accounts from storage:', err)
|
||||
@@ -444,11 +446,6 @@ function App() {
|
||||
const reconnectedAccounts = new Set<string>()
|
||||
|
||||
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') {
|
||||
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
|
||||
@@ -456,23 +453,17 @@ function App() {
|
||||
try {
|
||||
if (!(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue) {
|
||||
(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.
|
||||
|
||||
// Skip if we've already reconnected this account
|
||||
if (reconnectedAccounts.has(account.id)) {
|
||||
console.log('[bunker] ⏭️ Already reconnected this account, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[bunker] Account detected. Status:', {
|
||||
listening: nostrConnectAccount.signer.listening,
|
||||
isConnected: nostrConnectAccount.signer.isConnected,
|
||||
hasRemote: !!nostrConnectAccount.signer.remote,
|
||||
bunkerRelays: nostrConnectAccount.signer.relays
|
||||
})
|
||||
|
||||
try {
|
||||
// For restored signers, ensure they have the pool's subscription methods
|
||||
@@ -486,10 +477,9 @@ function App() {
|
||||
const newBunkerRelays = bunkerRelays.filter(url => !existingRelayUrls.has(url))
|
||||
|
||||
if (newBunkerRelays.length > 0) {
|
||||
console.log('[bunker] Adding bunker relays to pool BEFORE signer recreation:', newBunkerRelays)
|
||||
pool.group(newBunkerRelays)
|
||||
} else {
|
||||
console.log('[bunker] Bunker relays already in pool')
|
||||
// Bunker relays already in pool
|
||||
}
|
||||
|
||||
const recreatedSigner = new NostrConnectSigner({
|
||||
@@ -503,13 +493,11 @@ function App() {
|
||||
try {
|
||||
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
|
||||
recreatedSigner.relays = mergedRelays
|
||||
console.log('[bunker] 🔗 Signer relays merged with app RELAYS:', mergedRelays)
|
||||
} catch (err) { console.warn('[bunker] failed to merge signer relays', err) }
|
||||
|
||||
// Replace the signer on the account
|
||||
nostrConnectAccount.signer = recreatedSigner
|
||||
console.log('[bunker] ✅ Signer recreated with pool context')
|
||||
|
||||
|
||||
// Debug: log publish/subscription calls made by signer (decrypt/sign requests)
|
||||
// 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)
|
||||
@@ -531,7 +519,6 @@ function App() {
|
||||
tags: (event as { tags?: unknown })?.tags,
|
||||
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) }
|
||||
} catch (err) { console.warn('[bunker] failed to log publish summary', err) }
|
||||
// Fire-and-forget publish: trigger the publish but do not return the
|
||||
@@ -549,7 +536,6 @@ function App() {
|
||||
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[]) => {
|
||||
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) }
|
||||
} catch (err) { console.warn('[bunker] failed to log subscribe summary', err) }
|
||||
return originalSubscribe(relays, filters)
|
||||
@@ -559,20 +545,16 @@ function App() {
|
||||
// Just ensure the signer is listening for responses - don't call connect() again
|
||||
// The fromBunkerURI already connected with permissions during login
|
||||
if (!nostrConnectAccount.signer.listening) {
|
||||
console.log('[bunker] Opening signer subscription...')
|
||||
await nostrConnectAccount.signer.open()
|
||||
console.log('[bunker] ✅ Signer subscription opened')
|
||||
} else {
|
||||
console.log('[bunker] ✅ Signer already listening')
|
||||
// Signer already listening
|
||||
}
|
||||
|
||||
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
|
||||
try {
|
||||
if (nostrConnectAccount.signer.remote && !reconnectedAccounts.has(account.id)) {
|
||||
const permissions = getDefaultBunkerPermissions()
|
||||
console.log('[bunker] Attempting guarded connect() with permissions to ensure decrypt perms', { count: permissions.length })
|
||||
await nostrConnectAccount.signer.connect(undefined, permissions)
|
||||
console.log('[bunker] ✅ Guarded connect() succeeded with permissions')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[bunker] ⚠️ Guarded connect() failed:', e)
|
||||
@@ -581,7 +563,6 @@ function App() {
|
||||
// Give the subscription a moment to fully establish before allowing decrypt operations
|
||||
// This ensures the signer is ready to handle and receive responses
|
||||
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
|
||||
try {
|
||||
const withTimeout = async <T,>(p: Promise<T>, ms = 10000): Promise<T> => {
|
||||
@@ -594,38 +575,27 @@ function App() {
|
||||
const self = nostrConnectAccount.pubkey
|
||||
// Try a roundtrip so the bunker can respond successfully
|
||||
try {
|
||||
console.log('[bunker] 🔎 Probe nip44 roundtrip (encrypt→decrypt)…')
|
||||
const cipher44 = await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44'))
|
||||
const plain44 = await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, cipher44))
|
||||
console.log('[bunker] 🔎 Probe nip44 responded:', typeof plain44 === 'string' ? plain44 : typeof plain44)
|
||||
} catch (err) {
|
||||
console.log('[bunker] 🔎 Probe nip44 result:', err instanceof Error ? err.message : err)
|
||||
await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44'))
|
||||
await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, ''))
|
||||
} catch (_err) {
|
||||
// Ignore probe errors
|
||||
}
|
||||
try {
|
||||
console.log('[bunker] 🔎 Probe nip04 roundtrip (encrypt→decrypt)…')
|
||||
const cipher04 = await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04'))
|
||||
const plain04 = await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, cipher04))
|
||||
console.log('[bunker] 🔎 Probe nip04 responded:', typeof plain04 === 'string' ? plain04 : typeof plain04)
|
||||
} catch (err) {
|
||||
console.log('[bunker] 🔎 Probe nip04 result:', err instanceof Error ? err.message : err)
|
||||
await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04'))
|
||||
await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, ''))
|
||||
} catch (_err) {
|
||||
// Ignore probe errors
|
||||
}
|
||||
}, 0)
|
||||
} catch (err) {
|
||||
console.log('[bunker] 🔎 Probe setup failed:', err)
|
||||
} catch (_err) {
|
||||
// Ignore signer setup errors
|
||||
}
|
||||
// The bunker remembers the permissions from the initial connection
|
||||
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
|
||||
reconnectedAccounts.add(account.id)
|
||||
console.log('[bunker] 🎉 Signer ready for signing')
|
||||
} catch (error) {
|
||||
console.error('[bunker] ❌ Failed to open signer:', error)
|
||||
}
|
||||
@@ -639,7 +609,6 @@ function App() {
|
||||
next: () => {}, // No-op, we don't care about events
|
||||
error: (err) => console.warn('Keep-alive subscription error:', err)
|
||||
})
|
||||
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
|
||||
|
||||
// Store subscription for cleanup
|
||||
;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub
|
||||
|
||||
@@ -16,7 +16,7 @@ const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilte
|
||||
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
|
||||
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
||||
{ type: 'marked' as const, icon: faBooks, label: 'Marked as Read' }
|
||||
{ type: 'marked' as const, icon: faBooks, label: 'Archived' }
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -34,9 +34,9 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
|
||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||
}
|
||||
|
||||
// Debug log
|
||||
// Debug log - reading progress shown as visual indicator
|
||||
if (readingProgress !== undefined) {
|
||||
console.log('[progress] 🎴 Card render:', post.title.slice(0, 30), '=> progress:', progressPercent + '%', 'color:', progressColor)
|
||||
// Reading progress display
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,6 +22,10 @@ import { Hooks } from 'applesauce-react'
|
||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||
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 {
|
||||
bookmarks: Bookmark[]
|
||||
@@ -70,6 +74,45 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
return saved === 'flat' ? 'flat' : 'grouped'
|
||||
})
|
||||
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 newMode = groupingMode === 'grouped' ? 'flat' : 'grouped'
|
||||
@@ -221,6 +264,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
readingProgress={getBookmarkReadingProgress(individualBookmark)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -73,15 +73,16 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
{/* CTA removed */}
|
||||
</div>
|
||||
|
||||
{/* Reading progress indicator for articles */}
|
||||
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
|
||||
{/* Reading progress indicator for all bookmark types with reading data */}
|
||||
{readingProgress !== undefined && readingProgress > 0 && (
|
||||
<div
|
||||
style={{
|
||||
height: '2px',
|
||||
height: '1px',
|
||||
width: '100%',
|
||||
background: 'var(--color-border)',
|
||||
overflow: 'hidden',
|
||||
margin: '0'
|
||||
margin: '0',
|
||||
marginLeft: '1.5rem'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -64,7 +64,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
location.pathname === '/me/highlights' ? 'highlights' :
|
||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
||||
location.pathname.startsWith('/me/reads') ? 'reads' :
|
||||
location.pathname === '/me/links' ? 'links' :
|
||||
location.pathname.startsWith('/me/links') ? 'links' :
|
||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||
|
||||
// Extract tab from profile routes
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
hasMarkedEventAsRead,
|
||||
hasMarkedWebsiteAsRead
|
||||
} from '../services/reactionService'
|
||||
import { unarchiveEvent, unarchiveWebsite } from '../services/unarchiveService'
|
||||
import { archiveController } from '../services/archiveController'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
||||
@@ -151,33 +153,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
// Callback to save reading position
|
||||
const handleSavePosition = useCallback(async (position: number) => {
|
||||
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||
console.log('[progress] ⏭️ ContentPanel: Skipping save - missing requirements:', {
|
||||
hasAccount: !!activeAccount,
|
||||
hasRelayPool: !!relayPool,
|
||||
hasEventStore: !!eventStore,
|
||||
hasIdentifier: !!articleIdentifier
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!settings?.syncReadingPosition) {
|
||||
console.log('[progress] ⏭️ ContentPanel: Sync disabled in settings')
|
||||
return
|
||||
}
|
||||
|
||||
// Check if content is long enough to track reading progress
|
||||
if (!shouldTrackReadingProgress(html, markdown)) {
|
||||
console.log('[progress] ⏭️ ContentPanel: Content too short to track reading progress')
|
||||
return
|
||||
}
|
||||
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
console.log('[progress] 💾 ContentPanel: Saving position:', {
|
||||
position,
|
||||
percentage: Math.round(position * 100) + '%',
|
||||
scrollTop,
|
||||
articleIdentifier: articleIdentifier.slice(0, 50) + '...',
|
||||
url: selectedUrl?.slice(0, 50)
|
||||
})
|
||||
|
||||
try {
|
||||
const factory = new EventFactory({ signer: activeAccount })
|
||||
@@ -192,57 +179,41 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
scrollTop
|
||||
}
|
||||
)
|
||||
console.log('[progress] ✅ ContentPanel: Save completed successfully')
|
||||
} catch (error) {
|
||||
console.error('[progress] ❌ ContentPanel: Failed to save reading position:', error)
|
||||
}
|
||||
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl, html, markdown])
|
||||
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, html, markdown])
|
||||
|
||||
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
|
||||
const { progressPercentage, saveNow } = useReadingPosition({
|
||||
enabled: isTextContent,
|
||||
syncEnabled: settings?.syncReadingPosition !== false,
|
||||
onSave: handleSavePosition,
|
||||
onReadingComplete: () => {
|
||||
// Auto-mark as read when reading is complete (if enabled in settings)
|
||||
if (activeAccount && !isMarkedAsRead && settings?.autoMarkAsReadOnCompletion) {
|
||||
console.log('[progress] 📖 Auto-marking as read on completion')
|
||||
if (!settings?.autoMarkAsReadOnCompletion || !activeAccount) return
|
||||
if (!isMarkedAsRead) {
|
||||
handleMarkAsRead()
|
||||
} else {
|
||||
// Already archived: still show the success animation for feedback
|
||||
setShowCheckAnimation(true)
|
||||
setTimeout(() => setShowCheckAnimation(false), 600)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Log sync status when it changes
|
||||
useEffect(() => {
|
||||
console.log('[progress] 📊 ContentPanel reading position sync status:', {
|
||||
enabled: isTextContent,
|
||||
syncEnabled: settings?.syncReadingPosition !== false,
|
||||
hasAccount: !!activeAccount,
|
||||
hasRelayPool: !!relayPool,
|
||||
hasEventStore: !!eventStore,
|
||||
hasArticleIdentifier: !!articleIdentifier,
|
||||
currentProgress: progressPercentage + '%'
|
||||
})
|
||||
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
|
||||
|
||||
// Load saved reading position when article loads
|
||||
useEffect(() => {
|
||||
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||
console.log('⏭️ [ContentPanel] Skipping position restore - missing requirements:', {
|
||||
isTextContent,
|
||||
hasAccount: !!activeAccount,
|
||||
hasRelayPool: !!relayPool,
|
||||
hasEventStore: !!eventStore,
|
||||
hasIdentifier: !!articleIdentifier
|
||||
})
|
||||
return
|
||||
}
|
||||
if (settings?.syncReadingPosition === false) {
|
||||
console.log('⏭️ [ContentPanel] Sync disabled in settings - not restoring position')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('📖 [ContentPanel] Loading position for article:', selectedUrl?.slice(0, 50))
|
||||
|
||||
const loadPosition = async () => {
|
||||
try {
|
||||
const savedPosition = await loadReadingPosition(
|
||||
@@ -253,7 +224,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
)
|
||||
|
||||
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
||||
console.log('🎯 [ContentPanel] Restoring position:', Math.round(savedPosition.position * 100) + '%')
|
||||
// Wait for content to be fully rendered before scrolling
|
||||
setTimeout(() => {
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
@@ -264,14 +234,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
top: scrollTop,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
|
||||
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
|
||||
}, 500) // Give content time to render
|
||||
} else if (savedPosition) {
|
||||
if (savedPosition.position === 1) {
|
||||
console.log('✅ [ContentPanel] Article completed (100%), starting from top')
|
||||
// Article was completed, start from top
|
||||
} else {
|
||||
console.log('⏭️ [ContentPanel] Position too early (<5%):', Math.round(savedPosition.position * 100) + '%')
|
||||
// Position was too early, skip restore
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -605,12 +573,27 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
activeAccount.pubkey,
|
||||
relayPool
|
||||
)
|
||||
// Also check archiveController
|
||||
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
try {
|
||||
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
|
||||
hasRead = hasRead || archiveController.isMarked(naddr)
|
||||
console.log('[archive][content] check article', { naddr: naddr.slice(0, 24) + '...', hasRead })
|
||||
} catch (e) {
|
||||
console.warn('[archive][content] encode naddr failed', e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasRead = await hasMarkedWebsiteAsRead(
|
||||
selectedUrl,
|
||||
activeAccount.pubkey,
|
||||
relayPool
|
||||
)
|
||||
// Also check archiveController
|
||||
const ctrl = archiveController.isMarked(selectedUrl)
|
||||
hasRead = hasRead || ctrl
|
||||
console.log('[archive][content] check url', { url: selectedUrl, hasRead, ctrl })
|
||||
}
|
||||
setIsMarkedAsRead(hasRead)
|
||||
} catch (error) {
|
||||
@@ -624,7 +607,35 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
if (!activeAccount || !relayPool || isMarkedAsRead) {
|
||||
if (!activeAccount || !relayPool) return
|
||||
|
||||
// Toggle archive state: if already archived, request deletion; else archive
|
||||
if (isMarkedAsRead) {
|
||||
// Optimistically unarchive in UI; background deletion request (NIP-09)
|
||||
setIsMarkedAsRead(false)
|
||||
;(async () => {
|
||||
try {
|
||||
if (isNostrArticle && currentArticle) {
|
||||
// Send deletion for all matching reactions
|
||||
await unarchiveEvent(currentArticle.id, activeAccount, relayPool)
|
||||
// Also clear controller mark so lists update
|
||||
try {
|
||||
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
|
||||
archiveController.unmark(naddr)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[archive][content] encode naddr failed', e)
|
||||
}
|
||||
} else if (selectedUrl) {
|
||||
await unarchiveWebsite(selectedUrl, activeAccount, relayPool)
|
||||
archiveController.unmark(selectedUrl)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[archive][content] unarchive failed', err)
|
||||
}
|
||||
})()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -646,16 +657,36 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
currentArticle.pubkey,
|
||||
currentArticle.kind,
|
||||
activeAccount,
|
||||
relayPool
|
||||
relayPool,
|
||||
{
|
||||
aCoord: (() => {
|
||||
try {
|
||||
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (!dTag) return undefined
|
||||
return `${30023}:${currentArticle.pubkey}:${dTag}`
|
||||
} catch { return undefined }
|
||||
})()
|
||||
}
|
||||
)
|
||||
console.log('✅ Marked nostr article as read')
|
||||
// Update archiveController immediately
|
||||
try {
|
||||
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
|
||||
archiveController.mark(naddr)
|
||||
console.log('[archive][content] optimistic mark article', naddr.slice(0, 24) + '...')
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[archive][content] optimistic article mark failed', err)
|
||||
}
|
||||
} else if (selectedUrl) {
|
||||
await createWebsiteReaction(
|
||||
selectedUrl,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
console.log('✅ Marked website as read')
|
||||
archiveController.mark(selectedUrl)
|
||||
console.log('[archive][content] optimistic mark url', selectedUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to mark as read:', error)
|
||||
@@ -689,7 +720,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
{isTextContent && (
|
||||
<ReadingProgressIndicator
|
||||
progress={progressPercentage}
|
||||
isComplete={isReadingComplete}
|
||||
// Consider complete only at 95%+
|
||||
isComplete={progressPercentage >= 95}
|
||||
showPercentage={true}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
isHighlightsCollapsed={isHighlightsCollapsed}
|
||||
@@ -795,8 +827,9 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<button
|
||||
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||
onClick={handleMarkAsRead}
|
||||
disabled={isMarkedAsRead || isCheckingReadStatus}
|
||||
disabled={isCheckingReadStatus}
|
||||
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
|
||||
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||
@@ -958,21 +991,22 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mark as Read button */}
|
||||
{/* Archive button */}
|
||||
{activeAccount && (
|
||||
<div className="mark-as-read-container">
|
||||
<button
|
||||
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||
onClick={handleMarkAsRead}
|
||||
disabled={isMarkedAsRead || isCheckingReadStatus}
|
||||
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
|
||||
disabled={isCheckingReadStatus}
|
||||
title={isMarkedAsRead ? 'Already Archived' : 'Move to Archive'}
|
||||
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||
spin={isCheckingReadStatus}
|
||||
/>
|
||||
<span>
|
||||
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
|
||||
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Archived' : 'Move to Archive'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useSettings } from '../hooks/useSettings'
|
||||
import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||
import { contactsController } from '../services/contactsController'
|
||||
import { writingsController } from '../services/writingsController'
|
||||
import { readingProgressController } from '../services/readingProgressController'
|
||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||
|
||||
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
|
||||
@@ -102,6 +103,21 @@ const Debug: React.FC<DebugProps> = ({
|
||||
const [tLoadWritings, setTLoadWritings] = useState<number | null>(null)
|
||||
const [tFirstWriting, setTFirstWriting] = useState<number | null>(null)
|
||||
|
||||
// Reading Progress loading state
|
||||
const [isLoadingReadingProgress, setIsLoadingReadingProgress] = useState(false)
|
||||
const [readingProgressEvents, setReadingProgressEvents] = useState<NostrEvent[]>([])
|
||||
const [tLoadReadingProgress, setTLoadReadingProgress] = useState<number | null>(null)
|
||||
const [tFirstReadingProgress, setTFirstReadingProgress] = useState<number | null>(null)
|
||||
|
||||
// Mark-as-read reactions loading state
|
||||
const [isLoadingMarkAsRead, setIsLoadingMarkAsRead] = useState(false)
|
||||
const [markAsReadReactions, setMarkAsReadReactions] = useState<NostrEvent[]>([])
|
||||
const [tLoadMarkAsRead, setTLoadMarkAsRead] = useState<number | null>(null)
|
||||
const [tFirstMarkAsRead, setTFirstMarkAsRead] = useState<number | null>(null)
|
||||
|
||||
// Deduplicated reading progress from controller
|
||||
const [deduplicatedProgressMap, setDeduplicatedProgressMap] = useState<Map<string, number>>(new Map())
|
||||
|
||||
// Live timing state
|
||||
const [liveTiming, setLiveTiming] = useState<{
|
||||
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
||||
@@ -109,6 +125,8 @@ const Debug: React.FC<DebugProps> = ({
|
||||
loadBookmarks?: { startTime: number }
|
||||
decryptBookmarks?: { startTime: number }
|
||||
loadHighlights?: { startTime: number }
|
||||
loadReadingProgress?: { startTime: number }
|
||||
loadMarkAsRead?: { startTime: number }
|
||||
}>({})
|
||||
|
||||
// Web of Trust state
|
||||
@@ -310,10 +328,6 @@ const Debug: React.FC<DebugProps> = ({
|
||||
|
||||
// Subscribe to decrypt complete events for Debug UI display
|
||||
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, {
|
||||
public: publicCount,
|
||||
private: privateCount
|
||||
@@ -728,6 +742,150 @@ const Debug: React.FC<DebugProps> = ({
|
||||
setTFirstWriting(null)
|
||||
}
|
||||
|
||||
const handleLoadReadingProgress = async () => {
|
||||
if (!relayPool || !eventStore || !activeAccount?.pubkey) {
|
||||
DebugBus.warn('debug', 'Please log in to load reading progress')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingReadingProgress(true)
|
||||
setReadingProgressEvents([])
|
||||
setTLoadReadingProgress(null)
|
||||
setTFirstReadingProgress(null)
|
||||
setDeduplicatedProgressMap(new Map())
|
||||
DebugBus.info('debug', 'Loading reading progress events...')
|
||||
|
||||
const start = performance.now()
|
||||
let firstEventTime: number | null = null
|
||||
setLiveTiming(prev => ({ ...prev, loadReadingProgress: { startTime: start } }))
|
||||
|
||||
const { queryEvents } = await import('../services/dataFetch')
|
||||
const { KINDS } = await import('../config/kinds')
|
||||
|
||||
// Load raw events for display
|
||||
const rawEvents: NostrEvent[] = []
|
||||
const rawQueryPromise = queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [activeAccount.pubkey] }, {
|
||||
onEvent: (evt) => {
|
||||
if (firstEventTime === null) {
|
||||
firstEventTime = performance.now() - start
|
||||
setTFirstReadingProgress(Math.round(firstEventTime))
|
||||
}
|
||||
rawEvents.push(evt)
|
||||
setReadingProgressEvents([...rawEvents])
|
||||
}
|
||||
})
|
||||
|
||||
// Load deduplicated results via controller
|
||||
const unsubProgress = readingProgressController.onProgress((progressMap) => {
|
||||
setDeduplicatedProgressMap(new Map(progressMap))
|
||||
})
|
||||
|
||||
// Run both in parallel
|
||||
await Promise.all([
|
||||
rawQueryPromise,
|
||||
readingProgressController.start({ relayPool, eventStore, pubkey: activeAccount.pubkey, force: true })
|
||||
])
|
||||
|
||||
unsubProgress()
|
||||
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setTLoadReadingProgress(elapsed)
|
||||
setLiveTiming(prev => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const { loadReadingProgress, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
|
||||
const finalMap = readingProgressController.getProgressMap()
|
||||
DebugBus.info('debug', `Loaded ${rawEvents.length} raw events, deduplicated to ${finalMap.size} articles in ${elapsed}ms`)
|
||||
} catch (err) {
|
||||
console.error('Failed to load reading progress:', err)
|
||||
DebugBus.error('debug', `Failed to load reading progress: ${err instanceof Error ? err.message : String(err)}`)
|
||||
} finally {
|
||||
setIsLoadingReadingProgress(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearReadingProgress = () => {
|
||||
setReadingProgressEvents([])
|
||||
setTLoadReadingProgress(null)
|
||||
setTFirstReadingProgress(null)
|
||||
setDeduplicatedProgressMap(new Map())
|
||||
DebugBus.info('debug', 'Cleared reading progress data')
|
||||
}
|
||||
|
||||
const handleLoadMarkAsReadReactions = async () => {
|
||||
if (!relayPool || !activeAccount?.pubkey) {
|
||||
DebugBus.warn('debug', 'Please log in to load mark-as-read reactions')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingMarkAsRead(true)
|
||||
setMarkAsReadReactions([])
|
||||
setTLoadMarkAsRead(null)
|
||||
setTFirstMarkAsRead(null)
|
||||
DebugBus.info('debug', 'Loading mark-as-read reactions...')
|
||||
|
||||
const start = performance.now()
|
||||
let firstEventTime: number | null = null
|
||||
setLiveTiming(prev => ({ ...prev, loadMarkAsRead: { startTime: start } }))
|
||||
|
||||
const { queryEvents } = await import('../services/dataFetch')
|
||||
const { ARCHIVE_EMOJI } = await import('../services/reactionService')
|
||||
|
||||
// Load both kind:7 (reactions to events) and kind:17 (reactions to URLs)
|
||||
const [kind7Events, kind17Events] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [7], authors: [activeAccount.pubkey] }, {
|
||||
onEvent: (evt) => {
|
||||
if (evt.content === ARCHIVE_EMOJI) {
|
||||
if (firstEventTime === null) {
|
||||
firstEventTime = performance.now() - start
|
||||
setTFirstMarkAsRead(Math.round(firstEventTime))
|
||||
}
|
||||
setMarkAsReadReactions(prev => [...prev, evt])
|
||||
}
|
||||
}
|
||||
}),
|
||||
queryEvents(relayPool, { kinds: [17], authors: [activeAccount.pubkey] }, {
|
||||
onEvent: (evt) => {
|
||||
if (evt.content === ARCHIVE_EMOJI) {
|
||||
if (firstEventTime === null) {
|
||||
firstEventTime = performance.now() - start
|
||||
setTFirstMarkAsRead(Math.round(firstEventTime))
|
||||
}
|
||||
setMarkAsReadReactions(prev => [...prev, evt])
|
||||
}
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
const totalEvents = kind7Events.length + kind17Events.length
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setTLoadMarkAsRead(elapsed)
|
||||
setLiveTiming(prev => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const { loadMarkAsRead, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
|
||||
DebugBus.info('debug', `Loaded ${totalEvents} mark-as-read reactions in ${elapsed}ms`)
|
||||
} catch (err) {
|
||||
console.error('Failed to load mark-as-read reactions:', err)
|
||||
DebugBus.error('debug', `Failed to load mark-as-read reactions: ${err instanceof Error ? err.message : String(err)}`)
|
||||
} finally {
|
||||
setIsLoadingMarkAsRead(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearMarkAsRead = () => {
|
||||
setMarkAsReadReactions([])
|
||||
setTLoadMarkAsRead(null)
|
||||
setTFirstMarkAsRead(null)
|
||||
DebugBus.info('debug', 'Cleared mark-as-read reactions data')
|
||||
}
|
||||
|
||||
const handleLoadFriendsList = async () => {
|
||||
if (!relayPool || !activeAccount?.pubkey) {
|
||||
DebugBus.warn('debug', 'Please log in to load friends list')
|
||||
@@ -742,7 +900,6 @@ const Debug: React.FC<DebugProps> = ({
|
||||
|
||||
// Subscribe to controller updates to see streaming
|
||||
const unsubscribe = contactsController.onContacts((contacts) => {
|
||||
console.log('[debug] Received contacts update:', contacts.size)
|
||||
setFriendsPubkeys(new Set(contacts))
|
||||
})
|
||||
|
||||
@@ -1353,6 +1510,194 @@ const Debug: React.FC<DebugProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reading Progress Loading Section */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Reading Progress Loading</h3>
|
||||
<div className="text-sm opacity-70 mb-3">Test reading progress loading (kind: 39802) for the logged-in user</div>
|
||||
<div className="flex gap-2 mb-3 items-center">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleLoadReadingProgress}
|
||||
disabled={isLoadingReadingProgress || !relayPool || !activeAccount}
|
||||
>
|
||||
{isLoadingReadingProgress ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load Reading Progress'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary ml-auto"
|
||||
onClick={handleClearReadingProgress}
|
||||
disabled={readingProgressEvents.length === 0}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-3 flex gap-2 flex-wrap">
|
||||
<Stat label="total" value={tLoadReadingProgress} />
|
||||
<Stat label="first event" value={tFirstReadingProgress} />
|
||||
</div>
|
||||
{readingProgressEvents.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm opacity-70 mb-2">Loaded Reading Progress ({readingProgressEvents.length}):</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{readingProgressEvents.map((evt, idx) => {
|
||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1]
|
||||
const aTag = evt.tags?.find((t: string[]) => t[0] === 'a')?.[1]
|
||||
const content = evt.content || ''
|
||||
|
||||
return (
|
||||
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||
<div className="font-semibold mb-1">Reading Progress #{idx + 1}</div>
|
||||
<div className="opacity-70 mb-1">
|
||||
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
|
||||
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
{dTag && <div>d-tag: {dTag}</div>}
|
||||
{aTag && <div className="text-[11px] opacity-70">#a: {aTag}</div>}
|
||||
{content && <div>Progress: {content}</div>}
|
||||
</div>
|
||||
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deduplicatedProgressMap.size > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm opacity-70 mb-2">Deduplicated Reading Progress ({deduplicatedProgressMap.size} articles):</div>
|
||||
|
||||
{/* Category breakdown */}
|
||||
<div className="mb-3 p-2 bg-purple-50 dark:bg-purple-900/20 rounded border border-purple-200 dark:border-purple-700">
|
||||
<div className="text-sm font-semibold mb-2">Breakdown by Category:</div>
|
||||
<div className="space-y-1">
|
||||
{(() => {
|
||||
let unopened = 0, started = 0, reading = 0, completed = 0
|
||||
for (const progress of deduplicatedProgressMap.values()) {
|
||||
if (progress === 0) unopened++
|
||||
else if (progress > 0 && progress <= 0.10) started++
|
||||
else if (progress > 0.10 && progress <= 0.94) reading++
|
||||
else if (progress >= 0.95) completed++
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span>Unopened (0%):</span>
|
||||
<span className="font-semibold">{unopened}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span>Started (0% < progress ≤ 10%):</span>
|
||||
<span className="font-semibold">{started}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs bg-green-100 dark:bg-green-900/30 px-1 py-0.5 rounded">
|
||||
<span>Reading (10% < progress ≤ 94%) ✓:</span>
|
||||
<span className="font-semibold text-green-700 dark:text-green-400">{reading}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span>Completed (≥ 95%):</span>
|
||||
<span className="font-semibold">{completed}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{Array.from(deduplicatedProgressMap.entries()).map(([articleId, progress], idx) => {
|
||||
return (
|
||||
<div key={idx} className="font-mono text-xs p-2 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-700">
|
||||
<div className="font-semibold mb-1">Article #{idx + 1}</div>
|
||||
<div className="mt-1">
|
||||
<div className="break-all">ID: {articleId}</div>
|
||||
<div className="mt-1">
|
||||
<div className="text-[11px] opacity-70">Progress: {(progress * 100).toFixed(1)}%</div>
|
||||
<div className="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-1.5 mt-1 overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-600 h-full"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mark-as-read Reactions Loading Section */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Mark-as-read Reactions Loading</h3>
|
||||
<div className="text-sm opacity-70 mb-3">Test loading mark-as-read reactions (kind: 7 and 17) with the ARCHIVE_EMOJI for the logged-in user</div>
|
||||
<div className="flex gap-2 mb-3 items-center">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleLoadMarkAsReadReactions}
|
||||
disabled={isLoadingMarkAsRead || !relayPool || !activeAccount}
|
||||
>
|
||||
{isLoadingMarkAsRead ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load Mark-as-read Reactions'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary ml-auto"
|
||||
onClick={handleClearMarkAsRead}
|
||||
disabled={markAsReadReactions.length === 0}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-3 flex gap-2 flex-wrap">
|
||||
<Stat label="total" value={tLoadMarkAsRead} />
|
||||
<Stat label="first event" value={tFirstMarkAsRead} />
|
||||
</div>
|
||||
{markAsReadReactions.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm opacity-70 mb-2">Loaded Mark-as-read Reactions ({markAsReadReactions.length}):</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{markAsReadReactions.map((evt, idx) => {
|
||||
const eTag = evt.tags?.find((t: string[]) => t[0] === 'e')?.[1]
|
||||
const rTag = evt.tags?.find((t: string[]) => t[0] === 'r')?.[1]
|
||||
const pTag = evt.tags?.find((t: string[]) => t[0] === 'p')?.[1]
|
||||
|
||||
return (
|
||||
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||
<div className="font-semibold mb-1">Mark-as-read Reaction #{idx + 1}</div>
|
||||
<div className="opacity-70 mb-1">
|
||||
<div>Kind: {evt.kind}</div>
|
||||
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
|
||||
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<div>Emoji: {evt.content}</div>
|
||||
{eTag && <div className="text-[11px] opacity-70">#e: {eTag.slice(0, 16)}...</div>}
|
||||
{rTag && <div className="text-[11px] opacity-70">#r: {rTag.length > 60 ? rTag.substring(0, 60) + '...' : rTag}</div>}
|
||||
{pTag && <div className="text-[11px] opacity-70">#p: {pTag.slice(0, 16)}...</div>}
|
||||
</div>
|
||||
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Web of Trust Section */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Web of Trust</h3>
|
||||
|
||||
@@ -178,12 +178,10 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
useEffect(() => {
|
||||
// Get initial state immediately
|
||||
const initialMap = readingProgressController.getProgressMap()
|
||||
console.log('[progress] 🎯 Explore: Initial progress map size:', initialMap.size)
|
||||
setReadingProgressMap(initialMap)
|
||||
|
||||
// Subscribe to updates
|
||||
const unsubProgress = readingProgressController.onProgress((newMap) => {
|
||||
console.log('[progress] 🎯 Explore: Received progress update, size:', newMap.size)
|
||||
setReadingProgressMap(newMap)
|
||||
})
|
||||
|
||||
@@ -612,7 +610,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (!dTag) {
|
||||
console.log('[progress] ⚠️ No d-tag for post:', post.title)
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -624,16 +621,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
})
|
||||
const progress = readingProgressMap.get(naddr)
|
||||
|
||||
// Only log first lookup to avoid spam, or when found
|
||||
if (progress || readingProgressMap.size === 0) {
|
||||
console.log('[progress] 🔍 Looking up:', {
|
||||
title: post.title.slice(0, 30),
|
||||
naddr: naddr.slice(0, 80),
|
||||
mapSize: readingProgressMap.size,
|
||||
mapKeys: readingProgressMap.size > 0 ? Array.from(readingProgressMap.keys()).slice(0, 3).map(k => k.slice(0, 80)) : [],
|
||||
progress: progress ? Math.round(progress * 100) + '%' : 'not found'
|
||||
})
|
||||
}
|
||||
return progress
|
||||
} catch (err) {
|
||||
console.error('[progress] ❌ Error encoding naddr:', err)
|
||||
|
||||
@@ -27,7 +27,6 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
||||
// Fallback: extract directly from p tag
|
||||
const pTag = highlight.tags.find(t => t[0] === 'p')
|
||||
if (pTag && pTag[1]) {
|
||||
console.log('📝 Found author from p tag:', pTag[1])
|
||||
return pTag[1]
|
||||
}
|
||||
|
||||
@@ -45,6 +44,12 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
||||
try {
|
||||
if (!highlight.eventReference) return
|
||||
|
||||
// Skip if it's a raw event ID (hex string without colons)
|
||||
// Raw event IDs cannot be decoded to nadrs without additional context
|
||||
if (!highlight.eventReference.includes(':') && !highlight.eventReference.startsWith('naddr')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert eventReference to naddr if needed
|
||||
let naddr: string
|
||||
if (highlight.eventReference.includes(':')) {
|
||||
|
||||
@@ -348,11 +348,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
// Publish to all configured relays - let the relay pool handle connection state
|
||||
const targetRelays = RELAYS
|
||||
|
||||
console.log('📡 Rebroadcasting highlight to', targetRelays.length, 'relay(s):', targetRelays)
|
||||
|
||||
await relayPool.publish(targetRelays, event)
|
||||
|
||||
console.log('✅ Rebroadcast successful!')
|
||||
|
||||
// Update the highlight with new relay info
|
||||
const isLocalOnly = areAllRelaysLocal(targetRelays)
|
||||
@@ -449,7 +447,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
relayPool
|
||||
)
|
||||
|
||||
console.log('✅ Highlight deletion request published')
|
||||
|
||||
// Notify parent to remove this highlight from the list
|
||||
if (onHighlightDelete) {
|
||||
|
||||
@@ -11,8 +11,8 @@ import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { highlightsController } from '../services/highlightsController'
|
||||
import { writingsController } from '../services/writingsController'
|
||||
import { fetchAllReads, ReadItem } from '../services/readsService'
|
||||
import { fetchLinks } from '../services/linksService'
|
||||
import { ReadItem, readsController } from '../services/readsController'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import AuthorCard from './AuthorCard'
|
||||
@@ -28,10 +28,9 @@ import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
||||
import { filterByReadingProgress } from '../utils/readingProgressUtils'
|
||||
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
|
||||
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
|
||||
import { mergeReadItem } from '../utils/readItemMerge'
|
||||
import { readingProgressController } from '../services/readingProgressController'
|
||||
import { archiveController } from '../services/archiveController'
|
||||
|
||||
interface MeProps {
|
||||
relayPool: RelayPool
|
||||
@@ -44,7 +43,7 @@ interface MeProps {
|
||||
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
||||
|
||||
// Valid reading progress filters
|
||||
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted']
|
||||
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted', 'archive']
|
||||
|
||||
const Me: React.FC<MeProps> = ({
|
||||
relayPool,
|
||||
@@ -61,7 +60,6 @@ const Me: React.FC<MeProps> = ({
|
||||
const viewingPubkey = activeAccount?.pubkey
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [reads, setReads] = useState<ReadItem[]>([])
|
||||
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
|
||||
const [links, setLinks] = useState<ReadItem[]>([])
|
||||
const [, setLinksMap] = useState<Map<string, ReadItem>>(new Map())
|
||||
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||
@@ -90,8 +88,10 @@ const Me: React.FC<MeProps> = ({
|
||||
}
|
||||
|
||||
// Initialize reading progress filter from URL param
|
||||
const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
||||
? (urlFilter as ReadingProgressFilterType)
|
||||
// Backward compat: map legacy 'emoji' route to 'archive'
|
||||
const normalizedUrlFilter = urlFilter === 'emoji' ? 'archive' : urlFilter
|
||||
const initialFilter = normalizedUrlFilter && VALID_FILTERS.includes(normalizedUrlFilter as ReadingProgressFilterType)
|
||||
? (normalizedUrlFilter as ReadingProgressFilterType)
|
||||
: 'all'
|
||||
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
|
||||
|
||||
@@ -135,8 +135,9 @@ const Me: React.FC<MeProps> = ({
|
||||
|
||||
// Sync filter state with URL changes
|
||||
useEffect(() => {
|
||||
const filterFromUrl = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
||||
? (urlFilter as ReadingProgressFilterType)
|
||||
const normalized = urlFilter === 'emoji' ? 'archive' : urlFilter
|
||||
const filterFromUrl = normalized && VALID_FILTERS.includes(normalized as ReadingProgressFilterType)
|
||||
? (normalized as ReadingProgressFilterType)
|
||||
: 'all'
|
||||
setReadingProgressFilter(filterFromUrl)
|
||||
}, [urlFilter])
|
||||
@@ -150,10 +151,29 @@ const Me: React.FC<MeProps> = ({
|
||||
} else {
|
||||
navigate(`/me/reads/${filter}`, { replace: true })
|
||||
}
|
||||
} else if (activeTab === 'links') {
|
||||
if (filter === 'all') {
|
||||
navigate('/me/links', { replace: true })
|
||||
} else {
|
||||
navigate(`/me/links/${filter}`, { replace: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to reading progress controller
|
||||
// Subscribe to reads controller
|
||||
useEffect(() => {
|
||||
// Get initial state immediately
|
||||
setReads(readsController.getReads())
|
||||
|
||||
// Subscribe to updates
|
||||
const unsubReads = readsController.onReads(setReads)
|
||||
|
||||
return () => {
|
||||
unsubReads()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Subscribe to reading progress map for writings and links enrichment
|
||||
useEffect(() => {
|
||||
// Get initial state immediately
|
||||
setReadingProgressMap(readingProgressController.getProgressMap())
|
||||
@@ -165,6 +185,7 @@ const Me: React.FC<MeProps> = ({
|
||||
unsubProgress()
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
// Load reading progress data for writings tab
|
||||
useEffect(() => {
|
||||
@@ -232,42 +253,16 @@ const Me: React.FC<MeProps> = ({
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
|
||||
// Derive reads from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
|
||||
const initialReads = deriveReadsFromBookmarks(bookmarks)
|
||||
const initialMap = new Map(initialReads.map(item => [item.id, item]))
|
||||
setReadsMap(initialMap)
|
||||
setReads(initialReads)
|
||||
// Use readsController to get reads with progressive hydration
|
||||
await readsController.start({
|
||||
relayPool,
|
||||
eventStore,
|
||||
pubkey: viewingPubkey
|
||||
})
|
||||
|
||||
setLoadedTabs(prev => new Set(prev).add('reads'))
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
|
||||
// Background enrichment: merge reading progress and mark-as-read
|
||||
// Only update items that are already in our map
|
||||
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 => {
|
||||
// Only update if item exists in our current map
|
||||
if (!prevMap.has(item.id)) {
|
||||
console.log('⚠️ [Reads] Item not in map, skipping:', item.id.slice(0, 20) + '...')
|
||||
return prevMap
|
||||
}
|
||||
|
||||
const newMap = new Map(prevMap)
|
||||
const merged = mergeReadItem(newMap, item)
|
||||
if (merged) {
|
||||
console.log('✅ [Reads] Merged progress:', item.id.slice(0, 20) + '...', item.readingProgress)
|
||||
// Update reads array after map is updated
|
||||
setReads(Array.from(newMap.values()))
|
||||
return newMap
|
||||
}
|
||||
return prevMap
|
||||
})
|
||||
}).catch(err => console.warn('Failed to enrich reads:', err))
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to load reads:', err)
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
@@ -298,12 +293,13 @@ const Me: React.FC<MeProps> = ({
|
||||
if (!prevMap.has(item.id)) return prevMap
|
||||
|
||||
const newMap = new Map(prevMap)
|
||||
if (mergeReadItem(newMap, item)) {
|
||||
// Update links array after map is updated
|
||||
setLinks(Array.from(newMap.values()))
|
||||
return newMap
|
||||
if (item.type === 'article' && item.author) {
|
||||
const progress = readingProgressMap.get(item.id)
|
||||
if (progress !== undefined) {
|
||||
newMap.set(item.id, { ...item, readingProgress: progress })
|
||||
}
|
||||
}
|
||||
return prevMap
|
||||
return newMap
|
||||
})
|
||||
}).catch(err => console.warn('Failed to enrich links:', err))
|
||||
|
||||
@@ -499,17 +495,7 @@ const Me: React.FC<MeProps> = ({
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
// Enrich links with reading progress (reads already have progress from controller)
|
||||
const linksWithProgress = links.map(item => {
|
||||
if (item.url) {
|
||||
const progress = readingProgressMap.get(item.url)
|
||||
@@ -520,9 +506,81 @@ const Me: React.FC<MeProps> = ({
|
||||
return item
|
||||
})
|
||||
|
||||
// Apply reading progress filter
|
||||
const filteredReads = filterByReadingProgress(readsWithProgress, readingProgressFilter, highlights)
|
||||
const filteredLinks = filterByReadingProgress(linksWithProgress, readingProgressFilter, highlights)
|
||||
// Apply reading progress filter with simple type separation to keep Views distinct and DRY
|
||||
const filteredReads = filterByReadingProgress(
|
||||
reads.filter(item => item.type === 'article'),
|
||||
readingProgressFilter,
|
||||
highlights
|
||||
)
|
||||
const filteredLinks = filterByReadingProgress(
|
||||
linksWithProgress.filter(item => item.type === 'external'),
|
||||
readingProgressFilter,
|
||||
highlights
|
||||
)
|
||||
|
||||
// Helper: build archive-only list from marked IDs and a base list
|
||||
const buildArchiveOnly = (
|
||||
baseItems: ReadItem[],
|
||||
options: { kind: 'article' | 'external' }
|
||||
): ReadItem[] => {
|
||||
const allMarked = archiveController.getMarkedIds()
|
||||
const relevantMarked = options.kind === 'article'
|
||||
? allMarked.filter(id => id.startsWith('naddr1'))
|
||||
: allMarked.filter(id => !id.startsWith('naddr1'))
|
||||
const markedSet = new Set(relevantMarked)
|
||||
|
||||
const items: ReadItem[] = []
|
||||
for (const item of baseItems) {
|
||||
const key = options.kind === 'article' ? item.id : (item.url || item.id)
|
||||
if (key && markedSet.has(key)) {
|
||||
items.push({ ...item, markedAsRead: true })
|
||||
}
|
||||
}
|
||||
for (const id of markedSet) {
|
||||
const exists = items.find(i => (options.kind === 'article' ? i.id : (i.url || i.id)) === id)
|
||||
if (!exists) {
|
||||
items.push({
|
||||
id,
|
||||
source: 'marked-as-read',
|
||||
type: options.kind,
|
||||
url: options.kind === 'article' ? undefined : id,
|
||||
markedAsRead: true,
|
||||
readingTimestamp: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// Archive-only lists: independent of reading progress
|
||||
const archiveOnlyReads: ReadItem[] = readingProgressFilter === 'archive'
|
||||
? buildArchiveOnly(reads, { kind: 'article' })
|
||||
: []
|
||||
const archiveOnlyLinks: ReadItem[] = readingProgressFilter === 'archive'
|
||||
? buildArchiveOnly(linksWithProgress, { kind: 'external' })
|
||||
: []
|
||||
|
||||
// Debug logs for archive filter issues
|
||||
if (readingProgressFilter === 'archive') {
|
||||
const ids = Array.from(new Set([
|
||||
...archiveController.getMarkedIds(),
|
||||
...readingProgressController.getMarkedAsReadIds()
|
||||
]))
|
||||
const readIds = new Set(reads.map(i => i.id))
|
||||
const matches = ids.filter(id => readIds.has(id))
|
||||
const nonMatches = ids.filter(id => !readIds.has(id)).slice(0, 5)
|
||||
console.log('[archive][me] counts', {
|
||||
reads: reads.length,
|
||||
filteredReads: filteredReads.length,
|
||||
links: links.length,
|
||||
linksWithProgress: linksWithProgress.length,
|
||||
filteredLinks: filteredLinks.length,
|
||||
markedIds: ids.length,
|
||||
sampleMarked: ids.slice(0, 3),
|
||||
matches: matches.length,
|
||||
nonMatches
|
||||
})
|
||||
}
|
||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||
groupingMode === 'flat'
|
||||
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
|
||||
@@ -660,21 +718,42 @@ const Me: React.FC<MeProps> = ({
|
||||
selectedFilter={readingProgressFilter}
|
||||
onFilterChange={handleReadingProgressFilterChange}
|
||||
/>
|
||||
{filteredReads.length === 0 ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No articles match this filter.
|
||||
</div>
|
||||
{readingProgressFilter === 'archive' ? (
|
||||
archiveOnlyReads.length === 0 ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No articles in archive.
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{archiveOnlyReads
|
||||
.filter(item => item.type === 'article')
|
||||
.map((item) => (
|
||||
<BlogPostCard
|
||||
key={item.id}
|
||||
post={convertReadItemToBlogPostPreview(item)}
|
||||
href={getReadItemUrl(item)}
|
||||
readingProgress={item.readingProgress}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{filteredReads.map((item) => (
|
||||
<BlogPostCard
|
||||
key={item.id}
|
||||
post={convertReadItemToBlogPostPreview(item)}
|
||||
href={getReadItemUrl(item)}
|
||||
readingProgress={item.readingProgress}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
filteredReads.length === 0 ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No articles match this filter.
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{filteredReads.map((item) => (
|
||||
<BlogPostCard
|
||||
key={item.id}
|
||||
post={convertReadItemToBlogPostPreview(item)}
|
||||
href={getReadItemUrl(item)}
|
||||
readingProgress={item.readingProgress}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
@@ -707,21 +786,40 @@ const Me: React.FC<MeProps> = ({
|
||||
selectedFilter={readingProgressFilter}
|
||||
onFilterChange={handleReadingProgressFilterChange}
|
||||
/>
|
||||
{filteredLinks.length === 0 ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No links match this filter.
|
||||
</div>
|
||||
{readingProgressFilter === 'archive' ? (
|
||||
archiveOnlyLinks.length === 0 ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No links in archive.
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{archiveOnlyLinks.map((item) => (
|
||||
<BlogPostCard
|
||||
key={item.id}
|
||||
post={convertReadItemToBlogPostPreview(item)}
|
||||
href={getReadItemUrl(item)}
|
||||
readingProgress={item.readingProgress}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{filteredLinks.map((item) => (
|
||||
<BlogPostCard
|
||||
key={item.id}
|
||||
post={convertReadItemToBlogPostPreview(item)}
|
||||
href={getReadItemUrl(item)}
|
||||
readingProgress={item.readingProgress}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
filteredLinks.length === 0 ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No links match this filter.
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{filteredLinks.map((item) => (
|
||||
<BlogPostCard
|
||||
key={item.id}
|
||||
post={convertReadItemToBlogPostPreview(item)}
|
||||
href={getReadItemUrl(item)}
|
||||
readingProgress={item.readingProgress}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -68,12 +68,10 @@ const Profile: React.FC<ProfileProps> = ({
|
||||
useEffect(() => {
|
||||
// Get initial state immediately
|
||||
const initialMap = readingProgressController.getProgressMap()
|
||||
console.log('[progress] 🎯 Profile: Initial progress map size:', initialMap.size)
|
||||
setReadingProgressMap(initialMap)
|
||||
|
||||
// Subscribe to updates
|
||||
const unsubProgress = readingProgressController.onProgress((newMap) => {
|
||||
console.log('[progress] 🎯 Profile: Received progress update, size:', newMap.size)
|
||||
setReadingProgressMap(newMap)
|
||||
})
|
||||
|
||||
@@ -100,12 +98,11 @@ const Profile: React.FC<ProfileProps> = ({
|
||||
useEffect(() => {
|
||||
if (!pubkey || !relayPool || !eventStore) return
|
||||
|
||||
console.log('🔄 [Profile] Background fetching highlights and writings for', pubkey.slice(0, 8))
|
||||
|
||||
// Fetch highlights in background
|
||||
fetchHighlights(relayPool, pubkey, undefined, undefined, false, eventStore)
|
||||
.then(highlights => {
|
||||
console.log('✅ [Profile] Fetched', highlights.length, 'highlights')
|
||||
.then(() => {
|
||||
// Highlights fetched
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('⚠️ [Profile] Failed to fetch highlights:', err)
|
||||
@@ -115,7 +112,6 @@ const Profile: React.FC<ProfileProps> = ({
|
||||
fetchBlogPostsFromAuthors(relayPool, [pubkey], RELAYS, undefined, null)
|
||||
.then(writings => {
|
||||
writings.forEach(w => eventStore.add(w.event))
|
||||
console.log('✅ [Profile] Fetched', writings.length, 'writings')
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('⚠️ [Profile] Failed to fetch writings:', err)
|
||||
@@ -157,14 +153,9 @@ const Profile: React.FC<ProfileProps> = ({
|
||||
|
||||
// Only log when found or map is empty
|
||||
if (progress || readingProgressMap.size === 0) {
|
||||
console.log('[progress] 🔍 Profile lookup:', {
|
||||
title: post.title?.slice(0, 30),
|
||||
naddr: naddr.slice(0, 80),
|
||||
mapSize: readingProgressMap.size,
|
||||
mapKeys: readingProgressMap.size > 0 ? Array.from(readingProgressMap.keys()).slice(0, 3).map(k => k.slice(0, 80)) : [],
|
||||
progress: progress ? Math.round(progress * 100) + '%' : 'not found'
|
||||
})
|
||||
// Progress found or map is empty
|
||||
}
|
||||
|
||||
return progress
|
||||
} catch (err) {
|
||||
return undefined
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookOpen, faCheckCircle, faAsterisk, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
|
||||
|
||||
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted'
|
||||
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted' | 'archive'
|
||||
|
||||
interface ReadingProgressFiltersProps {
|
||||
selectedFilter: ReadingProgressFilterType
|
||||
@@ -13,11 +14,13 @@ interface ReadingProgressFiltersProps {
|
||||
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||
const filters = [
|
||||
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||
{ type: 'highlighted' as const, icon: faHighlighter, label: 'Highlighted' },
|
||||
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
|
||||
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
|
||||
{ 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' },
|
||||
// Archive-marked items (previously emoji-marked)
|
||||
{ type: 'archive' as const, icon: faBooks, label: 'Archive' }
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -31,6 +34,8 @@ const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selecte
|
||||
activeStyle = { color: '#10b981' } // green
|
||||
} else if (filter.type === 'highlighted') {
|
||||
activeStyle = { color: '#fde047' } // yellow
|
||||
} else if (filter.type === 'archive') {
|
||||
activeStyle = { color: '#60a5fa' } // blue accent
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,16 +50,8 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
|
||||
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
console.log('🔌 Relay Status Indicator:', {
|
||||
mode: isConnecting ? 'CONNECTING' : offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
|
||||
totalStatuses: relayStatuses.length,
|
||||
connectedCount: connectedUrls.length,
|
||||
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
|
||||
hasLocalRelay,
|
||||
hasRemoteRelay,
|
||||
isConnecting
|
||||
})
|
||||
}, [offlineMode, localOnlyMode, connectedUrls, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
|
||||
// Mode and relay status determined
|
||||
}, [isConnecting, offlineMode, localOnlyMode, relayStatuses, hasLocalRelay, hasRemoteRelay])
|
||||
|
||||
// Don't show indicator when fully connected (but show when connecting)
|
||||
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
||||
|
||||
@@ -41,7 +41,7 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
paragraphAlignment: 'justify',
|
||||
syncReadingPosition: true,
|
||||
autoMarkAsReadOnCompletion: false,
|
||||
hideBookmarksWithoutCreationDate: false,
|
||||
hideBookmarksWithoutCreationDate: true,
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
|
||||
@@ -127,7 +127,7 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
||||
onChange={(e) => onUpdate({ autoMarkAsReadOnCompletion: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Automatically mark as read at 100%</span>
|
||||
<span>Automatically move to archive at 100%</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
||||
if (isInstalled) return
|
||||
const success = await installApp()
|
||||
if (success) {
|
||||
console.log('App installed successfully')
|
||||
// Installation successful
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export const RELAYS = [
|
||||
'wss://relay.dergigi.com',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.current.fyi',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.primal.net',
|
||||
|
||||
@@ -43,21 +43,14 @@ export function useAdaptiveTextColor(imageUrl: string | undefined): AdaptiveText
|
||||
height: Math.floor(height * 0.25)
|
||||
})
|
||||
|
||||
console.log('Adaptive color detected:', {
|
||||
hex: color.hex,
|
||||
rgb: color.rgb,
|
||||
isLight: color.isLight,
|
||||
isDark: color.isDark
|
||||
})
|
||||
// Color analysis complete
|
||||
|
||||
// Use library's built-in isLight check for optimal contrast
|
||||
if (color.isLight) {
|
||||
console.log('Light background detected, using black text')
|
||||
setColors({
|
||||
textColor: '#000000'
|
||||
})
|
||||
} else {
|
||||
console.log('Dark background detected, using white text')
|
||||
setColors({
|
||||
textColor: '#ffffff'
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, Dispatch, SetStateAction } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { fetchArticleByNaddr } from '../services/articleService'
|
||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||
@@ -14,7 +14,7 @@ interface UseArticleLoaderProps {
|
||||
setReaderContent: (content: ReadableContent | undefined) => void
|
||||
setReaderLoading: (loading: boolean) => void
|
||||
setIsCollapsed: (collapsed: boolean) => void
|
||||
setHighlights: (highlights: Highlight[]) => void
|
||||
setHighlights: Dispatch<SetStateAction<Highlight[]>>
|
||||
setHighlightsLoading: (loading: boolean) => void
|
||||
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||
setCurrentArticleEventId: (id: string | undefined) => void
|
||||
@@ -64,8 +64,6 @@ export function useArticleLoader({
|
||||
setCurrentArticleEventId(article.event.id)
|
||||
setCurrentArticle?.(article.event)
|
||||
|
||||
console.log('📰 Article loaded:', article.title)
|
||||
console.log('📍 Coordinate:', articleCoordinate)
|
||||
|
||||
// Set reader loading to false immediately after article content is ready
|
||||
// Don't wait for highlights to finish loading
|
||||
@@ -76,23 +74,21 @@ export function useArticleLoader({
|
||||
try {
|
||||
setHighlightsLoading(true)
|
||||
setHighlights([]) // Clear old highlights
|
||||
const highlightsMap = new Map<string, Highlight>()
|
||||
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
articleCoordinate,
|
||||
article.event.id,
|
||||
(highlight) => {
|
||||
// Deduplicate highlights by ID as they arrive
|
||||
if (!highlightsMap.has(highlight.id)) {
|
||||
highlightsMap.set(highlight.id, highlight)
|
||||
const highlightsList = Array.from(highlightsMap.values())
|
||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
// Merge streaming results with existing UI state to preserve locally created highlights
|
||||
setHighlights((prev: Highlight[]) => {
|
||||
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||
const next = [highlight, ...prev]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
},
|
||||
settings
|
||||
)
|
||||
console.log(`📌 Found ${highlightsMap.size} highlights`)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
|
||||
@@ -61,6 +61,7 @@ export function useExternalUrlLoader({
|
||||
[url]
|
||||
)
|
||||
|
||||
// Load content and start streaming highlights when URL changes
|
||||
useEffect(() => {
|
||||
if (!relayPool || !url) return
|
||||
|
||||
@@ -77,7 +78,6 @@ export function useExternalUrlLoader({
|
||||
const content = await fetchReadableContent(url)
|
||||
setReaderContent(content)
|
||||
|
||||
console.log('🌐 External URL loaded:', content.title)
|
||||
|
||||
// Set reader loading to false immediately after content is ready
|
||||
setReaderLoading(false)
|
||||
@@ -88,7 +88,13 @@ export function useExternalUrlLoader({
|
||||
|
||||
// Seed with cached highlights first
|
||||
if (cachedUrlHighlights.length > 0) {
|
||||
setHighlights(cachedUrlHighlights.sort((a, b) => b.created_at - a.created_at))
|
||||
setHighlights((prev) => {
|
||||
// Seed with cache but keep any locally created highlights already in state
|
||||
const seen = new Set<string>(cachedUrlHighlights.map(h => h.id))
|
||||
const localOnly = prev.filter(h => !seen.has(h.id))
|
||||
const next = [...cachedUrlHighlights, ...localOnly]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
} else {
|
||||
setHighlights([])
|
||||
}
|
||||
@@ -107,7 +113,7 @@ export function useExternalUrlLoader({
|
||||
seen.add(highlight.id)
|
||||
setHighlights((prev) => {
|
||||
if (prev.some(h => h.id === highlight.id)) return prev
|
||||
const next = [...prev, highlight]
|
||||
const next = [highlight, ...prev]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
},
|
||||
@@ -135,6 +141,20 @@ export function useExternalUrlLoader({
|
||||
}
|
||||
|
||||
loadExternalUrl()
|
||||
}, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, cachedUrlHighlights])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId])
|
||||
|
||||
// Keep UI highlights synced with cached store updates without reloading content
|
||||
useEffect(() => {
|
||||
if (!url) return
|
||||
if (cachedUrlHighlights.length === 0) return
|
||||
setHighlights((prev) => {
|
||||
const seen = new Set<string>(prev.map(h => h.id))
|
||||
const additions = cachedUrlHighlights.filter(h => !seen.has(h.id))
|
||||
if (additions.length === 0) return prev
|
||||
const next = [...additions, ...prev]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
}, [cachedUrlHighlights, url, setHighlights])
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ export const useHighlightCreation = ({
|
||||
? currentArticle.content
|
||||
: readerContent?.markdown || readerContent?.html
|
||||
|
||||
console.log('🎯 Creating highlight...', { text: text.substring(0, 50) + '...' })
|
||||
|
||||
const newHighlight = await createHighlight(
|
||||
text,
|
||||
@@ -73,12 +72,7 @@ export const useHighlightCreation = ({
|
||||
settings
|
||||
)
|
||||
|
||||
console.log('✅ Highlight created successfully!', {
|
||||
id: newHighlight.id,
|
||||
isLocalOnly: newHighlight.isLocalOnly,
|
||||
isOfflineCreated: newHighlight.isOfflineCreated,
|
||||
publishedRelays: newHighlight.publishedRelays
|
||||
})
|
||||
// Highlight created successfully
|
||||
|
||||
// Clear the browser's text selection immediately to allow DOM update
|
||||
const selection = window.getSelection()
|
||||
|
||||
@@ -32,14 +32,7 @@ export const useHighlightedContent = ({
|
||||
}: UseHighlightedContentParams) => {
|
||||
// Filter highlights by URL and visibility settings
|
||||
const relevantHighlights = useMemo(() => {
|
||||
console.log('🔍 ContentPanel: Processing highlights', {
|
||||
totalHighlights: highlights.length,
|
||||
selectedUrl,
|
||||
showHighlights
|
||||
})
|
||||
|
||||
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
|
||||
console.log('📌 URL filtered highlights:', urlFiltered.length)
|
||||
|
||||
// Apply visibility filtering
|
||||
const classified = classifyHighlights(urlFiltered, currentUserPubkey, followedPubkeys)
|
||||
@@ -49,37 +42,25 @@ export const useHighlightedContent = ({
|
||||
return highlightVisibility.nostrverse
|
||||
})
|
||||
|
||||
console.log('✅ Relevant highlights after filtering:', filtered.length, filtered.map(h => h.content.substring(0, 30)))
|
||||
return filtered
|
||||
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys, showHighlights])
|
||||
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys])
|
||||
|
||||
// Prepare the final HTML with highlights applied
|
||||
const finalHtml = useMemo(() => {
|
||||
const sourceHtml = markdown ? renderedMarkdownHtml : html
|
||||
|
||||
console.log('🎨 Preparing final HTML:', {
|
||||
hasMarkdown: !!markdown,
|
||||
hasHtml: !!html,
|
||||
renderedHtmlLength: renderedMarkdownHtml.length,
|
||||
sourceHtmlLength: sourceHtml?.length || 0,
|
||||
showHighlights,
|
||||
relevantHighlightsCount: relevantHighlights.length
|
||||
})
|
||||
|
||||
// Prepare final HTML
|
||||
if (!sourceHtml) {
|
||||
console.warn('⚠️ No source HTML available')
|
||||
return ''
|
||||
}
|
||||
|
||||
if (showHighlights && relevantHighlights.length > 0) {
|
||||
console.log('✨ Applying', relevantHighlights.length, 'highlights to HTML')
|
||||
const highlightedHtml = applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
|
||||
console.log('✅ Highlights applied, result length:', highlightedHtml.length)
|
||||
return highlightedHtml
|
||||
}
|
||||
|
||||
console.log('📄 Returning source HTML without highlights')
|
||||
return sourceHtml
|
||||
|
||||
}, [html, renderedMarkdownHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
|
||||
|
||||
return { finalHtml, relevantHighlights }
|
||||
|
||||
@@ -43,7 +43,6 @@ export const useMarkdownToHTML = (
|
||||
|
||||
// Replace nostr URIs with resolved titles
|
||||
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
|
||||
console.log(`📚 Resolved ${articleTitles.size} article titles`)
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch article titles:', error)
|
||||
// Fall back to basic replacement
|
||||
@@ -58,12 +57,10 @@ export const useMarkdownToHTML = (
|
||||
|
||||
setProcessedMarkdown(processed)
|
||||
|
||||
console.log('📝 Converting markdown to HTML...')
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (previewRef.current && !isCancelled) {
|
||||
const html = previewRef.current.innerHTML
|
||||
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
||||
setRenderedHtml(html)
|
||||
} else if (!isCancelled) {
|
||||
console.warn('⚠️ markdownPreviewRef.current is null')
|
||||
|
||||
@@ -50,16 +50,10 @@ export function useOfflineSync({
|
||||
const isNowOnline = hasRemoteRelays
|
||||
|
||||
if (wasLocalOnly && isNowOnline) {
|
||||
console.log('✈️ Detected transition: Flight Mode → Online')
|
||||
console.log('📊 Relay state:', {
|
||||
connectedRelays: connectedRelays.length,
|
||||
remoteRelays: connectedRelays.filter(r => !isLocalRelay(r.url)).length,
|
||||
localRelays: connectedRelays.filter(r => isLocalRelay(r.url)).length
|
||||
})
|
||||
// Coming back online, sync events
|
||||
|
||||
// Wait a moment for relays to fully establish connections
|
||||
setTimeout(() => {
|
||||
console.log('🚀 Starting sync after delay...')
|
||||
syncLocalEventsToRemote(relayPool, eventStore)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,10 @@ export function useOnlineStatus() {
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
console.log('🌐 Back online')
|
||||
setIsOnline(true)
|
||||
}
|
||||
|
||||
const handleOffline = () => {
|
||||
console.log('📴 Gone offline')
|
||||
setIsOnline(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -51,12 +51,10 @@ export function usePWAInstall() {
|
||||
const choiceResult = await deferredPrompt.userChoice
|
||||
|
||||
if (choiceResult.outcome === 'accepted') {
|
||||
console.log('✅ PWA installed')
|
||||
setIsInstallable(false)
|
||||
setDeferredPrompt(null)
|
||||
return true
|
||||
} else {
|
||||
console.log('❌ PWA installation dismissed')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -22,6 +22,7 @@ export const useReadingPosition = ({
|
||||
completionHoldMs = 2000
|
||||
}: UseReadingPositionOptions = {}) => {
|
||||
const [position, setPosition] = useState(0)
|
||||
const positionRef = useRef(0)
|
||||
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||
const hasTriggeredComplete = useRef(false)
|
||||
const lastSavedPosition = useRef(0)
|
||||
@@ -32,7 +33,6 @@ export const useReadingPosition = ({
|
||||
// Debounced save function
|
||||
const scheduleSave = useCallback((currentPosition: number) => {
|
||||
if (!syncEnabled || !onSave) {
|
||||
console.log('[progress] ⏭️ scheduleSave skipped:', { syncEnabled, hasOnSave: !!onSave, position: Math.round(currentPosition * 100) + '%' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -43,12 +43,7 @@ export const useReadingPosition = ({
|
||||
const isInitialSave = !hasSavedOnce.current
|
||||
|
||||
if (!hasSignificantChange && !hasReachedCompletion && !isInitialSave) {
|
||||
console.log('[progress] ⏭️ No significant change:', {
|
||||
current: Math.round(currentPosition * 100) + '%',
|
||||
last: Math.round(lastSavedPosition.current * 100) + '%',
|
||||
diff: Math.abs(currentPosition - lastSavedPosition.current),
|
||||
isInitialSave
|
||||
})
|
||||
// Not significant enough to save
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,9 +53,7 @@ export const useReadingPosition = ({
|
||||
}
|
||||
|
||||
// Schedule new save
|
||||
console.log('[progress] ⏰ Scheduling save in', autoSaveInterval + 'ms for position:', Math.round(currentPosition * 100) + '%')
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
console.log('[progress] 💾 Auto-saving position:', Math.round(currentPosition * 100) + '%')
|
||||
lastSavedPosition.current = currentPosition
|
||||
hasSavedOnce.current = true
|
||||
onSave(currentPosition)
|
||||
@@ -78,7 +71,6 @@ export const useReadingPosition = ({
|
||||
}
|
||||
|
||||
// Always allow immediate save (including 0%)
|
||||
console.log('[progress] 💾 Immediate save triggered for position:', Math.round(position * 100) + '%')
|
||||
lastSavedPosition.current = position
|
||||
hasSavedOnce.current = true
|
||||
onSave(position)
|
||||
@@ -109,14 +101,11 @@ export const useReadingPosition = ({
|
||||
const prevPercent = Math.floor(position * 20) // Groups by 5%
|
||||
const newPercent = Math.floor(clampedProgress * 20)
|
||||
if (prevPercent !== newPercent) {
|
||||
console.log('[progress] 📏 useReadingPosition:', Math.round(clampedProgress * 100) + '%', {
|
||||
scrollTop,
|
||||
documentHeight,
|
||||
isAtBottom
|
||||
})
|
||||
// Position threshold crossed
|
||||
}
|
||||
|
||||
setPosition(clampedProgress)
|
||||
positionRef.current = clampedProgress
|
||||
onPositionChange?.(clampedProgress)
|
||||
|
||||
// Schedule auto-save if sync is enabled
|
||||
@@ -128,15 +117,13 @@ export const useReadingPosition = ({
|
||||
if (clampedProgress === 1) {
|
||||
if (!completionTimerRef.current) {
|
||||
completionTimerRef.current = setTimeout(() => {
|
||||
if (!hasTriggeredComplete.current && position === 1) {
|
||||
if (!hasTriggeredComplete.current && positionRef.current === 1) {
|
||||
setIsReadingComplete(true)
|
||||
hasTriggeredComplete.current = true
|
||||
console.log('[progress] ✅ Completion hold satisfied (100% for', completionHoldMs, 'ms)')
|
||||
onReadingComplete?.()
|
||||
}
|
||||
completionTimerRef.current = null
|
||||
}, completionHoldMs)
|
||||
console.log('[progress] ⏳ Completion hold started (waiting', completionHoldMs, 'ms)')
|
||||
}
|
||||
} else {
|
||||
// If we moved off 100%, cancel any pending completion hold
|
||||
@@ -147,7 +134,6 @@ export const useReadingPosition = ({
|
||||
if (clampedProgress >= readingCompleteThreshold) {
|
||||
setIsReadingComplete(true)
|
||||
hasTriggeredComplete.current = true
|
||||
console.log('[progress] ✅ Completion via threshold:', readingCompleteThreshold)
|
||||
onReadingComplete?.()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
const root = document.documentElement.style
|
||||
const fontKey = settings.readingFont || 'system'
|
||||
|
||||
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize, theme: settings.theme })
|
||||
|
||||
// Apply theme with color variants (defaults to 'system' if not set)
|
||||
applyTheme(
|
||||
@@ -59,9 +58,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
|
||||
// Load font first and wait for it to be ready
|
||||
if (fontKey !== 'system') {
|
||||
console.log('⏳ Waiting for font to load...')
|
||||
await loadFont(fontKey)
|
||||
console.log('✅ Font loaded, applying styles')
|
||||
}
|
||||
|
||||
// Apply font settings after font is loaded
|
||||
@@ -76,7 +73,6 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
// Set paragraph alignment
|
||||
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
||||
|
||||
console.log('✅ All styles applied')
|
||||
}
|
||||
|
||||
applyStyles()
|
||||
|
||||
@@ -11,7 +11,6 @@ if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js', { type: 'module' })
|
||||
.then(registration => {
|
||||
console.log('✅ Service Worker registered:', registration.scope)
|
||||
|
||||
// Check for updates periodically
|
||||
setInterval(() => {
|
||||
@@ -25,7 +24,6 @@ if ('serviceWorker' in navigator) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// New service worker available
|
||||
console.log('🔄 New version available! Reload to update.')
|
||||
|
||||
// Optionally show a toast notification
|
||||
const updateAvailable = new CustomEvent('sw-update-available')
|
||||
|
||||
212
src/services/archiveController.ts
Normal file
212
src/services/archiveController.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { ARCHIVE_EMOJI } from './reactionService'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
type MarkedChangeCallback = (markedIds: Set<string>) => void
|
||||
|
||||
class ArchiveController {
|
||||
private markedIds: Set<string> = new Set()
|
||||
private lastLoadedPubkey: string | null = null
|
||||
private listeners: MarkedChangeCallback[] = []
|
||||
private generation = 0
|
||||
private timelineSubscription: { unsubscribe: () => void } | null = null
|
||||
private pendingEventIds: Set<string> = new Set()
|
||||
|
||||
onMarked(cb: MarkedChangeCallback): () => void {
|
||||
this.listeners.push(cb)
|
||||
// Emit current state immediately to new subscribers
|
||||
cb(new Set(this.markedIds))
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
const snapshot = new Set(this.markedIds)
|
||||
this.listeners.forEach(cb => cb(snapshot))
|
||||
}
|
||||
|
||||
mark(id: string): void {
|
||||
if (!this.markedIds.has(id)) {
|
||||
this.markedIds.add(id)
|
||||
this.emit()
|
||||
console.log('[archive] mark() added', id.slice(0, 48))
|
||||
}
|
||||
}
|
||||
|
||||
unmark(id: string): void {
|
||||
if (this.markedIds.delete(id)) {
|
||||
this.emit()
|
||||
console.log('[archive] unmark() removed', id.slice(0, 48))
|
||||
}
|
||||
}
|
||||
|
||||
isMarked(id: string): boolean {
|
||||
return this.markedIds.has(id)
|
||||
}
|
||||
|
||||
getMarkedIds(): string[] {
|
||||
return Array.from(this.markedIds)
|
||||
}
|
||||
|
||||
isLoadedFor(pubkey: string): boolean {
|
||||
return this.lastLoadedPubkey === pubkey
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.generation++
|
||||
if (this.timelineSubscription) {
|
||||
try { this.timelineSubscription.unsubscribe() } catch (e) { console.warn('[archive] timeline unsub error', e) }
|
||||
this.timelineSubscription = null
|
||||
}
|
||||
this.markedIds = new Set()
|
||||
this.pendingEventIds = new Set()
|
||||
this.lastLoadedPubkey = null
|
||||
this.emit()
|
||||
}
|
||||
|
||||
async start(options: {
|
||||
relayPool: RelayPool
|
||||
eventStore: IEventStore
|
||||
pubkey: string
|
||||
force?: boolean
|
||||
}): Promise<void> {
|
||||
const { relayPool, eventStore, pubkey, force = false } = options
|
||||
const startGen = this.generation
|
||||
|
||||
if (!force && this.isLoadedFor(pubkey)) {
|
||||
console.log('[archive] start() skipped - already loaded for pubkey')
|
||||
return
|
||||
}
|
||||
|
||||
// Mark as loaded immediately (fetch runs non-blocking)
|
||||
this.lastLoadedPubkey = pubkey
|
||||
console.log('[archive] start() begin for pubkey:', pubkey.slice(0, 12), '...')
|
||||
|
||||
// Handlers for streaming queries
|
||||
const handleUrlReaction = (evt: NostrEvent) => {
|
||||
if (evt.content !== ARCHIVE_EMOJI) return
|
||||
const rTag = evt.tags.find(t => t[0] === 'r')?.[1]
|
||||
if (!rTag) return
|
||||
this.markedIds.add(rTag)
|
||||
this.emit()
|
||||
console.log('[archive] mark url:', rTag)
|
||||
}
|
||||
|
||||
const handleEventReaction = (evt: NostrEvent) => {
|
||||
if (evt.content !== ARCHIVE_EMOJI) return
|
||||
// Direct coordinate tag ('a') - can be mapped immediately
|
||||
const aTag = evt.tags.find(t => t[0] === 'a')?.[1]
|
||||
if (aTag) {
|
||||
try {
|
||||
const [kindStr, pubkey, identifier] = aTag.split(':')
|
||||
const kind = Number(kindStr)
|
||||
if (kind === KINDS.BlogPost && pubkey && identifier) {
|
||||
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
|
||||
this.markedIds.add(naddr)
|
||||
this.emit()
|
||||
console.log('[archive] mark naddr via a-tag:', naddr.slice(0, 24), '...')
|
||||
return
|
||||
}
|
||||
} catch { /* ignore malformed a-tag */ }
|
||||
}
|
||||
const eTag = evt.tags.find(t => t[0] === 'e')?.[1]
|
||||
if (!eTag) return
|
||||
this.pendingEventIds.add(eTag)
|
||||
console.log('[archive] pending event id:', eTag)
|
||||
}
|
||||
|
||||
try {
|
||||
// Stream kind:17 and kind:7 in parallel
|
||||
const [kind17, kind7] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleUrlReaction }),
|
||||
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleEventReaction })
|
||||
])
|
||||
|
||||
if (startGen !== this.generation) return
|
||||
|
||||
// Include EOSE events
|
||||
kind17.forEach(handleUrlReaction)
|
||||
kind7.forEach(handleEventReaction)
|
||||
console.log('[archive] EOSE sizes kind17:', kind17.length, 'kind7:', kind7.length, 'pendingEventIds:', this.pendingEventIds.size)
|
||||
|
||||
if (this.pendingEventIds.size > 0) {
|
||||
// Fetch referenced articles (kind:30023) and map event IDs to naddr
|
||||
const ids = Array.from(this.pendingEventIds)
|
||||
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids }, { relayUrls: RELAYS })
|
||||
console.log('[archive] fetched articles for mapping:', articleEvents.length)
|
||||
for (const article of articleEvents) {
|
||||
const dTag = article.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (!dTag) continue
|
||||
try {
|
||||
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: article.pubkey, identifier: dTag })
|
||||
this.markedIds.add(naddr)
|
||||
console.log('[archive] mark naddr:', naddr.slice(0, 24), '...')
|
||||
} catch {
|
||||
// skip invalid
|
||||
}
|
||||
}
|
||||
this.emit()
|
||||
}
|
||||
console.log('[archive] total marked ids:', this.markedIds.size)
|
||||
|
||||
// Try immediate mapping via eventStore for any still-pending e-ids
|
||||
if (this.pendingEventIds.size > 0) {
|
||||
const stillPending = new Set<string>()
|
||||
for (const eId of this.pendingEventIds) {
|
||||
try {
|
||||
const store = eventStore as unknown as { getEvent?: (id: string) => NostrEvent | undefined }
|
||||
const evt: NostrEvent | undefined = typeof store.getEvent === 'function' ? store.getEvent(eId) : undefined
|
||||
if (evt && evt.kind === KINDS.BlogPost) {
|
||||
const dTag = evt.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
|
||||
this.markedIds.add(naddr)
|
||||
console.log('[archive] map via eventStore naddr:', naddr.slice(0, 24), '...')
|
||||
}
|
||||
} else {
|
||||
stillPending.add(eId)
|
||||
}
|
||||
} catch (e) { stillPending.add(eId) }
|
||||
}
|
||||
this.pendingEventIds = stillPending
|
||||
if (stillPending.size > 0) {
|
||||
// Subscribe to future 30023 arrivals to finalize mapping
|
||||
if (this.timelineSubscription) {
|
||||
try { this.timelineSubscription.unsubscribe() } catch (e) { console.warn('[archive] timeline unsub error', e) }
|
||||
this.timelineSubscription = null
|
||||
}
|
||||
const sub$ = eventStore.timeline({ kinds: [KINDS.BlogPost] })
|
||||
const genAtSub = this.generation
|
||||
this.timelineSubscription = sub$.subscribe((events: NostrEvent[]) => {
|
||||
if (genAtSub !== this.generation) return
|
||||
for (const evt of events) {
|
||||
if (!this.pendingEventIds.has(evt.id)) continue
|
||||
const dTag = evt.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (!dTag) continue
|
||||
try {
|
||||
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
|
||||
this.markedIds.add(naddr)
|
||||
this.pendingEventIds.delete(evt.id)
|
||||
console.log('[archive] map via timeline naddr:', naddr.slice(0, 24), '...')
|
||||
this.emit()
|
||||
} catch (e) { console.warn('[archive] map via timeline encode error', e) }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-blocking fetch; ignore errors here
|
||||
console.warn('[archive] start() error:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const archiveController = new ArchiveController()
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ function getFromCache(naddr: string): ArticleContent | null {
|
||||
return null
|
||||
}
|
||||
|
||||
console.log('📦 Loaded article from cache:', naddr)
|
||||
return content
|
||||
} catch {
|
||||
return null
|
||||
@@ -63,7 +62,6 @@ function saveToCache(naddr: string, content: ArticleContent): void {
|
||||
timestamp: Date.now()
|
||||
}
|
||||
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
||||
console.log('💾 Saved article to cache:', naddr)
|
||||
} catch (err) {
|
||||
console.warn('Failed to cache article:', err)
|
||||
// Silently fail if storage is full or unavailable
|
||||
|
||||
@@ -30,8 +30,8 @@ async function decryptEvent(
|
||||
} catch {
|
||||
try {
|
||||
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
|
||||
} catch (err) {
|
||||
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
|
||||
} catch (_err) {
|
||||
// Ignore unlock errors
|
||||
}
|
||||
}
|
||||
} else if (evt.content && evt.content.length > 0) {
|
||||
@@ -45,8 +45,8 @@ async function decryptEvent(
|
||||
if (looksLikeNip44 && hasNip44Decrypt(signerCandidate)) {
|
||||
try {
|
||||
decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(evt.pubkey, evt.content)
|
||||
} catch (err) {
|
||||
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
|
||||
} catch (_err) {
|
||||
// Ignore NIP-44 decryption errors
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@ async function decryptEvent(
|
||||
if (!decryptedContent && hasNip04Decrypt(signerCandidate)) {
|
||||
try {
|
||||
decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(evt.pubkey, evt.content)
|
||||
} catch (err) {
|
||||
console.log("[bunker] ❌ nip04.decrypt failed:", err instanceof Error ? err.message : String(err))
|
||||
} catch (_err) {
|
||||
// Ignore NIP-04 decryption errors
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ export const fetchContacts = async (
|
||||
): Promise<Set<string>> => {
|
||||
try {
|
||||
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 events = await queryEvents(
|
||||
@@ -51,9 +50,7 @@ export const fetchContacts = async (
|
||||
}
|
||||
// merged already via streams
|
||||
|
||||
console.log('📊 Contact events fetched:', events.length)
|
||||
|
||||
console.log('👥 Followed contacts:', followed.size)
|
||||
return followed
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch contacts:', error)
|
||||
|
||||
@@ -73,13 +73,11 @@ class ContactsController {
|
||||
|
||||
// Skip if already loaded for this pubkey (unless forced)
|
||||
if (!force && this.isLoadedFor(pubkey)) {
|
||||
console.log('[contacts] ✅ Already loaded for', pubkey.slice(0, 8))
|
||||
this.emitContacts(this.currentContacts)
|
||||
return
|
||||
}
|
||||
|
||||
this.setLoading(true)
|
||||
console.log('[contacts] 🔍 Loading contacts for', pubkey.slice(0, 8))
|
||||
|
||||
try {
|
||||
const contacts = await fetchContacts(
|
||||
@@ -89,7 +87,6 @@ class ContactsController {
|
||||
// Stream partial updates
|
||||
this.currentContacts = new Set(partial)
|
||||
this.emitContacts(this.currentContacts)
|
||||
console.log('[contacts] 📥 Partial contacts:', partial.size)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -98,7 +95,6 @@ class ContactsController {
|
||||
this.lastLoadedPubkey = pubkey
|
||||
this.emitContacts(this.currentContacts)
|
||||
|
||||
console.log('[contacts] ✅ Loaded', contacts.size, 'contacts')
|
||||
} catch (error) {
|
||||
console.error('[contacts] ❌ Failed to load contacts:', error)
|
||||
this.currentContacts.clear()
|
||||
|
||||
@@ -36,12 +36,10 @@ export async function createDeletionRequest(
|
||||
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
console.log('🗑️ Created kind:5 deletion request for event:', eventId.slice(0, 8))
|
||||
|
||||
// Publish to relays
|
||||
await relayPool.publish(RELAYS, signed)
|
||||
|
||||
console.log('✅ Deletion request published to', RELAYS.length, 'relay(s)')
|
||||
|
||||
return signed
|
||||
}
|
||||
|
||||
@@ -33,11 +33,9 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
): Promise<BlogPostPreview[]> => {
|
||||
try {
|
||||
if (pubkeys.length === 0) {
|
||||
console.log('⚠️ No pubkeys to fetch blog posts from')
|
||||
return []
|
||||
}
|
||||
|
||||
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors', limit ? `(limit: ${limit})` : '(no limit)')
|
||||
|
||||
// Deduplicate replaceable events by keeping the most recent version
|
||||
// Group by author + d-tag identifier
|
||||
@@ -75,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)
|
||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||
@@ -97,7 +94,6 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
return timeB - timeA // Most recent first
|
||||
})
|
||||
|
||||
console.log('📰 Processed', blogPosts.length, 'unique blog posts')
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
|
||||
@@ -46,7 +46,6 @@ export async function createHighlight(
|
||||
}
|
||||
|
||||
// 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 })
|
||||
|
||||
let blueprintSource: NostrEvent | AddressPointer | string
|
||||
@@ -117,9 +116,7 @@ export async function createHighlight(
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
console.log('[bunker] Signing highlight event...', { kind: highlightEvent.kind, tags: highlightEvent.tags.length })
|
||||
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
|
||||
await publishEvent(relayPool, eventStore, signedEvent)
|
||||
|
||||
@@ -22,7 +22,6 @@ export const fetchHighlights = async (
|
||||
const cacheKey = highlightCache.authorKey(pubkey)
|
||||
const cached = highlightCache.get(cacheKey)
|
||||
if (cached) {
|
||||
console.log(`📌 Using cached highlights for author (${cached.length} items)`)
|
||||
// Stream cached highlights if callback provided
|
||||
if (onHighlight) {
|
||||
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
|
||||
if (eventStore) {
|
||||
|
||||
@@ -23,7 +23,6 @@ export const fetchHighlightsForArticle = async (
|
||||
const cacheKey = highlightCache.articleKey(articleCoordinate)
|
||||
const cached = highlightCache.get(cacheKey)
|
||||
if (cached) {
|
||||
console.log(`📌 Using cached highlights for article (${cached.length} items)`)
|
||||
// Stream cached highlights if callback provided
|
||||
if (onHighlight) {
|
||||
cached.forEach(h => onHighlight(h))
|
||||
@@ -54,7 +53,6 @@ export const fetchHighlightsForArticle = async (
|
||||
])
|
||||
|
||||
const rawEvents = [...aTagEvents, ...eTagEvents]
|
||||
console.log(`📌 Fetched ${rawEvents.length} highlight events for article:`, articleCoordinate)
|
||||
|
||||
// Store all events in event store if provided
|
||||
if (eventStore) {
|
||||
|
||||
@@ -22,7 +22,6 @@ export const fetchHighlightsForUrl = async (
|
||||
const cacheKey = highlightCache.urlKey(url)
|
||||
const cached = highlightCache.get(cacheKey)
|
||||
if (cached) {
|
||||
console.log(`📌 Using cached highlights for URL (${cached.length} items)`)
|
||||
// Stream cached highlights if callback provided
|
||||
if (onHighlight) {
|
||||
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
|
||||
if (eventStore) {
|
||||
|
||||
@@ -21,11 +21,9 @@ export const fetchHighlightsFromAuthors = async (
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
if (pubkeys.length === 0) {
|
||||
console.log('⚠️ No pubkeys to fetch highlights from')
|
||||
return []
|
||||
}
|
||||
|
||||
console.log('💡 Fetching highlights (kind 9802) from', pubkeys.length, 'authors')
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const rawEvents = await queryEvents(
|
||||
@@ -55,7 +53,6 @@ export const fetchHighlightsFromAuthors = async (
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights = uniqueEvents.map(eventToHighlight)
|
||||
|
||||
console.log('💡 Processed', highlights.length, 'unique highlights')
|
||||
|
||||
return sortHighlights(highlights)
|
||||
} catch (error) {
|
||||
|
||||
@@ -110,7 +110,6 @@ class HighlightsController {
|
||||
|
||||
// Skip if already loaded for this pubkey (unless forced)
|
||||
if (!force && this.isLoadedFor(pubkey)) {
|
||||
console.log('[highlights] ✅ Already loaded for', pubkey.slice(0, 8))
|
||||
this.emitHighlights(this.currentHighlights)
|
||||
return
|
||||
}
|
||||
@@ -120,7 +119,6 @@ class HighlightsController {
|
||||
const currentGeneration = this.generation
|
||||
|
||||
this.setLoading(true)
|
||||
console.log('[highlights] 🔍 Loading highlights for', pubkey.slice(0, 8))
|
||||
|
||||
try {
|
||||
const seenIds = new Set<string>()
|
||||
@@ -134,7 +132,6 @@ class HighlightsController {
|
||||
}
|
||||
if (lastSyncedAt) {
|
||||
filter.since = lastSyncedAt
|
||||
console.log('[highlights] 📅 Incremental sync since', new Date(lastSyncedAt * 1000).toISOString())
|
||||
}
|
||||
|
||||
const events = await queryEvents(
|
||||
@@ -165,7 +162,6 @@ class HighlightsController {
|
||||
|
||||
// Check if still active after async operation
|
||||
if (currentGeneration !== this.generation) {
|
||||
console.log('[highlights] ⚠️ Load cancelled (generation mismatch)')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -189,7 +185,6 @@ class HighlightsController {
|
||||
this.setLastSyncedAt(pubkey, newestTimestamp)
|
||||
}
|
||||
|
||||
console.log('[highlights] ✅ Loaded', sorted.length, 'highlights')
|
||||
} catch (error) {
|
||||
console.error('[highlights] ❌ Failed to load highlights:', error)
|
||||
this.currentHighlights = []
|
||||
|
||||
@@ -13,7 +13,6 @@ const CACHE_NAME = 'boris-image-cache-v1'
|
||||
export async function clearImageCache(): Promise<void> {
|
||||
try {
|
||||
await caches.delete(CACHE_NAME)
|
||||
console.log('🗑️ Cleared all cached images')
|
||||
} catch (err) {
|
||||
console.error('Failed to clear image cache:', err)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { MARK_AS_READ_EMOJI } from './reactionService'
|
||||
import { ARCHIVE_EMOJI } from './reactionService'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
import { queryEvents } from './dataFetch'
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function fetchReadArticles(
|
||||
|
||||
// Process kind:7 reactions (nostr-native articles)
|
||||
for (const event of kind7Events) {
|
||||
if (event.content === MARK_AS_READ_EMOJI) {
|
||||
if (event.content === ARCHIVE_EMOJI) {
|
||||
const eTag = event.tags.find((t) => t[0] === 'e')
|
||||
const pTag = event.tags.find((t) => t[0] === 'p')
|
||||
const kTag = event.tags.find((t) => t[0] === 'k')
|
||||
@@ -58,7 +58,7 @@ export async function fetchReadArticles(
|
||||
|
||||
// Process kind:17 reactions (external URLs)
|
||||
for (const event of kind17Events) {
|
||||
if (event.content === MARK_AS_READ_EMOJI) {
|
||||
if (event.content === ARCHIVE_EMOJI) {
|
||||
const rTag = event.tags.find((t) => t[0] === 'r')
|
||||
|
||||
if (rTag && rTag[1]) {
|
||||
|
||||
@@ -17,7 +17,6 @@ export async function fetchLinks(
|
||||
userPubkey: string,
|
||||
onItem?: (item: ReadItem) => void
|
||||
): Promise<ReadItem[]> {
|
||||
console.log('🔗 [Links] Fetching external links for user:', userPubkey.slice(0, 8))
|
||||
|
||||
const linksMap = new Map<string, ReadItem>()
|
||||
|
||||
@@ -37,11 +36,6 @@ export async function fetchLinks(
|
||||
fetchReadArticles(relayPool, userPubkey)
|
||||
])
|
||||
|
||||
console.log('📊 [Links] Data fetched:', {
|
||||
readingProgress: progressEvents.length,
|
||||
markedAsRead: markedAsReadArticles.length
|
||||
})
|
||||
|
||||
// Process reading progress events (kind 39802)
|
||||
processReadingProgress(progressEvents, linksMap)
|
||||
if (onItem) {
|
||||
@@ -79,7 +73,6 @@ export async function fetchLinks(
|
||||
const validLinks = filterValidItems(links)
|
||||
const sortedLinks = sortByReadingActivity(validLinks)
|
||||
|
||||
console.log('✅ [Links] Processed', sortedLinks.length, 'total links')
|
||||
return sortedLinks
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -24,7 +24,6 @@ export const fetchNostrverseBlogPosts = async (
|
||||
onPost?: (post: BlogPostPreview) => void
|
||||
): Promise<BlogPostPreview[]> => {
|
||||
try {
|
||||
console.log('[NOSTRVERSE] 📚 Fetching blog posts (kind 30023), limit:', limit)
|
||||
|
||||
// Deduplicate replaceable events by keeping the most recent version
|
||||
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)
|
||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||
@@ -81,7 +79,6 @@ export const fetchNostrverseBlogPosts = async (
|
||||
return timeB - timeA // Most recent first
|
||||
})
|
||||
|
||||
console.log('[NOSTRVERSE] 📰 Processed', blogPosts.length, 'unique blog posts')
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
@@ -103,7 +100,6 @@ export const fetchNostrverseHighlights = async (
|
||||
eventStore?: IEventStore
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
console.log('[NOSTRVERSE] 💡 Fetching highlights (kind 9802), limit:', limit)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
// Collect but do not block callers awaiting network completion
|
||||
@@ -133,7 +129,6 @@ export const fetchNostrverseHighlights = async (
|
||||
const uniqueEvents = dedupeHighlights([...collected, ...rawEvents])
|
||||
const highlights = uniqueEvents.map(eventToHighlight)
|
||||
|
||||
console.log('[NOSTRVERSE] 💡 Processed', highlights.length, 'unique highlights')
|
||||
|
||||
return sortHighlights(highlights)
|
||||
} catch (error) {
|
||||
|
||||
@@ -20,7 +20,6 @@ const syncStateListeners: Array<(eventId: string, isSyncing: boolean) => void> =
|
||||
*/
|
||||
export function markEventAsOfflineCreated(eventId: string): void {
|
||||
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
|
||||
): Promise<void> {
|
||||
if (isSyncing) {
|
||||
console.log('⏳ Sync already in progress, skipping...')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('🔄 Coming back online - syncing local events to remote relays...')
|
||||
console.log(`📦 Offline events tracked: ${offlineCreatedEvents.size}`)
|
||||
isSyncing = true
|
||||
|
||||
try {
|
||||
const remoteRelays = RELAYS.filter(url => !isLocalRelay(url))
|
||||
|
||||
console.log(`📡 Remote relays: ${remoteRelays.length}`)
|
||||
|
||||
if (remoteRelays.length === 0) {
|
||||
console.log('⚠️ No remote relays available for sync')
|
||||
isSyncing = false
|
||||
return
|
||||
}
|
||||
|
||||
if (offlineCreatedEvents.size === 0) {
|
||||
console.log('✅ No offline events to sync')
|
||||
isSyncing = false
|
||||
return
|
||||
}
|
||||
|
||||
// Get events from EventStore using the tracked IDs
|
||||
const eventsToSync: NostrEvent[] = []
|
||||
console.log(`🔍 Querying EventStore for ${offlineCreatedEvents.size} offline events...`)
|
||||
|
||||
for (const eventId of offlineCreatedEvents) {
|
||||
const event = eventStore.getEvent(eventId)
|
||||
if (event) {
|
||||
console.log(`📥 Found event ${eventId.slice(0, 8)} (kind ${event.kind}) in EventStore`)
|
||||
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) {
|
||||
console.log('✅ No events found in EventStore to sync')
|
||||
isSyncing = false
|
||||
offlineCreatedEvents.clear()
|
||||
return
|
||||
@@ -110,8 +95,6 @@ export async function syncLocalEventsToRemote(
|
||||
new Map(eventsToSync.map(e => [e.id, e])).values()
|
||||
)
|
||||
|
||||
console.log(`📤 Syncing ${uniqueEvents.length} event(s) to remote relays...`)
|
||||
|
||||
// Mark all events as syncing
|
||||
uniqueEvents.forEach(event => {
|
||||
syncingEvents.add(event.id)
|
||||
@@ -119,21 +102,16 @@ export async function syncLocalEventsToRemote(
|
||||
})
|
||||
|
||||
// Publish to remote relays
|
||||
let successCount = 0
|
||||
const successfulIds: string[] = []
|
||||
|
||||
for (const event of uniqueEvents) {
|
||||
try {
|
||||
await relayPool.publish(remoteRelays, event)
|
||||
successCount++
|
||||
successfulIds.push(event.id)
|
||||
console.log(`✅ Synced event ${event.id.slice(0, 8)}`)
|
||||
} 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
|
||||
successfulIds.forEach(eventId => {
|
||||
@@ -150,7 +128,7 @@ export async function syncLocalEventsToRemote(
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('❌ Error during offline sync:', error)
|
||||
// Silently fail
|
||||
} finally {
|
||||
isSyncing = false
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ export const fetchProfiles = async (
|
||||
}
|
||||
|
||||
const uniquePubkeys = Array.from(new Set(pubkeys))
|
||||
console.log('👤 Fetching profiles (kind:0) for', uniquePubkeys.length, 'authors')
|
||||
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const prioritized = prioritizeLocalRelays(relayUrls)
|
||||
@@ -65,7 +64,6 @@ export const fetchProfiles = async (
|
||||
await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
|
||||
const profiles = Array.from(profilesByPubkey.values())
|
||||
console.log('✅ Fetched', profiles.length, 'unique profiles')
|
||||
|
||||
// Rebroadcast profiles to local/all relays based on settings
|
||||
if (profiles.length > 0) {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
|
||||
const MARK_AS_READ_EMOJI = '📚'
|
||||
const ARCHIVE_EMOJI = '📚'
|
||||
|
||||
export { MARK_AS_READ_EMOJI }
|
||||
export { ARCHIVE_EMOJI }
|
||||
|
||||
/**
|
||||
* Creates a kind:7 reaction to a nostr event (for nostr-native articles)
|
||||
@@ -23,7 +23,8 @@ export async function createEventReaction(
|
||||
eventAuthor: string,
|
||||
eventKind: number,
|
||||
account: IAccount,
|
||||
relayPool: RelayPool
|
||||
relayPool: RelayPool,
|
||||
options?: { aCoord?: string }
|
||||
): Promise<NostrEvent> {
|
||||
const factory = new EventFactory({ signer: account })
|
||||
|
||||
@@ -32,22 +33,24 @@ export async function createEventReaction(
|
||||
['p', eventAuthor],
|
||||
['k', eventKind.toString()]
|
||||
]
|
||||
if (options?.aCoord) {
|
||||
tags.push(['a', options.aCoord])
|
||||
console.log('[archive] createEventReaction add a-tag:', options.aCoord)
|
||||
}
|
||||
|
||||
const draft = await factory.create(async () => ({
|
||||
kind: 7, // Reaction
|
||||
content: MARK_AS_READ_EMOJI,
|
||||
content: ARCHIVE_EMOJI,
|
||||
tags,
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
console.log('📚 Created kind:7 reaction (mark as read) for event:', eventId.slice(0, 8))
|
||||
|
||||
// Publish to relays
|
||||
await relayPool.publish(RELAYS, signed)
|
||||
|
||||
console.log('✅ Reaction published to', RELAYS.length, 'relay(s)')
|
||||
|
||||
return signed
|
||||
}
|
||||
@@ -87,23 +90,42 @@ export async function createWebsiteReaction(
|
||||
|
||||
const draft = await factory.create(async () => ({
|
||||
kind: 17, // Reaction to a website
|
||||
content: MARK_AS_READ_EMOJI,
|
||||
content: ARCHIVE_EMOJI,
|
||||
tags,
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
console.log('📚 Created kind:17 reaction (mark as read) for URL:', normalizedUrl)
|
||||
|
||||
// Publish to relays
|
||||
await relayPool.publish(RELAYS, signed)
|
||||
|
||||
console.log('✅ Website reaction published to', RELAYS.length, 'relay(s)')
|
||||
|
||||
return signed
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a deletion request (NIP-09) for a reaction event to effectively un-archive.
|
||||
* The caller must know the reaction event id to delete.
|
||||
*/
|
||||
export async function deleteReaction(
|
||||
reactionEventId: string,
|
||||
account: IAccount,
|
||||
relayPool: RelayPool
|
||||
): Promise<NostrEvent> {
|
||||
const factory = new EventFactory({ signer: account })
|
||||
const draft = await factory.create(async () => ({
|
||||
kind: 5, // Deletion per NIP-09
|
||||
content: 'unarchive',
|
||||
tags: [['e', reactionEventId]],
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
const signed = await factory.sign(draft)
|
||||
await relayPool.publish(RELAYS, signed)
|
||||
return signed
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user has already marked a nostr event as read
|
||||
* @param eventId The ID of the event to check
|
||||
@@ -134,8 +156,8 @@ export async function hasMarkedEventAsRead(
|
||||
|
||||
const events: NostrEvent[] = await lastValueFrom(events$)
|
||||
|
||||
// Check if any reaction has our mark-as-read emoji
|
||||
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
|
||||
// Check if any reaction has our archive emoji
|
||||
const hasReadReaction = events.some((event: NostrEvent) => event.content === ARCHIVE_EMOJI)
|
||||
|
||||
return hasReadReaction
|
||||
} catch (error) {
|
||||
@@ -187,8 +209,8 @@ export async function hasMarkedWebsiteAsRead(
|
||||
|
||||
const events: NostrEvent[] = await lastValueFrom(events$)
|
||||
|
||||
// Check if any reaction has our mark-as-read emoji
|
||||
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
|
||||
// Check if any reaction has our archive emoji
|
||||
const hasReadReaction = events.some((event: NostrEvent) => event.content === ARCHIVE_EMOJI)
|
||||
|
||||
return hasReadReaction
|
||||
} catch (error) {
|
||||
|
||||
@@ -27,31 +27,23 @@ export function processReadingProgress(
|
||||
events: NostrEvent[],
|
||||
readsMap: Map<string, ReadItem>
|
||||
): void {
|
||||
console.log('[progress] 🔧 processReadingProgress called with', events.length, 'events')
|
||||
|
||||
for (const event of events) {
|
||||
if (event.kind !== READING_PROGRESS_KIND) {
|
||||
console.log('[progress] ⏭️ Skipping event with wrong kind:', event.kind)
|
||||
continue
|
||||
}
|
||||
|
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (!dTag) {
|
||||
console.log('[progress] ⚠️ Event missing d-tag:', event.id.slice(0, 8))
|
||||
continue
|
||||
}
|
||||
|
||||
console.log('[progress] 📝 Processing event:', event.id.slice(0, 8), 'd-tag:', dTag.slice(0, 50))
|
||||
|
||||
try {
|
||||
const content = JSON.parse(event.content)
|
||||
const position = content.progress || 0
|
||||
|
||||
console.log('[progress] 📊 Progress value:', position, '(' + Math.round(position * 100) + '%)')
|
||||
|
||||
// Validate progress is between 0 and 1 (NIP-85 requirement)
|
||||
if (position < 0 || position > 1) {
|
||||
console.warn('[progress] ❌ Invalid progress value (must be 0-1):', position, 'event:', event.id.slice(0, 8))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -76,13 +68,10 @@ export function processReadingProgress(
|
||||
})
|
||||
itemId = naddr
|
||||
itemType = 'article'
|
||||
console.log('[progress] ✅ Converted coordinate to naddr:', naddr.slice(0, 50))
|
||||
} catch (e) {
|
||||
console.warn('[progress] ❌ Failed to encode naddr from coordinate:', dTag)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
console.warn('[progress] ⚠️ Invalid coordinate format:', dTag)
|
||||
continue
|
||||
}
|
||||
} else if (dTag.startsWith('url:')) {
|
||||
@@ -92,13 +81,10 @@ export function processReadingProgress(
|
||||
itemUrl = atob(encoded.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
itemId = itemUrl
|
||||
itemType = 'external'
|
||||
console.log('[progress] ✅ Decoded URL:', itemUrl.slice(0, 50))
|
||||
} catch (e) {
|
||||
console.warn('[progress] ❌ Failed to decode URL from d tag:', dTag)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
console.warn('[progress] ⚠️ Unknown d-tag format:', dTag)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -114,16 +100,11 @@ export function processReadingProgress(
|
||||
readingProgress: position,
|
||||
readingTimestamp: timestamp
|
||||
})
|
||||
console.log('[progress] ✅ Added/updated item in readsMap:', itemId.slice(0, 50), '=', Math.round(position * 100) + '%')
|
||||
} else {
|
||||
console.log('[progress] ⏭️ Skipping older event for:', itemId.slice(0, 50))
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[progress] ❌ Failed to parse reading progress event:', error)
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[progress] 🏁 processReadingProgress finished, readsMap size:', readsMap.size)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,14 +49,10 @@ function generateDTag(naddrOrUrl: string): string {
|
||||
const decoded = nip19.decode(naddrOrUrl)
|
||||
if (decoded.type === 'naddr') {
|
||||
const dTag = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier || ''}`
|
||||
console.log('[progress] 📋 Generated d-tag from naddr:', {
|
||||
naddr: naddrOrUrl.slice(0, 50) + '...',
|
||||
dTag: dTag.slice(0, 80) + '...'
|
||||
})
|
||||
return dTag
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to decode naddr:', naddrOrUrl)
|
||||
// Ignore decode errors
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,14 +115,6 @@ export async function saveReadingPosition(
|
||||
articleIdentifier: string,
|
||||
position: ReadingPosition
|
||||
): Promise<void> {
|
||||
console.log('[progress] 💾 saveReadingPosition: Starting save:', {
|
||||
identifier: articleIdentifier.slice(0, 50) + '...',
|
||||
position: position.position,
|
||||
positionPercent: Math.round(position.position * 100) + '%',
|
||||
timestamp: position.timestamp,
|
||||
scrollTop: position.scrollTop
|
||||
})
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
const progressContent: ReadingProgressContent = {
|
||||
@@ -138,13 +126,6 @@ export async function saveReadingPosition(
|
||||
|
||||
const tags = generateProgressTags(articleIdentifier)
|
||||
|
||||
console.log('[progress] 📝 Creating event with:', {
|
||||
kind: READING_PROGRESS_KIND,
|
||||
content: progressContent,
|
||||
tags: tags.map(t => `[${t.join(', ')}]`).join(', '),
|
||||
created_at: now
|
||||
})
|
||||
|
||||
const draft = await factory.create(async () => ({
|
||||
kind: READING_PROGRESS_KIND,
|
||||
content: JSON.stringify(progressContent),
|
||||
@@ -152,20 +133,9 @@ export async function saveReadingPosition(
|
||||
created_at: now
|
||||
}))
|
||||
|
||||
console.log('[progress] ✍️ Signing event...')
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
console.log('[progress] 📡 Publishing event:', {
|
||||
id: signed.id,
|
||||
kind: signed.kind,
|
||||
pubkey: signed.pubkey.slice(0, 8) + '...',
|
||||
content: signed.content,
|
||||
tags: signed.tags
|
||||
})
|
||||
|
||||
await publishEvent(relayPool, eventStore, signed)
|
||||
|
||||
console.log('[progress] ✅ Event published successfully, ID:', signed.id.slice(0, 16))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,12 +149,6 @@ export async function loadReadingPosition(
|
||||
): Promise<ReadingPosition | null> {
|
||||
const dTag = generateDTag(articleIdentifier)
|
||||
|
||||
console.log('📖 [ReadingProgress] Loading position:', {
|
||||
pubkey: pubkey.slice(0, 8) + '...',
|
||||
identifier: articleIdentifier.slice(0, 32) + '...',
|
||||
dTag: dTag.slice(0, 50) + '...'
|
||||
})
|
||||
|
||||
// Check local event store first
|
||||
try {
|
||||
const localEvent = await firstValueFrom(
|
||||
@@ -193,12 +157,6 @@ export async function loadReadingPosition(
|
||||
if (localEvent) {
|
||||
const content = getReadingProgressContent(localEvent)
|
||||
if (content) {
|
||||
console.log('✅ [ReadingProgress] Loaded from local store:', {
|
||||
position: content.position,
|
||||
positionPercent: Math.round(content.position * 100) + '%',
|
||||
timestamp: content.timestamp
|
||||
})
|
||||
|
||||
// Fetch from relays in background to get any updates
|
||||
relayPool
|
||||
.subscription(RELAYS, {
|
||||
@@ -213,7 +171,7 @@ export async function loadReadingPosition(
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('📭 No cached reading progress found, fetching from relays...')
|
||||
// Ignore errors and fetch from relays
|
||||
}
|
||||
|
||||
// Fetch from relays
|
||||
@@ -226,13 +184,7 @@ export async function loadReadingPosition(
|
||||
getReadingProgressContent
|
||||
)
|
||||
|
||||
if (result) {
|
||||
console.log('✅ [ReadingProgress] Loaded from relays')
|
||||
return result
|
||||
}
|
||||
|
||||
console.log('📭 No reading progress found')
|
||||
return null
|
||||
return result || null
|
||||
}
|
||||
|
||||
// Helper function to fetch from relays with timeout
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { Filter, NostrEvent } from 'nostr-tools'
|
||||
import { 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'
|
||||
import { ARCHIVE_EMOJI } from './reactionService'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
console.log('[readingProgress] Module loaded')
|
||||
|
||||
type ProgressMapCallback = (progressMap: Map<string, number>) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
@@ -20,11 +24,14 @@ const PROGRESS_CACHE_KEY = 'reading_progress_cache_v1'
|
||||
class ReadingProgressController {
|
||||
private progressListeners: ProgressMapCallback[] = []
|
||||
private loadingListeners: LoadingCallback[] = []
|
||||
private markedAsReadListeners: (() => void)[] = []
|
||||
|
||||
private currentProgressMap: Map<string, number> = new Map()
|
||||
private markedAsReadIds: Set<string> = new Set()
|
||||
private lastLoadedPubkey: string | null = null
|
||||
private generation = 0
|
||||
private timelineSubscription: { unsubscribe: () => void } | null = null
|
||||
private isLoading = false
|
||||
|
||||
onProgress(cb: ProgressMapCallback): () => void {
|
||||
this.progressListeners.push(cb)
|
||||
@@ -40,15 +47,25 @@ class ReadingProgressController {
|
||||
}
|
||||
}
|
||||
|
||||
onMarkedAsReadChanged(cb: () => void): () => void {
|
||||
this.markedAsReadListeners.push(cb)
|
||||
return () => {
|
||||
this.markedAsReadListeners = this.markedAsReadListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
private setLoading(loading: boolean): void {
|
||||
this.loadingListeners.forEach(cb => cb(loading))
|
||||
}
|
||||
|
||||
private emitProgress(progressMap: Map<string, number>): void {
|
||||
console.log('[progress] 📡 Emitting to', this.progressListeners.length, 'listeners with', progressMap.size, 'items')
|
||||
this.progressListeners.forEach(cb => cb(new Map(progressMap)))
|
||||
}
|
||||
|
||||
private emitMarkedAsReadChanged(): void {
|
||||
this.markedAsReadListeners.forEach(cb => cb())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current reading progress map without triggering a reload
|
||||
*/
|
||||
@@ -81,7 +98,7 @@ class ReadingProgressController {
|
||||
parsed[pubkey] = Object.fromEntries(progressMap.entries())
|
||||
localStorage.setItem(PROGRESS_CACHE_KEY, JSON.stringify(parsed))
|
||||
} catch (err) {
|
||||
console.warn('[progress] ⚠️ Failed to persist reading progress cache:', err)
|
||||
// Silently fail cache persistence
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +109,20 @@ class ReadingProgressController {
|
||||
return this.currentProgressMap.get(naddr)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if article is marked as read
|
||||
*/
|
||||
isMarkedAsRead(naddr: string): boolean {
|
||||
return this.markedAsReadIds.has(naddr)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all marked as read IDs (for debugging)
|
||||
*/
|
||||
getMarkedAsReadIds(): string[] {
|
||||
return Array.from(this.markedAsReadIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if reading progress is loaded for a specific pubkey
|
||||
*/
|
||||
@@ -109,29 +140,16 @@ class ReadingProgressController {
|
||||
try {
|
||||
this.timelineSubscription.unsubscribe()
|
||||
} catch (err) {
|
||||
console.warn('[progress] ⚠️ Failed to unsubscribe timeline on reset:', err)
|
||||
// Silently fail on unsubscribe
|
||||
}
|
||||
this.timelineSubscription = null
|
||||
}
|
||||
this.currentProgressMap = new Map()
|
||||
this.markedAsReadIds = new Set()
|
||||
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
|
||||
*/
|
||||
@@ -142,7 +160,7 @@ class ReadingProgressController {
|
||||
parsed[pubkey] = timestamp
|
||||
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
|
||||
} catch (err) {
|
||||
console.warn('Failed to update last synced timestamp:', err)
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,94 +176,98 @@ class ReadingProgressController {
|
||||
const { relayPool, eventStore, pubkey, force = false } = params
|
||||
const startGeneration = this.generation
|
||||
|
||||
console.log('[readingProgress] start() called for pubkey:', pubkey.slice(0, 16), '...', 'force:', force)
|
||||
|
||||
// Skip if already loaded for this pubkey and not forcing
|
||||
if (!force && this.isLoadedFor(pubkey)) {
|
||||
console.log('📊 [ReadingProgress] Already loaded for', pubkey.slice(0, 8))
|
||||
console.log('[readingProgress] Already loaded for pubkey, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent concurrent starts
|
||||
if (this.isLoading) {
|
||||
console.log('[readingProgress] Already loading, skipping concurrent start')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('📊 [ReadingProgress] Loading for', pubkey.slice(0, 8), force ? '(forced)' : '')
|
||||
|
||||
this.setLoading(true)
|
||||
this.lastLoadedPubkey = pubkey
|
||||
this.isLoading = true
|
||||
|
||||
try {
|
||||
// Seed from local cache immediately (survives refresh/flight mode)
|
||||
const cached = this.loadCachedProgress(pubkey)
|
||||
if (cached.size > 0) {
|
||||
console.log('📊 [ReadingProgress] Seeded from cache:', cached.size, 'items')
|
||||
this.currentProgressMap = cached
|
||||
this.emitProgress(this.currentProgressMap)
|
||||
}
|
||||
|
||||
// Subscribe to local timeline for immediate and reactive updates
|
||||
// Clean up any previous subscription first
|
||||
// Subscribe to local eventStore timeline for immediate and reactive updates
|
||||
// This handles both local writes and synced events from relays
|
||||
if (this.timelineSubscription) {
|
||||
try {
|
||||
this.timelineSubscription.unsubscribe()
|
||||
} catch (err) {
|
||||
console.warn('[progress] ⚠️ Failed to unsubscribe previous timeline:', err)
|
||||
// Silently fail
|
||||
}
|
||||
this.timelineSubscription = null
|
||||
}
|
||||
|
||||
console.log('[readingProgress] Setting up eventStore subscription...')
|
||||
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
|
||||
console.log('📊 [ReadingProgress] Timeline update with', localEvents.length, 'event(s)')
|
||||
this.processEvents(localEvents)
|
||||
})
|
||||
console.log('[readingProgress] EventStore subscription ready - updates streaming')
|
||||
|
||||
// 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 = {
|
||||
// Mark as loaded immediately - queries run in background non-blocking
|
||||
this.lastLoadedPubkey = pubkey
|
||||
|
||||
// Query reading progress from relays in background (non-blocking, fire-and-forget)
|
||||
console.log('[readingProgress] Starting background relay query for reading progress...')
|
||||
queryEvents(relayPool, {
|
||||
kinds: [KINDS.ReadingProgress],
|
||||
authors: [pubkey]
|
||||
}
|
||||
|
||||
if (lastSynced && !needsFullSync) {
|
||||
filter.since = lastSynced
|
||||
console.log('📊 [ReadingProgress] Incremental sync since', new Date(lastSynced * 1000).toISOString())
|
||||
} else {
|
||||
console.log('📊 [ReadingProgress] Full sync (map size:', this.currentProgressMap.size + ')')
|
||||
}
|
||||
}, { relayUrls: RELAYS })
|
||||
.then((relayEvents) => {
|
||||
if (startGeneration !== this.generation) return
|
||||
console.log('[readingProgress] Got reading progress from relays:', relayEvents.length)
|
||||
if (relayEvents.length > 0) {
|
||||
relayEvents.forEach(e => eventStore.add(e))
|
||||
this.processEvents(relayEvents)
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
this.updateLastSyncedAt(pubkey, now)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[readingProgress] Background reading progress query failed:', err)
|
||||
})
|
||||
|
||||
const relayEvents = await queryEvents(relayPool, filter, { relayUrls: RELAYS })
|
||||
|
||||
if (startGeneration !== this.generation) {
|
||||
console.log('📊 [ReadingProgress] Cancelled (generation changed)')
|
||||
return
|
||||
}
|
||||
// Load mark-as-read reactions in background (non-blocking, streaming)
|
||||
console.log('[readingProgress] Starting background relay query for mark-as-read reactions...')
|
||||
this.loadMarkAsReadReactions(relayPool, eventStore, pubkey, startGeneration)
|
||||
.then(() => {
|
||||
console.log('[readingProgress] Mark-as-read reactions loading complete')
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[readingProgress] Mark-as-read reactions loading failed:', err)
|
||||
})
|
||||
|
||||
if (relayEvents.length > 0) {
|
||||
// Add to event store
|
||||
relayEvents.forEach(e => eventStore.add(e))
|
||||
|
||||
// Process and emit (merge with existing)
|
||||
this.processEvents(relayEvents)
|
||||
console.log('📊 [ReadingProgress] Loaded', relayEvents.length, 'events from relays')
|
||||
|
||||
// Update last synced
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
this.updateLastSyncedAt(pubkey, now)
|
||||
} else {
|
||||
console.log('📊 [ReadingProgress] No new events from relays')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('📊 [ReadingProgress] Failed to load:', err)
|
||||
console.error('📊 [ReadingProgress] Failed to setup:', err)
|
||||
} finally {
|
||||
if (startGeneration === this.generation) {
|
||||
this.setLoading(false)
|
||||
}
|
||||
this.isLoading = false
|
||||
console.log('[readingProgress] === LOADED ===')
|
||||
console.log('[readingProgress] progressMap keys:', Array.from(this.currentProgressMap.keys()))
|
||||
console.log('[readingProgress] markedAsReadIds:', Array.from(this.markedAsReadIds))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,8 +275,6 @@ class ReadingProgressController {
|
||||
* Process events and update progress map
|
||||
*/
|
||||
private processEvents(events: NostrEvent[]): void {
|
||||
console.log('[progress] 🔄 Processing', events.length, 'events')
|
||||
|
||||
const readsMap = new Map<string, ReadItem>()
|
||||
|
||||
// Merge with existing progress
|
||||
@@ -267,24 +287,17 @@ class ReadingProgressController {
|
||||
})
|
||||
}
|
||||
|
||||
console.log('[progress] 📦 Starting with', readsMap.size, 'existing items')
|
||||
|
||||
// Process new events
|
||||
processReadingProgress(events, readsMap)
|
||||
|
||||
console.log('[progress] 📦 After processing:', readsMap.size, 'items')
|
||||
|
||||
// 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)
|
||||
console.log('[progress] ✅ Added:', id.slice(0, 50) + '...', '=', Math.round(item.readingProgress * 100) + '%')
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[progress] 📊 Final progress map size:', newProgressMap.size)
|
||||
|
||||
this.currentProgressMap = newProgressMap
|
||||
this.emitProgress(this.currentProgressMap)
|
||||
|
||||
@@ -293,6 +306,84 @@ class ReadingProgressController {
|
||||
this.persistProgress(this.lastLoadedPubkey, this.currentProgressMap)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load mark-as-read reactions in background (non-blocking)
|
||||
*/
|
||||
private async loadMarkAsReadReactions(
|
||||
relayPool: RelayPool,
|
||||
_eventStore: IEventStore,
|
||||
pubkey: string,
|
||||
generation: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Stream kind:17 (URL reactions) and kind:7 (event reactions) in parallel
|
||||
console.log('[readingProgress] Querying kind:17 and kind:7 reactions (streaming)...')
|
||||
const seenReactionIds = new Set<string>()
|
||||
|
||||
const handleUrlReaction = (evt: NostrEvent) => {
|
||||
if (seenReactionIds.has(evt.id)) return
|
||||
seenReactionIds.add(evt.id)
|
||||
if (evt.content !== ARCHIVE_EMOJI) return
|
||||
const rTag = evt.tags.find(t => t[0] === 'r')?.[1]
|
||||
if (!rTag) return
|
||||
this.markedAsReadIds.add(rTag)
|
||||
this.emitMarkedAsReadChanged()
|
||||
}
|
||||
|
||||
const pendingEventIds = new Set<string>()
|
||||
const handleEventReaction = (evt: NostrEvent) => {
|
||||
if (seenReactionIds.has(evt.id)) return
|
||||
seenReactionIds.add(evt.id)
|
||||
if (evt.content !== ARCHIVE_EMOJI) return
|
||||
const eTag = evt.tags.find(t => t[0] === 'e')?.[1]
|
||||
if (!eTag) return
|
||||
pendingEventIds.add(eTag)
|
||||
}
|
||||
|
||||
// Fire queries with onEvent callbacks for streaming behavior
|
||||
const [kind17Events, kind7Events] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleUrlReaction }),
|
||||
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleEventReaction })
|
||||
])
|
||||
|
||||
if (generation !== this.generation) return
|
||||
|
||||
// Include any reactions that arrived only at EOSE
|
||||
kind17Events.forEach(handleUrlReaction)
|
||||
kind7Events.forEach(handleEventReaction)
|
||||
|
||||
if (pendingEventIds.size > 0) {
|
||||
// Fetch referenced 30023 events, streaming not required here
|
||||
const ids = Array.from(pendingEventIds)
|
||||
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids }, { relayUrls: RELAYS })
|
||||
const eventIdToNaddr = new Map<string, string>()
|
||||
for (const article of articleEvents) {
|
||||
const dTag = article.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (!dTag) continue
|
||||
try {
|
||||
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: article.pubkey, identifier: dTag })
|
||||
eventIdToNaddr.set(article.id, naddr)
|
||||
} catch (e) {
|
||||
console.warn('[readingProgress] Failed to encode naddr for article:', article.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Map pending event IDs to naddrs and emit
|
||||
for (const eId of pendingEventIds) {
|
||||
const naddr = eventIdToNaddr.get(eId)
|
||||
if (naddr) {
|
||||
this.markedAsReadIds.add(naddr)
|
||||
}
|
||||
}
|
||||
this.emitMarkedAsReadChanged()
|
||||
}
|
||||
|
||||
console.log('[readingProgress] Mark-as-read reactions complete. Total:', Array.from(this.markedAsReadIds).length)
|
||||
} catch (err) {
|
||||
console.warn('[readingProgress] Failed to load mark-as-read reactions:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const readingProgressController = new ReadingProgressController()
|
||||
|
||||
276
src/services/readsController.ts
Normal file
276
src/services/readsController.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Helpers, IEventStore } from 'applesauce-core'
|
||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { merge } from 'rxjs'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { readingProgressController } from './readingProgressController'
|
||||
import { archiveController } from './archiveController'
|
||||
|
||||
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
|
||||
|
||||
export interface ReadItem {
|
||||
id: string // naddr coordinate
|
||||
source: 'reading-progress' | 'marked-as-read' | 'bookmark'
|
||||
type: 'article' | 'external'
|
||||
|
||||
// Article data
|
||||
event?: NostrEvent
|
||||
url?: string
|
||||
title?: string
|
||||
summary?: string
|
||||
image?: string
|
||||
published?: number
|
||||
author?: string
|
||||
|
||||
// Reading metadata
|
||||
readingProgress?: number // 0-1
|
||||
readingTimestamp?: number // Unix timestamp of last reading activity
|
||||
markedAsRead?: boolean
|
||||
markedAt?: number
|
||||
}
|
||||
|
||||
type ReadsCallback = (reads: ReadItem[]) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
|
||||
/**
|
||||
* Reads controller - manages read articles with progressive hydration
|
||||
* Follows the same pattern as bookmarkController
|
||||
*/
|
||||
class ReadsController {
|
||||
private readsListeners: ReadsCallback[] = []
|
||||
private loadingListeners: LoadingCallback[] = []
|
||||
|
||||
private currentReads: Map<string, ReadItem> = new Map()
|
||||
private isLoading = false
|
||||
private hydrationGeneration = 0
|
||||
|
||||
// Address loader for efficient batching
|
||||
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
|
||||
private eventStore: IEventStore | null = null
|
||||
|
||||
onReads(cb: ReadsCallback): () => void {
|
||||
this.readsListeners.push(cb)
|
||||
return () => {
|
||||
this.readsListeners = this.readsListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
onLoading(cb: LoadingCallback): () => void {
|
||||
this.loadingListeners.push(cb)
|
||||
return () => {
|
||||
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.hydrationGeneration++
|
||||
this.currentReads.clear()
|
||||
this.setLoading(false)
|
||||
}
|
||||
|
||||
private setLoading(loading: boolean): void {
|
||||
if (this.isLoading !== loading) {
|
||||
this.isLoading = loading
|
||||
this.loadingListeners.forEach(cb => cb(loading))
|
||||
}
|
||||
}
|
||||
|
||||
getReads(): ReadItem[] {
|
||||
return Array.from(this.currentReads.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate article events by coordinates using AddressLoader (auto-batching, streaming)
|
||||
*/
|
||||
private hydrateArticles(
|
||||
coordinates: string[],
|
||||
onProgress: () => void,
|
||||
generation: number
|
||||
): void {
|
||||
if (!this.addressLoader) {
|
||||
return
|
||||
}
|
||||
|
||||
if (coordinates.length === 0) return
|
||||
|
||||
// Parse coordinates into pointers
|
||||
const pointers: Array<{ kind: number; pubkey: string; identifier: string }> = []
|
||||
|
||||
for (const coord of coordinates) {
|
||||
try {
|
||||
// Decode naddr to get article coordinates
|
||||
if (coord.startsWith('naddr1')) {
|
||||
const decoded = nip19.decode(coord)
|
||||
if (decoded.type === 'naddr' && decoded.data.kind === KINDS.BlogPost) {
|
||||
pointers.push({
|
||||
kind: decoded.data.kind,
|
||||
pubkey: decoded.data.pubkey,
|
||||
identifier: decoded.data.identifier || ''
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to decode article coordinate:', coord)
|
||||
}
|
||||
}
|
||||
|
||||
if (pointers.length === 0) return
|
||||
|
||||
// Use AddressLoader - it auto-batches and streams results
|
||||
merge(...pointers.map(this.addressLoader)).subscribe({
|
||||
next: (event) => {
|
||||
// Check if hydration was cancelled
|
||||
if (this.hydrationGeneration !== generation) return
|
||||
|
||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
|
||||
// Build naddr from event
|
||||
try {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
identifier: dTag
|
||||
})
|
||||
|
||||
const item = this.currentReads.get(naddr)
|
||||
if (item) {
|
||||
// Enrich the item with article data
|
||||
item.event = event
|
||||
item.title = getArticleTitle(event) || 'Untitled'
|
||||
item.summary = getArticleSummary(event)
|
||||
item.image = getArticleImage(event)
|
||||
item.published = getArticlePublished(event)
|
||||
item.author = event.pubkey
|
||||
|
||||
// Store in event store if available
|
||||
if (this.eventStore) {
|
||||
this.eventStore.add(event)
|
||||
}
|
||||
|
||||
onProgress()
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to encode naddr for event:', event.id)
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// Silent error - AddressLoader handles retries
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ReadItems from reading progress and emit them
|
||||
*/
|
||||
private buildAndEmitReads(): void {
|
||||
const progressMap = readingProgressController.getProgressMap()
|
||||
const markedIds = Array.from(new Set([
|
||||
...readingProgressController.getMarkedAsReadIds(),
|
||||
...archiveController.getMarkedIds()
|
||||
]))
|
||||
|
||||
// Build read items from progress map
|
||||
const readItems: ReadItem[] = []
|
||||
|
||||
for (const [id, progress] of progressMap.entries()) {
|
||||
const existing = this.currentReads.get(id)
|
||||
const item: ReadItem = existing || {
|
||||
id,
|
||||
source: 'reading-progress',
|
||||
type: 'article',
|
||||
readingProgress: progress,
|
||||
readingTimestamp: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
// Update progress
|
||||
item.readingProgress = progress
|
||||
item.markedAsRead = markedIds.includes(id)
|
||||
|
||||
readItems.push(item)
|
||||
this.currentReads.set(id, item)
|
||||
}
|
||||
|
||||
// Include items that are only marked-as-read (no progress event yet)
|
||||
for (const id of markedIds) {
|
||||
if (!this.currentReads.has(id) && id.startsWith('naddr1')) {
|
||||
const item: ReadItem = {
|
||||
id,
|
||||
source: 'marked-as-read',
|
||||
type: 'article',
|
||||
markedAsRead: true,
|
||||
readingTimestamp: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
readItems.push(item)
|
||||
this.currentReads.set(id, item)
|
||||
}
|
||||
}
|
||||
|
||||
// Emit current state (items without article data yet)
|
||||
this.readsListeners.forEach(cb => cb(Array.from(this.currentReads.values())))
|
||||
|
||||
// Fetch missing articles in background (progressive hydration)
|
||||
const generation = this.hydrationGeneration
|
||||
const onProgress = () => {
|
||||
this.readsListeners.forEach(cb => cb(Array.from(this.currentReads.values())))
|
||||
}
|
||||
|
||||
const coordinatesToFetch = readItems
|
||||
.filter(item => !item.event && item.type === 'article')
|
||||
.map(item => item.id)
|
||||
|
||||
this.hydrateArticles(coordinatesToFetch, onProgress, generation)
|
||||
}
|
||||
|
||||
async start(options: {
|
||||
relayPool: RelayPool
|
||||
eventStore: IEventStore
|
||||
pubkey: string
|
||||
}): Promise<void> {
|
||||
const { relayPool, eventStore } = options
|
||||
|
||||
// Increment generation to cancel any in-flight hydration
|
||||
this.hydrationGeneration++
|
||||
this.eventStore = eventStore
|
||||
|
||||
// Initialize loader for this session
|
||||
this.addressLoader = createAddressLoader(relayPool, {
|
||||
eventStore,
|
||||
extraRelays: RELAYS
|
||||
})
|
||||
|
||||
this.setLoading(true)
|
||||
|
||||
try {
|
||||
// Subscribe to reading progress changes
|
||||
const unsubProgress = readingProgressController.onProgress(() => {
|
||||
this.buildAndEmitReads()
|
||||
})
|
||||
|
||||
const unsubMarked = archiveController.onMarked(() => {
|
||||
this.buildAndEmitReads()
|
||||
})
|
||||
|
||||
// Build initial reads
|
||||
this.buildAndEmitReads()
|
||||
|
||||
// Cleanup subscriptions on next start
|
||||
setTimeout(() => {
|
||||
unsubProgress()
|
||||
unsubMarked()
|
||||
}, 0)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load reads:', error)
|
||||
this.readsListeners.forEach(cb => cb([]))
|
||||
} finally {
|
||||
this.setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const readsController = new ReadsController()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { fetchReadArticles } from './libraryService'
|
||||
@@ -8,31 +7,15 @@ import { RELAYS } from '../config/relays'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
||||
import { mergeReadItem } from '../utils/readItemMerge'
|
||||
import type { ReadItem } from './readsController'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
export interface ReadItem {
|
||||
id: string // event ID or URL or coordinate
|
||||
source: 'bookmark' | 'reading-progress' | 'marked-as-read'
|
||||
type: 'article' | 'external' // article=kind:30023, external=URL
|
||||
|
||||
// Article data
|
||||
event?: NostrEvent
|
||||
url?: string
|
||||
title?: string
|
||||
summary?: string
|
||||
image?: string
|
||||
published?: number
|
||||
author?: string
|
||||
|
||||
// Reading metadata
|
||||
readingProgress?: number // 0-1
|
||||
readingTimestamp?: number // Unix timestamp of last reading activity
|
||||
markedAsRead?: boolean
|
||||
markedAt?: number
|
||||
}
|
||||
// Re-export ReadItem from readsController for consistency
|
||||
export type { ReadItem } from './readsController'
|
||||
|
||||
/**
|
||||
* Fetches all reads from multiple sources:
|
||||
@@ -46,7 +29,6 @@ export async function fetchAllReads(
|
||||
bookmarks: Bookmark[],
|
||||
onItem?: (item: ReadItem) => void
|
||||
): Promise<ReadItem[]> {
|
||||
console.log('📚 [Reads] Fetching all reads for user:', userPubkey.slice(0, 8))
|
||||
|
||||
const readsMap = new Map<string, ReadItem>()
|
||||
|
||||
@@ -66,25 +48,14 @@ export async function fetchAllReads(
|
||||
fetchReadArticles(relayPool, userPubkey)
|
||||
])
|
||||
|
||||
console.log('📊 [Reads] Data fetched:', {
|
||||
readingProgress: progressEvents.length,
|
||||
markedAsRead: markedAsReadArticles.length,
|
||||
bookmarks: bookmarks.length
|
||||
})
|
||||
|
||||
// Process reading progress events (kind 39802)
|
||||
processReadingProgress(progressEvents, readsMap)
|
||||
if (onItem) {
|
||||
readsMap.forEach(item => {
|
||||
if (item.type === 'article') onItem(item)
|
||||
})
|
||||
}
|
||||
|
||||
// Process marked-as-read and emit items
|
||||
processMarkedAsRead(markedAsReadArticles, readsMap)
|
||||
if (onItem) {
|
||||
readsMap.forEach(item => {
|
||||
if (item.type === 'article') onItem(item)
|
||||
onItem(item)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,7 +91,6 @@ export async function fetchAllReads(
|
||||
.map(item => item.id)
|
||||
|
||||
if (articleCoordinates.length > 0) {
|
||||
console.log('📖 [Reads] Fetching article events for', articleCoordinates.length, 'articles')
|
||||
|
||||
// Parse coordinates and fetch events
|
||||
const articlesToFetch: Array<{ pubkey: string; identifier: string }> = []
|
||||
@@ -130,11 +100,14 @@ export async function fetchAllReads(
|
||||
// Try to decode as naddr
|
||||
if (coord.startsWith('naddr1')) {
|
||||
const decoded = nip19.decode(coord)
|
||||
if (decoded.type === 'naddr' && decoded.data.kind === KINDS.BlogPost) {
|
||||
articlesToFetch.push({
|
||||
pubkey: decoded.data.pubkey,
|
||||
identifier: decoded.data.identifier || ''
|
||||
})
|
||||
if (decoded.type === 'naddr') {
|
||||
const data = decoded.data as AddressPointer
|
||||
if (data.kind === KINDS.BlogPost) {
|
||||
articlesToFetch.push({
|
||||
pubkey: data.pubkey,
|
||||
identifier: data.identifier || ''
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Try coordinate format (kind:pubkey:identifier)
|
||||
@@ -187,7 +160,6 @@ export async function fetchAllReads(
|
||||
const validArticles = filterValidItems(articles)
|
||||
const sortedReads = sortByReadingActivity(validArticles)
|
||||
|
||||
console.log('✅ [Reads] Processed', sortedReads.length, 'total reads')
|
||||
return sortedReads
|
||||
|
||||
} 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 (broadcastToAll && !hasRemoteConnection) {
|
||||
console.log('✈️ Flight mode: skipping rebroadcast to remote relays')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -50,7 +49,6 @@ export async function rebroadcastEvents(
|
||||
}
|
||||
|
||||
if (targetRelays.length === 0) {
|
||||
console.log('📡 No target relays for rebroadcast')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,7 +56,6 @@ export async function rebroadcastEvents(
|
||||
const rebroadcastPromises = events.map(async (event) => {
|
||||
try {
|
||||
await relayPool.publish(targetRelays, event)
|
||||
console.log('📡 Rebroadcast event', event.id?.slice(0, 8), 'to', targetRelays.length, 'relay(s)')
|
||||
} catch (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) => {
|
||||
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 disconnectedCount = statuses.filter(s => !s.isInPool).length
|
||||
if (connectedCount === 0 || disconnectedCount > 0) {
|
||||
console.log(`🔌 Relay status: ${connectedCount} connected, ${disconnectedCount} disconnected`)
|
||||
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(', '))
|
||||
// Debug: relay status changed, but we're not logging it
|
||||
}
|
||||
|
||||
// Add recently seen relays that are no longer connected
|
||||
|
||||
@@ -71,7 +71,6 @@ export async function loadSettings(
|
||||
pubkey: string,
|
||||
relays: string[]
|
||||
): 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
|
||||
try {
|
||||
@@ -80,7 +79,6 @@ export async function loadSettings(
|
||||
)
|
||||
if (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
|
||||
relayPool
|
||||
@@ -94,8 +92,8 @@ export async function loadSettings(
|
||||
|
||||
return content || null
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('📭 No cached settings found, fetching from relays...')
|
||||
} catch (_err) {
|
||||
// Ignore local store errors
|
||||
}
|
||||
|
||||
// If not in local store, fetch from relays
|
||||
@@ -127,10 +125,8 @@ export async function loadSettings(
|
||||
)
|
||||
if (event) {
|
||||
const content = getAppDataContent<UserSettings>(event)
|
||||
console.log('✅ Settings loaded from relays:', content)
|
||||
resolve(content || null)
|
||||
} else {
|
||||
console.log('📭 No settings event found - using defaults')
|
||||
resolve(null)
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -161,7 +157,6 @@ export async function saveSettings(
|
||||
factory: EventFactory,
|
||||
settings: UserSettings
|
||||
): Promise<void> {
|
||||
console.log('💾 Saving settings to nostr:', settings)
|
||||
|
||||
// Create NIP-78 application data event manually
|
||||
// Note: AppDataBlueprint is not available in the npm package
|
||||
@@ -177,7 +172,6 @@ export async function saveSettings(
|
||||
// Use unified write service
|
||||
await publishEvent(relayPool, eventStore, signed)
|
||||
|
||||
console.log('✅ Settings published successfully')
|
||||
}
|
||||
|
||||
export function watchSettings(
|
||||
|
||||
121
src/services/unarchiveService.ts
Normal file
121
src/services/unarchiveService.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { ARCHIVE_EMOJI, deleteReaction } from './reactionService'
|
||||
|
||||
/**
|
||||
* Returns the user's archive reactions (kind:7) for a given event id.
|
||||
*/
|
||||
export async function findArchiveReactionsForEvent(
|
||||
eventId: string,
|
||||
userPubkey: string,
|
||||
relayPool: RelayPool
|
||||
): Promise<NostrEvent[]> {
|
||||
try {
|
||||
const filter = {
|
||||
kinds: [7],
|
||||
authors: [userPubkey],
|
||||
'#e': [eventId]
|
||||
}
|
||||
|
||||
const events$ = relayPool
|
||||
.req(RELAYS, filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(2000)),
|
||||
toArray()
|
||||
)
|
||||
|
||||
const events: NostrEvent[] = await lastValueFrom(events$)
|
||||
return events.filter(evt => evt.content === ARCHIVE_EMOJI)
|
||||
} catch (error) {
|
||||
console.error('[unarchive] findArchiveReactionsForEvent error:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user's archive reactions (kind:17) for a given website URL.
|
||||
*/
|
||||
export async function findArchiveReactionsForWebsite(
|
||||
url: string,
|
||||
userPubkey: string,
|
||||
relayPool: RelayPool
|
||||
): Promise<NostrEvent[]> {
|
||||
try {
|
||||
// Normalize URL same as creation
|
||||
let normalizedUrl = url
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
parsed.hash = ''
|
||||
normalizedUrl = parsed.toString()
|
||||
if (normalizedUrl.endsWith('/')) normalizedUrl = normalizedUrl.slice(0, -1)
|
||||
} catch (e) {
|
||||
console.warn('[unarchive] URL normalize failed:', e)
|
||||
}
|
||||
|
||||
const filter = {
|
||||
kinds: [17],
|
||||
authors: [userPubkey],
|
||||
'#r': [normalizedUrl]
|
||||
}
|
||||
|
||||
const events$ = relayPool
|
||||
.req(RELAYS, filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(2000)),
|
||||
toArray()
|
||||
)
|
||||
|
||||
const events: NostrEvent[] = await lastValueFrom(events$)
|
||||
return events.filter(evt => evt.content === ARCHIVE_EMOJI)
|
||||
} catch (error) {
|
||||
console.error('[unarchive] findArchiveReactionsForWebsite error:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends deletion requests for all of the user's archive reactions to an event.
|
||||
* Returns the number of deletion requests published.
|
||||
*/
|
||||
export async function unarchiveEvent(
|
||||
eventId: string,
|
||||
account: IAccount,
|
||||
relayPool: RelayPool
|
||||
): Promise<number> {
|
||||
try {
|
||||
const reactions = await findArchiveReactionsForEvent(eventId, account.pubkey, relayPool)
|
||||
await Promise.all(reactions.map(r => deleteReaction(r.id, account, relayPool)))
|
||||
return reactions.length
|
||||
} catch (error) {
|
||||
console.error('[unarchive] unarchiveEvent error:', error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends deletion requests for all of the user's archive reactions to a website URL.
|
||||
* Returns the number of deletion requests published.
|
||||
*/
|
||||
export async function unarchiveWebsite(
|
||||
url: string,
|
||||
account: IAccount,
|
||||
relayPool: RelayPool
|
||||
): Promise<number> {
|
||||
try {
|
||||
const reactions = await findArchiveReactionsForWebsite(url, account.pubkey, relayPool)
|
||||
await Promise.all(reactions.map(r => deleteReaction(r.id, account, relayPool)))
|
||||
return reactions.length
|
||||
} catch (error) {
|
||||
console.error('[unarchive] unarchiveWebsite error:', error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,6 @@ export async function createWebBookmark(
|
||||
// Publish to relays in the background (don't block UI)
|
||||
relayPool.publish(relays, signedEvent)
|
||||
.then(() => {
|
||||
console.log('✅ Web bookmark published to', relays.length, 'relays:', signedEvent)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('⚠️ Some relays failed to publish bookmark:', err)
|
||||
|
||||
@@ -19,7 +19,6 @@ export async function publishEvent(
|
||||
|
||||
// Store the event in the local EventStore FIRST for immediate UI display
|
||||
eventStore.add(event)
|
||||
console.log(`${logPrefix} 💾 Stored event in EventStore:`, event.id.slice(0, 8), `(kind ${event.kind})`)
|
||||
|
||||
// Check current connection status - are we online or in flight mode?
|
||||
const connectedRelays = Array.from(relayPool.relays.values())
|
||||
@@ -35,14 +34,7 @@ export async function publishEvent(
|
||||
|
||||
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
|
||||
|
||||
console.log(`${logPrefix} 📍 Event relay status:`, {
|
||||
targetRelays: RELAYS.length,
|
||||
expectedSuccessRelays: expectedSuccessRelays.length,
|
||||
isLocalOnly,
|
||||
hasRemoteConnection,
|
||||
eventId: event.id.slice(0, 8),
|
||||
connectedRelays: connectedRelays.length
|
||||
})
|
||||
// Publishing event
|
||||
|
||||
// If we're in local-only mode, mark this event for later sync
|
||||
if (isLocalOnly) {
|
||||
@@ -50,10 +42,8 @@ export async function publishEvent(
|
||||
}
|
||||
|
||||
// Publish to all configured relays in the background (non-blocking)
|
||||
console.log(`${logPrefix} 📤 Publishing to relays:`, RELAYS)
|
||||
relayPool.publish(RELAYS, event)
|
||||
.then(() => {
|
||||
console.log(`${logPrefix} ✅ Event published to`, RELAYS.length, 'relay(s):', event.id.slice(0, 8))
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(`${logPrefix} ⚠️ Failed to publish event to relays (event still saved locally):`, error)
|
||||
|
||||
@@ -138,7 +138,6 @@ class WritingsController {
|
||||
|
||||
// Skip if already loaded for this pubkey (unless forced)
|
||||
if (!force && this.isLoadedFor(pubkey)) {
|
||||
console.log('[writings] ✅ Already loaded for', pubkey.slice(0, 8))
|
||||
this.emitWritings(this.currentPosts)
|
||||
return
|
||||
}
|
||||
@@ -148,7 +147,6 @@ class WritingsController {
|
||||
const currentGeneration = this.generation
|
||||
|
||||
this.setLoading(true)
|
||||
console.log('[writings] 🔍 Loading writings for', pubkey.slice(0, 8))
|
||||
|
||||
try {
|
||||
const seenIds = new Set<string>()
|
||||
@@ -162,7 +160,6 @@ class WritingsController {
|
||||
}
|
||||
if (lastSyncedAt) {
|
||||
filter.since = lastSyncedAt
|
||||
console.log('[writings] 📅 Incremental sync since', new Date(lastSyncedAt * 1000).toISOString())
|
||||
}
|
||||
|
||||
const events = await queryEvents(
|
||||
@@ -201,7 +198,6 @@ class WritingsController {
|
||||
|
||||
// Check if still active after async operation
|
||||
if (currentGeneration !== this.generation) {
|
||||
console.log('[writings] ⚠️ Load cancelled (generation mismatch)')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -231,7 +227,6 @@ class WritingsController {
|
||||
this.setLastSyncedAt(pubkey, newestTimestamp)
|
||||
}
|
||||
|
||||
console.log('[writings] ✅ Loaded', sorted.length, 'writings')
|
||||
} catch (error) {
|
||||
console.error('[writings] ❌ Failed to load writings:', error)
|
||||
this.currentPosts = []
|
||||
|
||||
@@ -22,7 +22,6 @@ export async function fetchBorisZappers(
|
||||
relayPool: RelayPool
|
||||
): Promise<ZapSender[]> {
|
||||
try {
|
||||
console.log('⚡ Fetching zap receipts for Boris...', BORIS_PUBKEY)
|
||||
|
||||
// Use all configured relays plus specific zap-heavy relays
|
||||
const zapRelays = [
|
||||
@@ -63,23 +62,18 @@ export async function fetchBorisZappers(
|
||||
merge(local$, remote$).pipe(toArray())
|
||||
)
|
||||
|
||||
console.log(`📊 Fetched ${zapReceipts.length} raw zap receipts`)
|
||||
|
||||
// Dedupe by event ID and validate
|
||||
const uniqueReceipts = new Map<string, NostrEvent>()
|
||||
let invalidCount = 0
|
||||
|
||||
zapReceipts.forEach(receipt => {
|
||||
if (!uniqueReceipts.has(receipt.id)) {
|
||||
if (isValidZap(receipt)) {
|
||||
uniqueReceipts.set(receipt.id, receipt)
|
||||
} else {
|
||||
invalidCount++
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`✅ ${uniqueReceipts.size} valid zap receipts (${invalidCount} invalid)`)
|
||||
|
||||
// Aggregate by sender using applesauce helpers
|
||||
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
|
||||
const zappers: ZapSender[] = Array.from(senderTotals.entries())
|
||||
@@ -115,7 +108,6 @@ export async function fetchBorisZappers(
|
||||
}))
|
||||
.sort((a, b) => b.totalSats - a.totalSats)
|
||||
|
||||
console.log(`✅ Found ${zappers.length} supporters (${zappers.filter(z => z.isWhale).length} whales)`)
|
||||
|
||||
return zappers
|
||||
} catch (error) {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
.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.bookmarks-compact { gap: 0.5rem; }
|
||||
.bookmarks-grid.bookmarks-compact { gap: 0.25rem; }
|
||||
.bookmarks-grid.bookmarks-large { gap: 1.5rem; }
|
||||
@media (max-width: 768px) {
|
||||
.bookmarks-grid { gap: 0.75rem; }
|
||||
@@ -44,9 +44,9 @@
|
||||
.individual-bookmark:hover { border-color: var(--color-border); background: var(--color-bg-elevated); }
|
||||
|
||||
/* 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; }
|
||||
.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 img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.compact-row.clickable { cursor: pointer; }
|
||||
|
||||
@@ -109,6 +109,16 @@
|
||||
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 {
|
||||
padding: 1rem;
|
||||
background: var(--color-bg);
|
||||
|
||||
@@ -14,6 +14,31 @@
|
||||
50% { opacity: 1; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
/* Subtle success burst used for archive action */
|
||||
@keyframes success-burst {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.45), 0 0 0 0 rgba(16, 185, 129, 0.25);
|
||||
transform: scale(1);
|
||||
}
|
||||
60% {
|
||||
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0), 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0), 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Apply archive animation when button enters animating state */
|
||||
.mark-as-read-btn.animating {
|
||||
animation: success-burst 600ms ease-out 1;
|
||||
}
|
||||
|
||||
.mark-as-read-btn.animating svg {
|
||||
animation: pulse 600ms ease-in-out 1;
|
||||
}
|
||||
|
||||
@keyframes highlight-pulse-animation {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2); transform: scale(1); }
|
||||
25% { box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6); transform: scale(1.02); }
|
||||
|
||||
@@ -22,7 +22,6 @@ cleanupOutdatedCaches()
|
||||
sw.skipWaiting()
|
||||
clientsClaim()
|
||||
|
||||
console.log('[SW] Boris service worker loaded')
|
||||
|
||||
// Runtime cache: Cross-origin images
|
||||
// This preserves the existing image caching behavior
|
||||
|
||||
@@ -16,18 +16,15 @@ const loadingFonts = new Map<string, Promise<void>>()
|
||||
|
||||
export async function loadFont(fontKey: string): Promise<void> {
|
||||
if (fontKey === 'system') {
|
||||
console.log('📝 Using system font')
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (loadedFonts.has(fontKey)) {
|
||||
console.log('✅ Font already loaded:', fontKey)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// If font is currently loading, return the existing promise
|
||||
if (loadingFonts.has(fontKey)) {
|
||||
console.log('⏳ Font already loading:', fontKey)
|
||||
return loadingFonts.get(fontKey)!
|
||||
}
|
||||
|
||||
@@ -37,7 +34,6 @@ export async function loadFont(fontKey: string): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
console.log('🔤 Loading font:', fontFamily)
|
||||
|
||||
// Create a promise for this font loading
|
||||
const loadPromise = new Promise<void>((resolve) => {
|
||||
@@ -48,7 +44,6 @@ export async function loadFont(fontKey: string): Promise<void> {
|
||||
|
||||
// Wait for the stylesheet to load
|
||||
link.onload = () => {
|
||||
console.log('📄 Stylesheet loaded for:', fontFamily)
|
||||
|
||||
// Use Font Loading API to wait for the actual font to be ready
|
||||
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(`700 16px "${fontFamily}"`)
|
||||
]).then(() => {
|
||||
console.log('✅ Font ready:', fontFamily)
|
||||
loadedFonts.add(fontKey)
|
||||
loadingFonts.delete(fontKey)
|
||||
resolve()
|
||||
@@ -69,7 +63,6 @@ export async function loadFont(fontKey: string): Promise<void> {
|
||||
} else {
|
||||
// Fallback: just wait a bit for older browsers
|
||||
setTimeout(() => {
|
||||
console.log('✅ Font assumed ready (no Font Loading API):', fontFamily)
|
||||
loadedFonts.add(fontKey)
|
||||
loadingFonts.delete(fontKey)
|
||||
resolve()
|
||||
|
||||
@@ -10,14 +10,9 @@ export function applyHighlightsToHTML(
|
||||
highlightStyle: 'marker' | 'underline' = 'marker'
|
||||
): string {
|
||||
if (!html || highlights.length === 0) {
|
||||
console.log('⚠️ applyHighlightsToHTML: No HTML or highlights', {
|
||||
htmlLength: html?.length,
|
||||
highlightsCount: highlights.length
|
||||
})
|
||||
return html
|
||||
}
|
||||
|
||||
console.log('🔨 applyHighlightsToHTML: Processing', highlights.length, 'highlights')
|
||||
|
||||
const tempDiv = document.createElement('div')
|
||||
tempDiv.innerHTML = html
|
||||
@@ -31,9 +26,6 @@ export function applyHighlightsToHTML(
|
||||
mark.parentNode?.replaceChild(textNode, mark)
|
||||
})
|
||||
|
||||
console.log('🧹 Removed', existingMarks.length, 'existing highlight marks')
|
||||
|
||||
let appliedCount = 0
|
||||
|
||||
for (const highlight of highlights) {
|
||||
const searchText = highlight.content.trim()
|
||||
@@ -42,7 +34,6 @@ export function applyHighlightsToHTML(
|
||||
continue
|
||||
}
|
||||
|
||||
console.log('🔍 Searching for highlight:', searchText.substring(0, 50) + '...')
|
||||
|
||||
// Collect all text nodes
|
||||
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
|
||||
@@ -50,21 +41,16 @@ export function applyHighlightsToHTML(
|
||||
let node: Node | null
|
||||
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
|
||||
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
|
||||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
|
||||
|
||||
if (found) {
|
||||
appliedCount++
|
||||
console.log('✅ Highlight applied successfully')
|
||||
} else {
|
||||
if (!found) {
|
||||
console.warn('❌ Could not find match for highlight:', searchText.substring(0, 50))
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎉 Applied', appliedCount, '/', highlights.length, 'highlights')
|
||||
|
||||
return tempDiv.innerHTML
|
||||
}
|
||||
|
||||
@@ -50,19 +50,23 @@ export function filterByReadingProgress(
|
||||
|
||||
return items.filter((item) => {
|
||||
const progress = item.readingProgress || 0
|
||||
const isMarked = item.markedAsRead || false
|
||||
// Reading progress filters MUST ignore emoji/archive reactions
|
||||
const hasHighlights = (articleHighlightCount.get(item.id) || 0) > 0 ||
|
||||
(item.url && (articleHighlightCount.get(item.url) || 0) > 0)
|
||||
|
||||
switch (filter) {
|
||||
case 'unopened':
|
||||
return progress === 0 && !isMarked
|
||||
return progress === 0
|
||||
case 'started':
|
||||
return progress > 0 && progress <= 0.10 && !isMarked
|
||||
return progress > 0 && progress <= 0.10
|
||||
case 'reading':
|
||||
return progress > 0.10 && progress <= 0.94 && !isMarked
|
||||
return progress > 0.10 && progress <= 0.94
|
||||
case 'completed':
|
||||
return progress >= 0.95 || isMarked
|
||||
// Completed is 95%+ progress only (no emoji fallback)
|
||||
return progress >= 0.95
|
||||
case 'archive':
|
||||
// Archive filter handled upstream; keep fallback as false to avoid mixing
|
||||
return false
|
||||
case 'highlighted':
|
||||
return hasHighlights
|
||||
case 'all':
|
||||
|
||||
@@ -43,8 +43,7 @@ export function applyTheme(
|
||||
root.classList.add(`light-${lightColorTheme}`)
|
||||
|
||||
// Listen for system theme changes
|
||||
mediaQueryListener = (e: MediaQueryListEvent) => {
|
||||
console.log('🎨 System theme changed to:', e.matches ? 'dark' : 'light')
|
||||
mediaQueryListener = () => {
|
||||
// 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 })
|
||||
}
|
||||
|
||||
@@ -11,26 +11,21 @@ export function normalizeUrl(url: string): string {
|
||||
|
||||
export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: string | undefined): Highlight[] {
|
||||
if (!selectedUrl || highlights.length === 0) {
|
||||
console.log('🔍 filterHighlightsByUrl: No URL or highlights', { selectedUrl, count: highlights.length })
|
||||
return []
|
||||
}
|
||||
|
||||
console.log('🔍 filterHighlightsByUrl:', { selectedUrl, totalHighlights: highlights.length })
|
||||
|
||||
// For Nostr articles, we already fetched highlights specifically for this article
|
||||
// So we don't need to filter them - they're all relevant
|
||||
if (selectedUrl.startsWith('nostr:')) {
|
||||
console.log('📌 Nostr article - returning all', highlights.length, 'highlights')
|
||||
return highlights
|
||||
}
|
||||
|
||||
// For web URLs, filter by URL matching
|
||||
const normalizedSelected = normalizeUrl(selectedUrl)
|
||||
console.log('🔗 Normalized selected URL:', normalizedSelected)
|
||||
|
||||
const filtered = highlights.filter(h => {
|
||||
if (!h.urlReference) {
|
||||
console.log('⚠️ Highlight has no urlReference:', h.id, 'eventReference:', h.eventReference)
|
||||
return false
|
||||
}
|
||||
const normalizedRef = normalizeUrl(h.urlReference)
|
||||
@@ -39,14 +34,13 @@ export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: stri
|
||||
normalizedRef.includes(normalizedSelected)
|
||||
|
||||
if (matches) {
|
||||
console.log('✅ URL match:', normalizedRef)
|
||||
// URLs match
|
||||
} else {
|
||||
console.log('❌ URL mismatch:', normalizedRef, 'vs', normalizedSelected)
|
||||
// URLs do not match
|
||||
}
|
||||
|
||||
return matches
|
||||
})
|
||||
|
||||
console.log('📊 Filtered to', filtered.length, 'highlights')
|
||||
return filtered
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user