Compare commits

..

170 Commits

Author SHA1 Message Date
Gigi
2c3aff0407 perf(bunker): make NIP-46 publish non-blocking at app wiring level; resolve immediately and let responses drive timing/results 2025-10-17 13:09:42 +02:00
Gigi
aad35d41db fix(debug): return benign object from fire-and-forget publish so timing UI remains stable 2025-10-17 13:06:17 +02:00
Gigi
cc6189a5d9 perf(bunker): fire-and-forget NIP-46 publish in app wrapper so UI isn’t blocked waiting on relay publish; encryption/decryption results now display immediately on /debug 2025-10-17 13:04:59 +02:00
Gigi
18bf8f9a2c ui(debug): use existing color pattern for red disconnect button with proper styling and hover effects 2025-10-17 12:56:47 +02:00
Gigi
37f3a32a1c fix(debug): use inline red styling for disconnect button since btn-danger class doesn't exist 2025-10-17 12:56:06 +02:00
Gigi
c9678564a5 ui(debug): change disconnect button to red (btn-danger) for better visual indication 2025-10-17 12:54:26 +02:00
Gigi
721c18c509 ui(debug): add Reset button to restore default payload text 2025-10-17 12:53:44 +02:00
Gigi
9e30fe683b ui(debug): left-align encrypt and decrypt buttons in both NIP-44 and NIP-04 sections 2025-10-17 12:53:20 +02:00
Gigi
7fff50c146 ui(debug): move Encrypt/Decrypt buttons above Encrypted text in both NIP-44 and NIP-04 sections 2025-10-17 12:52:40 +02:00
Gigi
fc1c845b67 ui(debug): change 'cipher' labels to 'Encrypted:' for better clarity 2025-10-17 12:52:12 +02:00
Gigi
c2ec1f3677 ui(debug): move Clear logs button below Show all checkbox 2025-10-17 12:51:37 +02:00
Gigi
0cbd357856 ui(debug): right-align all buttons using justify-end 2025-10-17 12:51:21 +02:00
Gigi
26ea9ed547 fix(lint): remove unused global variable declarations from Debug component 2025-10-17 12:50:49 +02:00
Gigi
9cbbecb32c ui(debug): increase debug logs height from max-h-96 to max-h-192 (2x taller) 2025-10-17 12:49:59 +02:00
Gigi
db12c89731 ui(debug): add character-wrap (break-all) to ciphertext textboxes 2025-10-17 12:49:28 +02:00
Gigi
6f413deb90 ui(debug): increase ciphertext textarea height to 5 lines (h-20) 2025-10-17 12:48:57 +02:00
Gigi
0127e2dc86 ui(debug): change page title from 'Bunker Debug' to 'Debug' 2025-10-17 12:48:25 +02:00
Gigi
7743928702 ui(debug): increase log area height from max-h-64 to max-h-96 (3x taller) 2025-10-17 12:48:01 +02:00
Gigi
bf76150fc1 ui(debug): show spinner in place of millisecond number during measurement 2025-10-17 12:47:36 +02:00
Gigi
c62107172b ui(debug): make ciphertext and plaintext fields multiline with proper whitespace handling 2025-10-17 12:47:13 +02:00
Gigi
a253587dfa ui(debug): add subtle background to payload textarea for better editability indication 2025-10-17 12:46:57 +02:00
Gigi
1938533d53 ui(debug): replace animated timing with spinner during measurement 2025-10-17 12:46:43 +02:00
Gigi
28943c55bd style(debug): update ciphertext and plaintext display to match logs textbox style 2025-10-17 12:46:21 +02:00
Gigi
791bbb68b6 fix(debug): implement proper stopwatch timing that counts up from 0ms in real-time 2025-10-17 12:44:29 +02:00
Gigi
ec8adcc794 refactor(debug): move plaintext display below buttons for better visual flow 2025-10-17 12:43:06 +02:00
Gigi
68058e7661 refactor(debug): move encrypt buttons next to decrypt buttons for better UX 2025-10-17 12:42:22 +02:00
Gigi
416c62369c refactor: extract VersionFooter component to eliminate duplication between debug and settings 2025-10-17 12:41:39 +02:00
Gigi
a19dd53423 feat(debug): add live performance timing with digital stopwatch display 2025-10-17 12:40:22 +02:00
Gigi
79ec33b79a style(debug): format NIP specifications as NIP-44 and NIP-04 2025-10-17 12:37:59 +02:00
Gigi
be881b957c feat(debug): update log description to 'Recent bunker logs:' 2025-10-17 12:36:50 +02:00
Gigi
244872e9f2 style(debug): move debug logs controls below the log output 2025-10-17 12:36:36 +02:00
Gigi
1397f7f0f4 style(debug): apply settings page styling structure and layout 2025-10-17 12:36:10 +02:00
Gigi
96424dd65c fix: resolve all linting issues - replace empty catch blocks and fix explicit any types 2025-10-17 12:33:53 +02:00
Gigi
9efc5459fb feat(debug): replace debug logs button with proper HTML checkbox element 2025-10-17 12:32:53 +02:00
Gigi
7e02168e54 feat(debug): make debug logs button show toggleable checkmark (✓/☐) 2025-10-17 12:32:29 +02:00
Gigi
f8e6b3e828 refactor(debug): move time measurements to dedicated Performance Timing section 2025-10-17 12:32:12 +02:00
Gigi
c06176bfc9 feat(debug): add bunker login section as first section of debug page 2025-10-17 12:31:31 +02:00
Gigi
e2a1701000 refactor(debug): move debug logs section to end with improved layout 2025-10-17 12:30:14 +02:00
Gigi
d7703ceef4 style(debug): use regular HTML checkmark instead of FontAwesome icon 2025-10-17 12:29:09 +02:00
Gigi
93daabc673 style(debug): improve cipher text wrapping with overflowWrap anywhere 2025-10-17 12:28:43 +02:00
Gigi
9264245944 style(debug): make Clear logs button a proper secondary button 2025-10-17 12:28:14 +02:00
Gigi
f56423040b feat(debug): add checkmark icon to debug logs button when enabled 2025-10-17 12:28:04 +02:00
Gigi
4b91504a50 feat(debug): clarify button text to 'Show all applesauce debug logs' 2025-10-17 12:27:45 +02:00
Gigi
1f0f7fef5e feat(debug): update title to 'Bunker Debug' for clarity 2025-10-17 12:27:25 +02:00
Gigi
6aced653fb feat(debug): add clock icon to time measurements for better visual clarity 2025-10-17 12:27:14 +02:00
Gigi
0899482869 style(debug): make Encrypt (nip04) and Clear buttons proper secondary buttons 2025-10-17 12:26:51 +02:00
Gigi
1bdfa1e6e1 style(debug): apply same max-width as reading view to debug page 2025-10-17 12:26:31 +02:00
Gigi
f22a8f15c0 style(debug): improve debug page styling and layout consistency 2025-10-17 12:22:31 +02:00
Gigi
bf6394fc7d feat(debug): add version and git commit footer to /debug page 2025-10-17 12:20:43 +02:00
Gigi
6f08586e8f feat(debug): improve layout/readability with sections, code boxes, and stats badges 2025-10-17 12:19:09 +02:00
Gigi
d60a4a24ad feat(debug): show encrypt/decrypt durations for nip04/nip44 on /debug page 2025-10-17 12:14:59 +02:00
Gigi
51069f3623 feat(debug): add debug toggle and clear logs; disable account queueing for nostr-connect 2025-10-17 12:12:25 +02:00
Gigi
1407af22e3 feat(debug): interactive /debug page (manual nip04/nip44 encrypt/decrypt, live logs); add DebugBus and wire signer logs 2025-10-17 10:50:20 +02:00
Gigi
ea6220277d feat(debug): add /debug page with NIP-46 encrypt→decrypt probes for nip04/nip44 2025-10-17 10:37:45 +02:00
Gigi
fbffa03dad docs(amber): summarize bunker decrypt investigation, evidence, and next steps 2025-10-17 09:48:11 +02:00
Gigi
a74760d804 chore(bunker): increase decrypt timeouts (probe 10s, bookmark decrypt 30s) 2025-10-17 09:36:13 +02:00
Gigi
c4b0a712d2 chore(bunker): log NIP-46 method from event content to debug decrypt calls 2025-10-17 09:34:31 +02:00
Gigi
1fecf9c7f4 fix(bunker): accept remote===pubkey for Amber; remove invalid-state warning 2025-10-17 01:26:32 +02:00
Gigi
7be21203d9 chore(types): cast through unknown for protected publish/subscription access in debug wrappers 2025-10-17 01:25:21 +02:00
Gigi
f65f2c6597 chore(lint): remove explicit any types, add deps for useEffect, and type relay logging 2025-10-17 01:24:41 +02:00
Gigi
227def4328 chore(lint): replace empty catch blocks with warnings; keep strict rules 2025-10-17 01:22:53 +02:00
Gigi
b506624f57 fix(bunker): use encrypt→decrypt roundtrip for nip44/nip04 probe to avoid false timeouts 2025-10-17 01:19:37 +02:00
Gigi
fbb6a0a153 fix(bunker): merge signer.relays with app RELAYS to include local Amber relays 2025-10-17 01:13:03 +02:00
Gigi
528de32689 fix(bunker): wire NostrConnectSigner to RelayPool publish/subscription statics for NIP-46 responses 2025-10-17 01:07:35 +02:00
Gigi
230e5380ca chore(bunker): expand debug logs for NIP-46 publish/subscribe (tags, content length) 2025-10-17 01:05:13 +02:00
Gigi
349237d097 fix(bunker): preserve signer context when wrapping publish/subscription for decrypt responses 2025-10-17 01:01:44 +02:00
Gigi
d4df9f0424 chore: commit pending changes to App and LoginOptions 2025-10-17 00:55:47 +02:00
Gigi
2f68e84002 debug(bunker): log NIP-46 request body preview (method, params, content slice)
- Helps align our request shape with Amber's expected BunkerRequest format
2025-10-17 00:53:58 +02:00
Gigi
b18dcc29cd revert: do not block when remote === user pubkey
- Amber may legally use user pubkey as remote id
- Remove validation and warning that caused false negatives
2025-10-17 00:45:39 +02:00
Gigi
680169e312 fix(bunker): validate bunker URI - remote must differ from user pubkey
- Prevents invalid state where Amber remote equals user pubkey
- Show actionable error to generate fresh connect link in Amber
2025-10-17 00:42:14 +02:00
Gigi
11753c4515 debug(bunker): add post-connect decrypt probe (nip04/nip44) with timeout
- Verifies Amber responds to NIP-46 decrypt after connect
- Logs probe results under [bunker]; non-blocking to UX
2025-10-17 00:29:52 +02:00
Gigi
bd29dfd65f chore(bunker): warn if remote pubkey equals user pubkey (invalid state)
- Add sanity check and toast guidance to reconnect via Amber
- Helps catch misconfigured bunker URIs that would never respond to requests
2025-10-17 00:26:54 +02:00
Gigi
4b1ae838e5 chore: add Amber to .gitignore 2025-10-17 00:23:58 +02:00
Gigi
85599d3103 fix(bunker): guarded connect with explicit permissions on restore
- Pass getDefaultBunkerPermissions() to connect() to ensure decrypt perms
- Keeps existing reconnection safeguards and logging
- Aims to make Amber accept decrypt requests after restore
2025-10-17 00:21:46 +02:00
Gigi
4603c5a258 fix(bunker): guarded connect after subscription to enable decrypt
- After opening subscription, call connect() once per session if remote is present
- Helps Amber authorize decrypt ops; safe-guarded and logged
- Keep isConnected=true for subsequent requireConnection() paths
2025-10-17 00:19:21 +02:00
Gigi
ec45fbc5e8 debug(bunker): log signer publish/subscribe calls and relay connectivity
- Wrap NostrConnectSigner publish/subscription to log relays and filters
- Log relayPool connectivity snapshot before bookmark decryption
- Helps diagnose decrypt requests not reaching Amber
2025-10-17 00:17:00 +02:00
Gigi
53400334b2 Revert "fix: skip bookmark decryption for bunker signers"
This reverts commit af4ff7081a.
2025-10-17 00:12:20 +02:00
Gigi
af4ff7081a fix: skip bookmark decryption for bunker signers
- Bunker (NIP-46) signers don't reliably support async decrypt operations
- Skip attempting to decrypt private bookmarks when using bunker
- Users can still see all public bookmarks
- Use extension signer for access to encrypted private bookmarks
- Prevents 15+ second hangs waiting for decrypt responses that won't come
2025-10-17 00:11:20 +02:00
Gigi
7f21b8ed76 fix: add startup delay to allow bunker subscription to fully establish
- Small 100ms delay after opening signer subscription
- Ensures the subscription is ready to receive decrypt responses
- May fix timeout issues with bunker decrypt operations
2025-10-17 00:09:27 +02:00
Gigi
55e44dcc9c debug: increase decrypt timeout to 15 seconds
- Give bunker operations more time to respond
- Will help determine if this is a timing issue or a fundamental limitation
- Still logging timeout errors for visibility
2025-10-17 00:05:53 +02:00
Gigi
59dac947ab fix: actually reorder bunker relay addition before signer recreation
- Previous commit had wrong message, code wasn't actually changed
- Now properly add relays to pool before creating NostrConnectSigner
- Ensures publishMethod/subscriptionMethod have full relay list available
2025-10-17 00:00:57 +02:00
Gigi
7d33c3c024 fix: add bunker relays to pool BEFORE recreating signer
- Bunker relays must be in pool when signer sets up publishMethod/subscriptionMethod
- Previously added after signer recreation, leaving pool incomplete
- This should fix decrypt operations that rely on publishMethod being set up correctly
- Same fix pattern as we used for signing
2025-10-16 23:59:14 +02:00
Gigi
38a014ef84 debug: verify subscriptionMethod and publishMethod on recreated signer
- Check if recreated NostrConnectSigner has methods needed for decrypt operations
- This will help identify if the issue is missing publishMethod for sending decrypt requests
- Or missing subscriptionMethod for receiving responses
2025-10-16 23:57:32 +02:00
Gigi
f451348430 debug: add logging to bookmark decrypt error handling
- Log nip04/nip44 decrypt errors instead of silently ignoring
- Will help identify why bookmark decryption is timing out with bunker
- Timeout errors will now be visible in console
2025-10-16 23:55:30 +02:00
Gigi
685aaf43b0 fix: add timeout to bookmark decryption to prevent hanging
- Wrap nip04/nip44 decrypt calls with 5 second timeout
- Prevents UI from hanging if decrypt request doesn't receive response
- Allows graceful degradation instead of infinite wait
- With bunker, decrypt responses may not arrive if perms/relay issues
2025-10-16 23:54:31 +02:00
Gigi
d6a20b5272 debug: add [bunker] prefix to bookmark decryption logging
- Better filtering of bunker-related logs
- Track when signer candidate is being selected
2025-10-16 23:50:16 +02:00
Gigi
d8d7a19fa1 fix: pass account.signer to EventFactory instead of full account
- EventFactory expects an EventSigner interface with signEvent method
- account.signer is the actual NostrConnectSigner instance
- Add debug logging to trace signer type
- This should fix signing hanging when using bunker
2025-10-16 23:46:25 +02:00
Gigi
63626fae3a fix: recreate NostrConnectSigner with pool on account restore
- Restored signers from JSON don't have pool context
- Recreate signer with pool passed explicitly to fix subscriptionMethod binding
- This ensures signing requests are properly sent/received through the pool
- Fixes hanging on signing after page reload
2025-10-16 23:44:43 +02:00
Gigi
de09ef2935 fix: avoid adding duplicate bunker relays to pool
- Only add bunker relays that aren't already in the pool
- Prevents duplicate subscriptions that could cause signing hangs
- Improves stability when account is reconnected
2025-10-16 23:43:03 +02:00
Gigi
bcb28a63a7 refactor: cleanup after bunker signing implementation
- Remove reconnectBunkerSigner function, inline logic into App.tsx for better control
- Clean up try-catch wrapper in highlightCreationService, signing now works reliably
- Remove extra logging from signing process (already has [bunker] prefix logs)
- Simplify nostrConnect.ts to just export permissions helper
- Update api/article-og.ts to use local relay config instead of import
- All bunker signing tests now passing 
2025-10-16 23:39:31 +02:00
Gigi
a479903ce3 debug: log signer state before signing 2025-10-16 23:34:59 +02:00
Gigi
567d105261 fix: restore isConnected = true so signing doesn't hang
- Without this, requireConnection() tries to connect() again
- That breaks the entire signing flow
- Mark signer as connected after opening subscription
2025-10-16 23:33:31 +02:00
Gigi
83743c5a9f fix: remove decrypt queue that was blocking highlight signing
- The global decrypt queue in bookmarkProcessing was getting stuck
- Caused all NIP-46 operations to hang indefinitely
- Decrypt already has per-call timeouts; queue was unnecessary
- Highlights should now sign immediately without waiting for bookmarks
2025-10-16 23:30:18 +02:00
Gigi
0b8f88ea1d revert(highlight): avoid pre-connect; rely on requireConnection during sign
- Remove manual connect/open in highlight flow
- Prevent side-effects that may interfere with pending requests
2025-10-16 23:28:06 +02:00
Gigi
fadc755930 fix(highlight): ensure NIP-46 signer is open/connected before signing
- Pre-open subscription and connect() if bunker signer present
- Restores reliable highlight signing with Amber (NIP-46)
2025-10-16 23:26:28 +02:00
Gigi
f67f171e64 fix(bookmarks): serialize decrypt/unlock NIP-46 operations
- Queue decrypt/unlock to avoid overlapping requests hanging the provider
- Keep timeouts and detailed [bunker] logs
- Should stop decrypt flood from blocking highlight signing
2025-10-16 23:21:52 +02:00
Gigi
449c59015e refactor(api): import RELAYS from central config to keep DRY
- Remove duplicated relay array from api/article-og.ts
- Import from src/config/relays.ts instead
2025-10-16 23:20:57 +02:00
Gigi
4d697e6a79 chore(relays): update RELAYS list (include relay.nsec.app early)
- Aligns app relay set with commonly used relays
- May improve connectivity and latency for NIP-46 roundtrips
2025-10-16 23:20:05 +02:00
Gigi
04ae70873a fix: restore direct pool bindings for NIP-46 methods
- Revert logging wrappers around subscription/publish
- Use pool.subscription.bind(pool) and pool.publish.bind(pool)
- Avoid any side effects interfering with signer requests
2025-10-16 23:18:37 +02:00
Gigi
2f8a64826a debug: restore [bunker] logs around highlight signing
- Log before/after factory.sign for highlights
- Surface errors to console for fast diagnosis
2025-10-16 23:16:59 +02:00
Gigi
11cb3542ee fix: revert forced connect on reconnection to restore signing
- Remove connect(undefined, permissions) on restore
- Let requireConnection() trigger connect per op
- Keeps highlights signing working as before while we debug decrypt
2025-10-16 23:11:08 +02:00
Gigi
905296621c fix: pass permissions on reconnect to ensure decrypt allowed
- Call signer.connect(undefined, permissions) when restoring account
- Ensures bunker re-grants decrypt (nip04/nip44) if needed
- Keeps implementation aligned with applesauce examples
2025-10-16 23:06:06 +02:00
Gigi
769484bc0d debug: log NIP-46 subscribe/publish traffic
- Wrap subscriptionMethod/publishMethod to log relays, filters, responses
- Helps confirm decrypt/sign requests are actually sent and on which relays
- Continue using applesauce-recommended binding pattern
2025-10-16 22:58:41 +02:00
Gigi
27ff4cef22 fix: properly connect NostrConnectSigner on reconnection
- Call signer.connect() instead of forcing isConnected
- Add [bunker] logs for connect lifecycle
- Should unblock nip44/nip04 decrypt calls that were timing out
2025-10-16 22:55:17 +02:00
Gigi
a352e2616e fix: prevent decrypt hangs with timeout + fallback
- Wrap nip44/nip04 decrypt and unlockHiddenTags in timeouts
- Fallback nip44->nip04 if nip44 hangs/fails
- Add detailed [bunker] logs for each stage
- Keeps UI responsive while debugging bunker responses
2025-10-16 22:51:58 +02:00
Gigi
77cbb9394f refactor: simplify bunker implementation following applesauce patterns
- Remove bunkerFixVersion migration logic
- Simplify account loading to match applesauce examples
- Simplify reconnectBunkerSigner (no waiting, no complex logging)
- Direct nip04/nip44 exposure from signer (like ExtensionAccount)
- Clean up bookmark service account checking
- Keep debug logs for now until verified working
2025-10-16 22:48:46 +02:00
Gigi
39c8b3dfe4 fix: auto-clear old bunker accounts that were created with wrong setup
- Old bunker accounts were created before proper method binding
- Add version check to clear nostr-connect accounts once
- Preserves extension accounts
- Users will need to reconnect bunker (one-time migration)
2025-10-16 22:45:56 +02:00
Gigi
7bd11e695e fix: use proper NostrConnectSigner setup per applesauce examples
- Was setting NostrConnectSigner.pool (wrong approach)
- Should set subscriptionMethod and publishMethod directly
- Follows the pattern from applesauce/packages/examples/src/examples/signers/bunker.tsx
- This is the correct way to wire up the signer with the relay pool
2025-10-16 22:44:56 +02:00
Gigi
a76b703d36 fix: cache wrapped nip04/nip44 objects instead of using getters
- Getters were returning new objects each time
- Code was getting reference then calling decrypt on it
- Now assign wrapped objects directly as properties
- This ensures our logging wrappers are actually used
2025-10-16 22:42:47 +02:00
Gigi
df51173405 debug: wrap nip04/nip44 methods with [bunker] logging
- Log when decrypt/encrypt methods are called
- Log when they complete or fail
- Show pubkey and ciphertext/plaintext lengths
- This will tell us if decrypt is hanging in the signer or never returning
2025-10-16 22:41:04 +02:00
Gigi
a79d7f9eaf debug: enable NostrConnectSigner logging to diagnose decrypt hang
- Add detailed logging for signer subscription opening
- Enable debug logs for NostrConnectSigner via localStorage
- This will show if requests are being sent and responses received
- Helps diagnose why decrypt requests hang indefinitely
2025-10-16 22:40:00 +02:00
Gigi
1032a46456 fix: wait for bunker relay connections before marking signer ready
- Decryption was hanging because relay connections weren't established
- NostrConnectSigner sends requests via relays but pool wasn't connected
- Now wait for at least one bunker relay to be connected (5s timeout)
- Prevents decrypt/sign requests from being sent to unconnected relays
- Adds detailed logging for connection status
2025-10-16 22:37:45 +02:00
Gigi
ae997758ab debug: add detailed [bunker] logs for bookmark decryption
- Log account properties and nip04/nip44 availability
- Log signer fallback logic
- Log each decryption attempt (nip44 and nip04)
- Log success/failure for hidden tags and content decryption
- Helps diagnose why bunker decryption isn't working
2025-10-16 22:36:00 +02:00
Gigi
91a827324d fix: expose nip04/nip44 on NostrConnectAccount for bookmark decryption
- NostrConnectSigner has nip04/nip44 but not exposed at account level
- ExtensionAccount exposes these via getters, NostrConnectAccount didn't
- Add properties dynamically during reconnection for compatibility
- Enables private bookmark decryption with bunker accounts
2025-10-16 22:34:18 +02:00
Gigi
bf849c9faa refactor: clean up bunker implementation for better maintainability
- Extract reconnectBunkerSigner into reusable helper function
- Reduce excessive debug logging in App.tsx (90+ lines → 30 lines)
- Simplify account restoration logic with cleaner conditionals
- Remove verbose signing logs from highlightCreationService
- Keep only essential error logs for debugging
- Follows DRY principles and applesauce patterns
2025-10-16 22:32:06 +02:00
Gigi
118ab46ac0 fix: add bunker relays to relay pool for signing requests
- NostrConnectSigner uses its own relay list for signing requests
- Pool must be connected to bunker relays to send/receive requests
- Add bunker relays to pool when reconnecting after page load
- This fixes signing hanging indefinitely
2025-10-16 22:28:54 +02:00
Gigi
d2f2b689f9 fix: create and setup pool BEFORE loading accounts from localStorage
- NostrConnectAccount.fromJSON needs NostrConnectSigner.pool to be set
- Move pool creation and setup before accounts.fromJSON()
- This fixes 'Missing subscriptionMethod' error on page reload
- Now bunker accounts can be properly restored from localStorage
2025-10-16 22:25:15 +02:00
Gigi
5229e45566 fix: remove unused getDefaultBunkerPermissions import from App.tsx
- Import was no longer needed after removing connect() call
- Fixes eslint no-unused-vars error
- All linter and type checks now pass
2025-10-16 22:22:16 +02:00
Gigi
b17043e85d debug: add detailed logging for account restoration from localStorage
- Log raw accounts JSON from localStorage
- Log parsed account count and types
- Log active ID lookup and restoration steps
- This will help diagnose why accounts aren't persisting across refresh
2025-10-16 22:21:05 +02:00
Gigi
19ca909ef5 fix: setup pool and relays BEFORE bunker reconnection subscription
- Move NostrConnectSigner.pool assignment before active account subscription
- Move pool.group(RELAYS) before subscription
- This ensures pool is ready when bunker signer tries to send requests
- The subscription can fire immediately, so pool must be configured first
- Add log to confirm pool assignment
2025-10-16 22:17:48 +02:00
Gigi
f7ff309b6e fix: set isConnected=true after opening restored bunker signer
- After page reload, signer is restored with isConnected=false
- When signing, requireConnection() would call connect() again without permissions
- Now we set isConnected=true after open() to prevent re-connection
- The bunker remembers permissions from initial connection
- This ensures signing works after page refresh
2025-10-16 22:16:06 +02:00
Gigi
ea5a8486b9 fix: don't call connect() again on restored bunker signer
- fromBunkerURI() already calls connect() with permissions during login
- Calling connect() again breaks the connection state
- Just call open() to ensure subscription is active
- This matches the pattern in applesauce examples which don't reconnect
- Log final signer status including relays for debugging
2025-10-16 22:15:02 +02:00
Gigi
58897b3436 fix: prevent double reconnection and add status checks after connect
- Track reconnected accounts to avoid double-connecting
- Log signer status after open() and connect() to verify state
- This should prevent the double reconnection issue
- Will help diagnose if connection is being lost immediately
2025-10-16 22:14:12 +02:00
Gigi
6a59ecfa47 debug: prefix all bunker logs with [bunker] for easy filtering
- Update App.tsx reconnection logs
- Update highlightCreationService signing logs
- Update LoginOptions error logs
- Makes it easy to filter console with 'bunker' keyword
2025-10-16 22:12:56 +02:00
Gigi
272066c6e0 debug: add comprehensive logging for bunker reconnection and signing
- Add detailed logs for active account changes and bunker detection
- Log signer status (listening, isConnected, hasRemote)
- Log each step of reconnection process
- Add signing attempt logs in highlightCreationService
- This will help diagnose where the signing process hangs
2025-10-16 22:08:14 +02:00
Gigi
0426c9d3b0 fix: correct Accounts import in App.tsx
- Import Accounts from 'applesauce-accounts' instead of 'applesauce-accounts/accounts'
- Fixes TypeScript error TS2305
- All linter and type checks now pass
2025-10-16 21:58:08 +02:00
Gigi
c22419ba0e fix: ensure bunker signer reconnects with permissions on app restore
- Create centralized getDefaultBunkerPermissions() in nostrConnect service
- Update LoginOptions to use centralized permissions
- Add bunker reconnection logic in App.tsx on active account change
- Reconnect bunker signer with open() and connect() when restored from localStorage
- Surface permission errors to users via toast in useHighlightCreation
- Ensures highlights, reactions, settings, and bookmarks work after page reload with bunker
2025-10-16 21:56:31 +02:00
Gigi
8278fed2fb fix: request NIP-46 permissions for bunker signing
- Add explicit signing permissions for event kinds: 5, 7, 17, 9802, 30078, 39701, 0
- Add encryption/decryption permissions: nip04_encrypt/decrypt, nip44_encrypt/decrypt
- Improve error messages when bunker permissions are missing or denied
- Add debug logging hint for bunker permission issues in write service
- This ensures highlights, reactions, settings, reading positions, and web bookmarks all work with bunker
2025-10-16 21:47:59 +02:00
Gigi
b24a65b490 feat: add Login with Bunker authentication option
- Wire NostrConnectSigner to RelayPool in App.tsx
- Create LoginOptions component with Extension and Bunker login flows
- Show LoginOptions in BookmarkList when user is logged out
- Add applesauce-accounts and applesauce-signers to vite optimizeDeps
- Support NIP-46 bunker:// URI authentication alongside extension login
2025-10-16 21:17:34 +02:00
Gigi
fb509fabd8 style(settings): add proper spacing around middot separator between version and commit 2025-10-16 20:59:27 +02:00
Gigi
d21285123f feat(settings): separate version and commit links - version links to release, commit links to commit 2025-10-16 20:59:09 +02:00
Gigi
1029b6be0c feat(settings): link version to GitHub release page instead of commit 2025-10-16 20:57:57 +02:00
Gigi
3fff9455a1 docs: update CHANGELOG.md for v0.6.24 2025-10-16 20:00:22 +02:00
Gigi
8c6232e029 chore(release): bump version to 0.6.24 2025-10-16 19:59:48 +02:00
Gigi
f6c562e9be fix(types): add global declarations for build-time defines and fix eslint issues 2025-10-16 19:58:57 +02:00
Gigi
a92b14e877 docs: update CHANGELOG.md for v0.6.23 2025-10-16 19:57:11 +02:00
Gigi
b69a956247 chore(release): bump version to 0.6.23 2025-10-16 19:54:35 +02:00
Gigi
82a8dcf6eb chore(settings): link short commit hash to GitHub and remove timestamp/branch 2025-10-16 19:35:20 +02:00
Gigi
8e19e22289 feat(settings): display app version and git commit in settings footer 2025-10-16 19:32:18 +02:00
Gigi
e167b57810 fix(api): align article-og relay usage to RelayPool.request and remove open/close 2025-10-16 19:20:54 +02:00
Gigi
ba3b82e6b5 chore(app): add RouteDebug gated by ?debug=1 to log route state 2025-10-16 19:19:33 +02:00
Gigi
b5edfbb2c9 chore(api): add structured debug logs to article-og handler with ?debug=1 2025-10-16 19:17:12 +02:00
Gigi
48048f877a fix(vercel): limit /a/:naddr rewrite to bots 2025-10-16 19:16:29 +02:00
Gigi
bd1afc54c3 docs: update CHANGELOG.md for v0.6.22 2025-10-16 16:02:02 +02:00
Gigi
a2c4bed0f5 chore: bump version to 0.6.22 2025-10-16 16:01:19 +02:00
Gigi
9bad49fe5f feat(vercel): add rewrite rule for article OG endpoint
Route /a/:naddr requests to /api/article-og for dynamic social preview tags.
2025-10-16 16:00:36 +02:00
Gigi
2aa6536496 Merge pull request #17 from dergigi/social-preview
Add dynamic social preview for article deep-links
2025-10-16 15:58:52 +02:00
Gigi
bd6d8a0342 chore(api): remove debug logging from article-og endpoint 2025-10-16 15:50:00 +02:00
Gigi
dc8e86bc57 fix(api): use history.replaceState before redirecting to SPA
Set the browser history to /a/{naddr} before redirecting to /, so when the SPA loads it sees the correct URL path.
2025-10-16 15:41:22 +02:00
Gigi
32b843908e debug: add logging and debug endpoint to article-og
Add console logging for debugging and ?debug=1 query param to see request details in browser.
2025-10-16 15:34:50 +02:00
Gigi
5a71480459 fix(api): add base tag for proper asset loading
Use named parameter syntax in Vercel rewrite and add <base href="/"> tag to ensure assets load correctly from root when serving index.html through the API.
2025-10-16 15:27:13 +02:00
Gigi
17455aa47b fix(api): serve index.html to browsers with preserved URL
Instead of redirecting, serve the static index.html file directly. The Vercel rewrite preserves the /a/{naddr} URL, allowing client-side SPA routing to work correctly.
2025-10-16 15:20:10 +02:00
Gigi
4cc32c27de fix(api): detect crawlers and redirect browsers to SPA
Browsers get 302 redirect to / where the SPA handles routing client-side with the original /a/{naddr} URL preserved. Crawlers/bots get the full HTML with OG meta tags.
2025-10-16 14:43:29 +02:00
Gigi
99bfe209a5 fix(api): use meta refresh instead of SPA boot in OG endpoint
Browsers will immediately redirect to / and load the SPA client-side, while crawlers/bots ignore meta refresh and only see the OG meta tags.
2025-10-16 14:38:17 +02:00
Gigi
0a28bfbd50 fix(api): replace any type with Filter from nostr-tools 2025-10-16 14:32:35 +02:00
Gigi
ba9fb109f6 refactor(api): DRY improvements for article OG endpoint
- Extract fetchEventsFromRelays helper to eliminate duplication
- Add setCacheHeaders helper for consistent header setting
- Parallelize article and profile fetching for faster response
- Move relayPool.close() to finally block to prevent leaks
- Remove redundant cacheKey variable and sorting
2025-10-16 14:31:39 +02:00
Gigi
ec9d2fcb49 chore(meta): add social preview image to homepage OG tags 2025-10-16 14:23:44 +02:00
Gigi
f841043e03 chore(assets): add default social preview image (1200x630) 2025-10-16 14:22:04 +02:00
Gigi
94dc95e1f0 feat(api): dynamic OG HTML for /a/{naddr} using relay metadata 2025-10-16 14:21:49 +02:00
Gigi
32a5145d8f chore(vercel): route /a/* to article OG handler 2025-10-16 14:20:58 +02:00
Gigi
a856e8ca26 docs: update CHANGELOG.md for v0.6.21 2025-10-16 09:57:13 +02:00
Gigi
d54306cf92 chore: bump version to 0.6.21 2025-10-16 09:56:06 +02:00
Gigi
9fdb96b64e Merge pull request #16 from dergigi/reading-progress-filters-part-two
feat: add reading progress filters and reads/links tabs
2025-10-16 09:55:32 +02:00
Gigi
c50aa3a243 fix: resolve TypeScript errors from merge
- Remove unused readingPositions and markedAsReadIds from useBookmarksData
- Remove eventStore parameter from useBookmarksData call
- Add reads and links fields to MeCache interface
2025-10-16 09:53:20 +02:00
Gigi
adef1a922c chore: remove completed plan file 2025-10-16 09:49:43 +02:00
Gigi
99df4d6761 chore: merge master into reading-progress-filters-part-two
Resolved conflicts by keeping feature branch changes:
- Kept /me/reads and /me/links routes (not /me/archive)
- Kept ReadingProgressFilters component and readingProgressUtils
- Kept readsService, linksService, and readingDataProcessor
- Restored files that were renamed/deleted in master
2025-10-16 09:49:13 +02:00
Gigi
cf2098a723 Merge pull request #15 from dergigi/revert-14-reading-progress-filters
Revert "Add reading progress filters and split Reads/Links tabs"
2025-10-16 08:06:06 +02:00
Gigi
5568437663 Revert "Add reading progress filters and split Reads/Links tabs" 2025-10-16 08:05:20 +02:00
Gigi
7bfd7fdf6c Merge pull request #14 from dergigi/reading-progress-filters
Add reading progress filters and split Reads/Links tabs
2025-10-16 01:46:32 +02:00
Gigi
85649ae283 Merge pull request #13 from dergigi/sync-reading-position
Add reading position sync and archive enhancements
2025-10-15 22:45:13 +02:00
38 changed files with 1756 additions and 625 deletions

View File

@@ -1,136 +0,0 @@
<!-- 658dc3b5-4b0b-4d30-8cfa-a9326f1d467e f1d78d5b-786d-4658-ae4b-56278aba318e -->
# Lazy Load Me Component Tabs
## Overview
Currently, the Me component loads all data for all tabs upfront, causing 30+ second load times even when viewing a single tab. This plan implements lazy loading where only the active tab's data is fetched on demand.
## Implementation Strategy
Based on user requirements:
- Load only the active tab's data (pure lazy loading)
- No background prefetching
- Show cached data immediately, refresh in background when revisiting tabs
- Works for both `/me` (own profile) and `/p/` (other profiles) using the same code
## Key Insight
The Me component already handles both own profile and other profiles via the `isOwnProfile` flag. The lazy loading will naturally work for both cases:
- Own profile (`/me`): Loads all tabs including private data (bookmarks, reads)
- Other profiles (`/p/npub...`): Only loads public tabs (highlights, writings)
## Changes Required
### 1. Update Me.tsx Loading Logic
**Current behavior**: Single `useEffect` loads all data (highlights, writings, bookmarks, reads) regardless of active tab.
**New behavior**:
- Create separate loading functions per tab
- Load only active tab's data on mount and tab switches
- Show cached data immediately if available
- Refresh cached data in background when tab is revisited
**Key changes**:
- Remove the monolithic `loadData()` function
- Add `loadedTabs` state to track which tabs have been fetched
- Create tab-specific loaders: `loadHighlights()`, `loadWritings()`, `loadBookmarks()`, `loadReads()`
- Add `useEffect` that watches `activeTab` and loads data for current tab only
- Check cache first, display cached data, then refresh in background
**Code location**: Lines 64-123 in `src/components/Me.tsx`
### 2. Per-Tab Loading State
Add tab-specific loading tracking:
```typescript
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
```
This prevents unnecessary reloads and allows showing cached data instantly.
### 3. Tab-Specific Load Functions
Create individual functions:
- `loadHighlightsTab()` - fetch highlights
- `loadWritingsTab()` - fetch writings
- `loadReadingListTab()` - fetch bookmarks
- `loadReadsTab()` - fetch bookmarks first, then reads
Each function:
1. Checks cache, displays if available
2. Sets loading state
3. Fetches fresh data
4. Updates state and cache
5. Marks tab as loaded
### 4. Tab Switch Effect
Replace the current useEffect with:
```typescript
useEffect(() => {
if (!activeTab || !viewingPubkey) return
// Check if we have cached data
const cached = getCachedMeData(viewingPubkey)
if (cached) {
// Show cached data immediately
setHighlights(cached.highlights)
setBookmarks(cached.bookmarks)
setReads(cached.reads)
// Continue to refresh in background
}
// Load data for active tab
switch (activeTab) {
case 'highlights':
loadHighlightsTab()
break
case 'writings':
loadWritingsTab()
break
case 'reading-list':
loadReadingListTab()
break
case 'reads':
loadReadsTab()
break
}
}, [activeTab, viewingPubkey, refreshTrigger])
```
### 5. Handle Pull-to-Refresh
Update pull-to-refresh logic to only reload the active tab instead of all tabs.
## Benefits
- Initial load: ~2-5s instead of 30+ seconds (only loads one tab)
- Tab switching: Instant with cached data, refreshes in background
- Network efficiency: Only fetches what the user views
- Better UX: Users see content immediately from cache
## Testing Checklist
- Verify each tab loads independently
- Confirm cached data shows immediately on tab switch
- Ensure background refresh works without flickering
- Test pull-to-refresh only reloads active tab
- Verify loading states per tab work correctly
### To-dos
- [ ] Create src/services/readsService.ts with fetchAllReads function
- [ ] Update Me.tsx to use reads instead of archive
- [ ] Update routes from /me/archive to /me/reads
- [ ] Update meCache.ts to use reads field
- [ ] Update filter logic to handle actual reading progress
- [ ] Test all 5 filters and data sources work correctly

1
.gitignore vendored
View File

@@ -11,4 +11,5 @@ dist
# Reference Projects
applesauce
primal-web-app
Amber

77
Amber.md Normal file
View File

@@ -0,0 +1,77 @@
## Boris ↔ Amber bunker: current findings
- **Environment**
- Client: Boris (web) using `applesauce` stack (`NostrConnectSigner`, `RelayPool`).
- Bunker: Amber (mobile).
- We restored a `nostr-connect` account from localStorage and re-wired the signer to the app `RelayPool` before use.
## What we changed client-side
- **Signer wiring**
- Bound `NostrConnectSigner.subscriptionMethod/publishMethod` to the app `RelayPool` at startup.
- After deserialization, recreated the signer with pool context and merged its relays with app `RELAYS` (includes local relays).
- Opened the signer subscription and performed a guarded `connect()` with default permissions including `nip04_encrypt/decrypt` and `nip44_encrypt/decrypt`.
- **Probes and timeouts**
- Initial probe tried `decrypt('invalid-ciphertext')` → timed out.
- Switched to roundtrip probes: `encrypt(self, ... )` then `decrypt(self, cipher)` for both nip-44 and nip-04.
- Increased probe timeout from 3s → 10s; increased bookmark decrypt timeout from 15s → 30s.
- **Logging**
- Added logs for publish/subscribe and parsed the NIP-46 request content length.
- Confirmed NIP46 request events are kind `24133` with a single `p` tag (expected). The method is inside the encrypted content, so it prints as `method: undefined` (expected).
## Evidence from logs (client)
```
[bunker] ✅ Wired NostrConnectSigner to RelayPool publish/subscription
[bunker] 🔗 Signer relays merged with app RELAYS: (19) [...]
[bunker] subscribe via signer: { relays: [...], filters: [...] }
[bunker] ✅ Signer subscription opened
[bunker] publish via signer: { relays: [...], kind: 24133, tags: [['p', <remote>]], contentLength: 260|304|54704 }
[bunker] 🔎 Probe nip44 roundtrip (encrypt→decrypt)… → probe timeout after 10000ms
[bunker] 🔎 Probe nip04 roundtrip (encrypt→decrypt)… → probe timeout after 10000ms
bookmarkProcessing.ts: ❌ nip44.decrypt failed: Decrypt timeout after 30000ms
bookmarkProcessing.ts: ❌ nip04.decrypt failed: Decrypt timeout after 30000ms
```
Notes:
- Final signer status shows `listening: true`, `isConnected: true`, and requests are published to 19 relays (includes Ambers).
## Evidence from Amber (device)
- Activity screen shows multiple entries for: “Encrypt data using nip 4” and “Encrypt data using nip 44” with green checkmarks.
- No entries for “Decrypt data using nip 4” or “Decrypt data using nip 44”.
## Interpretation
- Transport and publish paths are working: Boris is publishing NIP46 requests (kind 24133) and Amber receives them (ENCRYPT activity visible).
- The persistent failure is specific to DECRYPT handling: Amber does not show any DECRYPT activity and Boris receives no decrypt responses within 1030s windows.
- Client-side wiring is likely correct (subscription open, permissions requested, relays merged). The remaining issue appears provider-side in Ambers NIP46 decrypt handling or permission gating.
## Repro steps (quick)
1) Revoke Boris in Amber.
2) Reconnect with a fresh bunker URI; approve signing and both encrypt/decrypt scopes for nip04 and nip44.
3) Keep Amber unlocked and foregrounded.
4) Reload Boris; observe:
- Logs showing `publish via signer` for kind 24133.
- In Amber, activity should include “Decrypt data using nip 4/44”.
If DECRYPT entries still dont appear:
- This points to Ambers NIP46 provider not executing/authorizing `nip04_decrypt`/`nip44_decrypt` methods, or not publishing responses.
## Suggestions for Amber-side debugging
- Verify permission gating allows `nip04_decrypt` and `nip44_decrypt` (not just encrypt).
- Confirm the provider recognizes NIP46 methods `nip04_decrypt` and `nip44_decrypt` in the decrypted payload and routes them to decrypt routines.
- Ensure the response event is published back to the same relays and correctly addressed to the client (`p` tag set and content encrypted back to client pubkey).
- Add activity logging for “Decrypt …” attempts and failures to surface denial/exception states.
## Current conclusion
- Client is configured and publishing requests correctly; encryption proves endtoend path is alive.
- The missing DECRYPT activity in Amber is the blocker. Fixing Ambers NIP46 decrypt handling should resolve bookmark decryption in Boris without further client changes.

View File

@@ -7,6 +7,125 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.24] - 2025-01-16
### Fixed
- TypeScript global declarations for build-time defines
- Added proper type declarations for `__APP_VERSION__`, `__GIT_COMMIT__`, `__GIT_BRANCH__`, `__BUILD_TIME__`, and `__GIT_COMMIT_URL__`
- Resolved ESLint no-undef errors for build-time injected variables
- Added Node.js environment hint to Vite configuration
## [0.6.23] - 2025-01-16
### Fixed
- Deep-link refresh redirect issue for nostr-native articles
- Limited `/a/:naddr` rewrite to bot user-agents only in Vercel configuration
- Real browsers now hit the SPA directly, preventing redirect to root path
- Bot crawlers still receive proper OpenGraph metadata for social sharing
### Added
- Version and git commit information in Settings footer
- Displays app version and short commit hash with link to GitHub
- Build-time metadata injection via Vite configuration
- Subtle footer styling with selectable text
### Changed
- Article OG handler now uses proper RelayPool.request() API
- Aligned with applesauce RelayPool interface
- Removed deprecated open/close methods
- Fixed TypeScript linting errors
### Technical
- Added debug logging for route state and article OG handler
- Gated by `?debug=1` query parameter for production testing
- Structured logging for troubleshooting deep-link issues
- Temporary debug components for validation
## [0.6.22] - 2025-10-16
### Added
- Dynamic OpenGraph and Twitter Card meta tags for article deep-links
- Social media platforms display article title, author, cover image, and summary when sharing `/a/{naddr}` links
- Serverless endpoint fetches article metadata from Nostr relays (kind:30023) and author profiles (kind:0)
- User-agent detection serves appropriate content to crawlers vs browsers
- Falls back to default social preview image when articles have no cover image
- Social preview image for homepage and article links
- Added `boris-social-1200.png` as default OpenGraph image (1200x630)
- Homepage now includes social preview image in meta tags
### Changed
- Article deep-links now properly preserve URL when loading in browser
- Uses `history.replaceState()` to maintain correct article path
- Browser navigation works correctly on refresh and new tab opens
### Fixed
- Vercel rewrite configuration for article routes
- Routes `/a/:naddr` to serverless OG endpoint for dynamic meta tags
- Regular SPA routing preserved for browser navigation
## [0.6.21] - 2025-10-16
### Added
- Reading position sync across devices using Nostr Kind 30078 (NIP-78)
- Automatically saves and syncs reading position as you scroll
- Visual reading progress indicator on article cards
- Reading progress shown in Explore and Bookmarks sidebar
- Auto-scroll to last reading position setting (configurable in Settings)
- Reading position displayed as colored progress bar on cards
- Reading progress filters for organizing articles
- Filter by reading state: Unopened, Started (0-10%), Reading (11-94%), Completed (95-100% or marked as read)
- Filter icons colored when active (blue for most, green for completed)
- URL routing support for reading progress filters
- Reading progress filters available in Archive tab and bookmarks sidebar
- Reads and Links tabs on `/me` page
- Reads tab shows nostr-native articles with reading progress
- Links tab shows external URLs with reading progress
- Both tabs populate instantly from bookmarks for fast loading
- Lazy loading for improved performance
- Auto-mark as read at 100% reading progress
- Articles automatically marked as read when scrolled to end
- Marked-as-read articles treated as 100% progress
- Fancy checkmark animation on Mark as Read button
- Click-to-open article navigation on highlights
- Clicking highlights in Explore and Me pages opens the source article
- Automatically scrolls to highlighted text position
### Changed
- Renamed Archive to Reads with expanded functionality
- Merged 'Completed' and 'Marked as Read' filters into one unified filter
- Simplified filter icon colors to blue (except green for completed)
- Started reading progress state (0-10%) uses neutral text color
- Replace spinners with skeleton placeholders during refresh in Archive/Reads/Links tabs
- Removed unused IEventStore import in ContentPanel
### Fixed
- Reading position calculation now accurately reaches 100%
- Reading position filters work correctly in bookmarks sidebar
- Filter out reads without timestamps or 'Untitled' items
- Show skeleton placeholders correctly during initial tab load
- External URLs in Reads tab only shown if they have reading progress
- Reading progress merges even when timestamp is older than bookmark
- Resolved all linter errors and TypeScript type issues
### Refactored
- Renamed ArchiveFilters component to ReadingProgressFilters
- Extracted shared utilities from readsFromBookmarks for DRY code
- Use setState callback pattern for background enrichment
- Use naddr format for article IDs to match reading positions
- Extract article titles, images, summaries from bookmark tags using applesauce helpers
## [0.6.20] - 2025-10-15
### Added
@@ -1641,7 +1760,10 @@ 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.6.20...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.24...HEAD
[0.6.24]: https://github.com/dergigi/boris/compare/v0.6.23...v0.6.24
[0.6.23]: https://github.com/dergigi/boris/compare/v0.6.22...v0.6.23
[0.6.21]: https://github.com/dergigi/boris/compare/v0.6.20...v0.6.21
[0.6.20]: https://github.com/dergigi/boris/compare/v0.6.19...v0.6.20
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18

304
api/article-og.ts Normal file
View File

@@ -0,0 +1,304 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent, Filter } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
// Relay configuration (from src/config/relays.ts)
const RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'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'
]
type CacheEntry = {
html: string
expires: number
}
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
const memoryCache = new Map<string, CacheEntry>()
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
res.setHeader('Content-Type', 'text/html; charset=utf-8')
}
interface ArticleMetadata {
title: string
summary: string
image: string
author: string
published?: number
}
async function fetchEventsFromRelays(
relayPool: RelayPool,
relayUrls: string[],
filter: Filter,
timeoutMs: number
): Promise<NostrEvent[]> {
const events: NostrEvent[] = []
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => resolve(), timeoutMs)
// `request` emits NostrEvent objects directly
relayPool.request(relayUrls, filter).subscribe({
next: (event) => {
events.push(event)
},
error: () => resolve(),
complete: () => {
clearTimeout(timeout)
resolve()
}
})
})
// Sort by created_at and return most recent first
return events.sort((a, b) => b.created_at - a.created_at)
}
async function fetchArticleMetadata(naddr: string): Promise<ArticleMetadata | null> {
const relayPool = new RelayPool()
try {
// Decode naddr
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
return null
}
const pointer = decoded.data as AddressPointer
// Determine relay URLs
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
// Fetch article and profile in parallel
const [articleEvents, profileEvents] = await Promise.all([
fetchEventsFromRelays(relayPool, relayUrls, {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier || '']
}, 5000),
fetchEventsFromRelays(relayPool, relayUrls, {
kinds: [0],
authors: [pointer.pubkey]
}, 3000)
])
if (articleEvents.length === 0) {
return null
}
const article = articleEvents[0]
// Extract article metadata
const title = getArticleTitle(article) || 'Untitled Article'
const summary = getArticleSummary(article) || 'Read this article on Boris'
const image = getArticleImage(article) || '/boris-social-1200.png'
// Extract author name from profile
let authorName = pointer.pubkey.slice(0, 8) + '...'
if (profileEvents.length > 0) {
try {
const profileData = JSON.parse(profileEvents[0].content)
authorName = profileData.display_name || profileData.name || authorName
} catch {
// Use fallback
}
}
return {
title,
summary,
image,
author: authorName,
published: article.created_at
}
} catch (err) {
console.error('Failed to fetch article metadata:', err)
return null
} finally {
// No explicit close needed; pool manages connections internally
}
}
function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
const baseUrl = 'https://read.withboris.com'
const articleUrl = `${baseUrl}/a/${naddr}`
const title = meta?.title || 'Boris Nostr Bookmarks'
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
const author = meta?.author || 'Boris'
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f172a" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeHtml(description)}" />
<link rel="canonical" href="${articleUrl}" />
<!-- Open Graph / Social Media -->
<meta property="og:type" content="article" />
<meta property="og:url" content="${articleUrl}" />
<meta property="og:title" content="${escapeHtml(title)}" />
<meta property="og:description" content="${escapeHtml(description)}" />
<meta property="og:image" content="${escapeHtml(image)}" />
<meta property="og:site_name" content="Boris" />
${meta?.published ? `<meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
<meta property="article:author" content="${escapeHtml(author)}" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="${articleUrl}" />
<meta name="twitter:title" content="${escapeHtml(title)}" />
<meta name="twitter:description" content="${escapeHtml(description)}" />
<meta name="twitter:image" content="${escapeHtml(image)}" />
</head>
<body>
<noscript>
<p>Redirecting to <a href="/">Boris</a>...</p>
</noscript>
</body>
</html>`
}
function isCrawler(userAgent: string | undefined): boolean {
if (!userAgent) return false
const crawlers = [
'bot', 'crawl', 'spider', 'slurp', 'facebook', 'twitter', 'linkedin',
'whatsapp', 'telegram', 'slack', 'discord', 'preview'
]
const ua = userAgent.toLowerCase()
return crawlers.some(crawler => ua.includes(crawler))
}
export default async function handler(req: VercelRequest, res: VercelResponse) {
const naddr = (req.query.naddr as string | undefined)?.trim()
if (!naddr) {
return res.status(400).json({ error: 'Missing naddr parameter' })
}
const userAgent = req.headers['user-agent'] as string | undefined
const isCrawlerRequest = isCrawler(userAgent)
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')
}
// If it's a regular browser (not a bot), serve HTML that loads SPA
// Use history.replaceState to set the URL before the SPA boots
if (!isCrawlerRequest) {
const articlePath = `/a/${naddr}`
// Serve a minimal HTML that sets up the URL and loads the SPA
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Boris - Loading Article...</title>
<script>
// Set the URL to the article path before SPA loads
if (window.location.pathname !== '${articlePath}') {
history.replaceState(null, '', '${articlePath}');
}
</script>
${debugEnabled ? `<script>console.debug('article-og', { mode: 'browser', naddr: '${naddr}', path: location.pathname, referrer: document.referrer });</script>` : ''}
<script>
// Redirect to index.html which will load the SPA
// The history state is already set, so SPA will see the correct URL
window.location.replace('/');
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>`
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 }))
}
return res.status(200).send(html)
}
// Check cache for bots/crawlers
const now = Date.now()
const cached = memoryCache.get(naddr)
if (cached && cached.expires > now) {
setCacheHeaders(res)
if (debugEnabled) {
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: true }))
}
return res.status(200).send(cached.html)
}
try {
// Fetch metadata
const meta = await fetchArticleMetadata(naddr)
// Generate HTML
const html = generateHtml(naddr, meta)
// Cache the result
memoryCache.set(naddr, { html, expires: now + WEEK_MS })
// Send response
setCacheHeaders(res)
if (debugEnabled) {
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: false }))
}
return res.status(200).send(html)
} catch (err) {
console.error('Error generating article OG HTML:', err)
// Fallback to basic HTML with SPA boot
const html = generateHtml(naddr, null)
setCacheHeaders(res, 3600)
if (debugEnabled) {
console.log('[article-og] response', JSON.stringify({ mode: 'bot-fallback', naddr }))
}
return res.status(200).send(html)
}
}

View File

@@ -18,6 +18,7 @@
<meta property="og:url" content="https://read.withboris.com/" />
<meta property="og:title" content="Boris - Nostr Bookmarks" />
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta property="og:image" content="https://read.withboris.com/boris-social-1200.png" />
<meta property="og:site_name" content="Boris" />
<!-- Twitter Card -->
@@ -25,6 +26,7 @@
<meta name="twitter:url" content="https://read.withboris.com/" />
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
<!-- Default to system theme until settings load from Nostr -->
<script>

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.6.20",
"version": "0.6.24",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

View File

@@ -4,16 +4,21 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
import { EventStore } from 'applesauce-core'
import { AccountManager } from 'applesauce-accounts'
import { AccountManager, Accounts } from 'applesauce-accounts'
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
import { RelayPool } from 'applesauce-relay'
import { NostrConnectSigner } from 'applesauce-signers'
import { getDefaultBunkerPermissions } from './services/nostrConnect'
import { createAddressLoader } from 'applesauce-loaders/loaders'
import Debug from './components/Debug'
import Bookmarks from './components/Bookmarks'
import RouteDebug from './components/RouteDebug'
import Toast from './components/Toast'
import { useToast } from './hooks/useToast'
import { useOnlineStatus } from './hooks/useOnlineStatus'
import { RELAYS } from './config/relays'
import { SkeletonThemeProvider } from './components/Skeletons'
import { DebugBus } from './utils/debugBus'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
@@ -165,6 +170,7 @@ function AppRoutes({
/>
}
/>
<Route path="/debug" element={<Debug />} />
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes>
)
@@ -186,20 +192,57 @@ function App() {
// Register common account types (needed for deserialization)
registerCommonAccountTypes(accounts)
// Create relay pool and set it up BEFORE loading accounts
// NostrConnectAccount.fromJSON needs this to restore the signer
const pool = new RelayPool()
// Wire the signer to use this pool; make publish non-blocking so callers don't
// wait for every relay send to finish. Responses still resolve the pending request.
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
NostrConnectSigner.publishMethod = (relays: string[], event: unknown) => {
const result: any = pool.publish(relays, event as any)
if (result && typeof (result as any).subscribe === 'function') {
try { (result as any).subscribe({ complete: () => {}, error: () => {} }) } catch {}
}
// 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 json = JSON.parse(localStorage.getItem('accounts') || '[]')
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('Loaded', accounts.accounts.length, 'accounts from storage')
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')
if (activeId && accounts.getAccount(activeId)) {
accounts.setActive(activeId)
console.log('Restored active account:', activeId)
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')
}
} catch (err) {
console.error('Failed to load accounts from storage:', err)
console.error('[bunker] ❌ Failed to load accounts from storage:', err)
}
// Subscribe to accounts changes and persist to localStorage
@@ -216,12 +259,197 @@ function App() {
}
})
const pool = new RelayPool()
// Reconnect bunker signers when active account changes
// Keep track of which accounts we've already reconnected to avoid double-connecting
const reconnectedAccounts = new Set<string>()
// Create a relay group for better event deduplication and management
pool.group(RELAYS)
console.log('Created relay group with', RELAYS.length, 'relays (including local)')
console.log('Relay URLs:', RELAYS)
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>
// Disable applesauce account queueing so decrypt requests aren't serialized behind earlier ops
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) }
// 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
// The signer was created in fromJSON without pool context, so we need to recreate it
const signerData = nostrConnectAccount.toJSON().signer
// Add bunker's relays to the pool BEFORE recreating the signer
// This ensures the pool has all relays when the signer sets up its methods
const bunkerRelays = signerData.relays || []
const existingRelayUrls = new Set(Array.from(pool.relays.keys()))
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')
}
const recreatedSigner = new NostrConnectSigner({
relays: signerData.relays,
pubkey: nostrConnectAccount.pubkey,
remote: signerData.remote,
signer: nostrConnectAccount.signer.signer, // Use the existing SimpleSigner
pool: pool
})
// Ensure local relays are included for NIP-46 request/response traffic (e.g., Amber bunker)
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)
;(recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod = (relays: string[], event: unknown) => {
try {
let method: string | undefined
const content = (event as { content?: unknown })?.content
if (typeof content === 'string') {
try {
const parsed = JSON.parse(content) as { method?: string; id?: unknown }
method = parsed?.method
} catch (err) { console.warn('[bunker] failed to parse event content', err) }
}
const summary = {
relays,
kind: (event as { kind?: number })?.kind,
method,
// include tags array for debugging (NIP-46 expects method tag)
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
// Observable/Promise to upstream to avoid their awaiting of completion.
const result = originalPublish(relays, event)
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => {}, error: () => {} }) } catch {}
}
// If it's a Promise, simply ignore it (no await) so it resolves in the background.
// Return a benign object so callers that probe for a "subscribe" property
// (e.g., applesauce makeRequest) won't throw on `"subscribe" in result`.
return {} as unknown as never
}
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)
}
// 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')
}
// 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)
}
// 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> => {
return await Promise.race([
p,
new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`probe timeout after ${ms}ms`)), ms)),
])
}
setTimeout(async () => {
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)
}
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)
}
}, 0)
} catch (err) {
console.log('[bunker] 🔎 Probe setup failed:', err)
}
// 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)
}
}
})
// Keep all relay connections alive indefinitely by creating a persistent subscription
// This prevents disconnection when no other subscriptions are active
@@ -251,6 +479,7 @@ function App() {
return () => {
accountsSub.unsubscribe()
activeSub.unsubscribe()
bunkerReconnectSub.unsubscribe()
// Clean up keep-alive subscription if it exists
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
@@ -267,7 +496,7 @@ function App() {
return () => {
if (cleanup) cleanup()
}
}, [])
}, [isOnline, showToast])
// Monitor online/offline status
useEffect(() => {
@@ -303,6 +532,7 @@ function App() {
<BrowserRouter>
<div className="min-h-screen p-0 max-w-none m-0 relative">
<AppRoutes relayPool={relayPool} showToast={showToast} />
<RouteDebug />
</div>
</BrowserRouter>
{toastMessage && (

View File

@@ -0,0 +1,47 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookOpen, faBookmark, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
import { faBooks } from '../icons/customIcons'
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
interface ArchiveFiltersProps {
selectedFilter: ArchiveFilterType
onFilterChange: (filter: ArchiveFilterType) => void
}
const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilterChange }) => {
const filters = [
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
{ type: 'marked' as const, icon: faBooks, label: 'Marked as Read' }
]
return (
<div className="bookmark-filters">
{filters.map(filter => {
const isActive = selectedFilter === filter.type
// Only "completed" gets green color, everything else uses default blue
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
return (
<button
key={filter.type}
onClick={() => onFilterChange(filter.type)}
className={`filter-btn ${isActive ? 'active' : ''}`}
title={filter.label}
aria-label={`Filter by ${filter.label}`}
style={activeStyle}
>
<FontAwesomeIcon icon={filter.icon} />
</button>
)
})}
</div>
)
}
export default ArchiveFilters

View File

@@ -19,10 +19,9 @@ interface BookmarkItemProps {
index: number
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
viewMode?: ViewMode
readingProgress?: number // 0-1 reading progress (optional)
}
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
const [ogImage, setOgImage] = useState<string | null>(null)
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
@@ -151,7 +150,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
if (viewMode === 'large') {
const previewImage = articleImage || instantPreview || ogImage
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} readingProgress={readingProgress} />
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
}
return <CardView {...sharedProps} articleImage={articleImage} />

View File

@@ -21,7 +21,7 @@ import { RELAYS } from '../config/relays'
import { Hooks } from 'applesauce-react'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
import LoginOptions from './LoginOptions'
interface BookmarkListProps {
bookmarks: Bookmark[]
@@ -40,8 +40,6 @@ interface BookmarkListProps {
relayPool: RelayPool | null
isMobile?: boolean
settings?: UserSettings
readingPositions?: Map<string, number>
markedAsReadIds?: Set<string>
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
@@ -60,16 +58,13 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
loading = false,
relayPool,
isMobile = false,
settings,
readingPositions,
markedAsReadIds
settings
}) => {
const navigate = useNavigate()
const bookmarksListRef = useRef<HTMLDivElement>(null)
const friendsColor = settings?.highlightColorFriends || '#f97316'
const [showAddModal, setShowAddModal] = useState(false)
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>('all')
const activeAccount = Hooks.useActiveAccount()
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
@@ -96,42 +91,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
// Apply type filter
const typeFilteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
// Apply reading progress filter (only affects kind:30023 articles)
const filteredBookmarks = typeFilteredBookmarks.filter(bookmark => {
// Only apply reading progress filter to kind:30023 articles
if (bookmark.kind !== 30023) return true
// If reading progress filter is 'all', show all articles
if (readingProgressFilter === 'all') return true
const isMarkedAsRead = markedAsReadIds?.has(bookmark.id)
const position = readingPositions?.get(bookmark.id)
// Marked-as-read articles are always treated as 100% complete
if (isMarkedAsRead) {
return readingProgressFilter === 'completed'
}
switch (readingProgressFilter) {
case 'unopened':
// No reading progress - never opened
return !position || position === 0
case 'started':
// 0-10% reading progress - opened but not read far
return position !== undefined && position > 0 && position <= 0.10
case 'reading':
// Has some progress but not completed (11% - 94%)
return position !== undefined && position > 0.10 && position <= 0.94
case 'completed':
// 95% or more read
return position !== undefined && position >= 0.95
default:
return true
}
})
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
// Separate bookmarks with setName (kind 30003) from regular bookmarks
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
@@ -193,7 +154,9 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
/>
)}
{filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
{!activeAccount ? (
<LoginOptions />
) : filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
<div className="empty-state">
<p>No bookmarks match this filter.</p>
</div>
@@ -210,7 +173,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
<div className="empty-state">
<p>No bookmarks found.</p>
<p>Add bookmarks using your nostr client to see them here.</p>
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
</div>
)
) : (
@@ -244,7 +206,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
index={index}
onSelectUrl={onSelectUrl}
viewMode={viewMode}
readingProgress={markedAsReadIds?.has(individualBookmark.id) ? 1.0 : readingPositions?.get(individualBookmark.id)}
/>
))}
</div>
@@ -252,17 +213,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
))}
</div>
)}
{/* Reading progress filters - only show if there are kind:30023 articles */}
{typeFilteredBookmarks.some(b => b.kind === 30023) && (
<div className="reading-progress-filters-wrapper">
<ReadingProgressFilters
selectedFilter={readingProgressFilter}
onFilterChange={setReadingProgressFilter}
/>
</div>
)}
<div className="view-mode-controls">
<div className="view-mode-left">
<IconButton

View File

@@ -162,9 +162,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
isRefreshing,
lastFetchTime,
handleFetchHighlights,
handleRefreshAll,
readingPositions,
markedAsReadIds
handleRefreshAll
} = useBookmarksData({
relayPool,
activeAccount,
@@ -173,8 +171,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
externalUrl,
currentArticleCoordinate,
currentArticleEventId,
settings,
eventStore
settings
})
const {
@@ -316,8 +313,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
highlightButtonRef={highlightButtonRef}
onCreateHighlight={handleCreateHighlight}
hasActiveAccount={!!(activeAccount && relayPool)}
readingPositions={readingPositions}
markedAsReadIds={markedAsReadIds}
explore={showExplore ? (
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
) : undefined}

View File

@@ -187,77 +187,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
enabled: isTextContent,
syncEnabled: settings?.syncReadingPosition,
onSave: handleSavePosition
onSave: handleSavePosition,
onReadingComplete: () => {
// Optional: Auto-mark as read when reading is complete
if (activeAccount && !isMarkedAsRead) {
// Could trigger auto-mark as read here if desired
}
}
})
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
// Define handleMarkAsRead with useCallback to use in auto-mark effect
const handleMarkAsRead = useCallback(() => {
if (!activeAccount || !relayPool || isMarkedAsRead) {
return
}
// Instantly update UI with checkmark animation
setIsMarkedAsRead(true)
setShowCheckAnimation(true)
// Reset animation after it completes (2.5s for full fancy animation)
setTimeout(() => {
setShowCheckAnimation(false)
}, 2500)
// Fire-and-forget: publish in background without blocking UI
;(async () => {
try {
if (isNostrArticle && currentArticle) {
await createEventReaction(
currentArticle.id,
currentArticle.pubkey,
currentArticle.kind,
activeAccount,
relayPool
)
console.log('✅ Marked nostr article as read')
} else if (selectedUrl) {
await createWebsiteReaction(
selectedUrl,
activeAccount,
relayPool
)
console.log('✅ Marked website as read')
}
} catch (error) {
console.error('Failed to mark as read:', error)
// Revert UI state on error
setIsMarkedAsRead(false)
}
})()
}, [activeAccount, relayPool, isMarkedAsRead, isNostrArticle, currentArticle, selectedUrl])
// Auto-mark as read when reaching 100% for 2 seconds
useEffect(() => {
if (!settings?.autoMarkAsReadAt100 || isMarkedAsRead || !activeAccount || !relayPool) {
return
}
// Only trigger when progress is exactly 100%
if (progressPercentage === 100) {
console.log('📍 [ContentPanel] Progress at 100%, starting 2-second timer for auto-mark')
const timer = setTimeout(() => {
console.log('✅ [ContentPanel] Auto-marking as read after 2 seconds at 100%')
handleMarkAsRead()
}, 2000)
return () => {
console.log('⏹️ [ContentPanel] Canceling auto-mark timer (progress changed or unmounting)')
clearTimeout(timer)
}
}
}, [progressPercentage, settings?.autoMarkAsReadAt100, isMarkedAsRead, activeAccount, relayPool, handleMarkAsRead])
// Load saved reading position when article loads
useEffect(() => {
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
@@ -288,25 +226,19 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
console.log('🎯 [ContentPanel] Restoring position:', Math.round(savedPosition.position * 100) + '%')
// Only auto-scroll if the setting is enabled (default: true)
if (settings?.autoScrollToPosition !== false) {
// Wait for content to be fully rendered before scrolling
setTimeout(() => {
const documentHeight = document.documentElement.scrollHeight
const windowHeight = window.innerHeight
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
window.scrollTo({
top: scrollTop,
behavior: 'smooth'
})
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
}, 500) // Give content time to render
} else {
console.log('⏭️ [ContentPanel] Auto-scroll disabled in settings')
}
// Wait for content to be fully rendered before scrolling
setTimeout(() => {
const documentHeight = document.documentElement.scrollHeight
const windowHeight = window.innerHeight
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
window.scrollTo({
top: scrollTop,
behavior: 'smooth'
})
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
}, 500) // Give content time to render
} else if (savedPosition) {
if (savedPosition.position === 1) {
console.log('✅ [ContentPanel] Article completed (100%), starting from top')
@@ -320,7 +252,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}
loadPosition()
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, settings?.autoScrollToPosition, selectedUrl])
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
// Save position before unmounting or changing article
useEffect(() => {
@@ -392,6 +324,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const hasHighlights = relevantHighlights.length > 0
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
// Track external video duration (in seconds) for display in header
@@ -660,6 +594,48 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
checkReadStatus()
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
const handleMarkAsRead = () => {
if (!activeAccount || !relayPool || isMarkedAsRead) {
return
}
// Instantly update UI with checkmark animation
setIsMarkedAsRead(true)
setShowCheckAnimation(true)
// Reset animation after it completes
setTimeout(() => {
setShowCheckAnimation(false)
}, 600)
// Fire-and-forget: publish in background without blocking UI
;(async () => {
try {
if (isNostrArticle && currentArticle) {
await createEventReaction(
currentArticle.id,
currentArticle.pubkey,
currentArticle.kind,
activeAccount,
relayPool
)
console.log('✅ Marked nostr article as read')
} else if (selectedUrl) {
await createWebsiteReaction(
selectedUrl,
activeAccount,
relayPool
)
console.log('✅ Marked website as read')
}
} catch (error) {
console.error('Failed to mark as read:', error)
// Revert UI state on error
setIsMarkedAsRead(false)
}
})()
}
if (!selectedUrl) {
return (

395
src/components/Debug.tsx Normal file
View File

@@ -0,0 +1,395 @@
import React, { useEffect, useMemo, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faClock, faSpinner } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { Accounts } from 'applesauce-accounts'
import { NostrConnectSigner } from 'applesauce-signers'
import { getDefaultBunkerPermissions } from '../services/nostrConnect'
import { DebugBus, type DebugLogEntry } from '../utils/debugBus'
import VersionFooter from './VersionFooter'
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
const Debug: React.FC = () => {
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
const [payload, setPayload] = useState<string>(defaultPayload)
const [cipher44, setCipher44] = useState<string>('')
const [cipher04, setCipher04] = useState<string>('')
const [plain44, setPlain44] = useState<string>('')
const [plain04, setPlain04] = useState<string>('')
const [tEncrypt44, setTEncrypt44] = useState<number | null>(null)
const [tEncrypt04, setTEncrypt04] = useState<number | null>(null)
const [tDecrypt44, setTDecrypt44] = useState<number | null>(null)
const [tDecrypt04, setTDecrypt04] = useState<number | null>(null)
const [logs, setLogs] = useState<DebugLogEntry[]>(DebugBus.snapshot())
const [debugEnabled, setDebugEnabled] = useState<boolean>(() => localStorage.getItem('debug') === '*')
// Bunker login state
const [bunkerUri, setBunkerUri] = useState<string>('')
const [isBunkerLoading, setIsBunkerLoading] = useState<boolean>(false)
const [bunkerError, setBunkerError] = useState<string | null>(null)
// Live timing state
const [liveTiming, setLiveTiming] = useState<{
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
nip04?: { type: 'encrypt' | 'decrypt'; startTime: number }
}>({})
useEffect(() => {
return DebugBus.subscribe((e) => setLogs(prev => [...prev, e].slice(-300)))
}, [])
// Live timer effect - triggers re-renders for live timing updates
useEffect(() => {
const interval = setInterval(() => {
// Force re-render to update live timing display
setLiveTiming(prev => prev)
}, 16) // ~60fps for smooth updates
return () => clearInterval(interval)
}, [])
const signer = useMemo(() => (activeAccount as unknown as { signer?: unknown })?.signer, [activeAccount])
const pubkey = (activeAccount as unknown as { pubkey?: string })?.pubkey
const hasNip04 = typeof (signer as { nip04?: { encrypt?: unknown; decrypt?: unknown } } | undefined)?.nip04?.encrypt === 'function'
const hasNip44 = typeof (signer as { nip44?: { encrypt?: unknown; decrypt?: unknown } } | undefined)?.nip44?.encrypt === 'function'
const doEncrypt = async (mode: 'nip44' | 'nip04') => {
if (!signer || !pubkey) return
try {
const api = (signer as { [key: string]: { encrypt: (pubkey: string, message: string) => Promise<string> } })[mode]
DebugBus.info('debug', `encrypt start ${mode}`, { pubkey, len: payload.length })
// Start live timing
const start = performance.now()
setLiveTiming(prev => ({ ...prev, [mode]: { type: 'encrypt', startTime: start } }))
const cipher = await api.encrypt(pubkey, payload)
const ms = Math.round(performance.now() - start)
// Stop live timing
setLiveTiming(prev => ({ ...prev, [mode]: undefined }))
DebugBus.info('debug', `encrypt done ${mode}`, { len: typeof cipher === 'string' ? cipher.length : -1, ms })
if (mode === 'nip44') setCipher44(cipher)
else setCipher04(cipher)
if (mode === 'nip44') setTEncrypt44(ms)
else setTEncrypt04(ms)
} catch (e) {
// Stop live timing on error
setLiveTiming(prev => ({ ...prev, [mode]: undefined }))
DebugBus.error('debug', `encrypt error ${mode}`, e instanceof Error ? e.message : String(e))
}
}
const doDecrypt = async (mode: 'nip44' | 'nip04') => {
if (!signer || !pubkey) return
try {
const api = (signer as { [key: string]: { decrypt: (pubkey: string, ciphertext: string) => Promise<string> } })[mode]
const cipher = mode === 'nip44' ? cipher44 : cipher04
if (!cipher) {
DebugBus.warn('debug', `no cipher to decrypt for ${mode}`)
return
}
DebugBus.info('debug', `decrypt start ${mode}`, { len: cipher.length })
// Start live timing
const start = performance.now()
setLiveTiming(prev => ({ ...prev, [mode]: { type: 'decrypt', startTime: start } }))
const plain = await api.decrypt(pubkey, cipher)
const ms = Math.round(performance.now() - start)
// Stop live timing
setLiveTiming(prev => ({ ...prev, [mode]: undefined }))
DebugBus.info('debug', `decrypt done ${mode}`, { len: typeof plain === 'string' ? plain.length : -1, ms })
if (mode === 'nip44') setPlain44(String(plain))
else setPlain04(String(plain))
if (mode === 'nip44') setTDecrypt44(ms)
else setTDecrypt04(ms)
} catch (e) {
// Stop live timing on error
setLiveTiming(prev => ({ ...prev, [mode]: undefined }))
DebugBus.error('debug', `decrypt error ${mode}`, e instanceof Error ? e.message : String(e))
}
}
const toggleDebug = () => {
const next = !debugEnabled
setDebugEnabled(next)
if (next) localStorage.setItem('debug', '*')
else localStorage.removeItem('debug')
}
const handleBunkerLogin = async () => {
if (!bunkerUri.trim()) {
setBunkerError('Please enter a bunker URI')
return
}
if (!bunkerUri.startsWith('bunker://')) {
setBunkerError('Invalid bunker URI. Must start with bunker://')
return
}
try {
setIsBunkerLoading(true)
setBunkerError(null)
// Create signer from bunker URI with default permissions
const permissions = getDefaultBunkerPermissions()
const signer = await NostrConnectSigner.fromBunkerURI(bunkerUri, { permissions })
// Get pubkey from signer
const pubkey = await signer.getPublicKey()
// Create account from signer
const account = new Accounts.NostrConnectAccount(pubkey, signer)
// Add to account manager and set active
accountManager.addAccount(account)
accountManager.setActive(account)
// Clear input on success
setBunkerUri('')
} catch (err) {
console.error('[bunker] Login failed:', err)
const errorMessage = err instanceof Error ? err.message : 'Failed to connect to bunker'
// Check for permission-related errors
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
setBunkerError('Your bunker connection is missing signing permissions. Reconnect and approve signing.')
} else {
setBunkerError(errorMessage)
}
} finally {
setIsBunkerLoading(false)
}
}
const CodeBox = ({ value }: { value: string }) => (
<div className="h-20 overflow-y-auto font-mono text-xs leading-relaxed p-2 bg-gray-100 dark:bg-gray-800 rounded whitespace-pre-wrap break-all">
{value || '—'}
</div>
)
const getLiveTiming = (mode: 'nip44' | 'nip04', type: 'encrypt' | 'decrypt') => {
const timing = liveTiming[mode]
if (timing && timing.type === type) {
const elapsed = Math.round(performance.now() - timing.startTime)
return elapsed
}
return null
}
const Stat = ({ label, value, mode, type }: {
label: string;
value?: string | number | null;
mode?: 'nip44' | 'nip04';
type?: 'encrypt' | 'decrypt';
}) => {
const liveValue = mode && type ? getLiveTiming(mode, type) : null
const isLive = !!liveValue
let displayValue: string
if (isLive) {
displayValue = ''
} else if (value !== null && value !== undefined) {
displayValue = `${value}ms`
} else {
displayValue = '—'
}
return (
<span className="badge" style={{ marginRight: 8 }}>
<FontAwesomeIcon icon={faClock} style={{ marginRight: 4, fontSize: '0.8em' }} />
{label}: {isLive ? (
<FontAwesomeIcon icon={faSpinner} className="animate-spin" style={{ fontSize: '0.8em' }} />
) : (
displayValue
)}
</span>
)
}
return (
<div className="settings-view">
<div className="settings-header">
<h2>Debug</h2>
<div className="settings-header-actions">
<span className="opacity-70">Active pubkey:</span> <code className="text-sm">{pubkey || 'none'}</code>
</div>
</div>
<div className="settings-content">
{/* Bunker Login Section */}
<div className="settings-section">
<h3 className="section-title">Bunker Connection</h3>
{!activeAccount ? (
<div>
<div className="text-sm opacity-70 mb-3">Connect to your bunker (Nostr Connect signer) to enable encryption/decryption testing</div>
<div className="flex gap-2 mb-3">
<input
type="text"
className="input flex-1"
placeholder="bunker://..."
value={bunkerUri}
onChange={(e) => setBunkerUri(e.target.value)}
disabled={isBunkerLoading}
/>
<button
className="btn btn-primary"
onClick={handleBunkerLogin}
disabled={isBunkerLoading || !bunkerUri.trim()}
>
{isBunkerLoading ? 'Connecting...' : 'Connect'}
</button>
</div>
{bunkerError && (
<div className="text-sm text-red-600 dark:text-red-400 mb-2">{bunkerError}</div>
)}
</div>
) : (
<div className="flex items-center justify-between">
<div>
<div className="text-sm opacity-70">Connected to bunker</div>
<div className="text-sm font-mono">{pubkey}</div>
</div>
<button
className="btn"
style={{
background: 'rgb(220 38 38)',
color: 'white',
border: '1px solid rgb(220 38 38)',
padding: '0.75rem 1.5rem',
borderRadius: '6px',
fontSize: '1rem',
cursor: 'pointer',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgb(185 28 28)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'rgb(220 38 38)'}
onClick={() => accountManager.removeAccount(activeAccount)}
>
Disconnect
</button>
</div>
)}
</div>
{/* Encryption Tools Section */}
<div className="settings-section">
<h3 className="section-title">Encryption Tools</h3>
<div className="setting-group">
<label className="setting-label">Payload</label>
<textarea
className="textarea w-full bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700"
value={payload}
onChange={e => setPayload(e.target.value)}
rows={3}
/>
<div className="flex gap-2 mt-3 justify-end">
<button className="btn btn-secondary" onClick={() => setPayload(defaultPayload)}>Reset</button>
<button className="btn btn-secondary" onClick={() => { setCipher44(''); setCipher04(''); setPlain44(''); setPlain04(''); setTEncrypt44(null); setTEncrypt04(null); setTDecrypt44(null); setTDecrypt04(null) }}>Clear</button>
</div>
</div>
<div className="grid" style={{ gap: 12, gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)' }}>
<div className="setting-group">
<label className="setting-label">NIP-44</label>
<div className="flex gap-2 mb-3">
<button className="btn btn-primary" onClick={() => doEncrypt('nip44')} disabled={!hasNip44}>Encrypt</button>
<button className="btn btn-secondary" onClick={() => doDecrypt('nip44')} disabled={!cipher44}>Decrypt</button>
</div>
<label className="block text-sm opacity-70 mb-2">Encrypted:</label>
<CodeBox value={cipher44} />
<div className="mt-3">
<span className="text-sm opacity-70">Plain:</span>
<CodeBox value={plain44} />
</div>
</div>
<div className="setting-group">
<label className="setting-label">NIP-04</label>
<div className="flex gap-2 mb-3">
<button className="btn btn-primary" onClick={() => doEncrypt('nip04')} disabled={!hasNip04}>Encrypt</button>
<button className="btn btn-secondary" onClick={() => doDecrypt('nip04')} disabled={!cipher04}>Decrypt</button>
</div>
<label className="block text-sm opacity-70 mb-2">Encrypted:</label>
<CodeBox value={cipher04} />
<div className="mt-3">
<span className="text-sm opacity-70">Plain:</span>
<CodeBox value={plain04} />
</div>
</div>
</div>
</div>
{/* Performance Timing Section */}
<div className="settings-section">
<h3 className="section-title">Performance Timing</h3>
<div className="text-sm opacity-70 mb-3">Encryption and decryption operation durations</div>
<div className="grid" style={{ gap: 12, gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)' }}>
<div className="setting-group">
<label className="setting-label">NIP-44</label>
<div className="flex flex-wrap items-center gap-2">
<Stat label="enc" value={tEncrypt44} mode="nip44" type="encrypt" />
<Stat label="dec" value={tDecrypt44} mode="nip44" type="decrypt" />
</div>
</div>
<div className="setting-group">
<label className="setting-label">NIP-04</label>
<div className="flex flex-wrap items-center gap-2">
<Stat label="enc" value={tEncrypt04} mode="nip04" type="encrypt" />
<Stat label="dec" value={tDecrypt04} mode="nip04" type="decrypt" />
</div>
</div>
</div>
</div>
{/* Debug Logs Section */}
<div className="settings-section">
<h3 className="section-title">Debug Logs</h3>
<div className="text-sm opacity-70 mb-3">Recent bunker logs:</div>
<div className="max-h-192 overflow-y-auto font-mono text-xs leading-relaxed">
{logs.length === 0 ? (
<div className="text-sm opacity-50 italic">No logs yet</div>
) : (
logs.slice(-200).map((l, i) => (
<div key={i} className="mb-1 p-2 bg-gray-100 dark:bg-gray-800 rounded">
<span className="opacity-70">[{new Date(l.ts).toLocaleTimeString()}]</span> <span className="font-semibold">{l.level.toUpperCase()}</span> {l.source}: {l.message}
{l.data !== undefined && (
<span className="opacity-70"> {typeof l.data === 'string' ? l.data : JSON.stringify(l.data)}</span>
)}
</div>
))
)}
</div>
<div className="mt-3">
<div className="flex justify-end mb-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={debugEnabled}
onChange={toggleDebug}
className="checkbox"
/>
<span className="text-sm">Show all applesauce debug logs</span>
</label>
</div>
<div className="flex justify-end">
<button className="btn btn-secondary" onClick={() => setLogs([])}>Clear logs</button>
</div>
</div>
</div>
</div>
<VersionFooter />
</div>
)
}
export default Debug

View File

@@ -22,8 +22,6 @@ import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { classifyHighlights } from '../utils/highlightClassification'
import { HighlightVisibility } from './HighlightsPanel'
import { loadReadingPosition, generateArticleIdentifier } from '../services/readingPositionService'
import { fetchReadArticles } from '../services/libraryService'
interface ExploreProps {
relayPool: RelayPool
@@ -43,8 +41,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [refreshTrigger, setRefreshTrigger] = useState(0)
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
const [markedAsReadIds, setMarkedAsReadIds] = useState<Set<string>>(new Set())
// Visibility filters (defaults from settings, or friends only)
const [visibility, setVisibility] = useState<HighlightVisibility>({
@@ -217,88 +213,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
loadData()
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
// Fetch marked-as-read articles
useEffect(() => {
const loadMarkedAsRead = async () => {
if (!activeAccount || !eventStore) {
return
}
try {
const readArticles = await fetchReadArticles(relayPool, activeAccount.pubkey)
// Create a set of article IDs that are marked as read
const markedArticleIds = new Set<string>()
// For each read article, add both event ID and coordinate format
for (const readArticle of readArticles) {
// Add the event ID directly
markedArticleIds.add(readArticle.id)
// For nostr-native articles (kind:7 reactions), also add the coordinate format
if (readArticle.eventId && readArticle.eventAuthor && readArticle.eventKind) {
// Try to get the event from the eventStore to find the 'd' tag
const event = eventStore.getEvent(readArticle.eventId)
if (event) {
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
markedArticleIds.add(coordinate)
}
}
}
setMarkedAsReadIds(markedArticleIds)
} catch (error) {
console.warn('⚠️ [Explore] Failed to load marked-as-read articles:', error)
}
}
loadMarkedAsRead()
}, [relayPool, activeAccount, eventStore])
// Load reading positions for blog posts
useEffect(() => {
const loadPositions = async () => {
if (!activeAccount || !eventStore || blogPosts.length === 0 || !settings?.syncReadingPosition) {
return
}
const positions = new Map<string, number>()
await Promise.all(
blogPosts.map(async (post) => {
try {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
const articleUrl = `nostr:${naddr}`
const identifier = generateArticleIdentifier(articleUrl)
const savedPosition = await loadReadingPosition(
relayPool,
eventStore,
activeAccount.pubkey,
identifier
)
if (savedPosition && savedPosition.position > 0) {
positions.set(post.event.id, savedPosition.position)
}
} catch (error) {
console.warn('⚠️ [Explore] Failed to load reading position for post:', error)
}
})
)
setReadingPositions(positions)
}
loadPositions()
}, [blogPosts, activeAccount, relayPool, eventStore, settings?.syncReadingPosition])
// Pull-to-refresh
const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => {
@@ -388,7 +302,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
post={post}
href={getPostUrl(post)}
level={post.level}
readingProgress={markedAsReadIds.has(post.event.id) ? 1.0 : readingPositions.get(post.event.id)}
/>
))}
</div>

View File

@@ -0,0 +1,179 @@
import React, { useState } from 'react'
import { Hooks } from 'applesauce-react'
import { Accounts } from 'applesauce-accounts'
import { NostrConnectSigner } from 'applesauce-signers'
import { getDefaultBunkerPermissions } from '../services/nostrConnect'
const LoginOptions: React.FC = () => {
const accountManager = Hooks.useAccountManager()
const [showBunkerInput, setShowBunkerInput] = useState(false)
const [bunkerUri, setBunkerUri] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleExtensionLogin = async () => {
try {
setIsLoading(true)
setError(null)
const account = await Accounts.ExtensionAccount.fromExtension()
accountManager.addAccount(account)
accountManager.setActive(account)
} catch (err) {
console.error('Extension login failed:', err)
setError('Login failed. Please install a nostr browser extension and try again.')
} finally {
setIsLoading(false)
}
}
const handleBunkerLogin = async () => {
if (!bunkerUri.trim()) {
setError('Please enter a bunker URI')
return
}
if (!bunkerUri.startsWith('bunker://')) {
setError('Invalid bunker URI. Must start with bunker://')
return
}
try {
setIsLoading(true)
setError(null)
// Create signer from bunker URI with default permissions
const permissions = getDefaultBunkerPermissions()
const signer = await NostrConnectSigner.fromBunkerURI(bunkerUri, { permissions })
// Get pubkey from signer
const pubkey = await signer.getPublicKey()
// Create account from signer
const account = new Accounts.NostrConnectAccount(pubkey, signer)
// Add to account manager and set active
accountManager.addAccount(account)
accountManager.setActive(account)
// Clear input on success
setBunkerUri('')
setShowBunkerInput(false)
} catch (err) {
console.error('[bunker] Login failed:', err)
const errorMessage = err instanceof Error ? err.message : 'Failed to connect to bunker'
// Check for permission-related errors
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
setError('Your bunker connection is missing signing permissions. Reconnect and approve signing.')
} else {
setError(errorMessage)
}
} finally {
setIsLoading(false)
}
}
return (
<div className="empty-state">
<p style={{ marginBottom: '1rem' }}>Login with:</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxWidth: '300px', margin: '0 auto' }}>
<button
onClick={handleExtensionLogin}
disabled={isLoading}
style={{
padding: '0.75rem 1.5rem',
fontSize: '1rem',
cursor: isLoading ? 'wait' : 'pointer',
opacity: isLoading ? 0.6 : 1
}}
>
{isLoading && !showBunkerInput ? 'Connecting...' : 'Extension'}
</button>
{!showBunkerInput ? (
<button
onClick={() => setShowBunkerInput(true)}
disabled={isLoading}
style={{
padding: '0.75rem 1.5rem',
fontSize: '1rem',
cursor: isLoading ? 'wait' : 'pointer',
opacity: isLoading ? 0.6 : 1
}}
>
Bunker
</button>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<input
type="text"
placeholder="bunker://..."
value={bunkerUri}
onChange={(e) => setBunkerUri(e.target.value)}
disabled={isLoading}
style={{
padding: '0.75rem',
fontSize: '0.9rem',
width: '100%',
boxSizing: 'border-box'
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleBunkerLogin()
}
}}
/>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={handleBunkerLogin}
disabled={isLoading || !bunkerUri.trim()}
style={{
padding: '0.5rem 1rem',
fontSize: '0.9rem',
flex: 1,
cursor: isLoading || !bunkerUri.trim() ? 'not-allowed' : 'pointer',
opacity: isLoading || !bunkerUri.trim() ? 0.6 : 1
}}
>
{isLoading && showBunkerInput ? 'Connecting...' : 'Connect'}
</button>
<button
onClick={() => {
setShowBunkerInput(false)
setBunkerUri('')
setError(null)
}}
disabled={isLoading}
style={{
padding: '0.5rem 1rem',
fontSize: '0.9rem',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.6 : 1
}}
>
Cancel
</button>
</div>
</div>
)}
</div>
{error && (
<p style={{ color: 'var(--color-error, #ef4444)', marginTop: '1rem', fontSize: '0.9rem' }}>
{error}
</p>
)}
<p style={{ marginTop: '1.5rem', fontSize: '0.9rem' }}>
If you aren't on nostr yet, start here:{' '}
<a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">
nstart.me
</a>
</p>
</div>
)
}
export default LoginOptions

View File

@@ -0,0 +1,30 @@
import { useEffect } from 'react'
import { useLocation, useMatch } from 'react-router-dom'
export default function RouteDebug() {
const location = useLocation()
const matchArticle = useMatch('/a/:naddr')
useEffect(() => {
const params = new URLSearchParams(location.search)
if (params.get('debug') !== '1') return
const info: Record<string, unknown> = {
pathname: location.pathname,
search: location.search || null,
matchedArticleRoute: Boolean(matchArticle),
referrer: document.referrer || null
}
if (location.pathname === '/') {
// Unexpected during deep-link refresh tests
console.warn('[RouteDebug] unexpected root redirect', info)
} else {
console.debug('[RouteDebug]', info)
}
}, [location, matchArticle])
return null
}

View File

@@ -11,6 +11,7 @@ import ZapSettings from './Settings/ZapSettings'
import RelaySettings from './Settings/RelaySettings'
import PWASettings from './Settings/PWASettings'
import { useRelayStatus } from '../hooks/useRelayStatus'
import VersionFooter from './VersionFooter'
const DEFAULT_SETTINGS: UserSettings = {
collapseOnArticleOpen: true,
@@ -167,6 +168,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
</div>
<VersionFooter />
</div>
)
}

View File

@@ -117,32 +117,6 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
<span>Sync reading position across devices</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="autoScrollToPosition" className="checkbox-label">
<input
id="autoScrollToPosition"
type="checkbox"
checked={settings.autoScrollToPosition !== false}
onChange={(e) => onUpdate({ autoScrollToPosition: e.target.checked })}
className="setting-checkbox"
/>
<span>Auto-scroll to last reading position</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="autoMarkAsReadAt100" className="checkbox-label">
<input
id="autoMarkAsReadAt100"
type="checkbox"
checked={settings.autoMarkAsReadAt100 ?? false}
onChange={(e) => onUpdate({ autoMarkAsReadAt100: e.target.checked })}
className="setting-checkbox"
/>
<span>Automatically mark as read when reading progress is 100%</span>
</label>
</div>
</div>
)
}

View File

@@ -47,8 +47,6 @@ interface ThreePaneLayoutProps {
onRefresh: () => void
relayPool: RelayPool | null
eventStore: IEventStore | null
readingPositions?: Map<string, number>
markedAsReadIds?: Set<string>
// Content pane
readerLoading: boolean
@@ -326,8 +324,6 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
loading={props.bookmarksLoading}
relayPool={props.relayPool}
isMobile={isMobile}
readingPositions={props.readingPositions}
markedAsReadIds={props.markedAsReadIds}
settings={props.settings}
/>
</div>

View File

@@ -0,0 +1,32 @@
/* global __APP_VERSION__, __GIT_COMMIT__, __GIT_COMMIT_URL__, __RELEASE_URL__ */
import React from 'react'
const VersionFooter: React.FC = () => {
return (
<div className="text-xs opacity-60 mt-4 px-4 pb-3 select-text">
<span>
{typeof __RELEASE_URL__ !== 'undefined' && __RELEASE_URL__ ? (
<a href={__RELEASE_URL__} target="_blank" rel="noopener noreferrer">
Version {typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}
</a>
) : (
`Version ${typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}`
)}
</span>
{typeof __GIT_COMMIT__ !== 'undefined' && __GIT_COMMIT__ ? (
<span>
{' '}·{' '}
{typeof __GIT_COMMIT_URL__ !== 'undefined' && __GIT_COMMIT_URL__ ? (
<a href={__GIT_COMMIT_URL__} target="_blank" rel="noopener noreferrer">
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
</a>
) : (
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
)}
</span>
) : null}
</div>
)
}
export default VersionFooter

View File

@@ -7,6 +7,7 @@
export const RELAYS = [
'ws://localhost:10547',
'ws://localhost:4869',
'wss://relay.nsec.app',
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',

View File

@@ -1,16 +1,12 @@
import { useState, useEffect, useCallback } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IAccount, AccountManager } from 'applesauce-accounts'
import { IEventStore } from 'applesauce-core'
import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights'
import { fetchBookmarks } from '../services/bookmarkService'
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
import { fetchContacts } from '../services/contactService'
import { UserSettings } from '../services/settingsService'
import { loadReadingPosition, generateArticleIdentifier } from '../services/readingPositionService'
import { fetchReadArticles } from '../services/libraryService'
import { nip19 } from 'nostr-tools'
interface UseBookmarksDataParams {
relayPool: RelayPool | null
@@ -21,7 +17,6 @@ interface UseBookmarksDataParams {
currentArticleCoordinate?: string
currentArticleEventId?: string
settings?: UserSettings
eventStore?: IEventStore
}
export const useBookmarksData = ({
@@ -32,8 +27,7 @@ export const useBookmarksData = ({
externalUrl,
currentArticleCoordinate,
currentArticleEventId,
settings,
eventStore
settings
}: UseBookmarksDataParams) => {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [bookmarksLoading, setBookmarksLoading] = useState(true)
@@ -42,8 +36,6 @@ export const useBookmarksData = ({
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [isRefreshing, setIsRefreshing] = useState(false)
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
const [markedAsReadIds, setMarkedAsReadIds] = useState<Set<string>>(new Set())
const handleFetchContacts = useCallback(async () => {
if (!relayPool || !activeAccount) return
@@ -133,93 +125,6 @@ export const useBookmarksData = ({
handleFetchContacts()
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
// Fetch marked-as-read articles
useEffect(() => {
const loadMarkedAsRead = async () => {
if (!activeAccount || !relayPool || !eventStore || bookmarks.length === 0) {
return
}
try {
const readArticles = await fetchReadArticles(relayPool, activeAccount.pubkey)
// Create a set of bookmark IDs that are marked as read
const markedBookmarkIds = new Set<string>()
// For each read article, we need to match it to bookmark IDs
for (const readArticle of readArticles) {
// Add the event ID directly (for web bookmarks and legacy compatibility)
markedBookmarkIds.add(readArticle.id)
// For nostr-native articles (kind:7 reactions), also add the coordinate format
if (readArticle.eventId && readArticle.eventAuthor && readArticle.eventKind) {
// Try to get the event from the eventStore to find the 'd' tag
const event = eventStore.getEvent(readArticle.eventId)
if (event) {
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
markedBookmarkIds.add(coordinate)
}
}
}
setMarkedAsReadIds(markedBookmarkIds)
} catch (error) {
console.warn('⚠️ [Bookmarks] Failed to load marked-as-read articles:', error)
}
}
loadMarkedAsRead()
}, [relayPool, activeAccount, eventStore, bookmarks])
// Load reading positions for bookmarked articles (kind:30023)
useEffect(() => {
const loadPositions = async () => {
if (!activeAccount || !relayPool || !eventStore || bookmarks.length === 0 || !settings?.syncReadingPosition) {
return
}
const positions = new Map<string, number>()
// Extract all kind:30023 articles from bookmarks
const articles = bookmarks.flatMap(bookmark =>
(bookmark.individualBookmarks || []).filter(item => item.kind === 30023)
)
await Promise.all(
articles.map(async (article) => {
try {
const dTag = article.tags.find(t => t[0] === 'd')?.[1] || ''
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: article.pubkey,
identifier: dTag
})
const articleUrl = `nostr:${naddr}`
const identifier = generateArticleIdentifier(articleUrl)
const savedPosition = await loadReadingPosition(
relayPool,
eventStore,
activeAccount.pubkey,
identifier
)
if (savedPosition && savedPosition.position > 0) {
positions.set(article.id, savedPosition.position)
}
} catch (error) {
console.warn('⚠️ [Bookmarks] Failed to load reading position for article:', error)
}
})
)
setReadingPositions(positions)
}
loadPositions()
}, [bookmarks, activeAccount, relayPool, eventStore, settings?.syncReadingPosition])
return {
bookmarks,
bookmarksLoading,
@@ -232,9 +137,7 @@ export const useBookmarksData = ({
lastFetchTime,
handleFetchBookmarks,
handleFetchHighlights,
handleRefreshAll,
readingPositions,
markedAsReadIds
handleRefreshAll
}
}

View File

@@ -9,6 +9,7 @@ import { ReadableContent } from '../services/readerService'
import { createHighlight } from '../services/highlightCreationService'
import { HighlightButtonRef } from '../components/HighlightButton'
import { UserSettings } from '../services/settingsService'
import { useToast } from './useToast'
interface UseHighlightCreationParams {
activeAccount: IAccount | undefined
@@ -32,6 +33,7 @@ export const useHighlightCreation = ({
settings
}: UseHighlightCreationParams) => {
const highlightButtonRef = useRef<HighlightButtonRef>(null)
const { showToast } = useToast()
const handleTextSelection = useCallback((text: string) => {
highlightButtonRef.current?.updateSelection(text)
@@ -92,10 +94,19 @@ export const useHighlightCreation = ({
})
} catch (error) {
console.error('❌ Failed to create highlight:', error)
// Show user-friendly error messages
const errorMessage = error instanceof Error ? error.message : 'Failed to create highlight'
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
showToast('Reconnect bunker and approve signing permissions to create highlights')
} else {
showToast(`Failed to create highlight: ${errorMessage}`)
}
// Re-throw to allow parent to handle
throw error
}
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings])
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings, showToast])
return {
highlightButtonRef,

View File

@@ -11,6 +11,18 @@ type UnlockHiddenTagsFn = typeof Helpers.unlockHiddenTags
type HiddenContentSigner = Parameters<UnlockHiddenTagsFn>[1]
type UnlockMode = Parameters<UnlockHiddenTagsFn>[2]
/**
* Wrap a decrypt promise with a timeout to prevent hanging (using 30s timeout for bunker)
*/
function withDecryptTimeout<T>(promise: Promise<T>, timeoutMs = 30000): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(`Decrypt timeout after ${timeoutMs}ms`)), timeoutMs)
)
])
}
export async function collectBookmarksFromEvents(
bookmarkListEvents: NostrEvent[],
activeAccount: ActiveAccount,
@@ -80,7 +92,8 @@ export async function collectBookmarksFromEvents(
} catch {
try {
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
} catch {
} catch (err) {
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
// ignore
}
}
@@ -88,24 +101,26 @@ export async function collectBookmarksFromEvents(
let decryptedContent: string | undefined
try {
if (hasNip44Decrypt(signerCandidate)) {
decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(
decryptedContent = await withDecryptTimeout((signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(
evt.pubkey,
evt.content
)
))
}
} catch {
} catch (err) {
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
// ignore
}
if (!decryptedContent) {
try {
if (hasNip04Decrypt(signerCandidate)) {
decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(
decryptedContent = await withDecryptTimeout((signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(
evt.pubkey,
evt.content
)
))
}
} catch {
} catch (err) {
console.log("[bunker] ❌ nip04.decrypt failed:", err instanceof Error ? err.message : String(err))
// ignore
}
}
@@ -127,7 +142,7 @@ export async function collectBookmarksFromEvents(
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
// Don't set latestContent to decrypted JSON - it's not user-facing content
} catch {
} catch (err) {
// ignore
}
}

View File

@@ -85,7 +85,7 @@ export const fetchBookmarks = async (
}
// Aggregate across events
const maybeAccount = activeAccount as AccountWithExtension
console.log('🔐 Account object:', {
console.log('[bunker] 🔐 Account object:', {
hasSignEvent: typeof maybeAccount?.signEvent === 'function',
hasSigner: !!maybeAccount?.signer,
accountType: typeof maybeAccount,
@@ -102,12 +102,19 @@ export const fetchBookmarks = async (
signerCandidate = maybeAccount.signer
}
console.log('🔑 Signer candidate:', !!signerCandidate, typeof signerCandidate)
console.log('[bunker] 🔑 Signer candidate:', !!signerCandidate, typeof signerCandidate)
if (signerCandidate) {
console.log('🔑 Signer has nip04:', hasNip04Decrypt(signerCandidate))
console.log('🔑 Signer has nip44:', hasNip44Decrypt(signerCandidate))
console.log('[bunker] 🔑 Signer has nip04:', hasNip04Decrypt(signerCandidate))
console.log('[bunker] 🔑 Signer has nip44:', hasNip44Decrypt(signerCandidate))
}
const { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags } = await collectBookmarksFromEvents(
// Debug relay connectivity for bunker relays
try {
const urls = Array.from(relayPool.relays.values()).map(r => ({ url: r.url, connected: (r as unknown as { connected?: boolean }).connected }))
console.log('[bunker] Relay connections:', urls)
} catch (err) { console.warn('[bunker] Failed to read relay connections', err) }
const { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags } = await collectBookmarksFromEvents(
bookmarkListEvents,
activeAccount,
signerCandidate

View File

@@ -46,7 +46,8 @@ export async function createHighlight(
}
// Create EventFactory with the account as signer
const factory = new EventFactory({ signer: account })
console.log("[bunker] Creating EventFactory with signer:", { signerType: account.signer?.constructor?.name })
const factory = new EventFactory({ signer: account.signer })
let blueprintSource: NostrEvent | AddressPointer | string
let context: string | undefined
@@ -116,7 +117,9 @@ 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)

View File

@@ -1,12 +1,14 @@
import { Highlight } from '../types/highlights'
import { Bookmark } from '../types/bookmarks'
import { BlogPostPreview } from './exploreService'
import { ReadItem } from './readsService'
export interface MeCache {
highlights: Highlight[]
bookmarks: Bookmark[]
reads: ReadItem[]
links: ReadItem[]
readArticles: BlogPostPreview[]
reads?: ReadItem[]
links?: ReadItem[]
timestamp: number
}
@@ -22,14 +24,12 @@ export function setCachedMeData(
pubkey: string,
highlights: Highlight[],
bookmarks: Bookmark[],
reads: ReadItem[],
links: ReadItem[] = []
readArticles: BlogPostPreview[]
): void {
meCache.set(pubkey, {
highlights,
bookmarks,
reads,
links,
readArticles,
timestamp: Date.now()
})
}
@@ -48,10 +48,10 @@ export function updateCachedBookmarks(pubkey: string, bookmarks: Bookmark[]): vo
}
}
export function updateCachedReads(pubkey: string, reads: ReadItem[]): void {
export function updateCachedReadArticles(pubkey: string, readArticles: BlogPostPreview[]): void {
const existing = meCache.get(pubkey)
if (existing) {
meCache.set(pubkey, { ...existing, reads, timestamp: Date.now() })
meCache.set(pubkey, { ...existing, readArticles, timestamp: Date.now() })
}
}

View File

@@ -0,0 +1,26 @@
import { NostrConnectSigner } from 'applesauce-signers'
/**
* Get default NIP-46 permissions for bunker connections
* These permissions cover all event kinds and encryption/decryption operations Boris needs
*/
export function getDefaultBunkerPermissions(): string[] {
return [
// Signing permissions for event kinds we create
...NostrConnectSigner.buildSigningPermissions([
0, // Profile metadata
5, // Event deletion
7, // Reactions (nostr events)
17, // Reactions (websites)
9802, // Highlights
30078, // Settings & reading positions
39701, // Web bookmarks
]),
// Encryption/decryption for hidden content
'nip04_encrypt',
'nip04_decrypt',
'nip44_encrypt',
'nip44_decrypt',
]
}

View File

@@ -56,8 +56,6 @@ export interface UserSettings {
paragraphAlignment?: 'left' | 'justify' // default: justify
// Reading position sync
syncReadingPosition?: boolean // default: false (opt-in)
autoScrollToPosition?: boolean // default: true (auto-scroll to last reading position)
autoMarkAsReadAt100?: boolean // default: false (auto-mark as read when reaching 100% for 2 seconds)
}
export async function loadSettings(

View File

@@ -52,6 +52,11 @@ export async function publishEvent(
})
.catch((error) => {
console.warn('⚠️ Failed to publish event to relays (event still saved locally):', error)
// Surface common bunker signing errors for debugging
if (error instanceof Error && error.message.includes('permission')) {
console.warn('💡 Hint: This may be a bunker permission issue. Ensure your bunker connection has signing permissions.')
}
})
}

View File

@@ -216,72 +216,7 @@
.mark-as-read-btn:hover:not(:disabled) { background: var(--color-border); border-color: var(--color-text-muted); transform: translateY(-1px); }
.mark-as-read-btn:active:not(:disabled) { transform: translateY(0); }
.mark-as-read-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.mark-as-read-btn svg { font-size: 1.1rem; transition: transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); }
/* Fancy Mark as Read animation */
@keyframes markAsReadSuccess {
0% {
background: var(--color-bg-elevated);
border-color: var(--color-border-subtle);
transform: scale(1);
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
10% {
transform: scale(1.05);
box-shadow: 0 0 0 8px rgba(16, 185, 129, 0.3);
}
25% {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border-color: #10b981;
color: white;
transform: scale(1.02);
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.4);
}
65% {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border-color: #10b981;
color: white;
transform: scale(1.02);
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.4);
}
100% {
background: #6b7280;
border-color: #6b7280;
color: white;
transform: scale(1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
}
@keyframes iconSpin {
0% {
transform: rotate(0deg) scale(1);
}
15% {
transform: rotate(0deg) scale(1.2);
}
50% {
transform: rotate(360deg) scale(1.2);
}
100% {
transform: rotate(360deg) scale(1);
}
}
.mark-as-read-btn.animating {
animation: markAsReadSuccess 2.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
pointer-events: none;
}
.mark-as-read-btn.animating svg {
animation: iconSpin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.mark-as-read-btn.marked {
background: #6b7280;
border-color: #6b7280;
color: white;
}
.mark-as-read-btn svg { font-size: 1.1rem; }
@media (max-width: 768px) {
.reader {
max-width: 100%;

View File

@@ -211,12 +211,3 @@
background: transparent;
}
/* Reading progress filters in bookmarks sidebar - add top border, remove bottom border to avoid double border with view-mode-controls */
.reading-progress-filters-wrapper {
border-top: 1px solid var(--color-border);
}
.reading-progress-filters-wrapper .bookmark-filters {
border-bottom: none;
}

36
src/utils/debugBus.ts Normal file
View File

@@ -0,0 +1,36 @@
export type DebugLevel = 'info' | 'warn' | 'error'
export interface DebugLogEntry {
ts: number
level: DebugLevel
source: string
message: string
data?: unknown
}
type Listener = (entry: DebugLogEntry) => void
const listeners = new Set<Listener>()
const buffer: DebugLogEntry[] = []
const MAX_BUFFER = 300
export const DebugBus = {
log(level: DebugLevel, source: string, message: string, data?: unknown): void {
const entry: DebugLogEntry = { ts: Date.now(), level, source, message, data }
buffer.push(entry)
if (buffer.length > MAX_BUFFER) buffer.shift()
listeners.forEach(l => {
try { l(entry) } catch (err) { console.warn('[DebugBus] listener error:', err) }
})
},
info(source: string, message: string, data?: unknown): void { this.log('info', source, message, data) },
warn(source: string, message: string, data?: unknown): void { this.log('warn', source, message, data) },
error(source: string, message: string, data?: unknown): void { this.log('error', source, message, data) },
subscribe(listener: Listener): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
},
snapshot(): DebugLogEntry[] { return buffer.slice() }
}

8
src/vite-env.d.ts vendored
View File

@@ -8,3 +8,11 @@ declare module '*.svg?raw' {
const content: string
export default content
}
// Build-time defines injected by Vite in vite.config.ts
declare const __APP_VERSION__: string
declare const __GIT_COMMIT__: string
declare const __GIT_BRANCH__: string
declare const __BUILD_TIME__: string
declare const __GIT_COMMIT_URL__: string
declare const __RELEASE_URL__: string

View File

@@ -1,5 +1,16 @@
{
"rewrites": [
{
"source": "/a/:naddr",
"has": [
{
"type": "header",
"key": "user-agent",
"value": ".*(bot|crawl|spider|slurp|facebook|twitter|linkedin|whatsapp|telegram|slack|discord|preview).*"
}
],
"destination": "/api/article-og?naddr=:naddr"
},
{
"source": "/(.*)",
"destination": "/index.html"

View File

@@ -1,8 +1,101 @@
/* eslint-env node */
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
import { readFileSync } from 'node:fs'
import { execSync } from 'node:child_process'
function getGitMetadata() {
const envSha = process.env.VERCEL_GIT_COMMIT_SHA || ''
const envRef = process.env.VERCEL_GIT_COMMIT_REF || ''
let commit = envSha
let branch = envRef
try {
if (!commit) commit = execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
} catch {
// ignore
}
try {
if (!branch) branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
} catch {
// ignore
}
return { commit, branch }
}
function getPackageVersion() {
try {
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)).toString())
return pkg.version as string
} catch {
return '0.0.0'
}
}
const { commit, branch } = getGitMetadata()
const version = getPackageVersion()
const buildTime = new Date().toISOString()
function getReleaseUrl(version: string): string {
if (!version) return ''
const provider = process.env.VERCEL_GIT_PROVIDER || ''
const owner = process.env.VERCEL_GIT_REPO_OWNER || ''
const slug = process.env.VERCEL_GIT_REPO_SLUG || ''
if (provider.toLowerCase() === 'github' && owner && slug) {
return `https://github.com/${owner}/${slug}/releases/tag/v${version}`
}
try {
const remote = execSync('git config --get remote.origin.url', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
if (remote.includes('github.com')) {
// git@github.com:owner/repo.git or https://github.com/owner/repo.git
const https = remote.startsWith('git@')
? `https://github.com/${remote.split(':')[1]}`
: remote
const cleaned = https.replace(/\.git$/, '')
return `${cleaned}/releases/tag/v${version}`
}
} catch {
// ignore
}
return ''
}
function getCommitUrl(commit: string): string {
if (!commit) return ''
const provider = process.env.VERCEL_GIT_PROVIDER || ''
const owner = process.env.VERCEL_GIT_REPO_OWNER || ''
const slug = process.env.VERCEL_GIT_REPO_SLUG || ''
if (provider.toLowerCase() === 'github' && owner && slug) {
return `https://github.com/${owner}/${slug}/commit/${commit}`
}
try {
const remote = execSync('git config --get remote.origin.url', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
if (remote.includes('github.com')) {
// git@github.com:owner/repo.git or https://github.com/owner/repo.git
const https = remote.startsWith('git@')
? `https://github.com/${remote.split(':')[1]}`
: remote
const cleaned = https.replace(/\.git$/, '')
return `${cleaned}/commit/${commit}`
}
} catch {
// ignore
}
return ''
}
const releaseUrl = getReleaseUrl(version)
const commitUrl = getCommitUrl(commit)
export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(version),
__GIT_COMMIT__: JSON.stringify(commit),
__GIT_BRANCH__: JSON.stringify(branch),
__BUILD_TIME__: JSON.stringify(buildTime),
__GIT_COMMIT_URL__: JSON.stringify(commitUrl),
__RELEASE_URL__: JSON.stringify(releaseUrl)
},
plugins: [
react(),
VitePWA({
@@ -48,7 +141,7 @@ export default defineConfig({
mainFields: ['module', 'jsnext:main', 'jsnext', 'main']
},
optimizeDeps: {
include: ['applesauce-core', 'applesauce-factory', 'applesauce-relay', 'applesauce-react'],
include: ['applesauce-core', 'applesauce-factory', 'applesauce-relay', 'applesauce-react', 'applesauce-accounts', 'applesauce-signers'],
esbuildOptions: {
resolveExtensions: ['.js', '.ts', '.tsx', '.json']
}
@@ -65,7 +158,7 @@ export default defineConfig({
}
},
ssr: {
noExternal: ['applesauce-core', 'applesauce-factory', 'applesauce-relay']
noExternal: ['applesauce-core', 'applesauce-factory', 'applesauce-relay', 'applesauce-accounts', 'applesauce-signers']
}
})