Compare commits

...

384 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
5f6a414953 fix: resolve all linter errors and type issues
- Remove unused state variables (readsMap, linksMap) by using only setters
- Move VALID_FILTERS constant outside component to fix exhaustive-deps warning
- Remove unused isReading variable in ReadingProgressIndicator
- Remove unused extractUrlFromBookmark function and IndividualBookmark import
- Fix type errors in linksFromBookmarks by extracting metadata from tags instead of non-existent properties
2025-10-16 09:36:17 +02:00
Gigi
ed17a68986 refactor: simplify filter icon colors to blue (except green for completed) 2025-10-16 09:33:04 +02:00
Gigi
bedf3daed1 feat: add URL routing for reading progress filters 2025-10-16 09:32:30 +02:00
Gigi
2c913cf7e8 feat: color reading progress filter icons when active 2025-10-16 09:30:16 +02:00
Gigi
aff5bff03b refactor: use neutral text color for 'started' reading progress state 2025-10-16 09:29:41 +02:00
Gigi
e90f902f0b feat: add amber color for 'started' reading progress state (0-10%) 2025-10-16 09:28:06 +02:00
Gigi
d763aa5f15 fix: merge reading progress even when timestamp is older than bookmark 2025-10-16 09:20:24 +02:00
Gigi
9d6b1f6f84 fix: call onItem callback directly for items already in reads map 2025-10-16 09:18:32 +02:00
Gigi
9eb2f35dbf debug: add console logging to trace reading progress enrichment 2025-10-16 09:13:34 +02:00
Gigi
5f33ad3ba0 fix(reads): use setState callback pattern for background enrichment
- Replace closure over tempMap with setState callback pattern
- Ensures we always work with latest state when merging progress
- Prevents stale closure issues that block state updates
- Apply same fix to both reads and links tabs
- Fixes reading progress not updating in UI
2025-10-16 09:13:19 +02:00
Gigi
3db4855532 fix(reads): use naddr format for IDs to match reading positions
- Convert bookmark coordinates to naddr format in deriveReadsFromBookmarks
- Reading positions store progress with naddr as ID
- Using naddr format enables proper merging of reading progress data
- Simplify getReadItemUrl to use item.id directly (already naddr)
- Fixes reading progress not showing in /me/reads tab
2025-10-16 09:11:21 +02:00
Gigi
3305be1da5 feat(reads): extract image, summary, and published date from bookmark tags
- Extract metadata from tags same way BookmarkItem does (DRY)
- Add image tag extraction for article images
- Add summary tag extraction for article summaries
- Add published_at tag extraction for publish dates
- Images and summaries now display in /me/reads tab
2025-10-16 09:08:57 +02:00
Gigi
fe55e87496 fix: remove unused import from readsFromBookmarks 2025-10-16 09:06:06 +02:00
Gigi
f78f1a3460 fix(reads): use bookmark.content for article titles
- IndividualBookmark doesn't have separate title/event fields
- After hydration, article titles are stored in content field
- Simplified extraction logic to just use bookmark.content
2025-10-16 09:06:00 +02:00
Gigi
e73d89739b fix(reads): extract article titles from events using applesauce helpers
- Use getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished from Helpers
- Extract metadata from bookmark.event when available
- Fallback to bookmark fields if event not hydrated
- Fixes 'Untitled' articles in Reads tab
2025-10-16 09:01:51 +02:00
Gigi
7e2b4b46c9 feat(me): populate reads/links from bookmarks instantly
- Add deriveReadsFromBookmarks helper to convert 30023 bookmarks to ReadItems
- Add deriveLinksFromBookmarks helper for web bookmarks (39701) and URLs
- Update loadReadsTab to show bookmarked articles immediately, enrich in background
- Update loadLinksTab to show bookmarked links immediately, enrich in background
- Background enrichment merges reading progress only for displayed items
- Preserve existing pull-to-refresh and empty state logic
2025-10-16 08:45:31 +02:00
Gigi
fddf79e0c6 feat: add named kind constants, streaming updates, and fix reads/links tabs
- Create src/config/kinds.ts with named Nostr kind constants
- Add streaming support to fetchAllReads and fetchLinks with onItem callbacks
- Update all services to use KINDS constants instead of magic numbers
- Add mergeReadItem utility for DRY state management
- Add fallbackTitleFromUrl for external links without titles
- Relax validation to allow external items without titles
- Update Me.tsx to use streaming with Map-based state for reads/links
- Fix refresh to merge new data instead of clearing state
- Fix empty states for Reads and Links tabs (no more infinite skeletons)
- Services updated: readsService, linksService, libraryService, bookmarkService, exploreService, highlights/fetchByAuthor
2025-10-16 08:27:10 +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
e6876d141f fix: show skeletons during initial tab load for reads/links 2025-10-16 01:43:36 +02:00
Gigi
5bb81b3c22 fix: always show skeletons for reads/links when no data
Removed empty state messages like "No articles in your reads" and
"No links yet" - now just show loading skeletons until data arrives.

This is simpler and prevents showing empty states while data is still
being fetched in the background.

Users will only see:
- Skeletons when no data (loading or truly empty)
- "No articles/links match this filter" when filtered out
- Actual content when data is available
2025-10-16 01:40:37 +02:00
Gigi
1e8e58fa05 fix: show loading skeletons correctly for reads and links tabs
The bug was that showSkeletons checked if ANY tab had data, so if you
had highlights or bookmarks, it would never show skeletons for reads/links
even while they were still loading.

Fix: Each tab now checks its own loading state (loading && tabData.length === 0)
instead of using the shared showSkeletons variable.

This makes the logic simple and clear:
1. If loading AND no data → show skeletons
2. If not loading AND no data → show empty state
3. If has data but filtered out → show no match message
4. Otherwise → show content
2025-10-16 01:39:03 +02:00
Gigi
f44e36e4bf refactor: make code more DRY by extracting shared utilities
- Create readingProgressUtils.ts with filterByReadingProgress function
- Create readingDataProcessor.ts with shared processing functions:
  - processReadingPositions
  - processMarkedAsRead
  - filterValidItems
  - sortByReadingActivity
- Refactor readsService.ts to use shared utilities
- Refactor linksService.ts to use shared utilities
- Eliminate 100+ lines of duplicated code
- Simplify Me.tsx filter logic to 2 lines

Benefits:
- Single source of truth for reading progress filtering
- Easier to maintain and modify
- Less code duplication across services
- More testable with isolated utility functions
2025-10-16 01:36:28 +02:00
Gigi
11c7564f8c feat: split Reads tab into Reads and Links
- Reads: Only Nostr-native articles (kind:30023)
- Links: Only external URLs with reading progress
- Create linksService.ts for fetching external URL links
- Update readsService to filter only Nostr articles
- Add Links tab between Reads and Writings with same filtering
- Add /me/links route
- Update meCache to include links field
- Both tabs support reading progress filters
- Lazy loading for both tabs

This provides clear separation between native Nostr content and external web links.
2025-10-16 01:33:04 +02:00
Gigi
a064376bd8 fix: filter out 'Untitled' items from Reads tab
- Exclude Nostr articles without event data (can't fetch title)
- Exclude external URLs without proper titles
- Prevents cluttering Reads with items that have no meaningful title
- Only shows items we can properly identify and display
2025-10-16 01:25:31 +02:00
Gigi
292e8e9bda fix: only show external URLs in Reads if they have reading progress
- External URLs with 0% progress are now filtered out
- External URLs only appear if readingProgress > 0 OR marked as read
- Nostr articles still show even at 0% (bookmarked articles)
- Keeps Reads tab focused on actual reading activity for external links
2025-10-16 01:24:50 +02:00
Gigi
951a3699ca fix: replace spinners with skeleton placeholders in Me tabs
- Replace spinner in highlights tab with 'No highlights yet' message
- Replace spinner in reading-list tab with 'No bookmarks yet' message
- Only show these messages when loading is complete and arrays are empty
- Remove unused faSpinner import
- Consistent with skeleton placeholder pattern used elsewhere
2025-10-16 01:21:31 +02:00
Gigi
860ec70b1c feat: implement lazy loading for Me component tabs
- Add loadedTabs state to track which tabs have been loaded
- Create tab-specific loading functions (loadHighlightsTab, loadWritingsTab, loadReadingListTab, loadReadsTab)
- Only load data for active tab on mount and tab switches
- Show cached data immediately, refresh in background when revisiting tabs
- Update pull-to-refresh to only reload the active tab
- Show loading skeletons only on first load of each tab
- Works for both /me (own profile) and /p/ (other profiles)

This reduces initial load time from 30+ seconds to 2-5 seconds by only fetching data for the active tab.
2025-10-16 01:19:06 +02:00
Gigi
2b69c72939 refactor: simplify loading state to use unified logic
- Remove separate loadingReads state
- Keep single loading state true until ALL data is loaded
- Matches existing pattern used in other tabs
- Keeps code DRY and simple
2025-10-16 01:08:56 +02:00
Gigi
b98d774cbf fix: filter out reads without timestamps
- Exclude items without readingTimestamp or markedAt from reads
- Prevents 'Just Now' items from appearing in the reads list
- Only show reads with valid activity timestamps
2025-10-16 01:06:27 +02:00
Gigi
8972571a18 fix: keep showing skeletons while reads are loading
- Add separate loadingReads state to track reads fetching
- Show skeletons during the entire reads loading period
- Set loading=false after public data (highlights/writings) completes
- Prevents showing 'No articles match this filter' while reads are being fetched
2025-10-16 01:05:42 +02:00
Gigi
ab5d5dca58 debug: add logging to reads filtering 2025-10-16 00:59:28 +02:00
Gigi
e383356af1 feat: rename Archive to Reads and expand functionality
- Create new readsService to aggregate all read content from multiple sources
- Include bookmarked articles, reading progress tracked articles, and manually marked-as-read items
- Update Me component to use new reads service
- Update routes from /me/archive to /me/reads
- Update meCache to use ReadItem[] instead of BlogPostPreview[]
- Update filter logic to use actual reading progress data
- Support both Nostr-native articles and external URLs in reads
- Fetch and display article metadata from multiple sources
- Sort by most recent reading activity
2025-10-16 00:45:16 +02:00
Gigi
165d10c49b feat: split 'To read' filter into 'Unopened' and 'Started'
- Add 'unopened' filter (no progress, 0%) - uses fa-envelope icon
- Add 'started' filter (0-10% progress) - uses fa-envelope-open icon
- Remove 'to-read' filter
- Use classic/regular variant for envelope icons
- Update filter logic in BookmarkList and Me components
- New filter ranges:
  - Unopened: 0% (never opened)
  - Started: 0-10% (opened but not read far)
  - Reading: 11-94%
  - Completed: 95-100%
2025-10-16 00:13:34 +02:00
Gigi
e0869c436b fix: adjust 'Reading' filter to 11-94% range
- Change 'reading' filter from 10-95% to 11-94%
- Creates clearer boundaries between filters:
  - To read: 0-10%
  - Reading: 11-94%
  - Completed: 95-100%
2025-10-16 00:10:20 +02:00
Gigi
95432fc276 fix: reading position filters now work correctly in bookmarks
- Match marked-as-read event IDs to bookmark coordinate IDs
- Use eventStore to lookup events and build coordinates from them
- Add both event ID and coordinate format to markedAsReadIds set
- This fixes filtering of bookmarked articles by reading progress
- Apply same fix to both Bookmarks and Explore components
2025-10-15 23:54:44 +02:00
Gigi
1982d25fa8 feat: add fancy animation to Mark as Read button
- Icon spins 360° with bounce effect (scale up during spin)
- Button background changes to vibrant green gradient (#10b981)
- Green pulsing box-shadow effect on activation
- Button scales up slightly on click for emphasis
- Holds green state for 1.5 seconds
- Smoothly fades to gray after animation
- Final state is gray button to indicate marked status
- Uses cubic-bezier easing for modern, smooth feel
- Total animation duration: 2.5 seconds
- Prevents interaction during animation
2025-10-15 23:39:14 +02:00
Gigi
2fc64b6028 feat: change 'To read' filter to show 0-10% progress
- Update 'to-read' filter range from 0-5% to 0-10%
- Update 'reading' filter to start at 10% instead of 5%
- Adjust filter comments to reflect new ranges
2025-10-15 23:37:59 +02:00
Gigi
6e8686a49d feat: treat marked-as-read articles as 100% progress
- Fetch marked-as-read articles in useBookmarksData and Explore
- Pass markedAsReadIds through component chain (Bookmarks -> ThreePaneLayout -> BookmarkList)
- Display 100% progress for marked articles in all views (Archive, Bookmarks, Explore)
- Update filter logic to treat marked articles as completed
- Marked articles show green 100% progress bar
- Marked articles only appear in 'completed' or 'all' filters
- Remove reading position tracking from Me.tsx (not needed when all are marked)
- Clean up unused imports and variables
2025-10-15 23:36:05 +02:00
Gigi
fd5ce80a06 feat: add auto-mark as read at 100% reading progress
- Add autoMarkAsReadAt100 setting (default: false)
- Add checkbox in Layout & Behavior settings
- Automatically mark article as read after 2 seconds at 100% progress
- Trigger same animation as manual mark as read button
- Move isNostrArticle computation earlier for useCallback deps
- Move handleMarkAsRead to useCallback for use in auto-mark effect
2025-10-15 23:28:50 +02:00
Gigi
ac4185e2cc feat: merge 'Completed' and 'Marked as Read' filters into one
- Remove 'marked' filter type from ReadingProgressFilterType
- Update ReadingProgressFilters component to show only 4 filters
- Keep checkmark icon for unified 'Completed' filter
- Completed filter now shows both:
  - Articles with 95%+ reading progress
  - Articles manually marked as read (no position data or 0%)
- Remove unused faBooks icon import
- Update filter logic in BookmarkList and Me components
2025-10-15 23:22:40 +02:00
Gigi
9217077283 fix: replace spinners with skeletons during refresh in archive/writings tabs
- Changed spinner to empty state message only when not loading
- During refresh, keeps showing cached content or skeletons
- Archive: shows 'No articles in your archive' only when done loading
- Writings: shows 'No articles written yet' only when done loading
- Prevents jarring transition from skeletons to spinner during refresh
2025-10-15 23:20:54 +02:00
Gigi
b7c14b5c7c fix: restore top padding to reading progress filters
- Remove padding-top: 0 override
- Now has equal spacing top and bottom (0.5rem)
2025-10-15 23:18:31 +02:00
Gigi
9b3cc41770 refactor: rename ArchiveFilters to ReadingProgressFilters
- More accurate naming: filters are based on reading progress/position
- Renamed component: ArchiveFilters -> ReadingProgressFilters
- Renamed type: ArchiveFilterType -> ReadingProgressFilterType
- Renamed variables: archiveFilter -> readingProgressFilter
- Renamed CSS class: archive-filters-wrapper -> reading-progress-filters-wrapper
- Updated all imports and references in BookmarkList and Me components
- Updated comments to reflect reading progress filtering
2025-10-15 23:17:55 +02:00
Gigi
4c4bd2214c feat: add top border to archive filters in bookmarks sidebar
- Matches the style of bookmark type filters at top
- Visually separates archive filters from bookmarks content
2025-10-15 23:14:56 +02:00
Gigi
93c31650f4 fix: remove double border between archive filters and view controls
- Add archive-filters-wrapper class
- Remove border-bottom from bookmark-filters in wrapper
- Prevents double border (bookmark-filters border-bottom + view-mode-controls border-top)
2025-10-15 23:14:20 +02:00
Gigi
7f0d99fc29 fix: remove duplicate border between archive filters and view controls
- Remove borderTop from archive filters div
- Keep only the border from view-mode-controls CSS
2025-10-15 23:12:26 +02:00
Gigi
eb6dbe1644 feat: add archive filters to bookmarks sidebar
- Add ArchiveFilters component to bookmarks sidebar
- Filter buttons shown above view-mode-controls row
- Filters: All, To Read (0-5%), Reading (5-95%), Completed (95%+), Marked
- Only shown when kind:30023 articles are present
- Filters only apply to kind:30023 articles
- Other bookmark types (videos, notes, web) remain visible
2025-10-15 23:10:31 +02:00
Gigi
474da25f77 fix: add autoScrollToPosition to useEffect dependency array
- Fixes react-hooks/exhaustive-deps warning
- Ensures effect reruns when auto-scroll setting changes
2025-10-15 23:08:21 +02:00
Gigi
02eaa1c8f8 feat: show reading progress in Explore and Bookmarks sidebar
- Add reading position loading to Explore component
- Add reading position loading to useBookmarksData hook
- Display progress bars in Explore tab blog posts
- Display progress bars in Bookmarks large preview view
- Progress shown as colored bar (green for completed, orange for in-progress)
- Only shown for kind:30023 articles with saved reading positions
- Requires syncReadingPosition setting to be enabled
2025-10-15 23:07:18 +02:00
Gigi
8800791723 feat: add auto-scroll to reading position setting
- Add autoScrollToPosition setting (default: true)
- Add checkbox in Layout & Behavior settings
- Only auto-scroll when setting is enabled
- Allows users to disable auto-scrolling while keeping sync enabled
2025-10-15 22:53:47 +02:00
Gigi
6758b9678b fix: update 'To Read' filter to show 0-5% progress articles
- Filter now shows articles with 0-5% reading progress
- Excludes manually marked as read articles (those without position data)
- Updates comment to reflect new logic
2025-10-15 22:51:40 +02:00
Gigi
63f58e010f feat: use classic/regular bookmark icon for To Read filter
- Change from solid bookmark to regular (outline) bookmark icon
- Matches classic FontAwesome bookmark style
2025-10-15 22:46:15 +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
Gigi
d0b814e39d fix: update Archive filter icons for consistency
- Change 'All' icon to asterisk (*) to match Bookmarks filter
- Change 'Marked as Read' icon to faBooks (custom icon)
- Maintains consistent iconography across filter types
2025-10-15 22:40:52 +02:00
Gigi
f4a227e40a fix: improve reading position calculation to reach 100%
- Add 5px threshold to detect when scrolled to bottom
- Set position to exactly 1.0 (100%) when within 5px of bottom
- Remove upper limit on saving positions (now saves 100% completion)
- Always save when reaching 100% completion (important milestone)
- Don't restore position for completed articles (100%), start from top
- Better handling of edge cases in position detection
- Matches ReadingProgressIndicator calculation logic
2025-10-15 22:39:51 +02:00
Gigi
6ef0a6dd71 refactor: match ArchiveFilters styling to BookmarkFilters
- Use same CSS classes (filter-btn) as BookmarkFilters
- Show icons only, no text labels for consistency
- Add title and aria-label for accessibility
- Keep code DRY by following established pattern
2025-10-15 22:35:45 +02:00
Gigi
5502d71ac4 feat: add filter buttons to Archive tab
- Create ArchiveFilters component with 5 filter options
- All: Show all archived articles
- To Read: Articles with 0% progress (not started)
- Reading: Articles with progress between 0-95%
- Completed: Articles with 95%+ reading progress
- Marked: Manually marked as read (no position data)
- Filter logic based on reading position data
- Show empty state when no articles match filter
- Matches BookmarkFilters styling and UX pattern
2025-10-15 22:30:44 +02:00
Gigi
5e1146b015 fix: position reading progress bar as dividing line in cards
- Move progress indicator between summary and meta sections
- Replace the border-top dividing line with progress bar
- Show 3px progress bar when reading position exists
- Show 1px gray divider when no progress (maintains original look)
- Remove absolute positioning from bottom of card
- Remove border-top from meta section to avoid double lines
2025-10-15 22:26:48 +02:00
Gigi
8f89165711 debug: add comprehensive logging for reading position sync
- Add detailed console logs with emoji prefixes for easy filtering
- Log save/load operations in readingPositionService
- Log position restore in ContentPanel with requirements check
- Log Archive tab position loading with article details
- All logs prefixed with component/service name for clarity
- Log shows position percentages, identifiers, and timestamps
- Helps debug why positions may not be showing or syncing
2025-10-15 22:23:40 +02:00
Gigi
674634326f feat: add visual reading progress indicator to archive cards
- Display reading position as a horizontal progress bar at bottom of blog post cards
- Use blue (#6366f1) for progress <95%, green (#10b981) for >=95% complete
- Load reading positions for all articles in Archive tab
- Progress bar fills from left to right showing how much has been read
- Only shown when reading progress exists and is >0%
- Smooth transition animations on progress updates
2025-10-15 22:19:18 +02:00
Gigi
30eaec5770 refactor: remove redundant handleHighlightClick from Explore
- HighlightItem now handles navigation internally
- Remove duplicate navigation logic from Explore component
- Simplifies code and ensures consistent behavior across all highlight displays
2025-10-15 22:13:14 +02:00
Gigi
0ff3c864a9 feat: add click-to-open article navigation on highlights
- Click on highlights in /me/highlights or /p/:npub pages to open referenced article
- Parse eventReference to detect kind:30023 articles and navigate to /a/{naddr}
- Fall back to urlReference for external URLs, navigate to /r/{url}
- Maintain backward compatibility with existing onHighlightClick prop
- Show pointer cursor when highlight has navigable reference
2025-10-15 22:12:03 +02:00
Gigi
ab2ca1f5e7 fix: remove unused IEventStore import in ContentPanel 2025-10-15 22:09:58 +02:00
Gigi
cf2d227f61 feat: add reading position sync across devices using Nostr Kind 30078
- Create readingPositionService.ts for save/load operations
- Add syncReadingPosition setting (opt-in via Settings > Layout & Behavior)
- Enhance useReadingPosition hook with auto-save (debounced 5s) and immediate save on navigation
- Integrate position restore in ContentPanel with smooth scroll to saved position
- Support both Nostr articles (naddr) and external URLs
- Reading positions stored privately to user's relays
- Auto-save excludes first 5% and last 5% of content to avoid noise
- Position automatically restored when returning to article
2025-10-15 22:08:12 +02:00
Gigi
2c9e6cc54e docs: update CHANGELOG.md for v0.6.20 2025-10-15 21:54:02 +02:00
Gigi
8da0a06711 chore: bump version to 0.6.20 2025-10-15 21:53:06 +02:00
Gigi
be8d857223 Merge pull request #12 from dergigi/bookmark-filter-buttons
Add bookmark filter buttons by content type
2025-10-15 21:52:37 +02:00
Gigi
d50bcd700e fix(ui): make highlight button fixed to viewport 2025-10-15 21:51:24 +02:00
Gigi
820ab1d902 fix(ui): make highlight button sticky and always visible
- Wrap button in sticky positioned container with height: 0
- Button now floats and stays visible while scrolling
- Remains within reader pane boundaries on desktop
- Uses flexbox to align button to the right side
2025-10-15 21:48:41 +02:00
Gigi
f5e9e5bf61 fix(ui): position highlight button inside reader pane
- Move HighlightButton from fixed viewport positioning to absolute positioning within main pane
- Add position: relative to .pane.main for both desktop and mobile layouts
- Button now stays within the article/reader view instead of floating outside on desktop
- Maintains proper z-index and responsive behavior
2025-10-15 21:47:28 +02:00
Gigi
40b43532e8 style: use faLink icon for external articles
- Replace faArrowUpRightFromSquare with simpler faLink icon
- More concise visual representation for external article links
2025-10-15 21:40:31 +02:00
Gigi
51a3008730 feat: add separate filter for external articles with distinct icon
- Add 'external' type to differentiate external article links from nostr-native articles
- Nostr-native articles (kind:30023) use newspaper icon
- External article links use arrow-up-right icon (faArrowUpRightFromSquare)
- Add new 'External Articles' filter button
- Update classification logic and icon display accordingly
2025-10-15 21:39:10 +02:00
Gigi
e30cbc72c3 style: dramatically reduce whitespace around bookmark filters
- Remove all padding from filter buttons
- Reduce top padding from 0.75rem to 0.25rem
- Reduce bottom margin from 0.5rem to 0.25rem
- Much tighter, more compact layout
2025-10-15 21:35:44 +02:00
Gigi
6f913262f4 style: reduce whitespace around bookmark filters on /me page
- Reduce padding on bookmark filters from 1rem to 0.5rem
- Reduce top padding of tab content when filters are present
- Tighten spacing for more compact layout
2025-10-15 21:35:11 +02:00
Gigi
0f0462e6ac feat: add bookmark filters to /me page bookmarks tab
- Add filter buttons to reading-list tab in Me component
- Apply same filtering logic as main bookmarks sidebar
- Center-align filters and remove border for cleaner look
- Show empty state message when no bookmarks match filter
2025-10-15 21:24:19 +02:00
Gigi
e353f0e2d6 style: refine bookmark filter buttons
- Make buttons smaller (32px) and more compact
- Remove borders for cleaner look
- Active state uses primary color without background
- Match icon styling used on bookmark cards
2025-10-15 21:19:16 +02:00
Gigi
ee1365d3ca feat: add bookmark filter buttons by content type
- Add BookmarkFilters component with icon-based filter buttons
- Create bookmarkTypeClassifier utility for content type classification
- Filter bookmarks by article, video, note, or web types
- Apply filters across all bookmark lists (private, public, web, sets)
- Style filter buttons to match existing UI design
2025-10-15 21:17:27 +02:00
Gigi
a215d0b026 refactor: remove lock icon from individual bookmarks
- Private bookmarks are now grouped in 'Private Bookmarks' section
- No need for redundant lock icon on each individual bookmark
- Cleaner UI with less visual clutter
- Removed faUserLock import and conditional rendering from all three views
2025-10-15 20:37:40 +02:00
Gigi
b8d76c0bd8 feat: move encrypted legacy bookmarks to Private Bookmarks section
- Only non-encrypted legacy bookmarks (kind:30001) now appear in Legacy section
- Encrypted legacy bookmarks are grouped with other private bookmarks
- Improves organization by grouping by privacy level rather than source
2025-10-15 20:36:00 +02:00
Gigi
233169b082 feat: improve bookmark section labels for clarity
- Capitalize all bookmark section labels for consistency
- Change 'Old Bookmarks (Legacy)' to 'Legacy Bookmarks' for cleaner look
- Updated labels in both BookmarkList and Me components
2025-10-15 20:35:19 +02:00
Gigi
72b9a04cd2 docs: update CHANGELOG.md for v0.6.19 2025-10-15 20:01:43 +02:00
Gigi
432715efb6 chore: bump version to 0.6.19 2025-10-15 20:01:07 +02:00
Gigi
8b2b954dde fix: prevent useBookmarksData from overwriting external URL highlights
The issue was that useBookmarksData was fetching general highlights
whenever there was no naddr, which included external URL routes (/r/*).
This caused the URL-specific highlights loaded by useExternalUrlLoader
to be overwritten after a couple seconds.

Now we skip fetching general highlights when viewing external URLs,
letting useExternalUrlLoader manage those highlights instead.
2025-10-15 19:59:54 +02:00
Gigi
c2d2bd8106 fix: prevent highlights from disappearing on external URLs
- Improve error handling in fetchHighlightsForUrl to prevent silent failures
- Remove redundant setHighlights call that was overwriting streamed highlights
- Add logging to help diagnose highlight fetching issues
- Isolate rebroadcast errors so they don't break highlight display
2025-10-15 19:56:07 +02:00
Gigi
a5c3085c59 docs: update CHANGELOG.md for v0.6.18 2025-10-15 19:49:13 +02:00
Gigi
c0332f08d6 chore: bump version to 0.6.18 2025-10-15 19:48:00 +02:00
Gigi
38a1d6caec fix: always show PWA install section with disabled button states 2025-10-15 19:43:44 +02:00
Gigi
39dd607e7b style: make zap preset buttons expand to match slider width on desktop 2025-10-15 19:43:11 +02:00
Gigi
9dc0db3e06 fix: always show App & Airplane Mode section regardless of PWA status 2025-10-15 19:42:27 +02:00
Gigi
b1eb58a385 fix: display zap split share and percentage on same line 2025-10-15 19:41:26 +02:00
Gigi
f3c6404f76 refactor: simplify zap split labels and update terminology 2025-10-15 19:39:04 +02:00
Gigi
1a42a6422d fix: disable PWA install button when installation is not possible on device 2025-10-15 19:37:57 +02:00
Gigi
2e2de4ccda docs: update CHANGELOG.md for v0.6.17 2025-10-15 19:36:50 +02:00
Gigi
4325d3a519 chore: bump version to 0.6.17 2025-10-15 19:35:36 +02:00
Gigi
51115c5f68 refactor: move Default Highlight Visibility back after Paragraph Alignment 2025-10-15 19:34:03 +02:00
Gigi
2aa6fe860b refactor: merge Layout & Navigation and Startup & Behavior into Layout & Behavior section 2025-10-15 19:33:22 +02:00
Gigi
86f39eacf8 refactor: move Default Highlight Visibility after Font Size in reading settings 2025-10-15 19:32:13 +02:00
Gigi
d15daef3ea fix: properly align Font Size buttons to right using setting-control wrapper 2025-10-15 19:31:04 +02:00
Gigi
281c70cdea style: align Font Size buttons to the right to match highlight color buttons 2025-10-15 19:29:22 +02:00
Gigi
d6d6087543 refactor: move Layout & Navigation section below Zap Splits 2025-10-15 19:28:33 +02:00
Gigi
d06e38bc19 refactor: reorder settings sections - move Startup & Behavior after Zap Splits 2025-10-15 19:28:05 +02:00
Gigi
cfc8eb0bbc feat: use friend-highlight color at 50% opacity for right side of zap sliders 2025-10-15 19:26:43 +02:00
Gigi
b85f9b79c3 feat: add zaps.svg illustration to Zap Splits section with responsive layout 2025-10-15 19:26:10 +02:00
Gigi
1b0045c737 refactor: add 50% opacity to slider track highlight color 2025-10-15 19:24:27 +02:00
Gigi
3dc8d7d440 fix: improve lightning bolt icon centering and sizing on slider thumbs 2025-10-15 19:18:57 +02:00
Gigi
bf9ca48d64 feat: replace slider thumb circles with lightning bolt icons for zap splits 2025-10-15 19:17:55 +02:00
Gigi
70441f3d59 refactor: use default highlight color for zap slider 50% mark instead of primary color 2025-10-15 19:16:16 +02:00
Gigi
431f28e861 refactor: update zap split description to match offline-first paragraph style 2025-10-15 19:15:43 +02:00
Gigi
3b1fc095c4 feat: add 50% visual indicators to zap split sliders with gradient background and tick marks 2025-10-15 19:15:14 +02:00
Gigi
9a6c7a29d0 feat: restrict settings page width to 900px matching article view max-width 2025-10-15 19:13:22 +02:00
Gigi
c1d173f40e fix: move offline-first paragraph inside flex container to prevent overlap with image 2025-10-15 19:12:17 +02:00
Gigi
f03ec5df8c refactor: move 'Use local relays as cache' checkbox after local relay paragraph 2025-10-15 19:11:36 +02:00
Gigi
6c74a12636 feat: add offline-first description at the beginning of App & Airplane Mode section 2025-10-15 19:10:38 +02:00
Gigi
39797803d3 refactor: rename section title from 'PWA & Flight Mode' to 'App & Airplane Mode' 2025-10-15 19:07:54 +02:00
Gigi
c66c1e928d refactor: swap paragraph order - Note about relays first, Install Boris second 2025-10-15 19:06:55 +02:00
Gigi
f934b641bb refactor: replace IconButton with plain icon for clear cache trash button 2025-10-15 19:06:22 +02:00
Gigi
1128a11603 refactor: reorder PWA settings - checkboxes first, then paragraphs, then install button 2025-10-15 19:05:03 +02:00
Gigi
9f90718918 refactor: reduce clear cache button size from 28 to 20 2025-10-15 19:03:43 +02:00
Gigi
067a07fc00 refactor: further reduce spacing between PWA settings elements from 0.5rem to 0.25rem 2025-10-15 19:02:09 +02:00
Gigi
1811cf045e refactor: split PWA description into two paragraphs and update text 2025-10-15 19:01:34 +02:00
Gigi
270b4f429f refactor: remove 'Install Boris as a PWA' title from settings section 2025-10-15 18:59:48 +02:00
Gigi
380acbb55f feat: hide PWA SVG illustration on mobile devices 2025-10-15 18:59:24 +02:00
Gigi
c384f0b4fb refactor: reduce spacing between PWA settings elements from 1rem to 0.5rem 2025-10-15 18:58:33 +02:00
Gigi
27cf393a03 refactor: set PWA SVG width to 30% for responsive scaling 2025-10-15 18:57:35 +02:00
Gigi
8831726913 refactor: reduce PWA SVG size to 150px width 2025-10-15 18:57:02 +02:00
Gigi
2f4327874c refactor: format and clean up pwa.svg with proper indentation and Inkscape metadata 2025-10-15 18:55:23 +02:00
Gigi
483845962e refactor: combine relay info text with PWA description into single paragraph 2025-10-15 18:53:38 +02:00
Gigi
c44b1d6349 refactor: set PWA SVG height to 100% with auto width for full vertical span 2025-10-15 18:52:20 +02:00
Gigi
79f28a142d refactor: increase PWA SVG illustration size from 120px to 200px 2025-10-15 18:51:54 +02:00
Gigi
02dd537cd9 refactor: make PWA SVG illustration span full section height 2025-10-15 18:50:50 +02:00
Gigi
5af1f14a0b refactor: merge PWA and Flight Mode settings into single section 2025-10-15 18:49:25 +02:00
Gigi
664f59a9cc refactor: show PWA button state with checkmark when installed instead of hiding section 2025-10-15 18:48:03 +02:00
Gigi
7d3641aab7 refactor: simplify PWA install text to 'Install Boris as a PWA' 2025-10-15 18:45:38 +02:00
Gigi
7924df4c67 refactor: simplify PWA section title to 'App' 2025-10-15 18:45:25 +02:00
Gigi
68a8eed4af refactor: expand PWA install text to include full terminology 2025-10-15 18:45:07 +02:00
Gigi
887db84ce7 refactor: change PWA section title to 'Boris as an App' 2025-10-15 18:44:37 +02:00
Gigi
05348fbfeb feat: add pwa.svg illustration to PWA settings section 2025-10-15 18:44:18 +02:00
Gigi
38eb6716f8 refactor: move PWA settings above Relays section 2025-10-15 18:42:31 +02:00
Gigi
d7f9cd30eb feat: always show PWA install button for testing/styling purposes 2025-10-15 18:41:40 +02:00
Gigi
922d041e0e docs: update CHANGELOG.md for v0.6.16 2025-10-15 18:31:46 +02:00
Gigi
76f4588c85 chore: bump version to 0.6.16 2025-10-15 18:30:36 +02:00
Gigi
e163b92a7e fix: remove unused handleCancelDelete function
Removed handleCancelDelete as it's no longer needed after switching
from ConfirmDialog modal to inline confirmation
2025-10-15 18:30:15 +02:00
Gigi
11925a42b0 style: make trash icon red in delete confirmation
Change from CompactButton to regular button with explicit red color
styling so the trash icon inherits the red color (rgb(220 38 38))
2025-10-15 18:21:44 +02:00
Gigi
acf45530ca refactor: replace delete dialog with inline confirmation
Replace popup modal with inline confirmation UI:
- When delete is clicked, show red trash icon with 'Confirm?' text
- Clicking red trash icon again confirms deletion
- Confirmation appears to left of three-dot menu
- Click outside or reopen menu cancels confirmation
- Remove ConfirmDialog component dependency
2025-10-15 18:15:55 +02:00
Gigi
3792ad6abf refactor: move Highlight Style, Paragraph Alignment, and Default Highlight Visibility to top
Final order:
1. Highlight Style
2. Paragraph Alignment
3. Default Highlight Visibility
4. Reading Font + Font Size
5. My Highlights color
6. Friends Highlights color
7. Nostrverse Highlights color
8. Show highlights checkbox
9. Preview
2025-10-15 17:59:29 +02:00
Gigi
bf98b307e8 style: align setting buttons vertically with fixed label width
Add min-width: 220px to inline setting labels to create consistent
'tab stops' so buttons align vertically regardless of label length.
Remove constraint on mobile where settings stack vertically.
2025-10-15 17:57:33 +02:00
Gigi
d15392f41e refactor: reorder settings with Highlight Style and Paragraph Alignment above Default Highlight Visibility
Final order:
1. Reading Font + Font Size
2. My Highlights color
3. Friends Highlights color
4. Nostrverse Highlights color
5. Highlight Style
6. Paragraph Alignment
7. Default Highlight Visibility
8. Show highlights checkbox
9. Preview
2025-10-15 17:55:58 +02:00
Gigi
f26a024255 refactor: reorder Reading & Display settings
- Highlight Style (first)
- Paragraph Alignment (second)
- Reading Font + Font Size (third)

Better logical grouping with text styling before font selection
2025-10-15 17:54:08 +02:00
Gigi
bf9f894c0d refactor: improve delete dialog UI and simplify message
- Reduce verbose warning text to simple 'This will delete your highlight'
- Add proper CSS styling for confirm dialog with backdrop blur
- Center-aligned text and circular icon with color-coded background
- Modern button styling with proper hover states
- Full-width buttons in action row
- Theme-aware colors using CSS variables
2025-10-15 17:53:26 +02:00
Gigi
53a7b7d1c5 docs: update CHANGELOG.md for v0.6.15 2025-10-15 17:51:38 +02:00
Gigi
a12a883cc6 chore: bump version to 0.6.15 2025-10-15 17:50:47 +02:00
Gigi
0cf076b010 chore: change default paragraph alignment to justify 2025-10-15 17:45:10 +02:00
Gigi
e2c712033f feat: add paragraph alignment setting
- Add paragraphAlignment setting (left/justify) to UserSettings interface
- Add UI control with icon buttons in ReadingDisplaySettings
- Apply alignment via CSS variable to reader content and preview
- Default to left-aligned to maintain current behavior
- Keep headings always left-aligned for better readability
2025-10-15 17:43:31 +02:00
Gigi
e38237ca8e fix: resolve linter errors for unused variables 2025-10-15 17:39:22 +02:00
Gigi
1fff44fc6c chore: update button label from 'Open on Nostr' to 'Open with njump' 2025-10-15 17:37:38 +02:00
Gigi
4e50073e07 feat: update gateway config to use nostr.at and define ants.sh as search portal 2025-10-15 17:31:39 +02:00
Gigi
0ce64fe83f feat: open three-dot menus upward when insufficient space below 2025-10-15 17:28:44 +02:00
Gigi
ef848aa93e feat: update external article menu with Share (Boris link) and Search options 2025-10-15 17:25:29 +02:00
Gigi
67b287d75d feat: add Search option to article menu for ants.sh portal 2025-10-15 17:20:59 +02:00
Gigi
b795dfd2c6 feat: add Copy Link and Copy Original options to article menu 2025-10-15 17:19:20 +02:00
Gigi
c68d855983 feat: add Share and Share Original options to article menu 2025-10-15 17:18:36 +02:00
Gigi
fb1c19e64b docs: update CHANGELOG.md for v0.6.14 2025-10-15 16:30:11 +02:00
Gigi
384c16e29d chore: bump version to 0.6.14 2025-10-15 16:28:55 +02:00
Gigi
789982bd76 Merge pull request #11 from dergigi/bookmarks-reorg
Reorganize bookmarks UI with sections and bookmark sets support
2025-10-15 16:28:25 +02:00
Gigi
8bccc9de48 fix: remove unused articleImage prop from CompactView 2025-10-15 16:27:05 +02:00
Gigi
ec8584b4d2 feat: hide cover images in compact view 2025-10-15 16:25:48 +02:00
Gigi
54bd59fa2d refactor: rename Amethyst-style bookmarks to Old Bookmarks (Legacy) 2025-10-15 16:25:03 +02:00
Gigi
b19f5f55f7 fix: remove borders from compact bookmark cards 2025-10-15 16:21:26 +02:00
Gigi
0964f25f97 refactor: make section dividers more subtle
Changed border color from var(--color-border) to rgba(255, 255, 255, 0.05)
for a much more subtle dividing line between bookmark sections.
2025-10-15 16:20:35 +02:00
Gigi
5f3e6335c1 refactor: reduce section heading bottom padding by half
Changed bottom padding from 0.75rem to 0.375rem for both the section
title and action button to reduce spacing before bookmark items.
2025-10-15 16:20:06 +02:00
Gigi
f30c894c87 fix: align add bookmark button with section heading
- Added matching padding to bookmark-section-action button
- Button now has same vertical padding as section title (1.5rem top, 0.75rem bottom)
- Also handles first section case with reduced padding (0.5rem top)
- Removed unnecessary marginBottom from flex container
2025-10-15 16:19:34 +02:00
Gigi
bec769ac1b refactor: move add bookmark button to web bookmarks section
- Removed add bookmark button from sidebar header
- Added small CompactButton style button next to 'Web bookmarks' heading
- Button only shows when user is logged in and web bookmarks section exists
- Moved bookmark creation logic from SidebarHeader to BookmarkList
- Cleaned up unused imports in SidebarHeader
2025-10-15 16:17:58 +02:00
Gigi
cb3748e06f refactor: remove redundant loading spinner above tabs
Removed the loading spinner that appeared above the tab bar since we now
show spinners in the empty states themselves, making this redundant.
2025-10-15 16:14:04 +02:00
Gigi
d5a24f0a46 refactor: replace empty state messages with spinners
Replaced 'No X yet. Pull to refresh!' messages with spinning loaders for:
- No highlights yet (Me & Explore)
- No bookmarks yet (Me)
- No read articles yet (Me)
- No articles written yet (Me)
- No blog posts yet (Explore)

This provides better UX by showing an active loading state instead of
static empty state messages.
2025-10-15 16:11:05 +02:00
Gigi
401a8241bd fix: resolve lint and type errors
- Changed idToEvent from let to const (prefer-const)
- Fixed TypeScript type narrowing issue by using direct regex test instead of isHexId type guard
- Removed unused isHexId import

All lint and type checks now pass for src directory.
2025-10-15 16:09:46 +02:00
Gigi
2193a7a863 fix: properly handle AddressPointer bookmarks for long-form articles
The issue was that Primal bookmarks long-form articles using 'a' tags
(AddressPointer format: kind:pubkey:identifier) but our code was only
expecting EventPointer objects with 'id' properties.

Changes:
- Updated ApplesauceBookmarks interface to match actual applesauce types
- Added AddressPointer and EventPointer interfaces
- Rewrote processApplesauceBookmarks to handle all bookmark types:
  * notes (EventPointer) - regular notes
  * articles (AddressPointer) - long-form content (kind:30023)
  * hashtags (string[])
  * urls (string[])
- Updated bookmark hydration to query addressable events by coordinates
- Added logging to show hydration stats

This should fix the issue where Primal's Reads bookmarks weren't showing up.
2025-10-15 16:06:03 +02:00
Gigi
e6bc4d7fda chore: update .gitignore 2025-10-15 16:02:30 +02:00
Gigi
aee9f73316 debug: add detailed logging for bookmark event tags
Added logging to show:
- e and a tag counts for all events
- which events survived deduplication
- specific check for Primal reads list (kind:10003 with d='reads')
2025-10-15 15:59:56 +02:00
Gigi
aef7b4cea4 fix: include kind:30003 in default bookmark list detection
Previously, the dedupeNip51Events function was only looking for kind:10003
and kind:30001 when finding the default bookmark list. This excluded
kind:30003 events without a 'd' tag, which is what Primal uses for
bookmarks. Now kind:30003 is properly included in the filter.
2025-10-15 15:54:47 +02:00
Gigi
c9a8a3b91e refactor: remove text shadows from publication date
- Remove text-shadow from CSS for .publish-date-topright
- Remove shadowColor from useAdaptiveTextColor hook
- Only apply adaptive text color, no shadows or backgrounds
- Cleaner appearance with color-based readability only
2025-10-15 15:52:54 +02:00
Gigi
0c7b11bdf8 fix: improve shadow contrast without background overlay
- Increase shadow opacity from 0.5 to 0.8 for better readability
- Revert semi-transparent background approach per user feedback
- Keep debugging logs to diagnose color detection
2025-10-15 15:50:54 +02:00
Gigi
8c151a5855 fix: correct async handling in adaptive color detection
- Remove incorrect await on synchronous getColor method
- Add console logging to debug color detection
- This should fix black-on-black readability issues
2025-10-15 15:49:45 +02:00
Gigi
9b54fa9c14 fix: correct FastAverageColor import to use named export
- Change from default import to named import
- Resolves TypeScript error TS2351
2025-10-15 15:48:02 +02:00
Gigi
99d7705404 feat: add adaptive text color for publication date over images
- Install fast-average-color library for image color detection
- Create useAdaptiveTextColor hook to analyze top-right image corner
- Update ReaderHeader to dynamically adjust date text/shadow colors
- Ensures publication date is readable on both light and dark backgrounds
2025-10-15 15:40:57 +02:00
Gigi
eaa590b8e2 feat: add support for bookmark sets (kind 30003)
- Add setName, setTitle, setDescription, and setImage fields to IndividualBookmark type
- Extract d tag and metadata from kind 30003 events in bookmark processing
- Create helper functions to group bookmarks by set and extract set metadata
- Display bookmark sets as separate sections in BookmarkList UI
- Maintain existing content-type categorization alongside bookmark sets
2025-10-15 15:25:33 +02:00
Gigi
715fd8cf10 refactor: remove duplicate type indicator icon from bookmark cards 2025-10-15 15:11:00 +02:00
Gigi
99a9709605 style: left-align support button, right-align view mode buttons 2025-10-15 15:01:25 +02:00
Gigi
65d330d5ed style: make support heart icon orange using friends color from settings 2025-10-15 14:59:51 +02:00
Gigi
1d1d389a03 feat: move support button to bottom-left of bookmarks bar 2025-10-15 14:58:43 +02:00
Gigi
0392389355 style: change support button icon from lightning bolt to heart 2025-10-15 14:57:24 +02:00
Gigi
cf2a500a07 style(bookmarks): remove border from compact view bookmarks 2025-10-15 14:39:43 +02:00
Gigi
7d3748202e fix(bookmarks): ensure section heading styles override with important 2025-10-15 14:39:00 +02:00
Gigi
d7f90faea9 style(bookmarks): improve section headings with better typography and remove counts 2025-10-15 14:35:18 +02:00
Gigi
cb0066aac9 style(bookmarks): use file-lines icon instead of book for default bookmarks 2025-10-15 14:31:27 +02:00
Gigi
b48397b7a6 feat(bookmarks): use camera icon for image bookmarks 2025-10-15 14:29:35 +02:00
Gigi
82ab8419e3 fix(lint): remove unused variables and fix icon imports 2025-10-15 14:26:02 +02:00
Gigi
142a2414d3 style(bookmarks): use regular icon variants for all classification icons 2025-10-15 14:24:07 +02:00
Gigi
081bd95f60 feat(bookmarks): classify web bookmark URLs to show appropriate content icons 2025-10-15 14:22:13 +02:00
Gigi
300aed0589 style(bookmarks): use regular icon variants for lighter appearance 2025-10-15 14:21:09 +02:00
Gigi
b2b23c66cf feat(bookmarks): add sticky note icon for text-only bookmarks without URLs 2025-10-15 14:20:28 +02:00
Gigi
838bb6aa3d feat(bookmarks): add content type icons to indicate article/video/web 2025-10-15 14:15:01 +02:00
Gigi
f14ecc5acb refactor(bookmarks): simplify filtering to only exclude empty content 2025-10-15 14:14:54 +02:00
Gigi
d533e23dc0 feat(bookmarks): render grouped sections in /me reading-list with global controls 2025-10-15 13:59:10 +02:00
Gigi
eefcf99364 feat(bookmarks): render grouped sections in sidebar with global view mode 2025-10-15 13:59:00 +02:00
Gigi
1c0790bfb6 feat(bookmarks): add grouping and sorting helpers for sections 2025-10-15 13:58:56 +02:00
Gigi
29e351ba78 feat(bookmarks): tag sourceKind in collection for web and list/set items 2025-10-15 13:58:48 +02:00
Gigi
7592c5c327 feat(bookmarks): add sourceKind to IndividualBookmark for grouping 2025-10-15 13:58:41 +02:00
Gigi
f5018204ab docs: update CHANGELOG.md for v0.6.13 release 2025-10-15 12:21:21 +02:00
Gigi
7ae74268fd chore: bump version to 0.6.13 2025-10-15 12:20:13 +02:00
Gigi
52e959a7f5 fix: keep bookmark button visible at top, only hide highlights button
- Bookmark button now visible at top (only hides on scroll down)
- Highlights button hides both at top AND on scroll down
- Separated visibility logic into showBookmarkButton and showHighlightsButton
- Relay status indicator follows bookmark button behavior
2025-10-15 12:19:33 +02:00
Gigi
4f03a2c276 fix: hide highlights button when scrolled to the top
- Highlights button now hidden when at the very top of the page
- Tracks scroll position and hides button when scrollY <= 10px
- Bookmark button remains visible as always
- Highlights button appears when scrolling up from below
- Improves UX by reducing visual clutter at page top
2025-10-15 12:17:44 +02:00
Gigi
bc4c96ee35 feat: add gradient placeholder images for articles without covers
- Blog post cards now show subtle gradient background when no image
- Reader view displays gradient placeholder with newspaper icon
- Large view bookmarks use gradient backgrounds
- Gradients use theme colors (--color-bg-elevated, --color-bg-subtle)
- Placeholder icons have reduced opacity for subtlety
- Adapts automatically to light/dark themes
2025-10-15 12:15:20 +02:00
Gigi
a866040fc1 feat: support nprofile identifiers on /p/ profile pages
- Profile pages now accept both npub and nprofile identifiers (NIP-19)
- Extract pubkey from nprofile.data.pubkey when decoding
- Maintains backward compatibility with existing npub links
- Users can now share profiles with relay metadata included
2025-10-15 12:13:18 +02:00
Gigi
c90fad268a style: improve PWA install section styling in settings
- Add section-title class to heading to match other settings sections
- Replace gradient button with standard zap-preset-btn styling
- Remove inline styles and JavaScript hover effects
- Consistent with app's design system and theme colors
2025-10-15 12:12:12 +02:00
Gigi
8ef1f775f9 fix: improve mobile bookmark button visibility across all pages
- Bookmark button now visible on all pages except settings
- Only hides when scrolling down while reading an article
- Fixes issue where button was hidden on /p/ (profile) and other pages
- Highlights button only shows when viewing article content
- Prevents users from getting stuck without navigation options
2025-10-15 12:10:30 +02:00
Gigi
90af87339c docs: update CHANGELOG.md for v0.6.12 release 2025-10-15 12:06:31 +02:00
86 changed files with 5907 additions and 1010 deletions

4
.gitignore vendored
View File

@@ -8,6 +8,8 @@ dist
*.log
.DS_Store
# Applesauce Reference
# 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,393 @@ 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
- Bookmark filter buttons by content type (articles, videos, images, web links)
- Filter bookmarks by their content type on bookmarks sidebar
- Filters also available on `/me` page bookmarks tab
- Separate filter for external articles with link icon
- Multiple filters can be active simultaneously
- Private Bookmarks section for encrypted legacy bookmarks
- Encrypted legacy bookmarks now grouped in separate section
- Better organization and clarity for different bookmark types
### Changed
- Bookmark section labels improved for clarity
- More descriptive section headings throughout
- Better categorization of bookmark types
- Bookmark filter button styling refined
- Reduced whitespace around bookmark filters for cleaner layout
- Dramatically reduced whitespace on both sidebar and `/me` page
- Lock icon removed from individual bookmarks
- Encryption status now indicated by section grouping
- Cleaner bookmark item appearance
- External article icon changed to link icon (`faLink`)
- More intuitive icon for external content
### Fixed
- Highlight button positioning and visibility
- Fixed to viewport for consistent placement
- Sticky and always visible when needed
- Properly positioned inside reader pane
## [0.6.19] - 2025-10-15
### Fixed
- Highlights disappearing on external URLs after a few seconds
- Fixed `useBookmarksData` from fetching general highlights when viewing external URLs
- External URL highlights now managed exclusively by `useExternalUrlLoader`
- Removed redundant `setHighlights` call that was overwriting streamed highlights
- Improved error handling in `fetchHighlightsForUrl` to prevent silent failures
- Isolated rebroadcast errors so they don't break highlight display
- Added logging to help diagnose highlight fetching issues
## [0.6.18] - 2025-10-15
### Changed
- Zap split labels simplified and terminology updated
- Removed redundant "Weight: xy" label to save space
- Changed "Author(s) Share" to "Author's Share" (possessive singular)
- Changed "Support Boris" to "Boris' Share" for consistency
- Weight value now shown directly in label (e.g., "Your Share: 50")
- Share and percentage now displayed on same line for cleaner layout
- Zap preset buttons on desktop now expand to match slider width
- Added `flex: 1` to buttons for equal width distribution
- Buttons still wrap properly on smaller screens
- PWA install section now always visible in settings
- Section shows regardless of installation or device capability status
- Button adapts with proper disabled states and visual feedback
- "Installed" state shows checkmark icon and disabled button
- Non-installable state shows disabled button
### Fixed
- PWA install button now properly disabled when installation is not possible on device
- Button only enabled when browser fires `beforeinstallprompt` event
- Removed hardcoded testing state that always showed button as installable
- App & Airplane Mode section now always visible regardless of PWA status
- Image cache and local relay settings always accessible
- Previously entire section was hidden if PWA not installable/installed
- Only PWA-specific install button is conditionally affected
## [0.6.17] - 2025-10-15
### Added
- PWA settings illustration (`pwa.svg`) displayed on right side of section
- Responsive design: hidden on mobile, 30% width on desktop
- Visual enhancement for App & Airplane Mode section
- Zaps illustration (`zaps.svg`) displayed on right side of Zap Splits section
- Matching responsive layout and styling as PWA illustration
- Visual 50% indicators on zap split sliders
- Linear gradient background using highlight colors (yellow/orange) at 50% opacity
- Datalist tick marks at 50% for "Your Share" and "Author(s) Share" sliders
- Tick mark at 5 for "Support Boris" slider
- Lightning bolt icons as slider thumbs for zap splits
- Replaces default circular slider handles
- White lightning bolt SVG embedded in slider thumb background
- 24px square thumb with 4px border radius
- Offline-first description paragraph at beginning of App & Airplane Mode section
- Explains Boris's offline capabilities upfront
- Settings page width constraint (900px max-width)
- Matches article view max-width for consistent reading experience
- Centered layout with proper margins
### Changed
- Settings section reorganization
- "PWA & Flight Mode" merged into single "App & Airplane Mode" section
- "Layout & Navigation" and "Startup & Behavior" merged into "Layout & Behavior"
- Section order: Theme → Reading & Display → Zap Splits → Layout & Behavior → App & Airplane Mode → Relays
- "Startup & Behavior" moved after "Zap Splits"
- "Layout & Navigation" moved below "Zap Splits"
- PWA settings section restructure
- Checkboxes moved to top (image cache, local relays)
- Descriptive paragraphs in middle
- Install button at bottom
- Note about local relays moved before install paragraph
- Zap split sliders styling
- Left side (0-50%): highlight color (yellow) at 50% opacity
- Right side (50-100%): friend-highlight color (orange) at 50% opacity
- Creates visual distinction tied to app's highlight color scheme
- Zap split description text styling
- Now matches offline-first paragraph style with secondary color and smaller font size
- Clear cache button styling
- Replaced `IconButton` with plain `FontAwesomeIcon` for subtler appearance
- No border or background, just icon with opacity
- Font Size buttons alignment
- Now properly align to the right using `setting-control` wrapper
- Matches alignment of highlight color picker buttons
- Default Highlight Visibility position
- Moved back to original position after "Paragraph Alignment"
- Grouped with other reading display controls
- Spacing adjustments in App & Airplane Mode section
- Reduced gap between elements from 1rem → 0.5rem → 0.25rem for tighter layout
### Fixed
- PWA settings paragraph wrapping
- Moved offline-first paragraph inside flex container to prevent extending above image
- Font Size buttons alignment issues
- Properly implemented `setting-control` wrapper for right alignment
- Previously attempted alignment didn't work correctly
- Slider thumb icon centering
- Lightning bolt icons properly centered vertically on slider
- Added `position: relative`, `top: 0`, `margin-top: 0` for accurate positioning
## [0.6.16] - 2025-10-15
### Changed
- Replaced delete dialog popup with inline confirmation UI
- Shows red "Confirm?" text with trash icon when delete is clicked
- Clicking the red trash icon confirms deletion
- No more modal overlay or backdrop
- Click outside or reopen menu to cancel
- Reordered Reading & Display settings for better organization
- Highlight Style, Paragraph Alignment, and Default Highlight Visibility moved to top
- Followed by Reading Font, Font Size, and color pickers
- Setting buttons now align vertically with fixed label width (220px)
- Creates consistent "tab stops" for cleaner visual alignment
### Fixed
- Removed unused `handleCancelDelete` function after dialog removal
## [0.6.15] - 2025-10-15
### Added
- Paragraph alignment setting with left-aligned and justified text options
- Icon buttons in Reading & Display settings for switching alignment
- CSS variable system for applying alignment to reader content
- Real-time preview of alignment changes in settings
- Headings remain left-aligned for optimal readability
### Changed
- Default paragraph alignment changed to justified for improved reading experience
- Applies to paragraphs, list items, divs, and blockquotes
- Settings stored and synced via Nostr (NIP-78)
## [0.6.14] - 2025-10-15
### Added
- Support for bookmark sets (NIP-51 kind:30003)
- Bookmark sets now display alongside regular bookmark lists
- Properly handles AddressPointer bookmarks for long-form articles
- Content type icons for bookmarks
- Article, video, web, and image icons to indicate bookmark content type
- Camera icon for image bookmarks
- Sticky note icon for text-only bookmarks without URLs
- Bookmark grouping and sections
- Grouped sections in sidebar and `/me` reading-list
- Web bookmarks, default bookmarks, and legacy bookmarks in separate sections
- Grouping and sorting helpers for organizing bookmark sections
- Adaptive text color for publication date over hero images
- Automatically detects image brightness and adjusts text color
- Improved contrast for better readability
### Changed
- Renamed "Amethyst-style bookmarks" to "Old Bookmarks (Legacy)"
- Hide cover images in compact view for cleaner layout
- Support button improvements
- Moved to bottom-left of bookmarks bar
- Changed icon from lightning bolt to heart (orange color)
- Left-aligned support button, right-aligned view mode buttons
- Section headings improved with better typography (removed counts)
- Icon changed from book to file-lines for default bookmarks
- Use regular (outlined) icon variants for lighter, more refined appearance
- Add bookmark button moved to web bookmarks section
- Empty state messages replaced with loading spinners
- Section dividers made more subtle
- Simplified bookmark filtering to only exclude empty content
### Fixed
- Removed borders from compact bookmark cards for cleaner look
- Removed duplicate type indicator icons from bookmark cards
- Reduced section heading bottom padding for better spacing
- Aligned add bookmark button with section heading
- Removed redundant loading spinner above tabs
- Resolved linter and type errors
- Include kind:30003 in default bookmark list detection
- Removed text shadows from publication date for cleaner look
- Improved shadow contrast without background overlay
- Corrected async handling in adaptive color detection
- Corrected FastAverageColor import to use named export
- Section heading styles now properly override with `!important`
- Removed unused articleImage prop from CompactView
## [0.6.13] - 2025-10-15
### Added
- Support for `nprofile` identifiers on `/p/` profile pages (NIP-19)
- Profile pages now accept both `npub` and `nprofile` identifiers
- Extracts pubkey from nprofile data structure
- Users can share profiles with relay metadata included
- Gradient placeholder images for articles without cover images
- Blog post cards show subtle diagonal gradient using theme colors
- Reader view displays gradient background with newspaper icon
- Placeholders adapt automatically to light/dark themes
- Large view bookmarks use matching gradient backgrounds
### Changed
- PWA install section styling in settings
- Heading now matches other section headings with proper styling
- Install button uses standard app button styling instead of custom gradient
- Consistent with app's design system and theme colors
### Fixed
- Mobile bookmark button visibility across all pages
- Now visible on `/p/` (profile), `/explore`, `/me`, and `/support` pages
- Only hidden on settings page or when scrolling down while reading
- Prevents users from getting stuck without navigation options
- Mobile highlights button behavior at page top
- Hidden when scrolled to the very top of the page
- Appears when scrolling up from below
- Bookmark button remains visible at top (only hides on scroll down)
- Separate visibility logic for each button improves UX
## [0.6.12] - 2025-10-15
### Changed
- Horizontal dividers (`<hr>`) in blog posts now display with more subtle styling
- Reduced visual weight with 69% opacity for better readability
- Added increased vertical padding (2.5rem) above and below dividers
- Improved visual separation without disrupting reading flow
## [0.6.11] - 2025-10-15
### Added
@@ -1373,7 +1760,19 @@ 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.11...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
[0.6.17]: https://github.com/dergigi/boris/compare/v0.6.16...v0.6.17
[0.6.16]: https://github.com/dergigi/boris/compare/v0.6.15...v0.6.16
[0.6.15]: https://github.com/dergigi/boris/compare/v0.6.14...v0.6.15
[0.6.14]: https://github.com/dergigi/boris/compare/v0.6.13...v0.6.14
[0.6.13]: https://github.com/dergigi/boris/compare/v0.6.12...v0.6.13
[0.6.12]: https://github.com/dergigi/boris/compare/v0.6.11...v0.6.12
[0.6.11]: https://github.com/dergigi/boris/compare/v0.6.10...v0.6.11
[0.6.10]: https://github.com/dergigi/boris/compare/v0.6.9...v0.6.10
[0.6.9]: https://github.com/dergigi/boris/compare/v0.6.8...v0.6.9

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>

14
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "boris",
"version": "0.6.9",
"version": "0.6.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boris",
"version": "0.6.9",
"version": "0.6.13",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
@@ -22,6 +22,7 @@
"applesauce-react": "^4.0.0",
"applesauce-relay": "^4.0.0",
"date-fns": "^4.1.0",
"fast-average-color": "^9.5.0",
"nostr-tools": "^2.4.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",
@@ -6086,6 +6087,15 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-average-color": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/fast-average-color/-/fast-average-color-9.5.0.tgz",
"integrity": "sha512-nC6x2YIlJ9xxgkMFMd1BNoM1ctMjNoRKfRliPmiEWW3S6rLTHiQcy9g3pt/xiKv/D0NAAkhb9VyV+WJFvTqMGg==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.6.12",
"version": "0.6.24",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",
@@ -25,6 +25,7 @@
"applesauce-react": "^4.0.0",
"applesauce-relay": "^4.0.0",
"date-fns": "^4.1.0",
"fast-average-color": "^9.5.0",
"nostr-tools": "^2.4.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

215
public/pwa.svg Normal file
View File

@@ -0,0 +1,215 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="649.67538"
height="568.22024"
viewBox="0 0 649.67538 568.22024"
role="img"
artist="Katerina Limpitsouni"
source="https://undraw.co/"
version="1.1"
id="svg31"
sodipodi:docname="pwa.svg"
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs31" />
<sodipodi:namedview
id="namedview31"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.6866359"
inkscape:cx="303.56285"
inkscape:cy="531.82789"
inkscape:window-width="3840"
inkscape:window-height="1027"
inkscape:window-x="0"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="svg31" />
<path
d="M397.23858,566.04035,390.539,618.81819l-9.85909-59.95407c-47.3817-18.18194-102.78179-21.713-102.78179-21.713s-12.22552,114.50728,28.139,162.38683,82.92182,40.60129,118.03379,11.00042c35.1114-29.60039,49.48123-70.31412,9.11675-118.19368C424.20327,581.68766,411.521,573.04476,397.23858,566.04035Z"
transform="translate(-275.16231 -165.88988)"
fill="#f2f2f2"
id="path1" />
<path
d="M384.1004,626.79762l1.98958,2.36c22.98681,27.551,36.40476,52.8555,40.0327,75.5803.05864.33032.09573.65881.15431.98919l-1.53846.23773-1.48187.20991c-3.64942-24.76543-19.47993-50.77428-39.52347-74.8103-.63842-.781-1.28663-1.57364-1.95824-2.34655-8.57477-10.1-17.832-19.82437-27.217-28.9415-.72021-.712-1.46191-1.42587-2.20361-2.13968-12.44963-11.96994-25.01434-22.84351-36.237-32.03036-.7903-.653-1.59224-1.296-2.38439-1.92739-19.05943-15.4717-33.9044-25.802-37.21424-28.06849-.39875-.28343-.62465-.43273-.67573-.46958l.844-1.25121.00183-.02155.85568-1.26106c.05113.03692.81117.53546,2.18233,1.49814,5.15056,3.57268,18.987,13.39417,36.1433,27.27236.77059.62957,1.57259,1.27267,2.36284,1.92555,9.11521,7.44575,19.072,15.96086,29.1037,25.25221q3.78542,3.49455,7.37706,6.9724c.75332.704,1.495,1.41783,2.21523,2.12988Q372.14864,612.73905,384.1004,626.79762Z"
transform="translate(-275.16231 -165.88988)"
fill="#fff"
id="path2" />
<path
d="M315.8701,561.67759c-.6941.76509-1.39989,1.54-2.13716,2.30139a84.299,84.299,0,0,1-6.3038,5.89408,82.00518,82.00518,0,0,1-32.26683,16.72907c.03131,1.03285.06269,2.06578.09217,3.12018a85.04164,85.04164,0,0,0,34.14459-17.51256,87.22471,87.22471,0,0,0,6.71826-6.30338c.72551-.75156,1.43131-1.52651,2.11561-2.30323a84.3256,84.3256,0,0,0,13.87772-21.35332q-1.56615-.32858-3.06776-.65165A81.72351,81.72351,0,0,1,315.8701,561.67759Z"
transform="translate(-275.16231 -165.88988)"
fill="#fff"
id="path3" />
<path
d="M354.7137,595.82775q-1.15019,1.08949-2.35939,2.109c-.23552.21856-.49252.43522-.7379.64208a82.4401,82.4401,0,0,1-74.51659,16.59042c.1138,1.08323.22759,2.1666.36294,3.25167a85.5013,85.5013,0,0,0,76.12358-17.5054c.32717-.27581.65427-.55157.97158-.83909.80793-.70112,1.59437-1.40414,2.371-2.11878a85.04917,85.04917,0,0,0,24.39782-41.355c-.955-.37409-1.91-.74825-2.87668-1.11248A81.874,81.874,0,0,1,354.7137,595.82775Z"
transform="translate(-275.16231 -165.88988)"
fill="#fff"
id="path4" />
<path
d="M384.1004,626.79762c-.75869.75952-1.53717,1.49572-2.32545,2.22029-.84674.77374-1.70328,1.53585-2.57954,2.27457a82.66307,82.66307,0,0,1-98.92522,5.60818c.27211,1.38968.5343,2.76759.82973,4.13747a85.69022,85.69022,0,0,0,100.06542-7.409c.87626-.73872,1.74266-1.48914,2.56785-2.26471.80983-.72274,1.58831-1.45893,2.35679-2.20683a85.43958,85.43958,0,0,0,25.37276-57.38712c-.97424-.6577-1.97364-1.27419-2.98289-1.90237A82.39644,82.39644,0,0,1,384.1004,626.79762Z"
transform="translate(-275.16231 -165.88988)"
fill="#fff"
id="path5" />
<path
d="M648.03621,300.20693V215.13007a49.24034,49.24034,0,0,0-49.24-49.24019H418.54942a49.24029,49.24029,0,0,0-49.2406,49.24V271.632h-3.16709v19.90855h3.16709V312.7763h-3.16709v30.52644h3.16709V356.5751h-3.16709v30.52643h3.16709v294.7669a49.23993,49.23993,0,0,0,49.23995,49.24019H598.79561a49.24028,49.24028,0,0,0,49.2406-49.24V360.76613h3.10552v-60.5592Z"
transform="translate(-275.16231 -165.88988)"
fill="#3f3d56"
id="path6" />
<path
d="M600.78268,178.70047H577.2545a17.47031,17.47031,0,0,1-16.17511,24.06836H457.81825a17.4703,17.4703,0,0,1-16.17512-24.06839H419.66775a36.772,36.772,0,0,0-36.772,36.772V681.526a36.772,36.772,0,0,0,36.772,36.77205h181.115a36.772,36.772,0,0,0,36.772-36.772h0V215.47244A36.772,36.772,0,0,0,600.78268,178.70047Z"
transform="translate(-275.16231 -165.88988)"
fill="#fff"
id="path7" />
<path
d="M605.33827,340.8917H415.11207a5.0058,5.0058,0,0,1-5-5V258.70616a5.0058,5.0058,0,0,1,5-5h190.2262a5.00573,5.00573,0,0,1,5,5V335.8917A5.00573,5.00573,0,0,1,605.33827,340.8917Z"
transform="translate(-275.16231 -165.88988)"
fill="#6c63ff"
id="path8" />
<path
d="M587.22522,377.41807h-154a5.5,5.5,0,0,1,0-11h154a5.5,5.5,0,0,1,0,11Z"
transform="translate(-275.16231 -165.88988)"
fill="#6c63ff"
id="path9" />
<path
d="M587.22523,405.41807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path10" />
<path
d="M587.22523,432.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path11" />
<path
d="M605.33827,571.8917H415.11207a5.0058,5.0058,0,0,1-5-5V489.70616a5.0058,5.0058,0,0,1,5-5h190.2262a5.00573,5.00573,0,0,1,5,5V566.8917A5.00573,5.00573,0,0,1,605.33827,571.8917Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path12" />
<path
d="M587.22523,608.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path13" />
<path
d="M587.22523,636.41807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path14" />
<path
d="M587.22523,663.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path15" />
<path
d="M760.06605,312.22721c-1.93457-14.18963-4.36084-29.42431-14.3689-39.66754a33.65518,33.65518,0,0,0-48.62622.5033c-7.28515,7.77185-10.50244,18.68475-10.79687,29.33325s2.07714,21.17865,4.708,31.50122a97.0913,97.0913,0,0,0,40.52124-7.97583,65.28916,65.28916,0,0,1,9.71558-3.81427c3.376-.85925,5.78247,1.303,8.92285,2.81073l1.72388-3.30078c1.41113,2.62616,5.78076,1.84772,7.36572-.67737C760.81605,318.41483,760.46888,315.18107,760.06605,312.22721Z"
transform="translate(-275.16231 -165.88988)"
fill="#2f2e41"
id="path16" />
<polygon
points="612.434 535.007 602.208 541.77 571.257 505.545 586.349 495.564 612.434 535.007"
fill="#9e616a"
id="polygon16" />
<path
d="M896.7595,709.08432,863.787,730.89015l-.27582-.417a15.38729,15.38729,0,0,1,4.34573-21.32122l.00081-.00054,20.13853-13.31819Z"
transform="translate(-275.16231 -165.88988)"
fill="#2f2e41"
id="path17" />
<polygon
points="480.429 553.116 468.169 553.116 462.337 505.828 480.431 505.829 480.429 553.116"
fill="#9e616a"
id="polygon17" />
<path
d="M758.71777,730.89015l-39.53076-.00146v-.5a15.3873,15.3873,0,0,1,15.38647-15.38623h.001l24.144.001Z"
transform="translate(-275.16231 -165.88988)"
fill="#2f2e41"
id="path18" />
<path
d="M668.3639,394.03709l-46.28906-33.06561a8.99743,8.99743,0,1,0-10.80762,7.74816c5.78613,4.85816,48.04785,46.88825,54.09888,44.67127,6.1416-2.25012,32.99341-6.32324,32.99341-6.32324l.74755-25.4953Z"
transform="translate(-275.16231 -165.88988)"
fill="#9e616a"
id="path19" />
<path
d="M704.73272,454.19782l.437,58.1781s10.01741,86.201,13.712,100.76318,18.69148,81.94564,18.69148,81.94564l24.3788-3.93292-15.69975-88.09791,4.74535-73.017,27.36445,73.178L847.847,675.848l17.61024-14.2095-60.48051-88.88116-18.47283-72.811s2.29785-37.66031-18.40081-52.16322Z"
transform="translate(-275.16231 -165.88988)"
fill="#2f2e41"
id="path20" />
<circle
cx="443.5739"
cy="133.65539"
r="26.72083"
fill="#9e616a"
id="circle20" />
<rect
x="722.98731"
y="465.33587"
width="24.29166"
height="31.57916"
transform="translate(-279.66359 789.41207) rotate(-65.86746)"
fill="#2f2e41"
id="rect20" />
<path
d="M593.23271,362.65743"
transform="translate(-275.16231 -165.88988)"
fill="#6c63ff"
id="path21" />
<path
d="M761.53382,350.95884c-3.14892-6.267-4.67895-14.009-11.39209-16.04077-4.5332-1.372-22.86841.68408-27,3-6.87231,3.85236-.64453,11.07111-4.699,17.82642q-6.61121,11.01552-13.22241,22.031c-3.03,5.04852-6.0918,10.16889-7.73023,15.82434-1.63818,5.65546-1.717,12.00305,1.074,17.18756,2.4978,4.64045,7.02294,7.93158,9.53515,12.56433,2.61231,4.81806-2.07715,26.33136-4.50854,31.24341l1.167.539a263.08934,263.08934,0,0,0,48.448-1.63024c3.9873-.50489,8.12744-1.16449,11.41308-3.47895,4.83985-3.40918,6.75318-9.5954,7.949-15.39337A129.67713,129.67713,0,0,0,761.53382,350.95884Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path22" />
<path
d="M706.84845,411.65133c7.23924-7.1146,14.51542-14.27181,20.47486-22.48827s10.5936-17.62115,11.88744-27.68835a20.50914,20.50914,0,0,0-.64136-9.62007c-1.11054-3.049-3.56912-5.755-6.73861-6.45068-5.07194-1.11355-9.6829,2.93435-13.30226,6.6577q-16.00732,16.46812-32.01478,32.936,10.19649,13.42191,20.393,26.84353Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path23" />
<path
d="M785.75257,417.13127c-2.25-6.14148-6.32324-32.99323-6.32324-32.99323l-25.49512-.74756,12.4646,30.7431-34.01367,47.61615s.063.10462.17749.2912a8.99538,8.99538,0,1,0,7.54468,9.55927.62106.62106,0,0,0,.77978-.13385C744.67176,466.7169,788.00257,423.27281,785.75257,417.13127Z"
transform="translate(-275.16231 -165.88988)"
fill="#9e616a"
id="path24" />
<path
d="M788.34461,400.17338c-2.34008-9.87665-4.69751-19.807-8.64282-29.15894s-9.59326-18.18512-17.53711-24.50317a20.50909,20.50909,0,0,0-8.563-4.43085c-3.18359-.62805-6.77148.07483-9.00732,2.42658-3.57813,3.76318-2.50147,9.80365-1.18921,14.8277q5.80444,22.2203,11.60864,44.44061,16.76184-1.77667,33.52344-3.55347Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path25" />
<path
d="M752.14124,301.6237c-.83545-6.464-1.708-12.98224-3.67065-19.06879-1.96265-6.08661-5.12622-11.78747-9.66431-15.23547-7.1853-5.459-16.488-4.40613-24.54394-1.266-6.23,2.42846-12.31153,6.1195-16.70484,12.05346-4.39355,5.934-6.86108,14.40119-5.2268,22.1601q12.88989-3.58722,25.77954-7.1745l-.94068.783c5.57642,3.14221,9.81153,9.64361,11.07691,17.00482a28.7171,28.7171,0,0,1-4.53662,21.03778q8.79089-3.67337,17.58178-7.34662c3.61744-1.51153,7.489-3.25317,9.634-7.13025C753.41273,312.94608,752.83485,306.98814,752.14124,301.6237Z"
transform="translate(-275.16231 -165.88988)"
fill="#2f2e41"
id="path26" />
<path
d="M625.98113,343.51431,608.792,369.31226a4.46863,4.46863,0,0,1-3.75549,2.00125,4.47943,4.47943,0,0,1-4.13509-2.75491,4.12763,4.12763,0,0,1-.2689-.85745,4.51165,4.51165,0,0,1,.66976-3.37929l17.18913-25.79794a4.5,4.5,0,1,1,7.48973,4.99039Z"
transform="translate(-275.16231 -165.88988)"
fill="#6c63ff"
id="path27" />
<path
d="M610.17821,367.23178l-3.47923,5.19091-6.15652,5.42689a2.45095,2.45095,0,0,1-3.94221-2.627l2.69471-7.8881,3.39353-5.09311Z"
transform="translate(-275.16231 -165.88988)"
fill="#3f3d56"
id="path28" />
<path
d="M626.74053,329.98545l-8.6142,7.59289a2.45233,2.45233,0,0,0,.26168,3.88081l1.62984,1.086-4.71315,7.07363a1,1,0,0,0,1.66439,1.109l4.71314-7.07362,1.62985,1.086a2.45552,2.45552,0,0,0,3.39872-.675,2.46816,2.46816,0,0,0,.28357-.57793l3.69013-10.8738a2.45251,2.45251,0,0,0-3.944-2.62786Z"
transform="translate(-275.16231 -165.88988)"
fill="#3f3d56"
id="path29" />
<path
d="M516.97522,187.41807h-27a2,2,0,0,1,0-4h27a2,2,0,0,1,0,4Z"
transform="translate(-275.16231 -165.88988)"
fill="#fff"
id="path31" />
<circle
cx="255.31291"
cy="19.52819"
r="2"
fill="#fff"
id="circle31" />
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

235
public/zaps.svg Normal file
View File

@@ -0,0 +1,235 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="720.44"
height="718.635"
viewBox="0 0 720.44 718.635"
role="img"
artist="Katerina Limpitsouni"
source="https://undraw.co/"
version="1.1"
id="svg30"
sodipodi:docname="zaps.svg"
xml:space="preserve"
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs30" /><sodipodi:namedview
id="namedview30"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="2.6746164"
inkscape:cx="38.510195"
inkscape:cy="485.67712"
inkscape:window-width="3840"
inkscape:window-height="1027"
inkscape:window-x="0"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="g27" /><g
transform="translate(-600 -181)"
id="g30"><g
transform="translate(783.85 181)"
id="g2"><path
d="M624.7,249.968h-3.952V141.8a62.6,62.6,0,0,0-62.6-62.6H328.97a62.6,62.6,0,0,0-62.6,62.6V735.225a62.6,62.6,0,0,0,62.6,62.6H558.143a62.6,62.6,0,0,0,62.6-62.6V326.965H624.7Z"
transform="translate(-266.365 -79.193)"
fill="#090814"
id="path1" /><path
d="M560.888,95.686H530.974a22.212,22.212,0,0,1-20.565,30.6h-131.3a22.212,22.212,0,0,1-20.566-30.6H330.607a46.752,46.752,0,0,0-46.752,46.752V735a46.752,46.752,0,0,0,46.752,46.752H560.879A46.752,46.752,0,0,0,607.63,735V142.439a46.752,46.752,0,0,0-46.744-46.752Z"
transform="translate(-266.577 -79.397)"
fill="#fff"
id="path2" /></g><path
d="M8,0H256a8,8,0,0,1,8,8V72a8,8,0,0,1-8,8H8a8,8,0,0,1-8-8V8A8,8,0,0,1,8,0Z"
transform="translate(828 265)"
fill="#f2f2f2"
id="path3" /><path
d="M8,0H256a8.065,8.065,0,0,1,8,8.128V475.474a8.065,8.065,0,0,1-8,8.128H8a8.065,8.065,0,0,1-8-8.128V8.128A8.065,8.065,0,0,1,8,0Z"
transform="translate(828 358.398)"
fill="#f2f2f2"
id="path4" /><g
transform="translate(623.104 296.398)"
id="g9"><rect
width="278.304"
height="69.313"
rx="16"
transform="translate(0 0)"
fill="#090814"
id="rect4" /><rect
width="272.003"
height="63.012"
rx="15"
transform="translate(3.151 3.151)"
fill="#fff"
id="rect5" /><path
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
transform="translate(-53.047 -325.676)"
fill="#6c63ff"
id="path5" /><g
transform="translate(17.038 13.546)"
id="g7"><path
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
transform="translate(0 0)"
fill="#6c63ff"
id="path6" /><path
fill="#ffffff"
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.7844991 20.830193,9.2808662 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
id="path2-8-2"
style="stroke-width:0.575581" /></g><path
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(72.886 17.036)"
fill="#e6e6e6"
id="path8" /><path
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(72.886 37.98)"
fill="#e6e6e6"
id="path9" /></g><g
transform="translate(1003.278 402.469)"
id="g14"><rect
width="279.354"
height="69.313"
rx="16"
transform="translate(0 0)"
fill="#090814"
id="rect9" /><rect
width="272.003"
height="63.012"
rx="15"
transform="translate(3.151 3.151)"
fill="#fff"
id="rect10" /><path
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
transform="translate(-52.751 -325.287)"
fill="#6c63ff"
id="path10" /><g
transform="translate(17.334 13.936)"
id="g12"><path
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
transform="translate(0 0)"
fill="#6c63ff"
id="path11" /><path
fill="#ffffff"
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 l 0.811571,-3.253758 -2.019716,-0.503633 -0.828256,3.318801 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
id="path2-8-2-7"
style="stroke-width:0.575581" /></g><path
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(73.181 17.426)"
fill="#e6e6e6"
id="path13" /><path
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(73.181 38.369)"
fill="#e6e6e6"
id="path14" /></g><g
transform="translate(663.012 510.639)"
id="g19"><rect
width="279.354"
height="69.313"
rx="16"
transform="translate(0 0)"
fill="#090814"
id="rect14" /><rect
width="272.003"
height="63.012"
rx="15"
transform="translate(3.151 3.151)"
fill="#fff"
id="rect15" /><path
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
transform="translate(-52.814 -325.25)"
fill="#6c63ff"
id="path15" /><g
transform="translate(17.272 13.972)"
id="g17"><path
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
transform="translate(0 0)"
fill="#6c63ff"
id="path16" /><path
fill="#ffffff"
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.784499 20.830193,9.2808661 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
id="path2-8-2-0"
style="stroke-width:0.575581" /></g><path
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(73.119 17.463)"
fill="#e6e6e6"
id="path18" /><path
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(73.119 38.406)"
fill="#e6e6e6"
id="path19" /></g><g
transform="translate(1041.086 616.711)"
id="g24"><rect
width="279.354"
height="70.364"
rx="16"
transform="translate(0 0)"
fill="#090814"
id="rect19" /><rect
width="272.003"
height="63.012"
rx="15"
transform="translate(4.201 4.201)"
fill="#fff"
id="rect20" /><path
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
transform="translate(-52.163 -324.86)"
fill="#6c63ff"
id="path20" /><g
transform="translate(17.922 14.362)"
id="g22"><path
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
transform="translate(0 0)"
fill="#6c63ff"
id="path21" /><path
fill="#ffffff"
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.784499 20.830193,9.2808661 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
id="path2-8-2-9"
style="stroke-width:0.575581" /></g><path
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(73.77 17.853)"
fill="#e6e6e6"
id="path23" /><path
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(73.77 38.796)"
fill="#e6e6e6"
id="path24" /></g><g
transform="translate(600 723.832)"
id="g29"><rect
width="279.354"
height="69.313"
rx="16"
transform="translate(0 0)"
fill="#090814"
id="rect24" /><rect
width="273.053"
height="63.012"
rx="15"
transform="translate(3.151 3.151)"
fill="#fff"
id="rect25" /><path
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
transform="translate(-52.631 -325.518)"
fill="#6c63ff"
id="path25" /><g
transform="translate(17.454 13.704)"
id="g27"><path
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
transform="translate(0 0)"
fill="#6c63ff"
id="path26" /><path
fill="#ffffff"
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 l 0.811571,-3.253758 -2.019716,-0.503633 -0.828256,3.318801 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
id="path2-8-2-98"
style="stroke-width:0.575581" /></g><path
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(73.301 17.194)"
fill="#e6e6e6"
id="path28" /><path
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(73.301 38.138)"
fill="#e6e6e6"
id="path29" /></g></g></svg>

After

Width:  |  Height:  |  Size: 17 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'
@@ -112,7 +117,25 @@ function AppRoutes({
}
/>
<Route
path="/me/archive"
path="/me/reads"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/me/reads/:filter"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/me/links"
element={
<Bookmarks
relayPool={relayPool}
@@ -147,6 +170,7 @@ function AppRoutes({
/>
}
/>
<Route path="/debug" element={<Debug />} />
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes>
)
@@ -168,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
@@ -198,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
@@ -233,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) {
@@ -249,7 +496,7 @@ function App() {
return () => {
if (cleanup) cleanup()
}
}, [])
}, [isOnline, showToast])
// Monitor online/offline status
useEffect(() => {
@@ -285,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

@@ -11,9 +11,10 @@ interface BlogPostCardProps {
post: BlogPostPreview
href: string
level?: 'mine' | 'friends' | 'nostrverse'
readingProgress?: number // 0-1 reading progress (optional)
}
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress }) => {
const profile = useEventModel(Models.ProfileModel, [post.author])
const displayName = profile?.name || profile?.display_name ||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
@@ -23,6 +24,16 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
addSuffix: true
})
// Calculate progress percentage and determine color (matching readingProgressUtils.ts logic)
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
let progressColor = '#6366f1' // Default blue (reading)
if (readingProgress && readingProgress >= 0.95) {
progressColor = '#10b981' // Green (completed)
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
progressColor = 'var(--color-text)' // Neutral text color (started)
}
return (
<Link
to={href}
@@ -47,7 +58,37 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
{post.summary && (
<p className="blog-post-card-summary">{post.summary}</p>
)}
<div className="blog-post-card-meta">
{/* Reading progress indicator - replaces the dividing line */}
{readingProgress !== undefined && readingProgress > 0 ? (
<div
className="blog-post-reading-progress"
style={{
height: '3px',
width: '100%',
background: 'var(--color-border)',
overflow: 'hidden',
marginTop: '1rem'
}}
>
<div
style={{
height: '100%',
width: `${progressPercent}%`,
background: progressColor,
transition: 'width 0.3s ease, background 0.3s ease'
}}
/>
</div>
) : (
<div style={{
height: '1px',
background: 'var(--color-border)',
marginTop: '1rem'
}} />
)}
<div className="blog-post-card-meta" style={{ borderTop: 'none', paddingTop: '0.75rem' }}>
<span className="blog-post-card-author">
<FontAwesomeIcon icon={faUser} />
{displayName}

View File

@@ -0,0 +1,44 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faNewspaper, faStickyNote, faCirclePlay } from '@fortawesome/free-regular-svg-icons'
import { faGlobe, faAsterisk, faLink } from '@fortawesome/free-solid-svg-icons'
export type BookmarkFilterType = 'all' | 'article' | 'external' | 'video' | 'note' | 'web'
interface BookmarkFiltersProps {
selectedFilter: BookmarkFilterType
onFilterChange: (filter: BookmarkFilterType) => void
}
const BookmarkFilters: React.FC<BookmarkFiltersProps> = ({
selectedFilter,
onFilterChange
}) => {
const filters = [
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
{ type: 'article' as const, icon: faNewspaper, label: 'Articles' },
{ type: 'external' as const, icon: faLink, label: 'External Articles' },
{ type: 'video' as const, icon: faCirclePlay, label: 'Videos' },
{ type: 'note' as const, icon: faStickyNote, label: 'Notes' },
{ type: 'web' as const, icon: faGlobe, label: 'Web' }
]
return (
<div className="bookmark-filters">
{filters.map(filter => (
<button
key={filter.type}
onClick={() => onFilterChange(filter.type)}
className={`filter-btn ${selectedFilter === filter.type ? 'active' : ''}`}
title={filter.label}
aria-label={`Filter by ${filter.label}`}
>
<FontAwesomeIcon icon={filter.icon} />
</button>
))}
</div>
)
}
export default BookmarkFilters

View File

@@ -1,5 +1,7 @@
import React, { useState } from 'react'
import { faBookOpen, faPlay, faEye } from '@fortawesome/free-solid-svg-icons'
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
import { faGlobe, faLink } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { npubEncode, neventEncode } from 'nostr-tools/nip19'
@@ -66,18 +68,41 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
return short(bookmark.pubkey) // fallback to short pubkey
}
// use helper from kindIcon.ts
// Get content type icon based on bookmark kind and URL classification
const getContentTypeIcon = (): IconDefinition => {
if (isArticle) return faNewspaper // Nostr-native article
// For web bookmarks, classify the URL to determine icon
if (isWebBookmark && firstUrlClassification) {
switch (firstUrlClassification.type) {
case 'youtube':
case 'video':
return faCirclePlay
case 'image':
return faCamera
case 'article':
return faLink // External article
default:
return faGlobe
}
}
if (!hasUrls) return faStickyNote // Just a text note
if (firstUrlClassification?.type === 'youtube' || firstUrlClassification?.type === 'video') return faCirclePlay
if (firstUrlClassification?.type === 'article') return faLink // External article
return faFileLines
}
const getIconForUrlType = (url: string) => {
const classification = classifyUrl(url)
switch (classification.type) {
case 'youtube':
case 'video':
return faPlay
return faCirclePlay
case 'image':
return faEye
return faCamera
default:
return faBookOpen
return faFileLines
}
}
@@ -113,11 +138,14 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
getAuthorDisplayName,
handleReadNow,
articleImage,
articleSummary
articleSummary,
contentTypeIcon: getContentTypeIcon()
}
if (viewMode === 'compact') {
return <CompactView {...sharedProps} />
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const { articleImage, ...compactProps } = sharedProps
return <CompactView {...compactProps} />
}
if (viewMode === 'large') {
@@ -125,5 +153,5 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
}
return <CardView {...sharedProps} getIconForUrlType={getIconForUrlType} articleImage={articleImage} />
return <CardView {...sharedProps} articleImage={articleImage} />
}

View File

@@ -1,17 +1,27 @@
import React, { useRef } from 'react'
import React, { useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus } from '@fortawesome/free-solid-svg-icons'
import { formatDistanceToNow } from 'date-fns'
import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import { BookmarkItem } from './BookmarkItem'
import SidebarHeader from './SidebarHeader'
import IconButton from './IconButton'
import CompactButton from './CompactButton'
import { ViewMode } from './Bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { BookmarkSkeleton } from './Skeletons'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet } from '../utils/bookmarkUtils'
import { UserSettings } from '../services/settingsService'
import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService'
import { RELAYS } from '../config/relays'
import { Hooks } from 'applesauce-react'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import LoginOptions from './LoginOptions'
interface BookmarkListProps {
bookmarks: Bookmark[]
@@ -29,6 +39,7 @@ interface BookmarkListProps {
loading?: boolean
relayPool: RelayPool | null
isMobile?: boolean
settings?: UserSettings
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
@@ -46,9 +57,23 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
lastFetchTime,
loading = false,
relayPool,
isMobile = false
isMobile = false,
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 activeAccount = Hooks.useActiveAccount()
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
if (!activeAccount || !relayPool) {
throw new Error('Please login to create bookmarks')
}
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
}
// Pull-to-refresh for bookmarks
const { isRefreshing: isPulling, pullPosition } = usePullToRefresh({
@@ -62,36 +87,34 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
isDisabled: !onRefresh
})
// Helper to check if a bookmark has either content or a URL
const hasContentOrUrl = (ib: IndividualBookmark) => {
// Check if has content (text)
const hasContent = ib.content && ib.content.trim().length > 0
// Check if has URL
let hasUrl = false
// For web bookmarks (kind:39701), URL is in the 'd' tag
if (ib.kind === 39701) {
const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1]
hasUrl = !!dTag && dTag.trim().length > 0
} else {
// For other bookmarks, extract URLs from content
const urls = extractUrlsFromContent(ib.content || '')
hasUrl = urls.length > 0
}
// Always show articles (kind:30023) as they have special handling
if (ib.kind === 30023) return true
// Otherwise, must have either content or URL
return hasContent || hasUrl
}
// Merge and flatten all individual bookmarks from all lists
// Re-sort after flattening to ensure newest first across all lists
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContentOrUrl)
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
.filter(hasContent)
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
// Separate bookmarks with setName (kind 30003) from regular bookmarks
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
const bookmarkSets = getBookmarkSets(filteredBookmarks)
// Group non-set bookmarks as before
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
]
// Add bookmark sets as additional sections
bookmarkSets.forEach(set => {
sections.push({
key: `set-${set.name}`,
title: set.title || set.name,
items: set.bookmarks
})
})
if (isCollapsed) {
// Check if the selected URL is in bookmarks
@@ -121,11 +144,23 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onToggleCollapse={onToggleCollapse}
onLogout={onLogout}
onOpenSettings={onOpenSettings}
relayPool={relayPool}
isMobile={isMobile}
/>
{allIndividualBookmarks.length === 0 ? (
{allIndividualBookmarks.length > 0 && (
<BookmarkFilters
selectedFilter={selectedFilter}
onFilterChange={setSelectedFilter}
/>
)}
{!activeAccount ? (
<LoginOptions />
) : filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
<div className="empty-state">
<p>No bookmarks match this filter.</p>
</div>
) : allIndividualBookmarks.length === 0 ? (
loading ? (
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
@@ -138,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>
)
) : (
@@ -150,53 +184,87 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
isRefreshing={isPulling || isRefreshing || false}
pullPosition={pullPosition}
/>
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{allIndividualBookmarks.map((individualBookmark, index) =>
<BookmarkItem
key={`${individualBookmark.id}-${index}`}
bookmark={individualBookmark}
index={index}
onSelectUrl={onSelectUrl}
viewMode={viewMode}
/>
)}
</div>
{sections.filter(s => s.items.length > 0).map(section => (
<div key={section.key} className="bookmarks-section">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h3 className="bookmarks-section-title" style={{ margin: 0, padding: '1.5rem 0.5rem 0.375rem', flex: 1 }}>{section.title}</h3>
{section.key === 'web' && activeAccount && (
<CompactButton
icon={faPlus}
onClick={() => setShowAddModal(true)}
title="Add web bookmark"
ariaLabel="Add web bookmark"
className="bookmark-section-action"
/>
)}
</div>
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{section.items.map((individualBookmark, index) => (
<BookmarkItem
key={`${section.key}-${individualBookmark.id}-${index}`}
bookmark={individualBookmark}
index={index}
onSelectUrl={onSelectUrl}
viewMode={viewMode}
/>
))}
</div>
</div>
))}
</div>
)}
<div className="view-mode-controls">
{onRefresh && (
<div className="view-mode-left">
<IconButton
icon={faRotate}
onClick={onRefresh}
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
ariaLabel="Refresh bookmarks"
icon={faHeart}
onClick={() => navigate('/support')}
title="Support Boris"
ariaLabel="Support"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
style={{ color: friendsColor }}
/>
)}
<IconButton
icon={faList}
onClick={() => onViewModeChange('compact')}
title="Compact list view"
ariaLabel="Compact list view"
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faThLarge}
onClick={() => onViewModeChange('cards')}
title="Cards view"
ariaLabel="Cards view"
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faImage}
onClick={() => onViewModeChange('large')}
title="Large preview view"
ariaLabel="Large preview view"
variant={viewMode === 'large' ? 'primary' : 'ghost'}
/>
</div>
<div className="view-mode-right">
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
)}
<IconButton
icon={faList}
onClick={() => onViewModeChange('compact')}
title="Compact list view"
ariaLabel="Compact list view"
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faThLarge}
onClick={() => onViewModeChange('cards')}
title="Cards view"
ariaLabel="Cards view"
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faImage}
onClick={() => onViewModeChange('large')}
title="Large preview view"
ariaLabel="Large preview view"
variant={viewMode === 'large' ? 'primary' : 'ghost'}
/>
</div>
</div>
{showAddModal && (
<AddBookmarkModal
onClose={() => setShowAddModal(false)}
onSave={handleSaveBookmark}
/>
)}
</div>
)
}

View File

@@ -1,13 +1,12 @@
import React, { useState } from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons'
import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import IconButton from '../IconButton'
import { classifyUrl } from '../../utils/helpers'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
import { getEventUrl } from '../../config/nostrGateways'
@@ -18,13 +17,13 @@ interface CardViewProps {
hasUrls: boolean
extractedUrls: string[]
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
getIconForUrlType: IconGetter
authorNpub: string
eventNevent?: string
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleImage?: string
articleSummary?: string
contentTypeIcon: IconDefinition
}
export const CardView: React.FC<CardViewProps> = ({
@@ -33,13 +32,13 @@ export const CardView: React.FC<CardViewProps> = ({
hasUrls,
extractedUrls,
onSelectUrl,
getIconForUrlType,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleImage,
articleSummary
articleSummary,
contentTypeIcon
}) => {
const firstUrl = hasUrls ? extractedUrls[0] : null
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
@@ -52,7 +51,6 @@ export const CardView: React.FC<CardViewProps> = ({
const contentLength = (bookmark.content || '').length
const shouldTruncate = !expanded && contentLength > 210
const isArticle = bookmark.kind === 30023
const isWebBookmark = bookmark.kind === 39701
// Determine which image to use (article image, instant preview, or OG image)
const previewImage = articleImage || instantPreview || ogImage
@@ -92,19 +90,7 @@ export const CardView: React.FC<CardViewProps> = ({
)}
<div className="bookmark-header">
<span className="bookmark-type">
{isWebBookmark ? (
<span className="fa-layers fa-fw">
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
</span>
) : bookmark.isPrivate ? (
<>
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
</>
) : (
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
)}
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
</span>
{eventNevent ? (
@@ -127,23 +113,14 @@ export const CardView: React.FC<CardViewProps> = ({
<div className="bookmark-urls">
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
return (
<div key={urlIndex} className="url-row">
<button
className="bookmark-url"
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
title="Open in reader"
>
{url}
</button>
<IconButton
icon={getIconForUrlType(url)}
ariaLabel="Open"
title="Open"
variant="success"
size={32}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onSelectUrl?.(url) }}
/>
</div>
<button
key={urlIndex}
className="bookmark-url"
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
title="Open in reader"
>
{url}
</button>
)
})}
{extractedUrls.length > 1 && (

View File

@@ -1,10 +1,9 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDateCompact } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import { useImageCache } from '../../hooks/useImageCache'
interface CompactViewProps {
bookmark: IndividualBookmark
@@ -12,8 +11,8 @@ interface CompactViewProps {
hasUrls: boolean
extractedUrls: string[]
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
articleImage?: string
articleSummary?: string
contentTypeIcon: IconDefinition
}
export const CompactView: React.FC<CompactViewProps> = ({
@@ -22,16 +21,13 @@ export const CompactView: React.FC<CompactViewProps> = ({
hasUrls,
extractedUrls,
onSelectUrl,
articleImage,
articleSummary
articleSummary,
contentTypeIcon
}) => {
const isArticle = bookmark.kind === 30023
const isWebBookmark = bookmark.kind === 39701
const isClickable = hasUrls || isArticle || isWebBookmark
// Get cached image for thumbnail
const cachedImage = useImageCache(articleImage || undefined)
const handleCompactClick = () => {
if (!onSelectUrl) return
@@ -55,27 +51,8 @@ export const CompactView: React.FC<CompactViewProps> = ({
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
>
{/* Thumbnail image */}
{cachedImage && (
<div className="compact-thumbnail">
<img src={cachedImage} alt="" />
</div>
)}
<span className="bookmark-type-compact">
{isWebBookmark ? (
<span className="fa-layers fa-fw">
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
</span>
) : bookmark.isPrivate ? (
<>
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
</>
) : (
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
)}
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
</span>
{displayText && (
<div className="compact-text">

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
@@ -21,6 +22,8 @@ interface LargeViewProps {
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleSummary?: string
contentTypeIcon: IconDefinition
readingProgress?: number // 0-1 reading progress (optional)
}
export const LargeView: React.FC<LargeViewProps> = ({
@@ -35,11 +38,23 @@ export const LargeView: React.FC<LargeViewProps> = ({
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleSummary
articleSummary,
contentTypeIcon,
readingProgress
}) => {
const cachedImage = useImageCache(previewImage || undefined)
const isArticle = bookmark.kind === 30023
// Calculate progress display (matching readingProgressUtils.ts logic)
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
let progressColor = '#6366f1' // Default blue (reading)
if (readingProgress && readingProgress >= 0.95) {
progressColor = '#10b981' // Green (completed)
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
progressColor = 'var(--color-text)' // Neutral text color (started)
}
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -89,7 +104,32 @@ export const LargeView: React.FC<LargeViewProps> = ({
</div>
)}
{/* Reading progress indicator for articles - shown only if there's progress */}
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
<div
style={{
height: '3px',
width: '100%',
background: 'var(--color-border)',
overflow: 'hidden',
marginTop: '0.75rem'
}}
>
<div
style={{
height: '100%',
width: `${progressPercent}%`,
background: progressColor,
transition: 'width 0.3s ease, background 0.3s ease'
}}
/>
</div>
)}
<div className="large-footer">
<span className="bookmark-type-large">
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
</span>
<span className="large-author">
<Link
to={`/p/${authorNpub}`}

View File

@@ -52,22 +52,25 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const meTab = location.pathname === '/me' ? 'highlights' :
location.pathname === '/me/highlights' ? 'highlights' :
location.pathname === '/me/reading-list' ? 'reading-list' :
location.pathname === '/me/archive' ? 'archive' :
location.pathname.startsWith('/me/reads') ? 'reads' :
location.pathname === '/me/links' ? 'links' :
location.pathname === '/me/writings' ? 'writings' : 'highlights'
// Extract tab from profile routes
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
// Decode npub to pubkey for profile view
// Decode npub or nprofile to pubkey for profile view
let profilePubkey: string | undefined
if (npub && showProfile) {
try {
const decoded = nip19.decode(npub)
if (decoded.type === 'npub') {
profilePubkey = decoded.data
} else if (decoded.type === 'nprofile') {
profilePubkey = decoded.data.pubkey
}
} catch (err) {
console.error('Failed to decode npub:', err)
console.error('Failed to decode npub/nprofile:', err)
}
}
@@ -165,6 +168,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
activeAccount,
accountManager,
naddr,
externalUrl,
currentArticleCoordinate,
currentArticleEventId,
settings

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState, useEffect, useRef } from 'react'
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
import ReactPlayer from 'react-player'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -6,10 +6,10 @@ import rehypeRaw from 'rehype-raw'
import rehypePrism from 'rehype-prism-plus'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import 'prismjs/themes/prism-tomorrow.css'
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare } from '@fortawesome/free-solid-svg-icons'
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons'
import { ContentSkeleton } from './Skeletons'
import { nip19 } from 'nostr-tools'
import { getNostrUrl } from '../config/nostrGateways'
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
import { RELAYS } from '../config/relays'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
@@ -36,6 +36,13 @@ import { classifyUrl } from '../utils/helpers'
import { buildNativeVideoUrl } from '../utils/videoHelpers'
import { useReadingPosition } from '../hooks/useReadingPosition'
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
import { EventFactory } from 'applesauce-factory'
import { Hooks } from 'applesauce-react'
import {
generateArticleIdentifier,
loadReadingPosition,
saveReadingPosition
} from '../services/readingPositionService'
interface ContentPanelProps {
loading: boolean
@@ -100,6 +107,9 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const [showArticleMenu, setShowArticleMenu] = useState(false)
const [showVideoMenu, setShowVideoMenu] = useState(false)
const [showExternalMenu, setShowExternalMenu] = useState(false)
const [articleMenuOpenUpward, setArticleMenuOpenUpward] = useState(false)
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
const [externalMenuOpenUpward, setExternalMenuOpenUpward] = useState(false)
const articleMenuRef = useRef<HTMLDivElement>(null)
const videoMenuRef = useRef<HTMLDivElement>(null)
const externalMenuRef = useRef<HTMLDivElement>(null)
@@ -126,10 +136,58 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onClearSelection
})
// Get event store for reading position service
const eventStore = Hooks.useEventStore()
// Reading position tracking - only for text content, not videos
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
const { isReadingComplete, progressPercentage } = useReadingPosition({
// Generate article identifier for saving/loading position
const articleIdentifier = useMemo(() => {
if (!selectedUrl) return null
return generateArticleIdentifier(selectedUrl)
}, [selectedUrl])
// Callback to save reading position
const handleSavePosition = useCallback(async (position: number) => {
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
console.log('⏭️ [ContentPanel] Skipping save - missing requirements:', {
hasAccount: !!activeAccount,
hasRelayPool: !!relayPool,
hasEventStore: !!eventStore,
hasIdentifier: !!articleIdentifier
})
return
}
if (!settings?.syncReadingPosition) {
console.log('⏭️ [ContentPanel] Sync disabled in settings')
return
}
console.log('💾 [ContentPanel] Saving position:', Math.round(position * 100) + '%', 'for article:', selectedUrl?.slice(0, 50))
try {
const factory = new EventFactory({ signer: activeAccount })
await saveReadingPosition(
relayPool,
eventStore,
factory,
articleIdentifier,
{
position,
timestamp: Math.floor(Date.now() / 1000),
scrollTop: window.pageYOffset || document.documentElement.scrollTop
}
)
} catch (error) {
console.error('❌ [ContentPanel] Failed to save reading position:', error)
}
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
enabled: isTextContent,
syncEnabled: settings?.syncReadingPosition,
onSave: handleSavePosition,
onReadingComplete: () => {
// Optional: Auto-mark as read when reading is complete
if (activeAccount && !isMarkedAsRead) {
@@ -138,6 +196,73 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}
})
// Load saved reading position when article loads
useEffect(() => {
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
console.log('⏭️ [ContentPanel] Skipping position restore - missing requirements:', {
isTextContent,
hasAccount: !!activeAccount,
hasRelayPool: !!relayPool,
hasEventStore: !!eventStore,
hasIdentifier: !!articleIdentifier
})
return
}
if (!settings?.syncReadingPosition) {
console.log('⏭️ [ContentPanel] Sync disabled - not restoring position')
return
}
console.log('📖 [ContentPanel] Loading position for article:', selectedUrl?.slice(0, 50))
const loadPosition = async () => {
try {
const savedPosition = await loadReadingPosition(
relayPool,
eventStore,
activeAccount.pubkey,
articleIdentifier
)
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
console.log('🎯 [ContentPanel] Restoring position:', Math.round(savedPosition.position * 100) + '%')
// Wait for content to be fully rendered before scrolling
setTimeout(() => {
const documentHeight = document.documentElement.scrollHeight
const windowHeight = window.innerHeight
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
window.scrollTo({
top: scrollTop,
behavior: 'smooth'
})
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
}, 500) // Give content time to render
} else if (savedPosition) {
if (savedPosition.position === 1) {
console.log('✅ [ContentPanel] Article completed (100%), starting from top')
} else {
console.log('⏭️ [ContentPanel] Position too early (<5%):', Math.round(savedPosition.position * 100) + '%')
}
}
} catch (error) {
console.error('❌ [ContentPanel] Failed to load reading position:', error)
}
}
loadPosition()
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
// Save position before unmounting or changing article
useEffect(() => {
return () => {
if (saveNow) {
saveNow()
}
}
}, [saveNow, selectedUrl])
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -161,6 +286,35 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}
}, [showArticleMenu, showVideoMenu, showExternalMenu])
// Check available space and position menu upward if needed
useEffect(() => {
const checkMenuPosition = (menuRef: React.RefObject<HTMLDivElement>, setOpenUpward: (value: boolean) => void) => {
if (!menuRef.current) return
const menuWrapper = menuRef.current
const menuElement = menuWrapper.querySelector('.article-menu') as HTMLElement
if (!menuElement) return
const rect = menuWrapper.getBoundingClientRect()
const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - rect.bottom
const menuHeight = menuElement.offsetHeight || 300 // estimate if not rendered yet
// Open upward if there's not enough space below (with 20px buffer)
setOpenUpward(spaceBelow < menuHeight + 20 && rect.top > menuHeight)
}
if (showArticleMenu) {
checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward)
}
if (showVideoMenu) {
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
}
if (showExternalMenu) {
checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward)
}
}, [showArticleMenu, showVideoMenu, showExternalMenu])
const readingStats = useMemo(() => {
const content = markdown || html || ''
if (!content) return null
@@ -218,9 +372,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
relays: relayHints
})
// Check for source URL in 'r' tags
const sourceUrl = currentArticle.tags.find(t => t[0] === 'r')?.[1]
return {
portal: getNostrUrl(naddr),
native: `nostr:${naddr}`
native: `nostr:${naddr}`,
naddr,
sourceUrl,
borisUrl: `${window.location.origin}/a/${naddr}`
}
}
@@ -245,6 +405,73 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}
setShowArticleMenu(false)
}
const handleShareBoris = async () => {
try {
if (!articleLinks) return
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
title: title || 'Article',
url: articleLinks.borisUrl
})
} else {
await navigator.clipboard.writeText(articleLinks.borisUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowArticleMenu(false)
}
}
const handleShareOriginal = async () => {
try {
if (!articleLinks?.sourceUrl) return
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
title: title || 'Article',
url: articleLinks.sourceUrl
})
} else {
await navigator.clipboard.writeText(articleLinks.sourceUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowArticleMenu(false)
}
}
const handleCopyBoris = async () => {
try {
if (!articleLinks) return
await navigator.clipboard.writeText(articleLinks.borisUrl)
} catch (e) {
console.warn('Copy failed', e)
} finally {
setShowArticleMenu(false)
}
}
const handleCopyOriginal = async () => {
try {
if (!articleLinks?.sourceUrl) return
await navigator.clipboard.writeText(articleLinks.sourceUrl)
} catch (e) {
console.warn('Copy failed', e)
} finally {
setShowArticleMenu(false)
}
}
const handleOpenSearch = () => {
if (articleLinks) {
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
}
setShowArticleMenu(false)
}
// Video actions
const handleOpenVideoExternal = () => {
@@ -307,10 +534,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const handleShareExternalUrl = async () => {
try {
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Article', url: selectedUrl })
} else if (selectedUrl) {
await navigator.clipboard.writeText(selectedUrl)
if (!selectedUrl) return
const borisUrl = `${window.location.origin}/r/${encodeURIComponent(selectedUrl)}`
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
title: title || 'Article',
url: borisUrl
})
} else {
await navigator.clipboard.writeText(borisUrl)
}
} catch (e) {
console.warn('Share failed', e)
@@ -318,6 +551,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
setShowExternalMenu(false)
}
}
const handleSearchExternalUrl = () => {
if (selectedUrl) {
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
}
setShowExternalMenu(false)
}
// Check if article is already marked as read when URL/article changes
useEffect(() => {
@@ -501,7 +741,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showVideoMenu && (
<div className="article-menu">
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Link</span>
@@ -582,13 +822,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</button>
{showExternalMenu && (
<div className="article-menu">
<div className={`article-menu ${externalMenuOpenUpward ? 'open-upward' : ''}`}>
<button
className="article-menu-item"
onClick={handleOpenExternalUrl}
onClick={handleShareExternalUrl}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Original URL</span>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
<button
className="article-menu-item"
@@ -599,10 +839,17 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</button>
<button
className="article-menu-item"
onClick={handleShareExternalUrl}
onClick={handleOpenExternalUrl}
>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Original</span>
</button>
<button
className="article-menu-item"
onClick={handleSearchExternalUrl}
>
<FontAwesomeIcon icon={faSearch} />
<span>Search</span>
</button>
</div>
)}
@@ -623,13 +870,52 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</button>
{showArticleMenu && (
<div className="article-menu">
<div className={`article-menu ${articleMenuOpenUpward ? 'open-upward' : ''}`}>
<button
className="article-menu-item"
onClick={handleShareBoris}
>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
{articleLinks.sourceUrl && (
<button
className="article-menu-item"
onClick={handleShareOriginal}
>
<FontAwesomeIcon icon={faShare} />
<span>Share Original</span>
</button>
)}
<button
className="article-menu-item"
onClick={handleCopyBoris}
>
<FontAwesomeIcon icon={faCopy} />
<span>Copy Link</span>
</button>
{articleLinks.sourceUrl && (
<button
className="article-menu-item"
onClick={handleCopyOriginal}
>
<FontAwesomeIcon icon={faCopy} />
<span>Copy Original</span>
</button>
)}
<button
className="article-menu-item"
onClick={handleOpenSearch}
>
<FontAwesomeIcon icon={faSearch} />
<span>Search</span>
</button>
<button
className="article-menu-item"
onClick={handleOpenPortal}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open on Nostr</span>
<span>Open with njump</span>
</button>
<button
className="article-menu-item"

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

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate } from '@fortawesome/free-solid-svg-icons'
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { Hooks } from 'applesauce-react'
@@ -237,35 +237,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
return `/a/${naddr}`
}
const handleHighlightClick = (highlightId: string) => {
const highlight = highlights.find(h => h.id === highlightId)
if (!highlight) return
// For nostr-native articles
if (highlight.eventReference) {
// Convert eventReference to naddr
if (highlight.eventReference.includes(':')) {
const parts = highlight.eventReference.split(':')
const kind = parseInt(parts[0])
const pubkey = parts[1]
const identifier = parts[2] || ''
const naddr = nip19.naddrEncode({
kind,
pubkey,
identifier
})
navigate(`/a/${naddr}`, { state: { highlightId, openHighlights: true } })
} else {
// Already an naddr
navigate(`/a/${highlight.eventReference}`, { state: { highlightId, openHighlights: true } })
}
}
// For web URLs
else if (highlight.urlReference) {
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, { state: { highlightId, openHighlights: true } })
}
}
// Classify highlights with levels based on user context and apply visibility filters
const classifiedHighlights = useMemo(() => {
@@ -320,8 +291,8 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
)
}
return filteredBlogPosts.length === 0 ? (
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)', padding: '2rem' }}>
<p>No blog posts yet. Pull to refresh!</p>
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
</div>
) : (
<div className="explore-grid">
@@ -347,8 +318,8 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
)
}
return classifiedHighlights.length === 0 ? (
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)', padding: '2rem' }}>
<p>No highlights yet. Pull to refresh!</p>
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
</div>
) : (
<div className="explore-grid">
@@ -357,7 +328,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
key={highlight.id}
highlight={highlight}
relayPool={relayPool}
onHighlightClick={handleHighlightClick}
/>
))}
</div>

View File

@@ -13,10 +13,10 @@ import { areAllRelaysLocal } from '../utils/helpers'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
import { createDeletionRequest } from '../services/deletionService'
import ConfirmDialog from './ConfirmDialog'
import { getNostrUrl } from '../config/nostrGateways'
import CompactButton from './CompactButton'
import { HighlightCitation } from './HighlightCitation'
import { useNavigate } from 'react-router-dom'
// Helper to detect if a URL is an image
const isImageUrl = (url: string): boolean => {
@@ -207,6 +207,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const [showMenu, setShowMenu] = useState(false)
const activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate()
// Resolve the profile of the user who made the highlight
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
@@ -257,25 +258,52 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
}, [isSelected])
// Close menu when clicking outside
// Close menu and reset delete confirm when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setShowMenu(false)
setShowDeleteConfirm(false)
}
}
if (showMenu) {
if (showMenu || showDeleteConfirm) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [showMenu])
}, [showMenu, showDeleteConfirm])
const handleItemClick = () => {
// If onHighlightClick is provided, use it (legacy behavior)
if (onHighlightClick) {
onHighlightClick(highlight.id)
return
}
// Otherwise, navigate to the article that this highlight references
if (highlight.eventReference) {
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
const parts = highlight.eventReference.split(':')
// If it's an article coordinate (3 parts) and kind is 30023, navigate to it
if (parts.length === 3) {
const [kind, pubkey, identifier] = parts
if (kind === '30023') {
// Encode as naddr and navigate
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey,
identifier
})
navigate(`/a/${naddr}`)
}
}
} else if (highlight.urlReference) {
// Navigate to external URL
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`)
}
}
@@ -434,12 +462,12 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
}
const handleCancelDelete = () => {
setShowDeleteConfirm(false)
}
const handleMenuToggle = (e: React.MouseEvent) => {
e.stopPropagation()
// Reset delete confirm state when opening/closing menu
if (!showMenu) {
setShowDeleteConfirm(false)
}
setShowMenu(!showMenu)
}
@@ -461,6 +489,11 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
setShowDeleteConfirm(true)
}
const handleConfirmDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation()
handleConfirmDelete()
}
return (
<>
<div
@@ -468,7 +501,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
data-highlight-id={highlight.id}
onClick={handleItemClick}
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
style={{ cursor: (onHighlightClick || highlight.eventReference || highlight.urlReference) ? 'pointer' : 'default' }}
>
<div className="highlight-header">
<CompactButton
@@ -533,6 +566,33 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
</div>
<div className="highlight-menu-wrapper" ref={menuRef}>
{showDeleteConfirm && canDelete && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginRight: '0.5rem' }}>
<span style={{ fontSize: '0.875rem', color: 'rgb(220 38 38)', fontWeight: 500 }}>Confirm?</span>
<button
onClick={handleConfirmDeleteClick}
disabled={isDeleting}
title="Confirm deletion"
style={{
color: 'rgb(220 38 38)',
background: 'rgba(220, 38, 38, 0.1)',
border: '1px solid rgb(220 38 38)',
borderRadius: '4px',
padding: '0.375rem',
cursor: isDeleting ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '33px',
minHeight: '33px',
transition: 'all 0.2s'
}}
>
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
</button>
</div>
)}
<CompactButton
icon={faEllipsisH}
onClick={handleMenuToggle}
@@ -546,7 +606,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
onClick={handleOpenPortal}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open on Nostr</span>
<span>Open with njump</span>
</button>
<button
className="highlight-menu-item"
@@ -571,17 +631,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
</div>
</div>
</div>
<ConfirmDialog
isOpen={showDeleteConfirm}
title="Delete Highlight?"
message="This will request deletion of your highlight. It may still be visible on some relays that don't honor deletion requests."
confirmText="Delete"
cancelText="Cancel"
variant="danger"
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
</>
)
}

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

@@ -1,16 +1,17 @@
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useParams } from 'react-router-dom'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
import { fetchHighlights } from '../services/highlightService'
import { fetchBookmarks } from '../services/bookmarkService'
import { fetchReadArticlesWithData } from '../services/libraryService'
import { fetchAllReads, ReadItem } from '../services/readsService'
import { fetchLinks } from '../services/linksService'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
import { RELAYS } from '../config/relays'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
@@ -19,11 +20,18 @@ import BlogPostCard from './BlogPostCard'
import { BookmarkItem } from './BookmarkItem'
import IconButton from './IconButton'
import { ViewMode } from './Bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
import { filterByReadingProgress } from '../utils/readingProgressUtils'
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
import { mergeReadItem } from '../utils/readItemMerge'
interface MeProps {
relayPool: RelayPool
@@ -31,11 +39,15 @@ interface MeProps {
pubkey?: string // Optional pubkey for viewing other users' profiles
}
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
// Valid reading progress filters
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed']
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
const activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate()
const { filter: urlFilter } = useParams<{ filter?: string }>()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
// Use provided pubkey or fall back to active account
@@ -43,11 +55,22 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
const [highlights, setHighlights] = useState<Highlight[]>([])
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
const [reads, setReads] = useState<ReadItem[]>([])
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
const [links, setLinks] = useState<ReadItem[]>([])
const [, setLinksMap] = useState<Map<string, ReadItem>>(new Map())
const [writings, setWritings] = useState<BlogPostPreview[]>([])
const [loading, setLoading] = useState(true)
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
const [viewMode, setViewMode] = useState<ViewMode>('cards')
const [refreshTrigger, setRefreshTrigger] = useState(0)
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
// Initialize reading progress filter from URL param
const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
? (urlFilter as ReadingProgressFilterType)
: 'all'
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
// Update local state when prop changes
useEffect(() => {
@@ -56,72 +79,246 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
}
}, [propActiveTab])
// Sync filter state with URL changes
useEffect(() => {
const loadData = async () => {
if (!viewingPubkey) {
setLoading(false)
return
const filterFromUrl = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
? (urlFilter as ReadingProgressFilterType)
: 'all'
setReadingProgressFilter(filterFromUrl)
}, [urlFilter])
// Handler to change reading progress filter and update URL
const handleReadingProgressFilterChange = (filter: ReadingProgressFilterType) => {
setReadingProgressFilter(filter)
if (activeTab === 'reads') {
if (filter === 'all') {
navigate('/me/reads', { replace: true })
} else {
navigate(`/me/reads/${filter}`, { replace: true })
}
}
}
// Tab-specific loading functions
const loadHighlightsTab = async () => {
if (!viewingPubkey) return
// Only show loading skeleton if tab hasn't been loaded yet
const hasBeenLoaded = loadedTabs.has('highlights')
try {
if (!hasBeenLoaded) setLoading(true)
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
setHighlights(userHighlights)
setLoadedTabs(prev => new Set(prev).add('highlights'))
} catch (err) {
console.error('Failed to load highlights:', err)
} finally {
if (!hasBeenLoaded) setLoading(false)
}
}
const loadWritingsTab = async () => {
if (!viewingPubkey) return
const hasBeenLoaded = loadedTabs.has('writings')
try {
if (!hasBeenLoaded) setLoading(true)
const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
setWritings(userWritings)
setLoadedTabs(prev => new Set(prev).add('writings'))
} catch (err) {
console.error('Failed to load writings:', err)
} finally {
if (!hasBeenLoaded) setLoading(false)
}
}
const loadReadingListTab = async () => {
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('reading-list')
try {
if (!hasBeenLoaded) setLoading(true)
try {
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
setBookmarks([])
}
setLoadedTabs(prev => new Set(prev).add('reading-list'))
} catch (err) {
console.error('Failed to load reading list:', err)
} finally {
if (!hasBeenLoaded) setLoading(false)
}
}
const loadReadsTab = async () => {
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('reads')
try {
if (!hasBeenLoaded) setLoading(true)
// Ensure bookmarks are loaded
let fetchedBookmarks: Bookmark[] = bookmarks
if (bookmarks.length === 0) {
try {
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
fetchedBookmarks = newBookmarks
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
fetchedBookmarks = []
}
}
try {
setLoading(true)
// Seed from cache if available to avoid empty flash (own profile only)
if (isOwnProfile) {
const cached = getCachedMeData(viewingPubkey)
if (cached) {
setHighlights(cached.highlights)
setBookmarks(cached.bookmarks)
setReadArticles(cached.readArticles)
// Derive reads from bookmarks immediately
const initialReads = deriveReadsFromBookmarks(fetchedBookmarks)
const initialMap = new Map(initialReads.map(item => [item.id, item]))
setReadsMap(initialMap)
setReads(initialReads)
setLoadedTabs(prev => new Set(prev).add('reads'))
if (!hasBeenLoaded) setLoading(false)
// Background enrichment: merge reading progress and mark-as-read
// Only update items that are already in our map
fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks, (item) => {
console.log('📈 [Reads] Enrichment item received:', {
id: item.id.slice(0, 20) + '...',
progress: item.readingProgress,
hasProgress: item.readingProgress !== undefined && item.readingProgress > 0
})
setReadsMap(prevMap => {
// Only update if item exists in our current map
if (!prevMap.has(item.id)) {
console.log('⚠️ [Reads] Item not in map, skipping:', item.id.slice(0, 20) + '...')
return prevMap
}
}
// Fetch highlights and writings (public data)
const [userHighlights, userWritings] = await Promise.all([
fetchHighlights(relayPool, viewingPubkey),
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
])
setHighlights(userHighlights)
setWritings(userWritings)
// Only fetch private data for own profile
if (isOwnProfile && activeAccount) {
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
setReadArticles(userReadArticles)
// Fetch bookmarks using callback pattern
let fetchedBookmarks: Bookmark[] = []
try {
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
fetchedBookmarks = newBookmarks
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
setBookmarks([])
const newMap = new Map(prevMap)
const merged = mergeReadItem(newMap, item)
if (merged) {
console.log('✅ [Reads] Merged progress:', item.id.slice(0, 20) + '...', item.readingProgress)
// Update reads array after map is updated
setReads(Array.from(newMap.values()))
return newMap
}
return prevMap
})
}).catch(err => console.warn('Failed to enrich reads:', err))
} catch (err) {
console.error('Failed to load reads:', err)
if (!hasBeenLoaded) setLoading(false)
}
}
// Update cache with all fetched data
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
} else {
setBookmarks([])
setReadArticles([])
const loadLinksTab = async () => {
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('links')
try {
if (!hasBeenLoaded) setLoading(true)
// Ensure bookmarks are loaded
let fetchedBookmarks: Bookmark[] = bookmarks
if (bookmarks.length === 0) {
try {
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
fetchedBookmarks = newBookmarks
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
fetchedBookmarks = []
}
} catch (err) {
console.error('Failed to load data:', err)
// No blocking error - user can pull-to-refresh
} finally {
setLoading(false)
}
// Derive links from bookmarks immediately
const initialLinks = deriveLinksFromBookmarks(fetchedBookmarks)
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
setLinksMap(initialMap)
setLinks(initialLinks)
setLoadedTabs(prev => new Set(prev).add('links'))
if (!hasBeenLoaded) setLoading(false)
// Background enrichment: merge reading progress and mark-as-read
// Only update items that are already in our map
fetchLinks(relayPool, viewingPubkey, (item) => {
setLinksMap(prevMap => {
// Only update if item exists in our current map
if (!prevMap.has(item.id)) return prevMap
const newMap = new Map(prevMap)
if (mergeReadItem(newMap, item)) {
// Update links array after map is updated
setLinks(Array.from(newMap.values()))
return newMap
}
return prevMap
})
}).catch(err => console.warn('Failed to enrich links:', err))
} catch (err) {
console.error('Failed to load links:', err)
if (!hasBeenLoaded) setLoading(false)
}
}
// Load active tab data
useEffect(() => {
if (!viewingPubkey || !activeTab) {
setLoading(false)
return
}
// Load cached data immediately if available
if (isOwnProfile) {
const cached = getCachedMeData(viewingPubkey)
if (cached) {
setHighlights(cached.highlights)
setBookmarks(cached.bookmarks)
setReads(cached.reads || [])
setLinks(cached.links || [])
}
}
loadData()
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
// Load data for active tab (refresh in background if already loaded)
switch (activeTab) {
case 'highlights':
loadHighlightsTab()
break
case 'writings':
loadWritingsTab()
break
case 'reading-list':
loadReadingListTab()
break
case 'reads':
loadReadsTab()
break
case 'links':
loadLinksTab()
break
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, viewingPubkey, refreshTrigger])
// Pull-to-refresh
// Pull-to-refresh - reload active tab without clearing state
const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => {
// Just trigger refresh - loaders will merge new data
setRefreshTrigger(prev => prev + 1)
},
maximumPullLength: 240,
@@ -150,21 +347,47 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
return `/a/${naddr}`
}
// Helper to check if a bookmark has either content or a URL (same logic as BookmarkList)
const hasContentOrUrl = (ib: IndividualBookmark) => {
const hasContent = ib.content && ib.content.trim().length > 0
let hasUrl = false
if (ib.kind === 39701) {
const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1]
hasUrl = !!dTag && dTag.trim().length > 0
} else {
const urls = extractUrlsFromContent(ib.content || '')
hasUrl = urls.length > 0
const getReadItemUrl = (item: ReadItem) => {
if (item.type === 'article') {
// ID is already in naddr format
return `/a/${item.id}`
} else if (item.url) {
return `/r/${encodeURIComponent(item.url)}`
}
return '#'
}
const convertReadItemToBlogPostPreview = (item: ReadItem): BlogPostPreview => {
if (item.event) {
return {
event: item.event,
title: item.title || 'Untitled',
summary: item.summary,
image: item.image,
published: item.published,
author: item.author || item.event.pubkey
}
}
if (ib.kind === 30023) return true
return hasContent || hasUrl
// Create a mock event for external URLs
const mockEvent = {
id: item.id,
pubkey: item.author || '',
created_at: item.readingTimestamp || Math.floor(Date.now() / 1000),
kind: 1,
tags: [] as string[][],
content: item.title || item.url || 'Untitled',
sig: ''
} as const
return {
event: mockEvent as unknown as import('nostr-tools').NostrEvent,
title: item.title || item.url || 'Untitled',
summary: item.summary,
image: item.image,
published: item.published,
author: item.author || ''
}
}
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
@@ -186,13 +409,27 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
}
}
// Merge and flatten all individual bookmarks (same logic as BookmarkList)
// Merge and flatten all individual bookmarks
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContentOrUrl)
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
.filter(hasContent)
// Apply bookmark filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter)
const groups = groupIndividualBookmarks(filteredBookmarks)
// Apply reading progress filter
const filteredReads = filterByReadingProgress(reads, readingProgressFilter)
const filteredLinks = filterByReadingProgress(links, readingProgressFilter)
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
]
// Show content progressively - no blocking error screens
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
const showSkeletons = loading && !hasData
const renderTabContent = () => {
@@ -207,13 +444,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div>
)
}
return highlights.length === 0 ? (
<div className="explore-empty" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
<p>
{isOwnProfile
? 'No highlights yet. Pull to refresh!'
: 'No highlights yet. Pull to refresh!'}
</p>
return highlights.length === 0 && !loading ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No highlights yet.
</div>
) : (
<div className="highlights-list me-highlights-list">
@@ -240,23 +473,39 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div>
)
}
return allIndividualBookmarks.length === 0 ? (
<div className="explore-empty" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
<p>No bookmarks yet. Pull to refresh!</p>
return allIndividualBookmarks.length === 0 && !loading ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No bookmarks yet.
</div>
) : (
<div className="bookmarks-list">
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{allIndividualBookmarks.map((individualBookmark, index) => (
<BookmarkItem
key={`${individualBookmark.id}-${index}`}
bookmark={individualBookmark}
index={index}
viewMode={viewMode}
onSelectUrl={handleSelectUrl}
/>
))}
</div>
{allIndividualBookmarks.length > 0 && (
<BookmarkFilters
selectedFilter={bookmarkFilter}
onFilterChange={setBookmarkFilter}
/>
)}
{filteredBookmarks.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No bookmarks match this filter.
</div>
) : (
sections.filter(s => s.items.length > 0).map(section => (
<div key={section.key} className="bookmarks-section">
<h3 className="bookmarks-section-title">{section.title}</h3>
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{section.items.map((individualBookmark, index) => (
<BookmarkItem
key={`${section.key}-${individualBookmark.id}-${index}`}
bookmark={individualBookmark}
index={index}
viewMode={viewMode}
onSelectUrl={handleSelectUrl}
/>
))}
</div>
</div>
)))}
<div className="view-mode-controls" style={{
display: 'flex',
justifyContent: 'center',
@@ -290,8 +539,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div>
)
case 'archive':
if (showSkeletons) {
case 'reads':
// Show loading skeletons only while initially loading
if (loading && !loadedTabs.has('reads')) {
return (
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
@@ -300,20 +550,87 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div>
)
}
return readArticles.length === 0 ? (
<div className="explore-empty" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
<p>No read articles yet. Pull to refresh!</p>
</div>
) : (
<div className="explore-grid">
{readArticles.map((post) => (
<BlogPostCard
key={post.event.id}
post={post}
href={getPostUrl(post)}
/>
))}
</div>
// Show empty state if loaded but no reads
if (reads.length === 0 && loadedTabs.has('reads')) {
return (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles read yet.
</div>
)
}
// Show reads with filters
return (
<>
<ReadingProgressFilters
selectedFilter={readingProgressFilter}
onFilterChange={handleReadingProgressFilterChange}
/>
{filteredReads.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles match this filter.
</div>
) : (
<div className="explore-grid">
{filteredReads.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)}
</>
)
case 'links':
// Show loading skeletons only while initially loading
if (loading && !loadedTabs.has('links')) {
return (
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))}
</div>
)
}
// Show empty state if loaded but no links
if (links.length === 0 && loadedTabs.has('links')) {
return (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links with reading progress yet.
</div>
)
}
// Show links with filters
return (
<>
<ReadingProgressFilters
selectedFilter={readingProgressFilter}
onFilterChange={handleReadingProgressFilterChange}
/>
{filteredLinks.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links match this filter.
</div>
) : (
<div className="explore-grid">
{filteredLinks.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)}
</>
)
case 'writings':
@@ -326,13 +643,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div>
)
}
return writings.length === 0 ? (
<div className="explore-empty" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
<p>
{isOwnProfile
? 'No articles written yet. Pull to refresh!'
: 'No articles written yet. Pull to refresh!'}
</p>
return writings.length === 0 && !loading ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles written yet.
</div>
) : (
<div className="explore-grid">
@@ -360,12 +673,6 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
<div className="explore-header">
{viewingPubkey && <AuthorCard authorPubkey={viewingPubkey} clickable={false} />}
{loading && hasData && (
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
<FontAwesomeIcon icon={faSpinner} spin />
</div>
)}
<div className="me-tabs">
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
@@ -386,12 +693,20 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
<span className="tab-label">Bookmarks</span>
</button>
<button
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
data-tab="archive"
onClick={() => navigate('/me/archive')}
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
data-tab="reads"
onClick={() => navigate('/me/reads')}
>
<FontAwesomeIcon icon={faBooks} />
<span className="tab-label">Archive</span>
<span className="tab-label">Reads</span>
</button>
<button
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
data-tab="links"
onClick={() => navigate('/me/links')}
>
<FontAwesomeIcon icon={faLink} />
<span className="tab-label">Links</span>
</button>
</>
)}

View File

@@ -1,8 +1,9 @@
import React, { useMemo } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
import { faHighlighter, faClock, faNewspaper } from '@fortawesome/free-solid-svg-icons'
import { format } from 'date-fns'
import { useImageCache } from '../hooks/useImageCache'
import { useAdaptiveTextColor } from '../hooks/useAdaptiveTextColor'
import { UserSettings } from '../services/settingsService'
import { Highlight, HighlightLevel } from '../types/highlights'
import { HighlightVisibility } from './HighlightsPanel'
@@ -34,6 +35,7 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
highlightVisibility = { nostrverse: true, friends: true, mine: true }
}) => {
const cachedImage = useImageCache(image)
const { textColor } = useAdaptiveTextColor(cachedImage)
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
const isLongSummary = summary && summary.length > 150
@@ -70,13 +72,25 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
}
}, [highlights, highlightVisibility, settings])
if (cachedImage) {
// Show hero section if we have an image OR a title
if (cachedImage || title) {
return (
<>
<div className="reader-hero-image">
<img src={cachedImage} alt={title || 'Article image'} />
{cachedImage ? (
<img src={cachedImage} alt={title || 'Article image'} />
) : (
<div className="reader-hero-placeholder">
<FontAwesomeIcon icon={faNewspaper} />
</div>
)}
{formattedDate && (
<div className="publish-date-topright">
<div
className="publish-date-topright"
style={{
color: textColor
}}
>
{formattedDate}
</div>
)}
@@ -118,7 +132,12 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
{title && (
<div className="reader-header">
{formattedDate && (
<div className="publish-date-topright">
<div
className="publish-date-topright"
style={{
color: textColor
}}
>
{formattedDate}
</div>
)}

View File

@@ -0,0 +1,47 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed'
interface ReadingProgressFiltersProps {
selectedFilter: ReadingProgressFilterType
onFilterChange: (filter: ReadingProgressFilterType) => void
}
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
const filters = [
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
]
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 ReadingProgressFilters

View File

@@ -19,6 +19,21 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
}) => {
const clampedProgress = Math.min(100, Math.max(0, progress))
// Determine reading state based on progress (matching readingProgressUtils.ts logic)
const progressDecimal = clampedProgress / 100
const isStarted = progressDecimal > 0 && progressDecimal <= 0.10
// Determine bar color based on state
let barColorClass = ''
let barColorStyle: string | undefined = 'var(--color-primary)' // Default blue
if (isComplete) {
barColorClass = 'bg-green-500'
barColorStyle = undefined
} else if (isStarted) {
barColorStyle = 'var(--color-text)' // Neutral text color (matches card titles)
}
// Calculate left and right offsets based on sidebar states (desktop only)
const leftOffset = isSidebarCollapsed
? 'var(--sidebar-collapsed-width)'
@@ -42,14 +57,10 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
style={{ backgroundColor: 'var(--color-border)' }}
>
<div
className={`h-full rounded-full transition-all duration-300 relative ${
isComplete
? 'bg-green-500'
: ''
}`}
className={`h-full rounded-full transition-all duration-300 relative ${barColorClass}`}
style={{
width: `${clampedProgress}%`,
backgroundColor: isComplete ? undefined : 'var(--color-primary)'
backgroundColor: barColorStyle
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
@@ -60,7 +71,9 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
isComplete ? 'text-green-500' : ''
}`}
style={{ color: isComplete ? undefined : 'var(--color-text-muted)' }}
style={{
color: isComplete ? undefined : isStarted ? 'var(--color-text)' : 'var(--color-text-muted)'
}}
>
{isComplete ? '✓' : `${clampedProgress}%`}
</div>

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

@@ -6,13 +6,12 @@ import IconButton from './IconButton'
import { loadFont } from '../utils/fontLoader'
import ThemeSettings from './Settings/ThemeSettings'
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
import ZapSettings from './Settings/ZapSettings'
import OfflineModeSettings from './Settings/OfflineModeSettings'
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,
@@ -35,6 +34,8 @@ const DEFAULT_SETTINGS: UserSettings = {
zapSplitAuthorWeight: 50,
useLocalRelayAsCache: true,
rebroadcastToAllRelays: false,
paragraphAlignment: 'justify',
syncReadingPosition: false,
}
interface SettingsProps {
@@ -162,13 +163,12 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
<div className="settings-content">
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
<OfflineModeSettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
<PWASettings />
</div>
<VersionFooter />
</div>
)
}

View File

@@ -0,0 +1,125 @@
import React from 'react'
import { faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
interface LayoutBehaviorSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
}
const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ settings, onUpdate }) => {
return (
<div className="settings-section">
<h3 className="section-title">Layout & Behavior</h3>
<div className="setting-group setting-inline">
<label>Default Bookmark View</label>
<div className="setting-buttons">
<IconButton
icon={faList}
onClick={() => onUpdate({ defaultViewMode: 'compact' })}
title="Compact list view"
ariaLabel="Compact list view"
variant={(settings.defaultViewMode || 'compact') === 'compact' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faThLarge}
onClick={() => onUpdate({ defaultViewMode: 'cards' })}
title="Cards view"
ariaLabel="Cards view"
variant={settings.defaultViewMode === 'cards' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faImage}
onClick={() => onUpdate({ defaultViewMode: 'large' })}
title="Large preview view"
ariaLabel="Large preview view"
variant={settings.defaultViewMode === 'large' ? 'primary' : 'ghost'}
/>
</div>
</div>
<div className="setting-group">
<label htmlFor="collapseOnArticleOpen" className="checkbox-label">
<input
id="collapseOnArticleOpen"
type="checkbox"
checked={settings.collapseOnArticleOpen !== false}
onChange={(e) => onUpdate({ collapseOnArticleOpen: e.target.checked })}
className="setting-checkbox"
/>
<span>Collapse bookmark bar when opening an article</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="sidebarCollapsed" className="checkbox-label">
<input
id="sidebarCollapsed"
type="checkbox"
checked={settings.sidebarCollapsed !== false}
onChange={(e) => onUpdate({ sidebarCollapsed: e.target.checked })}
className="setting-checkbox"
/>
<span>Start with bookmarks sidebar collapsed</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="highlightsCollapsed" className="checkbox-label">
<input
id="highlightsCollapsed"
type="checkbox"
checked={settings.highlightsCollapsed !== false}
onChange={(e) => onUpdate({ highlightsCollapsed: e.target.checked })}
className="setting-checkbox"
/>
<span>Start with highlights panel collapsed</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
<input
id="rebroadcastToAllRelays"
type="checkbox"
checked={settings.rebroadcastToAllRelays ?? false}
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
className="setting-checkbox"
/>
<span>Rebroadcast events while browsing</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
<input
id="autoCollapseSidebarOnMobile"
type="checkbox"
checked={settings.autoCollapseSidebarOnMobile !== false}
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
className="setting-checkbox"
/>
<span>Auto-collapse sidebar on small screens</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="syncReadingPosition" className="checkbox-label">
<input
id="syncReadingPosition"
type="checkbox"
checked={settings.syncReadingPosition ?? false}
onChange={(e) => onUpdate({ syncReadingPosition: e.target.checked })}
className="setting-checkbox"
/>
<span>Sync reading position across devices</span>
</label>
</div>
</div>
)
}
export default LayoutBehaviorSettings

View File

@@ -3,15 +3,15 @@ import { faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
interface LayoutNavigationSettingsProps {
interface LayoutBehaviorSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
}
const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ settings, onUpdate }) => {
const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ settings, onUpdate }) => {
return (
<div className="settings-section">
<h3 className="section-title">Layout & Navigation</h3>
<h3 className="section-title">Layout & Behavior</h3>
<div className="setting-group setting-inline">
<label>Default Bookmark View</label>
@@ -52,9 +52,61 @@ const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ set
<span>Collapse bookmark bar when opening an article</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="sidebarCollapsed" className="checkbox-label">
<input
id="sidebarCollapsed"
type="checkbox"
checked={settings.sidebarCollapsed !== false}
onChange={(e) => onUpdate({ sidebarCollapsed: e.target.checked })}
className="setting-checkbox"
/>
<span>Start with bookmarks sidebar collapsed</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="highlightsCollapsed" className="checkbox-label">
<input
id="highlightsCollapsed"
type="checkbox"
checked={settings.highlightsCollapsed !== false}
onChange={(e) => onUpdate({ highlightsCollapsed: e.target.checked })}
className="setting-checkbox"
/>
<span>Start with highlights panel collapsed</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
<input
id="rebroadcastToAllRelays"
type="checkbox"
checked={settings.rebroadcastToAllRelays ?? false}
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
className="setting-checkbox"
/>
<span>Rebroadcast events while browsing</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
<input
id="autoCollapseSidebarOnMobile"
type="checkbox"
checked={settings.autoCollapseSidebarOnMobile !== false}
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
className="setting-checkbox"
/>
<span>Auto-collapse sidebar on small screens</span>
</label>
</div>
</div>
)
}
export default LayoutNavigationSettings
export default LayoutBehaviorSettings

View File

@@ -1,173 +0,0 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { faTrash } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
import IconButton from '../IconButton'
interface OfflineModeSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
onClose?: () => void
}
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
const navigate = useNavigate()
const [cacheStats, setCacheStats] = useState<{
totalSizeMB: number
itemCount: number
items: Array<{ url: string, sizeMB: number }>
}>({ totalSizeMB: 0, itemCount: 0, items: [] })
const handleLinkClick = (url: string) => {
if (onClose) onClose()
navigate(`/r/${encodeURIComponent(url)}`)
}
const handleClearCache = async () => {
if (confirm('Are you sure you want to clear all cached images?')) {
await clearImageCache()
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
}
// Update cache stats periodically
useEffect(() => {
const updateStats = async () => {
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
updateStats() // Initial load
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
return () => clearInterval(interval)
}, [])
return (
<div className="settings-section">
<h3 className="section-title">Flight Mode</h3>
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
<input
id="enableImageCache"
type="checkbox"
checked={settings.enableImageCache ?? true}
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
className="setting-checkbox"
/>
<span>Use local image cache</span>
</label>
{(settings.enableImageCache ?? true) && (
<div style={{
fontSize: '0.85rem',
color: 'var(--text-secondary)',
display: 'flex',
alignItems: 'center',
gap: '0.5rem'
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
( {cacheStats.totalSizeMB.toFixed(1)} MB /
<input
id="imageCacheSizeMB"
type="number"
min="10"
max="500"
value={settings.imageCacheSizeMB ?? 210}
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
style={{
width: '50px',
padding: '0.15rem 0.35rem',
background: 'var(--surface-secondary)',
border: '1px solid var(--border-color, #333)',
borderRadius: '4px',
color: 'inherit',
fontSize: 'inherit',
fontFamily: 'inherit',
textAlign: 'center'
}}
/>
MB used )
</span>
<IconButton
icon={faTrash}
onClick={handleClearCache}
title="Clear cache"
variant="ghost"
size={28}
/>
</div>
)}
</div>
<div className="setting-group">
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
<input
id="useLocalRelayAsCache"
type="checkbox"
checked={settings.useLocalRelayAsCache ?? true}
onChange={(e) => onUpdate({ useLocalRelayAsCache: e.target.checked })}
className="setting-checkbox"
/>
<span>Use local relays as cache</span>
</label>
</div>
<div style={{
marginTop: '1.5rem',
padding: '1rem',
background: 'var(--surface-secondary)',
borderRadius: '6px',
fontSize: '0.9rem',
lineHeight: '1.6'
}}>
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
Boris works best with a local relay. Consider running{' '}
<a
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent, #8b5cf6)' }}
>
Citrine
</a>
{' or '}
<a
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent, #8b5cf6)' }}
>
nostr-relay-tray
</a>
. Don't know what relays are? Learn more{' '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('https://nostr.how/en/relays')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
{' and '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
.
</p>
</div>
</div>
)
}
export default OfflineModeSettings

View File

@@ -1,80 +1,206 @@
import React from 'react'
import { faDownload, faCheckCircle, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { faDownload, faCheckCircle, faTrash } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { usePWAInstall } from '../../hooks/usePWAInstall'
import { useIsMobile } from '../../hooks/useMediaQuery'
import { UserSettings } from '../../services/settingsService'
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
const PWASettings: React.FC = () => {
interface PWASettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
onClose?: () => void
}
const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }) => {
const navigate = useNavigate()
const isMobile = useIsMobile()
const { isInstallable, isInstalled, installApp } = usePWAInstall()
const [cacheStats, setCacheStats] = useState<{
totalSizeMB: number
itemCount: number
items: Array<{ url: string, sizeMB: number }>
}>({ totalSizeMB: 0, itemCount: 0, items: [] })
const handleInstall = async () => {
if (isInstalled) return
const success = await installApp()
if (success) {
console.log('App installed successfully')
}
}
if (isInstalled) {
return (
<div className="settings-section">
<h3>Progressive Web App</h3>
<div className="setting-item">
<div className="setting-info">
<FontAwesomeIcon icon={faCheckCircle} style={{ color: '#22c55e', marginRight: '8px' }} />
<span>Boris is installed as an app</span>
</div>
<p className="setting-description">
You can launch Boris from your home screen or app drawer.
</p>
</div>
</div>
)
const handleLinkClick = (url: string) => {
if (onClose) onClose()
navigate(`/r/${encodeURIComponent(url)}`)
}
if (!isInstallable) {
return null
const handleClearCache = async () => {
if (confirm('Are you sure you want to clear all cached images?')) {
await clearImageCache()
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
}
// Update cache stats periodically
useEffect(() => {
const updateStats = async () => {
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
updateStats() // Initial load
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
return () => clearInterval(interval)
}, [])
return (
<div className="settings-section">
<h3>Progressive Web App</h3>
<div className="setting-item">
<div className="setting-info">
<FontAwesomeIcon icon={faMobileAlt} style={{ marginRight: '8px' }} />
<span>Install Boris as an app</span>
<h3 className="section-title">App & Airplane Mode</h3>
<div style={{ display: 'flex', gap: '2rem', alignItems: 'stretch' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
Boris is offlinefirst by design. You can read, create highlights, and browse your library without being connected to the internet. Boris will store changes locally and sync later.
</p>
{/* Flight Mode Section - Checkboxes First */}
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
<input
id="enableImageCache"
type="checkbox"
checked={settings.enableImageCache ?? true}
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
className="setting-checkbox"
/>
<span>Use local image cache</span>
</label>
{(settings.enableImageCache ?? true) && (
<div style={{
fontSize: '0.85rem',
color: 'var(--text-secondary)',
display: 'flex',
alignItems: 'center',
gap: '0.5rem'
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
( {cacheStats.totalSizeMB.toFixed(1)} MB /
<input
id="imageCacheSizeMB"
type="number"
min="10"
max="500"
value={settings.imageCacheSizeMB ?? 210}
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
style={{
width: '50px',
padding: '0.15rem 0.35rem',
background: 'var(--surface-secondary)',
border: '1px solid var(--border-color, #333)',
borderRadius: '4px',
color: 'inherit',
fontSize: 'inherit',
fontFamily: 'inherit',
textAlign: 'center'
}}
/>
MB used )
</span>
<FontAwesomeIcon
icon={faTrash}
onClick={handleClearCache}
title="Clear cache"
style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.7 }}
/>
</div>
)}
</div>
{/* PWA Install Section - Paragraphs */}
<div className="setting-group">
<p className="setting-description" style={{ marginTop: '0.5rem', marginBottom: '0.75rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
<strong>Note:</strong> Boris works best with a local relay. Consider running{' '}
<a
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent, #8b5cf6)' }}
>
Citrine
</a>
{' or '}
<a
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent, #8b5cf6)' }}
>
nostr-relay-tray
</a>
{' '}to bring full offline functionality to Boris. Don't know what relays are? Learn more{' '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('https://nostr.how/en/relays')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
{' and '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
.
</p>
</div>
<div className="setting-group">
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
<input
id="useLocalRelayAsCache"
type="checkbox"
checked={settings.useLocalRelayAsCache ?? true}
onChange={(e) => onUpdate({ useLocalRelayAsCache: e.target.checked })}
className="setting-checkbox"
/>
<span>Use local relays as cache</span>
</label>
</div>
<div className="setting-group">
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
Install Boris on your device for a native app experience.
</p>
<button
onClick={handleInstall}
className="zap-preset-btn"
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
disabled={isInstalled || !isInstallable}
>
<FontAwesomeIcon icon={isInstalled ? faCheckCircle : faDownload} />
{isInstalled ? 'Installed' : 'Install App'}
</button>
</div>
</div>
<p className="setting-description">
Install Boris on your device for a native app experience with offline support.
</p>
<button
onClick={handleInstall}
className="install-button"
style={{
marginTop: '12px',
padding: '8px 16px',
background: 'linear-gradient(135deg, #3b82f6 0%, #1e40af 100%)',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '14px',
fontWeight: '500',
transition: 'transform 0.2s, box-shadow 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.3)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = 'none'
}}
>
<FontAwesomeIcon icon={faDownload} />
Install App
</button>
{!isMobile && (
<img
src="/pwa.svg"
alt="Progressive Web App"
style={{ width: '30%', height: 'auto', flexShrink: 0, opacity: 0.8 }}
/>
)}
</div>
</div>
)

View File

@@ -1,5 +1,5 @@
import React from 'react'
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser, faAlignLeft, faAlignJustify } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
import ColorPicker from '../ColorPicker'
@@ -19,35 +19,6 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
<div className="settings-section">
<h3 className="section-title">Reading & Display</h3>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<div className="setting-group setting-inline" style={{ flex: '1 1 auto', minWidth: '200px' }}>
<label htmlFor="readingFont">Reading Font</label>
<div className="setting-control">
<FontSelector
value={settings.readingFont || 'source-serif-4'}
onChange={(font) => onUpdate({ readingFont: font })}
/>
</div>
</div>
<div className="setting-group setting-inline" style={{ flex: '0 1 auto' }}>
<label>Font Size</label>
<div className="setting-buttons">
{[16, 18, 21, 24, 28, 32].map(size => (
<button
key={size}
onClick={() => onUpdate({ fontSize: size })}
className={`font-size-btn ${(settings.fontSize || 21) === size ? 'active' : ''}`}
title={`${size}px`}
style={{ fontSize: `${size - 2}px` }}
>
A
</button>
))}
</div>
</div>
</div>
<div className="setting-group setting-inline">
<label>Highlight Style</label>
<div className="setting-buttons">
@@ -69,31 +40,21 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
</div>
<div className="setting-group setting-inline">
<label className="setting-label">My Highlights</label>
<div className="setting-control">
<ColorPicker
selectedColor={settings.highlightColorMine || '#fde047'}
onColorChange={(color) => onUpdate({ highlightColorMine: color })}
<label>Paragraph Alignment</label>
<div className="setting-buttons">
<IconButton
icon={faAlignLeft}
onClick={() => onUpdate({ paragraphAlignment: 'left' })}
title="Left aligned"
ariaLabel="Left aligned"
variant={settings.paragraphAlignment === 'left' ? 'primary' : 'ghost'}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">Friends Highlights</label>
<div className="setting-control">
<ColorPicker
selectedColor={settings.highlightColorFriends || '#f97316'}
onColorChange={(color) => onUpdate({ highlightColorFriends: color })}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">Nostrverse Highlights</label>
<div className="setting-control">
<ColorPicker
selectedColor={settings.highlightColorNostrverse || '#9333ea'}
onColorChange={(color) => onUpdate({ highlightColorNostrverse: color })}
<IconButton
icon={faAlignJustify}
onClick={() => onUpdate({ paragraphAlignment: 'justify' })}
title="Justified"
ariaLabel="Justified"
variant={(settings.paragraphAlignment || 'justify') === 'justify' ? 'primary' : 'ghost'}
/>
</div>
</div>
@@ -137,6 +98,65 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
</div>
</div>
<div className="setting-group setting-inline">
<label htmlFor="readingFont">Reading Font</label>
<div className="setting-control">
<FontSelector
value={settings.readingFont || 'source-serif-4'}
onChange={(font) => onUpdate({ readingFont: font })}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">Font Size</label>
<div className="setting-control">
<div className="setting-buttons">
{[16, 18, 21, 24, 28, 32].map(size => (
<button
key={size}
onClick={() => onUpdate({ fontSize: size })}
className={`font-size-btn ${(settings.fontSize || 21) === size ? 'active' : ''}`}
title={`${size}px`}
style={{ fontSize: `${size - 2}px` }}
>
A
</button>
))}
</div>
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">My Highlights</label>
<div className="setting-control">
<ColorPicker
selectedColor={settings.highlightColorMine || '#fde047'}
onColorChange={(color) => onUpdate({ highlightColorMine: color })}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">Friends Highlights</label>
<div className="setting-control">
<ColorPicker
selectedColor={settings.highlightColorFriends || '#f97316'}
onColorChange={(color) => onUpdate({ highlightColorFriends: color })}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">Nostrverse Highlights</label>
<div className="setting-control">
<ColorPicker
selectedColor={settings.highlightColorNostrverse || '#9333ea'}
onColorChange={(color) => onUpdate({ highlightColorNostrverse: color })}
/>
</div>
</div>
<div className="setting-group">
<label htmlFor="showHighlights" className="checkbox-label">
<input
@@ -157,7 +177,8 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
style={{
fontFamily: previewFontFamily,
fontSize: `${settings.fontSize || 21}px`,
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00')
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00'),
'--paragraph-alignment': settings.paragraphAlignment || 'justify'
} as React.CSSProperties}
>
<h3>The Quick Brown Fox</h3>

View File

@@ -1,70 +0,0 @@
import React from 'react'
import { UserSettings } from '../../services/settingsService'
interface StartupPreferencesSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
}
const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({ settings, onUpdate }) => {
return (
<div className="settings-section">
<h3 className="section-title">Startup & Behavior</h3>
<div className="setting-group">
<label htmlFor="sidebarCollapsed" className="checkbox-label">
<input
id="sidebarCollapsed"
type="checkbox"
checked={settings.sidebarCollapsed !== false}
onChange={(e) => onUpdate({ sidebarCollapsed: e.target.checked })}
className="setting-checkbox"
/>
<span>Start with bookmarks sidebar collapsed</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="highlightsCollapsed" className="checkbox-label">
<input
id="highlightsCollapsed"
type="checkbox"
checked={settings.highlightsCollapsed !== false}
onChange={(e) => onUpdate({ highlightsCollapsed: e.target.checked })}
className="setting-checkbox"
/>
<span>Start with highlights panel collapsed</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
<input
id="rebroadcastToAllRelays"
type="checkbox"
checked={settings.rebroadcastToAllRelays ?? false}
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
className="setting-checkbox"
/>
<span>Rebroadcast events while browsing</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
<input
id="autoCollapseSidebarOnMobile"
type="checkbox"
checked={settings.autoCollapseSidebarOnMobile !== false}
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
className="setting-checkbox"
/>
<span>Auto-collapse sidebar on small screens</span>
</label>
</div>
</div>
)
}
export default StartupPreferencesSettings

View File

@@ -1,5 +1,6 @@
import React from 'react'
import { UserSettings } from '../../services/settingsService'
import { useIsMobile } from '../../hooks/useMediaQuery'
interface ZapSettingsProps {
settings: UserSettings
@@ -7,6 +8,7 @@ interface ZapSettingsProps {
}
const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
const isMobile = useIsMobile()
const highlighterWeight = settings.zapSplitHighlighterWeight ?? 50
const borisWeight = settings.zapSplitBorisWeight ?? 2.1
const authorWeight = settings.zapSplitAuthorWeight ?? 50
@@ -42,98 +44,119 @@ const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
<div className="settings-section">
<h3 className="section-title">Zap Splits</h3>
<div className="setting-group">
<label className="setting-label">Presets</label>
<div className="zap-preset-buttons">
<button
onClick={() => applyPreset(presets.default)}
className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
title="You: 49%, Author: 49%, Boris: 2%"
>
Default
</button>
<button
onClick={() => applyPreset(presets.generous)}
className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
title="You: 6%, Author: 83%, Boris: 11%"
>
Generous
</button>
<button
onClick={() => applyPreset(presets.selfless)}
className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
title="You: 1%, Author: 80%, Boris: 19%"
>
Selfless
</button>
<button
onClick={() => applyPreset(presets.boris)}
className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
title="You: 10%, Author: 10%, Boris: 80%"
>
Boris 🧡
</button>
</div>
</div>
<div className="setting-group">
<label className="setting-label">Your Share</label>
<div className="zap-split-container">
<div className="zap-split-labels">
<span className="zap-split-label">Weight: {highlighterWeight}</span>
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
<div style={{ display: 'flex', gap: '2rem', alignItems: 'stretch' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<div className="setting-group">
<label className="setting-label">Presets</label>
<div className="zap-preset-buttons">
<button
onClick={() => applyPreset(presets.default)}
className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
title="You: 49%, Author: 49%, Boris: 2%"
>
Default
</button>
<button
onClick={() => applyPreset(presets.generous)}
className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
title="You: 6%, Author: 83%, Boris: 11%"
>
Generous
</button>
<button
onClick={() => applyPreset(presets.selfless)}
className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
title="You: 1%, Author: 80%, Boris: 19%"
>
Selfless
</button>
<button
onClick={() => applyPreset(presets.boris)}
className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
title="You: 10%, Author: 10%, Boris: 80%"
>
Boris 🧡
</button>
</div>
</div>
<input
type="range"
min="0"
max="100"
value={highlighterWeight}
onChange={(e) => onUpdate({ zapSplitHighlighterWeight: parseInt(e.target.value) })}
className="zap-split-slider"
/>
</div>
</div>
<div className="setting-group">
<label className="setting-label">Author(s) Share</label>
<div className="zap-split-container">
<div className="zap-split-labels">
<span className="zap-split-label">Weight: {authorWeight}</span>
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
<div className="setting-group">
<div className="zap-split-container">
<div className="zap-split-labels">
<span className="zap-split-label">Your Share: {highlighterWeight}</span>
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
</div>
<input
type="range"
min="0"
max="100"
value={highlighterWeight}
onChange={(e) => onUpdate({ zapSplitHighlighterWeight: parseInt(e.target.value) })}
className="zap-split-slider"
list="highlighter-ticks"
/>
<datalist id="highlighter-ticks">
<option value="50" label="50%"></option>
</datalist>
</div>
</div>
<input
type="range"
min="0"
max="100"
value={authorWeight}
onChange={(e) => onUpdate({ zapSplitAuthorWeight: parseInt(e.target.value) })}
className="zap-split-slider"
/>
</div>
</div>
<div className="setting-group">
<label className="setting-label">Support Boris</label>
<div className="zap-split-container">
<div className="zap-split-labels">
<span className="zap-split-label">Weight: {borisWeight.toFixed(1)}</span>
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
<div className="setting-group">
<div className="zap-split-container">
<div className="zap-split-labels">
<span className="zap-split-label">Author's Share: {authorWeight}</span>
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
</div>
<input
type="range"
min="0"
max="100"
value={authorWeight}
onChange={(e) => onUpdate({ zapSplitAuthorWeight: parseInt(e.target.value) })}
className="zap-split-slider"
list="author-ticks"
/>
<datalist id="author-ticks">
<option value="50" label="50%"></option>
</datalist>
</div>
</div>
<input
type="range"
min="0"
max="10"
step="0.1"
value={borisWeight}
onChange={(e) => onUpdate({ zapSplitBorisWeight: parseFloat(e.target.value) })}
className="zap-split-slider"
/>
</div>
</div>
<div className="zap-split-description">
Weights determine zap splits when highlighting nostr-native content.
If the content has multiple authors, their share is divided proportionally.
<div className="setting-group">
<div className="zap-split-container">
<div className="zap-split-labels">
<span className="zap-split-label">Boris' Share: {borisWeight.toFixed(1)}</span>
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
</div>
<input
type="range"
min="0"
max="10"
step="0.1"
value={borisWeight}
onChange={(e) => onUpdate({ zapSplitBorisWeight: parseFloat(e.target.value) })}
className="zap-split-slider"
list="boris-ticks"
/>
<datalist id="boris-ticks">
<option value="5" label="5"></option>
</datalist>
</div>
</div>
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
Weights determine zap splits when highlighting nostr-native content.
If the content has multiple authors, their share is divided proportionally.
</p>
</div>
{!isMobile && (
<img
src="/zaps.svg"
alt="Zap Splits"
style={{ width: '30%', height: 'auto', flexShrink: 0, opacity: 0.8 }}
/>
)}
</div>
</div>
)

View File

@@ -1,28 +1,22 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper, faTimes, faBolt } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { Accounts } from 'applesauce-accounts'
import { RelayPool } from 'applesauce-relay'
import IconButton from './IconButton'
import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService'
import { RELAYS } from '../config/relays'
interface SidebarHeaderProps {
onToggleCollapse: () => void
onLogout: () => void
onOpenSettings: () => void
relayPool: RelayPool | null
isMobile?: boolean
}
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool, isMobile = false }) => {
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
const [isConnecting, setIsConnecting] = useState(false)
const [showAddModal, setShowAddModal] = useState(false)
const navigate = useNavigate()
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
@@ -54,14 +48,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
}
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
if (!activeAccount || !relayPool) {
throw new Error('Please login to create bookmarks')
}
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
}
const profileImage = getProfileImage()
return (
@@ -117,13 +103,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Explore"
variant="ghost"
/>
<IconButton
icon={faBolt}
onClick={() => navigate('/support')}
title="Support"
ariaLabel="Support"
variant="ghost"
/>
<IconButton
icon={faGear}
onClick={onOpenSettings}
@@ -131,15 +110,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Settings"
variant="ghost"
/>
{activeAccount && (
<IconButton
icon={faPlus}
onClick={() => setShowAddModal(true)}
title="Add bookmark"
ariaLabel="Add bookmark"
variant="ghost"
/>
)}
{activeAccount ? (
<IconButton
icon={faRightFromBracket}
@@ -159,12 +129,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
)}
</div>
</div>
{showAddModal && (
<AddBookmarkModal
onClose={() => setShowAddModal(false)}
onSave={handleSaveBookmark}
/>
)}
</>
)
}

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBolt, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
import { faHeart, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
import { fetchProfiles } from '../services/profileService'
import { UserSettings } from '../services/settingsService'
@@ -207,7 +207,7 @@ const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) =>
className="absolute -bottom-1 -right-1 w-8 h-8 bg-yellow-400 rounded-full flex items-center justify-center border-2"
style={{ borderColor: 'var(--color-bg)' }}
>
<FontAwesomeIcon icon={faBolt} className="text-zinc-900 text-sm" />
<FontAwesomeIcon icon={faHeart} className="text-zinc-900 text-sm" />
</div>
)}
</div>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
@@ -105,13 +105,33 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
const highlightsRef = useRef<HTMLDivElement>(null)
const mainPaneRef = useRef<HTMLDivElement>(null)
// Detect scroll direction to hide/show mobile buttons
// Now using window scroll (document scroll) instead of pane scroll
// Detect scroll direction and position to hide/show mobile buttons
// Only hide on scroll down when viewing article content
const isViewingArticle = !!(props.selectedUrl)
const scrollDirection = useScrollDirection({
threshold: 10,
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && isViewingArticle
})
const showMobileButtons = scrollDirection !== 'down'
// Track if we're at the top of the page
const [isAtTop, setIsAtTop] = useState(true)
useEffect(() => {
if (!isMobile || !isViewingArticle) return
const handleScroll = () => {
setIsAtTop(window.scrollY <= 10)
}
handleScroll() // Check initial position
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [isMobile, isViewingArticle])
// Bookmark button: hide only when scrolling down
const showBookmarkButton = scrollDirection !== 'down'
// Highlights button: hide when scrolling down OR at the top
const showHighlightsButton = scrollDirection !== 'down' && !isAtTop
// Lock body scroll when mobile sidebar or highlights is open
useEffect(() => {
@@ -229,11 +249,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
return (
<>
{/* Mobile bookmark button - only show when viewing article or explore (not on settings/me/profile/support) */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showMe && !props.showProfile && !props.showSupport && (
{/* Mobile bookmark button - always show except on settings page */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && (
<button
className={`fixed z-[900] bg-zinc-800/70 border border-zinc-600/40 rounded-lg text-zinc-200 flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
showBookmarkButton ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
}`}
style={{
top: 'calc(1rem + env(safe-area-inset-top))',
@@ -249,11 +269,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
</button>
)}
{/* Mobile highlights button - only show when viewing article or explore (not on settings/me/profile/support) */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showMe && !props.showProfile && !props.showSupport && (
{/* Mobile highlights button - only show when viewing article content */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && isViewingArticle && (
<button
className={`fixed z-[900] border border-zinc-600/40 rounded-lg flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
showHighlightsButton ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
}`}
style={{
top: 'calc(1rem + env(safe-area-inset-top))',
@@ -304,6 +324,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
loading={props.bookmarksLoading}
relayPool={props.relayPool}
isMobile={isMobile}
settings={props.settings}
/>
</div>
<div
@@ -402,7 +423,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
)}
<RelayStatusIndicator
relayPool={props.relayPool}
showOnMobile={showMobileButtons}
showOnMobile={showBookmarkButton}
/>
{props.toastMessage && (
<Toast

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

15
src/config/kinds.ts Normal file
View File

@@ -0,0 +1,15 @@
// Nostr event kinds used throughout the application
export const KINDS = {
Highlights: 9802, // NIP-?? user highlights
BlogPost: 30023, // NIP-23 long-form article
AppData: 30078, // NIP-78 application data (reading positions)
List: 30001, // NIP-51 list (addressable)
ListReplaceable: 30003, // NIP-51 replaceable list
ListSimple: 10003, // NIP-51 simple list
WebBookmark: 39701, // NIP-B0 web bookmark
ReactionToEvent: 7, // emoji reaction to event (used for mark-as-read)
ReactionToUrl: 17 // emoji reaction to URL (used for mark-as-read)
} as const
export type KindValue = typeof KINDS[keyof typeof KINDS]

View File

@@ -2,20 +2,21 @@
* Nostr gateway URLs for viewing events and profiles on the web
*/
export const NOSTR_GATEWAY = 'https://ants.sh' as const
export const NOSTR_GATEWAY = 'https://nostr.at' as const
export const SEARCH_PORTAL = 'https://ants.sh' as const
/**
* Get a profile URL on the gateway
*/
export function getProfileUrl(npub: string): string {
return `${NOSTR_GATEWAY}/p/${npub}`
return `${NOSTR_GATEWAY}/${npub}`
}
/**
* Get an event URL on the gateway
*/
export function getEventUrl(nevent: string): string {
return `${NOSTR_GATEWAY}/e/${nevent}`
return `${NOSTR_GATEWAY}/${nevent}`
}
/**
@@ -23,12 +24,14 @@ export function getEventUrl(nevent: string): string {
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
*/
export function getNostrUrl(identifier: string): string {
// Check the prefix to determine if it's a profile or event
if (identifier.startsWith('npub') || identifier.startsWith('nprofile')) {
return `${NOSTR_GATEWAY}/p/${identifier}`
}
// Everything else (note, nevent, naddr) goes to /e/
return `${NOSTR_GATEWAY}/e/${identifier}`
// nostr.at uses simple /{identifier} format for all types
return `${NOSTR_GATEWAY}/${identifier}`
}
/**
* Get a search portal URL with a query
*/
export function getSearchUrl(query: string): string {
return `${SEARCH_PORTAL}/?q=${encodeURIComponent(query)}`
}

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

@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react'
import { FastAverageColor } from 'fast-average-color'
interface AdaptiveTextColor {
textColor: string
}
/**
* Hook to determine optimal text color based on image background
* Samples the top-right corner of the image to ensure publication date is readable
*
* @param imageUrl - The URL of the image to analyze
* @returns Object containing textColor for optimal contrast
*/
export function useAdaptiveTextColor(imageUrl: string | undefined): AdaptiveTextColor {
const [colors, setColors] = useState<AdaptiveTextColor>({
textColor: '#ffffff'
})
useEffect(() => {
if (!imageUrl) {
// No image, use default white text
setColors({
textColor: '#ffffff'
})
return
}
const fac = new FastAverageColor()
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
try {
const width = img.naturalWidth
const height = img.naturalHeight
// Sample top-right corner (last 25% width, first 25% height)
const color = fac.getColor(img, {
left: Math.floor(width * 0.75),
top: 0,
width: Math.floor(width * 0.25),
height: Math.floor(height * 0.25)
})
console.log('Adaptive color detected:', {
hex: color.hex,
rgb: color.rgb,
isLight: color.isLight,
isDark: color.isDark
})
// Use library's built-in isLight check for optimal contrast
if (color.isLight) {
console.log('Light background detected, using black text')
setColors({
textColor: '#000000'
})
} else {
console.log('Dark background detected, using white text')
setColors({
textColor: '#ffffff'
})
}
} catch (error) {
// Fallback to default on error
console.error('Error analyzing image color:', error)
setColors({
textColor: '#ffffff'
})
}
}
img.onerror = () => {
// Fallback to default if image fails to load
setColors({
textColor: '#ffffff'
})
}
img.src = imageUrl
return () => {
fac.destroy()
}
}, [imageUrl])
return colors
}

View File

@@ -13,6 +13,7 @@ interface UseBookmarksDataParams {
activeAccount: IAccount | undefined
accountManager: AccountManager
naddr?: string
externalUrl?: string
currentArticleCoordinate?: string
currentArticleEventId?: string
settings?: UserSettings
@@ -23,6 +24,7 @@ export const useBookmarksData = ({
activeAccount,
accountManager,
naddr,
externalUrl,
currentArticleCoordinate,
currentArticleEventId,
settings
@@ -115,11 +117,13 @@ export const useBookmarksData = ({
// Fetch highlights/contacts independently to avoid disturbing bookmarks
useEffect(() => {
if (!relayPool || !activeAccount) return
if (!naddr) {
// Only fetch general highlights when not viewing an article (naddr) or external URL
// External URLs have their highlights fetched by useExternalUrlLoader
if (!naddr && !externalUrl) {
handleFetchHighlights()
}
handleFetchContacts()
}, [relayPool, activeAccount, naddr, handleFetchHighlights, handleFetchContacts])
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
return {
bookmarks,

View File

@@ -71,7 +71,7 @@ export function useExternalUrlLoader({
// Check if fetchHighlightsForUrl exists, otherwise skip
if (typeof fetchHighlightsForUrl === 'function') {
const seen = new Set<string>()
const highlightsList = await fetchHighlightsForUrl(
await fetchHighlightsForUrl(
relayPool,
url,
(highlight) => {
@@ -84,9 +84,9 @@ export function useExternalUrlLoader({
})
}
)
// Ensure final list is sorted and contains all items
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
// Highlights are already set via the streaming callback
// No need to set them again as that could cause a flash/disappearance
console.log(`📌 Finished fetching highlights for URL`)
} else {
console.log('📌 Highlight fetching for URLs not yet implemented')
}

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

@@ -1,21 +1,72 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, useCallback } from 'react'
interface UseReadingPositionOptions {
enabled?: boolean
onPositionChange?: (position: number) => void
onReadingComplete?: () => void
readingCompleteThreshold?: number // Default 0.9 (90%)
syncEnabled?: boolean // Whether to sync positions to Nostr
onSave?: (position: number) => void // Callback for saving position
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
}
export const useReadingPosition = ({
enabled = true,
onPositionChange,
onReadingComplete,
readingCompleteThreshold = 0.9
readingCompleteThreshold = 0.9,
syncEnabled = false,
onSave,
autoSaveInterval = 5000
}: UseReadingPositionOptions = {}) => {
const [position, setPosition] = useState(0)
const [isReadingComplete, setIsReadingComplete] = useState(false)
const hasTriggeredComplete = useRef(false)
const lastSavedPosition = useRef(0)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Debounced save function
const scheduleSave = useCallback((currentPosition: number) => {
if (!syncEnabled || !onSave) return
// Don't save if position is too low (< 5%)
if (currentPosition < 0.05) return
// Don't save if position hasn't changed significantly (less than 1%)
// But always save if we've reached 100% (completion)
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
if (!hasSignificantChange && !hasReachedCompletion) return
// Clear existing timer
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
// Schedule new save
saveTimerRef.current = setTimeout(() => {
lastSavedPosition.current = currentPosition
onSave(currentPosition)
}, autoSaveInterval)
}, [syncEnabled, onSave, autoSaveInterval])
// Immediate save function
const saveNow = useCallback(() => {
if (!syncEnabled || !onSave) return
// Cancel any pending saves
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
saveTimerRef.current = null
}
// Save if position is meaningful (>= 5%)
if (position >= 0.05) {
lastSavedPosition.current = position
onSave(position)
}
}, [syncEnabled, onSave, position])
useEffect(() => {
if (!enabled) return
@@ -30,12 +81,20 @@ export const useReadingPosition = ({
const documentHeight = document.documentElement.scrollHeight
// Calculate position based on how much of the content has been scrolled through
const scrollProgress = Math.min(scrollTop / (documentHeight - windowHeight), 1)
const clampedProgress = Math.max(0, Math.min(1, scrollProgress))
// Add a small threshold (5px) to account for rounding and make it easier to reach 100%
const maxScroll = documentHeight - windowHeight
const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0
// If we're within 5px of the bottom, consider it 100%
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
setPosition(clampedProgress)
onPositionChange?.(clampedProgress)
// Schedule auto-save if sync is enabled
scheduleSave(clampedProgress)
// Check if reading is complete
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
setIsReadingComplete(true)
@@ -54,8 +113,13 @@ export const useReadingPosition = ({
return () => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleScroll)
// Clear save timer on unmount
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
}
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold])
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
// Reset reading complete state when enabled changes
useEffect(() => {
@@ -68,6 +132,7 @@ export const useReadingPosition = ({
return {
position,
isReadingComplete,
progressPercentage: Math.round(position * 100)
progressPercentage: Math.round(position * 100),
saveNow
}
}

View File

@@ -73,6 +73,9 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
// Set paragraph alignment
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
console.log('✅ All styles applied')
}

View File

@@ -19,7 +19,7 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
const webBookmarks = unique.filter(e => e.kind === 39701)
const bookmarkLists = unique
.filter(e => e.kind === 10003 || e.kind === 30001)
.filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd'))

View File

@@ -16,11 +16,24 @@ export interface BookmarkData {
tags?: string[][]
}
export interface AddressPointer {
kind: number
pubkey: string
identifier: string
relays?: string[]
}
export interface EventPointer {
id: string
relays?: string[]
author?: string
}
export interface ApplesauceBookmarks {
notes?: BookmarkData[]
articles?: BookmarkData[]
hashtags?: BookmarkData[]
urls?: BookmarkData[]
notes?: EventPointer[]
articles?: AddressPointer[]
hashtags?: string[]
urls?: string[]
}
export interface AccountWithExtension {
@@ -55,25 +68,83 @@ export const processApplesauceBookmarks = (
if (typeof bookmarks === 'object' && bookmarks !== null && !Array.isArray(bookmarks)) {
const applesauceBookmarks = bookmarks as ApplesauceBookmarks
const allItems: BookmarkData[] = []
if (applesauceBookmarks.notes) allItems.push(...applesauceBookmarks.notes)
if (applesauceBookmarks.articles) allItems.push(...applesauceBookmarks.articles)
if (applesauceBookmarks.hashtags) allItems.push(...applesauceBookmarks.hashtags)
if (applesauceBookmarks.urls) allItems.push(...applesauceBookmarks.urls)
const allItems: IndividualBookmark[] = []
// Process notes (EventPointer[])
if (applesauceBookmarks.notes) {
applesauceBookmarks.notes.forEach((note: EventPointer) => {
allItems.push({
id: note.id,
content: '',
created_at: Math.floor(Date.now() / 1000),
pubkey: note.author || activeAccount.pubkey,
kind: 1, // Short note kind
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
})
})
}
// Process articles (AddressPointer[])
if (applesauceBookmarks.articles) {
applesauceBookmarks.articles.forEach((article: AddressPointer) => {
// Convert AddressPointer to coordinate format: kind:pubkey:identifier
const coordinate = `${article.kind}:${article.pubkey}:${article.identifier || ''}`
allItems.push({
id: coordinate,
content: '',
created_at: Math.floor(Date.now() / 1000),
pubkey: article.pubkey,
kind: article.kind, // Usually 30023 for long-form articles
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
})
})
}
// Process hashtags (string[])
if (applesauceBookmarks.hashtags) {
applesauceBookmarks.hashtags.forEach((hashtag: string) => {
allItems.push({
id: `hashtag-${hashtag}`,
content: `#${hashtag}`,
created_at: Math.floor(Date.now() / 1000),
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['t', hashtag]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
})
})
}
// Process URLs (string[])
if (applesauceBookmarks.urls) {
applesauceBookmarks.urls.forEach((url: string) => {
allItems.push({
id: `url-${url}`,
content: url,
created_at: Math.floor(Date.now() / 1000),
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['r', url]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
})
})
}
return allItems
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
.map((bookmark: BookmarkData) => ({
id: bookmark.id!,
content: bookmark.content || '',
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
pubkey: activeAccount.pubkey,
kind: bookmark.kind || 30001,
tags: bookmark.tags || [],
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate,
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
}))
}
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]

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,
@@ -33,6 +45,12 @@ export async function collectBookmarksFromEvents(
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
// Extract the 'd' tag and metadata for bookmark sets (kind 30003)
const dTag = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] : undefined
const setTitle = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'title')?.[1] : undefined
const setDescription = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'description')?.[1] : undefined
const setImage = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'image')?.[1] : undefined
// Handle web bookmarks (kind:39701) as individual bookmarks
if (evt.kind === 39701) {
publicItemsAll.push({
@@ -45,13 +63,27 @@ export async function collectBookmarksFromEvents(
parsedContent: undefined,
type: 'web' as const,
isPrivate: false,
added_at: evt.created_at || Math.floor(Date.now() / 1000)
added_at: evt.created_at || Math.floor(Date.now() / 1000),
sourceKind: 39701,
setName: dTag,
setTitle,
setDescription,
setImage
})
continue
}
const pub = Helpers.getPublicBookmarks(evt)
publicItemsAll.push(...processApplesauceBookmarks(pub, activeAccount, false))
publicItemsAll.push(
...processApplesauceBookmarks(pub, activeAccount, false).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
setTitle,
setDescription,
setImage
}))
)
try {
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt) && signerCandidate) {
@@ -60,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
}
}
@@ -68,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
}
}
@@ -94,11 +129,20 @@ export async function collectBookmarksFromEvents(
try {
const hiddenTags = JSON.parse(decryptedContent) as string[][]
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
privateItemsAll.push(...processApplesauceBookmarks(manualPrivate, activeAccount, true))
privateItemsAll.push(
...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
setTitle,
setDescription,
setImage
}))
)
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
}
}
@@ -106,7 +150,16 @@ export async function collectBookmarksFromEvents(
const priv = Helpers.getHiddenBookmarks(evt)
if (priv) {
privateItemsAll.push(...processApplesauceBookmarks(priv, activeAccount, true))
privateItemsAll.push(
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
setTitle,
setDescription,
setImage
}))
)
}
} catch {
// ignore individual event failures

View File

@@ -5,7 +5,6 @@ import {
dedupeNip51Events,
hydrateItems,
isAccountWithExtension,
isHexId,
hasNip04Decrypt,
hasNip44Decrypt,
dedupeBookmarksById,
@@ -16,6 +15,7 @@ import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
@@ -35,7 +35,7 @@ export const fetchBookmarks = async (
const rawEvents = await queryEvents(
relayPool,
{ kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] },
{ kinds: [KINDS.ListSimple, KINDS.ListReplaceable, KINDS.List, KINDS.WebBookmark], authors: [activeAccount.pubkey] },
{}
)
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
@@ -57,18 +57,35 @@ export const fetchBookmarks = async (
rawEvents.forEach((evt, i) => {
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
const contentPreview = evt.content ? evt.content.slice(0, 50) + (evt.content.length > 50 ? '...' : '') : 'empty'
console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, contentPreview=${contentPreview}`)
const eTags = evt.tags?.filter((t: string[]) => t[0] === 'e').length || 0
const aTags = evt.tags?.filter((t: string[]) => t[0] === 'a').length || 0
console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, eTags=${eTags}, aTags=${aTags}, contentPreview=${contentPreview}`)
})
const bookmarkListEvents = dedupeNip51Events(rawEvents)
console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events')
// Log which events made it through deduplication
bookmarkListEvents.forEach((evt, i) => {
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
console.log(` Dedupe ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag="${dTag}"`)
})
// Check specifically for Primal's "reads" list
const primalReads = rawEvents.find(e => e.kind === KINDS.ListSimple && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads'))
if (primalReads) {
console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8))
} else {
console.log('❌ No Primal reads list found (kind:10003 with d="reads")')
}
if (bookmarkListEvents.length === 0) {
// Keep existing bookmarks visible; do not clear list if nothing new found
return
}
// 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,
@@ -85,32 +102,107 @@ 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
)
const allItems = [...publicItemsAll, ...privateItemsAll]
const noteIds = Array.from(new Set(allItems.map(i => i.id).filter(isHexId)))
let idToEvent: Map<string, NostrEvent> = new Map()
// Separate hex IDs (regular events) from coordinates (addressable events)
const noteIds: string[] = []
const coordinates: string[] = []
allItems.forEach(i => {
// Check if it's a hex ID (64 character hex string)
if (/^[0-9a-f]{64}$/i.test(i.id)) {
noteIds.push(i.id)
} else if (i.id.includes(':')) {
// Coordinate format: kind:pubkey:identifier
coordinates.push(i.id)
}
})
const idToEvent: Map<string, NostrEvent> = new Map()
// Fetch regular events by ID
if (noteIds.length > 0) {
try {
const events = await queryEvents(
relayPool,
{ ids: noteIds },
{ ids: Array.from(new Set(noteIds)) },
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
)
idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e]))
events.forEach((e: NostrEvent) => {
idToEvent.set(e.id, e)
// Also store by coordinate if it's an addressable event
if (e.kind && e.kind >= 30000 && e.kind < 40000) {
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
idToEvent.set(coordinate, e)
}
})
} catch (error) {
console.warn('Failed to fetch events for hydration:', error)
console.warn('Failed to fetch events by ID:', error)
}
}
// Fetch addressable events by coordinates
if (coordinates.length > 0) {
try {
// Group by kind for more efficient querying
const byKind = new Map<number, Array<{ pubkey: string; identifier: string }>>()
coordinates.forEach(coord => {
const parts = coord.split(':')
const kind = parseInt(parts[0])
const pubkey = parts[1]
const identifier = parts[2] || ''
if (!byKind.has(kind)) {
byKind.set(kind, [])
}
byKind.get(kind)!.push({ pubkey, identifier })
})
// Query each kind group
for (const [kind, items] of byKind.entries()) {
const authors = Array.from(new Set(items.map(i => i.pubkey)))
const identifiers = Array.from(new Set(items.map(i => i.identifier)))
const events = await queryEvents(
relayPool,
{ kinds: [kind], authors, '#d': identifiers },
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
)
events.forEach((e: NostrEvent) => {
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
idToEvent.set(coordinate, e)
// Also store by event ID
idToEvent.set(e.id, e)
})
}
} catch (error) {
console.warn('Failed to fetch addressable events:', error)
}
}
console.log(`📦 Hydration: fetched ${idToEvent.size} events for ${allItems.length} bookmarks (${noteIds.length} notes, ${coordinates.length} articles)`)
const allBookmarks = dedupeBookmarksById([
...hydrateItems(publicItemsAll, idToEvent),
...hydrateItems(privateItemsAll, idToEvent)

View File

@@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -41,7 +42,7 @@ export const fetchBlogPostsFromAuthors = async (
await queryEvents(
relayPool,
{ kinds: [30023], authors: pubkeys, limit: 100 },
{ kinds: [KINDS.BlogPost], authors: pubkeys, limit: 100 },
{
relayUrls,
onEvent: (event: NostrEvent) => {

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

@@ -6,6 +6,7 @@ import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService'
import { KINDS } from '../../config/kinds'
export const fetchHighlights = async (
relayPool: RelayPool,
@@ -21,7 +22,7 @@ export const fetchHighlights = async (
const seenIds = new Set<string>()
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], authors: [pubkey] })
.req(localRelays, { kinds: [KINDS.Highlights], authors: [pubkey] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
@@ -36,7 +37,7 @@ export const fetchHighlights = async (
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], authors: [pubkey] })
.req(remoteRelays, { kinds: [KINDS.Highlights], authors: [pubkey] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {

View File

@@ -14,10 +14,11 @@ export const fetchHighlightsForUrl = async (
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
const seenIds = new Set<string>()
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
try {
const seenIds = new Set<string>()
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
const local$ = localRelaysUrl.length > 0
? relayPool
.req(localRelaysUrl, { kinds: [9802], '#r': [url] })
@@ -45,11 +46,23 @@ export const fetchHighlightsForUrl = async (
)
: new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
await rebroadcastEvents(rawEvents, relayPool, settings)
console.log(`📌 Fetched ${rawEvents.length} highlight events for URL:`, url)
// Rebroadcast events - but don't let errors here break the highlight display
try {
await rebroadcastEvents(rawEvents, relayPool, settings)
} catch (err) {
console.warn('Failed to rebroadcast highlight events:', err)
}
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch {
} catch (err) {
console.error('Error fetching highlights for URL:', err)
// Return highlights that were already streamed via callback
// Don't return empty array as that would clear already-displayed highlights
return []
}
}

View File

@@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { MARK_AS_READ_EMOJI } from './reactionService'
import { BlogPostPreview } from './exploreService'
import { queryEvents } from './dataFetch'
@@ -29,8 +30,8 @@ export async function fetchReadArticles(
try {
// Fetch kind:7 and kind:17 reactions in parallel
const [kind7Events, kind17Events] = await Promise.all([
queryEvents(relayPool, { kinds: [7], authors: [userPubkey] }, { relayUrls: RELAYS }),
queryEvents(relayPool, { kinds: [17], authors: [userPubkey] }, { relayUrls: RELAYS })
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }, { relayUrls: RELAYS }),
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] }, { relayUrls: RELAYS })
])
const readArticles: ReadArticle[] = []
@@ -102,7 +103,7 @@ export async function fetchReadArticlesWithData(
// Filter to only nostr-native articles (kind 30023)
const nostrArticles = readArticles.filter(
article => article.eventKind === 30023 && article.eventId
article => article.eventKind === KINDS.BlogPost && article.eventId
)
if (nostrArticles.length === 0) {
@@ -114,7 +115,7 @@ export async function fetchReadArticlesWithData(
const articleEvents = await queryEvents(
relayPool,
{ kinds: [30023], ids: eventIds },
{ kinds: [KINDS.BlogPost], ids: eventIds },
{ relayUrls: RELAYS }
)

View File

@@ -0,0 +1,90 @@
import { RelayPool } from 'applesauce-relay'
import { fetchReadArticles } from './libraryService'
import { queryEvents } from './dataFetch'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { ReadItem } from './readsService'
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
import { mergeReadItem } from '../utils/readItemMerge'
/**
* Fetches external URL links with reading progress from:
* - URLs with reading progress (kind:30078)
* - Manually marked as read URLs (kind:7, kind:17)
*/
export async function fetchLinks(
relayPool: RelayPool,
userPubkey: string,
onItem?: (item: ReadItem) => void
): Promise<ReadItem[]> {
console.log('🔗 [Links] Fetching external links for user:', userPubkey.slice(0, 8))
const linksMap = new Map<string, ReadItem>()
// Helper to emit items as they're added/updated
const emitItem = (item: ReadItem) => {
if (onItem && mergeReadItem(linksMap, item)) {
onItem(linksMap.get(item.id)!)
} else if (!onItem) {
linksMap.set(item.id, item)
}
}
try {
// Fetch all data sources in parallel
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
fetchReadArticles(relayPool, userPubkey)
])
console.log('📊 [Links] Data fetched:', {
readingPositions: readingPositionEvents.length,
markedAsRead: markedAsReadArticles.length
})
// Process reading positions and emit external items
processReadingPositions(readingPositionEvents, linksMap)
if (onItem) {
linksMap.forEach(item => {
if (item.type === 'external') {
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
if (hasProgress) emitItem(item)
}
})
}
// Process marked-as-read and emit external items
processMarkedAsRead(markedAsReadArticles, linksMap)
if (onItem) {
linksMap.forEach(item => {
if (item.type === 'external') {
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
if (hasProgress) emitItem(item)
}
})
}
// Filter for external URLs only with reading progress
const links = Array.from(linksMap.values())
.filter(item => {
// Only external URLs
if (item.type !== 'external') return false
// Only include if there's reading progress or marked as read
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
return hasProgress
})
// Apply common validation and sorting
const validLinks = filterValidItems(links)
const sortedLinks = sortByReadingActivity(validLinks)
console.log('✅ [Links] Processed', sortedLinks.length, 'total links')
return sortedLinks
} catch (error) {
console.error('Failed to fetch links:', error)
return []
}
}

View File

@@ -1,11 +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[]
readArticles: BlogPostPreview[]
reads?: ReadItem[]
links?: ReadItem[]
timestamp: number
}

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

@@ -0,0 +1,147 @@
import { NostrEvent } from 'nostr-tools'
import { ReadItem } from './readsService'
import { fallbackTitleFromUrl } from '../utils/readItemMerge'
const READING_POSITION_PREFIX = 'boris:reading-position:'
interface ReadArticle {
id: string
url?: string
eventId?: string
eventKind?: number
markedAt: number
}
/**
* Processes reading position events into ReadItems
*/
export function processReadingPositions(
events: NostrEvent[],
readsMap: Map<string, ReadItem>
): void {
for (const event of events) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1]
if (!dTag || !dTag.startsWith(READING_POSITION_PREFIX)) continue
const identifier = dTag.replace(READING_POSITION_PREFIX, '')
try {
const positionData = JSON.parse(event.content)
const position = positionData.position
const timestamp = positionData.timestamp
let itemId: string
let itemUrl: string | undefined
let itemType: 'article' | 'external' = 'external'
// Check if it's a nostr article (naddr format)
if (identifier.startsWith('naddr1')) {
itemId = identifier
itemType = 'article'
} else {
// It's a base64url-encoded URL
try {
itemUrl = atob(identifier.replace(/-/g, '+').replace(/_/g, '/'))
itemId = itemUrl
itemType = 'external'
} catch (e) {
console.warn('Failed to decode URL identifier:', identifier)
continue
}
}
// Add or update the item
const existing = readsMap.get(itemId)
if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) {
readsMap.set(itemId, {
...existing,
id: itemId,
source: 'reading-progress',
type: itemType,
url: itemUrl,
readingProgress: position,
readingTimestamp: timestamp
})
}
} catch (error) {
console.warn('Failed to parse reading position:', error)
}
}
}
/**
* Processes marked-as-read articles into ReadItems
*/
export function processMarkedAsRead(
articles: ReadArticle[],
readsMap: Map<string, ReadItem>
): void {
for (const article of articles) {
const existing = readsMap.get(article.id)
if (article.eventId && article.eventKind === 30023) {
// Nostr article
readsMap.set(article.id, {
...existing,
id: article.id,
source: 'marked-as-read',
type: 'article',
markedAsRead: true,
markedAt: article.markedAt,
readingTimestamp: existing?.readingTimestamp || article.markedAt
})
} else if (article.url) {
// External URL
readsMap.set(article.id, {
...existing,
id: article.id,
source: 'marked-as-read',
type: 'external',
url: article.url,
markedAsRead: true,
markedAt: article.markedAt,
readingTimestamp: existing?.readingTimestamp || article.markedAt
})
}
}
}
/**
* Sorts ReadItems by most recent reading activity
*/
export function sortByReadingActivity(items: ReadItem[]): ReadItem[] {
return items.sort((a, b) => {
const timeA = a.readingTimestamp || a.markedAt || 0
const timeB = b.readingTimestamp || b.markedAt || 0
return timeB - timeA
})
}
/**
* Filters out items without timestamps and enriches external items with fallback titles
*/
export function filterValidItems(items: ReadItem[]): ReadItem[] {
return items
.filter(item => {
// Only include items that have a timestamp
const hasTimestamp = (item.readingTimestamp && item.readingTimestamp > 0) ||
(item.markedAt && item.markedAt > 0)
if (!hasTimestamp) return false
// For Nostr articles, we need the event to be valid
if (item.type === 'article' && !item.event) return false
// For external URLs, we need at least a URL
if (item.type === 'external' && !item.url) return false
return true
})
.map(item => {
// Add fallback title for external URLs without titles
if (item.type === 'external' && !item.title && item.url) {
return { ...item, title: fallbackTitleFromUrl(item.url) }
}
return item
})
}

View File

@@ -0,0 +1,196 @@
import { IEventStore, mapEventsToStore } from 'applesauce-core'
import { EventFactory } from 'applesauce-factory'
import { RelayPool, onlyEvents } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { firstValueFrom } from 'rxjs'
import { publishEvent } from './writeService'
import { RELAYS } from '../config/relays'
const APP_DATA_KIND = 30078 // NIP-78 Application Data
const READING_POSITION_PREFIX = 'boris:reading-position:'
export interface ReadingPosition {
position: number // 0-1 scroll progress
timestamp: number // Unix timestamp
scrollTop?: number // Optional: pixel position
}
// Helper to extract and parse reading position from an event
function getReadingPositionContent(event: NostrEvent): ReadingPosition | undefined {
if (!event.content || event.content.length === 0) return undefined
try {
return JSON.parse(event.content) as ReadingPosition
} catch {
return undefined
}
}
/**
* Generate a unique identifier for an article
* For Nostr articles: use the naddr directly
* For external URLs: use base64url encoding of the URL
*/
export function generateArticleIdentifier(naddrOrUrl: string): string {
// If it starts with "nostr:", extract the naddr
if (naddrOrUrl.startsWith('nostr:')) {
return naddrOrUrl.replace('nostr:', '')
}
// For URLs, use base64url encoding (URL-safe)
return btoa(naddrOrUrl)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
/**
* Save reading position to Nostr (Kind 30078)
*/
export async function saveReadingPosition(
relayPool: RelayPool,
eventStore: IEventStore,
factory: EventFactory,
articleIdentifier: string,
position: ReadingPosition
): Promise<void> {
console.log('💾 [ReadingPosition] Saving position:', {
identifier: articleIdentifier.slice(0, 32) + '...',
position: position.position,
positionPercent: Math.round(position.position * 100) + '%',
timestamp: position.timestamp,
scrollTop: position.scrollTop
})
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
const draft = await factory.create(async () => ({
kind: APP_DATA_KIND,
content: JSON.stringify(position),
tags: [
['d', dTag],
['client', 'boris']
],
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
// Use unified write service
await publishEvent(relayPool, eventStore, signed)
console.log('✅ [ReadingPosition] Position saved successfully, event ID:', signed.id.slice(0, 8))
}
/**
* Load reading position from Nostr
*/
export async function loadReadingPosition(
relayPool: RelayPool,
eventStore: IEventStore,
pubkey: string,
articleIdentifier: string
): Promise<ReadingPosition | null> {
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
console.log('📖 [ReadingPosition] Loading position:', {
pubkey: pubkey.slice(0, 8) + '...',
identifier: articleIdentifier.slice(0, 32) + '...',
dTag: dTag.slice(0, 50) + '...'
})
// First, check if we already have the position in the local event store
try {
const localEvent = await firstValueFrom(
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
)
if (localEvent) {
const content = getReadingPositionContent(localEvent)
if (content) {
console.log('✅ [ReadingPosition] Loaded from local store:', {
position: content.position,
positionPercent: Math.round(content.position * 100) + '%',
timestamp: content.timestamp
})
// Still fetch from relays in the background to get any updates
relayPool
.subscription(RELAYS, {
kinds: [APP_DATA_KIND],
authors: [pubkey],
'#d': [dTag]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
return content
}
}
} catch (err) {
console.log('📭 No cached reading position found, fetching from relays...')
}
// If not in local store, fetch from relays
return new Promise((resolve) => {
let hasResolved = false
const timeout = setTimeout(() => {
if (!hasResolved) {
console.log('⏱️ Reading position load timeout - no position found')
hasResolved = true
resolve(null)
}
}, 3000) // Shorter timeout for reading positions
const sub = relayPool
.subscription(RELAYS, {
kinds: [APP_DATA_KIND],
authors: [pubkey],
'#d': [dTag]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
complete: async () => {
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true
try {
const event = await firstValueFrom(
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
)
if (event) {
const content = getReadingPositionContent(event)
if (content) {
console.log('✅ [ReadingPosition] Loaded from relays:', {
position: content.position,
positionPercent: Math.round(content.position * 100) + '%',
timestamp: content.timestamp
})
resolve(content)
} else {
console.log('⚠️ [ReadingPosition] Event found but no valid content')
resolve(null)
}
} else {
console.log('📭 [ReadingPosition] No position found on relays')
resolve(null)
}
} catch (err) {
console.error('❌ Error loading reading position:', err)
resolve(null)
}
}
},
error: (err) => {
console.error('❌ Reading position subscription error:', err)
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true
resolve(null)
}
}
})
setTimeout(() => {
sub.unsubscribe()
}, 3000)
})
}

View File

@@ -0,0 +1,197 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { Bookmark } from '../types/bookmarks'
import { fetchReadArticles } from './libraryService'
import { queryEvents } from './dataFetch'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
import { nip19 } from 'nostr-tools'
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
import { mergeReadItem } from '../utils/readItemMerge'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
export interface ReadItem {
id: string // event ID or URL or coordinate
source: 'bookmark' | 'reading-progress' | 'marked-as-read'
type: 'article' | 'external' // article=kind:30023, external=URL
// Article data
event?: NostrEvent
url?: string
title?: string
summary?: string
image?: string
published?: number
author?: string
// Reading metadata
readingProgress?: number // 0-1
readingTimestamp?: number // Unix timestamp of last reading activity
markedAsRead?: boolean
markedAt?: number
}
/**
* Fetches all reads from multiple sources:
* - Bookmarked articles (kind:30023) and article/website URLs
* - Articles/URLs with reading progress (kind:30078)
* - Manually marked as read articles/URLs (kind:7, kind:17)
*/
export async function fetchAllReads(
relayPool: RelayPool,
userPubkey: string,
bookmarks: Bookmark[],
onItem?: (item: ReadItem) => void
): Promise<ReadItem[]> {
console.log('📚 [Reads] Fetching all reads for user:', userPubkey.slice(0, 8))
const readsMap = new Map<string, ReadItem>()
// Helper to emit items as they're added/updated
const emitItem = (item: ReadItem) => {
if (onItem && mergeReadItem(readsMap, item)) {
onItem(readsMap.get(item.id)!)
} else if (!onItem) {
readsMap.set(item.id, item)
}
}
try {
// Fetch all data sources in parallel
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
fetchReadArticles(relayPool, userPubkey)
])
console.log('📊 [Reads] Data fetched:', {
readingPositions: readingPositionEvents.length,
markedAsRead: markedAsReadArticles.length,
bookmarks: bookmarks.length
})
// Process reading positions and emit items
processReadingPositions(readingPositionEvents, readsMap)
if (onItem) {
readsMap.forEach(item => {
if (item.type === 'article') onItem(item)
})
}
// Process marked-as-read and emit items
processMarkedAsRead(markedAsReadArticles, readsMap)
if (onItem) {
readsMap.forEach(item => {
if (item.type === 'article') onItem(item)
})
}
// 3. Process bookmarked articles and article/website URLs
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
for (const bookmark of allBookmarks) {
const bookmarkType = classifyBookmarkType(bookmark)
// Only include articles
if (bookmarkType === 'article') {
// Kind:30023 nostr article
const coordinate = bookmark.id // Already in coordinate format
const existing = readsMap.get(coordinate)
if (!existing) {
const item: ReadItem = {
id: coordinate,
source: 'bookmark',
type: 'article',
readingProgress: 0,
readingTimestamp: bookmark.added_at || bookmark.created_at
}
readsMap.set(coordinate, item)
if (onItem) emitItem(item)
}
}
}
// 4. Fetch full event data for nostr articles
const articleCoordinates = Array.from(readsMap.values())
.filter(item => item.type === 'article' && !item.event)
.map(item => item.id)
if (articleCoordinates.length > 0) {
console.log('📖 [Reads] Fetching article events for', articleCoordinates.length, 'articles')
// Parse coordinates and fetch events
const articlesToFetch: Array<{ pubkey: string; identifier: string }> = []
for (const coord of articleCoordinates) {
try {
// Try to decode as naddr
if (coord.startsWith('naddr1')) {
const decoded = nip19.decode(coord)
if (decoded.type === 'naddr' && decoded.data.kind === KINDS.BlogPost) {
articlesToFetch.push({
pubkey: decoded.data.pubkey,
identifier: decoded.data.identifier || ''
})
}
} else {
// Try coordinate format (kind:pubkey:identifier)
const parts = coord.split(':')
if (parts.length === 3 && parseInt(parts[0]) === KINDS.BlogPost) {
articlesToFetch.push({
pubkey: parts[1],
identifier: parts[2]
})
}
}
} catch (e) {
console.warn('Failed to decode article coordinate:', coord)
}
}
if (articlesToFetch.length > 0) {
const authors = Array.from(new Set(articlesToFetch.map(a => a.pubkey)))
const identifiers = Array.from(new Set(articlesToFetch.map(a => a.identifier)))
const events = await queryEvents(
relayPool,
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers },
{ relayUrls: RELAYS }
)
// Merge event data into ReadItems and emit
for (const event of events) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const coordinate = `${KINDS.BlogPost}:${event.pubkey}:${dTag}`
const item = readsMap.get(coordinate) || readsMap.get(event.id)
if (item) {
item.event = event
item.title = getArticleTitle(event) || 'Untitled'
item.summary = getArticleSummary(event)
item.image = getArticleImage(event)
item.published = getArticlePublished(event)
item.author = event.pubkey
if (onItem) emitItem(item)
}
}
}
}
// 5. Filter for Nostr articles only and apply common validation/sorting
const articles = Array.from(readsMap.values())
.filter(item => item.type === 'article')
const validArticles = filterValidItems(articles)
const sortedReads = sortByReadingActivity(validArticles)
console.log('✅ [Reads] Processed', sortedReads.length, 'total reads')
return sortedReads
} catch (error) {
console.error('Failed to fetch all reads:', error)
return []
}
}

View File

@@ -52,6 +52,10 @@ export interface UserSettings {
theme?: 'dark' | 'light' | 'system' // default: system
darkColorTheme?: 'black' | 'midnight' | 'charcoal' // default: midnight
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
// Reading settings
paragraphAlignment?: 'left' | 'justify' // default: justify
// Reading position sync
syncReadingPosition?: boolean // default: false (opt-in)
}
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

@@ -7,6 +7,27 @@
.bookmark-content { color: var(--color-text); margin: 0.5rem 0; line-height: 1.4; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
.bookmark-meta { color: var(--color-text-secondary); font-size: 0.9rem; margin-top: 0.5rem; }
.bookmarks-section-title {
font-size: 0.75rem !important;
font-weight: 700 !important;
text-transform: uppercase !important;
letter-spacing: 0.05em !important;
color: var(--color-text-muted) !important;
padding: 1.5rem 0.5rem 0.375rem !important;
margin: 0 !important;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.bookmarks-section:first-of-type .bookmarks-section-title {
border-top: none;
padding-top: 0.5rem !important;
}
.bookmark-section-action {
padding: 1.5rem 0.5rem 0.375rem;
}
.bookmarks-section:first-of-type .bookmark-section-action {
padding-top: 0.5rem;
}
.individual-bookmarks { margin: 1rem 0; }
.individual-bookmarks h4 { margin: 0 0 1rem 0; font-size: 1rem; color: var(--color-text); }
@@ -23,8 +44,8 @@
.individual-bookmark:hover { border-color: var(--color-border); background: var(--color-bg-elevated); }
/* Compact view */
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none; border-bottom: 1px solid var(--color-bg-elevated); border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
.individual-bookmark.compact:hover { background: var(--color-bg-elevated); border-bottom-color: var(--color-border); transform: none; box-shadow: none; }
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none !important; border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
.individual-bookmark.compact:hover { background: var(--color-bg-elevated); transform: none; box-shadow: none; border: none !important; }
.compact-row { display: flex; align-items: center; gap: 0.5rem; height: 28px; width: 100%; min-width: 0; overflow: hidden; }
.compact-thumbnail { width: 24px; height: 24px; flex-shrink: 0; border-radius: 4px; overflow: hidden; background: var(--color-bg-elevated); display: flex; align-items: center; justify-content: center; }
.compact-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
@@ -57,10 +78,10 @@
/* Large preview view */
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--color-bg-elevated); }
.large-preview-image { width: 100%; height: 180px; background: var(--color-bg); background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid var(--color-border); position: relative; }
.large-preview-image { width: 100%; height: 180px; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 50%, var(--color-bg-elevated) 100%); background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid var(--color-border); position: relative; }
.large-preview-image:hover { opacity: 0.9; }
.large-preview-image::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.3) 100%); pointer-events: none; }
.preview-placeholder { font-size: 3rem; color: var(--color-border-subtle); }
.preview-placeholder { font-size: 3rem; color: var(--color-border-subtle); opacity: 0.4; }
.large-content { padding: 1.25rem; }
.large-text { color: var(--color-text); font-size: 0.95rem; line-height: 1.6; margin-bottom: 1rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.large-footer { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; font-size: 0.8rem; color: var(--color-text-secondary); padding-top: 0.75rem; border-top: 1px solid var(--color-border); }
@@ -84,10 +105,10 @@
.blog-post-card.level-mine { border-color: color-mix(in srgb, var(--highlight-color-mine, #fde047) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #fde047) 25%, transparent); }
.blog-post-card.level-friends { border-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent); }
.blog-post-card.level-nostrverse { border-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent); }
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: var(--color-bg-subtle); display: flex; align-items: center; justify-content: center; }
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 50%, var(--color-bg-elevated) 100%); display: flex; align-items: center; justify-content: center; position: relative; }
.blog-post-card-image img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.3s ease; }
.blog-post-card:hover .blog-post-card-image img { transform: scale(1.05); }
.blog-post-image-placeholder { font-size: 3rem; color: var(--color-border-subtle); display: flex; align-items: center; justify-content: center; }
.blog-post-image-placeholder { font-size: 3rem; color: var(--color-border-subtle); opacity: 0.4; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.blog-post-card-content { padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; flex: 1; }
.blog-post-card-title { font-size: 1.25rem; font-weight: 600; margin: 0; color: var(--color-text); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.blog-post-card-summary { font-size: 0.875rem; color: var(--color-text-secondary); margin: 0; line-height: 1.6; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; flex: 1; }

View File

@@ -3,7 +3,7 @@
.setting-group.setting-inline { display: flex; align-items: center; gap: 1rem; }
.setting-label { text-align: left; flex: 1; }
.setting-control { display: flex; justify-content: flex-end; align-items: center; }
.setting-group.setting-inline label { margin-bottom: 0; }
.setting-group.setting-inline label { margin-bottom: 0; min-width: 220px; }
.setting-group label { display: block; margin-bottom: 0.5rem; color: var(--color-text); font-weight: 500; text-align: left; }
.setting-buttons { display: flex; align-items: center; gap: 0.5rem; }
.color-picker { display: flex; align-items: center; gap: 0.5rem; }
@@ -41,6 +41,7 @@
.preview-content p {
margin: 0.75rem 0;
word-wrap: break-word;
text-align: var(--paragraph-alignment, justify);
}
.setting-select { width: 100%; padding: 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
@@ -58,6 +59,10 @@
gap: 0.75rem;
}
.setting-group.setting-inline label {
min-width: unset;
}
.setting-inline .setting-select {
width: 100%;
min-width: unset;

View File

@@ -67,6 +67,10 @@
width: 100%;
}
.me-tab-content:has(.bookmark-filters) {
padding-top: 0.25rem;
}
/* Align highlight list width with profile card width on /me */
.me-highlights-list { padding-left: 0; padding-right: 0; }
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
@@ -79,6 +83,15 @@
text-align: left; /* Override center alignment from .app */
}
/* Bookmark filters in Me page */
.me-tab-content .bookmark-filters {
background: transparent;
border: none;
padding: 0;
justify-content: center;
margin-bottom: 0.25rem;
}
/* Ensure all reading list elements are left-aligned */
.bookmarks-list .individual-bookmark,
.bookmarks-list .individual-bookmark * {

View File

@@ -25,3 +25,23 @@
.btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
/* Confirm Dialog */
.confirm-dialog-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 10000; padding: 1rem; }
.confirm-dialog { background: var(--color-bg-elevated); border: 1px solid var(--color-border); border-radius: 12px; max-width: 400px; width: 100%; padding: 1.5rem; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); }
.confirm-dialog-icon { display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; border-radius: 50%; margin: 0 auto 1rem; font-size: 1.5rem; }
.confirm-dialog-icon.danger { background: rgba(220, 38, 38, 0.1); color: rgb(220 38 38); }
.confirm-dialog-icon.warning { background: rgba(251, 191, 36, 0.1); color: rgb(251 191 36); }
.confirm-dialog-icon.info { background: rgba(59, 130, 246, 0.1); color: rgb(59 130 246); }
.confirm-dialog-title { margin: 0 0 0.5rem 0; font-size: 1.25rem; font-weight: 600; color: var(--color-text); text-align: center; }
.confirm-dialog-message { margin: 0 0 1.5rem 0; font-size: 0.9rem; color: var(--color-text-secondary); text-align: center; line-height: 1.5; }
.confirm-dialog-actions { display: flex; gap: 0.75rem; }
.confirm-dialog-btn { flex: 1; padding: 0.75rem 1rem; border: none; border-radius: 8px; font-size: 0.9rem; font-weight: 500; cursor: pointer; transition: all 0.2s; }
.confirm-dialog-btn.cancel { background: var(--color-bg); border: 1px solid var(--color-border); color: var(--color-text); }
.confirm-dialog-btn.cancel:hover { background: var(--color-border); }
.confirm-dialog-btn.confirm.danger { background: rgb(220 38 38); color: white; }
.confirm-dialog-btn.confirm.danger:hover { background: rgb(185 28 28); }
.confirm-dialog-btn.confirm.warning { background: rgb(251 191 36); color: rgb(17 24 39); }
.confirm-dialog-btn.confirm.warning:hover { background: rgb(245 158 11); }
.confirm-dialog-btn.confirm.info { background: rgb(59 130 246); color: white; }
.confirm-dialog-btn.confirm.info:hover { background: rgb(37 99 235); }

View File

@@ -30,16 +30,29 @@
.reader-meta { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.publish-date { display: flex; align-items: center; gap: 0.4rem; font-size: 0.813rem; color: var(--color-text-muted); opacity: 0.85; }
.publish-date svg { font-size: 0.75rem; opacity: 0.6; }
.publish-date-topright { position: absolute; top: 1rem; right: 1rem; font-size: 0.813rem; color: var(--color-text); padding: 0.4rem 0.75rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); z-index: 10; }
.publish-date-topright { position: absolute; top: 1rem; right: 1rem; font-size: 0.813rem; color: var(--color-text); padding: 0.4rem 0.75rem; z-index: 10; }
.reading-time { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border); border-radius: 6px; font-size: 0.875rem; color: var(--color-text-secondary); }
.reading-time svg { font-size: 0.875rem; }
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); border-radius: 6px; font-size: 0.875rem; color: var(--color-text); }
.highlight-indicator svg { font-size: 0.875rem; }
.reader-html { color: var(--color-text); line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-family: var(--reading-font); font-size: var(--reading-font-size); }
.reader-markdown { color: var(--color-text); line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
/* Ensure content is left-aligned even if source markup uses center */
.reader .reader-html *, .reader .reader-markdown * { text-align: left !important; font-family: inherit !important; }
.reader center, .reader [align="center"] { text-align: left !important; }
/* Ensure font inheritance */
.reader .reader-html *, .reader .reader-markdown * { font-family: inherit !important; }
/* Apply paragraph alignment from settings */
.reader .reader-html p,
.reader .reader-markdown p,
.reader .reader-html div,
.reader .reader-markdown div,
.reader .reader-html li,
.reader .reader-markdown li,
.reader .reader-html blockquote,
.reader .reader-markdown blockquote { text-align: var(--paragraph-alignment, justify); }
/* Override centered content with user preference */
.reader center, .reader [align="center"] { text-align: var(--paragraph-alignment, justify) !important; }
/* Keep headings left-aligned */
.reader .reader-html h1, .reader .reader-html h2, .reader .reader-html h3, .reader .reader-html h4, .reader .reader-html h5, .reader .reader-html h6,
.reader .reader-markdown h1, .reader .reader-markdown h2, .reader .reader-markdown h3, .reader .reader-markdown h4, .reader .reader-markdown h5, .reader .reader-markdown h6 { text-align: left !important; }
/* Tame images from external content */
.reader .reader-html img, .reader .reader-markdown img { max-width: 100%; max-height: 70vh; height: auto; width: auto; display: block; margin: 0.75rem 0; border-radius: 6px; }
/* Headlines with Tailwind typography */
@@ -192,6 +205,7 @@
.article-menu-btn { background: none; border: none; color: var(--color-text-secondary); cursor: pointer; padding: 0.5rem 0.75rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.5rem; transition: all 0.2s ease; border-radius: 6px; }
.article-menu-btn:hover { color: var(--color-primary); background: rgba(99, 102, 241, 0.1); }
.article-menu { position: absolute; right: 0; top: calc(100% + 4px); background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 180px; overflow: hidden; }
.article-menu.open-upward { top: auto; bottom: calc(100% + 4px); }
.article-menu-item { width: 100%; background: none; border: none; color: var(--color-text); padding: 0.75rem 1rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.75rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
.article-menu-item:hover { background: rgba(99, 102, 241, 0.15); color: var(--color-text); }
.article-menu-item svg { font-size: 0.875rem; flex-shrink: 0; }
@@ -221,8 +235,9 @@
.article-hero-image { width: 100%; height: 200px; background-size: cover; background-position: center; background-repeat: no-repeat; cursor: pointer; transition: all 0.2s ease; border-radius: 8px 8px 0 0; position: relative; }
.article-hero-image:hover { opacity: 0.9; }
.article-hero-image::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.4) 100%); pointer-events: none; border-radius: 8px 8px 0 0; }
.reader-hero-image { width: calc(100% + 1.5rem); margin: -0.75rem -0.75rem 2rem -0.75rem; border-radius: 0; overflow: hidden; position: relative; min-height: 300px; }
.reader-hero-image { width: calc(100% + 1.5rem); margin: -0.75rem -0.75rem 2rem -0.75rem; border-radius: 0; overflow: hidden; position: relative; min-height: 300px; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 25%, var(--color-bg-elevated) 50%, var(--color-bg-subtle) 75%, var(--color-bg-elevated) 100%); }
.reader-hero-image img { width: 100%; height: auto; max-height: 500px; object-fit: cover; display: block; }
.reader-hero-placeholder { width: 100%; height: 300px; display: flex; align-items: center; justify-content: center; font-size: 4rem; color: var(--color-border-subtle); opacity: 0.3; }
.reader-header-overlay { position: absolute; bottom: 0; left: 0; right: 0; padding: 2rem 2rem 1.5rem; background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.6) 60%, rgba(0, 0, 0, 0) 100%); }
.reader-header-overlay .reader-title { color: #fff; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); margin-bottom: 0.75rem; font-size: 2.5rem; font-weight: 700; line-height: 1.2; }
.reader-header-overlay .reader-summary { color: rgba(255, 255, 255, 0.9); font-size: 1.2rem; line-height: 1.6; margin: 0 0 1rem 0; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); font-family: var(--reading-font); }

View File

@@ -1,9 +1,9 @@
/* Settings view containers */
.settings-view { display: flex; flex-direction: column; height: 100%; overflow: hidden; padding: 0.75rem 1rem; text-align: left; }
.settings-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; padding: 0; }
.settings-header { display: flex; align-items: center; justify-content: space-between; padding: 0; max-width: 900px; margin: 0 auto 1.5rem auto; width: 100%; }
.settings-header h2 { margin: 0; font-size: 1.5rem; font-weight: 600; text-align: left; }
.settings-header-actions { display: flex; gap: 0.5rem; align-items: center; }
.settings-content { overflow-y: auto; flex: 1; margin-bottom: 1rem; text-align: left; padding: 0 0.25rem 2rem 0.25rem; }
.settings-content { overflow-y: auto; flex: 1; text-align: left; padding: 0 0.25rem 2rem 0.25rem; max-width: 900px; margin: 0 auto 1rem auto; width: 100%; }
.settings-section { margin-bottom: 2.5rem; }
.settings-section:last-child { margin-bottom: 0; }
.section-title { font-size: 1rem; font-weight: 600; color: var(--color-text); margin: 0 0 1rem 0; padding-bottom: 0.5rem; border-bottom: 1px solid var(--color-border); text-transform: uppercase; letter-spacing: 0.05em; }
@@ -19,6 +19,7 @@
/* Zap splits preset buttons */
.zap-preset-buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.zap-preset-btn {
flex: 1;
padding: 0.625rem 1.25rem;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-subtle);
@@ -54,35 +55,56 @@
width: 100%;
height: 8px;
border-radius: 4px;
background: var(--color-bg-elevated);
background: linear-gradient(
to right,
color-mix(in srgb, var(--highlight-color) 50%, transparent) 0%,
color-mix(in srgb, var(--highlight-color) 50%, transparent) 50%,
color-mix(in srgb, var(--highlight-color-friends) 50%, transparent) 50%,
color-mix(in srgb, var(--highlight-color-friends) 50%, transparent) 100%
);
outline: none;
-webkit-appearance: none;
position: relative;
}
.zap-split-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-primary);
width: 24px;
height: 24px;
border-radius: 4px;
background-color: var(--color-primary);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23fff'%3E%3Cpath d='M13 2L3 14h8l-1 8 10-12h-8l1-8z'/%3E%3C/svg%3E");
background-size: 14px 14px;
background-repeat: no-repeat;
background-position: center center;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
top: 0;
margin-top: 0;
}
.zap-split-slider::-webkit-slider-thumb:hover {
background: var(--color-primary-hover);
background-color: var(--color-primary-hover);
transform: scale(1.1);
}
.zap-split-slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-primary);
width: 24px;
height: 24px;
border-radius: 4px;
background-color: var(--color-primary);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23fff'%3E%3Cpath d='M13 2L3 14h8l-1 8 10-12h-8l1-8z'/%3E%3C/svg%3E");
background-size: 14px 14px;
background-repeat: no-repeat;
background-position: center center;
cursor: pointer;
border: none;
transition: all 0.2s ease;
position: relative;
top: 0;
margin-top: 0;
}
.zap-split-slider::-moz-range-thumb:hover {
background: var(--color-primary-hover);
background-color: var(--color-primary-hover);
transform: scale(1.1);
}
.zap-split-description {

View File

@@ -81,7 +81,14 @@
.view-mode-controls {
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between;
gap: 0.5rem;
}
.view-mode-left,
.view-mode-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
@@ -169,3 +176,38 @@
.read-inline-btn { background: rgb(34 197 94); /* green-500 */ color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; }
.read-inline-btn:hover { background: rgb(22 163 74); /* green-600 */ }
/* Bookmark filters */
.bookmark-filters {
display: flex;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
}
.bookmark-filters .filter-btn {
background: transparent;
color: var(--color-text-secondary);
border: none;
padding: 0.375rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
min-width: 32px;
min-height: 32px;
}
.bookmark-filters .filter-btn:hover {
color: var(--color-text);
background: var(--color-bg-elevated);
}
.bookmark-filters .filter-btn.active {
color: var(--color-primary);
background: transparent;
}

View File

@@ -42,6 +42,14 @@ export interface IndividualBookmark {
encryptedContent?: string
// When the item was added to the bookmark list (synthetic, for sorting)
added_at?: number
// The kind of the source list/set that produced this bookmark (e.g., 10003, 30003, 30001, or 39701 for web)
sourceKind?: number
// The 'd' tag value from kind 30003 bookmark sets
setName?: string
// Metadata from the bookmark set event (kind 30003)
setTitle?: string
setDescription?: string
setImage?: string
}
export interface ActiveAccount {

View File

@@ -0,0 +1,42 @@
import { IndividualBookmark } from '../types/bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { classifyUrl } from './helpers'
export type BookmarkType = 'article' | 'external' | 'video' | 'note' | 'web'
/**
* Classifies a bookmark into one of the content types
*/
export function classifyBookmarkType(bookmark: IndividualBookmark): BookmarkType {
// Kind 30023 is always a nostr-native article
if (bookmark.kind === 30023) return 'article'
const isWebBookmark = bookmark.kind === 39701
const webBookmarkUrl = isWebBookmark ? bookmark.tags.find(t => t[0] === 'd')?.[1] : null
const extractedUrls = webBookmarkUrl
? [webBookmarkUrl.startsWith('http') ? webBookmarkUrl : `https://${webBookmarkUrl}`]
: extractUrlsFromContent(bookmark.content)
const firstUrl = extractedUrls[0]
if (!firstUrl) return 'note'
const urlType = classifyUrl(firstUrl)?.type
if (urlType === 'youtube' || urlType === 'video') return 'video'
if (urlType === 'article') return 'external' // External article links
return 'web'
}
/**
* Filters bookmarks by type
*/
export function filterBookmarksByType(
bookmarks: IndividualBookmark[],
filterType: 'all' | BookmarkType
): IndividualBookmark[] {
if (filterType === 'all') return bookmarks
return bookmarks.filter(bookmark => classifyBookmarkType(bookmark) === filterType)
}

View File

@@ -1,6 +1,6 @@
import React from 'react'
import { formatDistanceToNow, differenceInSeconds, differenceInMinutes, differenceInHours, differenceInDays, differenceInMonths, differenceInYears } from 'date-fns'
import { ParsedContent, ParsedNode } from '../types/bookmarks'
import { ParsedContent, ParsedNode, IndividualBookmark } from '../types/bookmarks'
import ResolvedMention from '../components/ResolvedMention'
// Note: ContentWithResolvedProfiles is imported by components directly to keep this file component-only for fast refresh
@@ -82,3 +82,73 @@ export const renderParsedContent = (parsedContent: ParsedContent) => {
</div>
)
}
// Sorting and grouping for bookmarks
export const sortIndividualBookmarks = (items: IndividualBookmark[]) => {
return items
.slice()
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
}
export function groupIndividualBookmarks(items: IndividualBookmark[]) {
const sorted = sortIndividualBookmarks(items)
const web = sorted.filter(i => i.kind === 39701 || i.type === 'web')
// Only non-encrypted legacy bookmarks go to the amethyst section
const amethyst = sorted.filter(i => i.sourceKind === 30001 && !i.isPrivate)
const isIn = (list: IndividualBookmark[], x: IndividualBookmark) => list.some(i => i.id === x.id)
// Private items include encrypted legacy bookmarks
const privateItems = sorted.filter(i => i.isPrivate && !isIn(web, i))
const publicItems = sorted.filter(i => !i.isPrivate && !isIn(amethyst, i) && !isIn(web, i))
return { privateItems, publicItems, web, amethyst }
}
// Simple filter: only exclude bookmarks with empty/whitespace-only content
export function hasContent(bookmark: IndividualBookmark): boolean {
return !!(bookmark.content && bookmark.content.trim().length > 0)
}
// Bookmark sets helpers (kind 30003)
export interface BookmarkSet {
name: string
title?: string
description?: string
image?: string
bookmarks: IndividualBookmark[]
}
export function getBookmarkSets(items: IndividualBookmark[]): BookmarkSet[] {
// Group bookmarks by setName
const setMap = new Map<string, IndividualBookmark[]>()
items.forEach(bookmark => {
if (bookmark.setName) {
const existing = setMap.get(bookmark.setName) || []
existing.push(bookmark)
setMap.set(bookmark.setName, existing)
}
})
// Convert to array and extract metadata from the bookmarks
const sets: BookmarkSet[] = []
setMap.forEach((bookmarks, name) => {
// Get metadata from the first bookmark (all bookmarks in a set share the same metadata)
const firstBookmark = bookmarks[0]
const title = firstBookmark?.setTitle
const description = firstBookmark?.setDescription
const image = firstBookmark?.setImage
sets.push({
name,
title,
description,
image,
bookmarks: sortIndividualBookmarks(bookmarks)
})
})
return sets.sort((a, b) => a.name.localeCompare(b.name))
}
export function getBookmarksWithoutSet(items: IndividualBookmark[]): IndividualBookmark[] {
return sortIndividualBookmarks(items.filter(b => !b.setName))
}

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() }
}

View File

@@ -0,0 +1,69 @@
import { Bookmark } from '../types/bookmarks'
import { ReadItem } from '../services/readsService'
import { KINDS } from '../config/kinds'
import { fallbackTitleFromUrl } from './readItemMerge'
/**
* Derives ReadItems from bookmarks for external URLs:
* - Web bookmarks (kind:39701)
* - Any bookmark with http(s) URLs in content or urlReferences
*/
export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
const linksMap = new Map<string, ReadItem>()
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
for (const bookmark of allBookmarks) {
const urls: string[] = []
// Web bookmarks (kind:39701) - extract from 'd' tag
if (bookmark.kind === KINDS.WebBookmark) {
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const url = dTag.startsWith('http') ? dTag : `https://${dTag}`
urls.push(url)
}
}
// Extract URLs from content if not already captured
if (bookmark.content) {
const urlRegex = /(https?:\/\/[^\s]+)/g
const matches = bookmark.content.match(urlRegex)
if (matches) {
urls.push(...matches)
}
}
// Extract metadata from tags (for web bookmarks and other types)
const title = bookmark.tags.find(t => t[0] === 'title')?.[1]
const summary = bookmark.tags.find(t => t[0] === 'summary')?.[1]
const image = bookmark.tags.find(t => t[0] === 'image')?.[1]
// Create ReadItem for each unique URL
for (const url of [...new Set(urls)]) {
if (!linksMap.has(url)) {
const item: ReadItem = {
id: url,
source: 'bookmark',
type: 'external',
url,
title: title || fallbackTitleFromUrl(url),
summary,
image,
readingProgress: 0,
readingTimestamp: bookmark.added_at || bookmark.created_at
}
linksMap.set(url, item)
}
}
}
// Sort by most recent bookmark activity
return Array.from(linksMap.values()).sort((a, b) => {
const timeA = a.readingTimestamp || 0
const timeB = b.readingTimestamp || 0
return timeB - timeA
})
}

View File

@@ -0,0 +1,83 @@
import { ReadItem } from '../services/readsService'
/**
* Merges a ReadItem into a state map, returning whether the state changed.
* Uses most recent reading activity to determine precedence.
*/
export function mergeReadItem(
stateMap: Map<string, ReadItem>,
incoming: ReadItem
): boolean {
const existing = stateMap.get(incoming.id)
if (!existing) {
stateMap.set(incoming.id, incoming)
return true
}
// Always merge if incoming has reading progress data
const hasNewProgress = incoming.readingProgress !== undefined &&
(existing.readingProgress === undefined || existing.readingProgress !== incoming.readingProgress)
const hasNewMarkedAsRead = incoming.markedAsRead !== undefined && existing.markedAsRead === undefined
// Merge by taking the most recent reading activity
const existingTime = existing.readingTimestamp || existing.markedAt || 0
const incomingTime = incoming.readingTimestamp || incoming.markedAt || 0
if (incomingTime > existingTime || hasNewProgress || hasNewMarkedAsRead) {
// Keep existing data, but update with newer reading metadata
stateMap.set(incoming.id, {
...existing,
...incoming,
// Preserve event data if incoming doesn't have it
event: incoming.event || existing.event,
title: incoming.title || existing.title,
summary: incoming.summary || existing.summary,
image: incoming.image || existing.image,
published: incoming.published || existing.published,
author: incoming.author || existing.author,
// Always take reading progress if available
readingProgress: incoming.readingProgress !== undefined ? incoming.readingProgress : existing.readingProgress,
readingTimestamp: incomingTime > existingTime ? incoming.readingTimestamp : existing.readingTimestamp
})
return true
}
// If timestamps are equal but incoming has additional data, merge it
if (incomingTime === existingTime && (!existing.event && incoming.event || !existing.title && incoming.title)) {
stateMap.set(incoming.id, {
...existing,
...incoming,
event: incoming.event || existing.event,
title: incoming.title || existing.title,
summary: incoming.summary || existing.summary,
image: incoming.image || existing.image,
published: incoming.published || existing.published,
author: incoming.author || existing.author
})
return true
}
return false
}
/**
* Extracts a readable title from a URL when no title is available.
* Removes protocol, www, and shows domain + path.
*/
export function fallbackTitleFromUrl(url: string): string {
try {
const parsed = new URL(url)
let title = parsed.hostname.replace(/^www\./, '')
if (parsed.pathname && parsed.pathname !== '/') {
const path = parsed.pathname.slice(0, 40)
title += path.length < parsed.pathname.length ? path + '...' : path
}
return title
} catch {
// If URL parsing fails, just return the URL truncated
return url.length > 50 ? url.slice(0, 47) + '...' : url
}
}

View File

@@ -0,0 +1,30 @@
import { ReadItem } from '../services/readsService'
import { ReadingProgressFilterType } from '../components/ReadingProgressFilters'
/**
* Filters ReadItems by reading progress
*/
export function filterByReadingProgress(
items: ReadItem[],
filter: ReadingProgressFilterType
): ReadItem[] {
return items.filter((item) => {
const progress = item.readingProgress || 0
const isMarked = item.markedAsRead || false
switch (filter) {
case 'unopened':
return progress === 0 && !isMarked
case 'started':
return progress > 0 && progress <= 0.10 && !isMarked
case 'reading':
return progress > 0.10 && progress <= 0.94 && !isMarked
case 'completed':
return progress >= 0.95 || isMarked
case 'all':
default:
return true
}
})
}

View File

@@ -0,0 +1,71 @@
import { Bookmark } from '../types/bookmarks'
import { ReadItem } from '../services/readsService'
import { classifyBookmarkType } from './bookmarkTypeClassifier'
import { KINDS } from '../config/kinds'
import { nip19 } from 'nostr-tools'
/**
* Derives ReadItems from bookmarks for Nostr articles (kind:30023).
* Returns items with type='article', using hydrated data when available.
* Note: After hydration, article titles are in bookmark.content, metadata in tags.
*/
export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
const readsMap = new Map<string, ReadItem>()
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
for (const bookmark of allBookmarks) {
const bookmarkType = classifyBookmarkType(bookmark)
// Only include articles (kind:30023)
if (bookmarkType === 'article' && bookmark.kind === KINDS.BlogPost) {
const coordinate = bookmark.id // coordinate format: kind:pubkey:identifier
// Extract identifier from coordinate
const parts = coordinate.split(':')
const identifier = parts[2] || ''
// Convert to naddr format (reading positions use naddr as ID)
let naddr: string
try {
naddr = nip19.naddrEncode({
kind: KINDS.BlogPost,
pubkey: bookmark.pubkey,
identifier
})
} catch (e) {
console.warn('Failed to encode naddr for bookmark:', coordinate)
continue
}
// Extract metadata from tags (same as BookmarkItem does)
const title = bookmark.content || 'Untitled'
const image = bookmark.tags.find(t => t[0] === 'image')?.[1]
const summary = bookmark.tags.find(t => t[0] === 'summary')?.[1]
const published = bookmark.tags.find(t => t[0] === 'published_at')?.[1]
const item: ReadItem = {
id: naddr, // Use naddr format to match reading positions
source: 'bookmark',
type: 'article',
readingProgress: 0,
readingTimestamp: bookmark.added_at || bookmark.created_at,
title,
summary,
image,
published: published ? parseInt(published) : undefined,
author: bookmark.pubkey
}
readsMap.set(naddr, item)
}
}
// Sort by most recent bookmark activity
return Array.from(readsMap.values()).sort((a, b) => {
const timeA = a.readingTimestamp || 0
const timeB = b.readingTimestamp || 0
return timeB - timeA
})
}

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']
}
})