mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
170 Commits
reading-pr
...
bunker-enc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c3aff0407 | ||
|
|
aad35d41db | ||
|
|
cc6189a5d9 | ||
|
|
18bf8f9a2c | ||
|
|
37f3a32a1c | ||
|
|
c9678564a5 | ||
|
|
721c18c509 | ||
|
|
9e30fe683b | ||
|
|
7fff50c146 | ||
|
|
fc1c845b67 | ||
|
|
c2ec1f3677 | ||
|
|
0cbd357856 | ||
|
|
26ea9ed547 | ||
|
|
9cbbecb32c | ||
|
|
db12c89731 | ||
|
|
6f413deb90 | ||
|
|
0127e2dc86 | ||
|
|
7743928702 | ||
|
|
bf76150fc1 | ||
|
|
c62107172b | ||
|
|
a253587dfa | ||
|
|
1938533d53 | ||
|
|
28943c55bd | ||
|
|
791bbb68b6 | ||
|
|
ec8adcc794 | ||
|
|
68058e7661 | ||
|
|
416c62369c | ||
|
|
a19dd53423 | ||
|
|
79ec33b79a | ||
|
|
be881b957c | ||
|
|
244872e9f2 | ||
|
|
1397f7f0f4 | ||
|
|
96424dd65c | ||
|
|
9efc5459fb | ||
|
|
7e02168e54 | ||
|
|
f8e6b3e828 | ||
|
|
c06176bfc9 | ||
|
|
e2a1701000 | ||
|
|
d7703ceef4 | ||
|
|
93daabc673 | ||
|
|
9264245944 | ||
|
|
f56423040b | ||
|
|
4b91504a50 | ||
|
|
1f0f7fef5e | ||
|
|
6aced653fb | ||
|
|
0899482869 | ||
|
|
1bdfa1e6e1 | ||
|
|
f22a8f15c0 | ||
|
|
bf6394fc7d | ||
|
|
6f08586e8f | ||
|
|
d60a4a24ad | ||
|
|
51069f3623 | ||
|
|
1407af22e3 | ||
|
|
ea6220277d | ||
|
|
fbffa03dad | ||
|
|
a74760d804 | ||
|
|
c4b0a712d2 | ||
|
|
1fecf9c7f4 | ||
|
|
7be21203d9 | ||
|
|
f65f2c6597 | ||
|
|
227def4328 | ||
|
|
b506624f57 | ||
|
|
fbb6a0a153 | ||
|
|
528de32689 | ||
|
|
230e5380ca | ||
|
|
349237d097 | ||
|
|
d4df9f0424 | ||
|
|
2f68e84002 | ||
|
|
b18dcc29cd | ||
|
|
680169e312 | ||
|
|
11753c4515 | ||
|
|
bd29dfd65f | ||
|
|
4b1ae838e5 | ||
|
|
85599d3103 | ||
|
|
4603c5a258 | ||
|
|
ec45fbc5e8 | ||
|
|
53400334b2 | ||
|
|
af4ff7081a | ||
|
|
7f21b8ed76 | ||
|
|
55e44dcc9c | ||
|
|
59dac947ab | ||
|
|
7d33c3c024 | ||
|
|
38a014ef84 | ||
|
|
f451348430 | ||
|
|
685aaf43b0 | ||
|
|
d6a20b5272 | ||
|
|
d8d7a19fa1 | ||
|
|
63626fae3a | ||
|
|
de09ef2935 | ||
|
|
bcb28a63a7 | ||
|
|
a479903ce3 | ||
|
|
567d105261 | ||
|
|
83743c5a9f | ||
|
|
0b8f88ea1d | ||
|
|
fadc755930 | ||
|
|
f67f171e64 | ||
|
|
449c59015e | ||
|
|
4d697e6a79 | ||
|
|
04ae70873a | ||
|
|
2f8a64826a | ||
|
|
11cb3542ee | ||
|
|
905296621c | ||
|
|
769484bc0d | ||
|
|
27ff4cef22 | ||
|
|
a352e2616e | ||
|
|
77cbb9394f | ||
|
|
39c8b3dfe4 | ||
|
|
7bd11e695e | ||
|
|
a76b703d36 | ||
|
|
df51173405 | ||
|
|
a79d7f9eaf | ||
|
|
1032a46456 | ||
|
|
ae997758ab | ||
|
|
91a827324d | ||
|
|
bf849c9faa | ||
|
|
118ab46ac0 | ||
|
|
d2f2b689f9 | ||
|
|
5229e45566 | ||
|
|
b17043e85d | ||
|
|
19ca909ef5 | ||
|
|
f7ff309b6e | ||
|
|
ea5a8486b9 | ||
|
|
58897b3436 | ||
|
|
6a59ecfa47 | ||
|
|
272066c6e0 | ||
|
|
0426c9d3b0 | ||
|
|
c22419ba0e | ||
|
|
8278fed2fb | ||
|
|
b24a65b490 | ||
|
|
fb509fabd8 | ||
|
|
d21285123f | ||
|
|
1029b6be0c | ||
|
|
3fff9455a1 | ||
|
|
8c6232e029 | ||
|
|
f6c562e9be | ||
|
|
a92b14e877 | ||
|
|
b69a956247 | ||
|
|
82a8dcf6eb | ||
|
|
8e19e22289 | ||
|
|
e167b57810 | ||
|
|
ba3b82e6b5 | ||
|
|
b5edfbb2c9 | ||
|
|
48048f877a | ||
|
|
bd1afc54c3 | ||
|
|
a2c4bed0f5 | ||
|
|
9bad49fe5f | ||
|
|
2aa6536496 | ||
|
|
bd6d8a0342 | ||
|
|
dc8e86bc57 | ||
|
|
32b843908e | ||
|
|
5a71480459 | ||
|
|
17455aa47b | ||
|
|
4cc32c27de | ||
|
|
99bfe209a5 | ||
|
|
0a28bfbd50 | ||
|
|
ba9fb109f6 | ||
|
|
ec9d2fcb49 | ||
|
|
f841043e03 | ||
|
|
94dc95e1f0 | ||
|
|
32a5145d8f | ||
|
|
a856e8ca26 | ||
|
|
d54306cf92 | ||
|
|
9fdb96b64e | ||
|
|
c50aa3a243 | ||
|
|
adef1a922c | ||
|
|
99df4d6761 | ||
|
|
cf2098a723 | ||
|
|
5568437663 | ||
|
|
7bfd7fdf6c | ||
|
|
85649ae283 |
@@ -1,136 +0,0 @@
|
||||
<!-- 658dc3b5-4b0b-4d30-8cfa-a9326f1d467e f1d78d5b-786d-4658-ae4b-56278aba318e -->
|
||||
# Lazy Load Me Component Tabs
|
||||
|
||||
## Overview
|
||||
|
||||
Currently, the Me component loads all data for all tabs upfront, causing 30+ second load times even when viewing a single tab. This plan implements lazy loading where only the active tab's data is fetched on demand.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
Based on user requirements:
|
||||
|
||||
- Load only the active tab's data (pure lazy loading)
|
||||
- No background prefetching
|
||||
- Show cached data immediately, refresh in background when revisiting tabs
|
||||
- Works for both `/me` (own profile) and `/p/` (other profiles) using the same code
|
||||
|
||||
## Key Insight
|
||||
|
||||
The Me component already handles both own profile and other profiles via the `isOwnProfile` flag. The lazy loading will naturally work for both cases:
|
||||
|
||||
- Own profile (`/me`): Loads all tabs including private data (bookmarks, reads)
|
||||
- Other profiles (`/p/npub...`): Only loads public tabs (highlights, writings)
|
||||
|
||||
## Changes Required
|
||||
|
||||
### 1. Update Me.tsx Loading Logic
|
||||
|
||||
**Current behavior**: Single `useEffect` loads all data (highlights, writings, bookmarks, reads) regardless of active tab.
|
||||
|
||||
**New behavior**:
|
||||
|
||||
- Create separate loading functions per tab
|
||||
- Load only active tab's data on mount and tab switches
|
||||
- Show cached data immediately if available
|
||||
- Refresh cached data in background when tab is revisited
|
||||
|
||||
**Key changes**:
|
||||
|
||||
- Remove the monolithic `loadData()` function
|
||||
- Add `loadedTabs` state to track which tabs have been fetched
|
||||
- Create tab-specific loaders: `loadHighlights()`, `loadWritings()`, `loadBookmarks()`, `loadReads()`
|
||||
- Add `useEffect` that watches `activeTab` and loads data for current tab only
|
||||
- Check cache first, display cached data, then refresh in background
|
||||
|
||||
**Code location**: Lines 64-123 in `src/components/Me.tsx`
|
||||
|
||||
### 2. Per-Tab Loading State
|
||||
|
||||
Add tab-specific loading tracking:
|
||||
|
||||
```typescript
|
||||
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
|
||||
```
|
||||
|
||||
This prevents unnecessary reloads and allows showing cached data instantly.
|
||||
|
||||
### 3. Tab-Specific Load Functions
|
||||
|
||||
Create individual functions:
|
||||
|
||||
- `loadHighlightsTab()` - fetch highlights
|
||||
- `loadWritingsTab()` - fetch writings
|
||||
- `loadReadingListTab()` - fetch bookmarks
|
||||
- `loadReadsTab()` - fetch bookmarks first, then reads
|
||||
|
||||
Each function:
|
||||
|
||||
1. Checks cache, displays if available
|
||||
2. Sets loading state
|
||||
3. Fetches fresh data
|
||||
4. Updates state and cache
|
||||
5. Marks tab as loaded
|
||||
|
||||
### 4. Tab Switch Effect
|
||||
|
||||
Replace the current useEffect with:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (!activeTab || !viewingPubkey) return
|
||||
|
||||
// Check if we have cached data
|
||||
const cached = getCachedMeData(viewingPubkey)
|
||||
if (cached) {
|
||||
// Show cached data immediately
|
||||
setHighlights(cached.highlights)
|
||||
setBookmarks(cached.bookmarks)
|
||||
setReads(cached.reads)
|
||||
// Continue to refresh in background
|
||||
}
|
||||
|
||||
// Load data for active tab
|
||||
switch (activeTab) {
|
||||
case 'highlights':
|
||||
loadHighlightsTab()
|
||||
break
|
||||
case 'writings':
|
||||
loadWritingsTab()
|
||||
break
|
||||
case 'reading-list':
|
||||
loadReadingListTab()
|
||||
break
|
||||
case 'reads':
|
||||
loadReadsTab()
|
||||
break
|
||||
}
|
||||
}, [activeTab, viewingPubkey, refreshTrigger])
|
||||
```
|
||||
|
||||
### 5. Handle Pull-to-Refresh
|
||||
|
||||
Update pull-to-refresh logic to only reload the active tab instead of all tabs.
|
||||
|
||||
## Benefits
|
||||
|
||||
- Initial load: ~2-5s instead of 30+ seconds (only loads one tab)
|
||||
- Tab switching: Instant with cached data, refreshes in background
|
||||
- Network efficiency: Only fetches what the user views
|
||||
- Better UX: Users see content immediately from cache
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- Verify each tab loads independently
|
||||
- Confirm cached data shows immediately on tab switch
|
||||
- Ensure background refresh works without flickering
|
||||
- Test pull-to-refresh only reloads active tab
|
||||
- Verify loading states per tab work correctly
|
||||
|
||||
### To-dos
|
||||
|
||||
- [ ] Create src/services/readsService.ts with fetchAllReads function
|
||||
- [ ] Update Me.tsx to use reads instead of archive
|
||||
- [ ] Update routes from /me/archive to /me/reads
|
||||
- [ ] Update meCache.ts to use reads field
|
||||
- [ ] Update filter logic to handle actual reading progress
|
||||
- [ ] Test all 5 filters and data sources work correctly
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,4 +11,5 @@ dist
|
||||
# Reference Projects
|
||||
applesauce
|
||||
primal-web-app
|
||||
Amber
|
||||
|
||||
|
||||
77
Amber.md
Normal file
77
Amber.md
Normal 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 NIP‑46 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 Amber’s).
|
||||
|
||||
## 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 NIP‑46 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 10–30s windows.
|
||||
- Client-side wiring is likely correct (subscription open, permissions requested, relays merged). The remaining issue appears provider-side in Amber’s NIP‑46 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 nip‑04 and nip‑44.
|
||||
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 don’t appear:
|
||||
|
||||
- This points to Amber’s NIP‑46 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 NIP‑46 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 end‑to‑end path is alive.
|
||||
- The missing DECRYPT activity in Amber is the blocker. Fixing Amber’s NIP‑46 decrypt handling should resolve bookmark decryption in Boris without further client changes.
|
||||
|
||||
|
||||
124
CHANGELOG.md
124
CHANGELOG.md
@@ -7,6 +7,125 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.24] - 2025-01-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- TypeScript global declarations for build-time defines
|
||||
- Added proper type declarations for `__APP_VERSION__`, `__GIT_COMMIT__`, `__GIT_BRANCH__`, `__BUILD_TIME__`, and `__GIT_COMMIT_URL__`
|
||||
- Resolved ESLint no-undef errors for build-time injected variables
|
||||
- Added Node.js environment hint to Vite configuration
|
||||
|
||||
## [0.6.23] - 2025-01-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Deep-link refresh redirect issue for nostr-native articles
|
||||
- Limited `/a/:naddr` rewrite to bot user-agents only in Vercel configuration
|
||||
- Real browsers now hit the SPA directly, preventing redirect to root path
|
||||
- Bot crawlers still receive proper OpenGraph metadata for social sharing
|
||||
|
||||
### Added
|
||||
|
||||
- Version and git commit information in Settings footer
|
||||
- Displays app version and short commit hash with link to GitHub
|
||||
- Build-time metadata injection via Vite configuration
|
||||
- Subtle footer styling with selectable text
|
||||
|
||||
### Changed
|
||||
|
||||
- Article OG handler now uses proper RelayPool.request() API
|
||||
- Aligned with applesauce RelayPool interface
|
||||
- Removed deprecated open/close methods
|
||||
- Fixed TypeScript linting errors
|
||||
|
||||
### Technical
|
||||
|
||||
- Added debug logging for route state and article OG handler
|
||||
- Gated by `?debug=1` query parameter for production testing
|
||||
- Structured logging for troubleshooting deep-link issues
|
||||
- Temporary debug components for validation
|
||||
|
||||
## [0.6.22] - 2025-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Dynamic OpenGraph and Twitter Card meta tags for article deep-links
|
||||
- Social media platforms display article title, author, cover image, and summary when sharing `/a/{naddr}` links
|
||||
- Serverless endpoint fetches article metadata from Nostr relays (kind:30023) and author profiles (kind:0)
|
||||
- User-agent detection serves appropriate content to crawlers vs browsers
|
||||
- Falls back to default social preview image when articles have no cover image
|
||||
- Social preview image for homepage and article links
|
||||
- Added `boris-social-1200.png` as default OpenGraph image (1200x630)
|
||||
- Homepage now includes social preview image in meta tags
|
||||
|
||||
### Changed
|
||||
|
||||
- Article deep-links now properly preserve URL when loading in browser
|
||||
- Uses `history.replaceState()` to maintain correct article path
|
||||
- Browser navigation works correctly on refresh and new tab opens
|
||||
|
||||
### Fixed
|
||||
|
||||
- Vercel rewrite configuration for article routes
|
||||
- Routes `/a/:naddr` to serverless OG endpoint for dynamic meta tags
|
||||
- Regular SPA routing preserved for browser navigation
|
||||
|
||||
## [0.6.21] - 2025-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Reading position sync across devices using Nostr Kind 30078 (NIP-78)
|
||||
- Automatically saves and syncs reading position as you scroll
|
||||
- Visual reading progress indicator on article cards
|
||||
- Reading progress shown in Explore and Bookmarks sidebar
|
||||
- Auto-scroll to last reading position setting (configurable in Settings)
|
||||
- Reading position displayed as colored progress bar on cards
|
||||
- Reading progress filters for organizing articles
|
||||
- Filter by reading state: Unopened, Started (0-10%), Reading (11-94%), Completed (95-100% or marked as read)
|
||||
- Filter icons colored when active (blue for most, green for completed)
|
||||
- URL routing support for reading progress filters
|
||||
- Reading progress filters available in Archive tab and bookmarks sidebar
|
||||
- Reads and Links tabs on `/me` page
|
||||
- Reads tab shows nostr-native articles with reading progress
|
||||
- Links tab shows external URLs with reading progress
|
||||
- Both tabs populate instantly from bookmarks for fast loading
|
||||
- Lazy loading for improved performance
|
||||
- Auto-mark as read at 100% reading progress
|
||||
- Articles automatically marked as read when scrolled to end
|
||||
- Marked-as-read articles treated as 100% progress
|
||||
- Fancy checkmark animation on Mark as Read button
|
||||
- Click-to-open article navigation on highlights
|
||||
- Clicking highlights in Explore and Me pages opens the source article
|
||||
- Automatically scrolls to highlighted text position
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed Archive to Reads with expanded functionality
|
||||
- Merged 'Completed' and 'Marked as Read' filters into one unified filter
|
||||
- Simplified filter icon colors to blue (except green for completed)
|
||||
- Started reading progress state (0-10%) uses neutral text color
|
||||
- Replace spinners with skeleton placeholders during refresh in Archive/Reads/Links tabs
|
||||
- Removed unused IEventStore import in ContentPanel
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reading position calculation now accurately reaches 100%
|
||||
- Reading position filters work correctly in bookmarks sidebar
|
||||
- Filter out reads without timestamps or 'Untitled' items
|
||||
- Show skeleton placeholders correctly during initial tab load
|
||||
- External URLs in Reads tab only shown if they have reading progress
|
||||
- Reading progress merges even when timestamp is older than bookmark
|
||||
- Resolved all linter errors and TypeScript type issues
|
||||
|
||||
### Refactored
|
||||
|
||||
- Renamed ArchiveFilters component to ReadingProgressFilters
|
||||
- Extracted shared utilities from readsFromBookmarks for DRY code
|
||||
- Use setState callback pattern for background enrichment
|
||||
- Use naddr format for article IDs to match reading positions
|
||||
- Extract article titles, images, summaries from bookmark tags using applesauce helpers
|
||||
|
||||
## [0.6.20] - 2025-10-15
|
||||
|
||||
### Added
|
||||
@@ -1641,7 +1760,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.20...HEAD
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.24...HEAD
|
||||
[0.6.24]: https://github.com/dergigi/boris/compare/v0.6.23...v0.6.24
|
||||
[0.6.23]: https://github.com/dergigi/boris/compare/v0.6.22...v0.6.23
|
||||
[0.6.21]: https://github.com/dergigi/boris/compare/v0.6.20...v0.6.21
|
||||
[0.6.20]: https://github.com/dergigi/boris/compare/v0.6.19...v0.6.20
|
||||
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
|
||||
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18
|
||||
|
||||
304
api/article-og.ts
Normal file
304
api/article-og.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.20",
|
||||
"version": "0.6.24",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
BIN
public/boris-social-1200.png
Normal file
BIN
public/boris-social-1200.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 819 KiB |
256
src/App.tsx
256
src/App.tsx
@@ -4,16 +4,21 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
|
||||
import { EventStore } from 'applesauce-core'
|
||||
import { AccountManager } from 'applesauce-accounts'
|
||||
import { AccountManager, Accounts } from 'applesauce-accounts'
|
||||
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrConnectSigner } from 'applesauce-signers'
|
||||
import { getDefaultBunkerPermissions } from './services/nostrConnect'
|
||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import Debug from './components/Debug'
|
||||
import Bookmarks from './components/Bookmarks'
|
||||
import RouteDebug from './components/RouteDebug'
|
||||
import Toast from './components/Toast'
|
||||
import { useToast } from './hooks/useToast'
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||
import { RELAYS } from './config/relays'
|
||||
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||
import { DebugBus } from './utils/debugBus'
|
||||
|
||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||
@@ -165,6 +170,7 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/debug" element={<Debug />} />
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
@@ -186,20 +192,57 @@ function App() {
|
||||
// Register common account types (needed for deserialization)
|
||||
registerCommonAccountTypes(accounts)
|
||||
|
||||
// Create relay pool and set it up BEFORE loading accounts
|
||||
// NostrConnectAccount.fromJSON needs this to restore the signer
|
||||
const pool = new RelayPool()
|
||||
// Wire the signer to use this pool; make publish non-blocking so callers don't
|
||||
// wait for every relay send to finish. Responses still resolve the pending request.
|
||||
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
|
||||
NostrConnectSigner.publishMethod = (relays: string[], event: unknown) => {
|
||||
const result: any = pool.publish(relays, event as any)
|
||||
if (result && typeof (result as any).subscribe === 'function') {
|
||||
try { (result as any).subscribe({ complete: () => {}, error: () => {} }) } catch {}
|
||||
}
|
||||
// Return an already-resolved promise so upstream await finishes immediately
|
||||
return Promise.resolve()
|
||||
}
|
||||
console.log('[bunker] ✅ Wired NostrConnectSigner to RelayPool publish/subscription (before account load)')
|
||||
|
||||
// Create a relay group for better event deduplication and management
|
||||
pool.group(RELAYS)
|
||||
console.log('[bunker] Created relay group with', RELAYS.length, 'relays (including local)')
|
||||
|
||||
// Load persisted accounts from localStorage
|
||||
try {
|
||||
const json = JSON.parse(localStorage.getItem('accounts') || '[]')
|
||||
const accountsJson = localStorage.getItem('accounts')
|
||||
console.log('[bunker] Raw accounts from localStorage:', accountsJson)
|
||||
|
||||
const json = JSON.parse(accountsJson || '[]')
|
||||
console.log('[bunker] Parsed accounts:', json.length, 'accounts')
|
||||
|
||||
await accounts.fromJSON(json)
|
||||
console.log('Loaded', accounts.accounts.length, 'accounts from storage')
|
||||
console.log('[bunker] Loaded', accounts.accounts.length, 'accounts from storage')
|
||||
console.log('[bunker] Account types:', accounts.accounts.map(a => ({ id: a.id, type: a.type })))
|
||||
|
||||
// Load active account from storage
|
||||
const activeId = localStorage.getItem('active')
|
||||
if (activeId && accounts.getAccount(activeId)) {
|
||||
accounts.setActive(activeId)
|
||||
console.log('Restored active account:', activeId)
|
||||
console.log('[bunker] Active ID from localStorage:', activeId)
|
||||
|
||||
if (activeId) {
|
||||
const account = accounts.getAccount(activeId)
|
||||
console.log('[bunker] Found account for ID?', !!account, account?.type)
|
||||
|
||||
if (account) {
|
||||
accounts.setActive(activeId)
|
||||
console.log('[bunker] ✅ Restored active account:', activeId, 'type:', account.type)
|
||||
} else {
|
||||
console.warn('[bunker] ⚠️ Active ID found but account not in list')
|
||||
}
|
||||
} else {
|
||||
console.log('[bunker] No active account ID in localStorage')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load accounts from storage:', err)
|
||||
console.error('[bunker] ❌ Failed to load accounts from storage:', err)
|
||||
}
|
||||
|
||||
// Subscribe to accounts changes and persist to localStorage
|
||||
@@ -216,12 +259,197 @@ function App() {
|
||||
}
|
||||
})
|
||||
|
||||
const pool = new RelayPool()
|
||||
// Reconnect bunker signers when active account changes
|
||||
// Keep track of which accounts we've already reconnected to avoid double-connecting
|
||||
const reconnectedAccounts = new Set<string>()
|
||||
|
||||
// Create a relay group for better event deduplication and management
|
||||
pool.group(RELAYS)
|
||||
console.log('Created relay group with', RELAYS.length, 'relays (including local)')
|
||||
console.log('Relay URLs:', RELAYS)
|
||||
const bunkerReconnectSub = accounts.active$.subscribe(async (account) => {
|
||||
console.log('[bunker] Active account changed:', {
|
||||
hasAccount: !!account,
|
||||
type: account?.type,
|
||||
id: account?.id
|
||||
})
|
||||
|
||||
if (account && account.type === 'nostr-connect') {
|
||||
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
|
||||
// Disable applesauce account queueing so decrypt requests aren't serialized behind earlier ops
|
||||
try {
|
||||
if (!(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue) {
|
||||
(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue = true
|
||||
console.log('[bunker] ⚙️ Disabled account request queueing for nostr-connect')
|
||||
}
|
||||
} catch (err) { console.warn('[bunker] failed to disable queue', err) }
|
||||
// Note: for Amber bunker, the remote signer pubkey is the user's pubkey. This is expected.
|
||||
|
||||
// Skip if we've already reconnected this account
|
||||
if (reconnectedAccounts.has(account.id)) {
|
||||
console.log('[bunker] ⏭️ Already reconnected this account, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[bunker] Account detected. Status:', {
|
||||
listening: nostrConnectAccount.signer.listening,
|
||||
isConnected: nostrConnectAccount.signer.isConnected,
|
||||
hasRemote: !!nostrConnectAccount.signer.remote,
|
||||
bunkerRelays: nostrConnectAccount.signer.relays
|
||||
})
|
||||
|
||||
try {
|
||||
// For restored signers, ensure they have the pool's subscription methods
|
||||
// The signer was created in fromJSON without pool context, so we need to recreate it
|
||||
const signerData = nostrConnectAccount.toJSON().signer
|
||||
|
||||
// Add bunker's relays to the pool BEFORE recreating the signer
|
||||
// This ensures the pool has all relays when the signer sets up its methods
|
||||
const bunkerRelays = signerData.relays || []
|
||||
const existingRelayUrls = new Set(Array.from(pool.relays.keys()))
|
||||
const newBunkerRelays = bunkerRelays.filter(url => !existingRelayUrls.has(url))
|
||||
|
||||
if (newBunkerRelays.length > 0) {
|
||||
console.log('[bunker] Adding bunker relays to pool BEFORE signer recreation:', newBunkerRelays)
|
||||
pool.group(newBunkerRelays)
|
||||
} else {
|
||||
console.log('[bunker] Bunker relays already in pool')
|
||||
}
|
||||
|
||||
const recreatedSigner = new NostrConnectSigner({
|
||||
relays: signerData.relays,
|
||||
pubkey: nostrConnectAccount.pubkey,
|
||||
remote: signerData.remote,
|
||||
signer: nostrConnectAccount.signer.signer, // Use the existing SimpleSigner
|
||||
pool: pool
|
||||
})
|
||||
// Ensure local relays are included for NIP-46 request/response traffic (e.g., Amber bunker)
|
||||
try {
|
||||
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
|
||||
recreatedSigner.relays = mergedRelays
|
||||
console.log('[bunker] 🔗 Signer relays merged with app RELAYS:', mergedRelays)
|
||||
} catch (err) { console.warn('[bunker] failed to merge signer relays', err) }
|
||||
|
||||
// Replace the signer on the account
|
||||
nostrConnectAccount.signer = recreatedSigner
|
||||
console.log('[bunker] ✅ Signer recreated with pool context')
|
||||
|
||||
// Debug: log publish/subscription calls made by signer (decrypt/sign requests)
|
||||
// IMPORTANT: bind originals to preserve `this` context used internally by the signer
|
||||
const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner)
|
||||
;(recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod = (relays: string[], event: unknown) => {
|
||||
try {
|
||||
let method: string | undefined
|
||||
const content = (event as { content?: unknown })?.content
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as { method?: string; id?: unknown }
|
||||
method = parsed?.method
|
||||
} catch (err) { console.warn('[bunker] failed to parse event content', err) }
|
||||
}
|
||||
const summary = {
|
||||
relays,
|
||||
kind: (event as { kind?: number })?.kind,
|
||||
method,
|
||||
// include tags array for debugging (NIP-46 expects method tag)
|
||||
tags: (event as { tags?: unknown })?.tags,
|
||||
contentLength: typeof content === 'string' ? content.length : undefined
|
||||
}
|
||||
console.log('[bunker] publish via signer:', summary)
|
||||
try { DebugBus.info('bunker', 'publish', summary) } catch (err) { console.warn('[bunker] failed to log to DebugBus', err) }
|
||||
} catch (err) { console.warn('[bunker] failed to log publish summary', err) }
|
||||
// Fire-and-forget publish: trigger the publish but do not return the
|
||||
// Observable/Promise to upstream to avoid their awaiting of completion.
|
||||
const result = originalPublish(relays, event)
|
||||
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
|
||||
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => {}, error: () => {} }) } catch {}
|
||||
}
|
||||
// If it's a Promise, simply ignore it (no await) so it resolves in the background.
|
||||
// Return a benign object so callers that probe for a "subscribe" property
|
||||
// (e.g., applesauce makeRequest) won't throw on `"subscribe" in result`.
|
||||
return {} as unknown as never
|
||||
}
|
||||
const originalSubscribe = (recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod.bind(recreatedSigner)
|
||||
;(recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod = (relays: string[], filters: unknown[]) => {
|
||||
try {
|
||||
console.log('[bunker] subscribe via signer:', { relays, filters })
|
||||
try { DebugBus.info('bunker', 'subscribe', { relays, filters }) } catch (err) { console.warn('[bunker] failed to log subscribe to DebugBus', err) }
|
||||
} catch (err) { console.warn('[bunker] failed to log subscribe summary', err) }
|
||||
return originalSubscribe(relays, filters)
|
||||
}
|
||||
|
||||
|
||||
// Just ensure the signer is listening for responses - don't call connect() again
|
||||
// The fromBunkerURI already connected with permissions during login
|
||||
if (!nostrConnectAccount.signer.listening) {
|
||||
console.log('[bunker] Opening signer subscription...')
|
||||
await nostrConnectAccount.signer.open()
|
||||
console.log('[bunker] ✅ Signer subscription opened')
|
||||
} else {
|
||||
console.log('[bunker] ✅ Signer already listening')
|
||||
}
|
||||
|
||||
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
|
||||
try {
|
||||
if (nostrConnectAccount.signer.remote && !reconnectedAccounts.has(account.id)) {
|
||||
const permissions = getDefaultBunkerPermissions()
|
||||
console.log('[bunker] Attempting guarded connect() with permissions to ensure decrypt perms', { count: permissions.length })
|
||||
await nostrConnectAccount.signer.connect(undefined, permissions)
|
||||
console.log('[bunker] ✅ Guarded connect() succeeded with permissions')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[bunker] ⚠️ Guarded connect() failed:', e)
|
||||
}
|
||||
|
||||
// Give the subscription a moment to fully establish before allowing decrypt operations
|
||||
// This ensures the signer is ready to handle and receive responses
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
console.log("[bunker] Subscription ready after startup delay")
|
||||
// Fire-and-forget: probe decrypt path to verify Amber responds to NIP-46 decrypt
|
||||
try {
|
||||
const withTimeout = async <T,>(p: Promise<T>, ms = 10000): Promise<T> => {
|
||||
return await Promise.race([
|
||||
p,
|
||||
new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`probe timeout after ${ms}ms`)), ms)),
|
||||
])
|
||||
}
|
||||
setTimeout(async () => {
|
||||
const self = nostrConnectAccount.pubkey
|
||||
// Try a roundtrip so the bunker can respond successfully
|
||||
try {
|
||||
console.log('[bunker] 🔎 Probe nip44 roundtrip (encrypt→decrypt)…')
|
||||
const cipher44 = await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44'))
|
||||
const plain44 = await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, cipher44))
|
||||
console.log('[bunker] 🔎 Probe nip44 responded:', typeof plain44 === 'string' ? plain44 : typeof plain44)
|
||||
} catch (err) {
|
||||
console.log('[bunker] 🔎 Probe nip44 result:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
try {
|
||||
console.log('[bunker] 🔎 Probe nip04 roundtrip (encrypt→decrypt)…')
|
||||
const cipher04 = await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04'))
|
||||
const plain04 = await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, cipher04))
|
||||
console.log('[bunker] 🔎 Probe nip04 responded:', typeof plain04 === 'string' ? plain04 : typeof plain04)
|
||||
} catch (err) {
|
||||
console.log('[bunker] 🔎 Probe nip04 result:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
}, 0)
|
||||
} catch (err) {
|
||||
console.log('[bunker] 🔎 Probe setup failed:', err)
|
||||
}
|
||||
// The bunker remembers the permissions from the initial connection
|
||||
nostrConnectAccount.signer.isConnected = true
|
||||
|
||||
console.log('[bunker] Final signer status:', {
|
||||
listening: nostrConnectAccount.signer.listening,
|
||||
isConnected: nostrConnectAccount.signer.isConnected,
|
||||
remote: nostrConnectAccount.signer.remote,
|
||||
relays: nostrConnectAccount.signer.relays
|
||||
})
|
||||
|
||||
// Mark this account as reconnected
|
||||
reconnectedAccounts.add(account.id)
|
||||
console.log('[bunker] 🎉 Signer ready for signing')
|
||||
} catch (error) {
|
||||
console.error('[bunker] ❌ Failed to open signer:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Keep all relay connections alive indefinitely by creating a persistent subscription
|
||||
// This prevents disconnection when no other subscriptions are active
|
||||
@@ -251,6 +479,7 @@ function App() {
|
||||
return () => {
|
||||
accountsSub.unsubscribe()
|
||||
activeSub.unsubscribe()
|
||||
bunkerReconnectSub.unsubscribe()
|
||||
// Clean up keep-alive subscription if it exists
|
||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||
if (poolWithSub._keepAliveSubscription) {
|
||||
@@ -267,7 +496,7 @@ function App() {
|
||||
return () => {
|
||||
if (cleanup) cleanup()
|
||||
}
|
||||
}, [])
|
||||
}, [isOnline, showToast])
|
||||
|
||||
// Monitor online/offline status
|
||||
useEffect(() => {
|
||||
@@ -303,6 +532,7 @@ function App() {
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||
<RouteDebug />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
{toastMessage && (
|
||||
|
||||
47
src/components/ArchiveFilters.tsx
Normal file
47
src/components/ArchiveFilters.tsx
Normal 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
|
||||
|
||||
@@ -19,10 +19,9 @@ interface BookmarkItemProps {
|
||||
index: number
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
viewMode?: ViewMode
|
||||
readingProgress?: number // 0-1 reading progress (optional)
|
||||
}
|
||||
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
|
||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||
|
||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||
@@ -151,7 +150,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
|
||||
if (viewMode === 'large') {
|
||||
const previewImage = articleImage || instantPreview || ogImage
|
||||
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} readingProgress={readingProgress} />
|
||||
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
|
||||
}
|
||||
|
||||
return <CardView {...sharedProps} articleImage={articleImage} />
|
||||
|
||||
@@ -21,7 +21,7 @@ import { RELAYS } from '../config/relays'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
||||
import LoginOptions from './LoginOptions'
|
||||
|
||||
interface BookmarkListProps {
|
||||
bookmarks: Bookmark[]
|
||||
@@ -40,8 +40,6 @@ interface BookmarkListProps {
|
||||
relayPool: RelayPool | null
|
||||
isMobile?: boolean
|
||||
settings?: UserSettings
|
||||
readingPositions?: Map<string, number>
|
||||
markedAsReadIds?: Set<string>
|
||||
}
|
||||
|
||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
@@ -60,16 +58,13 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
loading = false,
|
||||
relayPool,
|
||||
isMobile = false,
|
||||
settings,
|
||||
readingPositions,
|
||||
markedAsReadIds
|
||||
settings
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
||||
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
||||
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>('all')
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
|
||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||
@@ -96,42 +91,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContent)
|
||||
|
||||
// Apply type filter
|
||||
const typeFilteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
||||
|
||||
// Apply reading progress filter (only affects kind:30023 articles)
|
||||
const filteredBookmarks = typeFilteredBookmarks.filter(bookmark => {
|
||||
// Only apply reading progress filter to kind:30023 articles
|
||||
if (bookmark.kind !== 30023) return true
|
||||
|
||||
// If reading progress filter is 'all', show all articles
|
||||
if (readingProgressFilter === 'all') return true
|
||||
|
||||
const isMarkedAsRead = markedAsReadIds?.has(bookmark.id)
|
||||
const position = readingPositions?.get(bookmark.id)
|
||||
|
||||
// Marked-as-read articles are always treated as 100% complete
|
||||
if (isMarkedAsRead) {
|
||||
return readingProgressFilter === 'completed'
|
||||
}
|
||||
|
||||
switch (readingProgressFilter) {
|
||||
case 'unopened':
|
||||
// No reading progress - never opened
|
||||
return !position || position === 0
|
||||
case 'started':
|
||||
// 0-10% reading progress - opened but not read far
|
||||
return position !== undefined && position > 0 && position <= 0.10
|
||||
case 'reading':
|
||||
// Has some progress but not completed (11% - 94%)
|
||||
return position !== undefined && position > 0.10 && position <= 0.94
|
||||
case 'completed':
|
||||
// 95% or more read
|
||||
return position !== undefined && position >= 0.95
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
// Apply filter
|
||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
||||
|
||||
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
||||
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
||||
@@ -193,7 +154,9 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
|
||||
{!activeAccount ? (
|
||||
<LoginOptions />
|
||||
) : filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No bookmarks match this filter.</p>
|
||||
</div>
|
||||
@@ -210,7 +173,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
<div className="empty-state">
|
||||
<p>No bookmarks found.</p>
|
||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
@@ -244,7 +206,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
readingProgress={markedAsReadIds?.has(individualBookmark.id) ? 1.0 : readingPositions?.get(individualBookmark.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -252,17 +213,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reading progress filters - only show if there are kind:30023 articles */}
|
||||
{typeFilteredBookmarks.some(b => b.kind === 30023) && (
|
||||
<div className="reading-progress-filters-wrapper">
|
||||
<ReadingProgressFilters
|
||||
selectedFilter={readingProgressFilter}
|
||||
onFilterChange={setReadingProgressFilter}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="view-mode-controls">
|
||||
<div className="view-mode-left">
|
||||
<IconButton
|
||||
|
||||
@@ -162,9 +162,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
isRefreshing,
|
||||
lastFetchTime,
|
||||
handleFetchHighlights,
|
||||
handleRefreshAll,
|
||||
readingPositions,
|
||||
markedAsReadIds
|
||||
handleRefreshAll
|
||||
} = useBookmarksData({
|
||||
relayPool,
|
||||
activeAccount,
|
||||
@@ -173,8 +171,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
externalUrl,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings,
|
||||
eventStore
|
||||
settings
|
||||
})
|
||||
|
||||
const {
|
||||
@@ -316,8 +313,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
highlightButtonRef={highlightButtonRef}
|
||||
onCreateHighlight={handleCreateHighlight}
|
||||
hasActiveAccount={!!(activeAccount && relayPool)}
|
||||
readingPositions={readingPositions}
|
||||
markedAsReadIds={markedAsReadIds}
|
||||
explore={showExplore ? (
|
||||
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
||||
) : undefined}
|
||||
|
||||
@@ -187,77 +187,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
|
||||
enabled: isTextContent,
|
||||
syncEnabled: settings?.syncReadingPosition,
|
||||
onSave: handleSavePosition
|
||||
onSave: handleSavePosition,
|
||||
onReadingComplete: () => {
|
||||
// Optional: Auto-mark as read when reading is complete
|
||||
if (activeAccount && !isMarkedAsRead) {
|
||||
// Could trigger auto-mark as read here if desired
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
||||
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
||||
|
||||
// Define handleMarkAsRead with useCallback to use in auto-mark effect
|
||||
const handleMarkAsRead = useCallback(() => {
|
||||
if (!activeAccount || !relayPool || isMarkedAsRead) {
|
||||
return
|
||||
}
|
||||
|
||||
// Instantly update UI with checkmark animation
|
||||
setIsMarkedAsRead(true)
|
||||
setShowCheckAnimation(true)
|
||||
|
||||
// Reset animation after it completes (2.5s for full fancy animation)
|
||||
setTimeout(() => {
|
||||
setShowCheckAnimation(false)
|
||||
}, 2500)
|
||||
|
||||
// Fire-and-forget: publish in background without blocking UI
|
||||
;(async () => {
|
||||
try {
|
||||
if (isNostrArticle && currentArticle) {
|
||||
await createEventReaction(
|
||||
currentArticle.id,
|
||||
currentArticle.pubkey,
|
||||
currentArticle.kind,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
console.log('✅ Marked nostr article as read')
|
||||
} else if (selectedUrl) {
|
||||
await createWebsiteReaction(
|
||||
selectedUrl,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
console.log('✅ Marked website as read')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to mark as read:', error)
|
||||
// Revert UI state on error
|
||||
setIsMarkedAsRead(false)
|
||||
}
|
||||
})()
|
||||
}, [activeAccount, relayPool, isMarkedAsRead, isNostrArticle, currentArticle, selectedUrl])
|
||||
|
||||
// Auto-mark as read when reaching 100% for 2 seconds
|
||||
useEffect(() => {
|
||||
if (!settings?.autoMarkAsReadAt100 || isMarkedAsRead || !activeAccount || !relayPool) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only trigger when progress is exactly 100%
|
||||
if (progressPercentage === 100) {
|
||||
console.log('📍 [ContentPanel] Progress at 100%, starting 2-second timer for auto-mark')
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
console.log('✅ [ContentPanel] Auto-marking as read after 2 seconds at 100%')
|
||||
handleMarkAsRead()
|
||||
}, 2000)
|
||||
|
||||
return () => {
|
||||
console.log('⏹️ [ContentPanel] Canceling auto-mark timer (progress changed or unmounting)')
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [progressPercentage, settings?.autoMarkAsReadAt100, isMarkedAsRead, activeAccount, relayPool, handleMarkAsRead])
|
||||
|
||||
// Load saved reading position when article loads
|
||||
useEffect(() => {
|
||||
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||
@@ -288,25 +226,19 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
||||
console.log('🎯 [ContentPanel] Restoring position:', Math.round(savedPosition.position * 100) + '%')
|
||||
|
||||
// Only auto-scroll if the setting is enabled (default: true)
|
||||
if (settings?.autoScrollToPosition !== false) {
|
||||
// Wait for content to be fully rendered before scrolling
|
||||
setTimeout(() => {
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
const windowHeight = window.innerHeight
|
||||
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
|
||||
|
||||
window.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
|
||||
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
|
||||
}, 500) // Give content time to render
|
||||
} else {
|
||||
console.log('⏭️ [ContentPanel] Auto-scroll disabled in settings')
|
||||
}
|
||||
// Wait for content to be fully rendered before scrolling
|
||||
setTimeout(() => {
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
const windowHeight = window.innerHeight
|
||||
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
|
||||
|
||||
window.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
|
||||
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
|
||||
}, 500) // Give content time to render
|
||||
} else if (savedPosition) {
|
||||
if (savedPosition.position === 1) {
|
||||
console.log('✅ [ContentPanel] Article completed (100%), starting from top')
|
||||
@@ -320,7 +252,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
}
|
||||
|
||||
loadPosition()
|
||||
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, settings?.autoScrollToPosition, selectedUrl])
|
||||
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||
|
||||
// Save position before unmounting or changing article
|
||||
useEffect(() => {
|
||||
@@ -392,6 +324,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
const hasHighlights = relevantHighlights.length > 0
|
||||
|
||||
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
||||
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
||||
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
|
||||
|
||||
// Track external video duration (in seconds) for display in header
|
||||
@@ -660,6 +594,48 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
checkReadStatus()
|
||||
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
if (!activeAccount || !relayPool || isMarkedAsRead) {
|
||||
return
|
||||
}
|
||||
|
||||
// Instantly update UI with checkmark animation
|
||||
setIsMarkedAsRead(true)
|
||||
setShowCheckAnimation(true)
|
||||
|
||||
// Reset animation after it completes
|
||||
setTimeout(() => {
|
||||
setShowCheckAnimation(false)
|
||||
}, 600)
|
||||
|
||||
// Fire-and-forget: publish in background without blocking UI
|
||||
;(async () => {
|
||||
try {
|
||||
if (isNostrArticle && currentArticle) {
|
||||
await createEventReaction(
|
||||
currentArticle.id,
|
||||
currentArticle.pubkey,
|
||||
currentArticle.kind,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
console.log('✅ Marked nostr article as read')
|
||||
} else if (selectedUrl) {
|
||||
await createWebsiteReaction(
|
||||
selectedUrl,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
console.log('✅ Marked website as read')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to mark as read:', error)
|
||||
// Revert UI state on error
|
||||
setIsMarkedAsRead(false)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
if (!selectedUrl) {
|
||||
return (
|
||||
|
||||
395
src/components/Debug.tsx
Normal file
395
src/components/Debug.tsx
Normal 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
|
||||
@@ -22,8 +22,6 @@ import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { loadReadingPosition, generateArticleIdentifier } from '../services/readingPositionService'
|
||||
import { fetchReadArticles } from '../services/libraryService'
|
||||
|
||||
interface ExploreProps {
|
||||
relayPool: RelayPool
|
||||
@@ -43,8 +41,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
|
||||
const [markedAsReadIds, setMarkedAsReadIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// Visibility filters (defaults from settings, or friends only)
|
||||
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
||||
@@ -217,88 +213,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
loadData()
|
||||
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
|
||||
|
||||
// Fetch marked-as-read articles
|
||||
useEffect(() => {
|
||||
const loadMarkedAsRead = async () => {
|
||||
if (!activeAccount || !eventStore) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const readArticles = await fetchReadArticles(relayPool, activeAccount.pubkey)
|
||||
|
||||
// Create a set of article IDs that are marked as read
|
||||
const markedArticleIds = new Set<string>()
|
||||
|
||||
// For each read article, add both event ID and coordinate format
|
||||
for (const readArticle of readArticles) {
|
||||
// Add the event ID directly
|
||||
markedArticleIds.add(readArticle.id)
|
||||
|
||||
// For nostr-native articles (kind:7 reactions), also add the coordinate format
|
||||
if (readArticle.eventId && readArticle.eventAuthor && readArticle.eventKind) {
|
||||
// Try to get the event from the eventStore to find the 'd' tag
|
||||
const event = eventStore.getEvent(readArticle.eventId)
|
||||
if (event) {
|
||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||
markedArticleIds.add(coordinate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMarkedAsReadIds(markedArticleIds)
|
||||
} catch (error) {
|
||||
console.warn('⚠️ [Explore] Failed to load marked-as-read articles:', error)
|
||||
}
|
||||
}
|
||||
|
||||
loadMarkedAsRead()
|
||||
}, [relayPool, activeAccount, eventStore])
|
||||
|
||||
// Load reading positions for blog posts
|
||||
useEffect(() => {
|
||||
const loadPositions = async () => {
|
||||
if (!activeAccount || !eventStore || blogPosts.length === 0 || !settings?.syncReadingPosition) {
|
||||
return
|
||||
}
|
||||
|
||||
const positions = new Map<string, number>()
|
||||
|
||||
await Promise.all(
|
||||
blogPosts.map(async (post) => {
|
||||
try {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: post.author,
|
||||
identifier: dTag
|
||||
})
|
||||
const articleUrl = `nostr:${naddr}`
|
||||
const identifier = generateArticleIdentifier(articleUrl)
|
||||
|
||||
const savedPosition = await loadReadingPosition(
|
||||
relayPool,
|
||||
eventStore,
|
||||
activeAccount.pubkey,
|
||||
identifier
|
||||
)
|
||||
|
||||
if (savedPosition && savedPosition.position > 0) {
|
||||
positions.set(post.event.id, savedPosition.position)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ [Explore] Failed to load reading position for post:', error)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
setReadingPositions(positions)
|
||||
}
|
||||
|
||||
loadPositions()
|
||||
}, [blogPosts, activeAccount, relayPool, eventStore, settings?.syncReadingPosition])
|
||||
|
||||
// Pull-to-refresh
|
||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||
onRefresh: () => {
|
||||
@@ -388,7 +302,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
level={post.level}
|
||||
readingProgress={markedAsReadIds.has(post.event.id) ? 1.0 : readingPositions.get(post.event.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
179
src/components/LoginOptions.tsx
Normal file
179
src/components/LoginOptions.tsx
Normal 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
|
||||
|
||||
30
src/components/RouteDebug.tsx
Normal file
30
src/components/RouteDebug.tsx
Normal 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
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import ZapSettings from './Settings/ZapSettings'
|
||||
import RelaySettings from './Settings/RelaySettings'
|
||||
import PWASettings from './Settings/PWASettings'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
import VersionFooter from './VersionFooter'
|
||||
|
||||
const DEFAULT_SETTINGS: UserSettings = {
|
||||
collapseOnArticleOpen: true,
|
||||
@@ -167,6 +168,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
||||
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||
</div>
|
||||
<VersionFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,32 +117,6 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
||||
<span>Sync reading position across devices</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="autoScrollToPosition" className="checkbox-label">
|
||||
<input
|
||||
id="autoScrollToPosition"
|
||||
type="checkbox"
|
||||
checked={settings.autoScrollToPosition !== false}
|
||||
onChange={(e) => onUpdate({ autoScrollToPosition: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Auto-scroll to last reading position</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="autoMarkAsReadAt100" className="checkbox-label">
|
||||
<input
|
||||
id="autoMarkAsReadAt100"
|
||||
type="checkbox"
|
||||
checked={settings.autoMarkAsReadAt100 ?? false}
|
||||
onChange={(e) => onUpdate({ autoMarkAsReadAt100: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Automatically mark as read when reading progress is 100%</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,8 +47,6 @@ interface ThreePaneLayoutProps {
|
||||
onRefresh: () => void
|
||||
relayPool: RelayPool | null
|
||||
eventStore: IEventStore | null
|
||||
readingPositions?: Map<string, number>
|
||||
markedAsReadIds?: Set<string>
|
||||
|
||||
// Content pane
|
||||
readerLoading: boolean
|
||||
@@ -326,8 +324,6 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
loading={props.bookmarksLoading}
|
||||
relayPool={props.relayPool}
|
||||
isMobile={isMobile}
|
||||
readingPositions={props.readingPositions}
|
||||
markedAsReadIds={props.markedAsReadIds}
|
||||
settings={props.settings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
32
src/components/VersionFooter.tsx
Normal file
32
src/components/VersionFooter.tsx
Normal 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
|
||||
@@ -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',
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount, AccountManager } from 'applesauce-accounts'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { fetchBookmarks } from '../services/bookmarkService'
|
||||
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { loadReadingPosition, generateArticleIdentifier } from '../services/readingPositionService'
|
||||
import { fetchReadArticles } from '../services/libraryService'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
interface UseBookmarksDataParams {
|
||||
relayPool: RelayPool | null
|
||||
@@ -21,7 +17,6 @@ interface UseBookmarksDataParams {
|
||||
currentArticleCoordinate?: string
|
||||
currentArticleEventId?: string
|
||||
settings?: UserSettings
|
||||
eventStore?: IEventStore
|
||||
}
|
||||
|
||||
export const useBookmarksData = ({
|
||||
@@ -32,8 +27,7 @@ export const useBookmarksData = ({
|
||||
externalUrl,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings,
|
||||
eventStore
|
||||
settings
|
||||
}: UseBookmarksDataParams) => {
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [bookmarksLoading, setBookmarksLoading] = useState(true)
|
||||
@@ -42,8 +36,6 @@ export const useBookmarksData = ({
|
||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
||||
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
|
||||
const [markedAsReadIds, setMarkedAsReadIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const handleFetchContacts = useCallback(async () => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
@@ -133,93 +125,6 @@ export const useBookmarksData = ({
|
||||
handleFetchContacts()
|
||||
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
|
||||
|
||||
// Fetch marked-as-read articles
|
||||
useEffect(() => {
|
||||
const loadMarkedAsRead = async () => {
|
||||
if (!activeAccount || !relayPool || !eventStore || bookmarks.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const readArticles = await fetchReadArticles(relayPool, activeAccount.pubkey)
|
||||
|
||||
// Create a set of bookmark IDs that are marked as read
|
||||
const markedBookmarkIds = new Set<string>()
|
||||
|
||||
// For each read article, we need to match it to bookmark IDs
|
||||
for (const readArticle of readArticles) {
|
||||
// Add the event ID directly (for web bookmarks and legacy compatibility)
|
||||
markedBookmarkIds.add(readArticle.id)
|
||||
|
||||
// For nostr-native articles (kind:7 reactions), also add the coordinate format
|
||||
if (readArticle.eventId && readArticle.eventAuthor && readArticle.eventKind) {
|
||||
// Try to get the event from the eventStore to find the 'd' tag
|
||||
const event = eventStore.getEvent(readArticle.eventId)
|
||||
if (event) {
|
||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||
markedBookmarkIds.add(coordinate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMarkedAsReadIds(markedBookmarkIds)
|
||||
} catch (error) {
|
||||
console.warn('⚠️ [Bookmarks] Failed to load marked-as-read articles:', error)
|
||||
}
|
||||
}
|
||||
|
||||
loadMarkedAsRead()
|
||||
}, [relayPool, activeAccount, eventStore, bookmarks])
|
||||
|
||||
// Load reading positions for bookmarked articles (kind:30023)
|
||||
useEffect(() => {
|
||||
const loadPositions = async () => {
|
||||
if (!activeAccount || !relayPool || !eventStore || bookmarks.length === 0 || !settings?.syncReadingPosition) {
|
||||
return
|
||||
}
|
||||
|
||||
const positions = new Map<string, number>()
|
||||
|
||||
// Extract all kind:30023 articles from bookmarks
|
||||
const articles = bookmarks.flatMap(bookmark =>
|
||||
(bookmark.individualBookmarks || []).filter(item => item.kind === 30023)
|
||||
)
|
||||
|
||||
await Promise.all(
|
||||
articles.map(async (article) => {
|
||||
try {
|
||||
const dTag = article.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: article.pubkey,
|
||||
identifier: dTag
|
||||
})
|
||||
const articleUrl = `nostr:${naddr}`
|
||||
const identifier = generateArticleIdentifier(articleUrl)
|
||||
|
||||
const savedPosition = await loadReadingPosition(
|
||||
relayPool,
|
||||
eventStore,
|
||||
activeAccount.pubkey,
|
||||
identifier
|
||||
)
|
||||
|
||||
if (savedPosition && savedPosition.position > 0) {
|
||||
positions.set(article.id, savedPosition.position)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ [Bookmarks] Failed to load reading position for article:', error)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
setReadingPositions(positions)
|
||||
}
|
||||
|
||||
loadPositions()
|
||||
}, [bookmarks, activeAccount, relayPool, eventStore, settings?.syncReadingPosition])
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
@@ -232,9 +137,7 @@ export const useBookmarksData = ({
|
||||
lastFetchTime,
|
||||
handleFetchBookmarks,
|
||||
handleFetchHighlights,
|
||||
handleRefreshAll,
|
||||
readingPositions,
|
||||
markedAsReadIds
|
||||
handleRefreshAll
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,6 +11,18 @@ type UnlockHiddenTagsFn = typeof Helpers.unlockHiddenTags
|
||||
type HiddenContentSigner = Parameters<UnlockHiddenTagsFn>[1]
|
||||
type UnlockMode = Parameters<UnlockHiddenTagsFn>[2]
|
||||
|
||||
/**
|
||||
* Wrap a decrypt promise with a timeout to prevent hanging (using 30s timeout for bunker)
|
||||
*/
|
||||
function withDecryptTimeout<T>(promise: Promise<T>, timeoutMs = 30000): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Decrypt timeout after ${timeoutMs}ms`)), timeoutMs)
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
export async function collectBookmarksFromEvents(
|
||||
bookmarkListEvents: NostrEvent[],
|
||||
activeAccount: ActiveAccount,
|
||||
@@ -80,7 +92,8 @@ export async function collectBookmarksFromEvents(
|
||||
} catch {
|
||||
try {
|
||||
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -88,24 +101,26 @@ export async function collectBookmarksFromEvents(
|
||||
let decryptedContent: string | undefined
|
||||
try {
|
||||
if (hasNip44Decrypt(signerCandidate)) {
|
||||
decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(
|
||||
decryptedContent = await withDecryptTimeout((signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(
|
||||
evt.pubkey,
|
||||
evt.content
|
||||
)
|
||||
))
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!decryptedContent) {
|
||||
try {
|
||||
if (hasNip04Decrypt(signerCandidate)) {
|
||||
decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(
|
||||
decryptedContent = await withDecryptTimeout((signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(
|
||||
evt.pubkey,
|
||||
evt.content
|
||||
)
|
||||
))
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.log("[bunker] ❌ nip04.decrypt failed:", err instanceof Error ? err.message : String(err))
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -127,7 +142,7 @@ export async function collectBookmarksFromEvents(
|
||||
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
||||
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
||||
// Don't set latestContent to decrypted JSON - it's not user-facing content
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export const fetchBookmarks = async (
|
||||
}
|
||||
// Aggregate across events
|
||||
const maybeAccount = activeAccount as AccountWithExtension
|
||||
console.log('🔐 Account object:', {
|
||||
console.log('[bunker] 🔐 Account object:', {
|
||||
hasSignEvent: typeof maybeAccount?.signEvent === 'function',
|
||||
hasSigner: !!maybeAccount?.signer,
|
||||
accountType: typeof maybeAccount,
|
||||
@@ -102,12 +102,19 @@ export const fetchBookmarks = async (
|
||||
signerCandidate = maybeAccount.signer
|
||||
}
|
||||
|
||||
console.log('🔑 Signer candidate:', !!signerCandidate, typeof signerCandidate)
|
||||
console.log('[bunker] 🔑 Signer candidate:', !!signerCandidate, typeof signerCandidate)
|
||||
if (signerCandidate) {
|
||||
console.log('🔑 Signer has nip04:', hasNip04Decrypt(signerCandidate))
|
||||
console.log('🔑 Signer has nip44:', hasNip44Decrypt(signerCandidate))
|
||||
console.log('[bunker] 🔑 Signer has nip04:', hasNip04Decrypt(signerCandidate))
|
||||
console.log('[bunker] 🔑 Signer has nip44:', hasNip44Decrypt(signerCandidate))
|
||||
}
|
||||
const { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags } = await collectBookmarksFromEvents(
|
||||
|
||||
// Debug relay connectivity for bunker relays
|
||||
try {
|
||||
const urls = Array.from(relayPool.relays.values()).map(r => ({ url: r.url, connected: (r as unknown as { connected?: boolean }).connected }))
|
||||
console.log('[bunker] Relay connections:', urls)
|
||||
} catch (err) { console.warn('[bunker] Failed to read relay connections', err) }
|
||||
|
||||
const { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags } = await collectBookmarksFromEvents(
|
||||
bookmarkListEvents,
|
||||
activeAccount,
|
||||
signerCandidate
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
import { ReadItem } from './readsService'
|
||||
|
||||
export interface MeCache {
|
||||
highlights: Highlight[]
|
||||
bookmarks: Bookmark[]
|
||||
reads: ReadItem[]
|
||||
links: ReadItem[]
|
||||
readArticles: BlogPostPreview[]
|
||||
reads?: ReadItem[]
|
||||
links?: ReadItem[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
@@ -22,14 +24,12 @@ export function setCachedMeData(
|
||||
pubkey: string,
|
||||
highlights: Highlight[],
|
||||
bookmarks: Bookmark[],
|
||||
reads: ReadItem[],
|
||||
links: ReadItem[] = []
|
||||
readArticles: BlogPostPreview[]
|
||||
): void {
|
||||
meCache.set(pubkey, {
|
||||
highlights,
|
||||
bookmarks,
|
||||
reads,
|
||||
links,
|
||||
readArticles,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
@@ -48,10 +48,10 @@ export function updateCachedBookmarks(pubkey: string, bookmarks: Bookmark[]): vo
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCachedReads(pubkey: string, reads: ReadItem[]): void {
|
||||
export function updateCachedReadArticles(pubkey: string, readArticles: BlogPostPreview[]): void {
|
||||
const existing = meCache.get(pubkey)
|
||||
if (existing) {
|
||||
meCache.set(pubkey, { ...existing, reads, timestamp: Date.now() })
|
||||
meCache.set(pubkey, { ...existing, readArticles, timestamp: Date.now() })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
26
src/services/nostrConnect.ts
Normal file
26
src/services/nostrConnect.ts
Normal 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',
|
||||
]
|
||||
}
|
||||
|
||||
@@ -56,8 +56,6 @@ export interface UserSettings {
|
||||
paragraphAlignment?: 'left' | 'justify' // default: justify
|
||||
// Reading position sync
|
||||
syncReadingPosition?: boolean // default: false (opt-in)
|
||||
autoScrollToPosition?: boolean // default: true (auto-scroll to last reading position)
|
||||
autoMarkAsReadAt100?: boolean // default: false (auto-mark as read when reaching 100% for 2 seconds)
|
||||
}
|
||||
|
||||
export async function loadSettings(
|
||||
|
||||
@@ -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.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -216,72 +216,7 @@
|
||||
.mark-as-read-btn:hover:not(:disabled) { background: var(--color-border); border-color: var(--color-text-muted); transform: translateY(-1px); }
|
||||
.mark-as-read-btn:active:not(:disabled) { transform: translateY(0); }
|
||||
.mark-as-read-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.mark-as-read-btn svg { font-size: 1.1rem; transition: transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
||||
|
||||
/* Fancy Mark as Read animation */
|
||||
@keyframes markAsReadSuccess {
|
||||
0% {
|
||||
background: var(--color-bg-elevated);
|
||||
border-color: var(--color-border-subtle);
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
}
|
||||
10% {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 0 8px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
25% {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
65% {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
100% {
|
||||
background: #6b7280;
|
||||
border-color: #6b7280;
|
||||
color: white;
|
||||
transform: scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes iconSpin {
|
||||
0% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
15% {
|
||||
transform: rotate(0deg) scale(1.2);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(360deg) scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.mark-as-read-btn.animating {
|
||||
animation: markAsReadSuccess 2.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mark-as-read-btn.animating svg {
|
||||
animation: iconSpin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
.mark-as-read-btn.marked {
|
||||
background: #6b7280;
|
||||
border-color: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
.mark-as-read-btn svg { font-size: 1.1rem; }
|
||||
@media (max-width: 768px) {
|
||||
.reader {
|
||||
max-width: 100%;
|
||||
|
||||
@@ -211,12 +211,3 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Reading progress filters in bookmarks sidebar - add top border, remove bottom border to avoid double border with view-mode-controls */
|
||||
.reading-progress-filters-wrapper {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.reading-progress-filters-wrapper .bookmark-filters {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
|
||||
36
src/utils/debugBus.ts
Normal file
36
src/utils/debugBus.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type DebugLevel = 'info' | 'warn' | 'error'
|
||||
|
||||
export interface DebugLogEntry {
|
||||
ts: number
|
||||
level: DebugLevel
|
||||
source: string
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
type Listener = (entry: DebugLogEntry) => void
|
||||
|
||||
const listeners = new Set<Listener>()
|
||||
const buffer: DebugLogEntry[] = []
|
||||
const MAX_BUFFER = 300
|
||||
|
||||
export const DebugBus = {
|
||||
log(level: DebugLevel, source: string, message: string, data?: unknown): void {
|
||||
const entry: DebugLogEntry = { ts: Date.now(), level, source, message, data }
|
||||
buffer.push(entry)
|
||||
if (buffer.length > MAX_BUFFER) buffer.shift()
|
||||
listeners.forEach(l => {
|
||||
try { l(entry) } catch (err) { console.warn('[DebugBus] listener error:', err) }
|
||||
})
|
||||
},
|
||||
info(source: string, message: string, data?: unknown): void { this.log('info', source, message, data) },
|
||||
warn(source: string, message: string, data?: unknown): void { this.log('warn', source, message, data) },
|
||||
error(source: string, message: string, data?: unknown): void { this.log('error', source, message, data) },
|
||||
subscribe(listener: Listener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
snapshot(): DebugLogEntry[] { return buffer.slice() }
|
||||
}
|
||||
|
||||
|
||||
8
src/vite-env.d.ts
vendored
8
src/vite-env.d.ts
vendored
@@ -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
|
||||
|
||||
11
vercel.json
11
vercel.json
@@ -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"
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user