Compare commits

..

857 Commits

Author SHA1 Message Date
Gigi
fdb22491a2 chore: bump version to 0.11.0 2025-11-07 23:18:28 +01:00
Gigi
ff2cb41a3c Merge pull request #45 from dergigi/fix-a-path-regression
fix: gate /a/:naddr rewrite to crawlers to prevent refresh redirect
2025-11-07 23:16:28 +01:00
Gigi
5a5cfb7edd fix: use sentinel query param for OG redirect to preserve /a/:naddr paths
- Change OG HTML redirect to use ?_spa=1 query param instead of redirecting to /
- Simplify vercel.json rewrites: serve SPA when _spa=1, otherwise serve OG HTML
- Remove brittle user-agent detection patterns
- Add cleanup effect to strip _spa param from URL after SPA loads
- Fixes refresh redirect regression while maintaining OG preview support
2025-11-07 23:11:41 +01:00
Gigi
63a820faf8 docs: remove Development section from README 2025-11-07 23:00:35 +01:00
Gigi
0bfa0a2e7b fix: gate /a/:naddr rewrite to crawlers to prevent refresh redirect
- Add conditional rewrite rules in vercel.json to only serve OG HTML to crawlers
- Add ?og=1 query parameter override for manual testing
- Document ?og=1 testing path in README
- Fixes regression where browser refresh on /a/:naddr redirected to root
2025-11-07 23:00:12 +01:00
Gigi
6445445e5d Merge pull request #44 from dergigi/reader-view-adjustments
Improve reader view typography and add configurable link colors
2025-11-07 22:43:03 +01:00
Gigi
85d256b47b fix: update preview link color when link color setting changes
- Set --color-link directly in preview inline styles based on current theme
- Preview now shows the correct link color for the active theme
- Link color updates immediately when changed in settings
2025-11-07 22:39:22 +01:00
Gigi
55d14d9e77 fix: store separate link colors for dark and light themes
- Restore linkColorDark and linkColorLight settings
- Single color picker UI updates the appropriate theme's color based on current theme
- Dark theme color picker updates linkColorDark
- Light theme color picker updates linkColorLight
- Separate values applied to --color-link-dark and --color-link-light CSS variables
- Matches the pattern used for --color-primary
2025-11-07 22:38:17 +01:00
Gigi
f41cb4b17e refactor: use single link color setting with theme-aware palette
- Revert to single linkColor setting (removed linkColorDark/Light)
- Add theme-specific color palettes: LINK_COLORS_DARK and LINK_COLORS_LIGHT
- Color picker shows appropriate palette based on current theme
- Single link color value applied to both dark and light CSS variables
- Dark theme shows lighter colors (sky-400, cyan-400, etc.)
- Light theme shows darker colors (blue-500, indigo-500, etc.)
2025-11-07 22:35:49 +01:00
Gigi
286d5df5b8 feat: rename link color to --color-link and add dark/light theme support
- Rename CSS variable from --link-color to --color-link
- Add linkColorDark and linkColorLight settings (replacing single linkColor)
- Add --color-link to dark and light theme CSS variables
- Use CSS var() references to automatically switch based on theme
- Update settings UI to show separate color pickers for dark and light themes
- Default: dark=#38bdf8 (sky-400), light=#3b82f6 (blue-500)
- Update all CSS references to use new variable name
2025-11-07 22:34:46 +01:00
Gigi
36659ad2cc fix: remove unused LINK_COLORS import from ColorPicker 2025-11-07 22:32:09 +01:00
Gigi
ee7e88bc62 refactor: use relative path for preview link to work on localhost 2025-11-07 22:31:15 +01:00
Gigi
120409dc7b refactor: move link to 3rd paragraph and remove 4th paragraph from preview 2025-11-07 22:30:18 +01:00
Gigi
b2aa9c4179 refactor: update preview link to use real article link instead of sample text 2025-11-07 22:29:41 +01:00
Gigi
0dc9e37ff4 chore: change default link color to Sky Blue (#38bdf8) 2025-11-07 22:28:12 +01:00
Gigi
5181176260 feat: add configurable link color setting for article links
- Add linkColor field to UserSettings interface
- Add LINK_COLORS palette with 6 link-appropriate colors
- Update ColorPicker to accept custom color arrays
- Add Link Color setting UI after Reading Font setting
- Apply link color as CSS variable in useSettings hook
- Update reader CSS to use --link-color variable instead of --color-primary
- Add link color preview in settings preview section
- Default to indigo-400 (#818cf8) for better visibility on dimmed displays
2025-11-07 22:27:16 +01:00
Gigi
3b4f3e8161 feat: improve link visibility in dark mode with lighter indigo-400 color
- Change primary color from indigo-500 to indigo-400 (#818cf8) in dark mode
- Improves readability on dimmed mobile displays
- Update both theme-dark and theme-system dark mode variants
2025-11-07 22:20:33 +01:00
Gigi
2323427dbd style: further increase top margin on headlines in reader view 2025-11-07 22:15:16 +01:00
Gigi
43e6455668 style: further increase top margin on headlines in reader view 2025-11-07 22:14:53 +01:00
Gigi
7b3f36b0bb style: increase top margin on headlines in reader view 2025-11-07 22:14:11 +01:00
Gigi
feafe4a07b style: increase paragraph spacing in reader view to 1.5rem 2025-11-07 22:13:18 +01:00
Gigi
ed1a4e489e style: increase paragraph spacing in reader view to 1rem 2025-11-07 22:12:43 +01:00
Gigi
4ab34456d1 Merge pull request #43 from dergigi/more-md-tests
Add basic markdown syntax test suite
2025-11-07 21:13:55 +01:00
Gigi
54ed0c547f docs: update source links to point to specific files
Update source links in each basic markdown test file to link to the specific file path rather than the directory.
2025-11-07 21:08:50 +01:00
Gigi
98291f0904 docs: add source links to basic markdown test files
Add GitHub source directory links at the end of each basic syntax test file for easy reference.
2025-11-07 21:08:23 +01:00
Gigi
f0b3ad239c feat: add basic markdown syntax test files
Add comprehensive test suite for basic markdown syntax features:
- basic-headings.md: All heading levels and setext syntax
- basic-paragraphs-line-breaks.md: Paragraph separation and line breaks
- basic-emphasis.md: Bold and italic formatting
- basic-blockquotes.md: Blockquotes with nested content
- basic-lists.md: Ordered and unordered lists with nesting
- basic-code.md: Inline code and code blocks
- basic-horizontal-rules.md: Horizontal rule variants
- basic-links-and-images.md: Links and images with various syntax
- basic-escaping.md: Character escaping
- basic-index.md: Index of all test files

All files follow the Markdown Guide's Basic Syntax specification.
2025-11-07 21:05:40 +01:00
Gigi
7d7e60c226 Merge pull request #42 from dergigi/fix-opengraph-try3
Fix OpenGraph metadata fetching and add Redis-backed caching
2025-11-07 19:59:19 +01:00
Gigi
55ea43e103 chore: remove debug logging from article metadata fetching
- Remove console.log statements from fetchArticleMetadataViaRelays
- Remove console.log statements from article-og handler
- Keep error logging (console.error) for debugging issues
2025-11-07 19:53:03 +01:00
Gigi
631d65be21 perf: implement early-return article fetch with micro-wait author
- Add fetchFirstEvent helper that resolves on first event (not waiting for complete)
- Add fetchAuthorProfile helper for DRY author fetching
- Refactor fetchArticleMetadataViaRelays to:
  - Return immediately when first article event arrives (no 7s wait)
  - Fetch author profile with 400ms micro-wait (connections already warm)
  - Optional hedge: try again with 600ms if first attempt fails
  - Fallback to pubkey prefix if profile not found
- Add logging to track article fetch and author resolution source

This dramatically improves first-request latency by returning as soon as
any relay responds, while still attempting to get author name with
minimal additional delay (400-600ms total).
2025-11-07 19:48:48 +01:00
Gigi
76b9797c41 feat: add article tags and image alt text to OG metadata
- Add tags field to ArticleMetadata type (extracted from 't' tags)
- Add imageAlt field to ArticleMetadata type (uses title as fallback)
- Extract 't' tags from article events in fetchArticleMetadataViaRelays
- Generate multiple article:tag meta tags in HTML output
- Add og:image:alt meta tag for better accessibility

This improves SEO and social media previews by including
article categorization tags and image descriptions.
2025-11-07 19:39:02 +01:00
Gigi
4fc4971345 refactor: simplify OG fetch - remove timeout wrapper and background refresh
Remove Promise.race timeout wrapper and let relay fetch use its natural
timeouts (7s for article, 5s for profile). Remove background refresh
complexity entirely.

Flow is now simple:
1. Check Redis cache
2. If miss, fetch from relays (up to 7s)
3. Cache result
4. Subsequent requests hit cache

First request may take 7-8 seconds, but after that it's cached and fast.
Much simpler and more reliable.
2025-11-07 19:30:36 +01:00
Gigi
f2bc0c1da1 feat: enhance background refresh logging
Add more detailed logging to diagnose background refresh issues:
- Log the exact URL being called
- Log whether secret is present
- Better error handling for non-JSON responses
- More detailed error messages

This will help identify if the refresh endpoint is being called
and why we're not seeing logs from it.
2025-11-07 19:28:04 +01:00
Gigi
f486de1597 fix: increase relay fetch timeout from 3s to 5s
Relays can be slow, especially on first connection. Increase timeout
to 5 seconds to give relays more time to respond before falling back
to default metadata.
2025-11-07 19:23:47 +01:00
Gigi
b0e43ccee7 feat: add comprehensive logging for background refresh
Add detailed logging to verify background refresh is working:
- Log when background refresh is triggered
- Log the response status and result from refresh endpoint
- Log errors if refresh fetch fails
- Add logging in refresh endpoint to track:
  - When refresh starts
  - When metadata is found
  - When metadata is cached
  - Any errors during refresh

This will help diagnose if background refresh is actually
populating the Redis cache after timeouts.
2025-11-07 19:19:47 +01:00
Gigi
66db9cd23f refactor: remove gateway fetch, use relays with short timeout
- Remove gateway HTTP fetch entirely
- Fetch directly from relays on cache miss with 3s timeout
- If relay fetch succeeds: cache and return article metadata
- If relay fetch fails/times out: return default OG and trigger background refresh
- Update logging to reflect relay-first strategy
- Keep background refresh endpoint for eventual consistency

This simplifies the code and removes dependency on unreliable gateway,
while ensuring fast responses and eventual correctness via background refresh.
2025-11-07 19:15:17 +01:00
Gigi
c2552d2e34 feat: add detailed logging for gateway metadata fetch
Add comprehensive logging to diagnose why gateway fetch is failing:
- Log the exact URL being fetched
- Log HTTP status on failure
- Log response length on success
- Log which OG tags were found/missing
- Add detailed error information

This will help identify if the issue is:
- Fetch timeout/failure
- Missing OG tags in response
- Regex pattern mismatch
2025-11-07 19:08:08 +01:00
Gigi
56547b3526 fix: improve Redis initialization and add debugging for metadata fetch
- Support both KV_* and UPSTASH_* env var names for Redis
- Add error handling for Redis get operations
- Add console logging to track cache hits/misses and gateway fetches
- Fix syntax error in background refresh trigger

This will help diagnose why article metadata isn't being returned correctly.
2025-11-07 19:05:25 +01:00
Gigi
70ac7dce95 fix: add .js extensions to ESM imports for Vercel compatibility
ESM requires explicit file extensions in import paths. Add .js
extensions to all relative imports in API files and services,
even though source files are .ts (they compile to .js).

This fixes ERR_MODULE_NOT_FOUND errors on Vercel.
2025-11-07 19:01:01 +01:00
Gigi
f982781dd8 fix: move OG service files to api/services for Vercel compatibility
- Move ogStore, ogHtml, and articleMeta from src/services/ to api/services/
- Update imports in article-og.ts and article-og-refresh.ts
- Update import paths in articleMeta.ts (lib/profile and src/config/relays)
- Remove old files from src/services/
- Clean up ESLint config to only reference api/**/*.ts

This fixes the ERR_MODULE_NOT_FOUND error on Vercel by ensuring
serverless functions can access the service modules.
2025-11-07 18:52:30 +01:00
Gigi
a73c7db9d3 fix: resolve linting and type errors
- Add ESLint override for Node.js environment in api/ and services files
- Fix WebSocket redeclaration warning in articleMeta.ts
- Fix import path for profile utility (../../lib/profile)
- Install @types/ws for TypeScript support
- Remove unused @ts-expect-error directive
2025-11-07 18:46:24 +01:00
Gigi
c81b7b89d1 feat: implement storage-backed OG previews with Upstash Redis
- Add ogStore service for Redis get/set operations
- Extract shared logic: ogHtml (generateHtml, escapeHtml) and articleMeta (relay/gateway fetching)
- Refactor article-og endpoint to read from Redis, try gateway on miss, trigger background refresh
- Add article-og-refresh endpoint for background relay fetching and caching
- Update vercel.json with refresh function config
- Remove WebSocket dependencies from main OG endpoint for faster crawler responses
2025-11-07 18:41:08 +01:00
Gigi
971b672591 chore: add .vercel to gitignore 2025-11-07 17:32:09 +01:00
Gigi
8b30ffd5e7 Merge pull request #41 from dergigi/fix-opengraph-try2
Fix OpenGraph metadata generation for article naddrs
2025-11-07 17:31:26 +01:00
Gigi
3975ef15dd chore(runtime): pin Node 22.x via package.json engines 2025-11-07 17:28:01 +01:00
Gigi
61e8517137 fix(vercel): remove functions.runtime and pin Node 18 via package.json engines 2025-11-07 17:26:35 +01:00
Gigi
b0d30946eb fix(vercel): add version=2 to vercel.json so functions.runtime is recognized 2025-11-07 17:21:59 +01:00
Gigi
c0cfd41e76 perf(og): increase relay request timeouts (7s article, 5s profile) to improve reliability 2025-11-07 17:15:48 +01:00
Gigi
be7b6c2cfb chore(vercel): pin Node runtime and increase maxDuration for article OG function 2025-11-07 17:15:30 +01:00
Gigi
afd27032e0 chore(api): add ws polyfill and dependency for RelayPool in serverless 2025-11-07 17:15:17 +01:00
Gigi
696fe42bee feat(og): always render OG meta for /a/:naddr and include redirect script for browsers 2025-11-07 17:14:55 +01:00
Gigi
1a0370aef9 Merge pull request #40 from dergigi/fix-500
Fix serverless import resolution for profile helpers
2025-11-07 16:48:11 +01:00
Gigi
ed3e8e9799 refactor(shared): move profile helpers to lib and import from API and src\n\n- Fix serverless import resolution by avoiding src/** in API\n- Keep code DRY with single shared module 2025-11-07 16:43:02 +01:00
Gigi
f590ff56ec fix(api): inline profile display name helper to avoid src import in serverless 2025-11-07 16:39:58 +01:00
Gigi
cc68980cdb Merge pull request #39 from dergigi/fix-opengraph-stuff
Fix OpenGraph meta tags for article URLs
2025-11-07 16:36:11 +01:00
Gigi
d83708ceb3 fix: remove user-agent restriction from article OG rewrite
All /a/:naddr requests now route to article-og handler, which handles crawler detection internally. This ensures social media crawlers always receive proper OpenGraph meta tags.
2025-11-07 16:10:31 +01:00
Gigi
507aa27d29 chore: remove trailing newline from tables.md test file 2025-11-07 16:04:50 +01:00
Gigi
1d4c5a7393 docs: add footnotes explaining Bitcoin frequency notation 2025-11-07 15:43:25 +01:00
Gigi
64fd2cc0d3 test: add real-world table from Bitcoin is Time article 2025-11-07 15:36:55 +01:00
Gigi
b6182b3c11 Merge pull request #38 from dergigi/render-tables
Add markdown table rendering support
2025-11-07 15:22:42 +01:00
Gigi
e7e02dd129 fix: remove quotation marks from title in publish-markdown script 2025-11-07 15:16:45 +01:00
Gigi
d76bfb66bb docs: add test account npub and profile link to .env.example
Add documentation about the test account used for publishing markdown test documents, including the npub and profile link to Marky Markdown Testerson's writings.
2025-11-07 15:14:31 +01:00
Gigi
024e62118b docs: add test account npub and profile link to publish-markdown.sh
Add documentation about the test account used for publishing markdown test documents, including the npub and profile link to Marky Markdown Testerson's writings.
2025-11-07 15:13:46 +01:00
Gigi
ed93675d8d chore: remove misplaced .env.example from scripts directory
The publish-markdown.sh script expects .env in the project root, not in scripts/, so this example file was in the wrong location.
2025-11-07 15:12:58 +01:00
Gigi
2089208448 docs: add explanatory paragraphs to each test table
- Add descriptive text before each table explaining what it tests
- Improve documentation for table test cases
- Help developers understand the purpose of each test scenario
2025-11-07 15:08:15 +01:00
Gigi
4fd8a0b18f test: extend table with numbers to 21 rows
- Add rows 4-21 with decreasing scores
- Test table styling with longer content
2025-11-07 15:06:46 +01:00
Gigi
48213fa584 style: add subtle table styling that matches app theme
- Add comprehensive table styles with borders, padding, and spacing
- Style table headers with elevated background
- Add subtle row striping for better readability
- Support text alignment (left, center, right)
- Maintain mobile responsiveness with horizontal scrolling
- Use theme CSS variables for consistent theming across light/dark modes
2025-11-07 15:04:03 +01:00
Gigi
eaabad98c2 fix: use @filename syntax to read markdown content from file instead of stdin 2025-11-07 15:00:52 +01:00
Gigi
31bcd61aae feat: add npm script for publishing test markdown files 2025-11-07 14:58:02 +01:00
Gigi
f6c00f4c20 docs: clarify that NOSTR_SECRET_KEY should be a test account key 2025-11-07 14:56:19 +01:00
Gigi
0ce9f76f3b fix: look for .env file in scripts directory instead of project root 2025-11-07 14:53:52 +01:00
Gigi
781cade78b docs: update script usage to include npm command 2025-11-07 14:49:09 +01:00
Gigi
15e91414da feat: add npm script for publishing markdown files 2025-11-07 14:49:02 +01:00
Gigi
453a4f48ca refactor: move .env to scripts directory and update documentation 2025-11-07 14:47:57 +01:00
Gigi
a91aa87ef9 refactor: move .env file to scripts directory 2025-11-07 14:47:49 +01:00
Gigi
52be65e382 chore: remove .env from git tracking, keep .env.example 2025-11-07 14:47:26 +01:00
Gigi
142995e83c fix: improve .env file parsing to handle quoted values 2025-11-07 14:47:12 +01:00
Gigi
03a7f91961 feat: add .env support for RELAYS and NOSTR_SECRET_KEY in publish-markdown script 2025-11-07 14:47:05 +01:00
Gigi
496b329e82 fix: improve command construction in publish-markdown script 2025-11-07 14:45:49 +01:00
Gigi
a4c8a7d68b feat: add script to publish markdown test files to Nostr using nak 2025-11-07 14:45:42 +01:00
Gigi
8f90de01fd test: add markdown table test file 2025-11-07 14:44:04 +01:00
Gigi
341fbd8c2a Merge pull request #37 from dergigi/njump-to
Change default nostr gateway to njump.to
2025-11-06 19:15:29 +01:00
Gigi
01722cff38 feat: change default nostr gateway to njump.to 2025-11-06 19:12:59 +01:00
Gigi
a7a7857219 docs: update CHANGELOG for v0.10.33 2025-11-05 23:09:11 +01:00
Gigi
104332fd94 chore: bump version to 0.10.33 2025-11-05 23:08:14 +01:00
Gigi
e736c9f5b9 Merge pull request #36 from dergigi/mobile-highlighting
Fix mobile text selection detection for highlighting
2025-11-05 23:07:33 +01:00
Gigi
103e104cb2 chore: remove unused React import from VideoEmbedProcessor 2025-11-05 23:05:14 +01:00
Gigi
5389397e9b fix(mobile): use selectionchange event for immediate text selection detection
Replace mouseup/touchend handlers with selectionchange event listener
for more reliable mobile text selection detection. This fixes the issue
where the highlight button required an extra tap to become active on
mobile devices.

- Extract selection checking logic into shared checkSelection function
- Use selectionchange event with requestAnimationFrame for immediate detection
- Remove onMouseUp and onTouchEnd props from VideoEmbedProcessor
- Simplify code by eliminating separate mouse/touch event handlers
2025-11-05 23:04:38 +01:00
Gigi
54cba2beed Merge pull request #35 from dergigi/fuzzy2
perf(highlights): optimize highlight application performance
2025-11-03 01:38:07 +01:00
Gigi
da76cb247c perf(highlights): optimize highlight application with multiple improvements
- perf: collect text nodes once instead of per highlight (O(n×m) -> O(n+m))
- fix: correct normalized index mapping algorithm for whitespace handling
- feat: allow nested mark elements for overlapping highlights
- perf: add caching for highlighted HTML results with TTL and size limits
2025-11-03 01:34:02 +01:00
Gigi
9b4a7b6263 docs: update CHANGELOG for v0.10.32 2025-11-02 23:54:07 +01:00
Gigi
e6f98d69e7 chore: bump version to 0.10.32 2025-11-02 23:53:37 +01:00
Gigi
3785d34e8f Merge pull request #34 from dergigi/show-npubs-and-nprofiles-as-names
Standardize npub/nprofile display and improve profile resolution
2025-11-02 23:53:11 +01:00
Gigi
a30943686e fix: correct Helpers import path in nostrUriResolver
Helpers should be imported from 'applesauce-core', not 'applesauce-core/helpers'
2025-11-02 23:43:49 +01:00
Gigi
d4b78d9484 refactor: standardize applesauce helpers for npub/nprofile detection
Replace manual type checking and pubkey extraction with getPubkeyFromDecodeResult helper:
- Update getNostrUriLabel to use helper instead of manual npub/nprofile cases
- Update replaceNostrUrisInMarkdownWithProfileLabels to use helper
- Update addLoadingClassToProfileLinks to use helper
- Simplify NostrMentionLink by removing redundant type checks
- Update Bookmarks.tsx to use helper for profile pubkey extraction

This eliminates duplicate logic and ensures consistent handling of npub/nprofile
across the codebase using applesauce helpers.
2025-11-02 23:42:59 +01:00
Gigi
66de230f66 refactor: standardize @ prefix handling and improve npub/nprofile display
- Fix getNpubFallbackDisplay to return names without @ prefix
- Update all call sites to consistently add @ when rendering mentions
- Fix incomplete error handling in getNpubFallbackDisplay catch block
- Add nprofile support to addLoadingClassToProfileLinks
- Extract shared isProfileInCacheOrStore utility to eliminate duplicate loading state checks
- Update ResolvedMention and NostrMentionLink to use shared utility

This ensures consistent @ prefix handling across all profile display contexts
and eliminates code duplication for profile loading state detection.
2025-11-02 23:36:46 +01:00
Gigi
8cb77864bc fix: resolve TypeScript errors in nostrUriResolver.tsx
- Add explicit type annotations for decoded variable and npub parameter
- Use switch statement for better type narrowing when checking npub type
2025-11-02 23:13:37 +01:00
Gigi
ea3c130cc3 chore: remove console.log debug output 2025-11-02 23:11:40 +01:00
Gigi
f417ed8210 fix: resolve race condition in profile label updates
Fix regression where npubs/nprofiles weren't being replaced with profile names.
The issue was a race condition: loading state was cleared immediately, but labels
were applied asynchronously via RAF, causing the condition check to fail.

Changes:
- Apply profile labels immediately when profiles resolve, instead of batching via RAF
- Update condition check to explicitly handle undefined loading state (isLoading !== true)
- This ensures labels are available in the Map when loading becomes false
2025-11-02 23:08:20 +01:00
Gigi
945b9502bc fix: preserve profile labels from pending updates in useEffect
- Fix merge logic in useEffect that syncs profileLabels state
- Previously was overwriting newly resolved labels when initialLabels changed
- Now preserves existing labels and only adds missing ones from initialLabels
- This fixes the issue where profileLabels was being reset to 0 after applyPendingUpdates
- Add debug logs to track when useEffect sync runs
2025-11-02 23:05:16 +01:00
Gigi
4a432bac8d debug: add logs to trace profile labels batching
- Add debug logs in applyPendingUpdates to see when updates are applied
- Add debug logs in scheduleBatchedUpdate to track RAF scheduling
- Add debug logs when adding to pending updates
- Add debug logs for profileLabelsKey computation to verify state updates
- Will help diagnose why profileLabels stays at size 0 despite profiles resolving
2025-11-02 23:03:33 +01:00
Gigi
541d30764e fix: extract HTML after ReactMarkdown renders processedMarkdown
- Separate markdown processing from HTML extraction
- Add useEffect that watches processedMarkdown and extracts HTML
- Use double RAF to ensure ReactMarkdown has finished rendering before extracting
- This fixes the issue where resolved profile names weren't updating in the article view
- Add debug logs to track HTML extraction after processedMarkdown changes
2025-11-02 23:02:26 +01:00
Gigi
7c2b373254 debug: add comprehensive shimmer debug logs
- Add [shimmer-debug] prefixed logs to trace loading state flow
- Log when profiles are marked as loading in useProfileLabels
- Log when loading state is cleared after profile resolution
- Log detailed post-processing steps in addLoadingClassToProfileLinks
- Log markdown replacement decisions in replaceNostrUrisInMarkdownWithProfileLabels
- Log HTML changes and class counts in useMarkdownToHTML
- All logs use [shimmer-debug] prefix for easy filtering
2025-11-02 23:00:32 +01:00
Gigi
0bf33f1a7d debug: add log to verify post-processing adds loading class
- Log when loading class is added to profile links during post-processing
- Will help verify the shimmer is being applied correctly
2025-11-02 22:59:28 +01:00
Gigi
1eca19154d fix: post-process rendered HTML to add loading class to profile links
- HTML inside markdown links doesn't render correctly with rehype-raw
- Instead, post-process rendered HTML to find profile links (/p/npub...)
- Decode npub to get pubkey and check loading state
- Add profile-loading class directly to <a> tags
- This ensures the loading shimmer appears on the actual link element
2025-11-02 22:57:41 +01:00
Gigi
fd2d4d106f fix: check loading state before resolved labels to show shimmer
- Check loading state FIRST before checking for resolved labels
- Profiles have fallback labels immediately, which caused early return
- Now loading shimmer will show even when fallback label exists
- This fixes the issue where shimmer never appeared
2025-11-02 22:55:14 +01:00
Gigi
d41cbb5305 refactor: use pubkey (hex) as Map key instead of encoded nprofile/npub strings
- Changed useProfileLabels to use pubkey as key for canonical identification
- Updated replaceNostrUrisInMarkdownWithProfileLabels to extract pubkey and use it for lookup
- This fixes the key mismatch issue where different nprofile encodings map to the same pubkey
- Multiple nprofile strings can refer to the same pubkey (different relay hints)
- Using pubkey as key is the Nostr standard way to identify profiles
2025-11-02 22:52:49 +01:00
Gigi
f57a4d4f1b debug: add key mismatch detection to identify format differences
- Check if encoded value from regex matches Map keys
- Log full comparison when mismatch detected
- Will help identify if regex capture group format differs from Map storage format
2025-11-02 22:50:05 +01:00
Gigi
4b03f32d21 debug: add logs to compare encoded format between markdown extraction and Map keys
- Log the exact encoded value being processed
- Log sample of Map keys for comparison
- Will help identify format mismatch between markdown and Map storage
2025-11-02 22:48:52 +01:00
Gigi
8f1288b1a2 debug: add detailed logs to nostr URI resolver for loading state detection
- Log when replacement function is called with Map sizes
- Log all loading keys in the Map
- Log detailed info for each npub/nprofile found: type, hasLoading, isLoading
- Will help identify if encoded IDs don't match or loading state isn't detected
2025-11-02 22:47:43 +01:00
Gigi
7ec87b66d8 fix: reduce markdown reprocessing to prevent flicker
- Use stable string keys instead of Map objects as dependencies
- Only clear rendered HTML when markdown content actually changes
- Use refs to access latest Map values without triggering re-renders
- Prevents excessive markdown reprocessing on every profile update
- Should significantly reduce screen flickering during profile resolution
2025-11-02 22:42:03 +01:00
Gigi
27dde5afa2 debug: add common prefix [profile-loading-debug] to all debug logs
All profile loading related debug logs now have the common prefix for easy filtering in console.
2025-11-02 22:40:08 +01:00
Gigi
3b2732681d fix: remove unused variables in debug log filter 2025-11-02 22:39:24 +01:00
Gigi
51a4b545e9 debug: add comprehensive logging for profile loading states and article refresh
- Add logs to useProfileLabels for loading state tracking
- Add logs to markdown processing to track when content is cleared/reprocessed
- Add logs to article loader for refresh behavior
- Add logs to ResolvedMention and NostrMentionLink for loading detection
- Add logs to nostr URI resolver when loading state is shown
- All logs prefixed with meaningful tags for easy filtering
2025-11-02 22:39:07 +01:00
Gigi
7e5972a6e2 fix: correct Hooks import path from applesauce-react
- Import Hooks from 'applesauce-react' instead of 'applesauce-react/hooks'
- Fixes TypeScript errors in ResolvedMention and NostrMentionLink
2025-11-02 22:30:37 +01:00
Gigi
156cf31625 feat: add loading states for profile lookups in articles
- Extend useProfileLabels to return loading Map alongside labels
- Update markdown replacement to show loading indicator for unresolved profiles
- Add loading state detection to ResolvedMention and NostrMentionLink components
- Add CSS animation for profile-loading class with opacity pulse
- Respect prefers-reduced-motion for accessibility
2025-11-02 22:29:35 +01:00
Gigi
ee7df54d87 refactor(profiles): standardize profile name extraction and improve code quality
- Create centralized profileUtils.ts with extractProfileDisplayName function
- Standardize profile name priority order: name || display_name || nip05 || fallback
- Replace duplicate profile parsing code across 6+ locations
- Add request deduplication to fetchProfiles to prevent duplicate relay requests
- Simplify RAF batching logic in useProfileLabels with helper functions
- Fix RichContent.tsx error when content.split() produces undefined parts
- Remove unused eventCount variable in profileService
- Fix React Hook dependency warnings by wrapping scheduleBatchedUpdate in useCallback
2025-11-02 22:21:43 +01:00
Gigi
15c016ad5e chore: remove console.log debug output from profile services and hooks 2025-11-02 22:07:33 +01:00
Gigi
b0574d3f8e fix: resolve React hooks exhaustive-deps linter warning
- Capture refs at effect level and use in cleanup function
- This satisfies react-hooks/exhaustive-deps rule for cleanup functions
- Prevents stale closure issues while keeping code clean
2025-11-02 22:05:08 +01:00
Gigi
4fd6605666 fix: ensure profile labels always update correctly
- Sync state when initialLabels changes (e.g., content changes)
- Flush pending batched updates after EOSE completes
- Flush pending updates in cleanup to avoid losing updates
- Better handling of profile data changes vs same profiles

Fixes issue where @npub... placeholders sometimes weren't replaced
until refresh. Now all profile updates are guaranteed to be applied.
2025-11-02 22:01:35 +01:00
Gigi
76a117cdda fix: batch profile label updates to prevent UI flickering
- Use requestAnimationFrame to batch rapid profile label updates
- Collect pending updates in a ref instead of updating state immediately
- Apply all pending updates in one render cycle
- Add cleanup to cancel pending RAF on unmount/effect cleanup

This prevents flickering when multiple profiles stream in quickly while
still maintaining progressive updates as profiles arrive.
2025-11-02 21:53:22 +01:00
Gigi
d4c6747d98 refactor: remove timeouts and make profile fetching reactive
- Add optional onEvent callback to fetchProfiles (following queryEvents pattern)
- Remove all timeouts - rely entirely on EOSE signals
- Update useProfileLabels to use reactive streaming callback
- Labels update progressively as profiles arrive from relays
- Remove unused timer/takeUntil imports
- Backwards compatible: other callers of fetchProfiles still work

This follows the controller pattern from fetching-data-with-controllers rule:
'Since we are streaming results, we should NEVER use timeouts for fetching
data. We should always rely on EOSE.'
2025-11-02 21:48:39 +01:00
Gigi
6b221e4d13 perf: increase remote relay timeout for profile fetches
Increase timeout from 6s to 10s to give slow relays (including purplepag.es)
more time to respond with profile metadata. This may help find profiles that
were timing out before.
2025-11-02 21:43:00 +01:00
Gigi
7ec2ddcceb debug: add log before fetchProfiles call to verify it's being invoked 2025-11-02 21:42:07 +01:00
Gigi
5ce13c667d feat: ensure purplepag.es relay is used for profile lookups
Add logic to check if purplepag.es is in the active relay pool when fetching
profiles. If not, add it temporarily to ensure we query this relay for
profile metadata. This should help find profiles that might not be available
on other relays.

Also adds debug logging to show which active relays are being queried.
2025-11-02 21:41:03 +01:00
Gigi
c1877a40e9 debug: add detailed logging to fetchProfiles function
Add comprehensive logs prefixed with [fetch-profiles] to track:
- How many profiles are requested
- Cache lookup results
- Relay query configuration
- Each profile event as it's received
- Summary of fetched vs missing profiles
- Which profiles weren't found on relays

This will help diagnose why only 9/19 profiles are being returned.
2025-11-02 21:40:14 +01:00
Gigi
18a38d054f debug: add comprehensive logging to profile label resolution
Add detailed debug logs prefixed with [profile-labels] and [markdown-replace]
to track the profile resolution flow:
- Profile identifier extraction from content
- Cache lookup and eventStore checks
- Profile fetching from relays
- Label updates when profiles resolve
- Markdown URI replacement with profile labels

This will help diagnose why profile names aren't resolving correctly.
2025-11-02 21:37:14 +01:00
Gigi
500cec88d0 fix: allow profile labels to update from fallback to resolved names
Previously, useProfileLabels would set fallback npub labels immediately for
missing profiles, then skip updating them when profiles were fetched because
the condition checked if the label already existed.

Now we track which profiles were being fetched (pubkeysToFetch) and update
their labels even if they already have fallback labels set, allowing profiles
to resolve progressively from fallback npubs to actual names as they load.
2025-11-02 21:34:57 +01:00
Gigi
affd80ca2e refactor: standardize profile display name fallbacks across codebase
- Add getProfileDisplayName() utility function for consistent profile name resolution
- Update all components to use standardized npub fallback format instead of hex
- Fix useProfileLabels hook to include fallback npub labels when profiles lack names
- Refactor NostrMentionLink to eliminate duplication between npub/nprofile cases
- Remove debug console.log statements from RichContent component
- Update AuthorCard, SidebarHeader, HighlightItem, Support, BlogPostCard, ResolvedMention, and useEventLoader to use new utilities
2025-11-02 21:31:16 +01:00
Gigi
5e1ed6b8de refactor: clean up npub/nprofile display implementation
- Remove all debug console.log/error statements (39+) and ts() helpers
- Eliminate redundant localStorage cache check in useProfileLabels
- Standardize fallback display format using getNpubFallbackDisplay() utility
- Update ResolvedMention to use npub format consistently
2025-11-02 21:26:06 +01:00
Gigi
5d36d6de4f refactor: add logging to verify initialLabels are set correctly from cache 2025-11-02 21:10:56 +01:00
Gigi
93eb8a63de fix: implement LRU cache eviction to handle QuotaExceededError
- Add LRU eviction strategy: limit to 1000 cached profiles, evict oldest when full
- Track lastAccessed timestamp for each cached profile
- Automatically evict old profiles when quota is exceeded
- Reduce error logging spam: only log quota error once per session
- Silently handle cache errors to match articleService pattern
- Proactively evict before caching when approaching limit

This prevents localStorage quota exceeded errors and ensures
the most recently accessed profiles remain cached.
2025-11-02 21:09:11 +01:00
Gigi
6074caaae3 refactor: change profile-cache log prefix to npub-cache for consistency 2025-11-02 21:07:44 +01:00
Gigi
d206ff228e fix: remove unnecessary label comparison and fix useEffect dependencies 2025-11-02 21:06:40 +01:00
Gigi
074af764ed fix: disable eslint warning for useEffect dependencies in useProfileLabels 2025-11-02 21:06:16 +01:00
Gigi
e814aadb5b fix: initialize profile labels synchronously from cache for instant display
- Use useMemo to check localStorage cache synchronously during render, before useEffect
- Initialize useState with labels from cache, so first render shows cached profiles immediately
- Add detailed logging for cache operations to debug caching issues
- Fix ESLint warnings about unused variables and dependencies

This eliminates the delay where profiles were only resolved after useEffect ran,
causing profiles to display instantly on page reload when cached.
2025-11-02 21:06:02 +01:00
Gigi
aaddd0ef6b feat: add localStorage caching for profile resolution
- Add localStorage caching functions to profileService.ts following articleService.ts pattern
  - getCachedProfile: get single cached profile with TTL validation (30 days)
  - cacheProfile: save profile to localStorage with error handling
  - loadCachedProfiles: batch load multiple profiles from cache
- Modify fetchProfiles() to check localStorage cache first, only fetch missing/expired profiles, and cache fetched profiles
- Update useProfileLabels hook to check localStorage before EventStore, add cached profiles to EventStore for consistency
- Update logging to show cache hits from localStorage
- Benefits: instant profile resolution on page reload, reduced relay queries, offline support for previously-seen profiles
2025-11-02 21:03:10 +01:00
Gigi
8a39258d8e fix: remove unused useMemo import 2025-11-02 20:54:45 +01:00
Gigi
3136b198d5 perf: add timing metrics and reduce excessive logging
- Add duration tracking for fetchProfiles (shows how long it takes)
- Add total time tracking for entire resolution process
- Reduce log noise by only logging when profileLabels size changes
- Helps identify performance bottlenecks
2025-11-02 20:54:40 +01:00
Gigi
8a431d962e debug: add timestamps to all npub-resolve logs for performance analysis
- Add timestamp helper function (HH:mm:ss.SSS format)
- Update all console.log/error statements to include timestamps
- Helps identify timing bottlenecks in profile resolution
2025-11-02 20:53:22 +01:00
Gigi
50ab59ebcd fix: use fetchedProfiles array directly instead of only checking eventStore
- fetchProfiles returns profiles that we should use immediately
- Check returned array first, then fallback to eventStore lookup
- Fixes issue where profiles were returned but not used for resolution
2025-11-02 20:49:34 +01:00
Gigi
3ba5bce437 debug: add detailed logging to diagnose nprofile resolution issues
- Log fetchProfiles return count
- Log profile events found in store vs missing
- Log profiles with names vs without names
- Help diagnose why 0 profiles are being resolved
2025-11-02 20:46:23 +01:00
Gigi
9ed56b213e fix: add periodic re-checking of eventStore for async profile arrivals
- Poll eventStore every 200ms for up to 2 seconds after fetchProfiles
- Accumulate resolved labels across checks instead of resetting
- Add detailed logging to diagnose why profiles aren't resolving
- Fixes issue where profiles arrive asynchronously after fetchProfiles completes
2025-11-02 20:44:15 +01:00
Gigi
34804540c5 fix: re-check eventStore after fetchProfiles to resolve all profiles
- After fetchProfiles completes, re-check eventStore for all profiles
- This ensures profiles are resolved even if fetchProfiles returns partial results
- Fixes issue where only 5 out of 19 profiles were being resolved
2025-11-02 20:41:34 +01:00
Gigi
30c2ca5b85 feat: remove 'npub1' prefix from shortened npub displays
- Show @derggg instead of @npub1derggg for truncated npubs
- Update getNostrUriLabel to skip first 5 chars ('npub1')
- Update NostrMentionLink fallback display to match
2025-11-02 20:40:12 +01:00
Gigi
68e6fcd3ac debug: standardize all npub resolution debug logs with [npub-resolve] prefix 2025-11-02 20:38:26 +01:00
Gigi
da385cd037 fix: resolve Rules of Hooks violation by using eventStore instead of useEventModel in map 2025-11-02 20:37:50 +01:00
Gigi
3b30bc98c7 fix: correct syntax error in RichContent try-catch structure 2025-11-02 20:04:34 +01:00
Gigi
056da1ad23 debug: add debug logs to trace NIP-19 parsing and profile resolution
- Add logs to useProfileLabels hook
- Add logs to useMarkdownToHTML hook
- Add logs to RichContent component
- Add logs to extractNostrUris function
- Add error handling with fallbacks
2025-11-02 20:04:01 +01:00
Gigi
b7cda7a351 refactor: replace custom NIP-19 parsing with applesauce helpers and add progressive profile resolution
- Replace custom regex patterns with Tokens.nostrLink from applesauce-content
- Use getContentPointers() and getPubkeyFromDecodeResult() from applesauce helpers
- Add useProfileLabels hook for shared profile resolution logic
- Implement progressive profile name updates in markdown articles
- Remove unused ContentWithResolvedProfiles component
- Simplify useMarkdownToHTML by extracting profile resolution to shared hook
- Fix TypeScript and ESLint errors
2025-11-02 20:01:51 +01:00
Gigi
5896a5d6db chore: consolidate duplicate and related entries in CHANGELOG
- Merge related bookmark button changes in 0.10.31
- Consolidate image preloading entries in 0.10.27
- Group flight mode fixes in 0.10.26
- Combine OpenGraph-related changes in 0.10.24
- Consolidate bookmark sorting fixes in 0.10.11
- Merge reading progress bar fixes in 0.10.25
- Reduce file from 2158 to 2108 lines
2025-11-02 19:19:34 +01:00
Gigi
af91e52555 chore: condense CHANGELOG from 3220 to 2157 lines
- Remove nested bullets and verbose explanations
- Condense implementation details to user-facing changes
- Maintain Keep a Changelog format and structure
2025-11-02 19:16:58 +01:00
Gigi
b4ebb6334f docs: update CHANGELOG for v0.10.31 2025-11-02 19:11:53 +01:00
Gigi
27178bc3d1 chore: bump version to 0.10.31 2025-11-02 19:11:22 +01:00
Gigi
76fefc88ca Merge pull request #33 from dergigi/big-plus-button
Move add bookmark button to filter bar
2025-11-02 19:09:55 +01:00
Gigi
98c006939b fix: align add bookmark button with filter buttons
- Match CompactButton styling to filter-btn when inside filter bar
- Ensure same size, padding, and alignment for consistent appearance
2025-11-02 18:49:44 +01:00
Gigi
80ed646dd4 feat: move add bookmark button to filter bar
- Move add bookmark button from web section header to filter bar
- Position button on the right side of filter bar
- Remove conditional rendering (always show button)
- Add bookmark-filters-wrapper styling for proper layout
2025-11-02 18:47:10 +01:00
Gigi
7ea868d0b2 docs: update CHANGELOG for v0.10.30 2025-11-01 10:25:02 +01:00
Gigi
88e1bc3419 chore: bump version to 0.10.30 2025-11-01 10:24:32 +01:00
Gigi
4ec34a0379 fix: reset scroll to top when navigating to profile pages 2025-11-01 10:23:27 +01:00
Gigi
aec2dcb75c feat: navigate to author's writings page from article author card 2025-11-01 10:22:15 +01:00
Gigi
5bdc435f5d fix: preserve image aspect ratio when full-width images setting is enabled
- Add object-fit: contain to prevent image squishing
- Make max-height conditional: none when full-width enabled, 70vh otherwise
- Apply fix to both desktop and mobile image styles
2025-11-01 10:15:58 +01:00
Gigi
db46edd39e docs: update CHANGELOG for v0.10.29 2025-11-01 00:35:17 +01:00
Gigi
c9739f804d chore: bump version to 0.10.29 2025-11-01 00:34:50 +01:00
Gigi
eeb44e344f Merge pull request #32 from dergigi/full-width-image-going-over
fix: correct fullWidthImages setting to use width instead of max-width
2025-11-01 00:34:32 +01:00
Gigi
a6674610b8 fix: correct fullWidthImages setting to use width instead of max-width
- Change from --image-max-width CSS variable to --image-width
- When enabled, sets images to width: 100% (enlarging small images)
- Always constrains with max-width: 100% to prevent overflow
- Update mobile responsive styles to respect the setting
2025-11-01 00:32:51 +01:00
Gigi
6ae3decafb docs: update CHANGELOG for v0.10.28 2025-11-01 00:27:08 +01:00
Gigi
00da638e81 chore: bump version to 0.10.28 2025-11-01 00:26:17 +01:00
Gigi
f04c0a401e Merge pull request #31 from dergigi/fix-boris-post-issues
Fix nested markdown links from processing nostr URIs in URLs
2025-11-01 00:26:00 +01:00
Gigi
f5e9f164f5 chore: remove debug console.log statements from nostrUriResolver
Removed all debug logging that was added for troubleshooting the
link processing issue. The functionality remains intact, including
the parser-based markdown link detection and HTTP URL protection.
2025-11-01 00:23:57 +01:00
Gigi
589ac17114 fix: prevent double-processing of markdown to avoid nested links
Added check to detect if markdown has already been processed by looking
for our internal routes (/a/naddr1... or /p/npub1...) in markdown links.
If found, skip re-processing to prevent nested markdown link issues.

This addresses timing issues where markdown might be processed multiple
times, causing nostr URIs that were already converted to links to be
processed again, creating nested/duplicated markdown link structures.
2025-11-01 00:22:15 +01:00
Gigi
8d3510947c fix: add HTTP URL detection to prevent processing nostr URIs in URLs
Enhanced protection to also skip nostr URIs that are part of HTTP/HTTPS
URL patterns, not just markdown link URLs. This addresses timing issues
where the source markdown may contain plain URLs with nostr identifiers
before they're formatted as markdown links.

The detection checks if a nostr URI appears after 'https://' or 'http://'
and is part of a valid URL continuation to avoid false positives.
2025-11-01 00:21:14 +01:00
Gigi
08a8f5623a debug: add comprehensive logging to diagnose timing issue with link processing
Added extensive debug logs to track:
- Input markdown preview and existing link count
- Each markdown link found with context and content
- Warnings when link URLs contain nostr URIs (should be protected)
- Detailed position information for each nostr URI match
- Whether matches are correctly identified as inside/outside link URLs
- Detection of nested markdown links in result (indicates bug)

This will help diagnose the timing issue where processing sometimes
works and sometimes doesn't.
2025-11-01 00:19:43 +01:00
Gigi
e85ccdc7da fix: use parser-based approach to detect markdown link URLs
Replace regex-based markdown link detection with a character-by-character
parser that correctly handles URLs containing brackets and parentheses.
The parser tracks parenthesis depth and escaped characters to correctly
find the end of markdown link URLs, even when they contain special
characters like brackets or nested parentheses.

This should fix the issue where nostr identifiers inside markdown link
URLs were still being processed, causing nested/duplicated markdown links.
2025-11-01 00:17:08 +01:00
Gigi
d0e7f146fb debug: add extensive logging to nostrUriResolver for debugging link processing
Added debug logs prefixed with [nostrUriResolver] to track:
- When markdown processing starts
- All markdown links found and their URL ranges
- All nostr URI matches and their positions
- Whether each nostr URI is skipped or replaced
- Final processing results

This will help diagnose why nostr identifiers are still being
processed inside markdown link URLs.
2025-10-31 23:53:55 +01:00
Gigi
efdb33eb31 fix: remove unused variables in nostrUriResolver
Removed unused linkEnd variable and prefixed unused type parameter
with underscore to satisfy linter and type checker.
2025-10-31 23:52:52 +01:00
Gigi
0abbe62515 fix: prevent nostr URI replacement inside markdown link URLs
Prevents nested markdown link issues when nostr identifiers appear in URLs.
The replaceNostrUrisInMarkdown functions now skip nostr URIs that are
already inside markdown link syntax [text](url) to avoid creating
malformed nested links.
2025-10-31 23:51:40 +01:00
Gigi
ab0972dd29 docs: update CHANGELOG for v0.10.27 2025-10-31 01:59:40 +01:00
Gigi
83fbb26e03 chore: bump version to 0.10.27 2025-10-31 01:58:35 +01:00
Gigi
e8ce928ec6 Merge pull request #30 from dergigi/long-form-loading-shenanigans
fix: article loading race condition and improve caching
2025-10-31 01:58:02 +01:00
Gigi
1a01e14702 fix: properly handle fetch errors in sw-dev.js
Fix scope issue where cachedResponse wasn't accessible in catch block.
Now if fetch fails, we first check if we have a cached response and
return it. If no cache exists, we let the error propagate so the
browser can handle it gracefully.
2025-10-31 01:54:39 +01:00
Gigi
aab8176987 fix: add error handling to sw-dev.js fetch requests
Add proper error handling to prevent uncaught promise rejections when
image fetches fail. If a fetch fails, try to return cached response,
or gracefully handle the error instead of letting it propagate as an
uncaught promise rejection.
2025-10-31 01:54:23 +01:00
Gigi
5a8b885d25 fix: remove bulk image preloading to prevent ERR_INSUFFICIENT_RESOURCES
Remove image preloading from BlogPostCard and profileService to prevent
trying to fetch hundreds of images simultaneously. Images are already
lazy-loaded and will be cached by Service Worker when they come into view.
Only preload images when specifically needed (e.g., when loading an article
from cache, or the logged-in user's profile image in SidebarHeader).

This fixes thousands of ERR_INSUFFICIENT_RESOURCES errors when loading
the explore page with many blog posts.
2025-10-31 01:52:53 +01:00
Gigi
c129b24352 chore: remove remaining console.log debug statements
Remove all console.log statements from Service Worker registration
and ReaderHeader image loading code, keeping only console.error and
console.warn for actual error handling.
2025-10-31 01:51:24 +01:00
Gigi
d98d750268 fix: move useEffect before early return in BlogPostCard
Move useEffect hook before the conditional early return to satisfy
React's rules of hooks. All hooks must be called before any
conditional returns to prevent 'Rendered fewer hooks than expected'
errors.
2025-10-31 01:49:08 +01:00
Gigi
8262b2bf24 chore: remove all debug console output from article cache and service worker
Remove all console.log, console.warn, and console.error statements
that were added for debugging in article cache, service worker,
and image caching code.
2025-10-31 01:47:00 +01:00
Gigi
b99f36c0c5 chore: remove unused refresh button from highlights panel header 2025-10-31 01:44:52 +01:00
Gigi
dfe37a260e chore: remove debug console.log statements from useImageCache
Remove all debug console.log statements that were added during
image caching implementation, keeping only error logs for actual
error handling.
2025-10-31 01:44:07 +01:00
Gigi
2a42f1de53 feat: add refresh button to highlights sidebar header
Add a refresh button to the highlights panel header, positioned to the
left of the eye icon. The button refreshes highlights for the current
article and shows a spinning animation while loading.
2025-10-31 01:41:36 +01:00
Gigi
cea2d0eda2 perf: avoid redundant image preload when using preview data
Skip image preload in useArticleLoader when preview data is available,
since the image should already be cached from BlogPostCard. This prevents
unnecessary network requests when navigating from explore.
2025-10-31 01:39:11 +01:00
Gigi
ef05974a72 feat: preload images in BlogPostCard for better caching
Preload article cover images when BlogPostCard is rendered to ensure
they're cached by Service Worker before navigating to the article.
This prevents re-fetching images that are already displayed in explore.
2025-10-31 01:38:49 +01:00
Gigi
5a6ac628d2 fix: add save suppression when resetting scroll position
Add 500ms save suppression when article changes to prevent
accidentally saving 0% reading position during navigation.
This works together with existing safeguards (tracking disabled,
document height check, throttling) to ensure reading progress
is only saved during actual reading.
2025-10-31 01:37:02 +01:00
Gigi
826f07544e fix: reset scroll position when switching articles
Reset scroll position to top immediately when articleIdentifier changes
to prevent showing wrong scroll position from previous article. Also
reset hasAttemptedRestoreRef when article changes to ensure proper
scroll restoration for new articles.
2025-10-31 01:35:56 +01:00
Gigi
911215c0fb feat: preload logged-in user profile image for offline access
Preload profile images when profiles are fetched and when displayed
in the sidebar to ensure they're cached by the Service Worker for
offline access.
2025-10-31 01:34:34 +01:00
Gigi
43ed41bfae chore: remove debug console.log statements
Remove all debug console.log statements that were added during
article loading and caching implementation, keeping only error
and warning logs for actual error handling.
2025-10-31 01:33:09 +01:00
Gigi
81597fbb6d fix: clear reader content immediately when naddr changes
Prevent showing stale images from previous articles by clearing
readerContent at the start of the effect when navigating to a new article.
2025-10-31 01:31:30 +01:00
Gigi
cc722c2599 fix: mark unused settings parameter as intentionally unused 2025-10-31 01:26:26 +01:00
Gigi
c20682fbe8 fix: resolve article loading race condition and populate cache from explore
- Move cache/EventStore checks before relayPool check in useArticleLoader
  to fix race condition where articles wouldn't load on direct navigation
- Add relayPool to dependency array so effect re-runs when it becomes available
- Populate localStorage cache when articles are loaded in explore view
- Extract cacheArticleEvent() helper to eliminate code duplication
- Enhance saveToCache() with settings parameter and better error handling
2025-10-31 01:24:58 +01:00
Gigi
cfa6dc4400 feat: add development Service Worker for testing image caching
With injectManifest strategy, the Service Worker needs to be built, so it's
not available in dev mode. To enable testing image caching in dev, we now:
1. Created public/sw-dev.js - a simplified SW that only handles image caching
2. Updated registration to use sw-dev.js in dev mode, sw.js in production
3. Dev SW uses simple cache-first strategy for images

This allows testing image caching in development without needing a build.
2025-10-31 01:10:20 +01:00
Gigi
851cecf18c fix: enable Service Worker registration in dev mode for testing
With devOptions.enabled: true, vite-plugin-pwa should serve the SW
in dev mode. Now we:
1. Attempt registration in both dev and prod
2. In dev mode, check if SW file exists and has correct MIME type first
3. Only register if file is actually available (not HTML fallback)
4. Handle errors gracefully with informative warnings

This allows testing image caching in dev mode when vite-plugin-pwa
is properly serving the Service Worker file.
2025-10-31 01:08:03 +01:00
Gigi
d4c67485a2 fix: skip Service Worker registration in dev mode
With injectManifest strategy, the Service Worker file is only generated
during build, so it's not available in development mode. This causes
MIME type errors when trying to register a non-existent file.

Now we:
1. Only register Service Worker in production builds
2. Skip registration gracefully in dev mode with informative log
3. Image caching will work in production but not in dev (expected)

This eliminates the 'unsupported MIME type' errors in development.
2025-10-31 01:05:01 +01:00
Gigi
381fd05c90 fix: improve Service Worker registration error handling
1. Check for existing registrations first to avoid duplicate registrations
2. In dev mode, check if SW file exists before attempting registration
3. Handle registration errors gracefully - don't crash if SW unavailable in dev
4. Use getRegistrations() instead of getRegistration() for better coverage
5. Add more detailed error logging for debugging

This prevents the 'Failed to register ServiceWorker' errors when the
SW file isn't available in development mode.
2025-10-31 00:59:39 +01:00
Gigi
60c4ef55c0 fix: enable Service Worker registration in development mode
Service Worker was only registered in production, but vite-plugin-pwa
has devOptions.enabled=true, so SW should work in dev too. Now we:
1. Register SW in both dev and prod modes
2. Use correct SW path for dev (/dev-sw.js?dev-sw) vs prod (/sw.js)
3. Add comprehensive debug logs for registration and activation
4. Log Service Worker state changes for debugging

Service Workers don't require PWA installation - they work in regular
browsers. This enables image caching in development mode.
2025-10-31 00:55:34 +01:00
Gigi
0b7891419b debug: add comprehensive logging for image caching
Add debug logs prefixed with [image-preload], [image-cache], [sw-image-cache],
and [reader-header] to track:
- When images are preloaded
- Service Worker availability and controller status
- Image fetch success/failure
- Service Worker intercepting and caching image requests
- Image loading in ReaderHeader component
- Cache hits/misses in Service Worker

This will help debug why images aren't available offline.
2025-10-31 00:52:29 +01:00
Gigi
aeedc622b1 fix: preload images when loading articles from cache
When loading articles from localStorage cache, images aren't automatically
cached by the Service Worker because they're not fetched until the <img> tag
renders. If the user goes offline before that, images won't be available.

Now we:
1. Added preloadImage() function to explicitly fetch images via Image() and fetch()
2. Preload images when loading from localStorage cache
3. Preload images when receiving first event from relays

This ensures images are cached by Service Worker before going offline,
making them available on refresh when offline.
2025-10-31 00:50:52 +01:00
Gigi
6f5b87136b fix: image caching issues
1. Fix cache name mismatch: imageCacheService now uses 'boris-images'
   to match the Service Worker cache name
2. Remove cross-origin restriction: Cache ALL images, not just
   cross-origin ones. This ensures article images from any source
   are cached by the Service Worker
3. Update comments to clarify Service Worker caching behavior

Images should now be properly cached when loaded via <img> tags.
2025-10-31 00:47:10 +01:00
Gigi
1ac0c719a2 fix: simplify cache save logic to avoid TypeScript errors
Simplify the finalization cache save - we already save on first event,
so only save in finalization if first event wasn't emitted. This
avoids TypeScript narrowing issues and duplicate cache saves.
2025-10-31 00:44:16 +01:00
Gigi
c9ce5442e0 fix: save to cache immediately when first event received
Move cache save to happen immediately when first event is received
via onEvent callback, instead of waiting for queryEvents to complete.
This ensures articles are cached even if queryEvents hangs or never
resolves.

Also deduplicate cache saves - only save again in finalization if
it's a different/newer event than the first one.
2025-10-31 00:43:11 +01:00
Gigi
c28052720e fix: save articles to localStorage cache after loading from relays
We were loading articles from relays but never saving them to cache,
which meant every refresh would query relays again. Now we:
1. Save to cache immediately after successfully loading from relays
2. Export saveToCache function for reuse
3. Add debug logs to track cache saves

This ensures articles are cached after first load, enabling instant
loading on subsequent visits/refreshes.
2025-10-31 00:41:08 +01:00
Gigi
d0f942c495 debug: add comprehensive logging to article loader
Add detailed debug logs prefixed with [article-loader] and [article-cache]
to track:
- Cache checks (hit/miss/expired)
- EventStore checks
- Relay queries and event streaming
- UI state updates
- Request lifecycle and abort conditions

This will help debug why articles are still loading from relays on refresh.
2025-10-31 00:39:29 +01:00
Gigi
907ef82efb fix: check cache synchronously before setting loading state
Move localStorage cache check outside async function to execute
immediately before any loading state is set. This prevents loading
skeletons from appearing when cached content is available.

Previously, cache was checked inside async function, allowing a render
cycle where loading=true was shown before cache could load content.
2025-10-31 00:38:13 +01:00
Gigi
415ff04345 fix: check localStorage cache before querying relays for articles
Previously, articles always loaded from relays on browser refresh because:
- EventStore is in-memory only and doesn't persist
- localStorage cache was only checked as last resort after relay queries failed

Now we check the localStorage cache immediately after EventStore,
before querying relays. This allows instant article loading from cache
on refresh without unnecessary relay queries.
2025-10-31 00:34:42 +01:00
Gigi
683ea27526 docs: update CHANGELOG for v0.10.26 2025-10-31 00:28:10 +01:00
Gigi
fa052483b2 chore: bump version to 0.10.26 2025-10-31 00:27:14 +01:00
Gigi
0ae9e6321e Merge pull request #29 from dergigi/flight-mode-shenanigans
Fix flight mode detection and persist highlight metadata
2025-10-31 00:26:08 +01:00
Gigi
5623f2e595 chore: remove debug console.log statements
- Remove debug console.log from AddBookmarkModal.tsx (modal fetch and description extraction)
- Remove console.debug from relayManager.ts (relay closing errors)
- Keep console.warn and console.error for legitimate error handling
2025-10-31 00:24:42 +01:00
Gigi
2c94c1e3f0 fix: remove unused variables to resolve lint errors
- Remove unused relayNames variable from HighlightItem.tsx
- Remove unused failedRelays variable from highlightCreationService.ts
- All linting and type checks now pass
2025-10-31 00:18:58 +01:00
Gigi
19dc2f70f2 feat: persist highlight metadata and offline events to localStorage
- Add localStorage persistence for highlightMetadataCache Map
- Add localStorage persistence for offlineCreatedEvents Set
- Load both caches from localStorage on module initialization
- Save to localStorage whenever caches are updated
- Update metadata cache during sync operations (isSyncing changes)
- Ensures airplane icon displays correctly after page reloads
- Gracefully handles localStorage errors and corrupted data
2025-10-31 00:17:13 +01:00
Gigi
5013ccc552 perf: remove excessive debug logging for better performance
- Remove debug logs from highlight creation, publishing, and UI rendering
- Keep only essential error logging
- Improves performance by reducing console spam
- Flight mode detection still works via fallback mechanisms
2025-10-31 00:12:04 +01:00
Gigi
29eed3395f fix: prioritize isLocalOnly check to show airplane icon
- Check isLocalOnly first before checking publishedRelays length
- Show airplane icon if isLocalOnly is true, even if publishedRelays is empty
- This ensures flight mode highlights show airplane icon via offline sync fallback
- Add debug logs to track cache storage and retrieval
- Fixes issue where airplane icon doesn't show when creating highlights offline
2025-10-31 00:09:09 +01:00
Gigi
d6da27c634 fix: resolve all linting errors and type issues
- Remove unused setShowOfflineIndicator calls
- Remove unused areAllRelaysLocal import
- Fix duplicate key 'failedRelays' in console.log
- Replace 'any' types with proper HighlightEvent interface
- Add eslint-disable comments for intentionally excluded useEffect dependencies
- All linting errors resolved, type checks pass
2025-10-31 00:04:24 +01:00
Gigi
5551b52bce fix: replace require() with ES module imports
- Add isEventOfflineCreated and isLocalRelay to imports
- Remove require() calls that don't work in ES modules
- Fixes ReferenceError: require is not defined
2025-10-31 00:02:29 +01:00
Gigi
af7eb48893 fix: add fallback logic for detecting flight mode highlights
- Add isEventOfflineCreated function to check offline sync service
- Use offline sync service as fallback if isLocalOnly is undefined
- Also check if publishedRelays only contains local relays
- This provides multiple fallback mechanisms to detect flight mode highlights
- Should finally fix the airplane icon not showing
2025-10-31 00:01:09 +01:00
Gigi
51ce79f13a fix: use metadata cache to preserve highlight properties across EventStore
- Create highlightMetadataCache to store isLocalOnly and publishedRelays
- Properties stored as __highlightProps are lost during EventStore serialization
- Cache persists across storage/retrieval cycles
- eventToHighlight now checks cache first before __highlightProps
- This should finally fix the airplane icon not showing for flight mode highlights
2025-10-30 23:56:53 +01:00
Gigi
bcfc04c35c debug: add logging to investigate tooltip showing all relays
- Add debug log to see what highlight data is available when rendering
- Check if publishedRelays or seenOnRelays are being used
- This will help identify why tooltip shows all relays instead of just published ones
2025-10-30 23:54:07 +01:00
Gigi
d6911b2acb fix: publish only to connected relays to avoid long timeouts
- Only publish to currently connected relays instead of all configured relays
- This prevents waiting for disconnected/offline relays to timeout
- Improves performance significantly in flight mode
- Handle case when no relays are connected
2025-10-30 23:53:44 +01:00
Gigi
2a869f11e0 fix: prevent duplicate highlights and remove excessive logging
- Add deduplication when adding highlights via onHighlightCreated
- Remove excessive UI logging that was causing performance issues
- Fixes duplicate highlight warning and improves render performance
2025-10-30 21:29:35 +01:00
Gigi
deab9974fa fix: remove excessive logging from eventToHighlight
- Remove console.log that was spamming console with EVENT-TO-HIGHLIGHT logs
- This function is called frequently during renders, causing performance issues
- Keep the function logic but remove the logging
2025-10-30 21:05:45 +01:00
Gigi
49872337f3 fix: manually set highlight properties after eventToHighlight
- Set publishedRelays, isLocalOnly, and isSyncing directly on highlight object
- This bypasses the __highlightProps mechanism which wasn't working
- Add logging to verify properties are set correctly
- This should finally fix the airplane icon not showing in flight mode
2025-10-30 21:01:18 +01:00
Gigi
389b4de0eb debug: add logging to eventToHighlight conversion
- Add [EVENT-TO-HIGHLIGHT] logs to see if __highlightProps are being preserved
- Add [HIGHLIGHT-CREATION] logs before calling eventToHighlight
- This will help identify why isLocalOnly and publishedRelays are undefined in final highlight
2025-10-30 20:59:43 +01:00
Gigi
959ccac857 fix: store event in EventStore after updating properties
- Move eventStore.add() call to AFTER updating __highlightProps with final values
- This ensures highlights loaded from EventStore have correct isLocalOnly and publishedRelays
- Reduce UI logging spam by only logging when values are meaningful
- This should fix the airplane icon not showing and reduce excessive re-renders
2025-10-30 20:56:50 +01:00
Gigi
78c58693a5 debug: enhance logging in HighlightItem to debug icon issue
- Log entire highlight object to see what properties are actually present
- Log publishedRelayCount and actualPublishedRelaysInUI for clarity
- Add conditionResult to show what icon should be displayed
- This will help identify why airplane icon isn't showing despite isLocalOnly being true
2025-10-30 20:54:15 +01:00
Gigi
ab81fe5030 debug: add logging to useHighlightCreation hook
- Add [HIGHLIGHT-CREATION] logs to track highlight creation flow
- Log when createHighlight function is called
- Log highlight properties after creation to verify isLocalOnly is set
- This will help debug why [HIGHLIGHT-PUBLISH] logs are missing
2025-10-30 20:48:53 +01:00
Gigi
6bae30070e fix: preserve isLocalOnly and publishedRelays in eventToHighlight
- Attach custom properties to event as __highlightProps before storing
- Update eventToHighlight to preserve these properties when converting
- This ensures highlights loaded from EventStore maintain flight mode status
- Fixes issue where isLocalOnly was undefined in UI component
- The airplane icon should now show correctly for flight mode highlights
2025-10-30 20:44:58 +01:00
Gigi
1f6a904717 debug: add comprehensive logging for flight mode detection
- Add detailed logs with [HIGHLIGHT-PUBLISH] prefix for publication process
- Add detailed logs with [HIGHLIGHT-UI] prefix for UI rendering
- Log relay responses, success/failure analysis, and flight mode reasoning
- Log icon decision making process in UI component
- This will help debug why airplane icon isn't showing in flight mode
2025-10-30 20:42:02 +01:00
Gigi
9379475d1c feat: implement proper relay response tracking for flight mode
- Use pool.publish() which returns individual relay responses
- Track which relays actually accepted the event (response.ok === true)
- Set isLocalOnly = true only when only local relays accepted the event
- This provides accurate flight mode detection based on actual publishing success
- Debug logging shows all relay responses for troubleshooting
2025-10-30 20:41:24 +01:00
Gigi
77a5f4bd2a refactor: implement proper flight mode detection for highlights
- Always publish to all relays but track which ones are actually connected
- isLocalOnly = true when only local relays are connected (flight mode)
- Store event in EventStore first for immediate UI display
- Track actually connected relays instead of guessing based on connection status
- This should fix the airplane icon not showing in flight mode
2025-10-30 20:40:20 +01:00
Gigi
4fa01231cd fix: determine isLocalOnly before publishing, not after
- Move connection status check before publishEvent call
- Set isLocalOnly based on actual connection state at creation time
- This ensures airplane icon shows correctly in flight mode
- Previous logic was checking after publishing, which was too late
2025-10-30 20:35:51 +01:00
Gigi
1cd85507a7 debug: add logging to highlight creation isLocalOnly logic
- Add console.log to debug why isLocalOnly is not being set correctly
- Fix logic: isLocalOnly should be true when only local relays are connected
- Previous logic was checking expectedSuccessRelays instead of actual connections
- This will help identify why airplane icon doesn't show in flight mode
2025-10-30 20:34:23 +01:00
Gigi
b6f151c711 refactor: use isLocalOnly instead of isOfflineCreated
- isLocalOnly is more accurate - covers both offline and online-but-local-only scenarios
- Update tooltip to 'Local relays only - will sync when remote relays available'
- Better semantic meaning: highlights only published to local relays
- Covers cases where user is online but only connected to local relays
2025-10-30 20:32:43 +01:00
Gigi
e3d924f3fc refactor: remove redundant isLocalOnly flag
- Remove isLocalOnly field from Highlight type and creation logic
- Use only isOfflineCreated flag for flight mode highlights
- Simplify UI logic to check only isOfflineCreated
- Both flags were set to the same value, making them redundant
- Cleaner, more maintainable code with single source of truth
2025-10-30 20:31:09 +01:00
Gigi
5914df23d3 fix: show airplane icon for flight mode highlights
- Simplify logic to check highlight.isOfflineCreated || highlight.isLocalOnly
- Show airplane icon with 'Created offline - will sync when online' tooltip
- Remove complex showOfflineIndicator state management
- Fixes issue where flight mode highlights showed highlighter icon instead of plane icon
2025-10-30 20:30:04 +01:00
Gigi
2083c2b8c6 fix: remove eventStore and setter functions from useEffect dependencies
- Remove eventStore from useArticleLoader and useExternalUrlLoader dependencies
- Remove setter functions from dependencies as they shouldn't change
- Only keep naddr/url and previewData/cachedUrlHighlights as dependencies
- This prevents content loaders from re-running when going offline
- Fixes the core issue where loading skeleton appears immediately on offline detection
2025-10-30 20:23:23 +01:00
Gigi
35a8411d9b fix: check EventStore before setting loading state
- Move EventStore check before setReaderLoading(true) call
- Only show loading skeleton if content not found in store and no preview data
- This prevents loading skeleton from appearing when cached content is available
- Fixes the core issue where offline mode shows loading skeleton with cached content
2025-10-30 20:21:59 +01:00
Gigi
15b3b5b990 fix: remove relayPool dependency from content loaders
- Remove relayPool from useEffect dependencies in useArticleLoader and useExternalUrlLoader
- This prevents content reloading when relay status changes (going offline/online)
- Content loaders now only re-run when the actual content identifier changes
- Fixes issue where loading skeleton appears when going offline with cached content
2025-10-30 20:20:07 +01:00
Gigi
ad56acb712 fix: prevent unnecessary relay queries when article content is cached
- Return early from useArticleLoader when content is found in EventStore
- This prevents loading skeleton from showing when going offline with cached content
- Improves offline experience by using locally cached article content
2025-10-30 20:18:06 +01:00
Gigi
2f5fe87fc8 docs: update CHANGELOG for v0.10.25 2025-10-25 01:59:51 +02:00
Gigi
d313c71e24 chore: bump version to 0.10.25 2025-10-25 01:59:25 +02:00
Gigi
903b4a4ec1 Merge pull request #28 from dergigi/mid-size-cards
feat: redesign medium-sized bookmark cards with compact layout and improved UX
2025-10-25 01:58:56 +02:00
Gigi
a511b25b87 fix: correct TypeScript error in content type icon logic
- Change 'document' case to 'article' to match valid UrlType
- Fix TypeScript compilation error for invalid UrlType comparison
- Maintain proper type safety while preserving icon functionality
- All linting and type checks now passing
2025-10-25 01:57:57 +02:00
Gigi
e920cf9477 feat: make image placeholder reflect bookmark type
- Add content type icon logic to CardView component
- Import appropriate FontAwesome icons for different content types
- Replace generic link icon with type-specific icons:
  - Articles: newspaper icon
  - Videos: play button icon
  - Images: camera icon
  - Documents: file lines icon
  - Web links: globe icon
  - Notes: sticky note icon
- Improve visual context and user experience with meaningful placeholders
2025-10-25 01:56:31 +02:00
Gigi
708a1bfd54 fix: ensure consistent reading progress bar thickness in large cards
- Add minHeight property to ReadingProgressBar container and inner div
- Ensure empty progress bar maintains same 3px height as filled progress bar
- Fix visual consistency between empty and filled reading progress states
- Maintain proper visual separator thickness in large card view
2025-10-25 01:55:37 +02:00
Gigi
51842f55bf fix: resolve linting issues in CardView component
- Remove unused onSelectUrl parameter from CardView destructuring
- Fix ESLint no-unused-vars error
- Maintain code quality and linting standards
- All linting issues resolved, type checks passing
2025-10-25 01:54:59 +02:00
Gigi
52991f8e20 fix: eliminate 0 artefacts in compact view conditional rendering
- Change condition from 'readingProgress && readingProgress > 0' to 'readingProgress !== undefined && readingProgress > 0'
- Prevent React from rendering 0 values when readingProgress is 0
- Fix weird 0 artefacts appearing in compact list view
- Ensure clean conditional rendering without unwanted text output
2025-10-25 01:53:52 +02:00
Gigi
e3cd4454b4 fix: only show reading progress bar in compact view when there is actual progress
- Add conditional rendering for ReadingProgressBar in CompactView
- Only display progress bar when readingProgress > 0
- Remove empty progress bar separator from compact list view
- Maintain clean, minimal compact view without unnecessary visual elements
- Keep progress bar functionality for cards with actual reading progress
2025-10-25 01:52:57 +02:00
Gigi
78bc1f46dd style: eliminate excessive space between progress bar and footer
- Reduce ReadingProgressBar margins from 0.25rem to 0.125rem (75% reduction)
- Reduce bookmark footer padding-top from 0.25rem to 0.125rem (75% reduction)
- Reduce reading progress separator margin from 0.25rem to 0.125rem (75% reduction)
- Update responsive breakpoints for ultra-compact spacing
- Achieve minimal gap between progress bar and date/author footer
- Create ultra-tight vertical layout with almost no wasted space
2025-10-25 01:52:35 +02:00
Gigi
c8cd1e6e66 style: make bookmark cards significantly more compact
- Reduce card content gap from 0.5rem to 0.25rem (50% reduction)
- Reduce bookmark title margin-bottom from 0.5rem to 0.25rem (50% reduction)
- Reduce bookmark footer padding-top from 0.5rem to 0.25rem (50% reduction)
- Reduce reading progress separator margin from 0.5rem to 0.25rem (50% reduction)
- Update ReadingProgressBar component margins to 0.25rem
- Update responsive breakpoints for consistent compact spacing
- Achieve much tighter vertical layout with minimal wasted space
2025-10-25 01:50:46 +02:00
Gigi
5254697fe2 refactor: create reusable ReadingProgressBar component for DRY code
- Create ReadingProgressBar component with configurable props
- Remove duplicated progress color logic from all view components
- Replace inline progress bar code with reusable component
- Maintain consistent behavior across CardView, CompactView, and LargeView
- Reduce code duplication and improve maintainability
2025-10-25 01:49:45 +02:00
Gigi
13462efaed feat: ensure reading progress bar shows for all bookmark types across all view modes
- Update CompactView to always show progress bar for all bookmark types
- Update LargeView to always show progress bar for all bookmark types
- Remove conditional logic that only showed progress for articles
- Ensure consistent visual separator across CardView, CompactView, and LargeView
- Maintain empty state display (1px border line) when no progress available
2025-10-25 01:48:42 +02:00
Gigi
1df00fbfda feat: show reading progress bar for all bookmark types
- Remove isArticle condition from reading progress bar display
- Show progress bar for videos, links, and articles
- Maintain consistent visual separator for all bookmark types
- Ensure reading progress tracking works across all content types
2025-10-25 01:48:08 +02:00
Gigi
c2e220a1f2 style: reduce vertical spacing in medium-sized bookmark cards
- Reduce reading progress separator margin from 0.75rem to 0.5rem
- Reduce card content gap from 0.75rem to 0.5rem
- Reduce bookmark title margin-bottom from 0.75rem to 0.5rem
- Reduce bookmark footer padding-top from 0.75rem to 0.5rem
- Update responsive breakpoints for consistent compact spacing
- Make cards more compact and visually tighter
2025-10-25 01:47:46 +02:00
Gigi
00740aab6d fix: ensure empty reading progress bar is always visible for articles
- Change progress bar background from transparent to border color when no progress
- Reading progress separator now always shows 1px line for articles
- Maintains visual consistency between articles with and without reading progress
- Ensures proper visual separation between content and footer
2025-10-25 01:45:53 +02:00
Gigi
e12d67cc5f feat: remove type icon from medium-sized bookmark cards
- Remove contentTypeIcon from CardViewProps interface
- Remove type icon display from bookmark footer
- Replace contentTypeIcon with faLink in thumbnail placeholder
- Simplify card interface by removing content type indicator
- Clean up unused icon-related code
2025-10-25 01:45:29 +02:00
Gigi
e12aaa2b6c feat: remove text expansion mechanic from medium-sized cards
- Remove expanded state and shouldTruncate logic
- Remove chevron icons and expand/collapse buttons
- Simplify content display to show full content without truncation
- Remove unused faChevronDown and faChevronUp imports
- Streamline card interface for cleaner, simpler design
2025-10-25 01:44:16 +02:00
Gigi
9880a9ae34 fix: ensure timestamp and icon display on same line
- Change bookmark-type display to inline-flex for inline layout
- Add flex-wrap: nowrap to prevent wrapping
- Set timestamp elements to display: inline with white-space: nowrap
- Reduce gap between timestamp and icon to 0.5rem for tighter spacing
- Ensure both elements stay on the same horizontal line
2025-10-25 01:43:22 +02:00
Gigi
603db680f2 style: match type icon color to author text color
- Set type icon color to var(--color-text-secondary) to match author text
- Icon now has same muted color as author link instead of bright white
- Creates better visual consistency in footer metadata
2025-10-25 01:42:02 +02:00
Gigi
ae0471946e feat: move timestamp to footer next to type icon
- Move timestamp from header to footer positioned next to type icon
- Create bookmark-footer-right container for timestamp and type icon
- Hide empty bookmark-header since timestamp is now in footer
- Update footer layout: author (left), timestamp + type icon (right)
- Maintain proper spacing and alignment for all elements
2025-10-25 01:41:40 +02:00
Gigi
a48308d57d fix: remove primary color from global bookmark-type rule
- Remove color: var(--color-primary) from global .bookmark-type rule
- Icon was still blue due to global CSS rule overriding footer-specific rule
- Now uses default text color for subtle appearance
2025-10-25 01:41:05 +02:00
Gigi
f67b358148 style: remove primary color from bookmark type icon
- Remove var(--color-primary) color from bookmark type icon
- Use default text color for more subtle icon appearance
- Maintain font size and spacing for consistent layout
2025-10-25 01:40:08 +02:00
Gigi
46a0a3da1f feat: add title display for regular bookmarks/links
- Extract title from tags for all bookmark types, not just articles
- Display titles for regular bookmarks that have title tags
- Support both article titles and bookmark titles in card display
- Maintain existing article title functionality
- Improve title coverage across all bookmark types
2025-10-25 01:39:46 +02:00
Gigi
c92a620ea8 feat: move bookmark type icon to bottom right footer
- Remove type icon from header and move to footer
- Position author name on left, type icon on right in footer
- Update header to right-align date only
- Add flex layout to footer for proper spacing
- Maintain consistent styling and responsive design
2025-10-25 01:39:08 +02:00
Gigi
34de372509 feat: remove URL display from medium-sized bookmark cards
- Remove bookmark URLs section from CardView component
- Remove unused urlsExpanded state variable
- Clean up unused URL styling from CSS
- Simplify card layout by removing URL display
- Maintain all other card functionality (title, content, progress, author)
2025-10-25 01:38:38 +02:00
Gigi
a422084949 feat: add title display to medium-sized bookmark cards
- Add articleTitle prop to CardView component interface
- Display article titles for kind:30023 articles in card layout
- Style titles with proper typography and responsive design
- Position titles between header and URLs for optimal hierarchy
- Add line clamping for long titles (2 lines max)
- Update BookmarkItem to pass articleTitle to CardView
2025-10-25 01:38:11 +02:00
Gigi
bd0e075984 fix: remove unused variables to resolve linting errors
- Remove unused imageLoading and imageError state variables
- Clean up CardView component to pass ESLint checks
- Maintain all existing functionality while fixing linting issues
2025-10-25 01:37:04 +02:00
Gigi
38f4b69d48 feat: position bookmark type icon in top-left corner of card
- Move bookmark type icon to top-left corner as overlay
- Add bookmark-type-overlay with absolute positioning
- Style icon with background, border, and shadow for visibility
- Update responsive design for smaller screens
- Remove icon from bookmark header to avoid duplication
- Ensure icon is always visible and accessible
2025-10-25 01:36:01 +02:00
Gigi
9d1d944daf feat: position reading progress bar to span full card width
- Move reading progress bar outside of text content area
- Position progress bar between content and author name
- Update CSS to remove card-content scoping for full-width display
- Maintain 1px thickness and smooth transitions
- Ensure progress bar spans entire card width for better visual separation
2025-10-25 01:35:15 +02:00
Gigi
e56461cb12 feat: restructure card layout to position author in bottom-left corner
- Move thumbnail to be next to text content instead of blocking author position
- Create card-content-header with thumbnail + text-content flex layout
- Position author name in bottom-left corner of card footer
- Update responsive design for new layout structure
- Maintain thumbnail functionality while fixing author positioning
2025-10-25 01:34:40 +02:00
Gigi
f6b6747f09 feat: always show reading progress bar as 1px separator
- Show reading progress bar for all article cards, even without progress
- Change progress bar thickness from 4px to 1px for subtle separation
- Remove fallback separator since progress bar is always shown
- Empty progress bars show as transparent fill with border background
- Maintain consistent visual separation across all article cards
2025-10-25 01:33:14 +02:00
Gigi
180c26c47a feat: use reading progress bar as visual separator
- Remove separate border separator from bookmark footer
- Enhance reading progress bar styling as primary separator
- Add subtle separator for cards without reading progress
- Improve visual hierarchy with progress-based separation
- Maintain consistent spacing and visual flow
2025-10-25 01:32:34 +02:00
Gigi
78da0cb3e4 feat: redesign medium-sized bookmark cards with left-side thumbnails
- Replace full-width hero images with compact left-side thumbnails
- Add card-layout flex container for thumbnail + content arrangement
- Implement 80px square thumbnails with hover scale effects
- Update responsive design for smaller screens (60px tablet, 50px mobile)
- Maintain content truncation and reading progress indicators
- Improve space efficiency while preserving visual appeal
2025-10-25 01:31:26 +02:00
Gigi
3d74c25c7d feat: enhance medium-sized bookmark cards with improved styling and layout
- Add card-view class for better visual hierarchy
- Implement hero image display with fallback placeholder
- Add responsive design for mobile and tablet screens
- Improve content truncation with line clamping
- Enhance URL display with better styling
- Add hover effects and smooth transitions
- Optimize card layout for better readability
2025-10-25 01:30:23 +02:00
Gigi
f46f55705b docs: update CHANGELOG for v0.10.24 2025-10-25 01:28:44 +02:00
Gigi
205591602d chore: bump version to 0.10.24 2025-10-25 01:28:22 +02:00
Gigi
c6a42e0304 Merge pull request #27 from dergigi/fixes-after-midnight-as-always
feat: improve OpenGraph extraction with fetch-opengraph library
2025-10-25 01:28:06 +02:00
Gigi
688d4285e3 fix: resolve all linting and TypeScript issues
- Rename fetch import to fetchOpenGraph to avoid global variable conflict
- Replace any types with proper Record<string, unknown> types
- Add proper type guards for OpenGraph data extraction
- Remove unused CACHE_TTL variable
- Fix TypeScript null assignment error in opengraphEnhancer
- All linting rules and type checks now pass
2025-10-25 01:26:54 +02:00
Gigi
9f806afc45 debug: add console logging to debug description extraction in AddBookmarkModal
- Add debug logs to track OpenGraph data fetching
- Add debug logs to track description extraction logic
- Help identify why description field is not being populated
2025-10-25 01:22:05 +02:00
Gigi
1282e778c6 fix: extract description from web bookmark content field
- Web bookmarks (kind:39701) store description in content field, not summary tag
- Update linksFromBookmarks.ts to check content field for web bookmarks
- Maintain backward compatibility with regular bookmarks using summary tag
- Fixes description display for web bookmarks in Links tab
2025-10-25 01:19:07 +02:00
Gigi
6fd40f2ff6 feat: enhance Links type bookmarks with OpenGraph data
- Add opengraphEnhancer service using fetch-opengraph library
- Enhance ReadItems with proper titles, descriptions, and cover images
- Update deriveLinksFromBookmarks to use async OpenGraph enhancement
- Add caching and batching to avoid overwhelming external services
- Improve bookmark card display with rich metadata from OpenGraph tags
2025-10-25 01:15:37 +02:00
Gigi
6ac40c8a17 feat: replace custom OpenGraph extraction with fetch-opengraph library
- Install fetch-opengraph library for robust OpenGraph extraction
- Replace custom regex patterns and proxy logic with specialized library
- Simplify AddBookmarkModal OpenGraph extraction logic
- Remove fetchRawHtml function from readerService
- Improve reliability and maintainability of metadata extraction
2025-10-25 01:14:28 +02:00
Gigi
92145af2bb feat: implement dynamic browser title based on content
- Add useDocumentTitle hook to manage document title dynamically
- Update useArticleLoader to set title when articles load
- Update useExternalUrlLoader to set title for external URLs/videos
- Update useEventLoader to set title for events
- Reset title to default when navigating away from content
- Browser title now shows article/video title instead of always 'Boris'
2025-10-25 01:07:55 +02:00
Gigi
1ebaf7ccd2 docs: update CHANGELOG for v0.10.23 2025-10-25 01:04:29 +02:00
Gigi
5d22819ae3 chore: bump version to 0.10.23 2025-10-25 01:03:57 +02:00
Gigi
6761b1861e Merge pull request #26 from dergigi/tts-fixes-and-stuff
Fix highlight loading issues and improve article performance
2025-10-25 01:03:35 +02:00
Gigi
1d989eae76 fix: improve article loading performance and error handling 2025-10-25 01:02:42 +02:00
Gigi
33d6e5882d refactor: simplify highlight loading code
- Remove redundant fallback mechanisms and backup effects
- Remove unnecessary parameters from useArticleLoader interface
- Keep only essential highlight loading logic
- Maintain DRY principle by eliminating duplicate code
- Simplify the codebase while preserving functionality
2025-10-25 01:00:36 +02:00
Gigi
0a62924b78 feat: implement robust highlight loading with fallback mechanisms
- Add detailed logging to track highlight loading process
- Implement fallback timeout mechanism to retry highlight loading after 2 seconds
- Add backup effect that triggers when article coordinate changes
- Ensure highlights are loaded reliably after article content is fully loaded
- Add console logging to help debug highlight loading issues
2025-10-25 00:59:26 +02:00
Gigi
e2472606dd fix: properly filter Nostr article highlights in sidebar
- Extract article coordinate from nostr: URLs using nip19.decode
- Filter highlights by eventReference matching the article coordinate
- Fix issue where unrelated highlights were showing in sidebar
- Apply same filtering logic to both useFilteredHighlights and filterHighlightsByUrl
2025-10-25 00:56:19 +02:00
Gigi
6f04b8f513 chore: update bookmark components and remove migration docs
- Update BookmarkItem.tsx with latest changes
- Update CardView.tsx and CompactView.tsx bookmark view components
- Update ThreePaneLayout.tsx with latest modifications
- Remove TAILWIND_MIGRATION.md as migration is complete
2025-10-25 00:55:02 +02:00
Gigi
a8ad346c5d feat: implement smart highlight clearing for articles
- Preserve highlights that belong to the current article when switching articles
- Only clear highlights that don't match the current article coordinate or event ID
- Improve user experience by maintaining relevant highlights during navigation
2025-10-25 00:54:02 +02:00
Gigi
465c24ed3a fix: resolve highlight loading issues for articles
- Add missing eventStore parameter to fetchHighlightsForArticle call
- Clear highlights immediately when starting to load new article
- Fix infinite loading spinners when articles have zero highlights
- Ensure highlights are properly stored and persisted
2025-10-25 00:52:49 +02:00
Gigi
04dea350a4 fix: consolidate multiple skeleton loaders in article view
Remove duplicate ContentSkeleton components that were showing simultaneously.
Now uses a single skeleton for both loading and no-content states.

This follows DRY principles and prevents multiple skeletons from appearing
at the same time in the article view.
2025-10-25 00:47:50 +02:00
Gigi
29c4bcb69b fix: replace markdown loading spinner with skeleton
Replace the small spinner used for markdown content loading with a proper
ContentSkeleton for better visual consistency and user experience.

This ensures all content loading states use skeleton loaders instead of
spinners where appropriate.
2025-10-25 00:47:15 +02:00
Gigi
23ea7f352b fix: replace 'No readable content found' with skeleton loader
Replace the confusing 'No readable content found for this URL' message that
appears during loading states with a skeleton loader for better UX.

This prevents users from seeing error messages while content is still loading.
2025-10-25 00:46:27 +02:00
Gigi
36b35367f1 fix: prevent race conditions between content loaders
Add coordination logic to ensure only one content loader (article/external/event)
runs at a time. This prevents state conflicts that caused 'No readable content found'
errors and stale content from previous articles appearing.

The existing instant-load + background-refresh flow is preserved.
2025-10-25 00:45:13 +02:00
Gigi
183463c817 feat: align home button to left next to profile button
- Move home button from right side to left side in sidebar header
- Add sidebar-header-left container for left-aligned elements
- Update CSS to support new layout with flex positioning
- Home button now appears next to profile button when logged in
2025-10-25 00:32:14 +02:00
Gigi
7fb91e71f1 fix: add missing relayPool dependency to useEffect
- Add relayPool to dependency array in VideoView useEffect
- Fixes React hooks exhaustive-deps linting warning
- Ensures effect runs when relayPool changes
2025-10-25 00:31:15 +02:00
Gigi
717f094984 feat: use note content as title for direct video URLs
- Extract first 100 characters of note content as video title
- Truncate with ellipsis if content is longer than 100 characters
- Fallback to YouTube metadata title or original title if no note content
- Improves user experience by showing meaningful titles for direct videos from Nostr notes
2025-10-25 00:30:32 +02:00
Gigi
c69e50d3bb feat: add note content support for direct video URLs
- Add noteContent prop to VideoView component for displaying note text
- Update VideoView to prioritize note content over metadata when available
- Detect direct video URLs from Nostr notes (nostr.build, nostr.video domains)
- Pass bookmark information through URL selection in bookmark components
- Show placeholder message for direct videos from Nostr notes
- Maintains backward compatibility with existing video metadata extraction
2025-10-25 00:30:07 +02:00
Gigi
4e4d719d94 feat: add video thumbnail support for cover images
- Add YouTube thumbnail extraction using existing getYouTubeThumbnail utility
- Add Vimeo thumbnail support using vumbnail.com service
- Update VideoView to use video thumbnails as cover images in ReaderHeader
- Update Vimeo API to include thumbnail_url in response
- Fallback to original image prop if no video thumbnail available
- Supports both YouTube and Vimeo video thumbnails
2025-10-25 00:28:07 +02:00
Gigi
d453a6439c fix: improve video metadata extraction for YouTube and Vimeo
- Add actual YouTube title and description fetching via web scraping
- Fix syntax error in video-meta.ts (missing opening brace)
- Complete Vimeo metadata implementation
- Both APIs now properly extract title and description from video pages
- Caption extraction remains functional for supported videos
2025-10-25 00:24:30 +02:00
Gigi
5dfa6ba3ae feat: extract video functionality into dedicated VideoView component
- Create VideoView component with dedicated video player, metadata, and menu
- Remove video-specific logic from ContentPanel for better separation of concerns
- Update ThreePaneLayout to conditionally render VideoView vs ContentPanel
- Maintain all existing video features: YouTube metadata, transcripts, mark as watched
- Improve code organization and maintainability
2025-10-25 00:19:16 +02:00
Gigi
f2d2883eee docs: update CHANGELOG for v0.10.22
- Added mobile-optimized tab interface improvements
- Documented brand tagline update and UI reordering
- Listed mobile sidebar navigation fixes
- Recorded reading progress versioning removal
2025-10-25 00:11:19 +02:00
Gigi
84001d1b83 chore: bump version to 0.10.22 2025-10-25 00:10:49 +02:00
Gigi
b7a390cf89 Merge pull request #25 from dergigi/trying-to-fix-annoying-bugs
Mobile UX improvements and bug fixes
2025-10-25 00:10:31 +02:00
Gigi
59d9179642 ui: hide tab text labels on mobile for /my and /p/ pages
- Add CSS media query to hide .tab-label on screens ≤768px
- Adjust tab padding and gap for mobile to show only icons
- Saves space on mobile for highlights, bookmarks, reads, links, writings tabs
- Affects Me, Profile, and Explore components
2025-10-25 00:08:40 +02:00
Gigi
68301cd20f brand: update tagline from 'Nostr Bookmarks' to 'Read, Highlight, Explore'
- Update HTML title and meta tags in index.html
- Update PWA manifest in public/manifest.webmanifest
- Update Vite PWA manifest in vite.config.ts
- Update article OG title fallback in api/article-og.ts
- Reflects the core functionality: reading, highlighting, and exploring content
2025-10-25 00:06:19 +02:00
Gigi
4d6b7e1a46 ui: reorder bookmarks bar buttons
- Change button order to: Home, Explore, Settings
- Maintains all existing functionality
2025-10-25 00:05:23 +02:00
Gigi
95fe9b548f fix: close mobile sidebar when clicking navigation items
- Add mobile sidebar close logic to handleMenuItemClick for profile menu items
- Add mobile sidebar close logic to Home, Settings, and Explore buttons
- Fixes issue where mobile bookmarks bar didn't close when navigating to My Reads, My Highlights, etc.
2025-10-25 00:05:04 +02:00
Gigi
e86ae9f05e ui: move highlight button higher up
- Change bottom position from 32px to 80px
- Provides more space from bottom edge for better positioning
2025-10-25 00:04:02 +02:00
Gigi
2124be83c3 refactor: remove versioning from reading progress implementation
- Remove 'ver' field from ReadingProgressContent interface
- Remove ver: '1' from saveReadingPosition function
- Update PROGRESS_CACHE_KEY to remove v1 suffix
2025-10-25 00:02:40 +02:00
Gigi
a8bb17d4cd fix: replace any types with proper type-safe inert attribute handling 2025-10-23 22:06:35 +02:00
Gigi
a886a68822 feat(highlights): increase fetch limits for better explore page coverage
- Increase friends highlights limit from 200 to 1000
- Add 1000 limit to nostrverse highlights on initial load
- Ensures users see more highlights from friends and nostrverse
- Incremental syncs continue to use 'since' filter without limit
2025-10-23 21:59:58 +02:00
Gigi
76bdbc670d fix(nostrverse): merge incremental highlights instead of replacing
- Initialize highlightsMap with existing highlights on incremental sync
- Merge new highlights with existing ones instead of replacing entire list
- Keep existing highlights on error instead of clearing them
- Fixes issue where nostrverse highlights would disappear on page refresh
2025-10-23 21:54:53 +02:00
Gigi
c16ce1fc7e feat(explore): persist scope visibility to localStorage
- Load explore scope visibility from localStorage on mount
- Save user's scope toggles to localStorage when changed
- Only reset to settings defaults if no saved preference exists
- Ensures user's scope selection persists across page refreshes
2025-10-23 21:43:48 +02:00
Gigi
a578d67b1e fix: load reading positions for web bookmarks (kind:39701)
Web bookmarks store their URL in the 'd' tag, not in content.
The getBookmarkReadingProgress function was only extracting URLs from
content, which meant reading progress indicators weren't showing for
web bookmarks. Now it properly extracts URLs from the 'd' tag for
kind:39701 bookmarks.
2025-10-23 21:14:15 +02:00
Gigi
25d1ead9f5 fix: add error handling when closing relay connections
Wrap relay.close() in try-catch to gracefully handle cases where WebSocket
connections are closed before they're fully established. This can occur when
relay sets change rapidly during app initialization (e.g., when loading user
relay lists).
2025-10-23 20:52:42 +02:00
Gigi
ae5ea66dd2 fix(a11y): replace aria-hidden with inert to prevent focus issues
Replaced aria-hidden with inert attribute on mobile sidebar and highlights panes. The inert attribute both hides from assistive technology AND prevents focus, eliminating the accessibility warning about focused elements being hidden.
2025-10-23 20:48:36 +02:00
Gigi
cf5f8fae16 docs: update CHANGELOG for v0.10.21 2025-10-23 20:43:15 +02:00
Gigi
d9c46e602a chore: bump version to 0.10.21 2025-10-23 20:41:32 +02:00
Gigi
4d980bf91c fix: deduplicate bookmarks in Me component
- Add dedupeBookmarksById to flatten operation in Me.tsx
- Same article can appear in multiple bookmark lists/sets
- Use coordinate-based deduplication (kind:pubkey:identifier) for articles
- Prevents duplicate display when article is in multiple bookmark sources
2025-10-23 20:38:06 +02:00
Gigi
cb3b0e38e9 fix: show article title instead of summary in compact view
- Extract article title from tags in BookmarkItem
- Update CompactView to display title as main text for articles
- Remove unused articleSummary prop from CompactView to keep code DRY
- Follows NIP-23 article metadata structure
2025-10-23 20:29:38 +02:00
Gigi
fbf5c455ca fix: prevent tracking reading position for nostr-event sentinel URLs
- Add check to filter out nostr-event: URLs from reading position tracking
- nostr-event: is an internal sentinel, not a valid Nostr URI per NIP-21
- Prevents these sentinel URLs from being saved to reading position data
2025-10-23 20:21:41 +02:00
Gigi
ed5decf3e9 fix: properly route nostr-event URLs to /e/ path instead of /a/
- Add special handling for nostr-event: URLs in getReadItemUrl
- Add special handling for nostr-event: URLs in handleSelectUrl
- Prevent nostr-event URLs from being incorrectly routed to /a/ path (which expects naddr)
- Route nostr-event URLs to /e/ path for proper event loading
- Fixes 'String must be lowercase or uppercase' error when loading base64-encoded nostr-event URLs
2025-10-23 20:18:35 +02:00
Gigi
44a7e6ae2c docs: update CHANGELOG for v0.10.20 2025-10-23 20:08:49 +02:00
Gigi
f52b94d72a chore: bump version to 0.10.20 2025-10-23 20:07:04 +02:00
Gigi
d0833b5ed4 fix(lint): add headings to markdown rule files
Add top-level headings and proper spacing around lists in
markdown documentation files to satisfy markdown linting rules
2025-10-23 20:06:23 +02:00
Gigi
2f20b393bc fix(mobile): preserve scroll position and fix infinite loop
- Fix scroll position reset when toggling highlights panel on mobile
  by using a ref to store the position and requestAnimationFrame
  to restore it after the DOM updates
- Fix infinite loop in useReadingPosition caused by callbacks in
  dependency array by storing them in refs instead
2025-10-23 20:04:18 +02:00
Gigi
13fa6cd485 fix(mobile): preserve scroll position when toggling highlights panel
When opening/closing the highlights sidebar on mobile, the body gets
position:fixed to prevent background scrolling. This was causing the
scroll position to reset to the top.

Now we save the scroll position before locking, apply it as a negative
top value to maintain visual position, and restore it when unlocking.
2025-10-23 20:00:16 +02:00
Gigi
e6e7240cd5 fix: navigate to article route instead of passing empty URL
- Update CompactView to navigate to /a/:naddr for kind:30023 articles
- Update BookmarkItem handleReadNow to navigate to /a/:naddr for articles
- Fixes issue where clicking bookmarked articles showed 'Select a bookmark' message
2025-10-23 19:55:11 +02:00
Gigi
c1ff3b44d1 fix: stop infinite skeleton loading when article has zero highlights 2025-10-23 17:32:54 +02:00
Gigi
0577f862fd feat: show Web Bookmarks first when grouped by source 2025-10-23 17:30:55 +02:00
Gigi
883cb352ff chore: update package-lock version to 0.10.19 2025-10-23 17:26:44 +02:00
Gigi
238cc9bc00 docs: update CHANGELOG for v0.10.19 2025-10-23 17:06:34 +02:00
Gigi
1800ee324e chore: bump version to 0.10.19 2025-10-23 17:01:33 +02:00
Gigi
7d2dac2f1a fix: remove unused props and clean up linting errors
- Remove unused lastFetchTime parameter from BookmarkList
- Remove unused loading and onRefresh parameters from HighlightsPanelHeader
- Update HighlightsPanel to not pass removed props
- All linting and type checking now passing
2025-10-23 17:00:55 +02:00
Gigi
7875f1d0bd refactor: remove refresh button from bookmarks sidebar
Remove the refresh IconButton from bookmarks sidebar and clean up unused imports (faRotate, formatDistanceToNow).
2025-10-23 16:57:56 +02:00
Gigi
d9263e07d1 refactor: remove refresh button from highlights sidebar
Remove the refresh IconButton from highlights panel header as it's no longer needed.
2025-10-23 16:56:51 +02:00
Gigi
9a345a7347 style: match highlights collapse button to bookmarks collapse button
Replace IconButton with native button element and apply same CSS styling as bookmarks collapse button for visual consistency.
2025-10-23 16:56:04 +02:00
Gigi
55d1af3bf9 refactor: move collapse highlights button to left side
Move the collapse highlights panel button from right to left side of the header, making it symmetrical to the bookmarks collapse button. Desktop only (hidden on mobile).
2025-10-23 16:54:08 +02:00
Gigi
feb3134b65 refactor: move grouping toggle to left side next to support button
Move the grouped/chronological toggle button from right/center to the left side, positioned next to the orange heart support button in both BookmarkList and Me components for better UX consistency.
2025-10-23 16:52:24 +02:00
Gigi
7d222e099f refactor: make profile picture trigger dropdown menu
Remove separate three-dot button and make the profile picture itself trigger the dropdown menu. This provides a cleaner, more intuitive UX.
2025-10-23 16:49:54 +02:00
Gigi
59436b5b9e refactor: remove redundant logout button from sidebar header
Remove standalone logout IconButton next to explore since logout is now available in the three-dot profile menu
2025-10-23 16:48:18 +02:00
Gigi
2e08954e83 feat: add three-dot profile menu to sidebar header
Add dropdown menu next to profile picture in bookmarks sidebar with:
- My Highlights
- My Bookmarks
- My Reads
- My Links
- My Writings
- Separator
- Logout

Includes click-outside-to-close functionality and smooth animations.
2025-10-23 16:47:26 +02:00
Gigi
9cb1791a3a docs: update CHANGELOG for v0.10.18 2025-10-23 16:39:07 +02:00
Gigi
28ba620967 chore: bump version to 0.10.18 2025-10-23 16:37:43 +02:00
Gigi
56f2d33e93 fix: fetch all highlights without incremental loading
- Remove incremental loading (since filter) from highlightsController
- Fetch ALL highlights without limits for complete results
- Remove unused timestamp tracking methods and constant
- Ensures /my/highlights shows all highlights consistently
- Matches the fix applied to writingsController
2025-10-23 16:36:02 +02:00
Gigi
312c742969 refactor: centralize writings fetching in controller
- Remove incremental loading (since filter) from writingsController
- Fetch ALL writings without limits for complete results
- Remove duplicate background fetch from Me.tsx and Profile.tsx
- Use writingsController.start() in Profile to populate event store
- Keep code DRY by having single source of truth in controller
- Follows controller pattern: stream, dedupe, store, emit
2025-10-23 16:31:56 +02:00
Gigi
0781c4ebfc fix: fetch all writings in background on /my/writings page
Add background fetch effect to Me component to populate event store with
all writings without limits, matching the behavior of Profile component.
This ensures all writings are displayed on /my/writings page.
2025-10-23 16:28:50 +02:00
Gigi
85f4cd3590 docs: update FEATURES and CHANGELOG from /me to /my 2025-10-23 16:18:32 +02:00
Gigi
89bc6258b1 docs: update CSS comments from Me to My 2025-10-23 16:14:32 +02:00
Gigi
534b628aea refactor: update ShareTargetHandler to navigate to /my/links 2025-10-23 16:13:35 +02:00
Gigi
317d2e0b53 refactor: update Bookmarks component to detect /my routes 2025-10-23 16:13:20 +02:00
Gigi
9ea69589fa refactor: update sidebar avatar navigation to /my 2025-10-23 16:12:52 +02:00
Gigi
89eaa97d30 refactor: update Me component navigation to /my routes 2025-10-23 16:12:33 +02:00
Gigi
0283405fb5 refactor: rename /me routes to /my in App.tsx 2025-10-23 16:12:01 +02:00
Gigi
5eade913d1 docs: update CHANGELOG for v0.10.17 2025-10-23 16:01:31 +02:00
Gigi
15a7129b6d chore: bump version to 0.10.17 2025-10-23 15:59:45 +02:00
Gigi
b9e17e0982 fix: remove unused variable in highlight timestamp handler 2025-10-23 15:59:11 +02:00
Gigi
1be8c62c94 fix: restore edge-to-edge hero image on mobile with adjusted negative margins 2025-10-23 15:56:34 +02:00
Gigi
e2bf243b01 style: increase mobile reader padding to 1rem for better title/body alignment 2025-10-23 15:55:00 +02:00
Gigi
85d816b2a7 style: increase horizontal padding in reader on mobile for better readability 2025-10-23 15:52:36 +02:00
Gigi
623bee4632 fix: timestamp in highlight cards now opens content in app instead of external search 2025-10-23 09:44:20 +02:00
Gigi
e68b97bde8 fix: add equal right padding to blockquotes for better mobile layout 2025-10-23 09:41:25 +02:00
Gigi
ca32dfca51 perf: reduce reading position throttle from 3s to 1s
Reading position now saves every 1 second during continuous scrolling
instead of every 3 seconds, providing more frequent position updates.
2025-10-23 01:04:54 +02:00
Gigi
9de8b00d5d chore: remove remaining console.log statements from reading position code
Removed all debug logs from readingPositionService.ts that were left
from the stabilization collector implementation.
2025-10-23 00:56:53 +02:00
Gigi
033ef5e995 fix: relay article link now opens via /a/ path instead of /r/
Updated handleLinkClick in PWASettings to check if URL is an internal
route (starts with /) and navigate directly, otherwise wrap external
URLs with /r/ path. This fixes the third relay education link to open
the nostr article correctly.
2025-10-23 00:54:29 +02:00
Gigi
c986b0d517 feat: add setting to control auto-scroll to reading position
- Added autoScrollToReadingPosition setting (enabled by default)
- Users can now disable auto-scroll while keeping position sync enabled
- Setting appears in Layout & Behavior section of settings
- Auto-scroll only happens when both syncReadingPosition and
  autoScrollToReadingPosition are enabled
2025-10-23 00:52:51 +02:00
Gigi
1729a5b066 chore: remove debug logs from reading position code 2025-10-23 00:51:01 +02:00
Gigi
c6186ea84e docs: update CHANGELOG for v0.10.16 2025-10-23 00:48:24 +02:00
Gigi
c798376411 chore: bump version to 0.10.16 2025-10-23 00:47:38 +02:00
Gigi
e83c301e6a fix(reading-position): don't clear save timer when tracking toggles
The save timer was being cleared every time the effect unmounted (when
tracking toggled on/off), preventing saves from ever completing.

Now the save timer persists across tracking toggles and will fire even
if tracking is temporarily disabled. This fixes the core issue where
saves were scheduled but never executed.
2025-10-23 00:45:05 +02:00
Gigi
2c0aee3fe4 debug(reading-position): add comprehensive logging to scheduleSave
Adding detailed logs to trace exactly what's happening when saves
are attempted. This will help identify why saves aren't working.
2025-10-23 00:43:42 +02:00
Gigi
d0f043fb5a debug(reading-position): add logging to track isTextContent changes
Added detailed logging to understand why isTextContent is changing
and causing tracking to toggle on/off.
2025-10-23 00:42:29 +02:00
Gigi
039b988869 fix(reading-position): prevent tracking from toggling on/off
Added logic to properly disable tracking when isTextContent becomes false.
This prevents the tracking state from flipping and ensures saves work
consistently.

Now tracking is only enabled once content is stable and stays enabled
until the article changes or content becomes unsuitable.
2025-10-23 00:42:08 +02:00
Gigi
d285003e1d fix(reading-position): fix infinite loop and enable saves
Fixed maximum update depth error by using refs for html/markdown content
instead of including them in useCallback dependencies. This prevents
handleSavePosition from being recreated on every content change, which
was causing scheduleSave to recreate, triggering infinite effect loops.

Now:
- handleSavePosition is stable across renders
- scheduleSave is stable
- Effect doesn't re-run infinitely
- Saves work properly with 3s throttle
2025-10-23 00:40:36 +02:00
Gigi
530abeeb33 fix(reading-position): remove noisy suppression logs and reduce suppression time
Changes:
- Removed log spam during suppression (was logging on every scroll event)
- Reduced suppression time from 2000ms to 1500ms for smooth scroll
  (500ms render delay + 1000ms smooth scroll animation)

The suppression still works but is now silent to avoid console spam.
After smooth scroll completes, saves will resume normally.
2025-10-23 00:38:30 +02:00
Gigi
3ac6954cb7 refactor(reading-position): remove unused complexity
Removed unnecessary refs and logic that are no longer needed with
the simple 3s throttle:

- Removed lastSavedPosition (not used for any logic)
- Removed hasSavedOnce (not used)
- Removed lastSavedAtRef (not used)
- Removed saveNow() function (no longer needed after removing save-on-unmount)
- Simplified to just lastSaved100Ref to prevent duplicate 100% saves

The hook is now much simpler and easier to understand.
2025-10-23 00:36:20 +02:00
Gigi
1c0f619a47 refactor(reading-position): remove 5% delta requirement
Simplified throttle logic to just save every 3 seconds during scrolling,
regardless of how much the position changed. This ensures all position
updates are captured reliably.

The 5% check was causing issues and unnecessary complexity. Now:
- First scroll schedules a save in 3s
- Continued scrolling updates pending position
- Timer fires and saves latest position
- Next scroll schedules another save in 3s

Simple and reliable.
2025-10-23 00:34:47 +02:00
Gigi
0fcfd200a4 fix(reading-position): fix throttle logic to work with slow scrolling
Previous fix didn't work because after a save, the 5% check would
prevent scheduling a new timer during slow scrolling.

Changes:
- Always update pendingPositionRef (line 62)
- Schedule timer if significant change OR 3s has passed since last save
- Check 5% delta again when timer fires before actually saving

This ensures continuous slow scrolling triggers saves every 3s.
2025-10-23 00:33:55 +02:00
Gigi
e01c8d33fc fix(reading-position): use throttle instead of debounce for saves
Changed from debounce (which resets timer on every scroll) to throttle
(which saves at regular 3s intervals). This ensures position is saved
during continuous slow scrolling.

Key changes:
- Don't reset timer if one is already pending
- Track latest position in pendingPositionRef
- Save the latest position when timer fires, not the position from when scheduled

This prevents the issue where slow continuous scrolling would never
trigger a save because the debounce timer kept resetting.
2025-10-23 00:31:29 +02:00
Gigi
51c0f7d923 fix(highlights): scroll to highlight when clicked from /me/highlights
Pass highlightId and openHighlights in navigation state when clicking
highlights from the highlights list. This triggers the scroll behavior
in Bookmarks.tsx that was already implemented but not being used.

The useHighlightInteractions hook automatically scrolls to the selected
highlight once the article loads and the highlight mark is found in the DOM.
2025-10-23 00:27:35 +02:00
Gigi
8c79b5fd75 docs: update CHANGELOG for v0.10.15 2025-10-23 00:26:13 +02:00
Gigi
29746f1042 chore: bump version to 0.10.15 2025-10-23 00:24:49 +02:00
Gigi
829ec4bf6e fix(reading-position): fix infinite loop causing spam saves
The root cause was scheduleSave being in the scroll effect's dependency array.
Even though scheduleSave had an empty dependency array, React still saw it as
a dependency and re-ran the effect constantly, causing unmount/remount loops
and triggering flush-on-unmount repeatedly.

Solution: Store scheduleSave in a ref (scheduleSaveRef) and call it via the ref
in the scroll handler. This removes scheduleSave from the effect dependencies
while still allowing the scroll handler to access the latest version.

This fixes the "Maximum update depth exceeded" error and stops the spam saves.
2025-10-23 00:20:55 +02:00
Gigi
30ae0d9dfb fix(reading-position): prevent spam saves during scroll animation
The issue was that scheduleSave and saveNow had syncEnabled/onSave in their
dependency arrays, causing them to be recreated when those props changed.
This triggered the scroll effect to unmount/remount repeatedly during smooth
scroll animations, flushing saves on each unmount.

Solution: Use refs (syncEnabledRef, onSaveRef) for all callback dependencies,
making scheduleSave and saveNow stable with empty dependency arrays. This
prevents effect re-runs and stops the save spam.

Now the scroll effect only runs once per article load, not on every render.
2025-10-23 00:19:04 +02:00
Gigi
8924f1b307 fix(reading-position): flush pending saves on unmount
Previously, if user navigated away within the 3-second debounce window,
the pending save would be canceled and reading progress would be lost.

Now flushes any pending save on unmount if:
- There's a pending save timer active
- Position has changed by at least 5% since last save
- Not currently in suppression window (e.g., during restore)

This ensures reading progress is always saved even when navigating away
quickly, while still avoiding the 0% save issue from back navigation
(which doesn't trigger scroll events that would set up a pending save).

Uses refs to stabilize cleanup function and avoid effect re-runs.
2025-10-23 00:16:51 +02:00
Gigi
f92fa2cc93 fix(reading-position): prevent 0% saves during back navigation
Removed save-on-unmount behavior that was causing 0% position saves when
using mobile back gesture. The browser scrolls to top during navigation,
triggering a position update to 0% before unmount, which then gets saved.

The auto-save with 3-second debounce already captures position during
normal reading, so saving on unmount is unnecessary and error-prone.

Fixes issue where back gesture on mobile would overwrite reading progress.
2025-10-23 00:15:21 +02:00
Gigi
cc70b533e5 refactor(reading-position): use pre-loaded data from controller
Instead of fetching reading position from scratch using collectReadingPositionsOnce,
now uses the position already loaded by readingProgressController and displayed on cards.

Benefits:
- Faster restore (no network wait)
- Simpler code (no stabilization window needed)
- Data consistency (same data shown on card and used for restore)
- Reduced relay queries
2025-10-23 00:06:35 +02:00
Gigi
003c439658 feat(reading-position): restore smooth animated scroll
Changed scroll behavior from 'auto' to 'smooth' when restoring reading position for better UX.
2025-10-23 00:05:05 +02:00
Gigi
019958073c fix(lint): add missing dependencies to restore effect
Added isTrackingEnabled and restoreKey to dependency array to satisfy react-hooks/exhaustive-deps rule.
2025-10-23 00:04:33 +02:00
Gigi
3d47dddbd2 refactor(reading): simplify back to basics, remove complex timing logic
Removed:
- isTrackingEnabled state and delays
- Complex composite keys
- Verbose debug logging
- isTrackingEnabledRef checks

Back to simple:
- isTextContent = basic check (loading, content exists, not video)
- Restore once per articleIdentifier
- Save on unmount
- Suppression during restore window

Much simpler, closer to original working version.
2025-10-23 00:02:26 +02:00
Gigi
cabf897df8 fix(reading): stabilize tracking enabled state to prevent reset loops
Split tracking enable logic into two effects:
1. Reset to false when article changes (selectedUrl)
2. Enable after 500ms if isTextContent is true AND not already enabled

Prevents isTextContent flipping from resetting the timer repeatedly, which was preventing isTrackingEnabled from ever becoming true.
2025-10-22 23:59:06 +02:00
Gigi
4801c0d621 debug(reading): add detailed logging to restore effect
Add comprehensive debug log showing all dependency states to diagnose why restore never initiates.
2025-10-22 23:57:20 +02:00
Gigi
ae76d6e4ea chore(reading): remove noisy debounce log messages
Removed 'Debouncing save for 3000ms' logs that spam console on every scroll event. Keep only the actual save execution logs.
2025-10-22 23:55:59 +02:00
Gigi
a611e99ff6 fix(reading): only saveNow on unmount if tracking was enabled
Prevents saving 0% position when navigating away before tracking starts. Now checks isTrackingEnabledRef before calling saveNow() in unmount effect.
2025-10-22 23:53:50 +02:00
Gigi
1c039e164f fix(reading): wait for tracking to be enabled before attempting restore
Use composite key (articleIdentifier + isTrackingEnabled) to ensure restore only happens once after:
1. Article loads
2. Content is validated (long enough)
3. 500ms stability delay passes
4. Tracking is enabled

Prevents multiple rapid restore attempts during initial load.
2025-10-22 23:50:50 +02:00
Gigi
ffa4b38106 fix(reading): reset restore attempt tracker when article changes
Previously, if restore was skipped due to missing dependencies (content not loaded), it would never retry even after content loaded. Now resets the attempt tracker whenever articleIdentifier changes, allowing retry when dependencies become available.
2025-10-22 23:49:07 +02:00
Gigi
3b22cb5c5d feat(reading): only track position on loaded, long-enough content
- Check content length before enabling tracking (uses existing 1000 char minimum)
- Wait 500ms after content loads before enabling tracking (ensures stability)
- Prevents tracking on short notes and during page load transitions
- isTextContent now uses useMemo with comprehensive checks
2025-10-22 23:46:19 +02:00
Gigi
7bc4522be4 fix(reading): prevent false 100% detection during page transitions
Add guards to prevent detecting 100% completion when:
- Document height is < 100px (likely during transition)
- scrollTop is < 100px (not actually scrolled)

Prevents accidentally saving 100% when navigating away at 50%.
2025-10-22 23:35:55 +02:00
Gigi
048e0d802b fix(reading): make saveNow respect suppression flag
saveNow() was bypassing suppression, causing 0% to overwrite saved positions during restore. Now checks suppressUntilRef before saving, just like the debounced auto-save.
2025-10-22 23:33:31 +02:00
Gigi
b282bc4972 fix(reading): suppress saves during restore to prevent overwriting
- Suppress saves for 1700ms when restore starts (covers collection + render time)
- If no position found or delta too small, clear suppression immediately
- If restore happens, extend suppression for 1.5s after scroll
- Prevents 0% from overwriting saved 22% position during page load
2025-10-22 23:31:47 +02:00
Gigi
c1a23c1f8f fix(reading): prevent restore effect from restarting during content load
Track whether we've already attempted restore for each article using a ref. Prevents the effect from restarting multiple times as html/markdown/loading state changes during initial page load, which was stopping the stabilization timer before it could complete.
2025-10-22 23:29:29 +02:00
Gigi
8a5aacfe7b feat(reading): allow saving 0% position for open tracking
Removed the check that prevented saving 0% positions. Now tracks when articles are opened, even if not read yet. Useful for engagement metrics and history.
2025-10-22 23:28:28 +02:00
Gigi
9126910de5 fix(reading): stabilize restore effect and prevent 0% saves
- Use ref for suppressSavesFor to prevent restore effect from restarting on every position change
- Skip saving positions at 0% (meaningless start position)
- Restore effect now only restarts when article actually changes, not on every scroll
2025-10-22 23:24:56 +02:00
Gigi
496bbc36f4 fix(reading): prevent saveNow from firing on every position change
The unmount effect had saveNow in its dependency array. Since saveNow is a useCallback that depends on position, it was recreated on every scroll event, triggering the effect cleanup and calling saveNow() repeatedly (every ~14ms).

Now using a ref to store the latest saveNow callback, so the cleanup only runs when selectedUrl changes (i.e., when actually navigating away).
2025-10-22 23:22:16 +02:00
Gigi
90f25420b2 debug(reading): add ISO timestamps to all position logs
Makes it easy to see exact timing of saves and identify if debouncing is working correctly.
2025-10-22 23:21:11 +02:00
Gigi
9167134a89 refactor(reading): increase debounce to 3 seconds 2025-10-22 23:18:40 +02:00
Gigi
b5717f1ebf docs: update CHANGELOG with simplified debouncing 2025-10-22 23:17:53 +02:00
Gigi
0c8eaaf220 refactor(reading): simplify save debouncing to 2s max
Replace complex interval logic with simple 2-second debounce. Every scroll event resets the timer, so saves only happen after 2s of no scrolling (or when reaching 100%). Much less aggressive than the previous 15s minimum interval.
2025-10-22 23:17:34 +02:00
Gigi
80b2720838 docs: update CHANGELOG with debug logging addition 2025-10-22 23:14:47 +02:00
Gigi
ea69740fc8 debug(reading): add comprehensive logging for position restore and save
Add detailed console logs to trace:
- Position collection and stabilization
- Save scheduling, suppression, and execution
- Restore calculations and decisions
- Scroll deltas and thresholds

Logs use [reading-position] prefix with emoji indicators for easy filtering and visual scanning.
2025-10-22 23:14:29 +02:00
Gigi
d650997ff9 docs: update CHANGELOG with reading restore stabilization 2025-10-22 23:06:54 +02:00
Gigi
ba3554b173 feat(reading): one-shot restore with suppression in ContentPanel
Replace continuous restore with stabilized one-shot collector. Suppress saves for 1.5s after restore, skip tiny deltas (<48px or <5%), and use instant scroll (behavior: auto) to eliminate jumpy view behavior from conflicting relay updates.
2025-10-22 23:06:34 +02:00
Gigi
2cc39d0200 feat(reading): add stabilized position collector in readingPositionService
Add collectReadingPositionsOnce() that buffers position updates for ~700ms, then emits the best one (newest timestamp, tie-break by highest progress). Prevents jumpy scrolling from conflicting relay updates.
2025-10-22 23:05:50 +02:00
Gigi
9aa914a704 feat(reading): add save suppression to useReadingPosition
Add suppressSavesFor(ms) API to temporarily block auto-saves after programmatic scrolls, preventing feedback loops where restore triggers save which triggers restore.
2025-10-22 23:05:11 +02:00
Gigi
497b6fa4be docs: update CHANGELOG for v0.10.14 2025-10-22 15:49:23 +02:00
Gigi
4c838b0123 chore: bump version to 0.10.14 2025-10-22 15:48:36 +02:00
Gigi
d551f66ef1 feat: add Relay Setup 101 article link to PWA settings
- Added third relay education article link in PWA settings
- Links to /a/naddr1qvzqqqr4gupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqq9hyetvv9uj6um9w36hq9mgjg8
- Updated punctuation to use commas for better readability (here, here, and here)
2025-10-22 15:47:26 +02:00
Gigi
34514199ee feat: timestamp in cards now opens content in app instead of external search
- Changed timestamp links in CardView and LargeView to use internal routes
- Articles (kind:30023) open in /a/{naddr}
- Notes (kind:1) open in /e/{eventId}
- External URLs open in /r/{encodedUrl}
- Removed unused eventNevent prop and neventEncode import
- Timestamp now uses Link component for client-side navigation
2025-10-22 15:46:25 +02:00
Gigi
228304f68a fix: prevent duplicate video embeds and stray HTML artifacts
- Refactored VideoEmbedProcessor to process HTML and extract URLs in single pass
- Previously processedHtml and videoUrls were computed separately, causing index mismatches
- Now both are computed together ensuring placeholders match collected URLs
- Added check to skip empty HTML parts to prevent rendering stray characters
2025-10-22 15:42:19 +02:00
Gigi
ba263acdff fix: stop highlights loading spinner when article has no highlights 2025-10-22 15:40:18 +02:00
Gigi
5131cbe12c docs: update CHANGELOG for v0.10.13 2025-10-22 15:38:08 +02:00
Gigi
fa8eed4f4e chore: bump version to 0.10.13 2025-10-22 15:36:42 +02:00
Gigi
3ff57c4b67 fix(lint): add previewData to useArticleLoader effect dependencies 2025-10-22 15:36:03 +02:00
Gigi
51c364ea53 feat(article): instant preview from blog cards - show title, image, summary, date immediately via navigation state while content loads 2025-10-22 15:33:37 +02:00
Gigi
4d032372dc fix(explore): show blog post skeletons instead of spinner when loading writings tab 2025-10-22 15:31:19 +02:00
Gigi
48b5aa3a30 feat(article): instant load from eventStore when clicking bookmark cards - check store by coordinate before relay query 2025-10-22 15:29:08 +02:00
Gigi
d4483a2f91 fix(lint): resolve eslint warnings in useArticleLoader - add comment for empty catch, use settingsRef consistently 2025-10-22 15:26:16 +02:00
Gigi
c62cb21962 fix(article): wire eventStore to useArticleLoader for instant local-first loads; keep SW enabled in prod for PWA 2025-10-22 15:24:24 +02:00
Gigi
3f7d726ae6 feat(article): local-first streaming loader using eventStore + queryEvents in useArticleLoader; emit immediately on first store/relay hit; finalize on EOSE 2025-10-22 15:22:39 +02:00
Gigi
ac0e5eb585 fix(article): add reliable-relay fallback (nostr.band, primal, damus, nos.lol) when first parallel query returns no events 2025-10-22 14:03:47 +02:00
Gigi
5a0dd49e4e fix(sw): disable Service Worker in dev and register non-module SW only in production to avoid stale cached HTML causing mismatched content 2025-10-22 14:01:53 +02:00
Gigi
d067193f21 fix(reader): force re-mount of markdown preview and rendered HTML per-content to eliminate stale display when switching articles 2025-10-22 13:46:57 +02:00
Gigi
774e2ba67c fix(reader): clear markdown render on change and add request guards to external URL loader to prevent stale content 2025-10-22 13:45:41 +02:00
Gigi
6f1c31058f fix(reader): guard against stale article fetches overwriting current content/highlights via requestId in useArticleLoader 2025-10-22 13:41:46 +02:00
Gigi
7551a05aee fix(article): prevent re-fetch on settings change by memoizing via ref in useArticleLoader 2025-10-22 13:38:24 +02:00
Gigi
df485b883d fix(article): query union of naddr relay hints and configured relays to prevent post-load ‘Article not found’ refresh 2025-10-22 13:35:59 +02:00
Gigi
6f428af1bc docs: update CHANGELOG for v0.10.12 2025-10-22 13:32:05 +02:00
Gigi
e821aaf058 chore: bump version to 0.10.12 2025-10-22 13:30:37 +02:00
Gigi
a84d439489 fix: properly deduplicate web bookmarks by d-tag
- Web bookmarks (kind:39701) are replaceable events and should be deduplicated by d-tag
- Update dedupeNip51Events to include kind:39701 in d-tag deduplication logic
- Use coordinate format (kind:pubkey:d-tag) for web bookmark IDs instead of event IDs
- Ensures same URL bookmarked multiple times only appears once
- Keeps newest version when duplicates exist
2025-10-22 13:28:36 +02:00
Gigi
67bf7e017d fix: make profile avatar button same size as other icon buttons on mobile
- Add mobile media query to profile-avatar-button for consistent sizing
- Use --min-touch-target (44px) on mobile to match IconButton components
- Ensures consistent touch target size across all sidebar buttons
2025-10-22 13:26:20 +02:00
Gigi
e47419a0b8 feat: update explore icon to fa-person-hiking and reorder sidebar buttons
- Change explore icon from fa-newspaper to fa-person-hiking in SidebarHeader and Explore components
- Switch positions of settings and explore buttons in sidebar navigation
- Remove all console.log statements from bookmarkController and bookmarkProcessing
- Update CHANGELOG.md with v0.10.11 changes
2025-10-22 13:25:34 +02:00
Gigi
2dda52c30f chore: bump version to 0.10.11 2025-10-22 13:19:36 +02:00
Gigi
2e0a493243 fix(bookmarks): sort by display time (created_at || listUpdatedAt) desc; nulls last 2025-10-22 13:07:37 +02:00
Gigi
2e955e9bed refactor(bookmarks): never default timestamps to now; allow nulls and sort nulls last; render empty when missing 2025-10-22 13:04:24 +02:00
Gigi
538cbd2296 fix(bookmarks): show sane dates using created_at fallback to listUpdatedAt; guard formatters 2025-10-22 12:54:26 +02:00
Gigi
c17eab5a47 fix(router): route /me/reading-list -> /me/bookmarks to render Bookmarks view 2025-10-22 12:49:23 +02:00
Gigi
b3c61ba635 fix: update Me.tsx bookmarks tab to use dynamic filter titles and chronological sorting 2025-10-22 12:46:16 +02:00
Gigi
3bfa750a0c fix: update Me.tsx to use faClock icon instead of faBars 2025-10-22 12:42:42 +02:00
Gigi
d1f7e549c2 fix: change bookmark URL from /me/reading-list to /me/bookmarks 2025-10-22 12:33:05 +02:00
Gigi
0fec120410 debug: add targeted logging to diagnose listUpdatedAt timestamp issue 2025-10-22 12:31:53 +02:00
Gigi
9b21075a9b refactor: remove excessive debug logging 2025-10-22 12:29:09 +02:00
Gigi
4f78ee4794 fix: preserve content created_at, add listUpdatedAt for sorting by when bookmarked 2025-10-22 12:26:01 +02:00
Gigi
8bb871913b refactor: remove synthetic added_at field, use created_at from bookmark list event 2025-10-22 12:18:43 +02:00
Gigi
49eb6855ca debug: add console logging for bookmark timestamp and sorting analysis 2025-10-22 12:14:36 +02:00
Gigi
748b2e1631 fix: correct added_at timestamp to use bookmark list creation time, not content creation time 2025-10-22 12:12:44 +02:00
Gigi
9fa83a2a1c fix: ensure robust sorting of merged bookmarks with fallback timestamps 2025-10-22 12:07:32 +02:00
Gigi
d45705e8e4 feat: use clock icon (regular style) for chronological bookmark view 2025-10-22 12:05:57 +02:00
Gigi
83c170b4e2 fix: ensure bookmarks are consistently sorted chronologically with useMemo 2025-10-22 12:04:41 +02:00
Gigi
8459853c43 refactor: remove bookmark count from section headings 2025-10-22 12:02:24 +02:00
Gigi
f7eeb080e1 feat: update bookmark heading based on selected filter 2025-10-22 12:01:09 +02:00
Gigi
2769b2dba7 fix: remove unused faTimes import 2025-10-22 11:51:17 +02:00
Gigi
46636b8e6a feat: move profile picture to first position (left-aligned) with consistent sizing 2025-10-22 11:50:16 +02:00
Gigi
92a85761ef feat: make highlight count clickable to open highlights sidebar 2025-10-22 11:48:41 +02:00
Gigi
f6a325f7e9 feat: hide close/collapse sidebar buttons on mobile 2025-10-22 11:45:51 +02:00
Gigi
a501fa816f feat: sort bookmarks chronologically by displayed date (newest first) 2025-10-22 11:43:30 +02:00
Gigi
5ece80b8e9 feat: change default bookmark view to flat chronological list 2025-10-22 11:42:12 +02:00
Gigi
87c017b2c2 docs: update CHANGELOG for v0.10.10 2025-10-22 11:38:09 +02:00
Gigi
550ee415f0 chore: bump version to 0.10.10 2025-10-22 11:37:08 +02:00
Gigi
aaaf226623 Merge pull request #24 from dergigi/controllers-and-fetching
Replace timeouts with streaming controllers and fix bookmark hydration
2025-10-22 11:36:35 +02:00
Gigi
23ce0c9d4c chore: remove debug logging from bookmark controller 2025-10-22 11:33:41 +02:00
Gigi
dddf8575c4 fix: resolve TypeScript type errors in bookmark hydration promises
Add .then() handlers to convert Promise<NostrEvent[]> to Promise<void>
for compatibility with Promise<void>[] array type.
2025-10-22 11:30:53 +02:00
Gigi
3ab0610e1e fix: prevent cascading hydration loops in bookmark controller
Run all coordinate queries in parallel with Promise.all instead of
sequential awaits. This prevents each query from triggering a rebuild
that causes another hydration cycle, which was creating infinite loops.

The issue was that awaiting each query sequentially would:
1. Fetch articles for author A
2. Call onProgress, rebuild bookmarks
3. Trigger new hydration because coordinates changed
4. Repeat indefinitely

Now all queries start at once and stream results as they arrive,
matching the original loader behavior.
2025-10-22 11:27:12 +02:00
Gigi
e40f820fdc fix: handle empty d-tags separately in bookmark hydration
Separate fetching of articles with empty vs non-empty d-tags to work
around relay filter handling issues. For empty d-tags, fetch all events
of that kind/author and filter client-side.
2025-10-22 11:25:30 +02:00
Gigi
3f82bc7873 debug: add logging for bookmark coordinate hydration 2025-10-22 11:23:26 +02:00
Gigi
b913cc4d7f fix: hide 'Open Original' button for nostr-native events
Only external URLs (/r/ paths) have original sources.
Nostr-native events don't need this option in the three-dot menu.
2025-10-22 11:21:08 +02:00
Gigi
bc1aed30b4 fix: open nostr events directly on ants.sh instead of as search query
When clicking search in the three-dot menu for a nostr event,
now opens https://ants.sh/e/<eventId> directly instead of
https://ants.sh/?q=nostr-event:<eventId>
2025-10-22 11:20:12 +02:00
Gigi
9a801975aa fix(bookmarks): replace applesauce loaders with local-first queryEvents
Replace EventLoader and AddressLoader with queryEvents for bookmark
hydration to properly prioritize local relays. The applesauce loaders
were not using local-first fetching strategy, causing bookmarked events
to not be hydrated from local relay cache.

- Remove createEventLoader and createAddressLoader usage
- Replace with queryEvents which handles local-first fetching
- Properly streams events from local relays before remote relays
- Follows the controller pattern used by other services (writings, etc)

This fixes the issue where bookmarks would only show event IDs instead
of full content, while blog posts (kind:30023) worked correctly.
2025-10-22 11:16:21 +02:00
Gigi
f3e44edd51 fix: remove unnecessary key prop causing lag on tab switching in Explore 2025-10-22 11:09:05 +02:00
Gigi
0be6aa81ce fix: add comments to empty catch blocks to satisfy linter 2025-10-22 09:00:01 +02:00
Gigi
c7b885cfcd refactor(reader): use startReadingPositionStream in ContentPanel 2025-10-22 08:55:50 +02:00
Gigi
11041df1fb refactor(reading-position): add startReadingPositionStream and remove timeouts 2025-10-22 08:55:18 +02:00
Gigi
89273e2a03 refactor(settings): use startSettingsStream in useSettings hook 2025-10-22 08:54:45 +02:00
Gigi
0610454e74 feat(settings): add startSettingsStream and remove timeout-based blocking 2025-10-22 08:54:17 +02:00
Gigi
a02413a7cb fix(reading-progress): load and display progress on fresh sessions; include external URL keys and avoid double-encoding; add debug guard 2025-10-22 02:02:39 +02:00
Gigi
0bc84e7c6c chore: update package-lock.json for v0.10.9 2025-10-22 01:41:46 +02:00
Gigi
a1e28c6bc9 docs: update CHANGELOG for v0.10.9 2025-10-22 01:41:34 +02:00
Gigi
a1a7f0e4a4 chore: bump version to 0.10.9 2025-10-22 01:41:14 +02:00
Gigi
cde8e30ab2 fix(events): improve /e/ reliability with retry + backoff in eventManager
- Add multi-attempt fetch with backoff
- Retry on not-found, errors, and timeouts before failing
- Keep deduplication and cache-first behavior
2025-10-22 01:40:26 +02:00
Gigi
aa7e532950 fix(bookmarks): use per-item added_at/created_at when available
- Read / from applesauce pointers for notes/articles
- Fallback to eventStore event  during enrichment
- Keeps sorting by  then  consistent
2025-10-22 01:35:06 +02:00
Gigi
c9208cfff2 chore: remove all debug console logs
- Remove console.log from bookmark hydration
- Remove console.log from relay initialization
- Remove all console.debug calls from TTS hook and controls
- Remove debug logging from RouteDebug component
- Fix useCallback dependency warning in speak function
2025-10-22 01:26:42 +02:00
Gigi
2fb4132342 docs: update CHANGELOG for v0.10.8 2025-10-22 01:25:41 +02:00
Gigi
81180c8ba8 chore: bump version to 0.10.8 2025-10-22 01:23:13 +02:00
Gigi
1c48adf44e Merge pull request #23 from dergigi/e-path
feat: add /e/:eventId path for individual event rendering
2025-10-22 01:22:52 +02:00
Gigi
366e10b23a feat(/e/): check eventStore first for author profile
- Try to load author profile from eventStore cache first
- Only fetch from relays if not found in cache
- Instant title update if profile already loaded
2025-10-22 01:19:09 +02:00
Gigi
bb66823915 fix(/e/): Search button opens note via /e/ path not search portal
- For kind:1 notes, open directly via /e/{eventId}
- For articles (kind:30023), continue using search portal
- Removes nostr-event: prefix in URLs
2025-10-22 01:18:51 +02:00
Gigi
f09973c858 feat(/e/): display publication date in top-right like articles
- Remove inline metadata HTML from note content
- Pass event.created_at as published timestamp via ReadableContent
- ReaderHeader now displays date in top-right corner
2025-10-22 01:18:14 +02:00
Gigi
d03726801d feat(/e/): title 'Note by @author' with background profile fetch
- Immediate fallback title using short pubkey
- Fetch kind:0 profile in background; update title when available
- Keeps UI responsive while improving attribution
2025-10-22 01:16:30 +02:00
Gigi
164e941a1f fix(events): make direct event loading robust
- Add completion and timeout handling to eventManager.fetchEvent
- Resolve/reject all pending promises correctly
- Prevent silent completes when event not found
- Improves /e/:eventId reliability on cold loads
2025-10-22 01:09:36 +02:00
Gigi
6def58f128 fix(bookmarks): show eventStore content as fallback for bookmarks without hydrated content
- Enrich bookmarks with content from externalEventStore when hydration hasn't populated yet
- Keeps sidebar from showing only event IDs while background hydration continues
2025-10-22 01:04:23 +02:00
Gigi
347e23ff6f fix: only request hydration for items without content
- Only fetch events for bookmarks that don't have content yet
- Bookmarks with existing content (web bookmarks, etc.) don't need fetching
- This reduces unnecessary fetches and focuses on what's needed
- Should show much better content in bookmarks list
2025-10-22 01:01:23 +02:00
Gigi
934768ebf2 chore: remove debug logging from hydration 2025-10-22 01:01:04 +02:00
Gigi
60e9ede9cf debug: add more detail to hydration logging 2025-10-22 00:59:06 +02:00
Gigi
c70e6bc2aa debug: log hydration progress to track content population
- Add logging to see how many hydrated items have content
- This will help diagnose why bookmarks are showing IDs instead of content
2025-10-22 00:57:47 +02:00
Gigi
ab8665815b chore: remove debug logging from bookmarkHelpers
- Remove 'NO MATCHES' debug logs from hydrateItems
- Console is now clean, hydration is working properly
2025-10-22 00:56:40 +02:00
Gigi
1929b50892 fix: properly implement eventManager with promise-based API
- Fix eventManager to handle async fetching with proper promise resolution
- Track pending requests and deduplicate concurrent requests for same event
- Auto-retry when relay pool becomes available
- Resolve all pending callbacks when event arrives
- Update useEventLoader to use eventManager.fetchEvent
- Simplify useEventLoader with just one effect for fetching
- Handles both instant cache hits and deferred relay fetching
2025-10-22 00:55:20 +02:00
Gigi
160dca628d fix: simplify eventManager and restore working event fetching
- Revert eventManager to simpler role: initialization and service coordination
- Restore original working fetching logic in useEventLoader
- eventManager now provides: getCachedEvent, getEventLoader, setServices
- Fixes broken bookmark hydration and direct event loading
- Uses eventManager for cache checking but direct subscription for fetching
2025-10-22 00:54:33 +02:00
Gigi
c04ba0c787 feat: add centralized eventManager for event fetching
- Create eventManager singleton for fetching and caching events
- Handles deduplication of concurrent requests for same event
- Waits for relay pool to become available before fetching
- Provides both async/await and callback-based APIs
- Update useEventLoader to use eventManager instead of direct loader
- Simplifies event fetching logic and enables better reuse across app
2025-10-22 00:52:15 +02:00
Gigi
479d9314bd fix: make event loading non-blocking and wait for relay pool
- Don't show error if relayPool isn't available yet
- Instead, keep loading state and wait for relayPool to become available
- Effect will re-run automatically when relayPool is set
- Enables smooth loading when navigating directly to /e/ URLs on page load
- Fetching happens in background without blocking user
2025-10-22 00:50:14 +02:00
Gigi
b9d5e501f4 improve: better error messages when direct event loading fails
- Show error if relayPool is not available when loading direct URL
- Improved error message wording to be clearer
- These messages will help diagnose direct /e/ path loading issues
2025-10-22 00:49:50 +02:00
Gigi
43e0dd76c4 fix: don't show user highlights when viewing events on /e/ path
- Set selectedUrl and ReadableContent url to empty string for events
- This prevents ThreePaneLayout from displaying user highlights for event views
- Events should only show event-specific content, not global user highlights
- Fixes issue where 422 highlights were always shown for all notes
2025-10-22 00:48:43 +02:00
Gigi
dc9a49e895 chore: remove debug logging from event loader and compact view
- Remove debug logs from useEventLoader hook
- Remove debug logs from Bookmarks component
- Remove empty kind:1 bookmark debug logging from CompactView
- Clean console output now that features are working correctly
2025-10-22 00:46:44 +02:00
Gigi
3200bdf378 fix: add hydrated bookmark events to global eventStore
- bookmarkController now accepts eventStore in start() options
- All hydrated events (both by ID and by coordinates) are added to the external eventStore
- This makes hydrated bookmark events available to useEventLoader and other hooks
- Fixes issue where /e/ path couldn't find events because they weren't in the global eventStore
- Now instant loading works for all bookmarked events
2025-10-22 00:42:25 +02:00
Gigi
2254586960 perf: check eventStore before setting loading state for instant cached event display
- Synchronously check eventStore first before setting loading state
- If event is cached, display it immediately without loading spinner
- Only set loading state if event not found in cache
- Provides instant display of events that are already hydrated
- Improves perceived performance when navigating to bookmarked events
2025-10-22 00:38:42 +02:00
Gigi
18c78c19be fix: render events as plain text html instead of markdown
- kind:1 notes are plain text, not markdown
- Changed from markdown to html rendering
- HTML-escape content to prevent injection
- Preserve whitespace and newlines for plain text display
- Display event metadata in styled HTML header
2025-10-22 00:36:55 +02:00
Gigi
167d5f2041 fix: clear reader content when loading event and set proper selectedUrl
- Clear readerContent at start of loading to ensure old content doesn't persist
- Set selectedUrl to nostr:eventId to match pattern used in other loaders
- This ensures consistent behavior across all content loaders
2025-10-22 00:35:33 +02:00
Gigi
cce7507e50 fix: properly extract eventId from route params
- Add eventId to useParams instead of manually parsing pathname
- useParams automatically extracts eventId from /e/:eventId route
- Add debug logging to track event loading
- This fixes the issue where eventId wasn't being passed to useEventLoader
2025-10-22 00:30:54 +02:00
Gigi
e83d4dbcdb feat: render notes like articles with markdown processing
- Change useEventLoader to set markdown instead of html
- Notes now get proper markdown processing and rendering similar to articles
- Use markdown comments for event metadata instead of HTML
- This enables proper styling and markdown features for note display
2025-10-22 00:28:29 +02:00
Gigi
a5bdde68fc fix: resolve all linter and type check errors
- Fix mergeMap concurrency syntax (pass as second parameter, not object)
- Fix type casting in CompactView debug logging
- Update useEventLoader to use ReadableContent type
- Fix eventStore type compatibility in useEventLoader
- All linter and TypeScript checks now pass
2025-10-22 00:27:45 +02:00
Gigi
5551cc3a55 feat: add relay.nostr.band as hardcoded relay
- Create HARDCODED_RELAYS constant with relay.nostr.band
- Always include hardcoded relays in relay pool
- Update computeRelaySet calls to use HARDCODED_RELAYS
- Ensures we can fetch events even if user has no relay list
- relay.nostr.band is a public searchable relay that indexes all events
2025-10-22 00:23:01 +02:00
Gigi
145ff138b0 feat: integrate event viewer into three-pane layout for /e/:eventId
- Create useEventLoader hook to fetch and display individual events
- Events display in middle pane with metadata (ID, timestamp, kind)
- Integrates with existing Bookmarks three-pane layout
- Remove standalone EventViewer component
- Route /e/:eventId now uses Bookmarks component
- Metadata displayed above event content for context
2025-10-22 00:22:04 +02:00
Gigi
5bd5686805 feat: add /e/:eventId route to display individual notes
- New EventViewer component to display kind:1 notes and other events
- Shows event ID, creation time, and content with RichContent rendering
- Add /e/:eventId route in App.tsx
- Update CompactView to navigate to /e/:eventId when clicking kind:1 bookmarks
- Mobile-optimized styling with back button and full viewport display
- Fallback for missing events with error message
2025-10-22 00:19:20 +02:00
Gigi
d2c1a16ca6 chore: remove verbose debug logging from hydration
- Clean up console output after diagnosing ID mismatch issue
- Keep error logging for when matches aren't found
- Deduplication before hydration now working
2025-10-22 00:17:03 +02:00
Gigi
b8242312b5 fix: deduplicate bookmarks before requesting hydration
- Collect all items, then dedupe before separating IDs/coordinates
- Prevents requesting hydration for 410 duplicate items
- Only requests ~96 unique event IDs instead
- Events are still hydrated for both public and private lists
- Dedupe after combining hydrated results
2025-10-22 00:15:27 +02:00
Gigi
96ef227f79 debug: log all fetched events to identify ID mismatch
- Show sample of note IDs being requested
- Log every event fetched with kind and content length
- Helps diagnose why kind:1 events aren't in the hydration map
2025-10-22 00:13:38 +02:00
Gigi
30ed5fb436 fix: batch event hydration with concurrency limit
- Replace merge(...map(eventLoader)) with mergeMap concurrency: 5
- Prevents overwhelming relays with 96+ simultaneous requests
- EventLoader now properly throttles to 5 concurrent requests at a time
- Fixes issue where only ~7 out of 96 events were being fetched
2025-10-22 00:12:34 +02:00
Gigi
42d7143845 debug: add logging for event ID requests
- Log how many note IDs and coordinates we're requesting
- Log how many unique event IDs are passed to EventLoader
- Track if all bookmarks are being requested for hydration
2025-10-22 00:11:06 +02:00
Gigi
f02bc21faf debug: simplify hydration logging for easier diagnosis
- Show how many items were matched in the map
- If zero matches, show actual IDs from both sides
- Makes it easy to see ID mismatch issues
2025-10-22 00:10:13 +02:00
Gigi
0f5d42465d debug: add detailed logging to hydrateItems
- Log which kind:1 items are being processed
- Show how many match events in the idToEvent map
- Compare sample IDs from items vs map keys
- Identify ID mismatch issue between bookmarks and fetched events
2025-10-22 00:08:47 +02:00
Gigi
004367bab6 debug: log the actual Bookmark object being emitted to component
- Show what's actually in individualBookmarks when emitted
- Check if content is present in the emitted object vs what component receives
- Identify if the issue is in hydration or state propagation
2025-10-22 00:05:04 +02:00
Gigi
312adea9f9 debug: add hydration logging to diagnose empty bookmarks
- Log when kind:1 events are fetched from relays
- Log when bookmarks are emitted with hydration status
- Track how many events are in the idToEvent map
- Check if event IDs match between bookmarks and fetched events
2025-10-22 00:03:14 +02:00
Gigi
a081b26333 feat: show event IDs for empty bookmarks and add debug logging
- Display event ID (first 12 chars) when bookmark content is missing
- Shows ID in dimmed code font as fallback for empty items
- Add debug console logging to identify which bookmarks are empty
- Helps diagnose hydration issues and identify events that aren't loading
2025-10-22 00:02:11 +02:00
Gigi
51e48804fe debug: remove console logging for kind:1 hydration
- Removed 📝, 💧, 🎨 and 📊 debug logs
- These were added for troubleshooting but are no longer needed
- Kind:1 content hydration and rendering is working correctly
2025-10-21 23:58:16 +02:00
Gigi
e08ce0e477 debug: add BookmarkList logging to track kind:1 filtering
- Log how many kind:1 bookmarks make it past the hasContent filter
- Show sample content to verify hydration is reaching the list
- Help identify where bookmarks are being filtered out
2025-10-21 23:55:10 +02:00
Gigi
2791c69ebe debug: add logging to CompactView to diagnose missing content rendering
- Log when kind:1 without URLs is being rendered
- Check if bookmark.content is actually present at render time
- Help diagnose why text isn't displaying even though it's hydrated
2025-10-21 23:54:15 +02:00
Gigi
96451e6173 debug: add logging to track kind:1 event hydration
- Log when kind:1 events are fetched by EventLoader
- Log when kind:1 events are hydrated with content
- Helps diagnose why text content isn't displaying for bookmarked notes
2025-10-21 23:52:39 +02:00
Gigi
d20cc684c3 feat: ensure kind:1 events display their text content in bookmarks bar
- Update hydrateItems to parse content for all events with text
- Previously, kind:1 events without URLs would appear empty in the bookmarks list
- Now any kind:1 event will display its text content appropriately
- Improves handling of short-form text notes in bookmarks
2025-10-21 23:50:12 +02:00
Gigi
4316c46a4d docs: update CHANGELOG for v0.10.7 2025-10-21 23:40:05 +02:00
Gigi
e382310c88 chore: bump version to 0.10.7 2025-10-21 23:39:11 +02:00
Gigi
e6b99490dd refactor: simplify profile background fetching
- Remove unnecessary .then() callback
- Extract relayUrls variable for clarity
- Make error handlers consistent
- Add clearer comment about no-limit fetching
2025-10-21 23:35:56 +02:00
Gigi
09ee05861d fix: ensure all writings are stored in eventStore for profile pages
- Add eventStore parameter to fetchBlogPostsFromAuthors
- Store events as they stream in, not just at the end
- Update all callers to pass eventStore parameter
- This fixes issue where profile pages don't show all writings
2025-10-21 23:28:27 +02:00
Gigi
205988a6b0 docs: update CHANGELOG for v0.10.6 2025-10-21 23:15:50 +02:00
Gigi
8012752a39 chore: bump version to 0.10.6 2025-10-21 23:14:18 +02:00
Gigi
c3302da11d chore(me): remove debug logs after fixing tab switching 2025-10-21 23:13:10 +02:00
Gigi
60e1e3c821 fix(me): remove loadedTabs from useCallback deps to prevent infinite loop 2025-10-21 23:11:22 +02:00
Gigi
6c2247249a fix(me): use propActiveTab directly to avoid infinite update loop 2025-10-21 23:07:51 +02:00
Gigi
33a31df2b4 fix(me): restore useEffect to sync propActiveTab to local state on route changes 2025-10-21 23:05:17 +02:00
Gigi
f9dda1c5d4 fix(me): add key to tab content div to force re-render on tab switch 2025-10-21 22:59:09 +02:00
Gigi
6522a2871c fix(me): derive activeTab directly from route prop to update instantly on navigation 2025-10-21 22:54:48 +02:00
Gigi
f39b926e7b fix(tts): remove self-assignment in rate-change handler; keep current lang without no-op 2025-10-21 22:48:01 +02:00
Gigi
144cf5cbd1 fix(explore): subscribe-first loading model for contacts, writings, highlights; no timeouts; hydrate on first result; non-blocking nostrverse streams 2025-10-21 22:44:49 +02:00
Gigi
4b9de7cd07 feat(tts): make Web TTS reliable by chunking long text and resuming by chunks 2025-10-21 22:26:51 +02:00
Gigi
2be58332bb chore: bump version to 0.10.5 2025-10-21 22:18:00 +02:00
Gigi
6fc93cbd0f fix(pwa): accept link/Link/url form fields in Web Share Target POST handler 2025-10-21 22:04:34 +02:00
Gigi
5df426a863 fix(pwa): include share_target in build manifest via vite-plugin-pwa 2025-10-21 21:57:33 +02:00
Gigi
8ca4671bea chore: update package-lock.json 2025-10-21 21:37:09 +02:00
Gigi
ad1a808c6d docs: update CHANGELOG for v0.10.4 2025-10-21 21:36:22 +02:00
Gigi
ae118a0581 chore: bump version to 0.10.4 2025-10-21 21:35:47 +02:00
Gigi
3cddcd850e feat: add Web Share Target support for auto-saving shared URLs
- Add share_target to manifest.webmanifest with POST method
- Implement service worker handler for POST /share-target requests
- Create ShareTargetHandler component to process and save shared URLs
- Add /share-target route in App.tsx
- Auto-saves shared URLs as web bookmarks (NIP-B0)
- Handles Android case where url param is omitted from share data
2025-10-21 21:32:50 +02:00
Gigi
cadf4dcb48 perf(reading): debounce reading position saves (>=5% delta, 15s min interval, instant on completion) 2025-10-21 21:19:45 +02:00
Gigi
47d257faaf feat: add hardcoded bot pubkey filtering 2025-10-21 09:01:10 +02:00
Gigi
f542cee4cc docs: update CHANGELOG for v0.10.3 2025-10-21 08:29:00 +02:00
Gigi
8274eb26c2 chore: bump version to 0.10.3 2025-10-21 08:28:11 +02:00
Gigi
35018fef91 style: update bot filter setting to 'Hide content posted by bots' 2025-10-21 08:27:06 +02:00
Gigi
1fd08bb64a style: simplify bot filter setting text 2025-10-21 08:25:06 +02:00
Gigi
d953542c93 style: remove example bots text from setting 2025-10-21 08:23:52 +02:00
Gigi
8c0b73ad0c fix: resolve all linting and type checking issues 2025-10-21 08:21:36 +02:00
Gigi
a5d2ed8b07 feat: hide articles from bot accounts by name; add setting (default on) 2025-10-21 07:36:00 +02:00
Gigi
67fec91ab3 chore: bump version to 0.10.2 2025-10-21 07:29:34 +02:00
Gigi
868fe68ce2 chore: remove console.log debug output across app and relay services 2025-10-21 07:27:32 +02:00
Gigi
66c4bfc449 refactor: remove all eslint-disable comments; fix types and deps; clean unused imports 2025-10-21 07:26:00 +02:00
Gigi
29918f78f9 refactor: remove eslint-disable comments by typing publish, fixing unused-vars, and updating effect deps 2025-10-21 07:21:01 +02:00
Gigi
18fcf6064e feat: swap position of refresh and list/group buttons in bookmarks bar 2025-10-21 07:12:24 +02:00
Gigi
35766d5691 docs: update CHANGELOG.md for v0.10.1 2025-10-20 23:20:42 +02:00
Gigi
7450ba4251 chore: bump version to 0.10.1 2025-10-20 23:20:19 +02:00
Gigi
95c770c083 deps: update package-lock.json 2025-10-20 23:20:13 +02:00
Gigi
14a7e1138e feat: differentiate between American and British English in TTS 2025-10-20 23:16:26 +02:00
Gigi
9c45c71c8a feat: add top 10 TTS languages to speaker language selector 2025-10-20 23:15:14 +02:00
Gigi
23b9224272 style: remove 'Test Example' label from TTS settings 2025-10-20 23:10:26 +02:00
Gigi
bcd4a12542 content: update TTS example text to Boris mission statement 2025-10-20 23:10:03 +02:00
Gigi
d82e22ce1c refactor: use TTSControls component in TTS settings for consistent UI 2025-10-20 23:09:36 +02:00
Gigi
ea5c173745 feat: add example text section to test TTS in settings 2025-10-20 23:08:47 +02:00
Gigi
a214c487cc style: increase padding-right on dropdown chevron to 1.75rem 2025-10-20 23:07:06 +02:00
Gigi
43f56fc29a style: add more padding-right to dropdown selector for better spacing 2025-10-20 23:06:06 +02:00
Gigi
cfbc3efeeb style: use consistent setting-select class for speaker language dropdown 2025-10-20 23:05:20 +02:00
Gigi
bb9e98ff16 docs: update CHANGELOG.md for v0.10.0 2025-10-20 23:04:45 +02:00
Gigi
073bb3867f chore: bump version to 0.10.0 2025-10-20 23:04:08 +02:00
Gigi
1ac7fb26b2 Merge pull request #22 from dergigi/tts
feat: Add comprehensive Text-to-Speech (TTS) functionality
2025-10-20 23:03:22 +02:00
Gigi
a551234a29 feat(tts): use Speaker language mode (system|content) with fallback to legacy flags 2025-10-20 22:59:26 +02:00
Gigi
227f062456 feat(settings): consolidate TTS language into Speaker language dropdown (default: content) 2025-10-20 22:58:36 +02:00
Gigi
6c42ee88ea fix(lint): avoid empty catch in TTSControls detection 2025-10-20 22:56:16 +02:00
Gigi
fc138f3ceb feat(tts): select voice by detected/system language per utterance 2025-10-20 22:55:15 +02:00
Gigi
831f701c04 feat(tts): detect content language with tinyld and honor system lang toggle 2025-10-20 22:54:06 +02:00
Gigi
94b9d89225 feat(deps): add tinyld for client-side language detection 2025-10-20 22:53:14 +02:00
Gigi
2793a6dd44 feat(settings): add toggles for TTS language (system, content detection) 2025-10-20 22:35:25 +02:00
Gigi
9086692e29 feat(settings): set defaults for TTS language flags (system=false, content=true) 2025-10-20 22:35:04 +02:00
Gigi
f8c4bbb99c feat(settings): add TTS language flags (system, content detection) to UserSettings 2025-10-20 22:34:35 +02:00
Gigi
b14842c6fe fix(lint): wrap createUtterance in useCallback and correct deps for hooks 2025-10-20 22:29:45 +02:00
Gigi
7cdf0673bd fix(tts): guard events to current utterance and force restart via updateRate() 2025-10-20 22:25:54 +02:00
Gigi
bbed20d679 chore(tts-debug): add temporary console debug logs for speed changes and state 2025-10-20 22:22:38 +02:00
Gigi
7594d30fd2 feat(tts): restart from word boundary on speed change for immediate effect 2025-10-20 22:14:56 +02:00
Gigi
67506d9040 fix(tts): apply rate changes immediately including when paused 2025-10-20 22:13:10 +02:00
Gigi
e2d0bc2acf fix(tts): sync default rate changes from settings without refresh 2025-10-20 22:11:21 +02:00
Gigi
2283f4ec08 fix: remove eslint-disable and use proper type casting for SpeechSynthesisUtterance 2025-10-20 22:10:55 +02:00
Gigi
463ac8f44c fix(tts): apply rate changes whether utterance is speaking or paused 2025-10-20 22:10:18 +02:00
Gigi
e2de6f2d91 fix: resolve linter and type check errors in TTS code 2025-10-20 22:09:28 +02:00
Gigi
fdb52fe3b2 style(tts-settings): use setting-buttons layout like Default Bookmark View 2025-10-20 22:07:31 +02:00
Gigi
ae14064822 style(tts-settings): use same speed cycling button as TTSControls 2025-10-20 22:06:25 +02:00
Gigi
5526bfc425 chore(settings): reorder TTS settings above Layout & Behavior 2025-10-20 22:06:02 +02:00
Gigi
b3f4b03229 style(tts): remove button labels, show icons only 2025-10-20 22:05:21 +02:00
Gigi
b92f5716dc feat(tts): use default speed from settings in TTSControls 2025-10-20 22:05:04 +02:00
Gigi
177f8c1e70 feat(settings): integrate TTSSettings into settings page 2025-10-20 22:05:01 +02:00
Gigi
0407769206 feat(settings): create TTSSettings component 2025-10-20 22:04:58 +02:00
Gigi
eb75e7722d feat(tts): add ttsDefaultSpeed to UserSettings 2025-10-20 22:04:55 +02:00
Gigi
81aa414d2e fix(tts): apply speed changes immediately during playback 2025-10-20 22:03:05 +02:00
Gigi
c82fb65745 style(tts): remove Stop button, keep Play/Pause and Speed 2025-10-20 22:02:00 +02:00
Gigi
cc1b9f042f feat(tts): extend speed range to 3x with 2.1x default 2025-10-20 22:01:13 +02:00
Gigi
c2bf4b4a9a feat(tts): replace speed dropdown with cycling button 2025-10-20 22:00:46 +02:00
Gigi
13a47e4fdc style(tts): use design system colors and typography 2025-10-20 22:00:27 +02:00
Gigi
24b652847c style(tts): right-align TTS controls 2025-10-20 21:59:47 +02:00
Gigi
c623dc8d84 style(tts): reduce button and text sizes for compact layout 2025-10-20 21:59:31 +02:00
Gigi
31987010b8 docs(tts): add TTS feature to FEATURES.md 2025-10-20 21:42:02 +02:00
Gigi
b3206d5e79 feat(reader): integrate TTS controls in ContentPanel 2025-10-20 21:41:31 +02:00
Gigi
34f44c59b5 feat(tts): add TTSControls component with play/pause/stop and rate 2025-10-20 21:41:19 +02:00
Gigi
a51fbd25d7 feat(tts): add Web Speech API hook 2025-10-20 21:41:07 +02:00
Gigi
95f6949ab7 docs(changelog): add 0.9.1 release notes and update compare links 2025-10-20 21:31:42 +02:00
Gigi
1e613bd2a2 chore: bump version to 0.9.1 2025-10-20 21:26:25 +02:00
Gigi
95b882b0d1 fix(css): constrain video player to prevent horizontal overflow
- Set .reader-video to width: 100%, max-width: 100%, aspect-ratio: 16/9
- Remove negative margins and viewport-based sizing
- Add overflow: hidden to contain player within reader bounds
- Fixes video bleeding to the right on smaller screens
2025-10-20 21:26:05 +02:00
Gigi
be00f1434d feat(settings): default renderVideoLinksAsEmbeds to true
- Initialize settings with renderVideoLinksAsEmbeds: true
- Merge default when loading and watching settings events
- Ensures video links are embedded by default
2025-10-20 21:20:39 +02:00
Gigi
568890e131 fix: prevent ReactMarkdown img renderer from injecting unknown props
- Remove props spread to avoid node="[object Object]" artifacts
- Ensures downstream VideoEmbedProcessor can cleanly replace video <img> tags
2025-10-20 21:19:27 +02:00
Gigi
f000ac3be1 feat: embed <video> blocks and <img> video src in VideoEmbedProcessor
- Replace entire <video>...</video> and <img> tags with placeholders
- Extract URLs in same order to align with placeholders
- Also replace bare file URLs and platform-classified video URLs
- Ensures no broken tags remain; uses ReactPlayer for rendering
2025-10-20 21:15:46 +02:00
Gigi
2fed1cc6e7 fix: robustly replace img tags with video URLs
- Changed approach to find ALL img tags first, then check if they contain video URLs
- Properly escapes regex special characters in img tags before replacement
- Fixes issue where img tags with video src attributes were not being replaced
- Handles edge cases like React-added attributes (node=[object Object])
- Now correctly converts markdown video images to embedded players
2025-10-20 21:11:58 +02:00
Gigi
4bdcfcaeb4 feat: properly handle video URLs in markdown img tags 2025-10-20 21:10:16 +02:00
Gigi
a5494ba15c fix: improve URL regex patterns to prevent text artifacts
- Updated VideoEmbedProcessor regex patterns to use lookahead assertions
- This prevents capturing HTML attribute syntax like quotes and angle brackets
- Fixes text artifact appearing in UI when processing video URLs in HTML content
2025-10-20 20:45:22 +02:00
Gigi
64aad42be3 fix: prevent double video player rendering
- Modified ContentPanel to disable VideoEmbedProcessor when isExternalVideo is true
- This prevents both ContentPanel and VideoEmbedProcessor from rendering ReactPlayer for the same video URL
- Fixes issue where video players were showing twice
2025-10-20 20:44:38 +02:00
Gigi
3673849a9a feat: enable media display options by default
- Set fullWidthImages default to true
- Set renderVideoLinksAsEmbeds default to true
- Users now get enhanced media experience out of the box
- Can still be disabled in settings if preferred
2025-10-20 20:40:17 +02:00
Gigi
c6795f7c18 fix: resolve linting and TypeScript errors
- Remove unused faExpand import from ReadingDisplaySettings
- Fix TypeScript type errors in VideoEmbedProcessor
- Add explicit string[] type annotations for regex match results
- All linting and type checking now passes
2025-10-20 20:40:03 +02:00
Gigi
b27f26b639 refactor: create dedicated Media Display settings section
- Create new MediaDisplaySettings component for media-related settings
- Move full-width images and video embed settings from Reading & Display
- Add MediaDisplaySettings to main Settings component
- Improve settings organization and user experience
- Keep media settings logically grouped together
2025-10-20 20:38:46 +02:00
Gigi
975399e293 feat: add video embed setting and processor
- Add renderVideoLinksAsEmbeds setting to UserSettings interface
- Add checkbox control in ReadingDisplaySettings component
- Create VideoEmbedProcessor component to handle video link embedding
- Integrate VideoEmbedProcessor into ContentPanel for article rendering
- Support .mp4, .webm, .ogg, .mov, .avi, .mkv, .m4v video formats
- Use ReactPlayer for embedded video playback
- Default to false (render as links)
- When enabled, video links are rendered as embedded players
2025-10-20 20:37:45 +02:00
Gigi
53b8356373 feat: add full-width images setting
- Add fullWidthImages setting to UserSettings interface
- Add checkbox control in ReadingDisplaySettings component
- Implement CSS custom property --image-max-width
- Set property in useSettings hook based on user preference
- Default to false (constrained width)
- When enabled, images use max-width: none for full-width display
2025-10-20 20:35:24 +02:00
Gigi
8c5225b271 perf: optimize support page loading with instant display and skeletons
- Remove blocking full-screen spinner on support page
- Show page content immediately with skeleton placeholders
- Load supporters data in background without blocking UI
- Fetch profiles asynchronously to avoid blocking
- Add SupporterSkeleton component with proper animations
- Significantly improve perceived loading performance
2025-10-20 20:33:10 +02:00
Gigi
dfac7a5089 feat: sort writings by publication date, newest first
- Add sorting to Profile component's cachedWritings
- Sort by publication date (or created_at as fallback) with newest first
- Ensures consistent sorting across all writings displays
- Uses useMemo for performance optimization
2025-10-20 20:31:28 +02:00
Gigi
9fe09b813b fix: include period in 'your own highlights' highlight
- Update highlight to include the period: 'your own highlights.'
- Ensures complete phrase is highlighted for better visual consistency
- Maintains proper sentence structure in the highlighted text
2025-10-20 20:30:08 +02:00
Gigi
ea30c136f2 feat: highlight 'Connect your npub' in login text
- Add highlight styling to 'Connect your npub' text in login screen
- Now both 'Connect your npub' and 'your own highlights' are highlighted
- Uses same login-highlight class for consistent styling
- Improves visual emphasis on key action phrases
2025-10-20 20:29:39 +02:00
Gigi
623856ffe9 feat: center images in article view
- Update CSS to center images in reader content
- Change margin from '0.75rem 0' to '0.75rem auto' for horizontal centering
- Applies to both HTML and Markdown content in article view
- Improves visual presentation of images in articles
2025-10-20 20:28:50 +02:00
Gigi
d08071def2 fix: improve contrast for highlighted text in login screen
- Change login-highlight text color from var(--color-text) to #000000
- Ensures proper contrast against bright yellow highlight background in dark mode
- Fixes readability issue where light gray text was hard to read on yellow background
2025-10-20 20:28:04 +02:00
Gigi
556e8f2f7d docs: update CHANGELOG for v0.9.0
- Added user relay list integration (NIP-65) and blocked relays (NIP-51)
- Improved relay list loading performance with streaming callbacks
- Enhanced relay URL handling and normalization
- Fixed all linting issues and TypeScript type safety
- Added relay list debug capabilities
- Cleaned up temporary test relays and debug output
2025-10-20 20:13:33 +02:00
Gigi
9ab6847501 chore: bump version to 0.9.0 2025-10-20 20:13:15 +02:00
Gigi
31afe3792e fix: replace any types with proper NostrEvent types in relayListService
- Import NostrEvent type from nostr-tools
- Replace any[] with NostrEvent[] for events array
- Replace Map<string, any> with Map<string, NostrEvent> for eventsMap
- Resolves ESLint warnings about explicit any usage
2025-10-20 20:13:05 +02:00
Gigi
ebe8ecf63b feat: stream user relay list into pool immediately and finalize after blocked relays
- loadUserRelayList accepts onUpdate callback to stream first user relay list
- App.tsx applies interim relay set on first event, keeps alive, then recomputes with blocked relays
- Keeps startup non-blocking and matches Debug page behavior
2025-10-20 20:10:08 +02:00
Gigi
c418000a0c fix: add streaming callback to relay list service for faster results
- Add onEvent streaming callback to relayListService queryEvents call
- Process events as they arrive instead of waiting for all relays to respond
- Deduplicate events by id and keep most recent version
- Remove artificial delay since streaming provides immediate results
- Should resolve hanging issue where debug works but app query hangs
2025-10-20 20:03:16 +02:00
Gigi
15fd19f6a4 fix: resolve all linting issues
- Remove unused DebugBus import from App.tsx
- Remove unused NostrEvent import from relayListService.ts
- Add comment to empty catch block in ContentPanel.tsx
- Remove unused targetUrlsMap variable from relayManager.ts
- All linting errors resolved, TypeScript type checking passes
2025-10-20 20:01:40 +02:00
Gigi
2a44b4e3c0 cleanup: remove temporary test relays from hardcoded list
- Remove temporary relay additions that were added for debugging
- Restore clean hardcoded relay list now that dynamic relay integration is working
- The non-blocking relay loading implementation handles user relay lists properly
2025-10-20 20:01:02 +02:00
Gigi
aa7807e3d2 fix: make relay list loading non-blocking in App.tsx
- Start with hardcoded relays immediately when user logs in
- Load user relay list and blocked relays in background Promise
- Apply user relay preferences when they become available
- Remove blocking await that was preventing immediate relay setup
- Update keep-alive subscription and address loader when user relays load
- Continue with initial relay set if user relay loading fails
2025-10-20 19:58:55 +02:00
Gigi
359d3d0dd6 feat: add relay list debug section to Debug component
- Add state variables for relay list loading (isLoadingRelayList, relayListEvents, timing)
- Add handleLoadRelayList function to query kind 10002 events
- Add handleClearRelayList function to clear loaded data
- Add UI section with Load/Clear buttons and event display
- Show relay URLs and permissions for each relay list event
- Add loadRelayList to live timing type definition
2025-10-20 19:56:00 +02:00
Gigi
d40b3c0048 debug: add more detailed logging to relay list query including broader query test 2025-10-20 19:54:07 +02:00
Gigi
7b4ca50b16 debug: add timeout to relay list query and temporarily add user's relays to hardcoded set to test relay list loading 2025-10-20 19:52:40 +02:00
Gigi
76e001aba4 debug: add logging to relay list loading to diagnose why user relay list is not found 2025-10-20 19:51:42 +02:00
Gigi
0b42aeb383 refactor: remove non-relay console.log statements
- Remove console.log statements from ContentPanel.tsx (archive/content related)
- Remove console.log statements from readingProgressController.ts (reading progress related)
- Remove console.log statements from reactionService.ts (reaction related)
- Remove debug console.log block from Me.tsx (archive/me related)
- Preserve all relay-related console.log statements in App.tsx and relayManager.ts
2025-10-20 19:46:50 +02:00
Gigi
a4554e5176 chore: remove non-relay debug output
Remove bunker-related debug logs and keep-alive subscription warnings.
Keep only relay-related logs ([relay-init] and [relayManager]) for debugging
relay loading and management.
2025-10-20 19:35:39 +02:00
Gigi
2e844fc26b fix: use user's relay list exclusively when logged in
When logged in:
- If user has relay list (kind:10002): use ONLY user relays + bunker + localhost
- If user has NO relay list: fall back to hardcoded RELAYS

This ensures the relay list changes when you log in based on your NIP-65 relay list.

Added debug logging to show user relay list, blocked relays, and final relay set.
2025-10-20 19:31:21 +02:00
Gigi
8c0a4cac16 config: remove relay.dergigi.com from default relays
Keep only wot.dergigi.com (WoT relay) in the default relay list.
2025-10-20 19:30:00 +02:00
Gigi
c6eccc9589 fix: normalize relay URLs to match applesauce-relay internal format
applesauce-relay adds trailing slashes to relay URLs without paths,
but our RELAYS config doesn't include them. This caused applyRelaySetToPool
to think they were different URLs and remove all relays except the proxy.

Now we normalize URLs before comparison to match the pool's format.
2025-10-20 19:26:43 +02:00
Gigi
2e5536c331 debug: add logging to relay initialization to diagnose single relay issue 2025-10-20 19:18:03 +02:00
Gigi
fc025b9579 feat: integrate user relay lists (NIP-65) and blocked relays (NIP-51)
- Add relayListService to load kind:10002 (user relay list) and kind:10006 (blocked relays)
- Add relayManager to compute active relay set and dynamically manage pool membership
- Update App.tsx to fetch and apply user relays on login, reset on logout
- Replace all hardcoded RELAYS usages with dynamic getActiveRelayUrls() across services and components
- Always preserve localhost relays (ws://localhost:10547, ws://localhost:4869) regardless of user blocks
- Merge bunker relays, user relays, and hardcoded relays while excluding blocked relays
- Update keep-alive subscription and address loaders to use dynamic relay set
- Modified files: App.tsx, relayListService.ts (new), relayManager.ts (new), readsService.ts, readingProgressController.ts, archiveController.ts, libraryService.ts, reactionService.ts, writeService.ts, HighlightItem.tsx, ContentPanel.tsx, BookmarkList.tsx, Profile.tsx
2025-10-20 18:40:23 +02:00
Gigi
88db14c352 docs: update CHANGELOG for v0.8.6 2025-10-20 18:07:46 +02:00
Gigi
49c5f0c3ad chore: bump version to 0.8.6 2025-10-20 18:07:24 +02:00
Gigi
dbed4ad253 fix: revert to inline mount tracking with useRef
- Replace useMountedState custom hook with inline useRef approach
- Set mountedRef.current = true at start of each effect run
- Ensures proper reset when navigating between articles
- Simpler and more reliable than custom hook approach
2025-10-20 18:05:02 +02:00
Gigi
b117b1e6cf fix: remove isMounted from useEffect dependencies
- isMounted is a stable function from useMountedState and shouldn't be in deps
- Including it was preventing effects from running correctly
- Fixes issue where articles wouldn't load (stuck on spinner)
2025-10-20 17:46:41 +02:00
Gigi
627ffd6c5d fix: resolve React Hooks violation in NostrMentionLink component
- Move useEventModel hook call to top level (Rules of Hooks)
- Extract pubkey before calling the hook
- Profile resolution now works correctly for npub and nprofile mentions
- Fixes issue where profiles weren't being fetched and displayed
2025-10-20 16:36:52 +02:00
Gigi
0d53027818 chore: bump version to 0.8.5 2025-10-20 16:34:30 +02:00
Gigi
811d96dee0 refactor: extract common isMounted pattern into reusable useMountedState hook
- Create useMountedState hook to track component mount status
- Refactor useArticleLoader to use shared hook
- Refactor useExternalUrlLoader to use shared hook
- Remove duplicated isMounted pattern across both loaders
- Cleaner, more DRY code with same functionality
2025-10-20 16:33:05 +02:00
Gigi
21335d56dc fix: prevent infinite loading spinner by fixing race conditions in article/URL loaders
- Add isMounted flag to track component lifecycle in useArticleLoader
- Add isMounted flag to track component lifecycle in useExternalUrlLoader
- Remove setter functions from useEffect dependencies to prevent re-triggers
- Add cleanup functions to cancel pending state updates on unmount
- Check isMounted before all state updates in async operations
- Fixes issue where spinner would spin forever when loading articles
2025-10-20 15:00:39 +02:00
Gigi
f7e50023a3 feat: replace ContentWithResolvedProfiles with comprehensive RichContent component
- Create RichContent component to handle ALL nostr URI types
- Support npub, nprofile, note, nevent, naddr with profile resolution
- Handle both 'nostr:npub1...' and plain 'npub1...' formats
- Replace all ContentWithResolvedProfiles usages in CardView, LargeView, and CompactView
- Now all bookmark content properly displays resolved nostr mentions
2025-10-20 14:57:39 +02:00
Gigi
6b09212fe9 feat: resolve user profiles for npub mentions in highlight comments
- Create NostrMentionLink component to fetch and display user names
- Replace truncated pubkey display with resolved profile names
- Fetch profiles in background non-blocking way using useEventModel
- Falls back to truncated pubkey if profile not available
2025-10-20 14:55:00 +02:00
Gigi
cecff6b8d5 fix: filter out bookmark list events from individual bookmarks display
- Bookmark list events (kind:10003, 30003, 30001) are containers, not content
- Add filter in hydrateItems to exclude these kinds after hydration
- Add debug logging to track which items are being filtered
- Prevents bookmark list events from showing as individual bookmarks in UI
2025-10-20 14:45:30 +02:00
Gigi
2b061afa47 debug: add [BOOKMARK_TS] logging to investigate timestamp issues
- Log parentCreatedAt value when processApplesauceBookmarks is called
- Log each bookmark event with its kind and created_at timestamp
- Log count and timestamp for notes, articles, and URLs being processed
- Prefixed with [BOOKMARK_TS] for easy console filtering
2025-10-20 13:56:07 +02:00
Gigi
7516013e67 fix: use parent event timestamp for bookmarks instead of placeholder
- Add parentCreatedAt parameter to processApplesauceBookmarks function
- Replace all Math.floor(Date.now() / 1000) placeholders with parentCreatedAt || 0
- Update all call sites in bookmarkProcessing.ts to pass evt.created_at
- Individual bookmarks now inherit timestamp from their bookmark list event
- Bookmarks without valid parent timestamp will show as 0 (epoch) and be filtered by hideBookmarksWithoutCreationDate setting
- Eliminates 'now' placeholder timestamps in bookmark sidebar
2025-10-20 13:51:26 +02:00
Gigi
567641de77 fix: improve detection of placeholder bookmarks without valid timestamps
- Enhanced hasCreationDate() to better detect unhydrated bookmark references
- Web bookmarks (kind 39701) always have real timestamps, always shown
- Filter out bookmarks with no content (failed hydration)
- Filter out URL-only bookmarks with minimal tags and synthetic IDs
- These are created during NIP-51 processing and show 'now' if not hydrated
- Fixes issue where placeholder timestamps would pass filter after time elapsed
2025-10-20 13:45:00 +02:00
Gigi
4e86907663 fix: apply hideBookmarksWithoutCreationDate setting to Me component
- Import hasCreationDate utility function in Me.tsx
- Add UserSettings to MeProps interface
- Pass settings prop from Bookmarks to Me component
- Filter out bookmarks without creation dates when setting is enabled
- This ensures bookmarks showing 'Now' are hidden by default
2025-10-20 13:41:45 +02:00
Gigi
ec34e00573 docs: update CHANGELOG for v0.8.4 release
- Document progressive article hydration feature for reads tab
- Document React type imports fix in useArticleLoader
2025-10-20 13:36:19 +02:00
Gigi
5e6c8b7516 chore: bump version to 0.8.4 2025-10-20 13:35:13 +02:00
Gigi
e50af42c96 fix: import React types correctly in useArticleLoader
- Import Dispatch and SetStateAction directly from 'react'
- Fixes linting errors about React not being defined
- Resolves eslint no-undef errors
2025-10-20 13:34:48 +02:00
Gigi
73470987be feat: add progressive article hydration for reads tab
- Create readsController service with background article fetching
- Implement progressive hydration pattern similar to bookmarkController
- Use AddressLoader for efficient batched article event retrieval
- Update Me.tsx to use readsController instead of direct readingProgressController
- Articles now show titles, summaries, images as data arrives from relays
- Fixes issue where reads showed 'Untitled' for all articles
- Keep event store integration for caching article events
- Maintain DRY principle by centralizing reads data fetching
2025-10-20 13:33:17 +02:00
Gigi
31e203825d fix(types): correct setHighlights type to accept setState updater functions 2025-10-20 13:19:39 +02:00
Gigi
6f9c0a35e2 fix(reader): trigger archive animation even if already archived on auto-complete 2025-10-20 13:17:35 +02:00
Gigi
96f59a54f3 fix(reading): ensure 2s linger at 100% uses live position ref for auto-archive 2025-10-20 13:14:10 +02:00
Gigi
87c0a0454b refactor(me): DRY archive-only builders into shared helper for reads/links 2025-10-20 13:12:34 +02:00
Gigi
77c2ef1794 feat(links): mirror archive-only vs progress-only behavior in Links tab 2025-10-20 13:02:56 +02:00
Gigi
8d08911bd3 feat(reads): separate archive vs reading-progress filters; archive shows emoji-only, progress filters ignore emoji 2025-10-20 13:00:34 +02:00
Gigi
31b005a989 fix(reads): build archive list exactly like debug loader (streamed union, no overwrite) 2025-10-20 12:56:19 +02:00
Gigi
337bfe5432 fix(reads): union archive marks from readingProgress and archiveController to prevent empty archive view 2025-10-20 12:49:29 +02:00
Gigi
2f275375f7 ui(animation): restore archive success burst on manual archive (animating state) 2025-10-20 12:45:12 +02:00
Gigi
27cbcb56ec ui(reader): keep Archived label and subtle style while remaining clickable 2025-10-20 12:43:28 +02:00
Gigi
7f150003b5 feat(reader): wire unarchive actions to delete matching reactions and clear controller 2025-10-20 12:39:28 +02:00
Gigi
1f50d8e1b6 feat(reader): make Archived button clickable and perform unarchive via NIP-09 2025-10-20 12:39:09 +02:00
Gigi
f53decef16 feat(archive): add unarchive service to delete ARCHIVE_EMOJI reactions (kind 7/17) 2025-10-20 12:38:27 +02:00
Gigi
f272943b64 chore: commit pending working changes before implementing unarchive behavior 2025-10-20 12:36:27 +02:00
Gigi
49745e1b8a refactor(archive): remove direct markedIds mutation; use controller.mark/unmark for DRY updates; fix duplicate import in reactionService 2025-10-20 11:23:45 +02:00
Gigi
470f4fb34e feat(archive): support un-archive toggle; add ArchiveController mark/unmark; prep NIP-09 deletion hook 2025-10-20 11:21:59 +02:00
Gigi
8cde36c08c fix(archive): add 'a' coord tag to mark-as-read reactions for articles; archiveController maps a-tag instantly; add debug 2025-10-20 11:17:30 +02:00
Gigi
c21f96f5bb chore(debug): deepen [archive] mapping with eventStore timeline and logs; add sampleMarked logs in Me 2025-10-20 11:05:59 +02:00
Gigi
c9fef5804b chore(debug): add [archive] debug logs in archiveController, Me, and ContentPanel to trace archive filter behavior 2025-10-20 10:48:44 +02:00
Gigi
8337622a22 feat(archive): introduce archiveController to manage marked-as-read (kind:7/17); wire into App, Me, and ContentPanel for DRY archive state 2025-10-20 10:33:42 +02:00
Gigi
572f0fed6f fix(reads/links): keep DRY filtering but enforce type separation (articles vs external) for /me/reads and /me/links filters 2025-10-20 10:14:20 +02:00
Gigi
27a55ec329 fix(links): keep Links tab active when using /me/links/:filter by recognizing links path prefix in tab detection 2025-10-20 09:50:13 +02:00
Gigi
7ba362a3bb feat(links): add /me/links/:filter routes and mirror Reads filters/state for Links tab 2025-10-20 09:47:31 +02:00
Gigi
dc1844907e feat(settings): enable 'Hide bookmarks missing a creation date' by default 2025-10-20 09:43:51 +02:00
Gigi
28123b5e13 feat(archive): rename 'Mark as Read' UI to 'Move to Archive' and show 'Archived' state; update settings and filters wording 2025-10-20 09:42:34 +02:00
Gigi
d9eb87aa5c feat(reads): rename 'emoji' filter to 'archive' and use fa-books icon; map legacy /me/reads/emoji to /me/reads/archive 2025-10-20 09:39:45 +02:00
Gigi
a0ff0daf9d docs: update CHANGELOG.md for v0.8.3 release 2025-10-20 09:30:30 +02:00
Gigi
8c3baf1416 chore: bump version to 0.8.3 2025-10-20 09:29:11 +02:00
Gigi
e0c169edbc fix(highlights): avoid unintended reload by decoupling cached highlight sync from content loading in useExternalUrlLoader 2025-10-20 09:15:41 +02:00
Gigi
d2181ad772 fix(highlights): preserve immediate UI highlight after creation by merging streaming results instead of overwriting in article and external URL loaders 2025-10-20 09:07:42 +02:00
Gigi
8ff3f08d8c fix(highlights): restore FAB selection updates by listening to document selectionchange; keep clearing selection after creation 2025-10-20 08:57:00 +02:00
Gigi
e17e1bc824 fix(lint): resolve unused var and empty catch issues 2025-10-20 00:47:11 +02:00
Gigi
948674ae8c feat(reading-progress): stream mark-as-read reactions non-blockingly and emit updates as they arrive 2025-10-20 00:45:35 +02:00
Gigi
431f14f56d feat(reads): move highlighted filter next to All for prominence 2025-10-20 00:44:03 +02:00
Gigi
4cc9d557a0 feat(reads): add emoji filter, refine completed to 95%+, and show checkmark only at >=95% progress 2025-10-20 00:43:31 +02:00
Gigi
cc60f9584a temp: disable mark-as-read reactions loading due to queryEvents hanging
Temporarily skip loading mark-as-read reactions to unblock the reads feature.
Focus on getting reading progress working first.

TODO: Debug why queryEvents hangs when querying kind:7 and kind:17 reactions.
The Promise never resolves even though we're not using timeouts.
2025-10-20 00:38:14 +02:00
Gigi
94f1f9035b debug: add logging before/after queryEvents calls for reactions 2025-10-20 00:35:51 +02:00
Gigi
e5b1594933 feat: add listener for markedAsReadChanged events
Implemented event listener pattern in readingProgressController:
- Added onMarkedAsReadChanged() method for subscribers
- Added emitMarkedAsReadChanged() to notify when marked IDs update
- Call emitMarkedAsReadChanged() after loading reactions

In Me.tsx:
- Subscribe to onMarkedAsReadChanged() in new useEffect
- When fired, rebuild reads list with new marked-as-read items
- Include marked-only items (no progress event)

Now when reactions finish loading in background, /me/reads/completed
will update automatically with newly marked articles.
2025-10-20 00:34:38 +02:00
Gigi
2bf9b9789b debug: add detailed logging to mark-as-read reactions loading
Added comprehensive logging to see:
- When reactions queries start and complete
- How many kind:17 and kind:7 events are returned
- What reactions have MARK_AS_READ_EMOJI content
- Event ID to naddr mapping progress
- Final count of markedAsReadIds

This will help identify why markedAsReadIds is empty.
2025-10-20 00:33:01 +02:00
Gigi
d3405a4029 refactor: use bookmarkController pattern in readingProgressController
Non-blocking, background loading pattern:
- Subscribe to eventStore timeline immediately (returns right away)
- Mark as loaded immediately
- Fire-and-forget background queries for reading progress from relays
- Fire-and-forget background queries for mark-as-read reactions
- All updates stream via eventStore subscription

No timeouts. No blocking awaits. Updates arrive progressively as relays
respond, UI shows data as soon as eventStore delivers it.
2025-10-20 00:29:39 +02:00
Gigi
763f7bef4d debug: add granular logging to identify where loading hangs
Added logs at each step:
- Setting up timeline subscription
- Timeline subscription ready
- Querying reading progress events
- Got reading progress events count
- Generation changed abort

This will show exactly which step is blocking.
2025-10-20 00:23:04 +02:00
Gigi
e8e629f4e1 fix: prevent concurrent start() calls in readingProgressController
Added isLoading flag to block multiple start() calls from running in parallel.
The repeated start() calls were all waiting on queryEvents() calls,
creating a thundering herd that prevented any from completing.

Now only one start() runs at a time, and concurrent calls are skipped
with a console log.
2025-10-20 00:18:23 +02:00
Gigi
a0829e834f feat: mirror debug behavior in Me tabs for MARK_AS_READ
- Reads (/me/reads/completed): fetch kind:7 📚 reactions and map #e -> 30023 naddr; include as completed reads
- Links (/me/links/completed): fetch kind:17 📚 reactions and use #r URL; include as completed links
- Keep progress-based items from readingProgressController, but explicitly add marked-only items per tab

This matches the debug page behavior and splits articles vs links cleanly.
2025-10-20 00:00:00 +02:00
Gigi
ff938aa384 feat(reads): include marked-as-read-only items in /me/reads
If an article or URL is marked as read (📚) but has no reading
progress event yet, include it in the reads list so the 'completed'
filter surfaces it.

Uses readingProgressController.getMarkedAsReadIds() to synthesize
ReadItems for marked-only entries.
2025-10-19 23:57:20 +02:00
Gigi
3991bfeeb2 fix: move lastLoadedPubkey assignment to end of start() method
The bug: start() was setting lastLoadedPubkey at the beginning, so if
start() got called twice (which it was), the second call would see
isLoadedFor(pubkey) return true and skip the entire loading process,
including fetching mark-as-read reactions.

Fix: Only set lastLoadedPubkey AFTER all fetching is complete. This
ensures that concurrent start() calls don't skip the loading.

This allows kind:7 and kind:17 mark-as-read reactions to be fetched
and tracked properly.
2025-10-19 23:54:44 +02:00
Gigi
e8c35c8914 debug: add module-level log to confirm module is loaded
If this log doesn't appear in console, the module isn't being imported at all.
2025-10-19 23:53:31 +02:00
Gigi
46345c154b debug: add log before fetching mark-as-read reactions
Shows we reached the point where we're about to fetch kind:7/kind:17 reactions.
2025-10-19 23:53:00 +02:00
Gigi
f43dae92aa debug: add start() method logs to confirm controller initialization
Added initial logs to show:
- When start() is called
- Whether already loaded (and skipped)

This helps confirm the controller is even being initialized.
2025-10-19 23:52:24 +02:00
Gigi
99c164a5e9 debug: add detailed logging to understand markedAsReadIds population
Added:
- getMarkedAsReadIds() method to expose markedAsReadIds for debugging
- Final state logging showing all progressMap keys and markedAsReadIds
- Comprehensive logging throughout kind:7/kind:17 processing

This will help identify why markedAsRead articles aren't showing in /me/reads/completed.
Check console logs to see:
1. All progressMap entries (nadrs)
2. All markedAsReadIds entries
3. Step-by-step kind:7 and kind:17 processing
2025-10-19 23:51:05 +02:00
Gigi
569b4357f2 fix: skip title fetching for raw event IDs in HighlightCitation
The eventReference can be either:
1. Raw event ID (hex string) - from event pointers
2. Coordinate string (kind:pubkey:identifier) - from address pointers
3. Already-encoded naddr - from some sources

Raw event IDs cannot be converted to nadrs without additional context
(we don't have the kind, pubkey, or identifier), so skip title fetching
for them to avoid bech32 decoding errors.

Fixes console errors:
- 'Invalid checksum in <hex>'
- 'Unknown letter: "b". Allowed: qpzry9x8gf2tvdw0s3jn54khce6mua7l'

These errors occurred when trying to decode raw hex event IDs as bech32.
2025-10-19 23:49:12 +02:00
Gigi
de287c625b chore: remove relay.current.fyi from relay list
Removed 'wss://relay.current.fyi' from both api/article-og.ts and
src/config/relays.ts as this relay is no longer used.
2025-10-19 23:47:33 +02:00
Gigi
1424f6ebc5 debug: add console.log statements to debug mark-as-read reaction tracking
Added detailed logging throughout the kind:7 and kind:17 reaction
processing to understand:
- What reactions are being fetched
- Which ones have MARK_AS_READ_EMOJI
- Event ID extraction
- Article lookups
- Event ID to naddr mapping
- Final markedAsReadIds set

Check browser console when loading /me/reads to see the full flow.
2025-10-19 23:46:25 +02:00
Gigi
b0a368fc64 fix: properly handle kind:7 mark-as-read reactions with event ID to naddr mapping
Restored kind:7 reaction handling with proper implementation:
1. Fetch kind:7 reactions with MARK_AS_READ_EMOJI
2. Extract event IDs from #e tags
3. Fetch the referenced articles (kind:30023)
4. Build mapping of event IDs to nadrs
5. Add marked articles to markedAsReadIds using their nadrs

Now both kind:7 (Nostr articles) and kind:17 (URLs) mark-as-read
reactions are properly tracked and will appear in /me/reads/completed.

Added nip19 import for naddr encoding.
2025-10-19 23:44:56 +02:00
Gigi
6f8cf641b7 fix: correctly track mark-as-read reactions in readingProgressController
Fixed several issues:
1. Clear markedAsReadIds on reset() so it doesn't persist across logouts
2. Skip kind:7 reactions (events) as they require complex event ID to naddr mapping
3. Only process kind:17 reactions (URLs) which directly use URLs as identifiers
4. Correctly extract URL from #r tag instead of using emoji content

Now kind:17 mark-as-read reactions for external URLs are properly tracked.
These articles will appear in /me/reads/completed.
2025-10-19 23:42:22 +02:00
Gigi
23b4c3475f feat: track mark-as-read reactions in readingProgressController
Extended readingProgressController to also fetch and track mark-as-read
reactions (kind:7 and kind:17 with MARK_AS_READ_EMOJI) alongside reading
progress events.

Changes:
- Added markedAsReadIds Set to controller
- Query mark-as-read reactions in parallel with reading progress
- Added isMarkedAsRead() method to check if article is marked as read
- Updated Me.tsx to include markedAsRead status in ReadItems

Now /me/reads/completed shows:
- Articles with >= 95% reading progress
- Articles marked as read with the 📚 emoji
2025-10-19 23:33:22 +02:00
Gigi
5633dc640c refactor: simplify reads - use readingProgressController directly
Removed the complex readsController wrapper. Now /me/reads simply:
1. Uses readingProgressController (already loaded in App.tsx)
2. Converts progress map to ReadItems
3. Subscribes to progress updates

This is much simpler and DRY - no need for a separate controller.
Reading progress is already deduped and managed centrally.

Same approach as debug page - just use the data source directly.
2025-10-19 23:29:06 +02:00
Gigi
0f1dfa445a refactor: simplify reads loading - don't require bookmarks
Reads don't actually need bookmarks to load. Reading progress (kind:39802)
is independent and stands on its own. Bookmarks are just optional enrichment.

Changed:
- readsController.start() no longer takes bookmarks parameter
- Pass empty array to fetchAllReads instead
- Load reads immediately in App.tsx like highlights/writings
- No more circular dependency on bookmarks loading first

This is simpler and loads reading progress faster.
2025-10-19 23:26:00 +02:00
Gigi
ab5225de50 fix: emit all reading items not just articles
The onItem callback was filtering to only 'article' type items,
which excluded external URLs from reading progress. Now all items
(articles and external URLs) are emitted to readsController.

This fixes the empty reads list issue where reading progress exists
but wasn't being displayed.
2025-10-19 23:24:58 +02:00
Gigi
b89705cf43 feat: load reads centrally in App.tsx like bookmarks and highlights
- Import readsController in App.tsx
- Start readsController in the central useEffect when user logs in
- Pass bookmarks to readsController.start() for article lookups
- Simplify Me.tsx loadReadsTab to just mark tab as loaded
- Subscription to readsController in Me.tsx still streams updates to UI

This means:
- Reads load in the background automatically
- Data is available even before clicking the Reads tab
- Consistent with how bookmarks, highlights, and writings are loaded
- Non-blocking - readsController streams updates progressively
2025-10-19 23:23:16 +02:00
Gigi
740dd53299 fix: properly subscribe to readsController updates with useEffect
The loadReadsTab async function was trying to return cleanup functions,
which doesn't work in React. Moved the subscription logic to a separate
useEffect hook with empty dependency array so:
- Subscriptions are set up once on mount
- Cleanup happens properly on unmount
- readsController updates flow through to UI correctly

This fixes the empty reads list issue.
2025-10-19 23:21:12 +02:00
Gigi
eb61553c20 feat: create readsController following highlightsController pattern
- New src/services/readsController.ts manages all reading activity centrally
- Streams reading items as they arrive (progress, marks as read, bookmarks)
- Supports subscriptions via onReads() and onLoading() callbacks
- Tracks loading state and last synced timestamp per user
- Generation-based cancellation for logout/pubkey changes
- Deduplicates by article ID and sorts by reading activity
- Updated Me.tsx loadReadsTab to use readsController instead of calling fetchAllReads
- Provides same reactive, non-blocking UX as highlightsController
2025-10-19 23:19:46 +02:00
Gigi
8b708535ca fix: don't block UI while loading reads - stream updates as data arrives
Changed loadReadsTab to not await fetchAllReads. Instead:
- Start with empty state immediately
- Use onItem callback to stream updates as they're fetched
- Reading data flows in as it arrives (reading progress, marks as read, etc)
- UI doesn't block waiting for all article data to be fetched

Same pattern as debug page - provides responsive UI with progressive loading.
2025-10-19 23:17:40 +02:00
Gigi
f77761c002 feat: show all reading activity in /me/reads, not just bookmarks
Changed loadReadsTab to use fetchAllReads directly instead of deriveReadsFromBookmarks.
Now /me/reads shows ALL articles with any reading activity:
- Articles with reading progress (kind:39802)
- Articles marked as read (kind:7, kind:17 reactions)
- Articles with highlights
- Bookmarked articles

Previously only showed bookmarked articles and tried to enrich with reading data.
Now the reading data (progress, marks as read) is the primary source.
2025-10-19 23:15:53 +02:00
Gigi
b900666eb8 feat: add category breakdown to reading progress debug output
Shows counts of articles in each reading progress category:
- Unopened (0%)
- Started (0% < progress ≤ 10%)
- Reading (10% < progress ≤ 94%) - highlighted in green
- Completed (≥ 95%)

This helps understand why /me/reads/reading shows fewer articles than
the total reading progress events - most articles fall into other categories.
2025-10-19 23:11:38 +02:00
Gigi
2639c78957 feat: display both raw and deduplicated reading progress events
- Load raw events from queryEvents for transparency
- Load deduplicated results from readingProgressController in parallel
- Display raw events first, then deduplicated results below for comparison
- Helps debugging by showing all events plus the final processed state
2025-10-19 23:08:31 +02:00
Gigi
8320911bc9 refactor: use readingProgressController for deduplicated progress in debug
- Replace raw queryEvents with readingProgressController.start() for reading progress
- Controller already handles deduplication by article (d-tag) and keeps most recent
- Display deduplicated progress map below raw events for easy comparison
- Add progress percentage and visual progress bar for each article
- Add styling with blue background to distinguish deduplicated results
2025-10-19 23:07:32 +02:00
Gigi
00d6bd4c46 feat: add reading progress loading section to debug page
- Add state variables for reading progress events and mark-as-read reactions
- Implement handler to load all reading progress events (kind:39802) for logged-in user
- Implement handler to load all mark-as-read reactions (kind:7, kind:17) with MARK_AS_READ_EMOJI filter
- Add two new sections to debug page with buttons and results display
- Display event details including author, creation time, and relevant tags
- Include timing metrics for load operations
2025-10-19 23:02:15 +02:00
Gigi
cd377b6f26 docs: update CHANGELOG.md for v0.8.2 release
- Added reading progress indicator in compact cards
- Compact cards layout optimizations (reduced padding, row height, gaps)
- Reading progress bar styling (thinner, aligned with text)
- Fixed: Removed borders from compact bookmarks
2025-10-19 22:55:39 +02:00
Gigi
84b0339505 chore: bump version to 0.8.2 2025-10-19 22:54:46 +02:00
Gigi
12fa1db0db style: adjust progress bar margin in compact cards
- Reduce left margin from 1.75rem to 1.5rem for better visual balance
2025-10-19 22:54:19 +02:00
Gigi
0919091f19 style: align reading progress bar with text in compact cards
- Add left margin of 1.75rem to progress bar to start where text begins
- Prevents progress bar from looking like a separator
- Creates visual association between progress indicator and the specific bookmark item
2025-10-19 22:53:49 +02:00
Gigi
e1c04b4e7f fix: align progress bar to start at title position
- Add padding-left to progress bar container to offset it to title position
- Remove margin from inner fill
- Progress bar now visually starts where the title starts, not at the icon
2025-10-19 22:52:32 +02:00
Gigi
b9642067a1 fix: use margin instead of padding for reading progress bar alignment
- Move left offset from outer container padding to inner progress fill margin
- Background bar now spans full width while progress fill starts at text position
- Creates cleaner visual alignment without distorting the bar appearance
2025-10-19 22:51:36 +02:00
Gigi
ceca37df08 style: align reading progress bar with title text in compact cards
- Add left padding (1.85rem) to progress bar to align with bookmark title
- Progress bar now starts at the same position as the text content
2025-10-19 22:50:48 +02:00
Gigi
dfdc5d0946 style: make reading progress bar thinner in compact cards
- Reduce reading progress bar height from 2px to 1px
- Creates a more subtle, minimal progress indicator for compact bookmarks
2025-10-19 22:49:46 +02:00
Gigi
3619cd2585 fix: remove borders from compact bookmarks in sidebar
- Add explicit CSS rule to remove border from compact bookmarks in .bookmarks-list
- Override the border styling from me.css that was applying to all .individual-bookmark elements
- Ensure compact cards remain borderless and transparent
2025-10-19 22:49:01 +02:00
Gigi
f93e52611e style: make compact cards even more compact
- Reduce padding from 0.5rem to 0.25rem vertically
- Reduce compact row height from 28px to 24px
- Reduce gap between compact cards from 0.5rem to 0.25rem
- Creates a tighter, more space-efficient list layout
2025-10-19 22:48:17 +02:00
Gigi
ecb81cb151 feat: show reading progress in compact cards in bookmarks sidebar
- Add reading progress state and subscription to BookmarkList component
- Create helper function to get reading progress for both articles (using naddr) and web bookmarks (using URL)
- Update CompactView to display reading progress indicator for all bookmark types
- Progress indicator now shows for any bookmark with reading data, not just articles
2025-10-19 22:46:34 +02:00
Gigi
adf73cb9d1 fix: resolve all linting and type errors
- Fix empty catch blocks by adding explanatory comments
- Remove unused variables or prefix with underscore
- Remove orphaned object literals from removed console.log statements
- Fix unnecessary dependency array entries
- Ensure all empty code blocks have comments to satisfy eslint no-empty rule
2025-10-19 22:41:35 +02:00
Gigi
4202807777 refactor: remove all console.log debug output 2025-10-19 22:35:45 +02:00
Gigi
1c21615103 chore: bump version to 0.8.1 2025-10-19 22:31:06 +02:00
Gigi
732070e89b fix: re-derive reads/links when bookmarks change
- Add bookmarks to useEffect dependencies that load tab data
- Reads tab now updates when bookmarks are loaded/updated
- Fixes 'No articles ready yet' disappearing when switching tabs
- Ensures reads are always derived from current bookmark state
- Re-renders Reads tab whenever bookmarks change
2025-10-19 22:29:02 +02:00
Gigi
d9a00dd157 fix: merge reading progress from controller into reads/links before filtering
- Enrich reads and links arrays with reading progress from readingProgressMap
- Use item.id to lookup progress for articles
- Use item.url to lookup progress for links
- Now 'started' and 'reading' filters show correct articles
- Filters respond in real-time as reading progress updates from controller
2025-10-19 22:25:55 +02:00
Gigi
103be75f6e feat: auto-load reading progress on login and app start
- Add readingProgressController.start() to App.tsx
- Follows same pattern as highlightsController and writingsController
- Checks isLoadedFor(pubkey) to prevent duplicate loading
- Automatically fetches reading progress when user logs in
- Loads progress from cache first, then streams from relays
- Reading progress now available immediately for filters and indicators
2025-10-19 22:23:03 +02:00
Gigi
8dd4e358b4 fix: normalize highlight article references to naddr format for proper matching
- Convert coordinate-format eventReferences (30023:pubkey:identifier) to naddr
- ReadItems use naddr format for IDs, but highlights store coordinates
- Properly match highlights to articles by normalizing both formats
- Fixes 'highlighted' filter showing no results
- Handles conversion errors gracefully by falling back to original format
2025-10-19 22:21:43 +02:00
Gigi
2e8dfaee09 refactor: reorder reading progress filters - highlighted before completed
- Move highlighted filter before completed in button order
- Reading filters now appear in logical order:
  All → Unopened → Started → Reading → Highlighted → Completed
2025-10-19 22:20:31 +02:00
Gigi
db3084b373 fix: use ES6 import instead of require in helpers.ts
- Replace require() call with ES6 import for READING_PROGRESS constant
- Fixes linter error: 'require' is not defined (no-undef)
- All linter checks now pass with no warnings or errors
2025-10-19 22:19:06 +02:00
Gigi
83e4a2ad4c refactor: rename Amethyst bookmark sections to simpler names
- Rename 'Amethyst Lists' to 'My Lists'
- Rename 'Amethyst Private' to 'Private Lists'
- Clearer and more intuitive names without referencing the Amethyst client
- Applied in both Me.tsx and BookmarkList.tsx

These sections contain kind:30001 bookmarks (replaceable list events).
2025-10-19 22:18:16 +02:00
Gigi
c1d23fac7b feat: show reading progress in compact and card view bookmarks
- Add readingProgress prop to BookmarkItem component
- Display reading progress in CompactView with 2px indicator
- Display reading progress in CardView with 3px indicator
- Progress color matches main app: blue (reading), green (completed), neutral (started)
- Add getBookmarkReadingProgress helper in Me.tsx
- Show progress only for kind:30023 articles with progress > 0
- Reading progress now visible across all bookmark view modes
2025-10-19 22:17:35 +02:00
Gigi
de32310801 feat: add highlights filter button to reading progress filters
- Add 'highlighted' filter type to ReadingProgressFilterType
- New filter button with yellow highlighter icon
- Filter shows only articles that have highlights
- Highlights filter checks both eventReference and urlReference tags
- Color-coded: green for completed, yellow for highlighted, blue for others
- Applies to reads and links tabs in /me page
2025-10-19 22:15:13 +02:00
Gigi
5c82dff8df feat: only track reading progress for articles above minimum length
- Add MIN_CONTENT_LENGTH constant (1000 chars ≈ 150 words) to config/kinds
- Create shouldTrackReadingProgress helper to validate content length
- Strip HTML tags when calculating character count
- Only save reading progress for articles meeting the threshold
- Log when content is too short to track

This prevents noisy tracking of very short articles or excerpts.
2025-10-19 22:13:37 +02:00
Gigi
abe2d6528a feat: add setting to hide bookmarks missing creation date
- Add hideBookmarksWithoutCreationDate to UserSettings
- New checkbox in Layout & Behavior settings
- Bookmarks without valid creation dates shown as 'Now'
- Setting disabled by default to maintain current behavior
2025-10-19 22:11:47 +02:00
Gigi
8b56fe3d6e ux: update Flight Mode notification text to say 'Local relays only' 2025-10-19 22:10:32 +02:00
Gigi
bdce7c9358 docs: update CHANGELOG.md for v0.8.0 release 2025-10-19 22:09:56 +02:00
Gigi
81a4ae392f bump: release version 0.8.0 2025-10-19 22:09:13 +02:00
Gigi
6e438b8ee2 Merge pull request #21 from dergigi/reading-progress-nip
feat: implement NIP-85 reading progress tracking
2025-10-19 22:08:04 +02:00
Gigi
31974e7271 feat(reading): 2s completion hold at 100% + reliable auto mark-as-read
- Add completionHoldMs (default 2000ms) to useReadingPosition
- Start hold timer when position hits 100%; cancel if user scrolls up
- Fallback to threshold completion when configured
- Clears timers on unmount/disable
2025-10-19 16:17:17 +02:00
Gigi
676be1a932 feat: make reading position sync default-on in runtime paths
- Treat undefined as enabled in ContentPanel (only false disables)
- Keeps DEFAULT_SETTINGS at true; ensures consistent behavior even for users without the new setting persisted yet
2025-10-19 16:15:43 +02:00
Gigi
9883f2eb1a chore(settings): tweak label for auto mark-as-read (remove animation note) 2025-10-19 16:13:56 +02:00
Gigi
87e46be86f feat(settings): restore 'auto mark as read at 100%' option
- Added autoMarkAsReadOnCompletion to default settings (disabled by default)
- Added toggle in Layout & Behavior section
- Existing ContentPanel logic already hooks into this to trigger animation & mark-as-read
2025-10-19 16:07:59 +02:00
Gigi
b745a92a7e feat: allow saving 0% reading position and initial save
- Remove low-position guard; allow 0% saves
- One-time initial save even without significant change
- Always allow immediate save regardless of position
- Fix linter empty-catch warnings in readingProgressController
2025-10-19 16:03:34 +02:00
Gigi
5a79da4024 feat: persist reading progress in localStorage per pubkey
- Seed controller state from cache on start for instant display after refresh
- Persist updated progress map after processing events
- Keeps progress visible even without immediate relay responses
2025-10-19 15:59:01 +02:00
Gigi
a7d05a29f5 feat: process local reading progress via eventStore.timeline()
- Subscribe to timeline for immediate local events and reactive updates
- Clean up timeline subscription on reset/start to avoid leaks
- Keep relay sync for background augmentation
- Should populate progress map even without relay roundtrip
2025-10-19 12:29:44 +02:00
Gigi
0740d53d37 fix: resolve all linter warnings
- Add proper types (Filter, NostrEvent) to readingProgressController
- Add eslint-disable comment for position dependency in useReadingPosition
  (position is derived from scroll and including it would cause infinite re-renders)
- All lint warnings resolved
- TypeScript type checks pass
2025-10-19 12:27:19 +02:00
Gigi
914738abb4 fix: force full sync when map is empty
- If currentProgressMap is empty, do a full sync (no 'since' filter)
- This ensures first load gets all events, not just recent ones
- Incremental sync only happens when we already have data
- This was the bug: lastSynced was preventing initial load of events
2025-10-19 12:18:46 +02:00
Gigi
4fac5f42c9 fix: remove broken timeline subscription, rely on queryEvents
- Timeline subscription is async and emits empty array first
- queryEvents already checks local store then relays
- Simpler and actually works correctly
- This is how all other controllers work (highlights, bookmarks, etc.)
2025-10-19 12:17:38 +02:00
Gigi
16b3668e73 debug: add logs to trace why events aren't processed
- Log sample event to see format
- Log map size after processEvents to see if it worked
- This will show if processEvents is failing silently
2025-10-19 12:13:45 +02:00
Gigi
f3a83256a8 debug: improve timeline subscription and add more logs
- Capture events from timeline before unsubscribing
- Add log to show when timeline emits
- Add log after unsubscribe to show what we got
- This will help debug why processEvents isn't being called
2025-10-19 12:10:53 +02:00
Gigi
0e98ddeef4 fix: use eventStore.timeline() to query local events
- Subscribe to timeline to get initial cached events
- Unsubscribe immediately after reading initial value
- This works with IEventStore interface correctly
2025-10-19 12:04:49 +02:00
Gigi
1ba375e93e fix: load reading progress from event store first (non-blocking)
- Query local event store immediately for instant display
- Then augment with relay data in background
- This matches how bookmarks work: local-first, then sync
- Events saved locally now appear immediately without waiting for relay propagation
2025-10-19 12:03:36 +02:00
Gigi
5d14d25d0e debug: add detailed logging to Profile component
- Show initial map size and updates in Profile
- Log lookups with map contents in Profile
- Helps debug reading progress on profile pages
2025-10-19 12:02:22 +02:00
Gigi
616038a23a debug: reduce log spam and show map size in lookups
- Only log when progress found or map is empty
- Show map size to quickly diagnose empty map issue
- Show first 3 map keys as sample instead of all
2025-10-19 11:59:37 +02:00
Gigi
14fce2c3dc debug: add detailed naddr comparison logs
- Show all map keys when looking up reading progress
- Show d-tag generation from naddr in save flow
- This will help identify if naddr encoding/decoding is causing mismatch
2025-10-19 11:56:27 +02:00
Gigi
7c511de474 feat: enable reading position sync by default
- Changed syncReadingPosition default from false to true in Settings.tsx
- Users can still disable it in settings if they prefer
- This ensures reading progress tracking works out of the box
2025-10-19 11:52:05 +02:00
Gigi
3a10ac8691 debug: add logs to show why reading position saves are skipped
- Log when scheduleSave returns early (syncEnabled false, no onSave callback)
- Log when position is too low (<5%)
- Log when change is not significant enough (<1%)
- Log ContentPanel sync status (enabled, settings, requirements)
- This will help diagnose why no events are being created
2025-10-19 11:41:38 +02:00
Gigi
205879f948 debug: add comprehensive logging for reading position calculation and event publishing
- Add logs in useReadingPosition: scroll position calculation (throttled to 5% changes)
- Add logs for scheduling and triggering auto-save
- Add detailed logs in ContentPanel handleSavePosition
- Add logs in saveReadingPosition: event creation, signing, publishing
- Add logs in publishEvent: event store addition, relay status, publishing
- All logs prefixed with [progress] for easy filtering
- Shows complete flow from scroll → calculate → save → create event → publish to relays
2025-10-19 11:39:25 +02:00
Gigi
bff43f4a28 debug: add comprehensive [progress] logging throughout reading progress flow
- Add logs in readingProgressController: processing events, emitting to listeners
- Add logs in Explore component: receiving updates, looking up progress
- Add logs in BlogPostCard: rendering with progress
- Add detailed logs in processReadingProgress: event parsing, naddr conversion
- All logs prefixed with [progress] for easy filtering
2025-10-19 11:30:57 +02:00
Gigi
2a7fffd594 fix: remove invalid eventStore.list() call in reading progress controller
- EventStore doesn't have a list() method
- Follow same pattern as highlightsController and just fetch from relays
- Fixes TypeError: eventStore.list is not a function
2025-10-19 11:18:21 +02:00
Gigi
50a4161e16 feat: reset reading progress controller on logout
- Add readingProgressController.reset() to handleLogout in App.tsx
- Ensures reading progress data is cleared when user logs out
- Consistent with other controllers (bookmarks, contacts, highlights)
2025-10-19 11:08:33 +02:00
Gigi
5fd8976097 refactor: create centralized reading progress controller
- Add readingProgressController following the same pattern as highlightsController and writingsController
- Controller manages reading progress (kind:39802) centrally with subscriptions
- Remove duplicated reading progress loading logic from Explore, Profile, and Me components
- Components now subscribe to controller updates instead of loading data individually
- Supports incremental sync and force reload
- Improves efficiency and maintainability
2025-10-19 11:06:57 +02:00
Gigi
80b26abff2 feat: add reading progress indicators to blog post cards
- Add reading progress loading and display in Explore component
- Add reading progress loading and display in Profile component
- Add reading progress loading and display in Me writings tab
- Reading progress now shows as colored progress bar in all blog post cards
- Progress colors: gray (started 0-10%), blue (reading 10-95%), green (completed 95%+)
2025-10-19 11:02:20 +02:00
Gigi
c0638851c6 docs: simplify NIP-85 to match NIP-84 style and length
- Remove verbose rationale section
- Remove excessive querying examples
- Remove privacy considerations (obvious)
- Remove implementation notes fluff
- Remove references section
- Keep only essential: format, tags, content, examples
- Match NIP-84's concise, to-the-point style

From 190 lines down to ~75 lines - much more readable
2025-10-19 10:54:31 +02:00
Gigi
9b6b14cfe8 refactor: remove client tag from reading progress events
- Remove 'client' tag from NIP-85 specification
- Remove 'client' tag from code implementation
- Align with Nostr principles of client-agnostic data
- Follow NIP-84 pattern which doesn't include client tags

Events should be client-agnostic and not include branding/tracking.
2025-10-19 10:46:44 +02:00
Gigi
b6ad62a3ab refactor: rename to NIP-85 (kind 39802 for reading progress)
- Rename NIP-39802.md to NIP-85.md
- Update all references from NIP-39802 to NIP-85 in code comments
- Add Table of Contents to NIP document
- Update kinds.ts to reference NIP-85 and NIP-84 (highlights)
- Maintain kind number 39802 for the event type

NIP-85 is the specification number, 39802 is the event kind number.
2025-10-19 10:41:02 +02:00
Gigi
85d87bac29 docs: improve NIP-39802 with URL cleaning guidance from NIP-84
- Add recommendation to clean URLs from tracking parameters
- Add URL Handling subsection with best practices
- Ensure same article from different sources maps to same progress
- Inspired by NIP-84 (Highlights) URL handling guidelines
2025-10-19 10:38:28 +02:00
Gigi
3b31eceeab feat: improve reading progress with validation and auto-mark
- Add autoMarkAsReadOnCompletion setting (opt-in, default: false)
- Implement auto-mark as read when reaching 95%+ completion
- Add validation for progress bounds (0-1) per NIP-39802 spec
- Align completion threshold to 95% to match filter behavior
- Skip invalid progress events with warning log

Improvements ensure consistency between completion detection and
filtering, while adding safety validation per the NIP spec.
2025-10-19 10:34:53 +02:00
Gigi
442c138d6a refactor: simplify NIP-39802 implementation - remove migration complexity
- Remove dual-write logic: only write kind 39802
- Remove legacy kind 30078 read fallback
- Remove migration settings flags (useReadingProgressKind, writeLegacyReadingPosition)
- Simplify readingPositionService: single write/read path
- Remove processReadingPositions() legacy processor
- Update readsService and linksService to only query kind 39802
- Simplify NIP-39802 spec: remove migration section
- Delete READING_PROGRESS_MIGRATION.md (not needed for unreleased app)
- Clean up imports and comments

No backward compatibility needed since app hasn't been released yet.
2025-10-19 10:14:37 +02:00
Gigi
61e6027252 docs: add migration guide and test documentation for NIP-39802
- Create READING_PROGRESS_MIGRATION.md with detailed migration phases
- Document test scenarios inline in readingPositionService and readingDataProcessor
- Outline timeline for dual-write, prefer-new, and deprecation phases
- Add rollback plan and settings API documentation
- Include comparison table of legacy vs new event formats
2025-10-19 10:10:18 +02:00
Gigi
7d373015b4 feat: implement NIP-39802 reading progress with dual-write migration
- Add kind 39802 (ReadingProgress) as dedicated parameterized replaceable event
- Create NIP-39802 specification document in public/md/
- Implement dual-write: publish both kind 39802 and legacy kind 30078
- Implement dual-read: prefer kind 39802, fall back to kind 30078
- Add migration flags to settings (useReadingProgressKind, writeLegacyReadingPosition)
- Update readingPositionService with new d-tag generation and tag helpers
- Add processReadingProgress() for kind 39802 events in readingDataProcessor
- Update readsService and linksService to query and process both kinds
- Use event.created_at as authoritative timestamp per NIP-39802 spec
- ContentPanel respects migration flags from settings
- Maintain backward compatibility during migration phase
2025-10-19 10:09:09 +02:00
Gigi
32b1286079 chore: remove [bookmark] debug logs
- Remove all console.log statements with [bookmark] prefix from App.tsx
- Remove all console.log statements with [bookmark] prefix from bookmarkController.ts
- Replace verbose error logging with simple error messages
- Keep code clean and reduce console clutter
2025-10-19 01:43:25 +02:00
Gigi
17fdd92827 fix(profile): fetch all writings for profile pages by removing limit
- Make limit parameter configurable in fetchBlogPostsFromAuthors
- Default limit is 100 for Explore page (multiple authors)
- Pass null limit for Profile pages to fetch all writings
- Fixes issue where only 1 writing was shown instead of all
2025-10-19 01:35:00 +02:00
Gigi
aa6aeb2723 refactor: split Me into Me and Profile components for simpler /p/ pages
- Create Profile.tsx for viewing other users (highlights + writings only)
- Profile uses useStoreTimeline for instant cache-first display
- Background fetches populate event store non-blocking
- Extract toBlogPostPreview helper for reuse
- Simplify Me.tsx to only handle own profile (/me routes)
- Remove isOwnProfile branching and cached data logic from Me
- Update Bookmarks.tsx to render Profile for /p/ routes
- Keep code DRY and files under 210 lines
2025-10-19 01:28:22 +02:00
Gigi
4b0f275f57 docs: update CHANGELOG.md for v0.7.4 release 2025-10-19 01:21:38 +02:00
Gigi
73e2e060e3 chore: bump version to 0.7.4 2025-10-19 01:19:10 +02:00
Gigi
3007ae83c2 fix(profile): display cached highlights and writings instantly, fetch fresh in background 2025-10-19 01:17:35 +02:00
Gigi
a862eb880e feat(profile): preload all highlights and writings into event store 2025-10-19 01:15:01 +02:00
Gigi
016e369fb1 feat(highlights): only show nostrverse filter when logged out 2025-10-19 01:09:39 +02:00
Gigi
4f21982c48 feat(me): show bookmarks in cards view on /me/bookmarks tab 2025-10-19 01:08:42 +02:00
Gigi
f6d3fe9aba docs: update CHANGELOG.md for v0.7.3 release 2025-10-19 01:06:19 +02:00
Gigi
fc60e6b80a chore: bump version to 0.7.3 2025-10-19 01:04:48 +02:00
Gigi
d9cdbb7279 Merge pull request #20 from dergigi/writings-controller
Make Explore non-blocking; centralize nostrverse highlights & writings controllers
2025-10-19 01:03:25 +02:00
Gigi
401d333e0f fix(explore): logged-out mode relies solely on centralized nostrverse controllers; start controllers even when logged out 2025-10-19 00:58:07 +02:00
Gigi
d32a47e3c3 perf(explore): make loading fully non-blocking; seed caches then stream and merge results progressively 2025-10-19 00:55:24 +02:00
Gigi
35efdb6d3f feat(nostrverse): add nostrverseWritingsController and subscribe in Explore; start controller at app init 2025-10-19 00:52:32 +02:00
Gigi
c7f7792d73 feat(highlights): add centralized nostrverseHighlightsController; start at app init; Explore subscribes to controller stream 2025-10-19 00:50:12 +02:00
Gigi
8aa26caae0 feat(explore): show skeletons instead of spinner; keep nostrverse non-blocking and stream into view 2025-10-19 00:48:24 +02:00
Gigi
6c00904bd5 fix(explore,nostrverse): never block explore highlights on nostrverse; show empty state instead of spinner and stream results into store immediately 2025-10-19 00:46:16 +02:00
Gigi
23526954ea fix(explore): reflect settings default scope immediately and avoid blank lists; preload/merge nostrverse from event store and keep fetches non-blocking 2025-10-19 00:42:39 +02:00
Gigi
9a437dd97b fix(explore): ensure nostrverse highlights are loaded and merged; preload nostrverse highlights at app start for instant Explore toggle 2025-10-19 00:38:05 +02:00
Gigi
0baf75462c refactor(explore): use writingsController for 'mine' posts; keep fetches non-blocking and centralized 2025-10-19 00:34:21 +02:00
Gigi
30b8f1af92 feat(writings): auto-load user writings at login so Explore 'mine' tab has local data 2025-10-19 00:30:07 +02:00
Gigi
07aea9d35f fix(explore): prevent disabling all explore scopes; ensure at least one filter remains active 2025-10-19 00:28:55 +02:00
Gigi
41a4abff37 fix(highlights): scope highlights to current article on /a and /r by deriving coordinate from naddr for early filtering, and ensure sidebar/content only show scoped highlights 2025-10-19 00:24:37 +02:00
Gigi
c9998984c3 feat(explore): include and stream my writings when enabled\n\n- Load my own writings in parallel with friends/nostrverse\n- Lazy-load on 'mine' toggle when logged in\n- Keep dedupe/sort consistent 2025-10-19 00:16:01 +02:00
Gigi
a799709e62 fix(explore): ensure writings are deduped by replaceable before visibility filtering and render 2025-10-19 00:14:20 +02:00
Gigi
18c6c3e68a fix(content): show only article-specific highlights in ContentPanel for nostr articles 2025-10-19 00:12:49 +02:00
Gigi
5e7395652f feat(explore): stream nostrverse writings when toggled on while logged in\n\n- Lazy-load nostrverse via onPost callback when filter is enabled\n- Avoid reloading twice using hasLoadedNostrverse guard\n- Keep DRY dedupe/sort behavior 2025-10-19 00:08:06 +02:00
Gigi
83076e7b01 feat(explore): stream nostrverse writings to paint instantly\n\n- Add onPost streaming callback to fetchNostrverseBlogPosts\n- Stream posts in Explore when logged out and logged in\n- Keep final deduped/sorted list after stream completes 2025-10-19 00:04:53 +02:00
Gigi
c79f4122da feat(debug): add Writings Loading section to debug page
- Add handlers for loading my writings, friends writings, and nostrverse writings
- Display writings with title, summary, author, and d-tag
- Show timing metrics (total load time and first event time)
- Use writingsController for own writings to test controller functionality
2025-10-18 23:57:46 +02:00
Gigi
179fe0bbc2 fix(explore): prevent infinite loop when loading nostrverse content
- Remove cachedHighlights, cachedWritings, myHighlights from useEffect deps
- These are derived from eventStore and caused infinite refetch loop
- Content is still seeded from cache but doesn't trigger re-fetches
2025-10-18 23:54:02 +02:00
Gigi
20b4f2b1b2 fix(explore): fetch nostrverse content when logged out
- Allow exploring nostrverse writings and highlights without account
- Default to nostrverse visibility when logged out
- Update visibility settings when login state changes
2025-10-18 23:50:12 +02:00
Gigi
936f9093cf fix(me): use myWritingsLoading state in writings tab rendering 2025-10-18 23:45:16 +02:00
Gigi
3149e5b824 feat(services): add centralized writingsController for kind 30023 2025-10-18 23:43:16 +02:00
Gigi
8619cecaf3 docs: update CHANGELOG.md for v0.7.2 2025-10-18 23:32:10 +02:00
180 changed files with 15330 additions and 3977 deletions

View File

@@ -2,4 +2,4 @@
alwaysApply: true
---
Keep files below 210 lines.
Keep files below 420 lines.

View File

@@ -0,0 +1,21 @@
---
description: fetching data from relays
alwaysApply: false
---
# Fetching Data with Controllers
We fetch data from relays using controllers:
- Start controllers immediatly; don't await.
- Stream via onEvent; dedupe replaceables; emit immediately.
- Parallel local/remote queries; complete on EOSE.
- Finalize and persist since after completion.
- Guard with generations to cancel stale runs.
- UI flips off loading on first streamed result.
We always include and prefer local relays for reads; optionally rebroadcast fetched content to local relays (depending on setting); and tolerate localonly mode for writes (queueing for later).
Since we are streaming results, we should NEVER use timeouts for fetching data. We should always rely on EOSE.
In short: Local-first hydration, background network fetch, reactive updates, and replaceable lookups provide instant UI with eventual consistency. Use local relays as local data store for everything we fetch from remote relays.

View File

@@ -3,6 +3,8 @@ description: anything related to UI/UX
alwaysApply: false
---
# Mobile-First UI/UX
This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.)
Let's not show too many error messages, and more importantly: let's not make them red. Nothing is ever this tragic.

2
.env
View File

@@ -1,2 +0,0 @@
# Default article to display on app load
VITE_DEFAULT_ARTICLE_NADDR=naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew

View File

@@ -1,3 +1,14 @@
# Default article to display on app load
# This should be a valid naddr1... string (NIP-19 encoded address pointer to a kind:30023 long-form article)
VITE_DEFAULT_ARTICLE_NADDR=naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew
# Nostr configuration for publish-markdown.sh script
# Copy this file to .env and fill in your values
# Your Nostr secret key (nsec, ncryptsec, or hex format)
# You can also set this via environment variable: export NOSTR_SECRET_KEY=your_key
NOSTR_SECRET_KEY=
# Space-separated list of relay URLs to publish to
# If not provided, events will be created but not published
RELAYS="ws://localhost:10547 ws://localhost:4869 wss://relay.primal.net wss://wot.dergigi.com wss://relay.dergigi.com wss://nostr.einundzwanzig.space wss://relay.damus.io wss://relay.nostr.bg wss://nos.lol wss://eden.nostr.land"
# Test account used for publishing markdown test documents:
# npub: npub1marky39a9qmadyuux9lr49pdhy3ddxrdwtmd9y957kye66qyu3vq7spdm2
# Profile: https://read.withboris.com/p/npub1marky39a9qmadyuux9lr49pdhy3ddxrdwtmd9y957kye66qyu3vq7spdm2/writings

3
.gitignore vendored
View File

@@ -13,3 +13,6 @@ applesauce
primal-web-app
Amber
.env
scripts/.env
.vercel

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
- **Distractionfree view**: Clean typography, optional hero image, summary, and published date.
- **Reading time**: Displays estimated reading time for text or duration for supported videos.
- **Progress**: Reading progress indicator with completion state.
- **TexttoSpeech**: Listen to articles with browsernative TTS; play/pause/stop controls with adjustable speed (0.81.6x).
- **Menus**: Quick actions to open, share, or copy links (for both Nostr and web content).
- **Performance**: Lightweight fetching and caching for speed; skeleton loaders to avoid empty flashes.
@@ -39,7 +40,7 @@
- **Explore**: Discover friends' highlights and writings, plus a "nostrverse" feed.
- **Filters**: Visibility toggles (mine, friends, nostrverse) apply to Explore highlights.
- **Profiles**: View your own (`/me`) or other users (`/p/:npub`) with tabs for Highlights, Bookmarks, Archive, and Writings.
- **Profiles**: View your own (`/my`) or other users (`/p/:npub`) with tabs for Highlights, Bookmarks, Archive, and Writings.
## Support

View File

@@ -1,188 +0,0 @@
# Tailwind CSS Migration Status
## ✅ Completed (Core Infrastructure)
### Phase 1: Setup & Foundation
- [x] Install Tailwind CSS with PostCSS and Autoprefixer
- [x] Configure `tailwind.config.js` with content globs and custom keyframes
- [x] Create `src/styles/tailwind.css` with base/components/utilities
- [x] Import Tailwind before existing CSS in `main.tsx`
- [x] Enable Tailwind preflight (CSS reset)
### Phase 2: Base Styles Reconciliation
- [x] Add CSS variables for user-settable theme colors
- `--highlight-color-mine`, `--highlight-color-friends`, `--highlight-color-nostrverse`
- `--reading-font`, `--reading-font-size`
- [x] Simplify `global.css` to work with Tailwind preflight
- [x] Remove redundant base styles handled by Tailwind
- [x] Keep app-specific overrides (mobile sidebar lock, loading states)
### Phase 3: Layout System Refactor ⭐ **CRITICAL FIX**
- [x] Switch from pane-scrolling to document-scrolling
- [x] Make sidebars sticky on desktop (`position: sticky`)
- [x] Update `app.css` to remove fixed container heights
- [x] Update `ThreePaneLayout.tsx` to use window scroll
- [x] Fix reading position tracking to work with document scroll
- [x] Maintain mobile overlay behavior
### Phase 4: Component Migrations
- [x] **ReadingProgressIndicator**: Full Tailwind conversion
- Removed 80+ lines of CSS
- Added shimmer animation to Tailwind config
- Z-index layering maintained (1102)
- [x] **Mobile UI Elements**: Tailwind utilities
- Mobile hamburger button
- Mobile highlights button
- Mobile backdrop
- Removed 60+ lines of CSS
- [x] **App Container**: Tailwind utilities
- Responsive padding (p-0 md:p-4)
- Min-height viewport support
## 📊 Impact & Metrics
### Lines of CSS Removed
- `global.css`: ~50 lines removed
- `reader.css`: ~80 lines removed (progress indicator)
- `app.css`: ~30 lines removed (mobile buttons/backdrop)
- `sidebar.css`: ~30 lines removed (mobile hamburger)
- **Total**: ~190 lines removed
### Key Achievements
1. **Fixed Core Issue**: Reading position tracking now works correctly with document scroll
2. **Tailwind Integration**: Fully functional with preflight enabled
3. **No Breaking Changes**: All existing functionality preserved
4. **Type Safety**: TypeScript checks passing
5. **Lint Clean**: ESLint checks passing
6. **Responsive**: Mobile/tablet/desktop layouts working
## 🔄 Remaining Work (Incremental)
The following migrations are **optional enhancements** that can be done as components are touched:
### High-Value Components
- [ ] **ContentPanel** - Large component, high impact
- Reader header, meta info, loading states
- Mark as read button
- Article/video menus
- [ ] **BookmarkList & BookmarkItem** - Core UI
- Card layouts (compact/cards/large views)
- Bookmark metadata display
- Interactive states
- [ ] **HighlightsPanel** - Feature-rich
- Header with toggles
- Highlight items
- Level-based styling
- [ ] **Settings Components** - Forms & controls
- Color pickers
- Font selectors
- Toggle switches
- Sliders
### CSS Files to Prune
- `src/index.css` - Contains many inline bookmark/highlight styles (~3000+ lines)
- `src/styles/components/cards.css` - Bookmark card styles
- `src/styles/components/modals.css` - Modal dialogs
- `src/styles/layout/highlights.css` - Highlight panel layout
## 🎯 Migration Strategy
### For New Components
Use Tailwind utilities from the start. Reference:
```tsx
// Good: Tailwind utilities
<div className="flex items-center gap-2 p-4 bg-gray-800 rounded-lg">
// Avoid: New CSS classes
<div className="custom-component">
```
### For Existing Components
Migrate incrementally when touching files:
1. Replace layout utilities (flex, grid, spacing, sizing)
2. Replace color/background utilities
3. Replace typography utilities
4. Replace responsive variants
5. Remove old CSS rules
6. Keep file under 210 lines
### CSS Variable Usage
Dynamic values should still use CSS variables or inline styles:
```tsx
// User-settable colors
style={{ backgroundColor: settings.highlightColorMine }}
// Or reference CSS variable
className="bg-[var(--highlight-color-mine)]"
```
## 📝 Technical Notes
### Z-Index Layering
- Mobile sidepanes: `z-[1001]`
- Mobile backdrop: `z-[999]`
- Progress indicator: `z-[1102]`
- Mobile buttons: `z-[900]`
- Relay status: `z-[999]`
- Modals: `z-[10000]`
### Responsive Breakpoints
- Mobile: `< 768px`
- Tablet: `768px - 1024px`
- Desktop: `> 1024px`
Use Tailwind: `md:` (768px), `lg:` (1024px)
### Safe Area Insets
Mobile notch support:
```tsx
style={{
top: 'calc(1rem + env(safe-area-inset-top))',
left: 'calc(1rem + env(safe-area-inset-left))'
}}
```
### Custom Animations
Add to `tailwind.config.js`:
```js
keyframes: {
shimmer: {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(100%)' },
},
}
```
## ✅ Success Criteria Met
- [x] Tailwind CSS fully integrated and functional
- [x] Document scrolling working correctly
- [x] Reading position tracking accurate
- [x] Progress indicator always visible
- [x] No TypeScript errors
- [x] No linting errors
- [x] Mobile responsiveness maintained
- [x] Theme colors (user settings) working
- [x] All existing features functional
## 🚀 Next Steps
1. **Ship It**: Current state is production-ready
2. **Incremental Migration**: Convert components as you touch them
3. **Monitor**: Watch for any CSS conflicts
4. **Cleanup**: Eventually remove unused CSS files
5. **Document**: Update component docs with Tailwind patterns
---
**Status**: ✅ **CORE MIGRATION COMPLETE**
**Date**: 2025-01-14
**Commits**: 8 conventional commits
**Lines Removed**: ~190 lines of CSS
**Breaking Changes**: None

41
api/article-og-refresh.ts Normal file
View File

@@ -0,0 +1,41 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import { setArticleMeta } from './services/ogStore.js'
import { fetchArticleMetadataViaRelays } from './services/articleMeta.js'
export default async function handler(req: VercelRequest, res: VercelResponse) {
// Validate refresh secret
const providedSecret = req.headers['x-refresh-key']
const expectedSecret = process.env.OG_REFRESH_SECRET || ''
if (providedSecret !== expectedSecret) {
console.error('Background refresh unauthorized: secret mismatch')
return res.status(401).json({ error: 'Unauthorized' })
}
const naddr = (req.query.naddr as string | undefined)?.trim()
if (!naddr) {
return res.status(400).json({ error: 'Missing naddr parameter' })
}
console.log(`Background refresh started for ${naddr}`)
try {
// Fetch metadata via relays (WebSockets) - no timeout, let it take as long as needed
const meta = await fetchArticleMetadataViaRelays(naddr)
if (meta) {
console.log(`Background refresh found metadata for ${naddr}:`, { title: meta.title, summary: meta.summary?.substring(0, 50) })
// Store in Redis
await setArticleMeta(naddr, meta)
console.log(`Background refresh cached metadata for ${naddr}`)
return res.status(200).json({ ok: true, cached: true })
} else {
console.log(`Background refresh found no metadata for ${naddr}`)
return res.status(200).json({ ok: true, cached: false })
}
} catch (err) {
console.error(`Error refreshing article metadata for ${naddr}:`, err)
return res.status(500).json({ error: 'Internal server error' })
}
}

View File

@@ -1,208 +1,13 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent, Filter } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
// Relay configuration (from src/config/relays.ts)
const RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.dergigi.com',
'wss://wot.dergigi.com',
'wss://relay.snort.social',
'wss://relay.current.fyi',
'wss://nostr-pub.wellorder.net',
'wss://purplepag.es',
'wss://relay.primal.net'
]
type CacheEntry = {
html: string
expires: number
}
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
const memoryCache = new Map<string, CacheEntry>()
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
import { getArticleMeta, setArticleMeta } from './services/ogStore.js'
import { fetchArticleMetadataViaRelays } from './services/articleMeta.js'
import { generateHtml } from './services/ogHtml.js'
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()
@@ -210,95 +15,46 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
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}');
// Try Redis cache first
let meta = await getArticleMeta(naddr).catch((err) => {
console.error('Failed to get article meta from Redis:', err)
return null
})
let cacheMaxAge = 86400
if (!meta) {
// Cache miss: fetch from relays (let it use its natural timeouts)
try {
meta = await fetchArticleMetadataViaRelays(naddr)
if (meta) {
// Store in Redis and use it
await setArticleMeta(naddr, meta).catch((err) => {
console.error('Failed to cache relay metadata:', err)
})
cacheMaxAge = 86400
} else {
// Relay fetch failed: use default fallback
cacheMaxAge = 300
}
} catch (err) {
console.error(`Error fetching from relays for ${naddr}:`, err)
cacheMaxAge = 300
}
</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)
// Generate and send HTML
const html = generateHtml(naddr, meta)
setCacheHeaders(res, cacheMaxAge)
if (debugEnabled) {
// Debug mode enabled
}
return res.status(200).send(html)
}

224
api/services/articleMeta.ts Normal file
View File

@@ -0,0 +1,224 @@
import WebSocket from 'ws'
;(globalThis as unknown as { WebSocket?: typeof WebSocket }).WebSocket ??= WebSocket
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'
import { extractProfileDisplayName } from '../../lib/profile.js'
import { RELAYS } from '../../src/config/relays.js'
import type { ArticleMetadata } from './ogStore.js'
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
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)
relayPool.request(relayUrls, filter).subscribe({
next: (event) => {
events.push(event)
},
error: () => resolve(),
complete: () => {
clearTimeout(timeout)
resolve()
}
})
})
return events.sort((a, b) => b.created_at - a.created_at)
}
async function fetchFirstEvent(
relayPool: RelayPool,
relayUrls: string[],
filter: Filter,
timeoutMs: number
): Promise<NostrEvent | null> {
return new Promise<NostrEvent | null>((resolve) => {
let resolved = false
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true
resolve(null)
}
}, timeoutMs)
const subscription = relayPool.request(relayUrls, filter).subscribe({
next: (event) => {
if (!resolved) {
resolved = true
clearTimeout(timeout)
subscription.unsubscribe()
resolve(event)
}
},
error: () => {
if (!resolved) {
resolved = true
clearTimeout(timeout)
resolve(null)
}
},
complete: () => {
if (!resolved) {
resolved = true
clearTimeout(timeout)
resolve(null)
}
}
})
})
}
async function fetchAuthorProfile(
relayPool: RelayPool,
relayUrls: string[],
pubkey: string,
timeoutMs: number
): Promise<string | null> {
const profileEvents = await fetchEventsFromRelays(relayPool, relayUrls, {
kinds: [0],
authors: [pubkey]
}, timeoutMs)
if (profileEvents.length === 0) {
return null
}
const displayName = extractProfileDisplayName(profileEvents[0])
if (displayName && !displayName.startsWith('@')) {
return displayName
} else if (displayName) {
return displayName.substring(1)
}
return null
}
export async function fetchArticleMetadataViaRelays(naddr: string): Promise<ArticleMetadata | null> {
const relayPool = new RelayPool()
try {
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
return null
}
const pointer = decoded.data as AddressPointer
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
// Step A: Fetch article - return as soon as first event arrives
const article = await fetchFirstEvent(relayPool, relayUrls, {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier || '']
}, 7000)
if (!article) {
return null
}
// Step B: Extract article metadata immediately
const title = getArticleTitle(article) || 'Untitled Article'
const summary = getArticleSummary(article) || 'Read this article on Boris'
const image = getArticleImage(article) || '/boris-social-1200.png'
// Extract 't' tags (topic tags) from article event
const tags = article.tags
?.filter((tag) => tag[0] === 't' && tag[1])
.map((tag) => tag[1])
.filter((tag) => tag.length > 0) || []
// Generate image alt text (use title as fallback)
const imageAlt = title || 'Article cover image'
// Step C: Fetch author profile with micro-wait (connections already warm)
let authorName = await fetchAuthorProfile(relayPool, relayUrls, pointer.pubkey, 400)
// Step D: Optional hedge - try again with slightly longer timeout if first attempt failed
if (!authorName) {
authorName = await fetchAuthorProfile(relayPool, relayUrls, pointer.pubkey, 600)
}
if (!authorName) {
authorName = pointer.pubkey.slice(0, 8) + '...'
}
return {
title,
summary,
image,
author: authorName,
published: article.created_at,
tags: tags.length > 0 ? tags : undefined,
imageAlt
}
} catch (err) {
console.error('Failed to fetch article metadata via relays:', err)
return null
}
}
export async function fetchArticleMetadataViaGateway(naddr: string): Promise<ArticleMetadata | null> {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 2000)
const url = `https://njump.to/${naddr}`
console.log(`Fetching from gateway: ${url}`)
const resp = await fetch(url, {
redirect: 'follow',
signal: controller.signal
})
clearTimeout(timeout)
if (!resp.ok) {
console.error(`Gateway fetch failed: ${resp.status} ${resp.statusText} for ${url}`)
return null
}
const html = await resp.text()
console.log(`Gateway response length: ${html.length} chars`)
const pick = (re: RegExp) => {
const match = html.match(re)
return match?.[1] ? match[1].trim() : ''
}
const title = pick(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) ||
pick(/<title[^>]*>([^<]+)<\/title>/i)
const summary = pick(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i)
const image = pick(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)
console.log(`Parsed from gateway - title: ${title ? 'found' : 'missing'}, summary: ${summary ? 'found' : 'missing'}, image: ${image ? 'found' : 'missing'}`)
if (!title && !summary && !image) {
console.log('No OG metadata found in gateway response')
return null
}
return {
title: title || 'Read on Boris',
summary: summary || 'Read this article on Boris',
image: image || '/boris-social-1200.png',
author: 'Boris'
}
} catch (err) {
console.error('Failed to fetch article metadata via gateway:', err)
if (err instanceof Error) {
console.error('Error details:', err.message, err.stack)
}
return null
}
}

80
api/services/ogHtml.ts Normal file
View File

@@ -0,0 +1,80 @@
import type { ArticleMetadata } from './ogStore.js'
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
export function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
const baseUrl = 'https://read.withboris.com'
const articleUrl = `${baseUrl}/a/${naddr}`
const title = meta?.title || 'Boris Read, Highlight, Explore'
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'
const imageAlt = meta?.imageAlt || title
// Generate article:tag meta tags
const articleTags = meta?.tags && meta.tags.length > 0
? meta.tags.map((tag) => ` <meta property="article:tag" content="${escapeHtml(tag)}" />`).join('\n')
: ''
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:image:alt" content="${escapeHtml(imageAlt)}" />
<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)}" />
${articleTags}
<!-- 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="/a/${naddr}">Boris</a>...</p>
</noscript>
<script>
(function(){
try {
var p = '/a/${naddr}';
if (window.location.pathname !== p) {
history.replaceState(null, '', p);
}
var sep = window.location.search ? '&' : '?';
window.location.replace(p + sep + '_spa=1');
} catch (e) {}
})();
</script>
</body>
</html>`
}

39
api/services/ogStore.ts Normal file
View File

@@ -0,0 +1,39 @@
import { Redis } from '@upstash/redis'
// Support both KV_* and UPSTASH_* env var names
const redisUrl = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL
const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN
const readOnlyToken = process.env.KV_REST_API_READ_ONLY_TOKEN
if (!redisUrl || !redisToken) {
console.error('Missing Redis credentials: UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN or KV_REST_API_URL/KV_REST_API_TOKEN')
}
const redisWrite = redisUrl && redisToken
? new Redis({ url: redisUrl, token: redisToken })
: Redis.fromEnv() // Fallback to fromEnv() if explicit vars not set
const redisRead = readOnlyToken && redisUrl
? new Redis({ url: redisUrl, token: readOnlyToken })
: redisWrite
const keyOf = (naddr: string) => `og:${naddr}`
export type ArticleMetadata = {
title: string
summary: string
image: string
author: string
published?: number
tags?: string[]
imageAlt?: string
}
export async function getArticleMeta(naddr: string): Promise<ArticleMetadata | null> {
return (await redisRead.get<ArticleMetadata>(keyOf(naddr))) || null
}
export async function setArticleMeta(naddr: string, meta: ArticleMetadata, ttlSec = 604800): Promise<void> {
await redisWrite.set(keyOf(naddr), meta, { ex: ttlSec })
}

View File

@@ -94,7 +94,7 @@ async function pickCaptions(videoID: string, preferredLangs: string[], manualFir
return null
}
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string; thumbnail_url?: string }> {
const vimeoUrl = `https://vimeo.com/${videoId}`
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
@@ -107,7 +107,8 @@ async function getVimeoMetadata(videoId: string): Promise<{ title: string; descr
return {
title: data.title || '',
description: data.description || ''
description: data.description || '',
thumbnail_url: data.thumbnail_url || ''
}
}
@@ -147,9 +148,28 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
try {
if (videoInfo.source === 'youtube') {
// YouTube handling
// Note: getVideoDetails doesn't exist in the library, so we use a simplified approach
const title = ''
const description = ''
// Fetch basic metadata from YouTube page
let title = ''
let description = ''
try {
const response = await fetch(`https://www.youtube.com/watch?v=${videoInfo.id}`)
if (response.ok) {
const html = await response.text()
// Extract title from HTML
const titleMatch = html.match(/<title>([^<]+)<\/title>/)
if (titleMatch) {
title = titleMatch[1].replace(' - YouTube', '').trim()
}
// Extract description from meta tag
const descMatch = html.match(/<meta name="description" content="([^"]+)"/)
if (descMatch) {
description = descMatch[1].trim()
}
}
} catch (error) {
console.warn('Failed to fetch YouTube metadata:', error)
}
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
@@ -178,11 +198,12 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
return ok(res, response)
} else if (videoInfo.source === 'vimeo') {
// Vimeo handling
const { title, description } = await getVimeoMetadata(videoInfo.id)
const { title, description, thumbnail_url } = await getVimeoMetadata(videoInfo.id)
const response = {
title,
description,
thumbnail_url,
captions: [], // Vimeo doesn't provide captions through oEmbed API
transcript: '', // No transcript available
lang: 'en', // Default language

View File

@@ -63,10 +63,28 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
}
try {
// Since getVideoDetails doesn't exist, we'll use a simple approach
// In a real implementation, you might want to use YouTube's API or other methods
const title = '' // Will be populated from captions or other sources
const description = ''
// Fetch basic metadata from YouTube page
let title = ''
let description = ''
try {
const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`)
if (response.ok) {
const html = await response.text()
// Extract title from HTML
const titleMatch = html.match(/<title>([^<]+)<\/title>/)
if (titleMatch) {
title = titleMatch[1].replace(' - YouTube', '').trim()
}
// Extract description from meta tag
const descMatch = html.match(/<meta name="description" content="([^"]+)"/)
if (descMatch) {
description = descMatch[1].trim()
}
}
} catch (error) {
console.warn('Failed to fetch YouTube metadata:', error)
}
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))

View File

@@ -9,14 +9,14 @@
<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>Boris - Nostr Bookmarks</title>
<title>Boris - Read, Highlight, Explore</title>
<meta name="description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<link rel="canonical" href="https://read.withboris.com/" />
<!-- Open Graph / Social Media -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://read.withboris.com/" />
<meta property="og:title" content="Boris - Nostr Bookmarks" />
<meta property="og:title" content="Boris - Read, Highlight, Explore" />
<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" />
@@ -24,7 +24,7 @@
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://read.withboris.com/" />
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
<meta name="twitter:title" content="Boris - Read, Highlight, Explore" />
<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" />

39
lib/profile.ts Normal file
View File

@@ -0,0 +1,39 @@
import { nip19 } from 'nostr-tools'
import type { NostrEvent } from 'nostr-tools'
export function getNpubFallbackDisplay(pubkey: string): string {
try {
const npub = nip19.npubEncode(pubkey)
return `${npub.slice(5, 12)}...`
} catch {
return `${pubkey.slice(0, 8)}...`
}
}
export function extractProfileDisplayName(profileEvent: NostrEvent | null | undefined): string {
if (!profileEvent || profileEvent.kind !== 0) {
return ''
}
try {
const profileData = JSON.parse(profileEvent.content || '{}') as {
name?: string
display_name?: string
nip05?: string
}
if (profileData.name) return profileData.name
if (profileData.display_name) return profileData.display_name
if (profileData.nip05) return profileData.nip05
return getNpubFallbackDisplay(profileEvent.pubkey)
} catch {
try {
return getNpubFallbackDisplay(profileEvent.pubkey)
} catch {
return ''
}
}
}

155
package-lock.json generated
View File

@@ -1,18 +1,19 @@
{
"name": "boris",
"version": "0.6.13",
"version": "0.10.33",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boris",
"version": "0.6.13",
"version": "0.10.33",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.0.2",
"@treeee/youtube-caption-extractor": "^1.5.5",
"@upstash/redis": "^1.35.6",
"@vercel/node": "^5.3.26",
"applesauce-accounts": "^4.0.0",
"applesauce-content": "^4.0.0",
@@ -23,6 +24,7 @@
"applesauce-relay": "^4.0.0",
"date-fns": "^4.1.0",
"fast-average-color": "^9.5.0",
"fetch-opengraph": "^1.0.36",
"nostr-tools": "^2.4.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",
@@ -35,12 +37,15 @@
"rehype-prism-plus": "^2.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"use-pull-to-refresh": "^2.4.1"
"tinyld": "^1.3.4",
"use-pull-to-refresh": "^2.4.1",
"ws": "^8.18.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.14",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
@@ -54,6 +59,9 @@
"vite": "^5.0.8",
"vite-plugin-pwa": "^1.0.3",
"workbox-window": "^7.3.0"
},
"engines": {
"node": "22.x"
}
},
"node_modules/@alloc/quick-lru": {
@@ -100,6 +108,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -2260,6 +2269,7 @@
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
@@ -3551,6 +3561,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -3593,6 +3604,16 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
@@ -3635,6 +3656,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@@ -3797,6 +3819,15 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
},
"node_modules/@upstash/redis": {
"version": "1.35.6",
"resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.35.6.tgz",
"integrity": "sha512-aSEIGJgJ7XUfTYvhQcQbq835re7e/BXjs8Janq6Pvr6LlmTZnyqwT97RziZLO/8AVUL037RLXqqiQC6kCt+5pA==",
"license": "MIT",
"dependencies": {
"uncrypto": "^0.1.3"
}
},
"node_modules/@vercel/build-utils": {
"version": "12.1.2",
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-12.1.2.tgz",
@@ -3925,7 +3956,8 @@
"version": "16.18.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.11.tgz",
"integrity": "sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@vercel/node/node_modules/esbuild": {
"version": "0.14.47",
@@ -4010,6 +4042,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4086,6 +4119,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4501,6 +4535,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.14.0"
}
},
"node_modules/babel-plugin-polyfill-corejs2": {
"version": "0.4.14",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
@@ -4629,6 +4672,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -5865,6 +5909,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -6170,6 +6215,16 @@
"reusify": "^1.0.4"
}
},
"node_modules/fetch-opengraph": {
"version": "1.0.36",
"resolved": "https://registry.npmjs.org/fetch-opengraph/-/fetch-opengraph-1.0.36.tgz",
"integrity": "sha512-w2Gs64zjL1O86E0I6E26MrxeXpTrR8Y1vWrgupmZN6NXKV8F5I3W0tlh+ZX686jZwxyilWnQjYwgnWpdETdHWw==",
"license": "MIT",
"dependencies": {
"axios": "^0.21.1",
"html-entities": "^2.3.2"
}
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -6263,6 +6318,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -6895,6 +6970,22 @@
"he": "bin/he"
}
},
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/mdevils"
},
{
"type": "patreon",
"url": "https://patreon.com/mdevils"
}
],
"license": "MIT"
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -9654,6 +9745,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -9800,6 +9892,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -9812,6 +9905,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -10377,6 +10471,7 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -11132,6 +11227,7 @@
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
@@ -11208,6 +11304,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11215,6 +11312,22 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyld": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/tinyld/-/tinyld-1.3.4.tgz",
"integrity": "sha512-u26CNoaInA4XpDU+8s/6Cq8xHc2T5M4fXB3ICfXPokUQoLzmPgSZU02TAkFwFMJCWTjk53gtkS8pETTreZwCqw==",
"license": "MIT",
"bin": {
"tinyld": "bin/tinyld.js",
"tinyld-heavy": "bin/tinyld-heavy.js",
"tinyld-light": "bin/tinyld-light.js"
},
"engines": {
"node": ">= 12.10.0",
"npm": ">= 6.12.0",
"yarn": ">= 1.20.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -11444,6 +11557,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11471,6 +11585,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/uncrypto": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
"license": "MIT"
},
"node_modules/undici": {
"version": "5.28.4",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
@@ -11487,8 +11607,7 @@
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1",
@@ -11769,6 +11888,7 @@
"integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -12154,6 +12274,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -12198,6 +12319,7 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -12446,6 +12568,27 @@
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -1,14 +1,18 @@
{
"name": "boris",
"version": "0.7.2",
"version": "0.11.0",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",
"engines": {
"node": "22.x"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"publish:test:markdown": "./scripts/publish-markdown.sh"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
@@ -16,6 +20,7 @@
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.0.2",
"@treeee/youtube-caption-extractor": "^1.5.5",
"@upstash/redis": "^1.35.6",
"@vercel/node": "^5.3.26",
"applesauce-accounts": "^4.0.0",
"applesauce-content": "^4.0.0",
@@ -26,6 +31,7 @@
"applesauce-relay": "^4.0.0",
"date-fns": "^4.1.0",
"fast-average-color": "^9.5.0",
"fetch-opengraph": "^1.0.36",
"nostr-tools": "^2.4.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",
@@ -38,12 +44,15 @@
"rehype-prism-plus": "^2.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"use-pull-to-refresh": "^2.4.1"
"tinyld": "^1.3.4",
"use-pull-to-refresh": "^2.4.1",
"ws": "^8.18.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.14",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
@@ -95,6 +104,15 @@
"@typescript-eslint/no-explicit-any": "warn",
"prefer-const": "error",
"no-var": "error"
}
},
"overrides": [
{
"files": ["api/**/*.ts"],
"env": {
"node": true,
"browser": false
}
}
]
}
}

View File

@@ -1,5 +1,5 @@
{
"name": "Boris - Nostr Bookmarks",
"name": "Boris - Read, Highlight, Explore",
"short_name": "Boris",
"description": "Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.",
"start_url": "/",
@@ -9,6 +9,16 @@
"background_color": "#0b1220",
"orientation": "any",
"categories": ["productivity", "social", "utilities"],
"share_target": {
"action": "/share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "link"
}
},
"icons": [
{
"src": "/icon-192.png",

75
public/md/NIP-85.md Normal file
View File

@@ -0,0 +1,75 @@
# NIP-85
## Reading Progress
`draft` `optional`
This NIP defines kind `39802`, a parameterized replaceable event for tracking reading progress across articles and web content.
## Table of Contents
* [Format](#format)
* [Tags](#tags)
* [Content](#content)
* [Examples](#examples)
## Format
Reading progress events use NIP-33 parameterized replaceable semantics. The `d` tag serves as the unique identifier per author and target content.
### Tags
Events SHOULD tag the source of the reading progress, whether nostr-native or not. `a` tags should be used for nostr events and `r` tags for URLs.
When tagging a URL, clients generating these events SHOULD do a best effort of cleaning the URL from trackers or obvious non-useful information from the query string.
- `d` (required): Unique identifier for the target content
- For Nostr articles: `30023:<pubkey>:<identifier>` (matching the article's coordinate)
- For external URLs: `url:<base64url-encoded-url>`
- `a` (optional but recommended for Nostr articles): Article coordinate `30023:<pubkey>:<identifier>`
- `r` (optional but recommended for URLs): Raw URL of the external content
### Content
The content is a JSON object with the following fields:
- `progress` (required): Number between 0 and 1 representing reading progress (0 = not started, 1 = completed)
- `loc` (optional): Number representing a location marker (e.g., pixel scroll position, page number, etc.)
- `ts` (optional): Unix timestamp (seconds) when the progress was recorded
- `ver` (optional): Schema version string
The latest event by `created_at` per (`pubkey`, `d`) pair is authoritative (NIP-33 semantics).
Clients SHOULD implement rate limiting to avoid excessive relay traffic (debounce writes, only save significant changes).
## Examples
### Nostr Article
```json
{
"kind": 39802,
"pubkey": "<user-pubkey>",
"created_at": 1734635012,
"content": "{\"progress\":0.66,\"loc\":1432,\"ts\":1734635012,\"ver\":\"1\"}",
"tags": [
["d", "30023:<author-pubkey>:<article-identifier>"],
["a", "30023:<author-pubkey>:<article-identifier>"]
]
}
```
### External URL
```json
{
"kind": 39802,
"pubkey": "<user-pubkey>",
"created_at": 1734635999,
"content": "{\"progress\":1,\"ts\":1734635999,\"ver\":\"1\"}",
"tags": [
["d", "url:aHR0cHM6Ly9leGFtcGxlLmNvbS9wb3N0"],
["r", "https://example.com/post"]
]
}
```

47
public/sw-dev.js Normal file
View File

@@ -0,0 +1,47 @@
// Development Service Worker - simplified version for testing image caching
// This is served in dev mode when vite-plugin-pwa doesn't serve the injectManifest SW
self.addEventListener('install', (event) => {
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(clients.claim())
})
// Image caching - simple version for dev testing
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
const isImage = event.request.destination === 'image' ||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
if (isImage) {
event.respondWith(
caches.open('boris-images-dev').then((cache) => {
return cache.match(event.request).then((cachedResponse) => {
// Try to fetch from network
return fetch(event.request).then((response) => {
// If fetch succeeds, cache it and return
if (response.ok) {
cache.put(event.request, response.clone()).catch(() => {
// Ignore cache put errors
})
}
return response
}).catch((error) => {
// If fetch fails (network error, CORS, etc.), return cached response if available
if (cachedResponse) {
return cachedResponse
}
// No cache available, reject the promise so browser handles it
return Promise.reject(error)
})
})
}).catch(() => {
// If cache operations fail, try to fetch directly without caching
return fetch(event.request)
})
)
}
})

202
scripts/publish-markdown.sh Executable file
View File

@@ -0,0 +1,202 @@
#!/bin/bash
# Script to publish markdown files from test/markdown/ to Nostr using nak
# Usage:
# ./scripts/publish-markdown.sh [filename] [relay1] [relay2] ...
# ./scripts/publish-markdown.sh # Interactive mode
# ./scripts/publish-markdown.sh tables.md # Publish specific file
# ./scripts/publish-markdown.sh tables.md wss://relay.example.com # With relay
#
# Environment:
# The script reads .env from the project root directory ($PROJECT_ROOT/.env)
# Required: NOSTR_SECRET_KEY (your nsec, ncryptsec, or hex format key)
# Optional: RELAYS (space-separated list of relay URLs)
#
# Test account for markdown test documents:
# npub: npub1marky39a9qmadyuux9lr49pdhy3ddxrdwtmd9y957kye66qyu3vq7spdm2
# Profile: https://read.withboris.com/p/npub1marky39a9qmadyuux9lr49pdhy3ddxrdwtmd9y957kye66qyu3vq7spdm2/writings
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
MARKDOWN_DIR="$PROJECT_ROOT/test/markdown"
ENV_FILE="$PROJECT_ROOT/.env"
# Load .env file if it exists
if [ -f "$ENV_FILE" ]; then
# Source the .env file, handling quoted values properly
set -a # Automatically export all variables
# Use eval to properly handle quoted values (safe since we control the file)
# This handles both unquoted and quoted values correctly
while IFS= read -r line || [ -n "$line" ]; do
# Skip comments and empty lines
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "$line" ]] && continue
# Remove leading/trailing whitespace
line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
# Export the variable (handles quoted values)
eval "export $line"
done < "$ENV_FILE"
set +a # Stop automatically exporting
fi
# Check if nak is installed
if ! command -v nak &> /dev/null; then
echo "Error: nak is not installed or not in PATH"
echo "Install from: https://github.com/fiatjaf/nak"
exit 1
fi
# Function to publish a markdown file
publish_file() {
local file_path="$1"
shift # Remove first argument, rest are relay URLs
local relays=("$@")
local filename=$(basename "$file_path")
local identifier="${filename%.md}" # Remove .md extension
echo "📝 Publishing: $filename"
echo " Identifier: $identifier"
# Extract title from first H1 if available, otherwise use filename
local title=$(grep -m 1 "^# " "$file_path" | sed 's/^# //' || echo "$identifier")
# Add relays if provided
if [ ${#relays[@]} -gt 0 ]; then
echo " Relays: ${relays[*]}"
else
echo " Note: No relays specified. Event will be created but not published."
echo " Add relay URLs as arguments to publish, e.g.: wss://relay.example.com"
fi
# Publish as kind 30023 (NIP-23 blog post)
# The "d" tag is required for replaceable events (kind 30023)
# Using the filename (without extension) as the identifier
# Build command array to avoid eval issues
# Use @filename syntax to read content from file (nak supports this)
local cmd_args=(
"event"
"-k" "30023"
"-d" "$identifier"
"-t" "title=$title"
"--content" "@$file_path"
)
# Add relays if provided
if [ ${#relays[@]} -gt 0 ]; then
cmd_args+=("${relays[@]}")
fi
nak "${cmd_args[@]}"
if [ $? -eq 0 ]; then
echo "✅ Successfully published: $filename"
else
echo "❌ Failed to publish: $filename"
return 1
fi
}
# Check for NOSTR_SECRET_KEY
if [ -z "$NOSTR_SECRET_KEY" ]; then
echo "⚠️ Warning: NOSTR_SECRET_KEY environment variable not set"
echo " Set it in .env file or with: export NOSTR_SECRET_KEY=your_key_here"
echo " Or use --prompt-sec flag (nak will prompt for key)"
echo ""
fi
# Parse RELAYS from environment if set
default_relays=()
if [ -n "$RELAYS" ]; then
# Split RELAYS string into array
read -ra default_relays <<< "$RELAYS"
fi
# Main logic
if [ $# -eq 0 ]; then
# No arguments: list all markdown files and let user choose
echo "Available markdown files:"
echo ""
files=("$MARKDOWN_DIR"/*.md)
if [ ! -e "${files[0]}" ]; then
echo "No markdown files found in $MARKDOWN_DIR"
exit 1
fi
# Display files with numbers
declare -a file_array
i=1
for file in "${files[@]}"; do
filename=$(basename "$file")
echo " $i) $filename"
file_array[$i]="$file"
((i++))
done
echo ""
echo "Enter file number(s) to publish (space-separated), or 'all' for all files:"
read -r selection
echo ""
if [ ${#default_relays[@]} -gt 0 ]; then
echo "Enter relay URLs (space-separated, or press Enter to use defaults from .env):"
echo " Defaults: ${default_relays[*]}"
else
echo "Enter relay URLs (space-separated, or press Enter to skip):"
fi
read -r relay_input
# Parse relay URLs
relays=()
if [ -n "$relay_input" ]; then
read -ra relays <<< "$relay_input"
elif [ ${#default_relays[@]} -gt 0 ]; then
# Use defaults from .env
relays=("${default_relays[@]}")
fi
if [ "$selection" = "all" ]; then
# Publish all files
for file in "${files[@]}"; do
publish_file "$file" "${relays[@]}"
echo ""
done
else
# Publish selected files
for num in $selection; do
if [ -n "${file_array[$num]}" ]; then
publish_file "${file_array[$num]}" "${relays[@]}"
echo ""
else
echo "⚠️ Invalid selection: $num"
fi
done
fi
else
# Argument provided: publish specific file
filename="$1"
shift # Remove filename, rest are relay URLs
relays=("$@")
# If no relays provided as arguments, use defaults from .env
if [ ${#relays[@]} -eq 0 ] && [ ${#default_relays[@]} -gt 0 ]; then
relays=("${default_relays[@]}")
fi
# If filename doesn't end with .md, add it
if [[ ! "$filename" =~ \.md$ ]]; then
filename="${filename}.md"
fi
file_path="$MARKDOWN_DIR/$filename"
if [ ! -f "$file_path" ]; then
echo "Error: File not found: $file_path"
exit 1
fi
publish_file "$file_path" "${relays[@]}"
fi

View File

@@ -8,21 +8,30 @@ import { AccountManager, Accounts } from 'applesauce-accounts'
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
import { RelayPool } from 'applesauce-relay'
import { NostrConnectSigner } from 'applesauce-signers'
import type { NostrEvent } from 'nostr-tools'
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 ShareTargetHandler from './components/ShareTargetHandler'
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'
import { loadUserRelayList, loadBlockedRelays, computeRelaySet } from './services/relayListService'
import { applyRelaySetToPool, getActiveRelayUrls, ALWAYS_LOCAL_RELAYS, HARDCODED_RELAYS } from './services/relayManager'
import { Bookmark } from './types/bookmarks'
import { bookmarkController } from './services/bookmarkController'
import { contactsController } from './services/contactsController'
import { highlightsController } from './services/highlightsController'
import { writingsController } from './services/writingsController'
import { readingProgressController } from './services/readingProgressController'
// import { fetchNostrverseHighlights } from './services/nostrverseService'
import { nostrverseHighlightsController } from './services/nostrverseHighlightsController'
import { nostrverseWritingsController } from './services/nostrverseWritingsController'
import { archiveController } from './services/archiveController'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
@@ -50,18 +59,14 @@ function AppRoutes({
// Subscribe to bookmark controller
useEffect(() => {
console.log('[bookmark] 🎧 Subscribing to bookmark controller')
const unsubBookmarks = bookmarkController.onBookmarks((bookmarks) => {
console.log('[bookmark] 📥 Received bookmarks:', bookmarks.length)
setBookmarks(bookmarks)
})
const unsubLoading = bookmarkController.onLoading((loading) => {
console.log('[bookmark] 📥 Loading state:', loading)
setBookmarksLoading(loading)
})
return () => {
console.log('[bookmark] 🔇 Unsubscribing from bookmark controller')
unsubBookmarks()
unsubLoading()
}
@@ -69,18 +74,14 @@ function AppRoutes({
// Subscribe to contacts controller
useEffect(() => {
console.log('[contacts] 🎧 Subscribing to contacts controller')
const unsubContacts = contactsController.onContacts((contacts) => {
console.log('[contacts] 📥 Received contacts:', contacts.size)
setContacts(contacts)
})
const unsubLoading = contactsController.onLoading((loading) => {
console.log('[contacts] 📥 Loading state:', loading)
setContactsLoading(loading)
})
return () => {
console.log('[contacts] 🔇 Unsubscribing from contacts controller')
unsubContacts()
unsubLoading()
}
@@ -94,31 +95,55 @@ function AppRoutes({
// Load bookmarks
if (bookmarks.length === 0 && !bookmarksLoading) {
console.log('[bookmark] 🚀 Auto-loading bookmarks on mount/login')
bookmarkController.start({ relayPool, activeAccount, accountManager })
bookmarkController.start({ relayPool, activeAccount, accountManager, eventStore: eventStore || undefined })
}
// Load contacts
if (pubkey && contacts.size === 0 && !contactsLoading) {
console.log('[contacts] 🚀 Auto-loading contacts on mount/login')
contactsController.start({ relayPool, pubkey })
}
// Load highlights (controller manages its own state)
if (pubkey && eventStore && !highlightsController.isLoadedFor(pubkey)) {
console.log('[highlights] 🚀 Auto-loading highlights on mount/login')
highlightsController.start({ relayPool, eventStore, pubkey })
}
// Load writings (controller manages its own state)
if (pubkey && eventStore && !writingsController.isLoadedFor(pubkey)) {
writingsController.start({ relayPool, eventStore, pubkey })
}
// Load reading progress (controller manages its own state)
if (pubkey && eventStore && !readingProgressController.isLoadedFor(pubkey)) {
readingProgressController.start({ relayPool, eventStore, pubkey })
}
// Load archive (marked-as-read) controller
if (pubkey && eventStore && !archiveController.isLoadedFor(pubkey)) {
archiveController.start({ relayPool, eventStore, pubkey })
}
// Start centralized nostrverse highlights controller (non-blocking)
if (eventStore) {
nostrverseHighlightsController.start({ relayPool, eventStore })
nostrverseWritingsController.start({ relayPool, eventStore })
}
}
}, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager])
// Ensure nostrverse controllers run even when logged out
useEffect(() => {
if (relayPool && eventStore) {
nostrverseHighlightsController.start({ relayPool, eventStore })
nostrverseWritingsController.start({ relayPool, eventStore })
}
}, [relayPool, eventStore])
// Manual refresh (for sidebar button)
const handleRefreshBookmarks = useCallback(async () => {
if (!relayPool || !activeAccount) {
console.warn('[bookmark] Cannot refresh: missing relayPool or activeAccount')
return
}
console.log('[bookmark] 🔄 Manual refresh triggered')
bookmarkController.reset()
await bookmarkController.start({ relayPool, activeAccount, accountManager })
}, [relayPool, activeAccount, accountManager])
@@ -128,11 +153,17 @@ function AppRoutes({
bookmarkController.reset() // Clear bookmarks via controller
contactsController.reset() // Clear contacts via controller
highlightsController.reset() // Clear highlights via controller
readingProgressController.reset() // Clear reading progress via controller
archiveController.reset() // Clear archive state
showToast('Logged out successfully')
}
return (
<Routes>
<Route
path="/share-target"
element={<ShareTargetHandler relayPool={relayPool} />}
/>
<Route
path="/a/:naddr"
element={
@@ -206,11 +237,11 @@ function AppRoutes({
}
/>
<Route
path="/me"
element={<Navigate to="/me/highlights" replace />}
path="/my"
element={<Navigate to="/my/highlights" replace />}
/>
<Route
path="/me/highlights"
path="/my/highlights"
element={
<Bookmarks
relayPool={relayPool}
@@ -222,7 +253,7 @@ function AppRoutes({
}
/>
<Route
path="/me/reading-list"
path="/my/bookmarks"
element={
<Bookmarks
relayPool={relayPool}
@@ -234,7 +265,7 @@ function AppRoutes({
}
/>
<Route
path="/me/reads"
path="/my/reads"
element={
<Bookmarks
relayPool={relayPool}
@@ -246,7 +277,7 @@ function AppRoutes({
}
/>
<Route
path="/me/reads/:filter"
path="/my/reads/:filter"
element={
<Bookmarks
relayPool={relayPool}
@@ -258,7 +289,7 @@ function AppRoutes({
}
/>
<Route
path="/me/links"
path="/my/links"
element={
<Bookmarks
relayPool={relayPool}
@@ -270,7 +301,19 @@ function AppRoutes({
}
/>
<Route
path="/me/writings"
path="/my/links/:filter"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
<Route
path="/my/writings"
element={
<Bookmarks
relayPool={relayPool}
@@ -305,6 +348,18 @@ function AppRoutes({
/>
}
/>
<Route
path="/e/:eventId"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
<Route
path="/debug"
element={
@@ -349,55 +404,35 @@ function App() {
// 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) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = pool.publish(relays, event as any)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (result && typeof (result as any).subscribe === 'function') {
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
try { (result as any).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
}
// Return an already-resolved promise so upstream await finishes immediately
NostrConnectSigner.publishMethod = (relays: string[], event: NostrEvent) => {
// Fire-and-forget publish; do not block callers
pool.publish(relays, event).catch(() => { /* ignore errors */ })
return Promise.resolve()
}
console.log('[bunker] ✅ Wired NostrConnectSigner to RelayPool publish/subscription (before account load)')
// Create a relay group for better event deduplication and management
pool.group(RELAYS)
console.log('[bunker] Created relay group with', RELAYS.length, 'relays (including local)')
// Load persisted accounts from localStorage
try {
const accountsJson = localStorage.getItem('accounts')
console.log('[bunker] Raw accounts from localStorage:', accountsJson)
const json = JSON.parse(accountsJson || '[]')
console.log('[bunker] Parsed accounts:', json.length, 'accounts')
await accounts.fromJSON(json)
console.log('[bunker] Loaded', accounts.accounts.length, 'accounts from storage')
console.log('[bunker] Account types:', accounts.accounts.map(a => ({ id: a.id, type: a.type })))
// Load active account from storage
const activeId = localStorage.getItem('active')
console.log('[bunker] Active ID from localStorage:', activeId)
if (activeId) {
const account = accounts.getAccount(activeId)
console.log('[bunker] Found account for ID?', !!account, account?.type)
if (account) {
accounts.setActive(activeId)
console.log('[bunker] ✅ Restored active account:', activeId, 'type:', account.type)
} else {
console.warn('[bunker] ⚠️ Active ID found but account not in list')
}
} else {
console.log('[bunker] No active account ID in localStorage')
}
} catch (err) {
console.error('[bunker] ❌ Failed to load accounts from storage:', err)
console.error('Failed to load accounts from storage:', err)
}
// Subscribe to accounts changes and persist to localStorage
@@ -419,11 +454,6 @@ function App() {
const reconnectedAccounts = new Set<string>()
const bunkerReconnectSub = accounts.active$.subscribe(async (account) => {
console.log('[bunker] Active account changed:', {
hasAccount: !!account,
type: account?.type,
id: account?.id
})
if (account && account.type === 'nostr-connect') {
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
@@ -431,23 +461,17 @@ function App() {
try {
if (!(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue) {
(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue = true
console.log('[bunker] ⚙️ Disabled account request queueing for nostr-connect')
}
} catch (err) { console.warn('[bunker] failed to disable queue', err) }
} catch (err) {
// Ignore queue disable errors
}
// Note: for Amber bunker, the remote signer pubkey is the user's pubkey. This is expected.
// Skip if we've already reconnected this account
if (reconnectedAccounts.has(account.id)) {
console.log('[bunker] ⏭️ Already reconnected this account, skipping')
return
}
console.log('[bunker] Account detected. Status:', {
listening: nostrConnectAccount.signer.listening,
isConnected: nostrConnectAccount.signer.isConnected,
hasRemote: !!nostrConnectAccount.signer.remote,
bunkerRelays: nostrConnectAccount.signer.relays
})
try {
// For restored signers, ensure they have the pool's subscription methods
@@ -461,10 +485,9 @@ function App() {
const newBunkerRelays = bunkerRelays.filter(url => !existingRelayUrls.has(url))
if (newBunkerRelays.length > 0) {
console.log('[bunker] Adding bunker relays to pool BEFORE signer recreation:', newBunkerRelays)
pool.group(newBunkerRelays)
} else {
console.log('[bunker] Bunker relays already in pool')
// Bunker relays already in pool
}
const recreatedSigner = new NostrConnectSigner({
@@ -478,85 +501,42 @@ function App() {
try {
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
recreatedSigner.relays = mergedRelays
console.log('[bunker] 🔗 Signer relays merged with app RELAYS:', mergedRelays)
} catch (err) { console.warn('[bunker] failed to merge signer relays', err) }
} catch (err) { /* ignore */ }
// 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)
// Fire-and-forget publish for bunker: trigger but don't wait for completion
// 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') {
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
}
// 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)
// Ignore reconnect errors
}
// 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> => {
@@ -569,52 +549,153 @@ function App() {
const self = nostrConnectAccount.pubkey
// Try a roundtrip so the bunker can respond successfully
try {
console.log('[bunker] 🔎 Probe nip44 roundtrip (encrypt→decrypt)…')
const cipher44 = await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44'))
const plain44 = await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, cipher44))
console.log('[bunker] 🔎 Probe nip44 responded:', typeof plain44 === 'string' ? plain44 : typeof plain44)
} catch (err) {
console.log('[bunker] 🔎 Probe nip44 result:', err instanceof Error ? err.message : err)
await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44'))
await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, ''))
} catch (_err) {
// Ignore probe errors
}
try {
console.log('[bunker] 🔎 Probe nip04 roundtrip (encrypt→decrypt)…')
const cipher04 = await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04'))
const plain04 = await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, cipher04))
console.log('[bunker] 🔎 Probe nip04 responded:', typeof plain04 === 'string' ? plain04 : typeof plain04)
} catch (err) {
console.log('[bunker] 🔎 Probe nip04 result:', err instanceof Error ? err.message : err)
await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04'))
await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, ''))
} catch (_err) {
// Ignore probe errors
}
}, 0)
} catch (err) {
console.log('[bunker] 🔎 Probe setup failed:', err)
} catch (_err) {
// Ignore signer setup errors
}
// The bunker remembers the permissions from the initial connection
nostrConnectAccount.signer.isConnected = true
console.log('[bunker] Final signer status:', {
listening: nostrConnectAccount.signer.listening,
isConnected: nostrConnectAccount.signer.isConnected,
remote: nostrConnectAccount.signer.remote,
relays: nostrConnectAccount.signer.relays
})
// Mark this account as reconnected
reconnectedAccounts.add(account.id)
console.log('[bunker] 🎉 Signer ready for signing')
} catch (error) {
console.error('[bunker] ❌ Failed to open signer:', error)
console.error('Failed to open signer:', error)
}
}
})
// Handle user relay list and blocked relays when account changes
const userRelaysSub = accounts.active$.subscribe((account) => {
if (account) {
// User logged in - start with hardcoded relays immediately, then stream user relay list updates
const pubkey = account.pubkey
// Bunker relays (if any)
let bunkerRelays: string[] = []
if (account.type === 'nostr-connect') {
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
const signerData = nostrConnectAccount.toJSON().signer
bunkerRelays = signerData.relays || []
}
// Start with hardcoded + bunker relays immediately (non-blocking)
const initialRelays = computeRelaySet({
hardcoded: RELAYS,
bunker: bunkerRelays,
userList: [],
blocked: [],
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
})
// Apply initial set immediately
applyRelaySetToPool(pool, initialRelays)
// Prepare keep-alive helper
const updateKeepAlive = () => {
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
poolWithSub._keepAliveSubscription.unsubscribe()
}
const activeRelays = getActiveRelayUrls(pool)
const newKeepAliveSub = pool.subscription(activeRelays, { kinds: [0], limit: 0 }).subscribe({
next: () => {},
error: () => {}
})
poolWithSub._keepAliveSubscription = newKeepAliveSub
}
// Begin loading blocked relays in background
const blockedPromise = loadBlockedRelays(pool, pubkey)
// Stream user relay list; apply immediately on first/updated event
loadUserRelayList(pool, pubkey, {
onUpdate: (userRelays) => {
const interimRelays = computeRelaySet({
hardcoded: HARDCODED_RELAYS,
bunker: bunkerRelays,
userList: userRelays,
blocked: [],
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
})
applyRelaySetToPool(pool, interimRelays)
updateKeepAlive()
}
}).then(async (userRelayList) => {
const blockedRelays = await blockedPromise.catch(() => [])
const finalRelays = computeRelaySet({
hardcoded: userRelayList.length > 0 ? HARDCODED_RELAYS : RELAYS,
bunker: bunkerRelays,
userList: userRelayList,
blocked: blockedRelays,
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
})
applyRelaySetToPool(pool, finalRelays)
updateKeepAlive()
// Update address loader with new relays
const activeRelays = getActiveRelayUrls(pool)
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: activeRelays
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
}).catch((error) => {
console.error('[relay-init] Failed to load user relay list (continuing with initial set):', error)
// Continue with initial relay set on error - no need to change anything
})
} else {
// User logged out - reset to hardcoded relays
applyRelaySetToPool(pool, RELAYS)
// Update keep-alive subscription
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
poolWithSub._keepAliveSubscription.unsubscribe()
}
const newKeepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
next: () => {},
error: () => {}
})
poolWithSub._keepAliveSubscription = newKeepAliveSub
// Reset address loader
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: RELAYS
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
}
})
// Keep all relay connections alive indefinitely by creating a persistent subscription
// This prevents disconnection when no other subscriptions are active
// Create a minimal subscription that never completes to keep connections alive
const keepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
next: () => {}, // No-op, we don't care about events
error: (err) => console.warn('Keep-alive subscription error:', err)
next: () => {},
error: () => {}
})
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
// Store subscription for cleanup
;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub
@@ -636,6 +717,7 @@ function App() {
accountsSub.unsubscribe()
activeSub.unsubscribe()
bunkerReconnectSub.unsubscribe()
userRelaysSub.unsubscribe()
// Clean up keep-alive subscription if it exists
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
@@ -673,6 +755,16 @@ function App() {
}
}, [showToast])
// Strip _spa query parameter from URL after SPA loads
useEffect(() => {
const url = new URL(window.location.href)
if (url.searchParams.has('_spa')) {
url.searchParams.delete('_spa')
const path = url.pathname + (url.search ? url.search : '') + url.hash
window.history.replaceState(null, '', path)
}
}, [])
if (!eventStore || !accountManager || !relayPool) {
return (
<div className="loading">

View File

@@ -4,41 +4,40 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
import { fetchReadableContent } from '../services/readerService'
import { fetch as fetchOpenGraph } from 'fetch-opengraph'
interface AddBookmarkModalProps {
onClose: () => void
onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise<void>
}
// Helper to extract metadata from HTML
function extractMetaTag(html: string, patterns: string[]): string | null {
for (const pattern of patterns) {
const match = html.match(new RegExp(pattern, 'i'))
if (match) return match[1]
}
return null
}
function extractTags(html: string): string[] {
// Helper to extract tags from OpenGraph data
function extractTagsFromOgData(ogData: Record<string, unknown>): string[] {
const tags: string[] = []
// Extract keywords meta tag
const keywords = extractMetaTag(html, [
'<meta\\s+name=["\'"]keywords["\'"]\\s+content=["\'"]([^"\']+)["\']'
])
if (keywords) {
keywords.split(/[,;]/)
.map(k => k.trim().toLowerCase())
.filter(k => k.length > 0 && k.length < 30)
.forEach(k => tags.push(k))
// Extract keywords from OpenGraph data
if (ogData.keywords && typeof ogData.keywords === 'string') {
ogData.keywords.split(/[,;]/)
.map((k: string) => k.trim().toLowerCase())
.filter((k: string) => k.length > 0 && k.length < 30)
.forEach((k: string) => tags.push(k))
}
// Extract article:tag (multiple possible)
const articleTagRegex = /<meta\s+property=["']article:tag["']\s+content=["']([^"']+)["']/gi
let match
while ((match = articleTagRegex.exec(html)) !== null) {
const tag = match[1].trim().toLowerCase()
if (tag && tag.length < 30) tags.push(tag)
// Extract article:tag from OpenGraph data
if (ogData['article:tag']) {
const articleTagValue = ogData['article:tag']
const articleTags = Array.isArray(articleTagValue)
? articleTagValue
: [articleTagValue]
articleTags.forEach((tag: unknown) => {
if (typeof tag === 'string') {
const cleanTag = tag.trim().toLowerCase()
if (cleanTag && cleanTag.length < 30) {
tags.push(cleanTag)
}
}
})
}
return Array.from(new Set(tags)).slice(0, 5)
@@ -83,17 +82,27 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
fetchTimeoutRef.current = window.setTimeout(async () => {
setIsFetchingMetadata(true)
try {
const content = await fetchReadableContent(normalizedUrl)
lastFetchedUrlRef.current = normalizedUrl
// Fetch both readable content and OpenGraph data in parallel
const [content, ogData] = await Promise.all([
fetchReadableContent(normalizedUrl),
fetchOpenGraph(normalizedUrl).catch(() => null) // Don't fail if OpenGraph fetch fails
])
lastFetchedUrlRef.current = normalizedUrl
let extractedAnything = false
// Extract title: prioritize og:title > twitter:title > <title>
if (!title && content.html) {
const extractedTitle = extractMetaTag(content.html, [
'<meta\\s+property=["\'"]og:title["\'"]\\s+content=["\'"]([^"\']+)["\']',
'<meta\\s+name=["\'"]twitter:title["\'"]\\s+content=["\'"]([^"\']+)["\']'
]) || content.title
// Extract title: prioritize og:title > twitter:title > content.title
if (!title) {
let extractedTitle = null
if (ogData) {
extractedTitle = ogData['og:title'] || ogData['twitter:title'] || ogData.title
}
// Fallback to content.title if no OpenGraph title found
if (!extractedTitle) {
extractedTitle = content.title
}
if (extractedTitle) {
setTitle(extractedTitle)
@@ -102,12 +111,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
}
// Extract description: prioritize og:description > twitter:description > meta description
if (!description && content.html) {
const extractedDesc = extractMetaTag(content.html, [
'<meta\\s+property=["\'"]og:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
'<meta\\s+name=["\'"]twitter:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
'<meta\\s+name=["\'"]description["\'"]\\s+content=["\'"]([^"\']+)["\']'
])
if (!description && ogData) {
const extractedDesc = ogData['og:description'] || ogData['twitter:description'] || ogData.description
if (extractedDesc) {
setDescription(extractedDesc)
@@ -116,8 +121,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
}
// Extract tags from keywords and article:tag (only if user hasn't modified tags)
if (!tagsInput && content.html) {
const extractedTags = extractTags(content.html)
if (!tagsInput && ogData) {
const extractedTags = extractTagsFromOgData(ogData)
// Only add boris tag if we extracted something
if (extractedAnything || extractedTags.length > 0) {

View File

@@ -16,7 +16,7 @@ const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilte
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
{ type: 'marked' as const, icon: faBooks, label: 'Marked as Read' }
{ type: 'marked' as const, icon: faBooks, label: 'Archived' }
]
return (

View File

@@ -5,6 +5,7 @@ import { faUserCircle } from '@fortawesome/free-solid-svg-icons'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
interface AuthorCardProps {
authorPubkey: string
@@ -16,9 +17,7 @@ const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey, clickable = true
const profile = useEventModel(Models.ProfileModel, [authorPubkey])
const getAuthorName = () => {
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
return `${authorPubkey.slice(0, 8)}...${authorPubkey.slice(-8)}`
return getProfileDisplayName(profile, authorPubkey)
}
const authorImage = profile?.picture || profile?.image
@@ -27,7 +26,7 @@ const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey, clickable = true
const handleClick = () => {
if (clickable) {
const npub = nip19.npubEncode(authorPubkey)
navigate(`/p/${npub}`)
navigate(`/p/${npub}/writings`)
}
}

View File

@@ -6,18 +6,32 @@ import { formatDistance } from 'date-fns'
import { BlogPostPreview } from '../services/exploreService'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { isKnownBot } from '../config/bots'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
interface BlogPostCardProps {
post: BlogPostPreview
href: string
level?: 'mine' | 'friends' | 'nostrverse'
readingProgress?: number // 0-1 reading progress (optional)
hideBotByName?: boolean // default true
}
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress }) => {
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress, hideBotByName = true }) => {
const profile = useEventModel(Models.ProfileModel, [post.author])
const displayName = profile?.name || profile?.display_name ||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
// Note: Images are lazy-loaded (loading="lazy" below), so they'll be fetched
// when they come into view. The Service Worker will cache them automatically.
// No need to preload all images at once - this causes ERR_INSUFFICIENT_RESOURCES
// when there are many blog posts.
const displayName = getProfileDisplayName(profile, post.author)
const rawName = (profile?.name || profile?.display_name || '').toLowerCase()
// Hide bot authors by name/display_name
if (hideBotByName && (rawName.includes('bot') || isKnownBot(post.author))) {
return null
}
const publishedDate = post.published || post.event.created_at
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
@@ -34,9 +48,22 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
progressColor = 'var(--color-text)' // Neutral text color (started)
}
// Debug log - reading progress shown as visual indicator
if (readingProgress !== undefined) {
// Reading progress display
}
return (
<Link
to={href}
state={{
previewData: {
title: post.title,
image: post.image,
summary: post.summary,
published: post.published
}
}}
className={`blog-post-card ${level ? `level-${level}` : ''}`}
style={{ textDecoration: 'none', color: 'inherit' }}
>

View File

@@ -1,15 +1,17 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
import { faGlobe, faLink } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { npubEncode, neventEncode } from 'nostr-tools/nip19'
import { npubEncode, naddrEncode } from 'nostr-tools/nip19'
import { IndividualBookmark } from '../types/bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { classifyUrl } from '../utils/helpers'
import { ViewMode } from './Bookmarks'
import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
import { CompactView } from './BookmarkViews/CompactView'
import { LargeView } from './BookmarkViews/LargeView'
import { CardView } from './BookmarkViews/CardView'
@@ -19,9 +21,11 @@ interface BookmarkItemProps {
index: number
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
viewMode?: ViewMode
readingProgress?: number
}
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
const navigate = useNavigate()
const [ogImage, setOgImage] = useState<string | null>(null)
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
@@ -39,10 +43,11 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
const firstUrl = hasUrls ? extractedUrls[0] : null
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : null
// For kind:30023 articles, extract image and summary tags (per NIP-23)
// For kind:30023 articles, extract title, image and summary tags (per NIP-23)
// Note: We extract directly from tags here since we don't have the full event.
// When we have full events, we use getArticleImage() helper (see articleService.ts)
const isArticle = bookmark.kind === 30023
const articleTitle = isArticle ? bookmark.tags.find(t => t[0] === 'title')?.[1] : undefined
const articleImage = isArticle ? bookmark.tags.find(t => t[0] === 'image')?.[1] : undefined
const articleSummary = isArticle ? bookmark.tags.find(t => t[0] === 'summary')?.[1] : undefined
@@ -57,15 +62,16 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
// Resolve author profile using applesauce
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
const authorNpub = npubEncode(bookmark.pubkey)
const isHexId = /^[0-9a-f]{64}$/i.test(bookmark.id)
const eventNevent = isHexId ? neventEncode({ id: bookmark.id }) : undefined
// Get display name for author
// Get display name for author using centralized utility
const getAuthorDisplayName = () => {
if (authorProfile?.name) return authorProfile.name
if (authorProfile?.display_name) return authorProfile.display_name
if (authorProfile?.nip05) return authorProfile.nip05
return short(bookmark.pubkey) // fallback to short pubkey
const displayName = getProfileDisplayName(authorProfile, bookmark.pubkey)
// getProfileDisplayName returns npub format for fallback, but we want short pubkey format
// So check if it's the fallback format and use short() instead
if (displayName.startsWith('@') && displayName.includes('...')) {
return short(bookmark.pubkey)
}
return displayName
}
// Get content type icon based on bookmark kind and URL classification
@@ -109,10 +115,16 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
const handleReadNow = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
// For kind:30023 articles, pass the bookmark data instead of URL
// For kind:30023 articles, navigate to /a/:naddr route
if (bookmark.kind === 30023) {
if (onSelectUrl) {
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = naddrEncode({
kind: bookmark.kind,
pubkey: bookmark.pubkey,
identifier: dTag
})
navigate(`/a/${naddr}`)
}
return
}
@@ -134,17 +146,25 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
extractedUrls,
onSelectUrl,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleImage,
articleSummary,
contentTypeIcon: getContentTypeIcon()
contentTypeIcon: getContentTypeIcon(),
readingProgress
}
if (viewMode === 'compact') {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const { articleImage, ...compactProps } = sharedProps
const compactProps = {
bookmark,
index,
hasUrls,
extractedUrls,
onSelectUrl,
articleTitle,
contentTypeIcon: getContentTypeIcon(),
readingProgress
}
return <CompactView {...compactProps} />
}
@@ -153,5 +173,5 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
}
return <CardView {...sharedProps} articleImage={articleImage} />
return <CardView {...sharedProps} articleImage={articleImage} articleTitle={articleTitle} />
}

View File

@@ -1,8 +1,8 @@
import React, { useRef, useState } from 'react'
import React, { useRef, useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
import { formatDistanceToNow } from 'date-fns'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faHeart, faPlus, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
import { faClock } from '@fortawesome/free-regular-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import { BookmarkItem } from './BookmarkItem'
@@ -13,15 +13,19 @@ import { ViewMode } from './Bookmarks'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { BookmarkSkeleton } from './Skeletons'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet } from '../utils/bookmarkUtils'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
import { UserSettings } from '../services/settingsService'
import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService'
import { RELAYS } from '../config/relays'
import { Hooks } from 'applesauce-react'
import { getActiveRelayUrls } from '../services/relayManager'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import LoginOptions from './LoginOptions'
import { useEffect } from 'react'
import { readingProgressController } from '../services/readingProgressController'
import { nip19 } from 'nostr-tools'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
interface BookmarkListProps {
bookmarks: Bookmark[]
@@ -54,7 +58,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onOpenSettings,
onRefresh,
isRefreshing,
lastFetchTime,
loading = false,
relayPool,
isMobile = false,
@@ -67,9 +70,59 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
const saved = localStorage.getItem('bookmarkGroupingMode')
return saved === 'flat' ? 'flat' : 'grouped'
return saved === 'grouped' ? 'grouped' : 'flat'
})
const activeAccount = Hooks.useActiveAccount()
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
// Subscribe to reading progress updates
useEffect(() => {
// Get initial progress map
setReadingProgressMap(readingProgressController.getProgressMap())
// Subscribe to updates
const unsubProgress = readingProgressController.onProgress(setReadingProgressMap)
return () => {
unsubProgress()
}
}, [])
// Helper to get reading progress for a bookmark
const getBookmarkReadingProgress = (bookmark: IndividualBookmark): number | undefined => {
if (bookmark.kind === 30023) {
// For articles, use naddr as key
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) return undefined
try {
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: bookmark.pubkey,
identifier: dTag
})
return readingProgressMap.get(naddr)
} catch (err) {
return undefined
}
}
// For web bookmarks (kind:39701), URL is in the 'd' tag
if (bookmark.kind === 39701) {
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
// Ensure URL has protocol
const url = dTag.startsWith('http') ? dTag : `https://${dTag}`
return readingProgressMap.get(url)
}
}
// For other bookmark types, try to extract URL from content
const urls = extractUrlsFromContent(bookmark.content)
if (urls.length > 0) {
return readingProgressMap.get(urls[0])
}
return undefined
}
const toggleGroupingMode = () => {
const newMode = groupingMode === 'grouped' ? 'flat' : 'grouped'
@@ -77,12 +130,24 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
localStorage.setItem('bookmarkGroupingMode', newMode)
}
const getFilterTitle = (filter: BookmarkFilterType): string => {
const titles: Record<BookmarkFilterType, string> = {
'all': 'All Bookmarks',
'article': 'Bookmarked Reads',
'external': 'Bookmarked Links',
'video': 'Bookmarked Videos',
'note': 'Bookmarked Notes',
'web': 'Web Bookmarks'
}
return titles[filter]
}
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
if (!activeAccount || !relayPool) {
throw new Error('Please login to create bookmarks')
}
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, getActiveRelayUrls(relayPool))
}
// Pull-to-refresh for bookmarks
@@ -97,38 +162,58 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
isDisabled: !onRefresh
})
// Merge and flatten all individual bookmarks from all lists
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
// Merge and flatten all individual bookmarks from all lists - memoized to ensure consistent sorting
const sections = useMemo(() => {
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
// Separate bookmarks with setName (kind 30003) from regular bookmarks
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
const bookmarkSets = getBookmarkSets(filteredBookmarks)
// Group non-set bookmarks by source or flatten based on mode
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
const sectionsArray: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: getFilterTitle(selectedFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
: [
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb },
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic }
]
// Add bookmark sets as additional sections (only in grouped mode)
if (groupingMode === 'grouped') {
bookmarkSets.forEach(set => {
sectionsArray.push({
key: `set-${set.name}`,
title: set.title || set.name,
items: set.bookmarks
})
})
}
return sectionsArray
}, [bookmarks, selectedFilter, groupingMode, settings?.hideBookmarksWithoutCreationDate])
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
// Get all filtered bookmarks for empty state checks
const allIndividualBookmarks = useMemo(() =>
bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b)),
[bookmarks, settings?.hideBookmarksWithoutCreationDate]
)
// Separate bookmarks with setName (kind 30003) from regular bookmarks
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
const bookmarkSets = getBookmarkSets(filteredBookmarks)
// Group non-set bookmarks by source or flatten based on mode
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: `All Bookmarks (${bookmarksWithoutSet.length})`, items: bookmarksWithoutSet }]
: [
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Amethyst Private', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'Amethyst Lists', items: groups.amethystPublic },
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
]
// Add bookmark sets as additional sections
bookmarkSets.forEach(set => {
sections.push({
key: `set-${set.name}`,
title: set.title || set.name,
items: set.bookmarks
})
})
const filteredBookmarks = useMemo(() =>
filterBookmarksByType(allIndividualBookmarks, selectedFilter),
[allIndividualBookmarks, selectedFilter]
)
if (isCollapsed) {
// Check if the selected URL is in bookmarks
@@ -162,10 +247,19 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
/>
{allIndividualBookmarks.length > 0 && (
<BookmarkFilters
selectedFilter={selectedFilter}
onFilterChange={setSelectedFilter}
/>
<div className="bookmark-filters-wrapper">
<BookmarkFilters
selectedFilter={selectedFilter}
onFilterChange={setSelectedFilter}
/>
<CompactButton
icon={faPlus}
onClick={() => setShowAddModal(true)}
title="Add web bookmark"
ariaLabel="Add web bookmark"
className="bookmark-section-action"
/>
</div>
)}
{!activeAccount ? (
@@ -202,15 +296,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
<div key={section.key} className="bookmarks-section">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h3 className="bookmarks-section-title" style={{ margin: 0, padding: '1.5rem 0.5rem 0.375rem', flex: 1 }}>{section.title}</h3>
{section.key === 'web' && activeAccount && (
<CompactButton
icon={faPlus}
onClick={() => setShowAddModal(true)}
title="Add web bookmark"
ariaLabel="Add web bookmark"
className="bookmark-section-action"
/>
)}
</div>
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{section.items.map((individualBookmark, index) => (
@@ -220,6 +305,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
index={index}
onSelectUrl={onSelectUrl}
viewMode={viewMode}
readingProgress={getBookmarkReadingProgress(individualBookmark)}
/>
))}
</div>
@@ -237,27 +323,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
variant="ghost"
style={{ color: friendsColor }}
/>
</div>
{activeAccount && (
<div className="view-mode-right">
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
)}
{activeAccount && (
<IconButton
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
onClick={toggleGroupingMode}
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
variant="ghost"
/>
)}
</div>
{activeAccount && (
<div className="view-mode-right">
<IconButton
icon={faList}
onClick={() => onViewModeChange('compact')}

View File

@@ -1,15 +1,17 @@
import React, { useState } from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { faLink } from '@fortawesome/free-solid-svg-icons'
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
import { faGlobe } from '@fortawesome/free-solid-svg-icons'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import RichContent from '../RichContent'
import { classifyUrl } from '../../utils/helpers'
import { useImageCache } from '../../hooks/useImageCache'
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
import { getEventUrl } from '../../config/nostrGateways'
import { naddrEncode } from 'nostr-tools/nip19'
import { ReadingProgressBar } from '../ReadingProgressBar'
interface CardViewProps {
bookmark: IndividualBookmark
@@ -18,12 +20,12 @@ interface CardViewProps {
extractedUrls: string[]
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
authorNpub: string
eventNevent?: string
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleImage?: string
articleSummary?: string
contentTypeIcon: IconDefinition
articleTitle?: string
readingProgress?: number
}
export const CardView: React.FC<CardViewProps> = ({
@@ -31,26 +33,53 @@ export const CardView: React.FC<CardViewProps> = ({
index,
hasUrls,
extractedUrls,
onSelectUrl,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleImage,
articleSummary,
contentTypeIcon
articleTitle,
readingProgress
}) => {
const firstUrl = hasUrls ? extractedUrls[0] : null
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassificationType || '') : null
const [ogImage, setOgImage] = useState<string | null>(null)
const [expanded, setExpanded] = useState(false)
const [urlsExpanded, setUrlsExpanded] = useState(false)
const contentLength = (bookmark.content || '').length
const shouldTruncate = !expanded && contentLength > 210
const isArticle = bookmark.kind === 30023
const isWebBookmark = bookmark.kind === 39701
const isNote = bookmark.kind === 1
// Extract title from tags for regular bookmarks (not just articles)
const bookmarkTitle = bookmark.tags.find(t => t[0] === 'title')?.[1]
// Get content type icon based on bookmark kind and URL classification
const getContentTypeIcon = () => {
if (isArticle) return faNewspaper // Nostr-native article
// For web bookmarks, classify the URL to determine icon
if (isWebBookmark && firstUrlClassificationType) {
switch (firstUrlClassificationType) {
case 'youtube':
case 'video':
return faCirclePlay
case 'image':
return faCamera
case 'article':
return faFileLines
default:
return faGlobe
}
}
// For notes, use sticky note icon
if (isNote) return faStickyNote
// Default fallback
return faLink
}
// Determine which image to use (article image, instant preview, or OG image)
const previewImage = articleImage || instantPreview || ogImage
@@ -63,6 +92,7 @@ export const CardView: React.FC<CardViewProps> = ({
}
}, [firstUrl, articleImage, instantPreview, ogImage])
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
@@ -72,109 +102,113 @@ export const CardView: React.FC<CardViewProps> = ({
}
}
// Get internal route for the bookmark
const getInternalRoute = (): string | null => {
if (bookmark.kind === 30023) {
// Nostr-native article - use /a/ route
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = naddrEncode({
kind: bookmark.kind,
pubkey: bookmark.pubkey,
identifier: dTag
})
return `/a/${naddr}`
}
} else if (bookmark.kind === 1) {
// Note - use /e/ route
return `/e/${bookmark.id}`
} else if (firstUrl) {
// External URL - use /r/ route
return `/r/${encodeURIComponent(firstUrl)}`
}
return null
}
return (
<div
key={`${bookmark.id}-${index}`}
className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
className={`individual-bookmark card-view ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
onClick={triggerOpen}
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
>
{cachedImage && (
<div
className="article-hero-image"
style={{ backgroundImage: `url(${cachedImage})` }}
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
/>
)}
<div className="bookmark-header">
<span className="bookmark-type">
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
</span>
{eventNevent ? (
<a
href={getEventUrl(eventNevent)}
target="_blank"
rel="noopener noreferrer"
className="bookmark-date-link"
title="Open event in search"
onClick={(e) => e.stopPropagation()}
>
{formatDate(bookmark.created_at)}
</a>
) : (
<span className="bookmark-date">{formatDate(bookmark.created_at)}</span>
)}
</div>
{extractedUrls.length > 0 && (
<div className="bookmark-urls">
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
return (
<button
key={urlIndex}
className="bookmark-url"
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
title="Open in reader"
<div className="card-layout">
<div className="card-content">
<div className="card-content-header">
{(cachedImage || firstUrl) && (
<div
className="card-thumbnail"
style={cachedImage ? { backgroundImage: `url(${cachedImage})` } : undefined}
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
>
{url}
</button>
)
})}
{extractedUrls.length > 1 && (
<button
className="expand-toggle-urls"
onClick={(e) => { e.stopPropagation(); setUrlsExpanded(v => !v) }}
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
{!cachedImage && firstUrl && (
<div className="thumbnail-placeholder">
<FontAwesomeIcon icon={getContentTypeIcon()} />
</div>
)}
</div>
)}
<div className="card-text-content">
<div className="bookmark-header">
</div>
{/* Display title for articles or bookmarks with titles */}
{(articleTitle || bookmarkTitle) && (
<h3 className="bookmark-title">
<RichContent content={articleTitle || bookmarkTitle || ''} className="" />
</h3>
)}
{isArticle && articleSummary ? (
<RichContent content={articleSummary} className="bookmark-content article-summary" />
) : bookmark.parsedContent ? (
<div className="bookmark-content">
{renderParsedContent(bookmark.parsedContent)}
</div>
) : bookmark.content && (
<RichContent content={bookmark.content} className="bookmark-content" />
)}
</div>
</div>
</div>
{/* Reading progress indicator as separator - always shown for all bookmark types */}
<ReadingProgressBar
readingProgress={readingProgress}
height={1}
marginTop="0.125rem"
marginBottom="0.125rem"
/>
<div className="bookmark-footer">
<div className="bookmark-meta-minimal">
<Link
to={`/p/${authorNpub}`}
className="author-link-minimal"
title="Open author profile"
onClick={(e) => e.stopPropagation()}
>
{urlsExpanded ? `Hide ${extractedUrls.length - 1} more` : `Show ${extractedUrls.length - 1} more`}
</button>
)}
{getAuthorDisplayName()}
</Link>
</div>
<div className="bookmark-footer-right">
{getInternalRoute() ? (
<Link
to={getInternalRoute()!}
className="bookmark-date-link"
title="Open in app"
onClick={(e) => e.stopPropagation()}
>
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
</Link>
) : (
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
)}
</div>
</div>
)}
{isArticle && articleSummary ? (
<div className="bookmark-content article-summary">
<ContentWithResolvedProfiles content={articleSummary} />
</div>
) : bookmark.parsedContent ? (
<div className="bookmark-content">
{shouldTruncate && bookmark.content
? <ContentWithResolvedProfiles content={`${bookmark.content.slice(0, 210).trimEnd()}`} />
: renderParsedContent(bookmark.parsedContent)}
</div>
) : bookmark.content && (
<div className="bookmark-content">
<ContentWithResolvedProfiles content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}` : bookmark.content} />
</div>
)}
{contentLength > 210 && (
<button
className="expand-toggle"
onClick={(e) => { e.stopPropagation(); setExpanded(v => !v) }}
aria-label={expanded ? 'Collapse' : 'Expand'}
title={expanded ? 'Collapse' : 'Expand'}
>
<FontAwesomeIcon icon={expanded ? faChevronUp : faChevronDown} />
</button>
)}
<div className="bookmark-footer">
<div className="bookmark-meta-minimal">
<Link
to={`/p/${authorNpub}`}
className="author-link-minimal"
title="Open author profile"
onClick={(e) => e.stopPropagation()}
>
{getAuthorDisplayName()}
</Link>
</div>
{/* CTA removed */}
</div>
</div>
)

View File

@@ -1,9 +1,12 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDateCompact } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import RichContent from '../RichContent'
import { naddrEncode } from 'nostr-tools/nip19'
import { ReadingProgressBar } from '../ReadingProgressBar'
interface CompactViewProps {
bookmark: IndividualBookmark
@@ -11,8 +14,9 @@ interface CompactViewProps {
hasUrls: boolean
extractedUrls: string[]
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
articleSummary?: string
articleTitle?: string
contentTypeIcon: IconDefinition
readingProgress?: number
}
export const CompactView: React.FC<CompactViewProps> = ({
@@ -21,28 +25,37 @@ export const CompactView: React.FC<CompactViewProps> = ({
hasUrls,
extractedUrls,
onSelectUrl,
articleSummary,
contentTypeIcon
articleTitle,
contentTypeIcon,
readingProgress
}) => {
const navigate = useNavigate()
const isArticle = bookmark.kind === 30023
const isWebBookmark = bookmark.kind === 39701
const isClickable = hasUrls || isArticle || isWebBookmark
const isNote = bookmark.kind === 1
const isClickable = hasUrls || isArticle || isWebBookmark || isNote
const displayText = isArticle && articleTitle ? articleTitle : bookmark.content
const handleCompactClick = () => {
if (!onSelectUrl) return
if (isArticle) {
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = naddrEncode({
kind: bookmark.kind,
pubkey: bookmark.pubkey,
identifier: dTag
})
navigate(`/a/${naddr}`)
}
} else if (hasUrls) {
onSelectUrl(extractedUrls[0])
onSelectUrl?.(extractedUrls[0])
} else if (isNote) {
navigate(`/e/${bookmark.id}`)
}
}
// For articles, prefer summary; for others, use content
const displayText = isArticle && articleSummary
? articleSummary
: bookmark.content
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
<div
@@ -54,14 +67,27 @@ export const CompactView: React.FC<CompactViewProps> = ({
<span className="bookmark-type-compact">
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
</span>
{displayText && (
{displayText ? (
<div className="compact-text">
<ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} />
<RichContent content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} className="" />
</div>
) : (
<div className="compact-text" style={{ opacity: 0.5, fontSize: '0.85em' }}>
<code>{bookmark.id.slice(0, 12)}...</code>
</div>
)}
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
{/* CTA removed */}
</div>
{/* Reading progress indicator - only show when there's actual progress */}
{readingProgress !== undefined && readingProgress > 0 && (
<ReadingProgressBar
readingProgress={readingProgress}
height={1}
marginLeft="1.5rem"
/>
)}
</div>
)
}

View File

@@ -4,10 +4,11 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import RichContent from '../RichContent'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { getEventUrl } from '../../config/nostrGateways'
import { naddrEncode } from 'nostr-tools/nip19'
import { ReadingProgressBar } from '../ReadingProgressBar'
interface LargeViewProps {
bookmark: IndividualBookmark
@@ -18,7 +19,6 @@ interface LargeViewProps {
getIconForUrlType: IconGetter
previewImage: string | null
authorNpub: string
eventNevent?: string
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleSummary?: string
@@ -35,7 +35,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
getIconForUrlType,
previewImage,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleSummary,
@@ -45,15 +44,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
const cachedImage = useImageCache(previewImage || undefined)
const isArticle = bookmark.kind === 30023
// Calculate progress display (matching readingProgressUtils.ts logic)
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
let progressColor = '#6366f1' // Default blue (reading)
if (readingProgress && readingProgress >= 0.95) {
progressColor = '#10b981' // Green (completed)
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
progressColor = 'var(--color-text)' // Neutral text color (started)
}
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
@@ -63,6 +53,30 @@ export const LargeView: React.FC<LargeViewProps> = ({
}
}
// Get internal route for the bookmark
const getInternalRoute = (): string | null => {
const firstUrl = hasUrls ? extractedUrls[0] : null
if (bookmark.kind === 30023) {
// Nostr-native article - use /a/ route
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = naddrEncode({
kind: bookmark.kind,
pubkey: bookmark.pubkey,
identifier: dTag
})
return `/a/${naddr}`
}
} else if (bookmark.kind === 1) {
// Note - use /e/ route
return `/e/${bookmark.id}`
} else if (firstUrl) {
// External URL - use /r/ route
return `/r/${encodeURIComponent(firstUrl)}`
}
return null
}
return (
<div
key={`${bookmark.id}-${index}`}
@@ -95,36 +109,17 @@ export const LargeView: React.FC<LargeViewProps> = ({
<div className="large-content">
{isArticle && articleSummary ? (
<div className="large-text article-summary">
<ContentWithResolvedProfiles content={articleSummary} />
</div>
<RichContent content={articleSummary} className="large-text article-summary" />
) : bookmark.content && (
<div className="large-text">
<ContentWithResolvedProfiles content={bookmark.content} />
</div>
<RichContent content={bookmark.content} className="large-text" />
)}
{/* Reading progress indicator for articles - shown only if there's progress */}
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
<div
style={{
height: '3px',
width: '100%',
background: 'var(--color-border)',
overflow: 'hidden',
marginTop: '0.75rem'
}}
>
<div
style={{
height: '100%',
width: `${progressPercent}%`,
background: progressColor,
transition: 'width 0.3s ease, background 0.3s ease'
}}
/>
</div>
)}
{/* Reading progress indicator for all bookmark types - always shown */}
<ReadingProgressBar
readingProgress={readingProgress}
height={3}
marginTop="0.75rem"
/>
<div className="large-footer">
<span className="bookmark-type-large">
@@ -140,16 +135,17 @@ export const LargeView: React.FC<LargeViewProps> = ({
</Link>
</span>
{eventNevent && (
<a
href={getEventUrl(eventNevent)}
target="_blank"
rel="noopener noreferrer"
{getInternalRoute() ? (
<Link
to={getInternalRoute()!}
className="bookmark-date-link"
title="Open in app"
onClick={(e) => e.stopPropagation()}
>
{formatDate(bookmark.created_at)}
</a>
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
</Link>
) : (
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
)}
{/* CTA removed */}

View File

@@ -2,8 +2,11 @@ import React, { useMemo, useEffect, useRef } from 'react'
import { useParams, useLocation, useNavigate } from 'react-router-dom'
import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks'
import { Helpers } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
const { getPubkeyFromDecodeResult } = Helpers
import { useSettings } from '../hooks/useSettings'
import { useArticleLoader } from '../hooks/useArticleLoader'
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
@@ -13,10 +16,13 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useRelayStatus } from '../hooks/useRelayStatus'
import { useOfflineSync } from '../hooks/useOfflineSync'
import { useEventLoader } from '../hooks/useEventLoader'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
import { Bookmark } from '../types/bookmarks'
import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore'
import Me from './Me'
import Profile from './Profile'
import Support from './Support'
import { classifyHighlights } from '../utils/highlightClassification'
@@ -37,7 +43,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
bookmarksLoading,
onRefreshBookmarks
}) => {
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
const { naddr, npub, eventId: eventIdParam } = useParams<{ naddr?: string; npub?: string; eventId?: string }>()
const location = useLocation()
const navigate = useNavigate()
const previousLocationRef = useRef<string>()
@@ -51,45 +57,59 @@ const Bookmarks: React.FC<BookmarksProps> = ({
const showSettings = location.pathname === '/settings'
const showExplore = location.pathname.startsWith('/explore')
const showMe = location.pathname.startsWith('/me')
const showMe = location.pathname.startsWith('/my')
const showProfile = location.pathname.startsWith('/p/')
const showSupport = location.pathname === '/support'
const eventId = eventIdParam
// Manage document title based on current route
const isViewingContent = !!(naddr || externalUrl || eventId)
useDocumentTitle({
title: isViewingContent ? undefined : 'Boris - Read, Highlight, Explore'
})
// Extract tab from explore routes
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
// Extract tab from me routes
const meTab = location.pathname === '/me' ? 'highlights' :
location.pathname === '/me/highlights' ? 'highlights' :
location.pathname === '/me/reading-list' ? 'reading-list' :
location.pathname.startsWith('/me/reads') ? 'reads' :
location.pathname === '/me/links' ? 'links' :
location.pathname === '/me/writings' ? 'writings' : 'highlights'
const meTab = location.pathname === '/my' ? 'highlights' :
location.pathname === '/my/highlights' ? 'highlights' :
location.pathname === '/my/bookmarks' ? 'bookmarks' :
location.pathname.startsWith('/my/reads') ? 'reads' :
location.pathname.startsWith('/my/links') ? 'links' :
location.pathname === '/my/writings' ? 'writings' : 'highlights'
// Extract tab from profile routes
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
// Decode npub or nprofile to pubkey for profile view
// Decode npub or nprofile to pubkey for profile view using applesauce helper
let profilePubkey: string | undefined
if (npub && showProfile) {
try {
const decoded = nip19.decode(npub)
if (decoded.type === 'npub') {
profilePubkey = decoded.data
} else if (decoded.type === 'nprofile') {
profilePubkey = decoded.data.pubkey
}
profilePubkey = getPubkeyFromDecodeResult(decoded)
} catch (err) {
console.error('Failed to decode npub/nprofile:', err)
}
}
// Track previous location for going back from settings/me/explore/profile
// Track previous location for going back from settings/my/explore/profile
useEffect(() => {
if (!showSettings && !showMe && !showExplore && !showProfile) {
previousLocationRef.current = location.pathname
}
}, [location.pathname, showSettings, showMe, showExplore, showProfile])
// Reset scroll to top when navigating to profile routes
useEffect(() => {
if (showProfile) {
// Reset scroll position when navigating to profile pages
// Use requestAnimationFrame to ensure it happens after DOM updates
requestAnimationFrame(() => {
window.scrollTo({ top: 0, behavior: 'instant' })
})
}
}, [location.pathname, showProfile])
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
@@ -219,14 +239,28 @@ const Bookmarks: React.FC<BookmarksProps> = ({
currentArticle,
selectedUrl,
readerContent,
onHighlightCreated: (highlight) => setHighlights(prev => [highlight, ...prev]),
onHighlightCreated: (highlight) => setHighlights(prev => {
// Deduplicate by checking if highlight with this ID already exists
const exists = prev.some(h => h.id === highlight.id)
if (exists) {
return prev // Don't add duplicate
}
return [highlight, ...prev]
}),
settings
})
// Determine which loader should be active based on route
// Only one loader should run at a time to prevent state conflicts
const shouldLoadArticle = !!naddr && !externalUrl && !eventId
const shouldLoadExternal = !!externalUrl && !naddr && !eventId
const shouldLoadEvent = !!eventId && !naddr && !externalUrl
// Load nostr-native article if naddr is in URL
useArticleLoader({
naddr,
naddr: shouldLoadArticle ? naddr : undefined,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,
@@ -241,7 +275,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
// Load external URL if /r/* route is used
useExternalUrlLoader({
url: externalUrl,
url: shouldLoadExternal ? externalUrl : undefined,
relayPool,
eventStore,
setSelectedUrl,
@@ -254,6 +288,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({
setCurrentArticleEventId
})
// Load event if /e/:eventId route is used
useEventLoader({
eventId: shouldLoadEvent ? eventId : undefined,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed
})
// Classify highlights with levels based on user context
const classifiedHighlights = useMemo(() => {
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
@@ -327,10 +372,10 @@ const Bookmarks: React.FC<BookmarksProps> = ({
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
) : undefined}
me={showMe ? (
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} settings={settings} /> : null
) : undefined}
profile={showProfile && profilePubkey ? (
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={profileTab} pubkey={profilePubkey} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
relayPool ? <Profile relayPool={relayPool} eventStore={eventStore} pubkey={profilePubkey} activeTab={profileTab} /> : null
) : undefined}
support={showSupport ? (
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null

View File

@@ -4,19 +4,20 @@ import { HIGHLIGHT_COLORS } from '../utils/colorHelpers'
interface ColorPickerProps {
selectedColor: string
onColorChange: (color: string) => void
colors?: typeof HIGHLIGHT_COLORS
}
const ColorPicker: React.FC<ColorPickerProps> = ({ selectedColor, onColorChange }) => {
const ColorPicker: React.FC<ColorPickerProps> = ({ selectedColor, onColorChange, colors = HIGHLIGHT_COLORS }) => {
return (
<div className="color-picker">
{HIGHLIGHT_COLORS.map(color => (
{colors.map(color => (
<button
key={color.value}
onClick={() => onColorChange(color.value)}
className={`color-swatch ${selectedColor === color.value ? 'active' : ''}`}
style={{ backgroundColor: color.value }}
title={color.name}
aria-label={`${color.name} highlight color`}
aria-label={`${color.name} color`}
/>
))}
</div>

View File

@@ -1,17 +1,17 @@
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
import ReactPlayer from 'react-player'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import rehypePrism from 'rehype-prism-plus'
import VideoEmbedProcessor from './VideoEmbedProcessor'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import 'prismjs/themes/prism-tomorrow.css'
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons'
import { ContentSkeleton } from './Skeletons'
import { nip19 } from 'nostr-tools'
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
import { RELAYS } from '../config/relays'
import { RelayPool } from 'applesauce-relay'
import { getActiveRelayUrls } from '../services/relayManager'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights'
@@ -29,20 +29,21 @@ import {
hasMarkedEventAsRead,
hasMarkedWebsiteAsRead
} from '../services/reactionService'
import { unarchiveEvent, unarchiveWebsite } from '../services/unarchiveService'
import { archiveController } from '../services/archiveController'
import AuthorCard from './AuthorCard'
import { faBooks } from '../icons/customIcons'
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
import { classifyUrl } from '../utils/helpers'
import { buildNativeVideoUrl } from '../utils/videoHelpers'
import { shouldTrackReadingProgress } from '../utils/helpers'
import { useReadingPosition } from '../hooks/useReadingPosition'
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
import { EventFactory } from 'applesauce-factory'
import { Hooks } from 'applesauce-react'
import {
generateArticleIdentifier,
loadReadingPosition,
saveReadingPosition
saveReadingPosition
} from '../services/readingPositionService'
import { readingProgressController } from '../services/readingProgressController'
import TTSControls from './TTSControls'
interface ContentPanelProps {
loading: boolean
@@ -72,6 +73,7 @@ interface ContentPanelProps {
// For reading progress indicator positioning
isSidebarCollapsed?: boolean
isHighlightsCollapsed?: boolean
onOpenHighlights?: () => void
}
const ContentPanel: React.FC<ContentPanelProps> = ({
@@ -99,21 +101,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onTextSelection,
onClearSelection,
isSidebarCollapsed = false,
isHighlightsCollapsed = false
isHighlightsCollapsed = false,
onOpenHighlights
}) => {
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
const [showArticleMenu, setShowArticleMenu] = useState(false)
const [showVideoMenu, setShowVideoMenu] = useState(false)
const [showExternalMenu, setShowExternalMenu] = useState(false)
const [articleMenuOpenUpward, setArticleMenuOpenUpward] = useState(false)
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
const [externalMenuOpenUpward, setExternalMenuOpenUpward] = useState(false)
const articleMenuRef = useRef<HTMLDivElement>(null)
const videoMenuRef = useRef<HTMLDivElement>(null)
const externalMenuRef = useRef<HTMLDivElement>(null)
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
const { finalHtml, relevantHighlights } = useHighlightedContent({
@@ -128,8 +127,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
currentUserPubkey,
followedPubkeys
})
// Key used to force re-mount of markdown preview/render when content changes
const contentKey = useMemo(() => {
// Prefer selectedUrl as a stable per-article key; fallback to title+length
return selectedUrl || `${title || ''}:${(markdown || html || '').length}`
}, [selectedUrl, title, markdown, html])
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
const { contentRef } = useHighlightInteractions({
onHighlightClick,
selectedHighlightId,
onTextSelection,
@@ -139,8 +143,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// Get event store for reading position service
const eventStore = Hooks.useEventStore()
// Reading position tracking - only for text content, not videos
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
// Reading position tracking - only for text content that's loaded and long enough
// Wait for content to load, check it's not a video, and verify it's long enough to track
const isTextContent = useMemo(() => {
if (loading) return false
if (!markdown && !html) return false
// Don't track internal sentinel URLs (nostr-event: is not a real Nostr URI per NIP-21)
if (selectedUrl?.startsWith('nostr-event:')) return false
if (selectedUrl?.includes('youtube') || selectedUrl?.includes('vimeo')) return false
if (!shouldTrackReadingProgress(html, markdown)) return false
return true
}, [loading, markdown, html, selectedUrl])
// Generate article identifier for saving/loading position
const articleIdentifier = useMemo(() => {
@@ -148,23 +162,29 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
return generateArticleIdentifier(selectedUrl)
}, [selectedUrl])
// Use refs for content to avoid recreating callback on every content change
const htmlRef = useRef(html)
const markdownRef = useRef(markdown)
useEffect(() => {
htmlRef.current = html
markdownRef.current = markdown
}, [html, markdown])
// Callback to save reading position
const handleSavePosition = useCallback(async (position: number) => {
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
console.log('⏭️ [ContentPanel] Skipping save - missing requirements:', {
hasAccount: !!activeAccount,
hasRelayPool: !!relayPool,
hasEventStore: !!eventStore,
hasIdentifier: !!articleIdentifier
})
return
}
if (!settings?.syncReadingPosition) {
console.log('⏭️ [ContentPanel] Sync disabled in settings')
return
}
// Check if content is long enough to track reading progress
if (!shouldTrackReadingProgress(htmlRef.current, markdownRef.current)) {
return
}
console.log('💾 [ContentPanel] Saving position:', Math.round(position * 100) + '%', 'for article:', selectedUrl?.slice(0, 50))
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
try {
const factory = new EventFactory({ signer: activeAccount })
@@ -176,92 +196,155 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
{
position,
timestamp: Math.floor(Date.now() / 1000),
scrollTop: window.pageYOffset || document.documentElement.scrollTop
scrollTop
}
)
} catch (error) {
console.error('❌ [ContentPanel] Failed to save reading position:', error)
console.error('[reading-position] Failed to save reading position:', error)
}
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition])
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
enabled: isTextContent,
syncEnabled: settings?.syncReadingPosition,
// Delay enabling position tracking to ensure content is stable
const [isTrackingEnabled, setIsTrackingEnabled] = useState(false)
// Reset tracking when article changes
useEffect(() => {
setIsTrackingEnabled(false)
}, [selectedUrl])
// Enable/disable tracking based on content state
useEffect(() => {
if (!isTextContent) {
// Disable tracking if content is not suitable
if (isTrackingEnabled) {
setIsTrackingEnabled(false)
}
return
}
if (!isTrackingEnabled) {
// Wait 500ms after content loads before enabling tracking
const timer = setTimeout(() => {
setIsTrackingEnabled(true)
}, 500)
return () => clearTimeout(timer)
}
}, [isTextContent, isTrackingEnabled])
const { progressPercentage, suppressSavesFor } = useReadingPosition({
enabled: isTrackingEnabled,
syncEnabled: settings?.syncReadingPosition !== false,
onSave: handleSavePosition,
onReadingComplete: () => {
// Optional: Auto-mark as read when reading is complete
if (activeAccount && !isMarkedAsRead) {
// Could trigger auto-mark as read here if desired
// Auto-mark as read when reading is complete (if enabled in settings)
if (!settings?.autoMarkAsReadOnCompletion || !activeAccount) return
if (!isMarkedAsRead) {
handleMarkAsRead()
} else {
// Already archived: still show the success animation for feedback
setShowCheckAnimation(true)
setTimeout(() => setShowCheckAnimation(false), 600)
}
}
})
// Load saved reading position when article loads
// Log sync status when it changes
useEffect(() => {
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
console.log('⏭️ [ContentPanel] Skipping position restore - missing requirements:', {
isTextContent,
hasAccount: !!activeAccount,
hasRelayPool: !!relayPool,
hasEventStore: !!eventStore,
hasIdentifier: !!articleIdentifier
})
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
// Load saved reading position when article loads (using pre-loaded data from controller)
const suppressSavesForRef = useRef(suppressSavesFor)
useEffect(() => {
suppressSavesForRef.current = suppressSavesFor
}, [suppressSavesFor])
// Track if we've successfully started restore for this article + tracking state
// Use a composite key to ensure we only restore once per article when tracking is enabled
const restoreKey = `${articleIdentifier}-${isTrackingEnabled}`
const hasAttemptedRestoreRef = useRef<string | null>(null)
// Reset scroll position and restore ref when article changes
useEffect(() => {
if (!articleIdentifier) return
// Suppress saves during navigation to prevent saving 0% position
// The 500ms suppression covers the scroll reset and initial render
if (suppressSavesForRef.current) {
suppressSavesForRef.current(500)
}
// Reset scroll to top when article identifier changes
// This prevents showing wrong scroll position from previous article
window.scrollTo({ top: 0, behavior: 'instant' })
// Reset restore attempt tracking for new article
hasAttemptedRestoreRef.current = null
}, [articleIdentifier])
useEffect(() => {
if (!isTextContent || !activeAccount || !articleIdentifier) {
return
}
if (!settings?.syncReadingPosition) {
console.log('⏭️ [ContentPanel] Sync disabled - not restoring position')
if (settings?.syncReadingPosition === false) {
return
}
if (settings?.autoScrollToReadingPosition === false) {
return
}
if (!isTrackingEnabled) {
return
}
console.log('📖 [ContentPanel] Loading position for article:', selectedUrl?.slice(0, 50))
// Only attempt restore once per article (after tracking is enabled)
if (hasAttemptedRestoreRef.current === restoreKey) {
return
}
const loadPosition = async () => {
try {
const savedPosition = await loadReadingPosition(
relayPool,
eventStore,
activeAccount.pubkey,
articleIdentifier
)
// Mark as attempted using composite key
hasAttemptedRestoreRef.current = restoreKey
// Get the saved position from the controller (already loaded and displayed on card)
const savedProgress = readingProgressController.getProgress(articleIdentifier)
if (!savedProgress || savedProgress <= 0.05 || savedProgress >= 1) {
return
}
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
console.log('🎯 [ContentPanel] Restoring position:', Math.round(savedPosition.position * 100) + '%')
// Wait for content to be fully rendered before scrolling
setTimeout(() => {
const documentHeight = document.documentElement.scrollHeight
const windowHeight = window.innerHeight
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
window.scrollTo({
top: scrollTop,
behavior: 'smooth'
})
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
}, 500) // Give content time to render
} else if (savedPosition) {
if (savedPosition.position === 1) {
console.log('✅ [ContentPanel] Article completed (100%), starting from top')
} else {
console.log('⏭️ [ContentPanel] Position too early (<5%):', Math.round(savedPosition.position * 100) + '%')
}
// Suppress saves during restore (500ms render + 1000ms smooth scroll = 1500ms)
if (suppressSavesForRef.current) {
suppressSavesForRef.current(1500)
}
// Wait for content to be fully rendered
setTimeout(() => {
const docH = document.documentElement.scrollHeight
const winH = window.innerHeight
const maxScroll = Math.max(0, docH - winH)
const currentTop = window.pageYOffset || document.documentElement.scrollTop
const targetTop = savedProgress * maxScroll
// Skip if delta is too small (< 48px or < 5%)
const deltaPx = Math.abs(targetTop - currentTop)
const deltaPct = maxScroll > 0 ? Math.abs((targetTop - currentTop) / maxScroll) : 0
if (deltaPx < 48 || deltaPct < 0.05) {
// Allow saves immediately since no scroll happened
if (suppressSavesForRef.current) {
suppressSavesForRef.current(0)
}
} catch (error) {
console.error('❌ [ContentPanel] Failed to load reading position:', error)
return
}
}
loadPosition()
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
// Perform smooth animated restore
window.scrollTo({
top: targetTop,
behavior: 'smooth'
})
}, 500) // Give content time to render
}, [isTextContent, activeAccount, articleIdentifier, settings?.syncReadingPosition, settings?.autoScrollToReadingPosition, selectedUrl, isTrackingEnabled, restoreKey])
// Save position before unmounting or changing article
useEffect(() => {
return () => {
if (saveNow) {
saveNow()
}
}
}, [saveNow, selectedUrl])
// Note: We intentionally do NOT save on unmount because:
// 1. Browser may scroll to top during back navigation, causing 0% saves
// 2. The auto-save with 1s throttle already captures position during reading
// 3. Position state may not reflect actual reading position during navigation
// Close menu when clicking outside
useEffect(() => {
@@ -270,21 +353,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (articleMenuRef.current && !articleMenuRef.current.contains(target)) {
setShowArticleMenu(false)
}
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
setShowVideoMenu(false)
}
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
setShowExternalMenu(false)
}
}
if (showArticleMenu || showVideoMenu || showExternalMenu) {
if (showArticleMenu || showExternalMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [showArticleMenu, showVideoMenu, showExternalMenu])
}, [showArticleMenu, showExternalMenu])
// Check available space and position menu upward if needed
useEffect(() => {
@@ -307,13 +387,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (showArticleMenu) {
checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward)
}
if (showVideoMenu) {
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
}
if (showExternalMenu) {
checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward)
}
}, [showArticleMenu, showVideoMenu, showExternalMenu])
}, [showArticleMenu, showExternalMenu])
const readingStats = useMemo(() => {
const content = markdown || html || ''
@@ -324,36 +401,29 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const hasHighlights = relevantHighlights.length > 0
// Extract plain text for TTS
const baseHtml = useMemo(() => {
if (markdown) return renderedMarkdownHtml && finalHtml ? finalHtml : ''
return finalHtml || html || ''
}, [markdown, renderedMarkdownHtml, finalHtml, html])
const articleText = useMemo(() => {
const parts: string[] = []
if (title) parts.push(title)
if (summary) parts.push(summary)
if (baseHtml) {
const div = document.createElement('div')
div.innerHTML = baseHtml
const txt = (div.textContent || '').replace(/\s+/g, ' ').trim()
if (txt) parts.push(txt)
}
return parts.join('. ')
}, [title, summary, baseHtml])
// 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
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
// Load YouTube metadata/captions when applicable
useEffect(() => {
(async () => {
try {
if (!selectedUrl) return setYtMeta(null)
const id = extractYouTubeId(selectedUrl)
if (!id) return setYtMeta(null)
const locale = navigator?.language?.split('-')[0] || 'en'
const data = await getYouTubeMeta(id, locale)
if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript })
} catch {
setYtMeta(null)
}
})()
}, [selectedUrl])
const formatDuration = (totalSeconds: number): string => {
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = Math.floor(totalSeconds % 60)
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
const ss = String(seconds).padStart(2, '0')
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
}
// Get article links for menu
@@ -361,7 +431,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (!currentArticle) return null
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
const relayHints = RELAYS.filter(r =>
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayHints = activeRelays.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3)
@@ -390,7 +461,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
setShowArticleMenu(!showArticleMenu)
}
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
const handleOpenPortal = () => {
if (articleLinks) {
@@ -467,52 +537,17 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}
const handleOpenSearch = () => {
if (articleLinks) {
// For regular notes (kind:1), open via /e/ path
if (currentArticle?.kind === 1) {
const borisUrl = `${window.location.origin}/e/${currentArticle.id}`
window.open(borisUrl, '_blank', 'noopener,noreferrer')
} else if (articleLinks) {
// For articles, use search portal
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
}
setShowArticleMenu(false)
}
// Video actions
const handleOpenVideoExternal = () => {
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
setShowVideoMenu(false)
}
const handleOpenVideoNative = () => {
if (!selectedUrl) return
const native = buildNativeVideoUrl(selectedUrl)
if (native) {
window.location.href = native
} else {
window.location.href = selectedUrl
}
setShowVideoMenu(false)
}
const handleCopyVideoUrl = async () => {
try {
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
} catch (e) {
console.warn('Clipboard copy failed', e)
} finally {
setShowVideoMenu(false)
}
}
const handleShareVideoUrl = async () => {
try {
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Video', url: selectedUrl })
} else if (selectedUrl) {
await navigator.clipboard.writeText(selectedUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowVideoMenu(false)
}
}
// External article actions
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
@@ -554,7 +589,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const handleSearchExternalUrl = () => {
if (selectedUrl) {
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
// If it's a nostr event sentinel, open the event directly on ants.sh
if (selectedUrl.startsWith('nostr-event:')) {
const eventId = selectedUrl.replace('nostr-event:', '')
window.open(`https://ants.sh/e/${eventId}`, '_blank', 'noopener,noreferrer')
} else {
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
}
}
setShowExternalMenu(false)
}
@@ -577,12 +618,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
activeAccount.pubkey,
relayPool
)
// Also check archiveController
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
try {
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
hasRead = hasRead || archiveController.isMarked(naddr)
} catch (e) {
// Silently ignore encoding errors
}
}
} else {
hasRead = await hasMarkedWebsiteAsRead(
selectedUrl,
activeAccount.pubkey,
relayPool
)
// Also check archiveController
const ctrl = archiveController.isMarked(selectedUrl)
hasRead = hasRead || ctrl
}
setIsMarkedAsRead(hasRead)
} catch (error) {
@@ -596,7 +650,35 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
const handleMarkAsRead = () => {
if (!activeAccount || !relayPool || isMarkedAsRead) {
if (!activeAccount || !relayPool) return
// Toggle archive state: if already archived, request deletion; else archive
if (isMarkedAsRead) {
// Optimistically unarchive in UI; background deletion request (NIP-09)
setIsMarkedAsRead(false)
;(async () => {
try {
if (isNostrArticle && currentArticle) {
// Send deletion for all matching reactions
await unarchiveEvent(currentArticle.id, activeAccount, relayPool)
// Also clear controller mark so lists update
try {
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
archiveController.unmark(naddr)
}
} catch (e) {
console.warn('[archive][content] encode naddr failed', e)
}
} else if (selectedUrl) {
await unarchiveWebsite(selectedUrl, activeAccount, relayPool)
archiveController.unmark(selectedUrl)
}
} catch (err) {
console.warn('[archive][content] unarchive failed', err)
}
})()
return
}
@@ -618,16 +700,34 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
currentArticle.pubkey,
currentArticle.kind,
activeAccount,
relayPool
relayPool,
{
aCoord: (() => {
try {
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) return undefined
return `${30023}:${currentArticle.pubkey}:${dTag}`
} catch { return undefined }
})()
}
)
console.log('✅ Marked nostr article as read')
// Update archiveController immediately
try {
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
archiveController.mark(naddr)
}
} catch (err) {
console.warn('[archive][content] optimistic article mark failed', err)
}
} else if (selectedUrl) {
await createWebsiteReaction(
selectedUrl,
activeAccount,
relayPool
)
console.log('✅ Marked website as read')
archiveController.mark(selectedUrl)
}
} catch (error) {
console.error('Failed to mark as read:', error)
@@ -645,13 +745,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
)
}
if (loading) {
return (
<div className="reader" aria-busy="true">
<ContentSkeleton />
</div>
)
}
const highlightRgb = hexToRgb(highlightColor)
@@ -661,7 +754,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
{isTextContent && (
<ReadingProgressIndicator
progress={progressPercentage}
isComplete={isReadingComplete}
// Consider complete only at 95%+
isComplete={progressPercentage >= 95}
showPercentage={true}
isSidebarCollapsed={isSidebarCollapsed}
isHighlightsCollapsed={isHighlightsCollapsed}
@@ -671,16 +765,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
{/* Hidden markdown preview to convert markdown to HTML */}
{markdown && (
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
<div ref={markdownPreviewRef} key={`preview:${contentKey}`} style={{ display: 'none' }}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypePrism]}
components={{
img: ({ src, alt, ...props }) => (
img: ({ src, alt }) => (
<img
src={src}
alt={alt}
{...props}
/>
)
}}
@@ -691,126 +784,55 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
)}
<ReaderHeader
title={ytMeta?.title || title}
title={title}
image={image}
summary={summary}
published={published}
readingTimeText={isExternalVideo ? (videoDurationSec !== null ? formatDuration(videoDurationSec) : null) : (readingStats ? readingStats.text : null)}
readingTimeText={readingStats ? readingStats.text : null}
hasHighlights={hasHighlights}
highlightCount={relevantHighlights.length}
settings={settings}
highlights={relevantHighlights}
highlightVisibility={highlightVisibility}
onHighlightCountClick={onOpenHighlights}
/>
{isExternalVideo ? (
<>
<div className="reader-video">
<ReactPlayer
url={selectedUrl as string}
controls
width="100%"
height="auto"
style={{
width: '100%',
height: 'auto',
aspectRatio: '16/9'
}}
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
/>
</div>
{ytMeta?.description && (
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
{ytMeta.description}
</div>
)}
{ytMeta?.transcript && (
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
<h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3>
<div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}>
{ytMeta.transcript}
</div>
</div>
)}
<div className="article-menu-container">
<div className="article-menu-wrapper" ref={videoMenuRef}>
<button
className="article-menu-btn"
onClick={toggleVideoMenu}
title="More options"
>
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showVideoMenu && (
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Link</span>
</button>
<button className="article-menu-item" onClick={handleOpenVideoNative}>
<FontAwesomeIcon icon={faMobileAlt} />
<span>Open in Native App</span>
</button>
<button className="article-menu-item" onClick={handleCopyVideoUrl}>
<FontAwesomeIcon icon={faCopy} />
<span>Copy URL</span>
</button>
<button className="article-menu-item" onClick={handleShareVideoUrl}>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
</div>
)}
</div>
</div>
{activeAccount && (
<div className="mark-as-read-container">
<button
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
onClick={handleMarkAsRead}
disabled={isMarkedAsRead || isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
>
<FontAwesomeIcon
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
spin={isCheckingReadStatus}
/>
<span>
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Watched' : 'Mark as Watched'}
</span>
</button>
</div>
)}
</>
{isTextContent && articleText && (
<div style={{ padding: '0 0.75rem 0.5rem 0.75rem' }}>
<TTSControls text={articleText} defaultLang={navigator?.language} settings={settings} />
</div>
)}
{loading || !markdown && !html ? (
<div className="reader" aria-busy="true">
<ContentSkeleton />
</div>
) : markdown || html ? (
<>
{markdown ? (
renderedMarkdownHtml && finalHtml ? (
<div
ref={contentRef}
className="reader-markdown"
dangerouslySetInnerHTML={{ __html: finalHtml }}
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
<VideoEmbedProcessor
key={`content:${contentKey}`}
ref={contentRef}
html={finalHtml}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
className="reader-markdown"
/>
) : (
<div className="reader-markdown">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
</div>
<ContentSkeleton />
</div>
)
) : (
<div
ref={contentRef}
className="reader-html"
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
<VideoEmbedProcessor
key={`content:${contentKey}`}
ref={contentRef}
html={finalHtml || html || ''}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
className="reader-html"
/>
)}
{/* Article menu for external URLs */}
{!isNostrArticle && !isExternalVideo && selectedUrl && (
{!isNostrArticle && selectedUrl && (
<div className="article-menu-container">
<div className="article-menu-wrapper" ref={externalMenuRef}>
<button
@@ -837,13 +859,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<FontAwesomeIcon icon={faCopy} />
<span>Copy URL</span>
</button>
<button
className="article-menu-item"
onClick={handleOpenExternalUrl}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Original</span>
</button>
{/* Only show "Open Original" for actual external URLs, not nostr events */}
{!selectedUrl?.startsWith('nostr-event:') && (
<button
className="article-menu-item"
onClick={handleOpenExternalUrl}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Original</span>
</button>
)}
<button
className="article-menu-item"
onClick={handleSearchExternalUrl}
@@ -930,21 +955,22 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</div>
)}
{/* Mark as Read button */}
{/* Archive button */}
{activeAccount && (
<div className="mark-as-read-container">
<button
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
onClick={handleMarkAsRead}
disabled={isMarkedAsRead || isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
disabled={isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Archived' : 'Move to Archive'}
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
>
<FontAwesomeIcon
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
spin={isCheckingReadStatus}
/>
<span>
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Archived' : 'Move to Archive'}
</span>
</button>
</div>
@@ -957,11 +983,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</div>
)}
</>
) : (
<div className="reader empty">
<p>No readable content found for this URL.</p>
</div>
)}
) : null}
</div>
</>
)

View File

@@ -1,38 +0,0 @@
import React from 'react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, Helpers } from 'applesauce-core'
import { decode } from 'nostr-tools/nip19'
import { extractNprofilePubkeys } from '../utils/helpers'
const { getPubkeyFromDecodeResult } = Helpers
interface Props { content: string }
const ContentWithResolvedProfiles: React.FC<Props> = ({ content }) => {
const matches = extractNprofilePubkeys(content)
const decoded = matches
.map((m) => {
try { return decode(m) } catch { return undefined as undefined }
})
.filter((v): v is ReturnType<typeof decode> => Boolean(v))
const lookups = decoded
.map((res) => getPubkeyFromDecodeResult(res))
.filter((v): v is string => typeof v === 'string')
const profiles = lookups.map((pubkey) => ({ pubkey, profile: useEventModel(Models.ProfileModel, [pubkey]) }))
let rendered = content
matches.forEach((m, i) => {
const pk = getPubkeyFromDecodeResult(decoded[i])
const found = profiles.find((p) => p.pubkey === pk)
const name = found?.profile?.name || found?.profile?.display_name || found?.profile?.nip05 || `${pk?.slice(0,8)}...`
if (name) rendered = rendered.replace(m, `@${name}`)
})
return <div className="bookmark-content">{rendered}</div>
}
export default ContentWithResolvedProfiles

View File

@@ -18,6 +18,9 @@ import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useSettings } from '../hooks/useSettings'
import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService'
import { contactsController } from '../services/contactsController'
import { writingsController } from '../services/writingsController'
import { readingProgressController } from '../services/readingProgressController'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
@@ -94,6 +97,33 @@ const Debug: React.FC<DebugProps> = ({
const [tLoadHighlights, setTLoadHighlights] = useState<number | null>(null)
const [tFirstHighlight, setTFirstHighlight] = useState<number | null>(null)
// Writings loading state
const [isLoadingWritings, setIsLoadingWritings] = useState(false)
const [writingPosts, setWritingPosts] = useState<BlogPostPreview[]>([])
const [tLoadWritings, setTLoadWritings] = useState<number | null>(null)
const [tFirstWriting, setTFirstWriting] = useState<number | null>(null)
// Reading Progress loading state
const [isLoadingReadingProgress, setIsLoadingReadingProgress] = useState(false)
const [readingProgressEvents, setReadingProgressEvents] = useState<NostrEvent[]>([])
const [tLoadReadingProgress, setTLoadReadingProgress] = useState<number | null>(null)
const [tFirstReadingProgress, setTFirstReadingProgress] = useState<number | null>(null)
// Mark-as-read reactions loading state
const [isLoadingMarkAsRead, setIsLoadingMarkAsRead] = useState(false)
const [markAsReadReactions, setMarkAsReadReactions] = useState<NostrEvent[]>([])
const [tLoadMarkAsRead, setTLoadMarkAsRead] = useState<number | null>(null)
const [tFirstMarkAsRead, setTFirstMarkAsRead] = useState<number | null>(null)
// Relay list loading state
const [isLoadingRelayList, setIsLoadingRelayList] = useState(false)
const [relayListEvents, setRelayListEvents] = useState<NostrEvent[]>([])
const [tLoadRelayList, setTLoadRelayList] = useState<number | null>(null)
const [tFirstRelayList, setTFirstRelayList] = useState<number | null>(null)
// Deduplicated reading progress from controller
const [deduplicatedProgressMap, setDeduplicatedProgressMap] = useState<Map<string, number>>(new Map())
// Live timing state
const [liveTiming, setLiveTiming] = useState<{
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
@@ -101,6 +131,9 @@ const Debug: React.FC<DebugProps> = ({
loadBookmarks?: { startTime: number }
decryptBookmarks?: { startTime: number }
loadHighlights?: { startTime: number }
loadReadingProgress?: { startTime: number }
loadMarkAsRead?: { startTime: number }
loadRelayList?: { startTime: number }
}>({})
// Web of Trust state
@@ -302,10 +335,6 @@ const Debug: React.FC<DebugProps> = ({
// Subscribe to decrypt complete events for Debug UI display
const unsubscribeDecrypt = bookmarkController.onDecryptComplete((eventId, publicCount, privateCount) => {
console.log('[bunker] ✅ Auto-decrypted:', eventId.slice(0, 8), {
public: publicCount,
private: privateCount
})
setDecryptedEvents(prev => new Map(prev).set(eventId, {
public: publicCount,
private: privateCount
@@ -405,11 +434,7 @@ const Debug: React.FC<DebugProps> = ({
const elapsed = Math.round(performance.now() - start)
setTLoadHighlights(elapsed)
setLiveTiming(prev => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const { loadHighlights, ...rest } = prev
return rest
})
setLiveTiming(prev => ({ ...prev, loadHighlights: undefined }))
DebugBus.info('debug', `Loaded ${events.length} highlight events in ${elapsed}ms`)
} catch (err) {
@@ -538,6 +563,393 @@ const Debug: React.FC<DebugProps> = ({
}
}
const handleLoadMyWritings = async () => {
if (!relayPool || !activeAccount?.pubkey || !eventStore) {
DebugBus.warn('debug', 'Please log in to load your writings')
return
}
const start = performance.now()
setWritingPosts([])
setIsLoadingWritings(true)
setTLoadWritings(null)
setTFirstWriting(null)
DebugBus.info('debug', 'Loading my writings via writingsController...')
try {
let firstEventTime: number | null = null
const unsub = writingsController.onWritings((posts) => {
if (firstEventTime === null && posts.length > 0) {
firstEventTime = performance.now() - start
setTFirstWriting(Math.round(firstEventTime))
}
setWritingPosts(posts)
})
await writingsController.start({
relayPool,
eventStore,
pubkey: activeAccount.pubkey,
force: true
})
unsub()
const currentWritings = writingsController.getWritings()
setWritingPosts(currentWritings)
DebugBus.info('debug', `Loaded ${currentWritings.length} writings via controller`)
} finally {
setIsLoadingWritings(false)
const elapsed = Math.round(performance.now() - start)
setTLoadWritings(elapsed)
DebugBus.info('debug', `Loaded my writings in ${elapsed}ms`)
}
}
const handleLoadFriendsWritings = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load friends writings')
return
}
const start = performance.now()
setWritingPosts([])
setIsLoadingWritings(true)
setTLoadWritings(null)
setTFirstWriting(null)
DebugBus.info('debug', 'Loading friends writings...')
try {
// Get contacts first
await contactsController.start({ relayPool, pubkey: activeAccount.pubkey })
const friends = contactsController.getContacts()
const friendsArray = Array.from(friends)
DebugBus.info('debug', `Found ${friendsArray.length} friends`)
if (friendsArray.length === 0) {
DebugBus.warn('debug', 'No friends found to load writings from')
return
}
let firstEventTime: number | null = null
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const posts = await fetchBlogPostsFromAuthors(
relayPool,
friendsArray,
relayUrls,
(post) => {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstWriting(Math.round(firstEventTime))
}
setWritingPosts(prev => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${post.author}:${dTag}`
const exists = prev.find(p => {
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
return `${p.author}:${pDTag}` === key
})
if (exists) return prev
return [...prev, post].sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
},
100,
eventStore || undefined
)
setWritingPosts(posts)
DebugBus.info('debug', `Loaded ${posts.length} friend writings`)
} finally {
setIsLoadingWritings(false)
const elapsed = Math.round(performance.now() - start)
setTLoadWritings(elapsed)
DebugBus.info('debug', `Loaded friend writings in ${elapsed}ms`)
}
}
const handleLoadNostrverseWritings = async () => {
if (!relayPool) {
DebugBus.warn('debug', 'Relay pool not available')
return
}
const start = performance.now()
setWritingPosts([])
setIsLoadingWritings(true)
setTLoadWritings(null)
setTFirstWriting(null)
DebugBus.info('debug', 'Loading nostrverse writings (kind:30023)...')
try {
let firstEventTime: number | null = null
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const { queryEvents } = await import('../services/dataFetch')
const { Helpers } = await import('applesauce-core')
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
const uniqueEvents = new Map<string, NostrEvent>()
await queryEvents(relayPool, { kinds: [30023], limit: 50 }, {
relayUrls,
onEvent: (evt) => {
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${evt.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
if (!existing || evt.created_at > existing.created_at) {
uniqueEvents.set(key, evt)
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstWriting(Math.round(firstEventTime))
}
const posts = Array.from(uniqueEvents.values()).map(event => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
} as BlogPostPreview)).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setWritingPosts(posts)
}
}
})
const finalPosts = Array.from(uniqueEvents.values()).map(event => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
} as BlogPostPreview)).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setWritingPosts(finalPosts)
DebugBus.info('debug', `Loaded ${finalPosts.length} nostrverse writings`)
} finally {
setIsLoadingWritings(false)
const elapsed = Math.round(performance.now() - start)
setTLoadWritings(elapsed)
DebugBus.info('debug', `Loaded nostrverse writings in ${elapsed}ms`)
}
}
const handleClearWritings = () => {
setWritingPosts([])
setTLoadWritings(null)
setTFirstWriting(null)
}
const handleLoadReadingProgress = async () => {
if (!relayPool || !eventStore || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load reading progress')
return
}
try {
setIsLoadingReadingProgress(true)
setReadingProgressEvents([])
setTLoadReadingProgress(null)
setTFirstReadingProgress(null)
setDeduplicatedProgressMap(new Map())
DebugBus.info('debug', 'Loading reading progress events...')
const start = performance.now()
let firstEventTime: number | null = null
setLiveTiming(prev => ({ ...prev, loadReadingProgress: { startTime: start } }))
const { queryEvents } = await import('../services/dataFetch')
const { KINDS } = await import('../config/kinds')
// Load raw events for display
const rawEvents: NostrEvent[] = []
const rawQueryPromise = queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [activeAccount.pubkey] }, {
onEvent: (evt) => {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstReadingProgress(Math.round(firstEventTime))
}
rawEvents.push(evt)
setReadingProgressEvents([...rawEvents])
}
})
// Load deduplicated results via controller (includes articles and external URLs)
const unsubProgress = readingProgressController.onProgress((progressMap) => {
setDeduplicatedProgressMap(new Map(progressMap))
// Regression guard: ensure keys include both naddr and raw URL forms when present
try {
const keys = Array.from(progressMap.keys())
const sample = keys.slice(0, 5).join(', ')
DebugBus.info('debug', `Progress keys sample: ${sample}`)
} catch { /* ignore */ }
})
// Run both in parallel
await Promise.all([
rawQueryPromise,
readingProgressController.start({ relayPool, eventStore, pubkey: activeAccount.pubkey, force: true })
])
unsubProgress()
const elapsed = Math.round(performance.now() - start)
setTLoadReadingProgress(elapsed)
setLiveTiming(prev => ({ ...prev, loadReadingProgress: undefined }))
const finalMap = readingProgressController.getProgressMap()
DebugBus.info('debug', `Loaded ${rawEvents.length} raw events, deduplicated to ${finalMap.size} articles in ${elapsed}ms`)
} catch (err) {
console.error('Failed to load reading progress:', err)
DebugBus.error('debug', `Failed to load reading progress: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setIsLoadingReadingProgress(false)
}
}
const handleClearReadingProgress = () => {
setReadingProgressEvents([])
setTLoadReadingProgress(null)
setTFirstReadingProgress(null)
setDeduplicatedProgressMap(new Map())
DebugBus.info('debug', 'Cleared reading progress data')
}
const handleLoadMarkAsReadReactions = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load mark-as-read reactions')
return
}
try {
setIsLoadingMarkAsRead(true)
setMarkAsReadReactions([])
setTLoadMarkAsRead(null)
setTFirstMarkAsRead(null)
DebugBus.info('debug', 'Loading mark-as-read reactions...')
const start = performance.now()
let firstEventTime: number | null = null
setLiveTiming(prev => ({ ...prev, loadMarkAsRead: { startTime: start } }))
const { queryEvents } = await import('../services/dataFetch')
const { ARCHIVE_EMOJI } = await import('../services/reactionService')
// Load both kind:7 (reactions to events) and kind:17 (reactions to URLs)
const [kind7Events, kind17Events] = await Promise.all([
queryEvents(relayPool, { kinds: [7], authors: [activeAccount.pubkey] }, {
onEvent: (evt) => {
if (evt.content === ARCHIVE_EMOJI) {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstMarkAsRead(Math.round(firstEventTime))
}
setMarkAsReadReactions(prev => [...prev, evt])
}
}
}),
queryEvents(relayPool, { kinds: [17], authors: [activeAccount.pubkey] }, {
onEvent: (evt) => {
if (evt.content === ARCHIVE_EMOJI) {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstMarkAsRead(Math.round(firstEventTime))
}
setMarkAsReadReactions(prev => [...prev, evt])
}
}
})
])
const totalEvents = kind7Events.length + kind17Events.length
const elapsed = Math.round(performance.now() - start)
setTLoadMarkAsRead(elapsed)
setLiveTiming(prev => ({ ...prev, loadMarkAsRead: undefined }))
DebugBus.info('debug', `Loaded ${totalEvents} mark-as-read reactions in ${elapsed}ms`)
} catch (err) {
console.error('Failed to load mark-as-read reactions:', err)
DebugBus.error('debug', `Failed to load mark-as-read reactions: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setIsLoadingMarkAsRead(false)
}
}
const handleClearMarkAsRead = () => {
setMarkAsReadReactions([])
setTLoadMarkAsRead(null)
setTFirstMarkAsRead(null)
DebugBus.info('debug', 'Cleared mark-as-read reactions data')
}
const handleLoadRelayList = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load relay list')
return
}
try {
setIsLoadingRelayList(true)
setRelayListEvents([])
setTLoadRelayList(null)
setTFirstRelayList(null)
DebugBus.info('debug', 'Loading relay list (kind 10002)...')
const start = performance.now()
let firstEventTime: number | null = null
setLiveTiming(prev => ({ ...prev, loadRelayList: { startTime: start } }))
const { queryEvents } = await import('../services/dataFetch')
// Query for kind:10002 (relay list)
const events = await queryEvents(relayPool, {
kinds: [10002],
authors: [activeAccount.pubkey],
limit: 10
}, {
onEvent: (evt) => {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstRelayList(Math.round(firstEventTime))
}
setRelayListEvents(prev => [...prev, evt])
}
})
const elapsed = Math.round(performance.now() - start)
setTLoadRelayList(elapsed)
setLiveTiming(prev => ({ ...prev, loadRelayList: undefined }))
DebugBus.info('debug', `Loaded ${events.length} relay list events in ${elapsed}ms`)
// Log details about the events
events.forEach((event, index) => {
const relayCount = event.tags.filter(tag => tag[0] === 'r').length
DebugBus.info('debug', `Event ${index + 1}: ${relayCount} relays, created ${new Date(event.created_at * 1000).toISOString()}`)
})
} catch (err) {
console.error('Failed to load relay list:', err)
DebugBus.error('debug', `Failed to load relay list: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setIsLoadingRelayList(false)
}
}
const handleClearRelayList = () => {
setRelayListEvents([])
setTLoadRelayList(null)
setTFirstRelayList(null)
DebugBus.info('debug', 'Cleared relay list data')
}
const handleLoadFriendsList = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load friends list')
@@ -552,7 +964,6 @@ const Debug: React.FC<DebugProps> = ({
// Subscribe to controller updates to see streaming
const unsubscribe = contactsController.onContacts((contacts) => {
console.log('[debug] Received contacts update:', contacts.size)
setFriendsPubkeys(new Set(contacts))
})
@@ -1070,6 +1481,353 @@ const Debug: React.FC<DebugProps> = ({
)}
</div>
{/* Writings Loading Section */}
<div className="settings-section">
<h3 className="section-title">Writings Loading</h3>
<div className="mb-3 text-sm opacity-70">Quick load options:</div>
<div className="flex gap-2 mb-3 flex-wrap">
<button
className="btn btn-secondary text-sm"
onClick={handleLoadMyWritings}
disabled={isLoadingWritings || !relayPool || !activeAccount || !eventStore}
>
{isLoadingWritings ? (
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
) : (
'Load My Writings'
)}
</button>
<button
className="btn btn-secondary text-sm"
onClick={handleLoadFriendsWritings}
disabled={isLoadingWritings || !relayPool || !activeAccount}
>
{isLoadingWritings ? (
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
) : (
'Load Friends Writings'
)}
</button>
<button
className="btn btn-secondary text-sm"
onClick={handleLoadNostrverseWritings}
disabled={isLoadingWritings || !relayPool}
>
{isLoadingWritings ? (
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
) : (
'Load Nostrverse Writings'
)}
</button>
<button
className="btn btn-secondary text-sm ml-auto"
onClick={handleClearWritings}
disabled={writingPosts.length === 0}
>
Clear
</button>
</div>
<div className="mb-3 flex gap-2 flex-wrap">
<Stat label="total" value={tLoadWritings} />
<Stat label="first event" value={tFirstWriting} />
</div>
{writingPosts.length > 0 && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Loaded Writings ({writingPosts.length}):</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{writingPosts.map((post, idx) => {
const title = post.title
const summary = post.summary
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
return (
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
<div className="font-semibold mb-1">Writing #{idx + 1}</div>
<div className="opacity-70 mb-1">
<div>Author: {post.author.slice(0, 16)}...</div>
<div>Published: {post.published ? new Date(post.published * 1000).toLocaleString() : new Date(post.event.created_at * 1000).toLocaleString()}</div>
<div>d-tag: {dTag || '(empty)'}</div>
</div>
<div className="mt-1">
<div className="font-semibold text-[11px]">Title:</div>
<div>&quot;{title}&quot;</div>
</div>
{summary && (
<div className="mt-1 text-[11px] opacity-70">
<div>Summary: {summary.substring(0, 100)}{summary.length > 100 ? '...' : ''}</div>
</div>
)}
{post.image && (
<div className="mt-1 text-[11px] opacity-70">
<div>Image: {post.image.substring(0, 40)}...</div>
</div>
)}
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {post.event.id}</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Reading Progress Loading Section */}
<div className="settings-section">
<h3 className="section-title">Reading Progress Loading</h3>
<div className="text-sm opacity-70 mb-3">Test reading progress loading (kind: 39802) for the logged-in user</div>
<div className="flex gap-2 mb-3 items-center">
<button
className="btn btn-primary"
onClick={handleLoadReadingProgress}
disabled={isLoadingReadingProgress || !relayPool || !activeAccount}
>
{isLoadingReadingProgress ? (
<>
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
Loading...
</>
) : (
'Load Reading Progress'
)}
</button>
<button
className="btn btn-secondary ml-auto"
onClick={handleClearReadingProgress}
disabled={readingProgressEvents.length === 0}
>
Clear
</button>
</div>
<div className="mb-3 flex gap-2 flex-wrap">
<Stat label="total" value={tLoadReadingProgress} />
<Stat label="first event" value={tFirstReadingProgress} />
</div>
{readingProgressEvents.length > 0 && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Loaded Reading Progress ({readingProgressEvents.length}):</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{readingProgressEvents.map((evt, idx) => {
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1]
const aTag = evt.tags?.find((t: string[]) => t[0] === 'a')?.[1]
const content = evt.content || ''
return (
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
<div className="font-semibold mb-1">Reading Progress #{idx + 1}</div>
<div className="opacity-70 mb-1">
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
</div>
<div className="mt-1">
{dTag && <div>d-tag: {dTag}</div>}
{aTag && <div className="text-[11px] opacity-70">#a: {aTag}</div>}
{content && <div>Progress: {content}</div>}
</div>
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
</div>
)
})}
</div>
</div>
)}
{deduplicatedProgressMap.size > 0 && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Deduplicated Reading Progress ({deduplicatedProgressMap.size} articles):</div>
{/* Category breakdown */}
<div className="mb-3 p-2 bg-purple-50 dark:bg-purple-900/20 rounded border border-purple-200 dark:border-purple-700">
<div className="text-sm font-semibold mb-2">Breakdown by Category:</div>
<div className="space-y-1">
{(() => {
let unopened = 0, started = 0, reading = 0, completed = 0
for (const progress of deduplicatedProgressMap.values()) {
if (progress === 0) unopened++
else if (progress > 0 && progress <= 0.10) started++
else if (progress > 0.10 && progress <= 0.94) reading++
else if (progress >= 0.95) completed++
}
return (
<>
<div className="flex justify-between text-xs">
<span>Unopened (0%):</span>
<span className="font-semibold">{unopened}</span>
</div>
<div className="flex justify-between text-xs">
<span>Started (0% &lt; progress 10%):</span>
<span className="font-semibold">{started}</span>
</div>
<div className="flex justify-between text-xs bg-green-100 dark:bg-green-900/30 px-1 py-0.5 rounded">
<span>Reading (10% &lt; progress 94%) :</span>
<span className="font-semibold text-green-700 dark:text-green-400">{reading}</span>
</div>
<div className="flex justify-between text-xs">
<span>Completed ( 95%):</span>
<span className="font-semibold">{completed}</span>
</div>
</>
)
})()}
</div>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{Array.from(deduplicatedProgressMap.entries()).map(([articleId, progress], idx) => {
return (
<div key={idx} className="font-mono text-xs p-2 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-700">
<div className="font-semibold mb-1">Article #{idx + 1}</div>
<div className="mt-1">
<div className="break-all">ID: {articleId}</div>
<div className="mt-1">
<div className="text-[11px] opacity-70">Progress: {(progress * 100).toFixed(1)}%</div>
<div className="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-1.5 mt-1 overflow-hidden">
<div
className="bg-blue-600 h-full"
style={{ width: `${progress * 100}%` }}
/>
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Mark-as-read Reactions Loading Section */}
<div className="settings-section">
<h3 className="section-title">Mark-as-read Reactions Loading</h3>
<div className="text-sm opacity-70 mb-3">Test loading mark-as-read reactions (kind: 7 and 17) with the ARCHIVE_EMOJI for the logged-in user</div>
<div className="flex gap-2 mb-3 items-center">
<button
className="btn btn-primary"
onClick={handleLoadMarkAsReadReactions}
disabled={isLoadingMarkAsRead || !relayPool || !activeAccount}
>
{isLoadingMarkAsRead ? (
<>
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
Loading...
</>
) : (
'Load Mark-as-read Reactions'
)}
</button>
<button
className="btn btn-secondary ml-auto"
onClick={handleClearMarkAsRead}
disabled={markAsReadReactions.length === 0}
>
Clear
</button>
</div>
<div className="mb-3 flex gap-2 flex-wrap">
<Stat label="total" value={tLoadMarkAsRead} />
<Stat label="first event" value={tFirstMarkAsRead} />
</div>
{markAsReadReactions.length > 0 && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Loaded Mark-as-read Reactions ({markAsReadReactions.length}):</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{markAsReadReactions.map((evt, idx) => {
const eTag = evt.tags?.find((t: string[]) => t[0] === 'e')?.[1]
const rTag = evt.tags?.find((t: string[]) => t[0] === 'r')?.[1]
const pTag = evt.tags?.find((t: string[]) => t[0] === 'p')?.[1]
return (
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
<div className="font-semibold mb-1">Mark-as-read Reaction #{idx + 1}</div>
<div className="opacity-70 mb-1">
<div>Kind: {evt.kind}</div>
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
</div>
<div className="mt-1">
<div>Emoji: {evt.content}</div>
{eTag && <div className="text-[11px] opacity-70">#e: {eTag.slice(0, 16)}...</div>}
{rTag && <div className="text-[11px] opacity-70">#r: {rTag.length > 60 ? rTag.substring(0, 60) + '...' : rTag}</div>}
{pTag && <div className="text-[11px] opacity-70">#p: {pTag.slice(0, 16)}...</div>}
</div>
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Relay List Loading Section */}
<div className="settings-section">
<h3 className="section-title">Relay List Loading (kind 10002)</h3>
<div className="text-sm opacity-70 mb-3">Load your relay list to debug dynamic relay integration:</div>
<div className="flex gap-2 mb-3 items-center">
<button
className="btn btn-primary"
onClick={handleLoadRelayList}
disabled={isLoadingRelayList || !relayPool || !activeAccount}
>
{isLoadingRelayList ? (
<>
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
Loading...
</>
) : (
'Load Relay List'
)}
</button>
<button
className="btn btn-secondary ml-auto"
onClick={handleClearRelayList}
disabled={relayListEvents.length === 0}
>
Clear
</button>
</div>
<div className="flex gap-4 mb-3 text-sm">
<Stat label="total" value={tLoadRelayList} />
<Stat label="first event" value={tFirstRelayList} />
</div>
{relayListEvents.length > 0 && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Loaded Relay List Events ({relayListEvents.length}):</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{relayListEvents.map((evt, idx) => {
const relayTags = evt.tags?.filter((t: string[]) => t[0] === 'r') || []
return (
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
<div className="font-semibold mb-1">Relay List Event #{idx + 1}</div>
<div className="opacity-70 mb-1">
<div>Kind: {evt.kind}</div>
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
<div>Relays: {relayTags.length}</div>
</div>
<div className="mt-1">
<div className="text-[11px] opacity-70 mb-1">Relay URLs:</div>
{relayTags.map((tag, tagIdx) => (
<div key={tagIdx} className="text-[10px] opacity-60 break-all">
{tag[1]} {tag[2] ? `(${tag[2]})` : ''}
</div>
))}
</div>
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Web of Trust Section */}
<div className="settings-section">
<h3 className="section-title">Web of Trust</h3>

View File

@@ -0,0 +1 @@

View File

@@ -1,34 +1,40 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
import { faPersonHiking, faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore, Helpers } from 'applesauce-core'
import { nip19, NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
import { fetchContacts } from '../services/contactService'
// Contacts are managed via controller subscription
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import { fetchHighlightsFromAuthors } from '../services/highlightService'
import { fetchProfiles } from '../services/profileService'
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
import { nostrverseHighlightsController } from '../services/nostrverseHighlightsController'
import { highlightsController } from '../services/highlightsController'
import { Highlight } from '../types/highlights'
import { UserSettings } from '../services/settingsService'
import BlogPostCard from './BlogPostCard'
import { HighlightItem } from './HighlightItem'
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
import { getCachedPosts, setCachedPosts, getCachedHighlights, setCachedHighlights } from '../services/exploreCache'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { classifyHighlights } from '../utils/highlightClassification'
import { HighlightVisibility } from './HighlightsPanel'
import { KINDS } from '../config/kinds'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { useStoreTimeline } from '../hooks/useStoreTimeline'
// import { KINDS } from '../config/kinds'
// import { eventToHighlight } from '../services/highlightEventProcessor'
// import { useStoreTimeline } from '../hooks/useStoreTimeline'
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
import { writingsController } from '../services/writingsController'
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
import { readingProgressController } from '../services/readingProgressController'
import { contactsController } from '../services/contactsController'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
// Accessors from Helpers (currently unused here)
// const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
interface ExploreProps {
relayPool: RelayPool
@@ -48,42 +54,242 @@ 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 [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
const [hasLoadedMine, setHasLoadedMine] = useState(false)
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
const hasHydratedRef = useRef(false)
// Get myHighlights directly from controller
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false)
const [/* myHighlights */, setMyHighlights] = useState<Highlight[]>([])
// Remove unused loading state to avoid warnings
// Reading progress state (naddr -> progress 0-1)
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
// Load cached content from event store (instant display)
const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
// const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}), [])
// const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
// event,
// title: getArticleTitle(event) || 'Untitled',
// summary: getArticleSummary(event),
// image: getArticleImage(event),
// published: getArticlePublished(event),
// author: event.pubkey
// }), [])
const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
// const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
// Visibility filters (defaults from settings)
const [visibility, setVisibility] = useState<HighlightVisibility>({
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
friends: settings?.defaultExploreScopeFriends ?? true,
mine: settings?.defaultExploreScopeMine ?? false
// Visibility filters - load from localStorage first, fallback to settings
const [visibility, setVisibility] = useState<HighlightVisibility>(() => {
// Try to load from localStorage first
try {
const saved = localStorage.getItem('exploreScopeVisibility')
if (saved) {
const parsed = JSON.parse(saved)
// Validate that at least one scope is enabled
if (parsed.nostrverse || parsed.friends || parsed.mine) {
return parsed
}
}
} catch (err) {
console.warn('Failed to load explore scope from localStorage:', err)
}
// Fallback to settings or defaults
return {
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
friends: settings?.defaultExploreScopeFriends ?? true,
mine: settings?.defaultExploreScopeMine ?? false
}
})
// Ensure at least one scope remains active
const toggleScope = useCallback((key: 'nostrverse' | 'friends' | 'mine') => {
setVisibility(prev => {
const next = { ...prev, [key]: !prev[key] }
if (!next.nostrverse && !next.friends && !next.mine) {
return prev // ignore toggle that would disable all scopes
}
// Persist to localStorage
try {
localStorage.setItem('exploreScopeVisibility', JSON.stringify(next))
} catch (err) {
console.warn('Failed to save explore scope to localStorage:', err)
}
return next
})
}, [])
// Subscribe to highlights controller
useEffect(() => {
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading)
return () => {
unsubHighlights()
unsubLoading()
}
}, [])
// Subscribe to contacts stream and mirror into local state
useEffect(() => {
const unsubscribe = contactsController.onContacts((contacts) => {
setFollowedPubkeys(new Set(contacts))
})
return () => unsubscribe()
}, [])
// Ensure contacts controller is started for the active account (non-blocking)
useEffect(() => {
if (relayPool && activeAccount?.pubkey) {
contactsController.start({ relayPool, pubkey: activeAccount.pubkey }).catch(() => {})
}
}, [relayPool, activeAccount?.pubkey])
// Subscribe to nostrverse highlights controller for global stream
useEffect(() => {
const apply = (incoming: Highlight[]) => {
setHighlights(prev => {
const byId = new Map(prev.map(h => [h.id, h]))
for (const h of incoming) byId.set(h.id, h)
return Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
})
}
// seed immediately
apply(nostrverseHighlightsController.getHighlights())
const unsub = nostrverseHighlightsController.onHighlights(apply)
return () => unsub()
}, [])
// Subscribe to nostrverse writings controller for global stream
useEffect(() => {
const apply = (incoming: BlogPostPreview[]) => {
setBlogPosts(prev => {
const byKey = new Map<string, BlogPostPreview>()
for (const p of prev) {
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${p.author}:${dTag}`
byKey.set(key, p)
}
for (const p of incoming) {
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${p.author}:${dTag}`
const existing = byKey.get(key)
if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p)
}
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
}
apply(nostrverseWritingsController.getWritings())
const unsub = nostrverseWritingsController.onWritings(apply)
return () => unsub()
}, [])
// Subscribe to writings controller for "mine" posts and seed immediately
useEffect(() => {
// Seed from controller's current state
const seed = writingsController.getWritings()
if (seed.length > 0) {
setBlogPosts(prev => {
const merged = dedupeWritingsByReplaceable([...prev, ...seed])
return merged.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
}
// Stream updates
const unsub = writingsController.onWritings((posts) => {
setBlogPosts(prev => {
const merged = dedupeWritingsByReplaceable([...prev, ...posts])
return merged.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
})
return () => unsub()
}, [])
// Subscribe to reading progress controller
useEffect(() => {
// Get initial state immediately
const initialMap = readingProgressController.getProgressMap()
setReadingProgressMap(initialMap)
// Subscribe to updates
const unsubProgress = readingProgressController.onProgress((newMap) => {
setReadingProgressMap(newMap)
})
return () => {
unsubProgress()
}
}, [])
// Load reading progress data when logged in
useEffect(() => {
if (!activeAccount?.pubkey) {
return
}
readingProgressController.start({
relayPool,
eventStore,
pubkey: activeAccount.pubkey,
force: refreshTrigger > 0
})
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
// Update visibility when settings/login state changes
useEffect(() => {
// Check if user has a saved preference
const hasSavedPreference = (() => {
try {
return localStorage.getItem('exploreScopeVisibility') !== null
} catch {
return false
}
})()
// Only reset to defaults if no saved preference exists
if (hasSavedPreference) {
return
}
if (!activeAccount) {
// When logged out, show nostrverse by default
const defaultVisibility = { nostrverse: true, friends: false, mine: false }
setVisibility(defaultVisibility)
try {
localStorage.setItem('exploreScopeVisibility', JSON.stringify(defaultVisibility))
} catch (err) {
console.warn('Failed to save explore scope to localStorage:', err)
}
setHasLoadedNostrverse(true) // logged out path loads nostrverse immediately
setHasLoadedNostrverseHighlights(true)
} else {
// When logged in, use settings defaults immediately
const defaultVisibility = {
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
friends: settings?.defaultExploreScopeFriends ?? true,
mine: settings?.defaultExploreScopeMine ?? false
}
setVisibility(defaultVisibility)
try {
localStorage.setItem('exploreScopeVisibility', JSON.stringify(defaultVisibility))
} catch (err) {
console.warn('Failed to save explore scope to localStorage:', err)
}
setHasLoadedNostrverse(false)
setHasLoadedNostrverseHighlights(false)
}
}, [activeAccount, settings?.defaultExploreScopeNostrverse, settings?.defaultExploreScopeFriends, settings?.defaultExploreScopeMine])
// Update local state when prop changes
useEffect(() => {
if (propActiveTab) {
@@ -91,211 +297,164 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}
}, [propActiveTab])
useEffect(() => {
const loadData = async () => {
if (!activeAccount) {
setLoading(false)
return
}
// Load initial data and refresh on triggers
const loadData = useCallback(() => {
if (!relayPool) return
try {
// show spinner but keep existing data
setLoading(true)
// Seed from cache for instant UI
if (activeAccount) {
const cachedPosts = getCachedPosts(activeAccount.pubkey)
if (cachedPosts && cachedPosts.length > 0) setBlogPosts(cachedPosts)
const cached = getCachedHighlights(activeAccount.pubkey)
if (cached && cached.length > 0) setHighlights(cached)
}
// Seed from in-memory cache if available to avoid empty flash
const memoryCachedPosts = getCachedPosts(activeAccount.pubkey)
if (memoryCachedPosts && memoryCachedPosts.length > 0) {
setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev)
}
const memoryCachedHighlights = getCachedHighlights(activeAccount.pubkey)
if (memoryCachedHighlights && memoryCachedHighlights.length > 0) {
setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev)
}
// Seed with cached content from event store (instant display)
if (cachedHighlights.length > 0 || myHighlights.length > 0) {
const merged = dedupeHighlightsById([...cachedHighlights, ...myHighlights])
setHighlights(prev => {
const all = dedupeHighlightsById([...prev, ...merged])
return all.sort((a, b) => b.created_at - a.created_at)
})
}
// Seed with cached writings from event store
if (cachedWritings.length > 0) {
setBlogPosts(prev => {
const all = dedupeWritingsByReplaceable([...prev, ...cachedWritings])
return all.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
}
setLoading(true)
// Fetch the user's contacts (friends)
const contacts = await fetchContacts(
try {
// Prepare parallel fetches
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
// Nostrverse writings: subscribe-style via onPost; hydrate on first post
if (!activeAccount || (activeAccount && visibility.nostrverse)) {
fetchNostrverseBlogPosts(
relayPool,
activeAccount.pubkey,
(partial) => {
// Store followed pubkeys for highlight classification
setFollowedPubkeys(partial)
// When local contacts are available, kick off early fetch
if (partial.size > 0) {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const partialArray = Array.from(partial)
// Fetch blog posts
fetchBlogPostsFromAuthors(
relayPool,
partialArray,
relayUrls,
(post) => {
setBlogPosts((prev) => {
// Deduplicate by author:d-tag (replaceable event key)
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${post.author}:${dTag}`
const existingIndex = prev.findIndex(p => {
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
return `${p.author}:${pDTag}` === key
})
// If exists, only replace if this one is newer
if (existingIndex >= 0) {
const existing = prev[existingIndex]
if (post.event.created_at <= existing.event.created_at) {
return prev // Keep existing (newer or same)
}
// Replace with newer version
const next = [...prev]
next[existingIndex] = post
return next.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
}
// New post, add it
const next = [...prev, post]
return next.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
}
).then((all) => {
setBlogPosts((prev) => {
// Deduplicate by author:d-tag (replaceable event key)
const byKey = new Map<string, BlogPostPreview>()
// Add existing posts
for (const p of prev) {
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${p.author}:${dTag}`
byKey.set(key, p)
}
// Merge in new posts (keeping newer versions)
for (const post of all) {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${post.author}:${dTag}`
const existing = byKey.get(key)
if (!existing || post.event.created_at > existing.event.created_at) {
byKey.set(key, post)
}
}
const merged = Array.from(byKey.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setCachedPosts(activeAccount.pubkey, merged)
return merged
})
})
// Fetch highlights
fetchHighlightsFromAuthors(
relayPool,
partialArray,
(highlight) => {
setHighlights((prev) => {
const exists = prev.some(h => h.id === highlight.id)
if (exists) return prev
const next = [...prev, highlight]
return next.sort((a, b) => b.created_at - a.created_at)
})
setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
}
).then((all) => {
setHighlights((prev) => {
const byId = new Map(prev.map(h => [h.id, h]))
for (const highlight of all) byId.set(highlight.id, highlight)
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
setCachedHighlights(activeAccount.pubkey, merged)
return merged
})
})
}
relayUrls,
50,
eventStore || undefined,
(post) => {
setBlogPosts(prev => {
const merged = dedupeWritingsByReplaceable([...prev, post])
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
}
)
// Always proceed to load nostrverse content even if no contacts
// (removed blocking error for empty contacts)
// Store final followed pubkeys
setFollowedPubkeys(contacts)
// Fetch both friends content and nostrverse content in parallel
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const contactsArray = Array.from(contacts)
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
fetchHighlightsFromAuthors(relayPool, contactsArray),
fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined),
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
])
// Merge and deduplicate all posts
const allPosts = [...friendsPosts, ...nostrversePosts]
const uniquePosts = dedupeWritingsByReplaceable(allPosts).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
// Merge and deduplicate all highlights (mine from controller + friends + nostrverse)
const allHighlights = [...myHighlights, ...friendsHighlights, ...nostriverseHighlights]
const uniqueHighlights = dedupeHighlightsById(allHighlights).sort((a, b) => b.created_at - a.created_at)
// Fetch profiles for all blog post authors to cache them
if (uniquePosts.length > 0) {
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author)))
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
console.error('Failed to fetch author profiles:', err)
})
}
// No blocking errors - let empty states handle messaging
setBlogPosts(uniquePosts)
setCachedPosts(activeAccount.pubkey, uniquePosts)
setHighlights(uniqueHighlights)
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
).then((nostrversePosts) => {
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
}).catch(() => {})
}
} catch (err) {
console.error('Failed to load data:', err)
// No blocking error - user can pull-to-refresh
} finally {
setLoading(false)
// loading is already turned off after seeding
}
}
}, [relayPool, activeAccount, eventStore, visibility.nostrverse])
useEffect(() => {
loadData()
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings, myHighlights, cachedHighlights, cachedWritings])
}, [loadData, refreshTrigger])
// Kick off friends fetches reactively when contacts arrive
useEffect(() => {
if (!relayPool) return
if (followedPubkeys.size === 0) return
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const contactsArray = Array.from(followedPubkeys)
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls, (post) => {
setBlogPosts(prev => {
const merged = dedupeWritingsByReplaceable([...prev, post])
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
// Pre-cache profiles in background
const authorPubkeys = Array.from(new Set(merged.map(p => p.author)))
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {})
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
}, 100, eventStore).then((friendsPosts) => {
setBlogPosts(prev => {
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
}).catch(() => {})
fetchHighlightsFromAuthors(relayPool, contactsArray, (highlight) => {
setHighlights(prev => {
const merged = dedupeHighlightsById([...prev, highlight])
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
return merged.sort((a, b) => b.created_at - a.created_at)
})
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
}, eventStore || undefined).then((friendsHighlights) => {
setHighlights(prev => {
const merged = dedupeHighlightsById([...prev, ...friendsHighlights])
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
return merged.sort((a, b) => b.created_at - a.created_at)
})
}).catch(() => {})
}, [relayPool, followedPubkeys, eventStore, settings, activeAccount])
// Lazy-load nostrverse writings when user toggles it on (logged in)
useEffect(() => {
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverse) return
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
setHasLoadedNostrverse(true)
fetchNostrverseBlogPosts(
relayPool,
relayUrls,
50,
eventStore || undefined,
(post) => {
setBlogPosts(prev => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${post.author}:${dTag}`
const existingIndex = prev.findIndex(p => {
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
return `${p.author}:${pDTag}` === key
})
if (existingIndex >= 0) {
const existing = prev[existingIndex]
if (post.event.created_at <= existing.event.created_at) return prev
const next = [...prev]
next[existingIndex] = post
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
}
const next = [...prev, post]
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
}
).then((finalPosts) => {
// Ensure final deduped list
setBlogPosts(prev => {
const byKey = new Map<string, BlogPostPreview>()
for (const p of [...prev, ...finalPosts]) {
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${p.author}:${dTag}`
const existing = byKey.get(key)
if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p)
}
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
}).catch(() => {})
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
.then((nostriverseHighlights) => {
setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at))
}).catch(() => {})
}, [activeAccount, relayPool, visibility.nostrverse, hasLoadedNostrverse, eventStore])
// Lazy-load nostrverse highlights when user toggles it on (logged in)
useEffect(() => {
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverseHighlights) return
setHasLoadedNostrverseHighlights(true)
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
.then((hl) => {
if (hl && hl.length > 0) {
setHighlights(prev => dedupeHighlightsById([...prev, ...hl]).sort((a, b) => b.created_at - a.created_at))
}
})
.catch(() => {})
}, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverseHighlights])
// Lazy-load my writings when user toggles "mine" on (logged in)
// No direct fetch here; writingsController streams my posts centrally
useEffect(() => {
if (!activeAccount || !visibility.mine || hasLoadedMine) return
setHasLoadedMine(true)
}, [visibility.mine, activeAccount, hasLoadedMine])
// Pull-to-refresh
const { isRefreshing, pullPosition } = usePullToRefresh({
@@ -333,15 +492,31 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
})
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
// Dedupe and sort posts once for rendering
const uniqueSortedPosts = useMemo(() => {
const unique = dedupeWritingsByReplaceable(blogPosts)
return unique.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
}, [blogPosts])
// Filter blog posts by future dates and visibility, and add level classification
const filteredBlogPosts = useMemo(() => {
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
return blogPosts
return uniqueSortedPosts
.filter(post => {
// Filter out future dates
const publishedTime = post.published || post.event.created_at
if (publishedTime > maxFutureTime) return false
// Hide bot authors by profile display name if setting enabled
if (settings?.hideBotArticlesByName !== false) {
// Profile resolution and filtering is handled in BlogPostCard via ProfileModel
// Keep list intact here; individual cards will render null if author is a bot
}
// Apply visibility filters
const isMine = activeAccount && post.author === activeAccount.pubkey
const isFriend = followedPubkeys.has(post.author)
@@ -360,7 +535,29 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
return { ...post, level }
})
}, [blogPosts, activeAccount, followedPubkeys, visibility])
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility, settings?.hideBotArticlesByName])
// Helper to get reading progress for a post
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) {
return undefined
}
try {
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
const progress = readingProgressMap.get(naddr)
return progress
} catch (err) {
console.error('[progress] ❌ Error encoding naddr:', err)
return undefined
}
}, [readingProgressMap])
const renderTabContent = () => {
switch (activeTab) {
@@ -375,8 +572,10 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
)
}
return filteredBlogPosts.length === 0 ? (
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))}
</div>
) : (
<div className="explore-grid">
@@ -386,6 +585,8 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
post={post}
href={getPostUrl(post)}
level={post.level}
readingProgress={getReadingProgress(post)}
hideBotByName={settings?.hideBotArticlesByName !== false}
/>
))}
</div>
@@ -403,7 +604,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}
return classifiedHighlights.length === 0 ? (
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<span>No highlights to show for the selected scope.</span>
</div>
) : (
<div className="explore-grid">
@@ -422,9 +623,9 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}
}
// Show content progressively - no blocking error screens
// Show skeletons while first load in this session
const hasData = highlights.length > 0 || blogPosts.length > 0
const showSkeletons = (loading || myHighlightsLoading) && !hasData
const showSkeletons = loading && !hasData
return (
<div className="explore-container">
@@ -434,7 +635,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/>
<div className="explore-header">
<h1>
<FontAwesomeIcon icon={faNewspaper} />
<FontAwesomeIcon icon={faPersonHiking} />
Explore
</h1>
@@ -451,7 +652,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/>
<IconButton
icon={faNetworkWired}
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
onClick={() => toggleScope('nostrverse')}
title="Toggle nostrverse content"
ariaLabel="Toggle nostrverse content"
variant="ghost"
@@ -462,7 +663,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/>
<IconButton
icon={faUserGroup}
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
onClick={() => toggleScope('friends')}
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
ariaLabel="Toggle friends content"
variant="ghost"
@@ -474,7 +675,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/>
<IconButton
icon={faUser}
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
onClick={() => toggleScope('mine')}
title={activeAccount ? "Toggle my content" : "Login to see your content"}
ariaLabel="Toggle my content"
variant="ghost"
@@ -506,7 +707,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
</div>
</div>
<div key={activeTab}>
<div>
{renderTabContent()}
</div>
</div>

View File

@@ -45,7 +45,7 @@ export const HighlightButton = React.forwardRef<HighlightButtonRef, HighlightBut
className="highlight-fab"
style={{
position: 'fixed',
bottom: '32px',
bottom: '80px',
right: '32px',
zIndex: 1000,
width: '56px',

View File

@@ -5,6 +5,7 @@ import { Models } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { fetchArticleTitle } from '../services/articleTitleResolver'
import { Highlight } from '../types/highlights'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
interface HighlightCitationProps {
highlight: Highlight
@@ -27,7 +28,6 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
// Fallback: extract directly from p tag
const pTag = highlight.tags.find(t => t[0] === 'p')
if (pTag && pTag[1]) {
console.log('📝 Found author from p tag:', pTag[1])
return pTag[1]
}
@@ -45,6 +45,12 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
try {
if (!highlight.eventReference) return
// Skip if it's a raw event ID (hex string without colons)
// Raw event IDs cannot be decoded to nadrs without additional context
if (!highlight.eventReference.includes(':') && !highlight.eventReference.startsWith('naddr')) {
return
}
// Convert eventReference to naddr if needed
let naddr: string
if (highlight.eventReference.includes(':')) {
@@ -74,7 +80,8 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
loadTitle()
}, [highlight.eventReference, relayPool])
const authorName = authorProfile?.name || authorProfile?.display_name
// Use centralized profile display name utility
const authorName = authorPubkey ? getProfileDisplayName(authorProfile, authorPubkey) : undefined
// For nostr-native content with article reference
if (highlight.eventReference && (authorName || articleTitle)) {

View File

@@ -7,9 +7,9 @@ import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { Hooks } from 'applesauce-react'
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
import { RELAYS } from '../config/relays'
import { areAllRelaysLocal } from '../utils/helpers'
import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService'
import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers'
import { getActiveRelayUrls } from '../services/relayManager'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
import { createDeletionRequest } from '../services/deletionService'
@@ -17,6 +17,8 @@ import { getNostrUrl } from '../config/nostrGateways'
import CompactButton from './CompactButton'
import { HighlightCitation } from './HighlightCitation'
import { useNavigate } from 'react-router-dom'
import NostrMentionLink from './NostrMentionLink'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
// Helper to detect if a URL is an image
const isImageUrl = (url: string): boolean => {
@@ -29,99 +31,6 @@ const isImageUrl = (url: string): boolean => {
}
}
// Helper to render a nostr identifier
const renderNostrId = (nostrUri: string, index: number): React.ReactElement => {
try {
// Remove nostr: prefix
const identifier = nostrUri.replace(/^nostr:/, '')
const decoded = nip19.decode(identifier)
switch (decoded.type) {
case 'npub': {
const pubkey = decoded.data
return (
<a
key={index}
href={`/p/${nip19.npubEncode(pubkey)}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
@{pubkey.slice(0, 8)}...
</a>
)
}
case 'nprofile': {
const { pubkey } = decoded.data
const npub = nip19.npubEncode(pubkey)
return (
<a
key={index}
href={`/p/${npub}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
@{pubkey.slice(0, 8)}...
</a>
)
}
case 'naddr': {
const { kind, pubkey, identifier } = decoded.data
// Check if it's a blog post (kind:30023)
if (kind === 30023) {
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
return (
<a
key={index}
href={`/a/${naddr}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
{identifier || 'Article'}
</a>
)
}
// For other kinds, show shortened identifier
return (
<span key={index} className="highlight-comment-nostr-id">
nostr:{identifier.slice(0, 12)}...
</span>
)
}
case 'note': {
const eventId = decoded.data
return (
<span key={index} className="highlight-comment-nostr-id">
note:{eventId.slice(0, 12)}...
</span>
)
}
case 'nevent': {
const { id } = decoded.data
return (
<span key={index} className="highlight-comment-nostr-id">
event:{id.slice(0, 12)}...
</span>
)
}
default:
// Fallback for unrecognized types
return (
<span key={index} className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
} catch (error) {
// If decoding fails, show shortened identifier
const identifier = nostrUri.replace(/^nostr:/, '')
return (
<span key={index} className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
}
// Component to render comment with links, inline images, and nostr identifiers
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
// Pattern to match both http(s) URLs and nostr: URIs
@@ -131,9 +40,15 @@ const CommentContent: React.FC<{ text: string }> = ({ text }) => {
return (
<>
{parts.map((part, index) => {
// Handle nostr: URIs
// Handle nostr: URIs - now with profile resolution
if (part.startsWith('nostr:')) {
return renderNostrId(part, index)
return (
<NostrMentionLink
key={index}
nostrUri={part}
onClick={(e) => e.stopPropagation()}
/>
)
}
// Handle http(s) URLs
@@ -200,7 +115,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const itemRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
@@ -214,17 +128,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
// Get display name for the user
const getUserDisplayName = () => {
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
return getProfileDisplayName(profile, highlight.pubkey)
}
// Update offline indicator when highlight prop changes
useEffect(() => {
if (highlight.isOfflineCreated && !isSyncing) {
setShowOfflineIndicator(true)
}
}, [highlight.isOfflineCreated, isSyncing])
// Listen to sync state changes
useEffect(() => {
@@ -233,13 +139,11 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
setIsSyncing(syncingState)
// When sync completes successfully, update highlight to show all relays
if (!syncingState) {
setShowOfflineIndicator(false)
// Update the highlight with all relays after successful sync
if (onHighlightUpdate && highlight.isLocalOnly) {
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
const updatedHighlight = {
...highlight,
publishedRelays: RELAYS,
publishedRelays: getActiveRelayUrls(relayPool),
isLocalOnly: false,
isOfflineCreated: false
}
@@ -250,7 +154,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
})
return unsubscribe
}, [highlight, onHighlightUpdate])
}, [highlight, onHighlightUpdate, relayPool])
useEffect(() => {
if (isSelected && itemRef.current) {
@@ -298,19 +202,31 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
pubkey,
identifier
})
navigate(`/a/${naddr}`)
// Pass highlight ID in navigation state to trigger scroll
navigate(`/a/${naddr}`, {
state: {
highlightId: highlight.id,
openHighlights: true
}
})
}
}
} else if (highlight.urlReference) {
// Navigate to external URL
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`)
// Navigate to external URL with highlight ID to trigger scroll
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
state: {
highlightId: highlight.id,
openHighlights: true
}
})
}
}
const getHighlightLinks = () => {
// Encode the highlight event itself (kind 9802) as a nevent
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayHints = activeRelays.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
@@ -346,13 +262,11 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
// Publish to all configured relays - let the relay pool handle connection state
const targetRelays = RELAYS
const targetRelays = getActiveRelayUrls(relayPool)
console.log('📡 Rebroadcasting highlight to', targetRelays.length, 'relay(s):', targetRelays)
await relayPool.publish(targetRelays, event)
console.log('✅ Rebroadcast successful!')
// Update the highlight with new relay info
const isLocalOnly = areAllRelaysLocal(targetRelays)
@@ -368,9 +282,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
onHighlightUpdate(updatedHighlight)
}
// Update local state
setShowOfflineIndicator(false)
} catch (error) {
console.error('❌ Failed to rebroadcast:', error)
} finally {
@@ -389,8 +300,37 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
}
// Always show relay list, use plane icon for local-only
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
// Check if this highlight was only published to local relays
let isLocalOnly = highlight.isLocalOnly
const publishedRelays = highlight.publishedRelays || []
// Fallback 1: Check if this highlight was marked for offline sync (flight mode)
if (isLocalOnly === undefined) {
if (isEventOfflineCreated(highlight.id)) {
isLocalOnly = true
}
}
// Fallback 2: If publishedRelays only contains local relays, it's local-only
if (isLocalOnly === undefined && publishedRelays.length > 0) {
const hasOnlyLocalRelays = publishedRelays.every(url => isLocalRelay(url))
const hasRemoteRelays = publishedRelays.some(url => !isLocalRelay(url))
if (hasOnlyLocalRelays && !hasRemoteRelays) {
isLocalOnly = true
}
}
// If isLocalOnly is true (from any fallback), show airplane icon
if (isLocalOnly === true) {
return {
icon: faPlane,
tooltip: publishedRelays.length > 0
? 'Local relays only - will sync when remote relays available'
: 'Created in flight mode - will sync when remote relays available',
spin: false
}
}
// Show highlighter icon with relay info if available
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
@@ -398,7 +338,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: isLocalOrOffline ? faPlane : faHighlighter,
icon: faHighlighter,
tooltip: relayNames.join('\n'),
spin: false
}
@@ -416,7 +356,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
// Fallback: show all relays we queried (where this was likely fetched from)
const relayNames = RELAYS.map(url =>
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayNames = activeRelays.map(url =>
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
@@ -449,7 +390,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
relayPool
)
console.log('✅ Highlight deletion request published')
// Notify parent to remove this highlight from the list
if (onHighlightDelete) {
@@ -509,7 +449,31 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
title={new Date(highlight.created_at * 1000).toLocaleString()}
onClick={(e) => {
e.stopPropagation()
window.location.href = highlightLinks.native
// Navigate within app using same logic as handleItemClick
if (highlight.eventReference) {
const parts = highlight.eventReference.split(':')
if (parts.length === 3 && parts[0] === '30023') {
const [, pubkey, identifier] = parts
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey,
identifier
})
navigate(`/a/${naddr}`, {
state: {
highlightId: highlight.id,
openHighlights: true
}
})
}
} else if (highlight.urlReference) {
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
state: {
highlightId: highlight.id,
openHighlights: true
}
})
}
}}
>
{formatDateCompact(highlight.created_at)}

View File

@@ -37,6 +37,7 @@ interface HighlightsPanelProps {
relayPool?: RelayPool | null
eventStore?: IEventStore | null
settings?: UserSettings
isMobile?: boolean
}
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
@@ -56,7 +57,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
followedPubkeys = new Set(),
relayPool,
eventStore,
settings
settings,
isMobile = false
}) => {
const [showHighlights, setShowHighlights] = useState(true)
const [localHighlights, setLocalHighlights] = useState(highlights)
@@ -116,15 +118,14 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
return (
<div className="highlights-container">
<HighlightsPanelHeader
loading={loading}
hasHighlights={filteredHighlights.length > 0}
showHighlights={showHighlights}
highlightVisibility={highlightVisibility}
currentUserPubkey={currentUserPubkey}
onToggleHighlights={handleToggleHighlights}
onRefresh={onRefresh}
onToggleCollapse={onToggleCollapse}
onHighlightVisibilityChange={onHighlightVisibilityChange}
isMobile={isMobile}
/>
{loading && filteredHighlights.length === 0 ? (

View File

@@ -1,35 +1,44 @@
import React from 'react'
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faEye, faEyeSlash, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
import { HighlightVisibility } from '../HighlightsPanel'
import IconButton from '../IconButton'
interface HighlightsPanelHeaderProps {
loading: boolean
hasHighlights: boolean
showHighlights: boolean
highlightVisibility: HighlightVisibility
currentUserPubkey?: string
onToggleHighlights: () => void
onRefresh?: () => void
onToggleCollapse: () => void
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
isMobile?: boolean
}
const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
loading,
hasHighlights,
showHighlights,
highlightVisibility,
currentUserPubkey,
onToggleHighlights,
onRefresh,
onToggleCollapse,
onHighlightVisibilityChange
onHighlightVisibilityChange,
isMobile = false
}) => {
return (
<div className="highlights-header">
<div className="highlights-actions">
<div className="highlights-actions-left">
{!isMobile && (
<button
onClick={onToggleCollapse}
className="toggle-highlights-btn"
title="Collapse highlights panel"
aria-label="Collapse highlights panel"
>
<FontAwesomeIcon icon={faChevronRight} style={{ transform: 'rotate(180deg)' }} />
</button>
)}
{onHighlightVisibilityChange && (
<div className="highlight-level-toggles">
<IconButton
@@ -46,49 +55,42 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
opacity: highlightVisibility.nostrverse ? 1 : 0.4
}}
/>
<IconButton
icon={faUserGroup}
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
friends: !highlightVisibility.friends
})}
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
ariaLabel="Toggle friends highlights"
variant="ghost"
disabled={!currentUserPubkey}
style={{
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: highlightVisibility.friends ? 1 : 0.4
}}
/>
<IconButton
icon={faUser}
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
mine: !highlightVisibility.mine
})}
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
ariaLabel="Toggle my highlights"
variant="ghost"
disabled={!currentUserPubkey}
style={{
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: highlightVisibility.mine ? 1 : 0.4
}}
/>
{currentUserPubkey && (
<>
<IconButton
icon={faUserGroup}
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
friends: !highlightVisibility.friends
})}
title="Toggle friends highlights"
ariaLabel="Toggle friends highlights"
variant="ghost"
style={{
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: highlightVisibility.friends ? 1 : 0.4
}}
/>
<IconButton
icon={faUser}
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
mine: !highlightVisibility.mine
})}
title="Toggle my highlights"
ariaLabel="Toggle my highlights"
variant="ghost"
style={{
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: highlightVisibility.mine ? 1 : 0.4
}}
/>
</>
)}
</div>
)}
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title="Refresh highlights"
ariaLabel="Refresh highlights"
variant="ghost"
disabled={loading}
spin={loading}
/>
)}
</div>
<div className="highlights-actions-right">
{hasHighlights && (
<IconButton
icon={showHighlights ? faEye : faEyeSlash}
@@ -99,14 +101,6 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
/>
)}
</div>
<IconButton
icon={faChevronRight}
onClick={onToggleCollapse}
title="Collapse highlights panel"
ariaLabel="Collapse highlights panel"
variant="ghost"
style={{ transform: 'rotate(180deg)' }}
/>
</div>
</div>
)

View File

@@ -124,7 +124,7 @@ const LoginOptions: React.FC = () => {
<div className="login-content">
<h2 className="login-title">Hi! I'm Boris.</h2>
<p className="login-description">
Connect your npub to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights</mark>.
<mark className="login-highlight">Connect your npub</mark> to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights.</mark>
</p>
<div className="login-buttons">

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
import React, { useMemo } from 'react'
import { nip19 } from 'nostr-tools'
import { useEventModel } from 'applesauce-react/hooks'
import { Hooks } from 'applesauce-react'
import { Models, Helpers } from 'applesauce-core'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
import { isProfileInCacheOrStore } from '../utils/profileLoadingUtils'
const { getPubkeyFromDecodeResult } = Helpers
interface NostrMentionLinkProps {
nostrUri: string
onClick?: (e: React.MouseEvent) => void
className?: string
}
/**
* Component to render nostr mentions with resolved profile names
* Handles npub, nprofile, note, nevent, and naddr URIs
*/
const NostrMentionLink: React.FC<NostrMentionLinkProps> = ({
nostrUri,
onClick,
className = 'highlight-comment-link'
}) => {
// Decode the nostr URI first
let decoded: ReturnType<typeof nip19.decode> | null = null
try {
const identifier = nostrUri.replace(/^nostr:/, '')
decoded = nip19.decode(identifier)
} catch (error) {
// Decoding failed, will fallback to shortened identifier
}
// Extract pubkey for profile fetching using applesauce helper (works for npub and nprofile)
const pubkey = decoded ? getPubkeyFromDecodeResult(decoded) : undefined
const eventStore = Hooks.useEventStore()
// Fetch profile at top level (Rules of Hooks)
const profile = useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null)
// Check if profile is in cache or eventStore for loading detection
const isInCacheOrStore = useMemo(() => {
if (!pubkey) return false
return isProfileInCacheOrStore(pubkey, eventStore)
}, [pubkey, eventStore])
// Show loading if profile doesn't exist and not in cache/store (for npub/nprofile)
// pubkey will be undefined for non-profile types, so no need for explicit type check
const isLoading = !profile && pubkey && !isInCacheOrStore
// If decoding failed, show shortened identifier
if (!decoded) {
const identifier = nostrUri.replace(/^nostr:/, '')
return (
<span className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
// Helper function to render profile links (used for both npub and nprofile)
const renderProfileLink = (pubkey: string) => {
const npub = nip19.npubEncode(pubkey)
const displayName = getProfileDisplayName(profile, pubkey)
const linkClassName = isLoading ? `${className} profile-loading` : className
return (
<a
href={`/p/${npub}`}
className={linkClassName}
onClick={onClick}
>
@{displayName}
</a>
)
}
// Render based on decoded type
// If we have a pubkey (from npub/nprofile), render profile link directly
if (pubkey) {
return renderProfileLink(pubkey)
}
switch (decoded.type) {
case 'naddr': {
const { kind, pubkey: pk, identifier: addrIdentifier } = decoded.data
// Check if it's a blog post (kind:30023)
if (kind === 30023) {
const naddr = nip19.naddrEncode({ kind, pubkey: pk, identifier: addrIdentifier })
return (
<a
href={`/a/${naddr}`}
className={className}
onClick={onClick}
>
{addrIdentifier || 'Article'}
</a>
)
}
// For other kinds, show shortened identifier
return (
<span className="highlight-comment-nostr-id">
nostr:{addrIdentifier.slice(0, 12)}...
</span>
)
}
case 'note': {
const eventId = decoded.data
return (
<span className="highlight-comment-nostr-id">
note:{eventId.slice(0, 12)}...
</span>
)
}
case 'nevent': {
const { id } = decoded.data
return (
<span className="highlight-comment-nostr-id">
event:{id.slice(0, 12)}...
</span>
)
}
default: {
// Fallback for unrecognized types
const identifier = nostrUri.replace(/^nostr:/, '')
return (
<span className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
}
}
export default NostrMentionLink

269
src/components/Profile.tsx Normal file
View File

@@ -0,0 +1,269 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
import { IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
import { HighlightItem } from './HighlightItem'
import { BlogPostPreview } from '../services/exploreService'
import { KINDS } from '../config/kinds'
import AuthorCard from './AuthorCard'
import BlogPostCard from './BlogPostCard'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { useStoreTimeline } from '../hooks/useStoreTimeline'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { toBlogPostPreview } from '../utils/toBlogPostPreview'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { Hooks } from 'applesauce-react'
import { readingProgressController } from '../services/readingProgressController'
import { writingsController } from '../services/writingsController'
import { highlightsController } from '../services/highlightsController'
interface ProfileProps {
relayPool: RelayPool
eventStore: IEventStore
pubkey: string
activeTab?: 'highlights' | 'writings'
}
const Profile: React.FC<ProfileProps> = ({
relayPool,
eventStore,
pubkey,
activeTab: propActiveTab
}) => {
const navigate = useNavigate()
const activeAccount = Hooks.useActiveAccount()
const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights')
const [refreshTrigger, setRefreshTrigger] = useState(0)
// Reading progress state (naddr -> progress 0-1)
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
// Load cached data from event store instantly
const cachedHighlights = useStoreTimeline(
eventStore,
{ kinds: [KINDS.Highlights], authors: [pubkey] },
eventToHighlight,
[pubkey]
)
const cachedWritings = useStoreTimeline(
eventStore,
{ kinds: [30023], authors: [pubkey] },
toBlogPostPreview,
[pubkey]
)
// Sort writings by publication date, newest first
const sortedWritings = useMemo(() => {
return cachedWritings.slice().sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
}, [cachedWritings])
// Update local state when prop changes
useEffect(() => {
if (propActiveTab) {
setActiveTab(propActiveTab)
}
}, [propActiveTab])
// Subscribe to reading progress controller
useEffect(() => {
// Get initial state immediately
const initialMap = readingProgressController.getProgressMap()
setReadingProgressMap(initialMap)
// Subscribe to updates
const unsubProgress = readingProgressController.onProgress((newMap) => {
setReadingProgressMap(newMap)
})
return () => {
unsubProgress()
}
}, [])
// Load reading progress data when logged in
useEffect(() => {
if (!activeAccount?.pubkey) {
return
}
readingProgressController.start({
relayPool,
eventStore,
pubkey: activeAccount.pubkey,
force: refreshTrigger > 0
})
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
// Background fetch via controllers to populate event store
useEffect(() => {
if (!pubkey || !relayPool || !eventStore) return
// Start controllers to fetch and populate event store
// Controllers handle streaming, deduplication, and storage
highlightsController.start({ relayPool, eventStore, pubkey })
.catch(err => console.warn('⚠️ [Profile] Failed to fetch highlights:', err))
writingsController.start({ relayPool, eventStore, pubkey, force: refreshTrigger > 0 })
.catch(err => console.warn('⚠️ [Profile] Failed to fetch writings:', err))
}, [pubkey, relayPool, eventStore, refreshTrigger])
// Pull-to-refresh
const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => {
setRefreshTrigger(prev => prev + 1)
},
maximumPullLength: 240,
refreshThreshold: 80,
isDisabled: !pubkey
})
const getPostUrl = (post: BlogPostPreview) => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
return `/a/${naddr}`
}
// Helper to get reading progress for a post
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) return undefined
try {
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
const progress = readingProgressMap.get(naddr)
// Only log when found or map is empty
if (progress || readingProgressMap.size === 0) {
// Progress found or map is empty
}
return progress
} catch (err) {
return undefined
}
}, [readingProgressMap])
const handleHighlightDelete = () => {
// Not allowed to delete other users' highlights
return
}
const npub = nip19.npubEncode(pubkey)
const showSkeletons = cachedHighlights.length === 0 && sortedWritings.length === 0
const renderTabContent = () => {
switch (activeTab) {
case 'highlights':
if (showSkeletons) {
return (
<div className="explore-grid">
{Array.from({ length: 8 }).map((_, i) => (
<HighlightSkeleton key={i} />
))}
</div>
)
}
return cachedHighlights.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No highlights yet.
</div>
) : (
<div className="highlights-list me-highlights-list">
{cachedHighlights.map((highlight) => (
<HighlightItem
key={highlight.id}
highlight={{ ...highlight, level: 'mine' }}
relayPool={relayPool}
onHighlightDelete={handleHighlightDelete}
/>
))}
</div>
)
case 'writings':
if (showSkeletons) {
return (
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))}
</div>
)
}
return sortedWritings.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles written yet.
</div>
) : (
<div className="explore-grid">
{sortedWritings.map((post) => (
<BlogPostCard
key={post.event.id}
post={post}
href={getPostUrl(post)}
readingProgress={getReadingProgress(post)}
/>
))}
</div>
)
default:
return null
}
}
return (
<div className="explore-container">
<RefreshIndicator
isRefreshing={isRefreshing}
pullPosition={pullPosition}
/>
<div className="explore-header">
<AuthorCard authorPubkey={pubkey} clickable={false} />
<div className="me-tabs">
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights"
onClick={() => navigate(`/p/${npub}`)}
>
<FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span>
</button>
<button
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings"
onClick={() => navigate(`/p/${npub}/writings`)}
>
<FontAwesomeIcon icon={faPenToSquare} />
<span className="tab-label">Writings</span>
</button>
</div>
</div>
<div className="me-tab-content">
{renderTabContent()}
</div>
</div>
)
}
export default Profile

View File

@@ -20,6 +20,7 @@ interface ReaderHeaderProps {
settings?: UserSettings
highlights?: Highlight[]
highlightVisibility?: HighlightVisibility
onHighlightCountClick?: () => void
}
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
@@ -32,7 +33,8 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
highlightCount,
settings,
highlights = [],
highlightVisibility = { nostrverse: true, friends: true, mine: true }
highlightVisibility = { nostrverse: true, friends: true, mine: true },
onHighlightCountClick
}) => {
const cachedImage = useImageCache(image)
const { textColor } = useAdaptiveTextColor(cachedImage)
@@ -78,7 +80,13 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
<>
<div className="reader-hero-image">
{cachedImage ? (
<img src={cachedImage} alt={title || 'Article image'} />
<img
src={cachedImage}
alt={title || 'Article image'}
onError={(e) => {
console.error('[reader-header] Image failed to load:', cachedImage, e)
}}
/>
) : (
<div className="reader-hero-placeholder">
<FontAwesomeIcon icon={faNewspaper} />
@@ -107,8 +115,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
)}
{hasHighlights && (
<div
className="highlight-indicator"
className="highlight-indicator clickable"
style={getHighlightIndicatorStyles(true)}
onClick={onHighlightCountClick}
title="Open highlights sidebar"
>
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
@@ -152,8 +162,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
)}
{hasHighlights && (
<div
className="highlight-indicator"
className="highlight-indicator clickable"
style={getHighlightIndicatorStyles(false)}
onClick={onHighlightCountClick}
title="Open highlights sidebar"
>
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>

View File

@@ -0,0 +1,58 @@
import React from 'react'
interface ReadingProgressBarProps {
readingProgress?: number
height?: number
marginTop?: string
marginBottom?: string
marginLeft?: string
className?: string
}
export const ReadingProgressBar: React.FC<ReadingProgressBarProps> = ({
readingProgress,
height = 1,
marginTop,
marginBottom,
marginLeft,
className
}) => {
// Calculate progress color
let progressColor = '#6366f1' // Default blue (reading)
if (readingProgress && readingProgress >= 0.95) {
progressColor = '#10b981' // Green (completed)
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
progressColor = 'var(--color-text)' // Neutral text color (started)
}
const progressWidth = readingProgress ? `${Math.round(readingProgress * 100)}%` : '0%'
const progressBackground = readingProgress ? progressColor : 'var(--color-border)'
return (
<div
className={className}
style={{
height: `${height}px`,
width: '100%',
background: 'var(--color-border)',
borderRadius: '0.5px',
overflow: 'hidden',
marginTop,
marginBottom,
marginLeft,
position: 'relative',
minHeight: `${height}px`
}}
>
<div
style={{
height: '100%',
width: progressWidth,
background: progressBackground,
transition: 'width 0.3s ease, background 0.3s ease',
minHeight: `${height}px`
}}
/>
</div>
)
}

View File

@@ -1,9 +1,10 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
import { faBookOpen, faCheckCircle, faAsterisk, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { faBooks } from '../icons/customIcons'
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed'
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted' | 'archive'
interface ReadingProgressFiltersProps {
selectedFilter: ReadingProgressFilterType
@@ -13,18 +14,30 @@ interface ReadingProgressFiltersProps {
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
const filters = [
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
{ type: 'highlighted' as const, icon: faHighlighter, label: 'Highlighted' },
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
// Archive-marked items (previously emoji-marked)
{ type: 'archive' as const, icon: faBooks, label: 'Archive' }
]
return (
<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
// Only "completed" gets green color, "highlighted" gets yellow, everything else uses default blue
let activeStyle: Record<string, string> | undefined = undefined
if (isActive) {
if (filter.type === 'completed') {
activeStyle = { color: '#10b981' } // green
} else if (filter.type === 'highlighted') {
activeStyle = { color: '#fde047' } // yellow
} else if (filter.type === 'archive') {
activeStyle = { color: '#60a5fa' } // blue accent
}
}
return (
<button

View File

@@ -50,16 +50,8 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
// Debug logging
useEffect(() => {
console.log('🔌 Relay Status Indicator:', {
mode: isConnecting ? 'CONNECTING' : offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
totalStatuses: relayStatuses.length,
connectedCount: connectedUrls.length,
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
hasLocalRelay,
hasRemoteRelay,
isConnecting
})
}, [offlineMode, localOnlyMode, connectedUrls, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
// Mode and relay status determined
}, [isConnecting, offlineMode, localOnlyMode, relayStatuses, hasLocalRelay, hasRemoteRelay])
// Don't show indicator when fully connected (but show when connecting)
if (!localOnlyMode && !offlineMode && !isConnecting) return null
@@ -156,7 +148,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
fontWeight: 400
}}
>
{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}
Local relays only
</span>
</>
)}

View File

@@ -1,8 +1,11 @@
import React from 'react'
import React, { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { useEventModel } from 'applesauce-react/hooks'
import { Hooks } from 'applesauce-react'
import { Models, Helpers } from 'applesauce-core'
import { decode, npubEncode } from 'nostr-tools/nip19'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
import { isProfileInCacheOrStore } from '../utils/profileLoadingUtils'
const { getPubkeyFromDecodeResult } = Helpers
@@ -19,15 +22,27 @@ const ResolvedMention: React.FC<ResolvedMentionProps> = ({ encoded }) => {
// ignore; will fallback to showing the encoded value
}
const eventStore = Hooks.useEventStore()
const profile = pubkey ? useEventModel(Models.ProfileModel, [pubkey]) : undefined
const display = profile?.name || profile?.display_name || profile?.nip05 || (pubkey ? `${pubkey.slice(0, 8)}...` : encoded)
// Check if profile is in cache or eventStore
const isInCacheOrStore = useMemo(() => {
if (!pubkey) return false
return isProfileInCacheOrStore(pubkey, eventStore)
}, [pubkey, eventStore])
// Show loading if profile doesn't exist and not in cache/store
const isLoading = !profile && pubkey && !isInCacheOrStore
const display = pubkey ? getProfileDisplayName(profile, pubkey) : encoded
const npub = pubkey ? npubEncode(pubkey) : undefined
if (npub) {
const className = isLoading ? 'nostr-mention profile-loading' : 'nostr-mention'
return (
<Link
to={`/p/${npub}`}
className="nostr-mention"
className={className}
>
@{display}
</Link>

View File

@@ -0,0 +1,100 @@
import React from 'react'
import NostrMentionLink from './NostrMentionLink'
import { Tokens } from 'applesauce-content/helpers'
// Helper to add timestamps to error logs
const ts = () => {
const now = new Date()
const ms = now.getMilliseconds().toString().padStart(3, '0')
return `${now.toLocaleTimeString('en-US', { hour12: false })}.${ms}`
}
interface RichContentProps {
content: string
className?: string
}
/**
* Component to render text content with:
* - Clickable links
* - Resolved nostr mentions (npub, nprofile, note, nevent, naddr)
* - Plain text
*
* Handles both nostr:npub1... and plain npub1... formats
*/
const RichContent: React.FC<RichContentProps> = ({
content,
className = 'bookmark-content'
}) => {
try {
// Pattern to match:
// 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.) using applesauce Tokens.nostrLink
// 2. http(s) URLs
const nostrPattern = Tokens.nostrLink
const urlPattern = /https?:\/\/[^\s]+/gi
const combinedPattern = new RegExp(`(${nostrPattern.source}|${urlPattern.source})`, 'gi')
const parts = content.split(combinedPattern)
// Helper to check if a string is a nostr identifier (without mutating regex state)
const isNostrIdentifier = (str: string): boolean => {
const testPattern = new RegExp(nostrPattern.source, nostrPattern.flags)
return testPattern.test(str)
}
return (
<div className={className}>
{parts.map((part, index) => {
// Skip empty or undefined parts
if (!part) {
return null
}
// Handle nostr: URIs - Tokens.nostrLink matches both formats
if (part.startsWith('nostr:')) {
return (
<NostrMentionLink
key={index}
nostrUri={part}
/>
)
}
// Handle plain nostr identifiers (Tokens.nostrLink matches these too)
if (isNostrIdentifier(part)) {
return (
<NostrMentionLink
key={index}
nostrUri={`nostr:${part}`}
/>
)
}
// Handle http(s) URLs
if (part.match(/^https?:\/\//)) {
return (
<a
key={index}
href={part}
className="nostr-link"
target="_blank"
rel="noopener noreferrer"
>
{part}
</a>
)
}
// Plain text
return <React.Fragment key={index}>{part}</React.Fragment>
})}
</div>
)
} catch (err) {
console.error(`[${ts()}] [npub-resolve] RichContent: Error rendering:`, err)
return <div className={className}>Error rendering content</div>
}
}
export default RichContent

View File

@@ -20,7 +20,7 @@ export default function RouteDebug() {
// Unexpected during deep-link refresh tests
console.warn('[RouteDebug] unexpected root redirect', info)
} else {
console.debug('[RouteDebug]', info)
// silent
}
}, [location, matchArticle])

View File

@@ -6,11 +6,13 @@ import IconButton from './IconButton'
import { loadFont } from '../utils/fontLoader'
import ThemeSettings from './Settings/ThemeSettings'
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
import MediaDisplaySettings from './Settings/MediaDisplaySettings'
import ExploreSettings from './Settings/ExploreSettings'
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
import ZapSettings from './Settings/ZapSettings'
import RelaySettings from './Settings/RelaySettings'
import PWASettings from './Settings/PWASettings'
import TTSSettings from './Settings/TTSSettings'
import { useRelayStatus } from '../hooks/useRelayStatus'
import VersionFooter from './VersionFooter'
@@ -39,7 +41,18 @@ const DEFAULT_SETTINGS: UserSettings = {
useLocalRelayAsCache: true,
rebroadcastToAllRelays: false,
paragraphAlignment: 'justify',
syncReadingPosition: false,
fullWidthImages: true,
renderVideoLinksAsEmbeds: true,
syncReadingPosition: true,
autoScrollToReadingPosition: true,
autoMarkAsReadOnCompletion: false,
hideBookmarksWithoutCreationDate: true,
ttsUseSystemLanguage: false,
ttsDetectContentLanguage: true,
ttsLanguageMode: 'content',
ttsDefaultSpeed: 2.1,
linkColorDark: '#38bdf8',
linkColorLight: '#3b82f6',
}
interface SettingsProps {
@@ -167,8 +180,10 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
<div className="settings-content">
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
<MediaDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
<TTSSettings settings={localSettings} onUpdate={handleUpdate} />
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />

View File

@@ -51,6 +51,19 @@ const ExploreSettings: React.FC<ExploreSettingsProps> = ({ settings, onUpdate })
/>
</div>
</div>
<div className="setting-group">
<label htmlFor="hideBotArticlesByName" className="checkbox-label">
<input
id="hideBotArticlesByName"
type="checkbox"
checked={settings.hideBotArticlesByName !== false}
onChange={(e) => onUpdate({ hideBotArticlesByName: e.target.checked })}
className="setting-checkbox"
/>
<span>Hide content posted by bots</span>
</label>
</div>
</div>
)
}

View File

@@ -117,6 +117,45 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
<span>Sync reading position across devices</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="autoScrollToReadingPosition" className="checkbox-label">
<input
id="autoScrollToReadingPosition"
type="checkbox"
checked={settings.autoScrollToReadingPosition !== false}
onChange={(e) => onUpdate({ autoScrollToReadingPosition: e.target.checked })}
className="setting-checkbox"
/>
<span>Auto-scroll to saved reading position</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="autoMarkAsReadOnCompletion" className="checkbox-label">
<input
id="autoMarkAsReadOnCompletion"
type="checkbox"
checked={settings.autoMarkAsReadOnCompletion ?? false}
onChange={(e) => onUpdate({ autoMarkAsReadOnCompletion: e.target.checked })}
className="setting-checkbox"
/>
<span>Automatically move to archive at 100%</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="hideBookmarksWithoutCreationDate" className="checkbox-label">
<input
id="hideBookmarksWithoutCreationDate"
type="checkbox"
checked={settings.hideBookmarksWithoutCreationDate ?? false}
onChange={(e) => onUpdate({ hideBookmarksWithoutCreationDate: e.target.checked })}
className="setting-checkbox"
/>
<span>Hide bookmarks missing a creation date</span>
</label>
</div>
</div>
)
}

View File

@@ -0,0 +1,43 @@
import React from 'react'
import { UserSettings } from '../../services/settingsService'
interface MediaDisplaySettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
}
const MediaDisplaySettings: React.FC<MediaDisplaySettingsProps> = ({ settings, onUpdate }) => {
return (
<div className="settings-section">
<h3 className="section-title">Media Display</h3>
<div className="setting-group">
<label htmlFor="fullWidthImages" className="checkbox-label">
<input
id="fullWidthImages"
type="checkbox"
checked={settings.fullWidthImages === true}
onChange={(e) => onUpdate({ fullWidthImages: e.target.checked })}
className="setting-checkbox"
/>
<span>Full-width images in articles</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="renderVideoLinksAsEmbeds" className="checkbox-label">
<input
id="renderVideoLinksAsEmbeds"
type="checkbox"
checked={settings.renderVideoLinksAsEmbeds === true}
onChange={(e) => onUpdate({ renderVideoLinksAsEmbeds: e.target.checked })}
className="setting-checkbox"
/>
<span>Render video links as embeds</span>
</label>
</div>
</div>
)
}
export default MediaDisplaySettings

View File

@@ -27,13 +27,19 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
if (isInstalled) return
const success = await installApp()
if (success) {
console.log('App installed successfully')
// Installation successful
}
}
const handleLinkClick = (url: string) => {
if (onClose) onClose()
navigate(`/r/${encodeURIComponent(url)}`)
// If it's an internal route (starts with /), navigate directly
if (url.startsWith('/')) {
navigate(url)
} else {
// External URL: wrap with /r/ path
navigate(`/r/${encodeURIComponent(url)}`)
}
}
const handleClearCache = async () => {
@@ -151,7 +157,7 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
>
here
</a>
{' and '}
{', '}
<a
onClick={(e) => {
e.preventDefault()
@@ -161,6 +167,16 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
>
here
</a>
{', and '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('/a/naddr1qvzqqqr4gupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqq9hyetvv9uj6um9w36hq9mgjg8')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
.
</p>
</div>

View File

@@ -5,7 +5,7 @@ import IconButton from '../IconButton'
import ColorPicker from '../ColorPicker'
import FontSelector from '../FontSelector'
import { getFontFamily } from '../../utils/fontLoader'
import { hexToRgb } from '../../utils/colorHelpers'
import { hexToRgb, LINK_COLORS_DARK, LINK_COLORS_LIGHT } from '../../utils/colorHelpers'
interface ReadingDisplaySettingsProps {
settings: UserSettings
@@ -14,6 +14,23 @@ interface ReadingDisplaySettingsProps {
const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ settings, onUpdate }) => {
const previewFontFamily = getFontFamily(settings.readingFont || 'source-serif-4')
// Determine current effective theme for color palette selection
const currentTheme = settings.theme ?? 'system'
const isDark = currentTheme === 'dark' ||
(currentTheme === 'system' && (typeof window !== 'undefined' ? window.matchMedia('(prefers-color-scheme: dark)').matches : true))
const linkColors = isDark ? LINK_COLORS_DARK : LINK_COLORS_LIGHT
const currentLinkColor = isDark
? (settings.linkColorDark || '#38bdf8')
: (settings.linkColorLight || '#3b82f6')
const handleLinkColorChange = (color: string) => {
if (isDark) {
onUpdate({ linkColorDark: color })
} else {
onUpdate({ linkColorLight: color })
}
}
return (
<div className="settings-section">
@@ -59,6 +76,7 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
</div>
</div>
<div className="setting-group setting-inline">
<label>Default Highlight Visibility</label>
<div className="highlight-level-toggles">
@@ -108,6 +126,17 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">Link Color</label>
<div className="setting-control">
<ColorPicker
selectedColor={currentLinkColor}
onColorChange={handleLinkColorChange}
colors={linkColors}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">Font Size</label>
<div className="setting-control">
@@ -178,14 +207,16 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
fontFamily: previewFontFamily,
fontSize: `${settings.fontSize || 21}px`,
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00'),
'--paragraph-alignment': settings.paragraphAlignment || 'justify'
'--paragraph-alignment': settings.paragraphAlignment || 'justify',
'--color-link': isDark
? (settings.linkColorDark || '#38bdf8')
: (settings.linkColorLight || '#3b82f6')
} as React.CSSProperties}
>
<h3>The Quick Brown Fox</h3>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityMine !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityFriends !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityNostrverse !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
<p>Totam rem aperiam, eaque ipsa quae ab illo <a href="/a/naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqq8ky6t5vdhkjm3dd9ej6arfd4jszh5rdq">inventore veritatis</a> et quasi architecto beatae vitae dicta sunt explicabo. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityNostrverse !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,86 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faGauge } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import TTSControls from '../TTSControls'
interface TTSSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
}
const SPEED_OPTIONS = [0.8, 1, 1.2, 1.4, 1.6, 1.8, 2, 2.1, 2.4, 2.8, 3]
const EXAMPLE_TEXT = "Boris aims to be a calm reader app with clean typography, beautiful design, and a focus on readability. Boris does not and will never have ads, trackers, paywalls, subscriptions, or any other distractions."
const TTSSettings: React.FC<TTSSettingsProps> = ({ settings, onUpdate }) => {
const currentSpeed = settings.ttsDefaultSpeed || 2.1
const handleCycleSpeed = () => {
const currentIndex = SPEED_OPTIONS.indexOf(currentSpeed)
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
onUpdate({ ttsDefaultSpeed: SPEED_OPTIONS[nextIndex] })
}
return (
<div className="settings-section">
<h3 className="section-title">Text-to-Speech</h3>
<div className="setting-group setting-inline">
<label>Default Playback Speed</label>
<div className="setting-buttons">
<button
type="button"
className="article-menu-btn"
onClick={handleCycleSpeed}
title="Cycle speed"
>
<FontAwesomeIcon icon={faGauge} />
<span>{currentSpeed}x</span>
</button>
</div>
</div>
<div className="setting-group setting-inline">
<label>Speaker language</label>
<div className="setting-control">
<select
value={settings.ttsLanguageMode || 'content'}
onChange={e => {
const value = e.target.value
onUpdate({
ttsLanguageMode: value,
ttsUseSystemLanguage: value === 'system',
ttsDetectContentLanguage: value === 'content'
})
}}
className="setting-select"
>
<option value="system">System Language</option>
<option value="content">Content (auto-detect)</option>
<option disabled></option>
<option value="en-US">English (American)</option>
<option value="en-GB">English (British)</option>
<option value="zh">Mandarin Chinese</option>
<option value="es">Spanish</option>
<option value="hi">Hindi</option>
<option value="ar">Arabic</option>
<option value="fr">French</option>
<option value="pt">Portuguese</option>
<option value="de">German</option>
<option value="ja">Japanese</option>
<option value="ru">Russian</option>
</select>
</div>
</div>
<div className="setting-group">
<div style={{ padding: '0.75rem', backgroundColor: 'var(--color-bg)', borderRadius: '4px', marginBottom: '0.75rem', fontSize: '0.95rem', lineHeight: '1.5' }}>
{EXAMPLE_TEXT}
</div>
<TTSControls text={EXAMPLE_TEXT} settings={settings} />
</div>
</div>
)
}
export default TTSSettings

View File

@@ -0,0 +1,99 @@
import { useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { createWebBookmark } from '../services/webBookmarkService'
import { getActiveRelayUrls } from '../services/relayManager'
import { useToast } from '../hooks/useToast'
interface ShareTargetHandlerProps {
relayPool: RelayPool
}
/**
* Handles incoming shared URLs from the Web Share Target API.
* Auto-saves the shared URL as a web bookmark (NIP-B0).
*/
export default function ShareTargetHandler({ relayPool }: ShareTargetHandlerProps) {
const navigate = useNavigate()
const location = useLocation()
const activeAccount = Hooks.useActiveAccount()
const { showToast } = useToast()
const [processing, setProcessing] = useState(false)
const [waitingForLogin, setWaitingForLogin] = useState(false)
useEffect(() => {
const handleSharedContent = async () => {
// Parse query parameters
const params = new URLSearchParams(location.search)
const link = params.get('link')
const title = params.get('title')
const text = params.get('text')
// Validate we have a URL
if (!link) {
showToast('No URL to save')
navigate('/')
return
}
// If no active account, wait for login
if (!activeAccount) {
setWaitingForLogin(true)
showToast('Please log in to save this bookmark')
return
}
// We have account and URL, proceed with saving
if (!processing) {
setProcessing(true)
try {
await createWebBookmark(
link,
title || undefined,
text || undefined,
undefined,
activeAccount,
relayPool,
getActiveRelayUrls(relayPool)
)
showToast('Bookmark saved!')
navigate('/my/links')
} catch (err) {
console.error('Failed to save shared bookmark:', err)
showToast('Failed to save bookmark')
navigate('/')
} finally {
setProcessing(false)
}
}
}
handleSharedContent()
}, [activeAccount, location.search, navigate, relayPool, showToast, processing])
// Show waiting for login state
if (waitingForLogin && !activeAccount) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<FontAwesomeIcon icon={faSpinner} spin className="text-4xl mb-4" />
<p className="text-lg">Waiting for login...</p>
</div>
</div>
)
}
// Show processing state
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<FontAwesomeIcon icon={faSpinner} spin className="text-4xl mb-4" />
<p className="text-lg">Saving bookmark...</p>
</div>
</div>
)
}

View File

@@ -1,11 +1,14 @@
import React from 'react'
import React, { useState, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faPersonHiking, faHighlighter, faBookmark, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import IconButton from './IconButton'
import { faBooks } from '../icons/customIcons'
import { preloadImage } from '../hooks/useImageCache'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
interface SidebarHeaderProps {
onToggleCollapse: () => void
@@ -18,6 +21,8 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
const navigate = useNavigate()
const activeAccount = Hooks.useActiveAccount()
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
const [showProfileMenu, setShowProfileMenu] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const getProfileImage = () => {
return profile?.picture || null
@@ -25,81 +30,159 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
const getUserDisplayName = () => {
if (!activeAccount) return 'Unknown User'
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
if (profile?.nip05) return profile.nip05
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
return getProfileDisplayName(profile, activeAccount.pubkey)
}
const profileImage = getProfileImage()
// Preload profile image for offline access
useEffect(() => {
if (profileImage) {
preloadImage(profileImage)
}
}, [profileImage])
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setShowProfileMenu(false)
}
}
if (showProfileMenu) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [showProfileMenu])
const handleMenuItemClick = (action: () => void) => {
setShowProfileMenu(false)
// Close mobile sidebar when navigating on mobile
if (isMobile) {
onToggleCollapse()
}
action()
}
return (
<>
<div className="sidebar-header-bar">
{isMobile ? (
<div className="sidebar-header-left">
{activeAccount && (
<div className="profile-menu-wrapper" ref={menuRef}>
<button
className="profile-avatar-button"
title={getUserDisplayName()}
onClick={() => setShowProfileMenu(!showProfileMenu)}
aria-label={`Profile: ${getUserDisplayName()}`}
>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
)}
</button>
{showProfileMenu && (
<div className="profile-dropdown-menu">
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/highlights'))}
>
<FontAwesomeIcon icon={faHighlighter} />
<span>My Highlights</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/bookmarks'))}
>
<FontAwesomeIcon icon={faBookmark} />
<span>My Bookmarks</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/reads'))}
>
<FontAwesomeIcon icon={faBooks} />
<span>My Reads</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/links'))}
>
<FontAwesomeIcon icon={faLink} />
<span>My Links</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/writings'))}
>
<FontAwesomeIcon icon={faPenToSquare} />
<span>My Writings</span>
</button>
<div className="profile-menu-separator"></div>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(onLogout)}
>
<FontAwesomeIcon icon={faRightFromBracket} />
<span>Logout</span>
</button>
</div>
)}
</div>
)}
<IconButton
icon={faTimes}
onClick={onToggleCollapse}
title="Close sidebar"
ariaLabel="Close sidebar"
icon={faHome}
onClick={() => {
if (isMobile) {
onToggleCollapse()
}
navigate('/')
}}
title="Home"
ariaLabel="Home"
variant="ghost"
className="mobile-close-btn"
/>
) : (
<button
onClick={onToggleCollapse}
className="toggle-sidebar-btn"
title="Collapse bookmarks sidebar"
aria-label="Collapse bookmarks sidebar"
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
)}
</div>
<div className="sidebar-header-right">
{activeAccount && (
<div
className="profile-avatar"
title={getUserDisplayName()}
onClick={() => navigate('/me')}
style={{ cursor: 'pointer' }}
>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
)}
</div>
)}
<IconButton
icon={faHome}
onClick={() => navigate('/')}
title="Home"
ariaLabel="Home"
variant="ghost"
/>
<IconButton
icon={faNewspaper}
onClick={() => navigate('/explore')}
title="Explore"
ariaLabel="Explore"
variant="ghost"
/>
<IconButton
icon={faGear}
onClick={onOpenSettings}
title="Settings"
ariaLabel="Settings"
variant="ghost"
/>
{activeAccount && (
<IconButton
icon={faRightFromBracket}
onClick={onLogout}
title="Logout"
ariaLabel="Logout"
icon={faPersonHiking}
onClick={() => {
if (isMobile) {
onToggleCollapse()
}
navigate('/explore')
}}
title="Explore"
ariaLabel="Explore"
variant="ghost"
/>
)}
<IconButton
icon={faGear}
onClick={() => {
if (isMobile) {
onToggleCollapse()
}
onOpenSettings()
}}
title="Settings"
ariaLabel="Settings"
variant="ghost"
/>
{!isMobile && (
<button
onClick={onToggleCollapse}
className="toggle-sidebar-btn"
title="Collapse bookmarks sidebar"
aria-label="Collapse bookmarks sidebar"
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
)}
</div>
</div>
</>

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHeart, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
import { faHeart, faUserCircle } from '@fortawesome/free-solid-svg-icons'
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
import { fetchProfiles } from '../services/profileService'
import { UserSettings } from '../services/settingsService'
@@ -10,6 +10,7 @@ import { Models } from 'applesauce-core'
import { useEventModel } from 'applesauce-react/hooks'
import { useNavigate } from 'react-router-dom'
import { nip19 } from 'nostr-tools'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
interface SupportProps {
relayPool: RelayPool
@@ -21,7 +22,7 @@ type SupporterProfile = ZapSender
const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) => {
const [supporters, setSupporters] = useState<SupporterProfile[]>([])
const [loading, setLoading] = useState(true)
const [loading, setLoading] = useState(false)
useEffect(() => {
const loadSupporters = async () => {
@@ -31,7 +32,8 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
if (zappers.length > 0) {
const pubkeys = zappers.map(z => z.pubkey)
await fetchProfiles(relayPool, eventStore, pubkeys, settings)
// Fetch profiles in background without blocking
fetchProfiles(relayPool, eventStore, pubkeys, settings).catch(() => {})
}
setSupporters(zappers)
@@ -45,14 +47,6 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
loadSupporters()
}, [relayPool, eventStore, settings])
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen p-4">
<FontAwesomeIcon icon={faSpinner} spin size="2x" className="text-zinc-400" />
</div>
)
}
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text)' }}>
<div className="max-w-5xl mx-auto px-4 py-12 md:py-16">
@@ -82,7 +76,32 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
</p>
</div>
{supporters.length === 0 ? (
{loading ? (
<>
{/* Loading Skeletons */}
<div className="mb-16 md:mb-20">
<h2 className="text-2xl md:text-3xl font-semibold mb-8 md:mb-10 text-center" style={{ color: 'var(--color-text)' }}>
Legends
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-8 md:gap-10">
{Array.from({ length: 3 }).map((_, i) => (
<SupporterSkeleton key={`whale-${i}`} isWhale={true} />
))}
</div>
</div>
<div className="mb-12">
<h2 className="text-xl md:text-2xl font-semibold mb-8 text-center" style={{ color: 'var(--color-text)' }}>
Supporters
</h2>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-4 md:gap-5">
{Array.from({ length: 12 }).map((_, i) => (
<SupporterSkeleton key={`supporter-${i}`} isWhale={false} />
))}
</div>
</div>
</>
) : supporters.length === 0 ? (
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
<p>No supporters yet. Be the first to zap Boris!</p>
</div>
@@ -164,7 +183,7 @@ const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) =>
const profile = useEventModel(Models.ProfileModel, [supporter.pubkey])
const picture = profile?.picture
const name = profile?.name || profile?.display_name || `${supporter.pubkey.slice(0, 8)}...`
const name = getProfileDisplayName(profile, supporter.pubkey)
const handleClick = () => {
const npub = nip19.npubEncode(supporter.pubkey)
@@ -231,5 +250,55 @@ const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) =>
)
}
interface SupporterSkeletonProps {
isWhale: boolean
}
const SupporterSkeleton: React.FC<SupporterSkeletonProps> = ({ isWhale }) => {
return (
<div className="flex flex-col items-center">
<div className="relative">
{/* Avatar Skeleton */}
<div
className={`rounded-full overflow-hidden flex items-center justify-center animate-pulse
${isWhale ? 'w-24 h-24 md:w-28 md:h-28' : 'w-10 h-10 md:w-12 md:h-12'}
`}
style={{
backgroundColor: 'var(--color-bg-elevated)'
}}
>
<div
className={`rounded-full ${isWhale ? 'w-20 h-20 md:w-24 md:h-24' : 'w-8 h-8 md:w-10 md:h-10'}`}
style={{ backgroundColor: 'var(--color-border)' }}
/>
</div>
{/* Whale Badge Skeleton */}
{isWhale && (
<div
className="absolute -bottom-1 -right-1 w-8 h-8 rounded-full animate-pulse border-2"
style={{
backgroundColor: 'var(--color-border)',
borderColor: 'var(--color-bg)'
}}
/>
)}
</div>
{/* Name and Total Skeleton */}
<div className="mt-2 text-center space-y-1">
<div
className={`rounded animate-pulse ${isWhale ? 'h-4 w-16' : 'h-3 w-12'}`}
style={{ backgroundColor: 'var(--color-border)' }}
/>
<div
className={`rounded animate-pulse ${isWhale ? 'h-3 w-12' : 'h-2 w-10'}`}
style={{ backgroundColor: 'var(--color-border)' }}
/>
</div>
</div>
)
}
export default Support

View File

@@ -0,0 +1,113 @@
import React, { useMemo } from 'react'
import { useTextToSpeech } from '../hooks/useTextToSpeech'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPlay, faPause, faGauge } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../services/settingsService'
import { detect } from 'tinyld'
interface Props {
text: string
defaultLang?: string
className?: string
settings?: UserSettings
}
const SPEED_OPTIONS = [0.8, 1, 1.2, 1.4, 1.6, 1.8, 2, 2.1, 2.4, 2.8, 3]
const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }) => {
const {
supported, speaking, paused,
speak, pause, resume,
rate, setRate
} = useTextToSpeech({ defaultLang, defaultRate: settings?.ttsDefaultSpeed })
const canPlay = supported && text?.trim().length > 0
const resolvedSystemLang = useMemo(() => {
const mode = settings?.ttsLanguageMode
if ((mode ? mode === 'system' : settings?.ttsUseSystemLanguage) === true) {
return navigator?.language?.split('-')[0]
}
return undefined
}, [settings?.ttsLanguageMode, settings?.ttsUseSystemLanguage])
const detectContentLang = useMemo(() => {
const mode = settings?.ttsLanguageMode
if (mode) return mode === 'content'
return settings?.ttsDetectContentLanguage !== false
}, [settings?.ttsLanguageMode, settings?.ttsDetectContentLanguage])
const specificLang = useMemo(() => {
const mode = settings?.ttsLanguageMode
// If mode is not 'system' or 'content', it's a specific language code
if (mode && mode !== 'system' && mode !== 'content') {
return mode
}
return undefined
}, [settings?.ttsLanguageMode])
const handlePlayPause = () => {
if (!canPlay) return
if (!speaking) {
let langOverride: string | undefined
// Priority: specific language > content detection > system language
if (specificLang) {
langOverride = specificLang
} else if (detectContentLang && text) {
try {
const lang = detect(text)
if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2)
} catch (err) {
// ignore detection errors
}
}
if (!langOverride && resolvedSystemLang) {
langOverride = resolvedSystemLang
}
speak(text, langOverride)
} else if (paused) {
resume()
} else {
pause()
}
}
const handleCycleSpeed = () => {
const currentIndex = SPEED_OPTIONS.indexOf(rate)
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
const next = SPEED_OPTIONS[nextIndex]
setRate(next)
}
const playLabel = !speaking ? 'Listen' : (paused ? 'Resume' : 'Pause')
if (!supported) return null
return (
<div className={className || 'tts-controls'} style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<button
type="button"
className="article-menu-btn"
onClick={handlePlayPause}
title={playLabel}
disabled={!canPlay}
>
<FontAwesomeIcon icon={!speaking ? faPlay : (paused ? faPlay : faPause)} />
</button>
<button
type="button"
className="article-menu-btn"
onClick={handleCycleSpeed}
title="Cycle speed"
>
<FontAwesomeIcon icon={faGauge} />
<span>{rate}x</span>
</button>
</div>
)
}
export default TTSControls

View File

@@ -5,6 +5,7 @@ import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { BookmarkList } from './BookmarkList'
import ContentPanel from './ContentPanel'
import VideoView from './VideoView'
import { HighlightsPanel } from './HighlightsPanel'
import Settings from './Settings'
import Toast from './Toast'
@@ -19,6 +20,7 @@ import { HighlightVisibility } from './HighlightsPanel'
import { HighlightButtonRef } from './HighlightButton'
import { BookmarkReference } from '../utils/contentLoader'
import { useIsMobile } from '../hooks/useMediaQuery'
import { classifyUrl } from '../utils/helpers'
import { useScrollDirection } from '../hooks/useScrollDirection'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
@@ -134,15 +136,30 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
const showHighlightsButton = scrollDirection !== 'down' && !isAtTop
// Lock body scroll when mobile sidebar or highlights is open
const savedScrollPosition = useRef<number>(0)
useEffect(() => {
if (isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed)) {
// Save current scroll position
savedScrollPosition.current = window.scrollY
document.body.style.top = `-${savedScrollPosition.current}px`
document.body.classList.add('mobile-sidebar-open')
} else {
// Restore scroll position
document.body.classList.remove('mobile-sidebar-open')
document.body.style.top = ''
if (savedScrollPosition.current > 0) {
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
window.scrollTo(0, savedScrollPosition.current)
savedScrollPosition.current = 0
})
}
}
return () => {
document.body.classList.remove('mobile-sidebar-open')
document.body.style.top = ''
}
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed])
@@ -306,7 +323,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
<div
ref={sidebarRef}
className={`pane sidebar ${isMobile && props.isSidebarOpen ? 'mobile-open' : ''}`}
aria-hidden={isMobile && !props.isSidebarOpen}
{...(isMobile && !props.isSidebarOpen ? { inert: '' } : {})}
>
<BookmarkList
bookmarks={props.bookmarks}
@@ -358,40 +375,73 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
<>
{props.support}
</>
) : (
<ContentPanel
loading={props.readerLoading}
title={props.readerContent?.title}
html={props.readerContent?.html}
markdown={props.readerContent?.markdown}
image={props.readerContent?.image}
summary={props.readerContent?.summary}
published={props.readerContent?.published}
selectedUrl={props.selectedUrl}
highlights={props.classifiedHighlights}
showHighlights={props.showHighlights}
highlightStyle={props.settings.highlightStyle || 'marker'}
highlightColor={props.settings.highlightColor || '#ffff00'}
onHighlightClick={props.onHighlightClick}
selectedHighlightId={props.selectedHighlightId}
highlightVisibility={props.highlightVisibility}
onTextSelection={props.onTextSelection}
onClearSelection={props.onClearSelection}
currentUserPubkey={props.currentUserPubkey}
followedPubkeys={props.followedPubkeys}
settings={props.settings}
relayPool={props.relayPool}
activeAccount={props.activeAccount}
currentArticle={props.currentArticle}
isSidebarCollapsed={props.isCollapsed}
isHighlightsCollapsed={props.isHighlightsCollapsed}
/>
)}
) : (() => {
// Determine if this is a video URL
const isNostrArticle = props.selectedUrl && props.selectedUrl.startsWith('nostr:')
const isExternalVideo = !isNostrArticle && !!props.selectedUrl && ['youtube', 'video'].includes(classifyUrl(props.selectedUrl).type)
if (isExternalVideo) {
return (
<VideoView
videoUrl={props.selectedUrl!}
title={props.readerContent?.title}
image={props.readerContent?.image}
summary={props.readerContent?.summary}
published={props.readerContent?.published}
settings={props.settings}
relayPool={props.relayPool}
activeAccount={props.activeAccount}
onOpenHighlights={() => {
if (props.isHighlightsCollapsed) {
props.onToggleHighlightsPanel()
}
}}
/>
)
}
return (
<ContentPanel
loading={props.readerLoading}
title={props.readerContent?.title}
html={props.readerContent?.html}
markdown={props.readerContent?.markdown}
image={props.readerContent?.image}
summary={props.readerContent?.summary}
published={props.readerContent?.published}
selectedUrl={props.selectedUrl}
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
? props.highlights // article-specific highlights only
: props.classifiedHighlights}
showHighlights={props.showHighlights}
highlightStyle={props.settings.highlightStyle || 'marker'}
highlightColor={props.settings.highlightColor || '#ffff00'}
onHighlightClick={props.onHighlightClick}
selectedHighlightId={props.selectedHighlightId}
highlightVisibility={props.highlightVisibility}
onTextSelection={props.onTextSelection}
onClearSelection={props.onClearSelection}
currentUserPubkey={props.currentUserPubkey}
followedPubkeys={props.followedPubkeys}
settings={props.settings}
relayPool={props.relayPool}
activeAccount={props.activeAccount}
currentArticle={props.currentArticle}
isSidebarCollapsed={props.isCollapsed}
isHighlightsCollapsed={props.isHighlightsCollapsed}
onOpenHighlights={() => {
if (props.isHighlightsCollapsed) {
props.onToggleHighlightsPanel()
}
}}
/>
)
})()}
</div>
<div
ref={highlightsRef}
className={`pane highlights ${isMobile && !props.isHighlightsCollapsed ? 'mobile-open' : ''}`}
aria-hidden={isMobile && props.isHighlightsCollapsed}
{...(isMobile && props.isHighlightsCollapsed ? { inert: '' } : {})}
>
<HighlightsPanel
highlights={props.highlights}
@@ -411,6 +461,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
relayPool={props.relayPool}
eventStore={props.eventStore}
settings={props.settings}
isMobile={isMobile}
/>
</div>
</div>

View File

@@ -0,0 +1,158 @@
import { useMemo, forwardRef } from 'react'
import ReactPlayer from 'react-player'
import { classifyUrl } from '../utils/helpers'
interface VideoEmbedProcessorProps {
html: string
renderVideoLinksAsEmbeds: boolean
className?: string
}
/**
* Component that processes HTML content and optionally embeds video links
* as ReactPlayer components when renderVideoLinksAsEmbeds is enabled
*/
const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>(({
html,
renderVideoLinksAsEmbeds,
className
}, ref) => {
// Process HTML and extract video URLs in a single pass to keep them in sync
const { processedHtml, videoUrls } = useMemo(() => {
if (!renderVideoLinksAsEmbeds || !html) {
return { processedHtml: html, videoUrls: [] }
}
// Process HTML in stages: <video> blocks, <img> tags with video src, and bare video URLs
let result = html
const collectedUrls: string[] = []
let placeholderIndex = 0
// 1) Replace entire <video>...</video> blocks when they reference a video URL
const videoBlockPattern = /<video[^>]*>[\s\S]*?<\/video>/gi
const videoBlocks = result.match(videoBlockPattern) || []
videoBlocks.forEach((block) => {
// Try src on <video>
let url: string | null = null
const videoSrcMatch = block.match(/<video[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
if (videoSrcMatch && videoSrcMatch[1]) {
url = videoSrcMatch[1]
} else {
// Try nested <source>
const sourceSrcMatch = block.match(/<source[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
if (sourceSrcMatch && sourceSrcMatch[1]) {
url = sourceSrcMatch[1]
}
}
if (url) {
collectedUrls.push(url)
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
const escaped = block.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
result = result.replace(new RegExp(escaped, 'g'), placeholder)
placeholderIndex++
}
})
// 2) Replace entire <img ...> tags if their src points to a video
const imgTagPattern = /<img[^>]*>/gi
const allImgTags = result.match(imgTagPattern) || []
allImgTags.forEach((imgTag) => {
const srcMatch = imgTag.match(/src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?/i)
if (srcMatch && srcMatch[1]) {
const videoUrl = srcMatch[1]
collectedUrls.push(videoUrl)
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
const escapedTag = imgTag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
result = result.replace(new RegExp(escapedTag, 'g'), placeholder)
placeholderIndex++
}
})
// 3) Replace remaining bare video URLs (direct files or recognized video platforms)
const fileVideoPattern = /https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)(?:\?[^\s<>"']*)?/gi
const fileVideoUrls: string[] = result.match(fileVideoPattern) || []
const allUrlPattern = /https?:\/\/[^\s<>"']+(?=\s|>|"|'|$)/gi
const allUrls: string[] = result.match(allUrlPattern) || []
const platformVideoUrls = allUrls.filter(url => {
// include URLs classified as video and not already collected
const classification = classifyUrl(url)
return classification.type === 'video' && !collectedUrls.includes(url)
})
const remainingUrls = [...fileVideoUrls, ...platformVideoUrls].filter(url => !collectedUrls.includes(url))
let finalHtml = result
remainingUrls.forEach((url) => {
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
finalHtml = finalHtml.replace(new RegExp(url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), placeholder)
collectedUrls.push(url)
placeholderIndex++
})
// Return both processed HTML and collected URLs (in the same order as placeholders)
return {
processedHtml: collectedUrls.length > 0 ? finalHtml : html,
videoUrls: collectedUrls
}
}, [html, renderVideoLinksAsEmbeds])
// If no video embedding is enabled, just render the HTML normally
if (!renderVideoLinksAsEmbeds || videoUrls.length === 0) {
return (
<div
ref={ref}
className={className}
dangerouslySetInnerHTML={{ __html: processedHtml }}
/>
)
}
// Split the HTML by video placeholders and render with embedded players
const parts = processedHtml.split(/(__VIDEO_EMBED_\d+__)/)
return (
<div ref={ref} className={className}>
{parts.map((part, index) => {
const videoMatch = part.match(/^__VIDEO_EMBED_(\d+)__$/)
if (videoMatch) {
const videoIndex = parseInt(videoMatch[1])
const videoUrl = videoUrls[videoIndex]
if (videoUrl) {
return (
<div key={index} className="reader-video" style={{ margin: '1rem 0' }}>
<ReactPlayer
url={videoUrl}
controls
width="100%"
height="auto"
style={{
width: '100%',
height: 'auto',
aspectRatio: '16/9'
}}
/>
</div>
)
}
}
// Regular HTML content - only render if not empty
if (part.trim()) {
return (
<div
key={index}
dangerouslySetInnerHTML={{ __html: part }}
/>
)
}
return null
})}
</div>
)
})
VideoEmbedProcessor.displayName = 'VideoEmbedProcessor'
export default VideoEmbedProcessor

View File

@@ -0,0 +1,320 @@
import React, { useState, useEffect, useRef } from 'react'
import ReactPlayer from 'react-player'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faCheckCircle } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { UserSettings } from '../services/settingsService'
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
import { buildNativeVideoUrl } from '../utils/videoHelpers'
import { getYouTubeThumbnail } from '../utils/imagePreview'
// Helper function to get Vimeo thumbnail
const getVimeoThumbnail = (url: string): string | null => {
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/)
if (!vimeoMatch) return null
const videoId = vimeoMatch[1]
return `https://vumbnail.com/${videoId}.jpg`
}
import {
createWebsiteReaction,
hasMarkedWebsiteAsRead
} from '../services/reactionService'
import { unarchiveWebsite } from '../services/unarchiveService'
import ReaderHeader from './ReaderHeader'
interface VideoViewProps {
videoUrl: string
title?: string
image?: string
summary?: string
published?: number
settings?: UserSettings
relayPool?: RelayPool | null
activeAccount?: IAccount | null
onOpenHighlights?: () => void
}
const VideoView: React.FC<VideoViewProps> = ({
videoUrl,
title,
image,
summary,
published,
settings,
relayPool,
activeAccount,
onOpenHighlights
}) => {
const [isMarkedAsWatched, setIsMarkedAsWatched] = useState(false)
const [isCheckingWatchedStatus, setIsCheckingWatchedStatus] = useState(false)
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
const [showVideoMenu, setShowVideoMenu] = useState(false)
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
const videoMenuRef = useRef<HTMLDivElement>(null)
// Load YouTube metadata when applicable
useEffect(() => {
(async () => {
try {
if (!videoUrl) return setYtMeta(null)
const id = extractYouTubeId(videoUrl)
if (!id) return setYtMeta(null)
const locale = navigator?.language?.split('-')[0] || 'en'
const data = await getYouTubeMeta(id, locale)
if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript })
} catch {
setYtMeta(null)
}
})()
}, [videoUrl])
// Check if video is marked as watched
useEffect(() => {
const checkWatchedStatus = async () => {
if (!activeAccount || !videoUrl) return
setIsCheckingWatchedStatus(true)
try {
const isWatched = relayPool ? await hasMarkedWebsiteAsRead(videoUrl, activeAccount.pubkey, relayPool) : false
setIsMarkedAsWatched(isWatched)
} catch (error) {
console.warn('Failed to check watched status:', error)
} finally {
setIsCheckingWatchedStatus(false)
}
}
checkWatchedStatus()
}, [activeAccount, videoUrl, relayPool])
// Handle click outside to close menu
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
setShowVideoMenu(false)
}
}
if (showVideoMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [showVideoMenu])
// Check menu position for upward opening
useEffect(() => {
const checkMenuPosition = (menuRef: React.RefObject<HTMLDivElement>, setOpenUpward: (upward: boolean) => void) => {
if (!menuRef.current) return
const rect = menuRef.current.getBoundingClientRect()
const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - rect.bottom
const spaceAbove = rect.top
// Open upward if there's more space above and less space below
setOpenUpward(spaceAbove > spaceBelow && spaceBelow < 200)
}
if (showVideoMenu) {
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
}
}, [showVideoMenu])
const formatDuration = (totalSeconds: number): string => {
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = Math.floor(totalSeconds % 60)
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
const ss = String(seconds).padStart(2, '0')
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
}
const handleMarkAsWatched = async () => {
if (!activeAccount || !videoUrl || isCheckingWatchedStatus) return
setIsCheckingWatchedStatus(true)
setShowCheckAnimation(true)
try {
if (isMarkedAsWatched) {
// Unmark as watched
if (relayPool) {
await unarchiveWebsite(videoUrl, activeAccount, relayPool)
}
setIsMarkedAsWatched(false)
} else {
// Mark as watched
if (relayPool) {
await createWebsiteReaction(videoUrl, activeAccount, relayPool)
}
setIsMarkedAsWatched(true)
}
} catch (error) {
console.warn('Failed to update watched status:', error)
} finally {
setIsCheckingWatchedStatus(false)
setTimeout(() => setShowCheckAnimation(false), 1000)
}
}
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
const handleOpenVideoExternal = () => {
window.open(videoUrl, '_blank', 'noopener,noreferrer')
setShowVideoMenu(false)
}
const handleOpenVideoNative = () => {
const native = buildNativeVideoUrl(videoUrl)
if (native) {
window.location.href = native
} else {
window.location.href = videoUrl
}
setShowVideoMenu(false)
}
const handleCopyVideoUrl = async () => {
try {
await navigator.clipboard.writeText(videoUrl)
} catch (e) {
console.warn('Clipboard copy failed', e)
} finally {
setShowVideoMenu(false)
}
}
const handleShareVideoUrl = async () => {
try {
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
title: ytMeta?.title || title || 'Video',
url: videoUrl
})
} else {
await navigator.clipboard.writeText(videoUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowVideoMenu(false)
}
}
const displayTitle = ytMeta?.title || title
const displaySummary = ytMeta?.description || summary
const durationText = videoDurationSec !== null ? formatDuration(videoDurationSec) : null
// Get video thumbnail for cover image
const youtubeThumbnail = getYouTubeThumbnail(videoUrl)
const vimeoThumbnail = getVimeoThumbnail(videoUrl)
const videoThumbnail = youtubeThumbnail || vimeoThumbnail
const displayImage = videoThumbnail || image
return (
<>
<ReaderHeader
title={displayTitle}
image={displayImage}
summary={displaySummary}
published={published}
readingTimeText={durationText}
hasHighlights={false}
highlightCount={0}
settings={settings}
highlights={[]}
highlightVisibility={{ nostrverse: true, friends: true, mine: true }}
onHighlightCountClick={onOpenHighlights}
/>
<div className="reader-video">
<ReactPlayer
url={videoUrl}
controls
width="100%"
height="auto"
style={{
width: '100%',
height: 'auto',
aspectRatio: '16/9'
}}
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
/>
</div>
{displaySummary && (
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
{displaySummary}
</div>
)}
{ytMeta?.transcript && (
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
<h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3>
<div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}>
{ytMeta.transcript}
</div>
</div>
)}
<div className="article-menu-container">
<div className="article-menu-wrapper" ref={videoMenuRef}>
<button
className="article-menu-btn"
onClick={toggleVideoMenu}
title="More options"
>
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showVideoMenu && (
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Link</span>
</button>
<button className="article-menu-item" onClick={handleOpenVideoNative}>
<FontAwesomeIcon icon={faMobileAlt} />
<span>Open in Native App</span>
</button>
<button className="article-menu-item" onClick={handleCopyVideoUrl}>
<FontAwesomeIcon icon={faCopy} />
<span>Copy URL</span>
</button>
<button className="article-menu-item" onClick={handleShareVideoUrl}>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
</div>
)}
</div>
</div>
{activeAccount && (
<div className="mark-as-read-container">
<button
className={`mark-as-read-btn ${isMarkedAsWatched ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
onClick={handleMarkAsWatched}
disabled={isCheckingWatchedStatus}
title={isMarkedAsWatched ? 'Already Marked as Watched' : 'Mark as Watched'}
style={isMarkedAsWatched ? { opacity: 0.85 } : undefined}
>
<FontAwesomeIcon
icon={faCheckCircle}
className={isMarkedAsWatched ? 'check-icon' : 'check-icon-empty'}
/>
<span>{isMarkedAsWatched ? 'Watched' : 'Mark as Watched'}</span>
</button>
</div>
)}
</>
)
}
export default VideoView

17
src/config/bots.ts Normal file
View File

@@ -0,0 +1,17 @@
import { nip19 } from 'nostr-tools'
/**
* Hardcoded list of bot pubkeys (hex format) to hide articles from
* These are accounts known to be bots or automated services
*/
export const BOT_PUBKEYS = new Set([
// Step Counter Bot (npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss)
nip19.decode('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss').data as string,
])
/**
* Check if a pubkey corresponds to a known bot
*/
export function isKnownBot(pubkey: string): boolean {
return BOT_PUBKEYS.has(pubkey)
}

View File

@@ -1,8 +1,9 @@
// Nostr event kinds used throughout the application
export const KINDS = {
Highlights: 9802, // NIP-?? user highlights
Highlights: 9802, // NIP-84 user highlights
BlogPost: 30023, // NIP-23 long-form article
AppData: 30078, // NIP-78 application data (reading positions)
AppData: 30078, // NIP-78 application data
ReadingProgress: 39802, // NIP-85 reading progress
List: 30001, // NIP-51 list (addressable)
ListReplaceable: 30003, // NIP-51 replaceable list
ListSimple: 10003, // NIP-51 simple list
@@ -13,3 +14,9 @@ export const KINDS = {
export type KindValue = typeof KINDS[keyof typeof KINDS]
// Reading progress tracking configuration
export const READING_PROGRESS = {
// Minimum character count to track reading progress (roughly 150 words)
MIN_CONTENT_LENGTH: 1000
} as const

View File

@@ -2,7 +2,7 @@
* Nostr gateway URLs for viewing events and profiles on the web
*/
export const NOSTR_GATEWAY = 'https://nostr.at' as const
export const NOSTR_GATEWAY = 'https://njump.to' as const
export const SEARCH_PORTAL = 'https://ants.sh' as const
/**
@@ -24,7 +24,7 @@ export function getEventUrl(nevent: string): string {
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
*/
export function getNostrUrl(identifier: string): string {
// nostr.at uses simple /{identifier} format for all types
// njump.to uses simple /{identifier} format for all types
return `${NOSTR_GATEWAY}/${identifier}`
}

View File

@@ -11,13 +11,11 @@ export 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',
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87'
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87',
]

View File

@@ -43,21 +43,14 @@ export function useAdaptiveTextColor(imageUrl: string | undefined): AdaptiveText
height: Math.floor(height * 0.25)
})
console.log('Adaptive color detected:', {
hex: color.hex,
rgb: color.rgb,
isLight: color.isLight,
isDark: color.isDark
})
// Color analysis complete
// Use library's built-in isLight check for optimal contrast
if (color.isLight) {
console.log('Light background detected, using black text')
setColors({
textColor: '#000000'
})
} else {
console.log('Dark background detected, using white text')
setColors({
textColor: '#ffffff'
})

View File

@@ -1,20 +1,36 @@
import { useEffect } from 'react'
import { useEffect, useRef, useState, Dispatch, SetStateAction } from 'react'
import { useLocation } from 'react-router-dom'
import { RelayPool } from 'applesauce-relay'
import { fetchArticleByNaddr } from '../services/articleService'
import type { IEventStore } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { Helpers } from 'applesauce-core'
import { queryEvents } from '../services/dataFetch'
import { fetchArticleByNaddr, getFromCache, saveToCache } from '../services/articleService'
import { fetchHighlightsForArticle } from '../services/highlightService'
import { preloadImage } from './useImageCache'
import { ReadableContent } from '../services/readerService'
import { Highlight } from '../types/highlights'
import { NostrEvent } from 'nostr-tools'
import { UserSettings } from '../services/settingsService'
import { useDocumentTitle } from './useDocumentTitle'
interface PreviewData {
title: string
image?: string
summary?: string
published?: number
}
interface UseArticleLoaderProps {
naddr: string | undefined
relayPool: RelayPool | null
eventStore?: IEventStore | null
setSelectedUrl: (url: string) => void
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
setIsCollapsed: (collapsed: boolean) => void
setHighlights: (highlights: Highlight[]) => void
setHighlights: Dispatch<SetStateAction<Highlight[]>>
setHighlightsLoading: (loading: boolean) => void
setCurrentArticleCoordinate: (coord: string | undefined) => void
setCurrentArticleEventId: (id: string | undefined) => void
@@ -25,6 +41,7 @@ interface UseArticleLoaderProps {
export function useArticleLoader({
naddr,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,
@@ -36,79 +53,475 @@ export function useArticleLoader({
setCurrentArticle,
settings
}: UseArticleLoaderProps) {
const location = useLocation()
const mountedRef = useRef(true)
// Hold latest settings without retriggering effect
const settingsRef = useRef<UserSettings | undefined>(settings)
useEffect(() => {
if (!relayPool || !naddr) return
settingsRef.current = settings
}, [settings])
// Track in-flight request to prevent stale updates from previous naddr
const currentRequestIdRef = useRef(0)
// Extract preview data from navigation state (from blog post cards)
const previewData = (location.state as { previewData?: PreviewData })?.previewData
// Track the current article title for document title
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
useDocumentTitle({ title: currentTitle })
useEffect(() => {
mountedRef.current = true
const loadArticle = async () => {
setReaderLoading(true)
// First check: naddr is required
if (!naddr) {
setReaderContent(undefined)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
// Keep highlights panel collapsed by default - only open on user interaction
try {
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
return
}
// Clear readerContent immediately to prevent showing stale content from previous article
// This ensures images from previous articles don't flash briefly
setReaderContent(undefined)
// Synchronously check cache sources BEFORE checking relayPool
// This prevents showing loading skeletons when content is immediately available
// and fixes the race condition where relayPool isn't ready yet
let foundInCache = false
try {
// Check localStorage cache first (synchronous, doesn't need relayPool)
const cachedArticle = getFromCache(naddr)
if (cachedArticle) {
foundInCache = true
const title = cachedArticle.title || 'Untitled Article'
setCurrentTitle(title)
setReaderContent({
title: article.title,
markdown: article.markdown,
image: article.image,
summary: article.summary,
published: article.published,
title,
markdown: cachedArticle.markdown,
image: cachedArticle.image,
summary: cachedArticle.summary,
published: cachedArticle.published,
url: `nostr:${naddr}`
})
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
const dTag = cachedArticle.event.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(article.event.id)
setCurrentArticle?.(article.event)
console.log('📰 Article loaded:', article.title)
console.log('📍 Coordinate:', articleCoordinate)
// Set reader loading to false immediately after article content is ready
// Don't wait for highlights to finish loading
setCurrentArticleEventId(cachedArticle.event.id)
setCurrentArticle?.(cachedArticle.event)
setReaderLoading(false)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
// Fetch highlights asynchronously without blocking article display
// Stream them as they arrive for instant rendering
try {
setHighlightsLoading(true)
setHighlights([]) // Clear old highlights
const highlightsMap = new Map<string, Highlight>()
// Preload image if available to ensure it's cached by Service Worker
// This ensures images are available when offline
if (cachedArticle.image) {
preloadImage(cachedArticle.image)
}
// Store in EventStore for future lookups
if (eventStore) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eventStore.add?.(cachedArticle.event as unknown as any)
} catch {
// Silently ignore store errors
}
}
// Fetch highlights in background (don't block UI)
// Only fetch highlights if relayPool is available
if (mountedRef.current && relayPool) {
const dTag = cachedArticle.event.tags.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coord = dTag ? `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}` : undefined
const eventId = cachedArticle.event.id
await fetchHighlightsForArticle(
relayPool,
articleCoordinate,
article.event.id,
(highlight) => {
// Deduplicate highlights by ID as they arrive
if (!highlightsMap.has(highlight.id)) {
highlightsMap.set(highlight.id, highlight)
const highlightsList = Array.from(highlightsMap.values())
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
if (coord && eventId) {
setHighlightsLoading(true)
fetchHighlightsForArticle(
relayPool,
coord,
eventId,
(highlight) => {
if (!mountedRef.current) return
setHighlights((prev: Highlight[]) => {
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
const next = [highlight, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
},
settings,
false,
eventStore || undefined
).then(() => {
if (mountedRef.current) {
setHighlightsLoading(false)
}
},
settings
)
console.log(`📌 Found ${highlightsMap.size} highlights`)
}).catch(() => {
if (mountedRef.current) {
setHighlightsLoading(false)
}
})
}
}
// Return early - we have cached content, no need to query relays
return
}
} catch (err) {
// If cache check fails, fall through to async loading
console.warn('[article-loader] Cache check failed:', err)
}
// Check EventStore synchronously (also doesn't need relayPool)
let foundInEventStore = false
if (eventStore && !foundInCache) {
try {
// Decode naddr to get the coordinate
const decoded = nip19.decode(naddr)
if (decoded.type === 'naddr') {
const pointer = decoded.data as AddressPointer
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
const storedEvent = eventStore.getEvent?.(coordinate)
if (storedEvent) {
foundInEventStore = true
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
setCurrentTitle(title)
const image = Helpers.getArticleImage(storedEvent)
const summary = Helpers.getArticleSummary(storedEvent)
const published = Helpers.getArticlePublished(storedEvent)
setReaderContent({
title,
markdown: storedEvent.content,
image,
summary,
published,
url: `nostr:${naddr}`
})
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(storedEvent.id)
setCurrentArticle?.(storedEvent)
setReaderLoading(false)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
// Fetch highlights in background if relayPool is available
if (relayPool) {
const coord = dTag ? `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}` : undefined
const eventId = storedEvent.id
if (coord && eventId) {
setHighlightsLoading(true)
fetchHighlightsForArticle(
relayPool,
coord,
eventId,
(highlight) => {
if (!mountedRef.current) return
setHighlights((prev: Highlight[]) => {
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
const next = [highlight, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
},
settings,
false,
eventStore || undefined
).then(() => {
if (mountedRef.current) {
setHighlightsLoading(false)
}
}).catch(() => {
if (mountedRef.current) {
setHighlightsLoading(false)
}
})
}
}
// Return early - we have EventStore content, no need to query relays yet
// But we might want to fetch from relays in background if relayPool becomes available
return
}
}
} catch (err) {
// Ignore store errors, fall through to relay query
console.warn('[article-loader] EventStore check failed:', err)
}
}
// Only return early if we have no content AND no relayPool to fetch from
if (!relayPool && !foundInCache && !foundInEventStore) {
setReaderLoading(true)
setReaderContent(undefined)
return
}
// If we have relayPool, proceed with async loading
if (!relayPool) {
return
}
const loadArticle = async () => {
const requestId = ++currentRequestIdRef.current
if (!mountedRef.current) {
return
}
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
// Don't clear highlights yet - let the smart filtering logic handle it
// when we know the article coordinate
setHighlightsLoading(false) // Don't show loading yet
// Note: Cache and EventStore were already checked synchronously above
// This async function only runs if we need to fetch from relays
// At this point, we've checked EventStore and cache - neither had content
// Only show loading skeleton if we also don't have preview data
if (previewData) {
// If we have preview data from navigation, show it immediately (no skeleton!)
setCurrentTitle(previewData.title)
setReaderContent({
title: previewData.title,
markdown: '', // Will be loaded from relay
image: previewData.image,
summary: previewData.summary,
published: previewData.published,
url: `nostr:${naddr}`
})
setReaderLoading(false) // Turn off loading immediately - we have the preview!
// Don't preload image here - it should already be cached from BlogPostCard
// Preloading again would be redundant and could cause unnecessary network requests
} else {
// No cache, no EventStore, no preview data - need to load from relays
setReaderLoading(true)
setReaderContent(undefined)
}
try {
// Decode naddr to filter
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
throw new Error('Invalid naddr format')
}
const pointer = decoded.data as AddressPointer
const filter = {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier]
}
let firstEmitted = false
let latestEvent: NostrEvent | null = null
// Stream local-first via queryEvents; rely on EOSE (no timeouts)
const events = await queryEvents(relayPool, filter, {
onEvent: (evt) => {
if (!mountedRef.current) {
return
}
if (currentRequestIdRef.current !== requestId) {
return
}
// Store in event store for future local reads
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eventStore?.add?.(evt as unknown as any)
} catch {
// Silently ignore store errors
}
// Keep latest by created_at
if (!latestEvent || evt.created_at > latestEvent.created_at) {
latestEvent = evt
}
// Emit immediately on first event
if (!firstEmitted) {
firstEmitted = true
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
const image = Helpers.getArticleImage(evt)
const summary = Helpers.getArticleSummary(evt)
const published = Helpers.getArticlePublished(evt)
setCurrentTitle(title)
setReaderContent({
title,
markdown: evt.content,
image,
summary,
published,
url: `nostr:${naddr}`
})
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${evt.kind}:${evt.pubkey}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(evt.id)
setCurrentArticle?.(evt)
setReaderLoading(false)
// Save to cache immediately when we get the first event
// Don't wait for queryEvents to complete in case it hangs
const articleContent = {
title,
markdown: evt.content,
image,
summary,
published,
author: evt.pubkey,
event: evt
}
saveToCache(naddr, articleContent, settings)
// Preload image to ensure it's cached by Service Worker
if (image) {
preloadImage(image)
}
}
}
})
if (!mountedRef.current || currentRequestIdRef.current !== requestId) {
return
}
// Finalize with newest version if it's newer than what we first rendered
const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent
if (finalEvent) {
const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article'
const image = Helpers.getArticleImage(finalEvent)
const summary = Helpers.getArticleSummary(finalEvent)
const published = Helpers.getArticlePublished(finalEvent)
setCurrentTitle(title)
setReaderContent({
title,
markdown: finalEvent.content,
image,
summary,
published,
url: `nostr:${naddr}`
})
const dTag = finalEvent.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${finalEvent.kind}:${finalEvent.pubkey}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(finalEvent.id)
setCurrentArticle?.(finalEvent)
// Save to cache for future loads (if we haven't already saved from first event)
// Only save if this is a different/newer event than what we first rendered
// Note: We already saved from first event, so only save if this is different
if (!firstEmitted) {
// First event wasn't emitted, so save now
const articleContent = {
title,
markdown: finalEvent.content,
image,
summary,
published,
author: finalEvent.pubkey,
event: finalEvent
}
saveToCache(naddr, articleContent)
}
} else {
// As a last resort, fall back to the legacy helper (which includes cache)
const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current)
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
setCurrentTitle(article.title)
setReaderContent({
title: article.title,
markdown: article.markdown,
image: article.image,
summary: article.summary,
published: article.published,
url: `nostr:${naddr}`
})
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(article.event.id)
setCurrentArticle?.(article.event)
}
// Fetch highlights after content is shown
try {
if (!mountedRef.current) return
const le = latestEvent as NostrEvent | null
const dTag = le ? (le.tags.find((t: string[]) => t[0] === 'd')?.[1] || '') : ''
const coord = le && dTag ? `${le.kind}:${le.pubkey}:${dTag}` : undefined
const eventId = le ? le.id : undefined
if (coord && eventId) {
setHighlightsLoading(true)
// Clear highlights that don't belong to this article coordinate
setHighlights((prev) => {
return prev.filter(h => {
// Keep highlights that match this article coordinate or event ID
return h.eventReference === coord || h.eventReference === eventId
})
})
await fetchHighlightsForArticle(
relayPool,
coord,
eventId,
(highlight) => {
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
setHighlights((prev: Highlight[]) => {
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
const next = [highlight, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
},
settingsRef.current,
false, // force
eventStore || undefined
)
} else {
// No article event to fetch highlights for - clear and don't show loading
setHighlights([])
setHighlightsLoading(false)
}
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
setHighlightsLoading(false)
if (mountedRef.current && currentRequestIdRef.current === requestId) {
setHighlightsLoading(false)
}
}
} catch (err) {
console.error('Failed to load article:', err)
setReaderContent({
title: 'Error Loading Article',
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url: `nostr:${naddr}`
})
setReaderLoading(false)
if (mountedRef.current && currentRequestIdRef.current === requestId) {
setReaderContent({
title: 'Error Loading Article',
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url: `nostr:${naddr}`
})
setReaderLoading(false)
}
}
}
loadArticle()
}, [naddr, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, setCurrentArticle, settings])
return () => {
mountedRef.current = false
}
// Include relayPool in dependencies so effect re-runs when it becomes available
// This fixes the race condition where articles don't load on direct navigation
// We guard against unnecessary re-renders by checking cache/EventStore first
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
naddr,
previewData,
relayPool
])
}

View File

@@ -11,6 +11,7 @@ import { contactsController } from '../services/contactsController'
import { useStoreTimeline } from './useStoreTimeline'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { KINDS } from '../config/kinds'
import { nip19 } from 'nostr-tools'
interface UseBookmarksDataParams {
relayPool: RelayPool | null
@@ -44,21 +45,38 @@ export const useBookmarksData = ({
const [isRefreshing, setIsRefreshing] = useState(false)
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
// Determine effective article coordinate as early as possible
// Prefer state-derived coordinate, but fall back to route naddr before content loads
const effectiveArticleCoordinate = useMemo(() => {
if (currentArticleCoordinate) return currentArticleCoordinate
if (!naddr) return undefined
try {
const decoded = nip19.decode(naddr)
if (decoded.type === 'naddr') {
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
return `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
}
} catch {
// ignore decode failure; treat as no coordinate yet
}
return undefined
}, [currentArticleCoordinate, naddr])
// Load cached article-specific highlights from event store
const articleFilter = useMemo(() => {
if (!currentArticleCoordinate) return null
if (!effectiveArticleCoordinate) return null
return {
kinds: [KINDS.Highlights],
'#a': [currentArticleCoordinate],
'#a': [effectiveArticleCoordinate],
...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {})
}
}, [currentArticleCoordinate, currentArticleEventId])
}, [effectiveArticleCoordinate, currentArticleEventId])
const cachedArticleHighlights = useStoreTimeline(
eventStore || null,
articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article
eventToHighlight,
[currentArticleCoordinate, currentArticleEventId]
[effectiveArticleCoordinate, currentArticleEventId]
)
// Subscribe to centralized controllers
@@ -84,7 +102,7 @@ export const useBookmarksData = ({
setHighlightsLoading(true)
try {
if (currentArticleCoordinate) {
if (effectiveArticleCoordinate) {
// Seed with cached highlights first
if (cachedArticleHighlights.length > 0) {
setArticleHighlights(cachedArticleHighlights.sort((a, b) => b.created_at - a.created_at))
@@ -97,7 +115,7 @@ export const useBookmarksData = ({
await fetchHighlightsForArticle(
relayPool,
currentArticleCoordinate,
effectiveArticleCoordinate,
currentArticleEventId,
(highlight) => {
// Deduplicate highlights by ID as they arrive
@@ -120,7 +138,7 @@ export const useBookmarksData = ({
} finally {
setHighlightsLoading(false)
}
}, [relayPool, currentArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
}, [relayPool, effectiveArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
const handleRefreshAll = useCallback(async () => {
if (!relayPool || !activeAccount || isRefreshing) return
@@ -140,22 +158,29 @@ export const useBookmarksData = ({
// Fetch article-specific highlights when viewing an article
useEffect(() => {
if (!relayPool || !activeAccount) return
if (!relayPool || !activeAccount) {
setHighlightsLoading(false)
return
}
// Fetch article-specific highlights when viewing an article
// External URLs have their highlights fetched by useExternalUrlLoader
if (currentArticleCoordinate && !externalUrl) {
if (effectiveArticleCoordinate && !externalUrl) {
handleFetchHighlights()
} else if (!naddr && !externalUrl) {
// Clear article highlights when not viewing an article
setArticleHighlights([])
setHighlightsLoading(false)
} else {
// For external URLs or other cases, loading is not needed
setHighlightsLoading(false)
}
}, [relayPool, activeAccount, currentArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
}, [relayPool, activeAccount, effectiveArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
// Merge highlights from controller with article-specific highlights
const highlights = [...myHighlights, ...articleHighlights]
.filter((h, i, arr) => arr.findIndex(x => x.id === h.id) === i) // Deduplicate
.sort((a, b) => b.created_at - a.created_at)
// When viewing an article, show only article-specific highlights
// Otherwise, show user's highlights from controller
const highlights = effectiveArticleCoordinate || externalUrl
? articleHighlights.sort((a, b) => b.created_at - a.created_at)
: myHighlights
return {
highlights,

View File

@@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react'
const DEFAULT_TITLE = 'Boris - Read, Highlight, Explore'
interface UseDocumentTitleProps {
title?: string
fallback?: string
}
export function useDocumentTitle({ title, fallback }: UseDocumentTitleProps) {
const originalTitleRef = useRef<string>(document.title)
useEffect(() => {
// Store the original title on first mount
if (originalTitleRef.current === DEFAULT_TITLE) {
originalTitleRef.current = document.title
}
// Set the new title if provided, otherwise use fallback or default
const newTitle = title || fallback || DEFAULT_TITLE
document.title = newTitle
// Cleanup: restore original title when component unmounts
return () => {
document.title = originalTitleRef.current
}
}, [title, fallback])
// Return a function to manually reset to default
const resetTitle = () => {
document.title = DEFAULT_TITLE
}
return { resetTitle }
}

143
src/hooks/useEventLoader.ts Normal file
View File

@@ -0,0 +1,143 @@
import { useEffect, useCallback, useState } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { NostrEvent } from 'nostr-tools'
import { ReadableContent } from '../services/readerService'
import { eventManager } from '../services/eventManager'
import { fetchProfiles } from '../services/profileService'
import { useDocumentTitle } from './useDocumentTitle'
import { getNpubFallbackDisplay } from '../utils/nostrUriResolver'
import { extractProfileDisplayName } from '../utils/profileUtils'
interface UseEventLoaderProps {
eventId?: string
relayPool?: RelayPool | null
eventStore?: IEventStore | null
setSelectedUrl: (url: string) => void
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
setIsCollapsed: (collapsed: boolean) => void
}
export function useEventLoader({
eventId,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed
}: UseEventLoaderProps) {
// Track the current event title for document title
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
useDocumentTitle({ title: currentTitle })
const displayEvent = useCallback((event: NostrEvent) => {
// Escape HTML in content and convert newlines to breaks for plain text display
const escapedContent = event.content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br />')
// Initial title
let title = `Note (${event.kind})`
if (event.kind === 1) {
title = `Note by ${getNpubFallbackDisplay(event.pubkey)}`
}
// Emit immediately
const baseContent: ReadableContent = {
url: '',
html: `<div style="white-space: pre-wrap; word-break: break-word;">${escapedContent}</div>`,
title,
published: event.created_at
}
setCurrentTitle(title)
setReaderContent(baseContent)
// Background: resolve author profile for kind:1 and update title
if (event.kind === 1 && eventStore) {
(async () => {
try {
let resolved = ''
// First, try to get from event store cache
const storedProfile = eventStore.getEvent(event.pubkey + ':0')
if (storedProfile) {
const displayName = extractProfileDisplayName(storedProfile as NostrEvent)
if (displayName && !displayName.startsWith('@')) {
// Remove @ prefix if present (we'll add it when displaying)
resolved = displayName
} else if (displayName) {
resolved = displayName.substring(1) // Remove @ prefix
}
}
// If not found in event store, fetch from relays
if (!resolved && relayPool) {
const profiles = await fetchProfiles(relayPool, eventStore as unknown as IEventStore, [event.pubkey])
if (profiles && profiles.length > 0) {
const latest = profiles.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
const displayName = extractProfileDisplayName(latest)
if (displayName && !displayName.startsWith('@')) {
resolved = displayName
} else if (displayName) {
resolved = displayName.substring(1) // Remove @ prefix
}
}
}
if (resolved) {
const updatedTitle = `Note by @${resolved}`
setCurrentTitle(updatedTitle)
setReaderContent({ ...baseContent, title: updatedTitle })
}
} catch {
// ignore profile failures; keep fallback title
}
})()
}
}, [setReaderContent, relayPool, eventStore])
// Initialize event manager with services
useEffect(() => {
eventManager.setServices(eventStore || null, relayPool || null)
}, [eventStore, relayPool])
useEffect(() => {
if (!eventId) return
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(`nostr-event:${eventId}`) // sentinel: truthy selection, not treated as article
setIsCollapsed(false)
// Fetch using event manager (handles cache, deduplication, and retry)
let cancelled = false
eventManager.fetchEvent(eventId).then(
(event) => {
if (!cancelled) {
displayEvent(event)
setReaderLoading(false)
}
},
(err) => {
if (!cancelled) {
const errorContent: ReadableContent = {
url: '',
html: `<div style="padding: 1rem; color: var(--color-error, red);">Failed to load event: ${err instanceof Error ? err.message : 'Unknown error'}</div>`,
title: 'Error'
}
setCurrentTitle('Error')
setReaderContent(errorContent)
setReaderLoading(false)
}
}
)
return () => {
cancelled = true
}
}, [eventId, displayEvent, setReaderLoading, setSelectedUrl, setIsCollapsed, setReaderContent])
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'
import { useEffect, useRef, useMemo, useState } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { fetchReadableContent, ReadableContent } from '../services/readerService'
@@ -7,6 +7,7 @@ import { Highlight } from '../types/highlights'
import { useStoreTimeline } from './useStoreTimeline'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { KINDS } from '../config/kinds'
import { useDocumentTitle } from './useDocumentTitle'
// Helper to extract filename from URL
function getFilenameFromUrl(url: string): string {
@@ -48,6 +49,14 @@ export function useExternalUrlLoader({
setCurrentArticleCoordinate,
setCurrentArticleEventId
}: UseExternalUrlLoaderProps) {
const mountedRef = useRef(true)
// Track in-flight request to prevent stale updates when switching quickly
const currentRequestIdRef = useRef(0)
// Track the current content title for document title
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
useDocumentTitle({ title: currentTitle })
// Load cached URL-specific highlights from event store
const urlFilter = useMemo(() => {
if (!url) return null
@@ -61,80 +70,120 @@ export function useExternalUrlLoader({
[url]
)
// Load content and start streaming highlights when URL changes
useEffect(() => {
mountedRef.current = true
if (!relayPool || !url) return
const loadExternalUrl = async () => {
const requestId = ++currentRequestIdRef.current
if (!mountedRef.current) return
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(url)
setIsCollapsed(true)
// Clear article-specific state
setCurrentArticleCoordinate(undefined)
setCurrentArticleEventId(undefined)
try {
const content = await fetchReadableContent(url)
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
setCurrentTitle(content.title)
setReaderContent(content)
console.log('🌐 External URL loaded:', content.title)
// Set reader loading to false immediately after content is ready
setReaderLoading(false)
// Fetch highlights for this URL asynchronously
try {
if (!mountedRef.current) return
setHighlightsLoading(true)
// Seed with cached highlights first
if (cachedUrlHighlights.length > 0) {
setHighlights(cachedUrlHighlights.sort((a, b) => b.created_at - a.created_at))
setHighlights((prev) => {
const seen = new Set<string>(cachedUrlHighlights.map(h => h.id))
const localOnly = prev.filter(h => !seen.has(h.id))
const next = [...cachedUrlHighlights, ...localOnly]
return next.sort((a, b) => b.created_at - a.created_at)
})
} else {
setHighlights([])
}
// Check if fetchHighlightsForUrl exists, otherwise skip
if (typeof fetchHighlightsForUrl === 'function') {
const seen = new Set<string>()
// Seed with cached IDs
cachedUrlHighlights.forEach(h => seen.add(h.id))
await fetchHighlightsForUrl(
relayPool,
url,
(highlight) => {
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
if (seen.has(highlight.id)) return
seen.add(highlight.id)
setHighlights((prev) => {
if (prev.some(h => h.id === highlight.id)) return prev
const next = [...prev, highlight]
const next = [highlight, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
},
undefined, // settings
false, // force
undefined,
false,
eventStore || undefined
)
}
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
setHighlightsLoading(false)
if (mountedRef.current && currentRequestIdRef.current === requestId) {
setHighlightsLoading(false)
}
}
} catch (err) {
console.error('Failed to load external URL:', err)
// For videos and other media files, use the filename as the title
const filename = getFilenameFromUrl(url)
setReaderContent({
title: filename,
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url
})
setReaderLoading(false)
if (mountedRef.current && currentRequestIdRef.current === requestId) {
const filename = getFilenameFromUrl(url)
setReaderContent({
title: filename,
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url
})
setReaderLoading(false)
}
}
}
loadExternalUrl()
}, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, cachedUrlHighlights])
return () => {
mountedRef.current = false
}
// Dependencies intentionally excluded to prevent re-renders when relay/eventStore state changes
// This fixes the loading skeleton appearing when going offline (flight mode)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
url,
cachedUrlHighlights
])
// Keep UI highlights synced with cached store updates without reloading content
useEffect(() => {
if (!url) return
if (cachedUrlHighlights.length === 0) return
setHighlights((prev) => {
const seen = new Set<string>(prev.map(h => h.id))
const additions = cachedUrlHighlights.filter(h => !seen.has(h.id))
if (additions.length === 0) return prev
const next = [...additions, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
}, [cachedUrlHighlights, url, setHighlights])
}

View File

@@ -3,6 +3,7 @@ import { Highlight } from '../types/highlights'
import { HighlightVisibility } from '../components/HighlightsPanel'
import { normalizeUrl } from '../utils/urlHelpers'
import { classifyHighlights } from '../utils/highlightClassification'
import { nip19 } from 'nostr-tools'
interface UseFilteredHighlightsParams {
highlights: Highlight[]
@@ -24,8 +25,29 @@ export const useFilteredHighlights = ({
let urlFiltered = highlights
// For Nostr articles, we already fetched highlights specifically for this article
if (!selectedUrl.startsWith('nostr:')) {
// Filter highlights based on URL type
if (selectedUrl.startsWith('nostr:')) {
// For Nostr articles, extract the article coordinate and filter by eventReference
try {
const decoded = nip19.decode(selectedUrl.replace('nostr:', ''))
if (decoded.type === 'naddr') {
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
const articleCoordinate = `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
urlFiltered = highlights.filter(h => {
// Keep highlights that match this article coordinate
return h.eventReference === articleCoordinate
})
} else {
// Not a valid naddr, clear all highlights
urlFiltered = []
}
} catch {
// Invalid naddr, clear all highlights
urlFiltered = []
}
} else {
// For web URLs, filter by URL matching
const normalizedSelected = normalizeUrl(selectedUrl)
urlFiltered = highlights.filter(h => {

View File

@@ -44,6 +44,7 @@ export const useHighlightCreation = ({
}, [])
const handleCreateHighlight = useCallback(async (text: string) => {
if (!activeAccount || !relayPool || !eventStore) {
console.error('Missing requirements for highlight creation')
return
@@ -60,8 +61,6 @@ export const useHighlightCreation = ({
? currentArticle.content
: readerContent?.markdown || readerContent?.html
console.log('🎯 Creating highlight...', { text: text.substring(0, 50) + '...' })
const newHighlight = await createHighlight(
text,
source,
@@ -73,13 +72,7 @@ export const useHighlightCreation = ({
settings
)
console.log('✅ Highlight created successfully!', {
id: newHighlight.id,
isLocalOnly: newHighlight.isLocalOnly,
isOfflineCreated: newHighlight.isOfflineCreated,
publishedRelays: newHighlight.publishedRelays
})
// Highlight created successfully
// Clear the browser's text selection immediately to allow DOM update
const selection = window.getSelection()
if (selection) {

View File

@@ -93,26 +93,37 @@ export const useHighlightInteractions = ({
return () => clearTimeout(timeoutId)
}, [selectedHighlightId, contentVersion])
// Handle text selection (works for both mouse and touch)
const handleSelectionEnd = useCallback(() => {
setTimeout(() => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
onClearSelection?.()
return
}
// Shared function to check and handle text selection
const checkSelection = useCallback(() => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
onClearSelection?.()
return
}
const range = selection.getRangeAt(0)
const text = selection.toString().trim()
const range = selection.getRangeAt(0)
const text = selection.toString().trim()
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
onTextSelection?.(text)
} else {
onClearSelection?.()
}
}, 10)
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
onTextSelection?.(text)
} else {
onClearSelection?.()
}
}, [onTextSelection, onClearSelection])
return { contentRef, handleSelectionEnd }
// Listen to selectionchange events for immediate detection (works reliably on mobile)
useEffect(() => {
const handleSelectionChange = () => {
// Use requestAnimationFrame to ensure selection is checked after browser updates
requestAnimationFrame(checkSelection)
}
document.addEventListener('selectionchange', handleSelectionChange)
return () => {
document.removeEventListener('selectionchange', handleSelectionChange)
}
}, [checkSelection])
return { contentRef }
}

View File

@@ -32,14 +32,7 @@ export const useHighlightedContent = ({
}: UseHighlightedContentParams) => {
// Filter highlights by URL and visibility settings
const relevantHighlights = useMemo(() => {
console.log('🔍 ContentPanel: Processing highlights', {
totalHighlights: highlights.length,
selectedUrl,
showHighlights
})
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
console.log('📌 URL filtered highlights:', urlFiltered.length)
// Apply visibility filtering
const classified = classifyHighlights(urlFiltered, currentUserPubkey, followedPubkeys)
@@ -49,37 +42,25 @@ export const useHighlightedContent = ({
return highlightVisibility.nostrverse
})
console.log('✅ Relevant highlights after filtering:', filtered.length, filtered.map(h => h.content.substring(0, 30)))
return filtered
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys, showHighlights])
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys])
// Prepare the final HTML with highlights applied
const finalHtml = useMemo(() => {
const sourceHtml = markdown ? renderedMarkdownHtml : html
console.log('🎨 Preparing final HTML:', {
hasMarkdown: !!markdown,
hasHtml: !!html,
renderedHtmlLength: renderedMarkdownHtml.length,
sourceHtmlLength: sourceHtml?.length || 0,
showHighlights,
relevantHighlightsCount: relevantHighlights.length
})
// Prepare final HTML
if (!sourceHtml) {
console.warn('⚠️ No source HTML available')
return ''
}
if (showHighlights && relevantHighlights.length > 0) {
console.log('✨ Applying', relevantHighlights.length, 'highlights to HTML')
const highlightedHtml = applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
console.log('✅ Highlights applied, result length:', highlightedHtml.length)
return highlightedHtml
}
console.log('📄 Returning source HTML without highlights')
return sourceHtml
}, [html, renderedMarkdownHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
return { finalHtml, relevantHighlights }

View File

@@ -10,6 +10,8 @@ export function useImageCache(
imageUrl: string | undefined
): string | undefined {
// Service Worker handles everything - just return the URL as-is
// The Service Worker will intercept fetch requests and cache them
// Make sure images use standard <img src> tags for SW interception
return imageUrl
}
@@ -26,3 +28,26 @@ export function useCacheImageOnLoad(
void imageUrl
}
/**
* Preload an image URL to ensure it's cached by the Service Worker
* This is useful when loading content from cache - we want to ensure
* images are cached before going offline
*/
export function preloadImage(imageUrl: string | undefined): void {
if (!imageUrl) {
return
}
// Create a link element with rel=prefetch or use Image object to trigger fetch
// Service Worker will intercept and cache the request
const img = new Image()
img.src = imageUrl
// Also try using fetch to explicitly trigger Service Worker
// This ensures the image is cached even if <img> tag hasn't rendered yet
fetch(imageUrl, { mode: 'no-cors' }).catch(() => {
// Ignore errors - image might not be CORS-enabled, but SW will still cache it
// The Image() approach above will work for most cases
})
}

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { RelayPool } from 'applesauce-relay'
import { extractNaddrUris, replaceNostrUrisInMarkdown, replaceNostrUrisInMarkdownWithTitles } from '../utils/nostrUriResolver'
import { extractNaddrUris, replaceNostrUrisInMarkdownWithProfileLabels, addLoadingClassToProfileLinks } from '../utils/nostrUriResolver'
import { fetchArticleTitles } from '../services/articleTitleResolver'
import { useProfileLabels } from './useProfileLabels'
/**
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
@@ -18,59 +19,129 @@ export const useMarkdownToHTML = (
const previewRef = useRef<HTMLDivElement>(null)
const [renderedHtml, setRenderedHtml] = useState<string>('')
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
const [articleTitles, setArticleTitles] = useState<Map<string, string>>(new Map())
// Resolve profile labels progressively as profiles load
const { labels: profileLabels, loading: profileLoading } = useProfileLabels(markdown || '', relayPool)
// Create stable dependencies based on Map contents, not Map objects
// This prevents unnecessary reprocessing when Maps are recreated with same content
const profileLabelsKey = useMemo(() => {
const key = Array.from(profileLabels.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}:${v}`).join('|')
return key
}, [profileLabels])
const profileLoadingKey = useMemo(() => {
return Array.from(profileLoading.entries())
.filter(([, loading]) => loading)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k]) => k)
.join('|')
}, [profileLoading])
const articleTitlesKey = useMemo(() => {
return Array.from(articleTitles.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}:${v}`).join('|')
}, [articleTitles])
// Keep refs to latest Maps for processing without causing re-renders
const profileLabelsRef = useRef(profileLabels)
const profileLoadingRef = useRef(profileLoading)
const articleTitlesRef = useRef(articleTitles)
// Ref to track second RAF ID for HTML extraction cleanup
const htmlExtractionRafIdRef = useRef<number | null>(null)
useEffect(() => {
if (!markdown) {
setRenderedHtml('')
setProcessedMarkdown('')
profileLabelsRef.current = profileLabels
profileLoadingRef.current = profileLoading
articleTitlesRef.current = articleTitles
}, [profileLabels, profileLoading, articleTitles])
// Fetch article titles
useEffect(() => {
if (!markdown || !relayPool) {
setArticleTitles(new Map())
return
}
let isCancelled = false
const processMarkdown = async () => {
// Extract all naddr references
const fetchTitles = async () => {
const naddrs = extractNaddrUris(markdown)
let processed: string
if (naddrs.length > 0 && relayPool) {
// Fetch article titles for all naddrs
try {
const articleTitles = await fetchArticleTitles(relayPool, naddrs)
if (isCancelled) return
// Replace nostr URIs with resolved titles
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
console.log(`📚 Resolved ${articleTitles.size} article titles`)
} catch (error) {
console.warn('Failed to fetch article titles:', error)
// Fall back to basic replacement
processed = replaceNostrUrisInMarkdown(markdown)
}
} else {
// No articles to resolve, use basic replacement
processed = replaceNostrUrisInMarkdown(markdown)
if (naddrs.length === 0) {
setArticleTitles(new Map())
return
}
if (isCancelled) return
setProcessedMarkdown(processed)
console.log('📝 Converting markdown to HTML...')
const rafId = requestAnimationFrame(() => {
if (previewRef.current && !isCancelled) {
const html = previewRef.current.innerHTML
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
setRenderedHtml(html)
} else if (!isCancelled) {
console.warn('⚠️ markdownPreviewRef.current is null')
try {
const titlesMap = await fetchArticleTitles(relayPool!, naddrs)
if (!isCancelled) {
setArticleTitles(titlesMap)
}
})
} catch {
if (!isCancelled) setArticleTitles(new Map())
}
}
return () => cancelAnimationFrame(rafId)
fetchTitles()
return () => { isCancelled = true }
}, [markdown, relayPool])
// Track previous markdown and processed state to detect actual content changes
const previousMarkdownRef = useRef<string | undefined>(markdown)
const processedMarkdownRef = useRef<string>(processedMarkdown)
useEffect(() => {
processedMarkdownRef.current = processedMarkdown
}, [processedMarkdown])
// Process markdown with progressive profile labels and article titles
// Use stable string keys instead of Map objects to prevent excessive reprocessing
useEffect(() => {
if (!markdown) {
setRenderedHtml('')
setProcessedMarkdown('')
previousMarkdownRef.current = markdown
processedMarkdownRef.current = ''
return
}
let isCancelled = false
const processMarkdown = () => {
try {
// Replace nostr URIs with profile labels (progressive) and article titles
// Use refs to get latest values without causing dependency changes
const processed = replaceNostrUrisInMarkdownWithProfileLabels(
markdown,
profileLabelsRef.current,
articleTitlesRef.current,
profileLoadingRef.current
)
if (isCancelled) return
setProcessedMarkdown(processed)
processedMarkdownRef.current = processed
// HTML extraction will happen in separate useEffect that watches processedMarkdown
} catch (error) {
console.error(`[markdown-to-html] Error processing markdown:`, error)
if (!isCancelled) {
setProcessedMarkdown(markdown) // Fallback to original
processedMarkdownRef.current = markdown
}
}
}
// Only clear previous content if this is the first processing or markdown changed
// For profile updates, just reprocess without clearing to avoid flicker
const isMarkdownChange = previousMarkdownRef.current !== markdown
previousMarkdownRef.current = markdown
if (isMarkdownChange || !processedMarkdownRef.current) {
setRenderedHtml('')
setProcessedMarkdown('')
processedMarkdownRef.current = ''
}
processMarkdown()
@@ -78,7 +149,44 @@ export const useMarkdownToHTML = (
return () => {
isCancelled = true
}
}, [markdown, relayPool])
}, [markdown, profileLabelsKey, profileLoadingKey, articleTitlesKey])
// Extract HTML after processedMarkdown renders
// This useEffect watches processedMarkdown and extracts HTML once ReactMarkdown has rendered it
useEffect(() => {
if (!processedMarkdown || !markdown) {
return
}
let isCancelled = false
// Use double RAF to ensure ReactMarkdown has finished rendering:
// First RAF: let React complete its render cycle
// Second RAF: extract HTML after DOM has updated
const rafId1 = requestAnimationFrame(() => {
htmlExtractionRafIdRef.current = requestAnimationFrame(() => {
if (previewRef.current && !isCancelled) {
let html = previewRef.current.innerHTML
// Post-process HTML to add loading class to profile links
html = addLoadingClassToProfileLinks(html, profileLoadingRef.current)
setRenderedHtml(html)
} else if (!isCancelled && processedMarkdown) {
console.warn('⚠️ markdownPreviewRef.current is null but processedMarkdown exists')
}
})
})
return () => {
isCancelled = true
cancelAnimationFrame(rafId1)
if (htmlExtractionRafIdRef.current !== null) {
cancelAnimationFrame(htmlExtractionRafIdRef.current)
htmlExtractionRafIdRef.current = null
}
}
}, [processedMarkdown, markdown])
return { renderedHtml, previewRef, processedMarkdown }
}

View File

@@ -0,0 +1,28 @@
import { useRef, useEffect, useCallback } from 'react'
/**
* Hook to track if component is mounted and prevent state updates after unmount.
* Returns a function to check if still mounted.
*
* @example
* const isMounted = useMountedState()
*
* async function loadData() {
* const data = await fetch(...)
* if (isMounted()) {
* setState(data)
* }
* }
*/
export function useMountedState(): () => boolean {
const mountedRef = useRef(true)
useEffect(() => {
return () => {
mountedRef.current = false
}
}, [])
return useCallback(() => mountedRef.current, [])
}

View File

@@ -50,16 +50,10 @@ export function useOfflineSync({
const isNowOnline = hasRemoteRelays
if (wasLocalOnly && isNowOnline) {
console.log('✈️ Detected transition: Flight Mode → Online')
console.log('📊 Relay state:', {
connectedRelays: connectedRelays.length,
remoteRelays: connectedRelays.filter(r => !isLocalRelay(r.url)).length,
localRelays: connectedRelays.filter(r => isLocalRelay(r.url)).length
})
// Coming back online, sync events
// Wait a moment for relays to fully establish connections
setTimeout(() => {
console.log('🚀 Starting sync after delay...')
syncLocalEventsToRemote(relayPool, eventStore)
}, 2000)
}

View File

@@ -5,12 +5,10 @@ export function useOnlineStatus() {
useEffect(() => {
const handleOnline = () => {
console.log('🌐 Back online')
setIsOnline(true)
}
const handleOffline = () => {
console.log('📴 Gone offline')
setIsOnline(false)
}

View File

@@ -51,12 +51,10 @@ export function usePWAInstall() {
const choiceResult = await deferredPrompt.userChoice
if (choiceResult.outcome === 'accepted') {
console.log('✅ PWA installed')
setIsInstallable(false)
setDeferredPrompt(null)
return true
} else {
console.log('❌ PWA installation dismissed')
return false
}
} catch (error) {

View File

@@ -0,0 +1,324 @@
import { useMemo, useState, useEffect, useRef, useCallback } from 'react'
import { Hooks } from 'applesauce-react'
import { Helpers, IEventStore } from 'applesauce-core'
import { getContentPointers } from 'applesauce-factory/helpers'
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { fetchProfiles, loadCachedProfiles } from '../services/profileService'
import { getNpubFallbackDisplay } from '../utils/nostrUriResolver'
import { extractProfileDisplayName } from '../utils/profileUtils'
const { getPubkeyFromDecodeResult, encodeDecodeResult } = Helpers
/**
* Hook to resolve profile labels from content containing npub/nprofile identifiers
* Returns an object with labels Map and loading Map that updates progressively as profiles load
*/
export function useProfileLabels(
content: string,
relayPool?: RelayPool | null
): { labels: Map<string, string>; loading: Map<string, boolean> } {
const eventStore = Hooks.useEventStore()
// Extract profile pointers (npub and nprofile) using applesauce helpers
const profileData = useMemo(() => {
try {
const pointers = getContentPointers(content)
const filtered = pointers.filter(p => p.type === 'npub' || p.type === 'nprofile')
const result: Array<{ pubkey: string; encoded: string }> = []
filtered.forEach(pointer => {
try {
const pubkey = getPubkeyFromDecodeResult(pointer)
const encoded = encodeDecodeResult(pointer)
if (pubkey && encoded) {
result.push({ pubkey, encoded: encoded as string })
}
} catch {
// Ignore errors, continue processing other pointers
}
})
return result
} catch (error) {
console.warn(`[profile-labels] Error extracting profile pointers:`, error)
return []
}
}, [content])
// Initialize labels synchronously from cache on first render to avoid delay
// Use pubkey (hex) as the key instead of encoded string for canonical identification
const initialLabels = useMemo(() => {
if (profileData.length === 0) {
return new Map<string, string>()
}
const allPubkeys = profileData.map(({ pubkey }) => pubkey)
const cachedProfiles = loadCachedProfiles(allPubkeys)
const labels = new Map<string, string>()
profileData.forEach(({ pubkey }) => {
const cachedProfile = cachedProfiles.get(pubkey)
if (cachedProfile) {
const displayName = extractProfileDisplayName(cachedProfile)
if (displayName) {
// Add @ prefix (extractProfileDisplayName returns name without @)
const label = `@${displayName}`
labels.set(pubkey, label)
} else {
// Use fallback npub display if profile has no name (add @ prefix)
const fallback = getNpubFallbackDisplay(pubkey)
labels.set(pubkey, `@${fallback}`)
}
}
})
return labels
}, [profileData])
const [profileLabels, setProfileLabels] = useState<Map<string, string>>(initialLabels)
const [profileLoading, setProfileLoading] = useState<Map<string, boolean>>(new Map())
// Batching strategy: Collect profile updates and apply them in batches via RAF to prevent UI flicker
// when many profiles resolve simultaneously. We use refs to avoid stale closures in async callbacks.
// Use pubkey (hex) as the key for canonical identification
const pendingUpdatesRef = useRef<Map<string, string>>(new Map())
const rafScheduledRef = useRef<number | null>(null)
/**
* Helper to apply pending batched updates to state
* Cancels any scheduled RAF and applies updates synchronously
*/
const applyPendingUpdates = () => {
const pendingUpdates = pendingUpdatesRef.current
if (pendingUpdates.size === 0) {
return
}
// Cancel scheduled RAF since we're applying synchronously
if (rafScheduledRef.current !== null) {
cancelAnimationFrame(rafScheduledRef.current)
rafScheduledRef.current = null
}
// Apply all pending updates in one batch
setProfileLabels(prevLabels => {
const updatedLabels = new Map(prevLabels)
for (const [pubkey, label] of pendingUpdates.entries()) {
updatedLabels.set(pubkey, label)
}
pendingUpdates.clear()
return updatedLabels
})
}
/**
* Helper to schedule a batched update via RAF (if not already scheduled)
* This prevents multiple RAF calls when many profiles resolve at once
* Wrapped in useCallback for stable reference in dependency arrays
*/
const scheduleBatchedUpdate = useCallback(() => {
if (rafScheduledRef.current === null) {
rafScheduledRef.current = requestAnimationFrame(() => {
applyPendingUpdates()
rafScheduledRef.current = null
})
}
}, []) // Empty deps: only uses refs which are stable
// Sync state when initialLabels changes (e.g., when content changes)
// This ensures we start with the correct cached labels even if profiles haven't loaded yet
useEffect(() => {
// Use a functional update to access current state without including it in dependencies
setProfileLabels(prevLabels => {
const currentPubkeys = new Set(Array.from(prevLabels.keys()))
const newPubkeys = new Set(profileData.map(p => p.pubkey))
// If the content changed significantly (different set of profiles), reset state
const hasDifferentProfiles =
currentPubkeys.size !== newPubkeys.size ||
!Array.from(newPubkeys).every(pk => currentPubkeys.has(pk))
if (hasDifferentProfiles) {
// Clear pending updates and cancel RAF for old profiles
pendingUpdatesRef.current.clear()
if (rafScheduledRef.current !== null) {
cancelAnimationFrame(rafScheduledRef.current)
rafScheduledRef.current = null
}
// Reset to initial labels
return new Map(initialLabels)
} else {
// Same profiles, merge initial labels with existing state
// IMPORTANT: Preserve existing labels (from pending updates) and only add initial labels if missing
const merged = new Map(prevLabels)
for (const [pubkey, label] of initialLabels.entries()) {
// Only add initial label if we don't already have a label for this pubkey
// This preserves labels that were added via applyPendingUpdates
if (!merged.has(pubkey)) {
merged.set(pubkey, label)
}
}
return merged
}
})
// Reset loading state when content changes significantly
setProfileLoading(prevLoading => {
const currentPubkeys = new Set(Array.from(prevLoading.keys()))
const newPubkeys = new Set(profileData.map(p => p.pubkey))
const hasDifferentProfiles =
currentPubkeys.size !== newPubkeys.size ||
!Array.from(newPubkeys).every(pk => currentPubkeys.has(pk))
if (hasDifferentProfiles) {
return new Map()
}
return prevLoading
})
}, [initialLabels, profileData])
// Build initial labels: localStorage cache -> eventStore -> fetch from relays
useEffect(() => {
// Extract all pubkeys
const allPubkeys = profileData.map(({ pubkey }) => pubkey)
if (allPubkeys.length === 0) {
setProfileLabels(new Map())
setProfileLoading(new Map())
// Clear pending updates and cancel RAF when clearing labels
pendingUpdatesRef.current.clear()
if (rafScheduledRef.current !== null) {
cancelAnimationFrame(rafScheduledRef.current)
rafScheduledRef.current = null
}
return
}
// Add cached profiles to EventStore for consistency
const cachedProfiles = loadCachedProfiles(allPubkeys)
if (eventStore) {
for (const profile of cachedProfiles.values()) {
eventStore.add(profile)
}
}
// Build labels from localStorage cache and eventStore
// initialLabels already has all cached profiles, so we only need to check eventStore
// Use pubkey (hex) as the key for canonical identification
const labels = new Map<string, string>(initialLabels)
const loading = new Map<string, boolean>()
const pubkeysToFetch: string[] = []
profileData.forEach(({ pubkey }) => {
// Skip if already resolved from initial cache
if (labels.has(pubkey)) {
loading.set(pubkey, false)
return
}
// Check EventStore for profiles that weren't in cache
const eventStoreProfile = eventStore?.getEvent(pubkey + ':0')
if (eventStoreProfile && eventStore) {
// Extract display name using centralized utility
const displayName = extractProfileDisplayName(eventStoreProfile as NostrEvent)
if (displayName) {
// Add @ prefix (extractProfileDisplayName returns name without @)
const label = `@${displayName}`
labels.set(pubkey, label)
} else {
// Use fallback npub display if profile has no name (add @ prefix)
const fallback = getNpubFallbackDisplay(pubkey)
labels.set(pubkey, `@${fallback}`)
}
loading.set(pubkey, false)
} else {
// No profile found yet, will use fallback after fetch or keep empty
// We'll set fallback labels for missing profiles at the end
// Mark as loading since we'll fetch it
pubkeysToFetch.push(pubkey)
loading.set(pubkey, true)
}
})
// Don't set fallback labels in the Map - we'll use fallback directly when rendering
// This allows us to distinguish between "no label yet" (use fallback) vs "resolved label" (use Map value)
setProfileLabels(new Map(labels))
setProfileLoading(new Map(loading))
// Fetch missing profiles asynchronously with reactive updates
if (pubkeysToFetch.length > 0 && relayPool && eventStore) {
// Reactive callback: collects profile updates and batches them via RAF to prevent flicker
// Strategy: Apply label immediately when profile resolves, but still batch for multiple profiles
const handleProfileEvent = (event: NostrEvent) => {
// Use pubkey directly as the key
const pubkey = event.pubkey
// Determine the label for this profile using centralized utility
// Add @ prefix (both extractProfileDisplayName and getNpubFallbackDisplay return names without @)
const displayName = extractProfileDisplayName(event)
const label = displayName ? `@${displayName}` : `@${getNpubFallbackDisplay(pubkey)}`
// Apply label immediately to prevent race condition with loading state
// This ensures labels are available when isLoading becomes false
setProfileLabels(prevLabels => {
const updated = new Map(prevLabels)
updated.set(pubkey, label)
return updated
})
// Clear loading state for this profile when it resolves
setProfileLoading(prevLoading => {
const updated = new Map(prevLoading)
updated.set(pubkey, false)
return updated
})
}
fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch, undefined, handleProfileEvent)
.then(() => {
// After EOSE: apply any remaining pending updates immediately
// This ensures all profile updates are applied even if RAF hasn't fired yet
applyPendingUpdates()
// Clear loading state for all fetched profiles
setProfileLoading(prevLoading => {
const updated = new Map(prevLoading)
pubkeysToFetch.forEach(pubkey => {
updated.set(pubkey, false)
})
return updated
})
})
.catch((error) => {
console.error(`[profile-labels] Error fetching profiles:`, error)
// Silently handle fetch errors, but still clear any pending updates
pendingUpdatesRef.current.clear()
if (rafScheduledRef.current !== null) {
cancelAnimationFrame(rafScheduledRef.current)
rafScheduledRef.current = null
}
// Clear loading state on error (show fallback)
setProfileLoading(prevLoading => {
const updated = new Map(prevLoading)
pubkeysToFetch.forEach(pubkey => {
updated.set(pubkey, false)
})
return updated
})
})
// Cleanup: apply any pending updates before unmount to avoid losing them
return () => {
applyPendingUpdates()
}
}
}, [profileData, eventStore, relayPool, initialLabels, scheduleBatchedUpdate])
return { labels: profileLabels, loading: profileLoading }
}

View File

@@ -4,69 +4,82 @@ interface UseReadingPositionOptions {
enabled?: boolean
onPositionChange?: (position: number) => void
onReadingComplete?: () => void
readingCompleteThreshold?: number // Default 0.9 (90%)
readingCompleteThreshold?: number // Default 0.95 (95%) - matches filter threshold
syncEnabled?: boolean // Whether to sync positions to Nostr
onSave?: (position: number) => void // Callback for saving position
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000)
}
export const useReadingPosition = ({
enabled = true,
onPositionChange,
onReadingComplete,
readingCompleteThreshold = 0.9,
readingCompleteThreshold = 0.95, // Match filter threshold for consistency
syncEnabled = false,
onSave,
autoSaveInterval = 5000
completionHoldMs = 2000
}: UseReadingPositionOptions = {}) => {
const [position, setPosition] = useState(0)
const positionRef = useRef(0)
const [isReadingComplete, setIsReadingComplete] = useState(false)
const hasTriggeredComplete = useRef(false)
const lastSavedPosition = useRef(0)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const suppressUntilRef = useRef<number>(0)
const pendingPositionRef = useRef<number>(0) // Track latest position for throttled save
const lastSaved100Ref = useRef(false) // Track if we've saved 100% to avoid duplicate saves
// Store callbacks in refs to avoid them being dependencies
const onPositionChangeRef = useRef(onPositionChange)
const onReadingCompleteRef = useRef(onReadingComplete)
const onSaveRef = useRef(onSave)
useEffect(() => {
onPositionChangeRef.current = onPositionChange
onReadingCompleteRef.current = onReadingComplete
onSaveRef.current = onSave
}, [onPositionChange, onReadingComplete, onSave])
// Debounced save function
// Suppress auto-saves for a given duration (used after programmatic restore)
const suppressSavesFor = useCallback((ms: number) => {
const until = Date.now() + ms
suppressUntilRef.current = until
}, [])
// Throttled save function - saves at 1s intervals during scrolling
const scheduleSave = useCallback((currentPosition: number) => {
if (!syncEnabled || !onSave) return
// Don't save if position is too low (< 5%)
if (currentPosition < 0.05) return
// Don't save if position hasn't changed significantly (less than 1%)
// But always save if we've reached 100% (completion)
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
if (!hasSignificantChange && !hasReachedCompletion) return
// Clear existing timer
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
if (!syncEnabled || !onSaveRef.current) {
return
}
// Schedule new save
// Always save instantly when we reach completion (1.0)
if (currentPosition === 1 && !lastSaved100Ref.current) {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
saveTimerRef.current = null
}
lastSaved100Ref.current = true
onSaveRef.current(1)
return
}
// Always update the pending position (latest position to save)
pendingPositionRef.current = currentPosition
// Throttle: only schedule a save if one isn't already pending
// This ensures saves happen at regular 1s intervals during continuous scrolling
if (saveTimerRef.current) {
return // Already have a save scheduled, don't reset the timer
}
const THROTTLE_MS = 1000
saveTimerRef.current = setTimeout(() => {
lastSavedPosition.current = currentPosition
onSave(currentPosition)
}, autoSaveInterval)
}, [syncEnabled, onSave, autoSaveInterval])
// Immediate save function
const saveNow = useCallback(() => {
if (!syncEnabled || !onSave) return
// Cancel any pending saves
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
// Save the latest position, not the one from when timer was scheduled
const positionToSave = pendingPositionRef.current
onSaveRef.current?.(positionToSave)
saveTimerRef.current = null
}
// Save if position is meaningful (>= 5%)
if (position >= 0.05) {
lastSavedPosition.current = position
onSave(position)
}
}, [syncEnabled, onSave, position])
}, THROTTLE_MS)
}, [syncEnabled])
useEffect(() => {
if (!enabled) return
@@ -80,26 +93,55 @@ export const useReadingPosition = ({
const windowHeight = window.innerHeight
const documentHeight = document.documentElement.scrollHeight
// Ignore if document is too small (likely during page transition)
if (documentHeight < 100) return
// Calculate position based on how much of the content has been scrolled through
// Add a small threshold (5px) to account for rounding and make it easier to reach 100%
const maxScroll = documentHeight - windowHeight
const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0
// If we're within 5px of the bottom, consider it 100%
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
// Only consider it 100% if we're truly at the bottom AND have scrolled significantly
// This prevents false 100% during page transitions
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5 && scrollTop > 100
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
setPosition(clampedProgress)
onPositionChange?.(clampedProgress)
positionRef.current = clampedProgress
onPositionChangeRef.current?.(clampedProgress)
// Schedule auto-save if sync is enabled
scheduleSave(clampedProgress)
// Schedule auto-save if sync is enabled (unless suppressed)
if (Date.now() >= suppressUntilRef.current) {
scheduleSave(clampedProgress)
}
// Note: Suppression is silent to avoid log spam during scrolling
// Check if reading is complete
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
onReadingComplete?.()
// Completion detection with 2s hold at 100%
if (!hasTriggeredComplete.current) {
// If at exact 100%, start a hold timer; cancel if we scroll up
if (clampedProgress === 1) {
if (!completionTimerRef.current) {
completionTimerRef.current = setTimeout(() => {
if (!hasTriggeredComplete.current && positionRef.current === 1) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
onReadingCompleteRef.current?.()
}
completionTimerRef.current = null
}, completionHoldMs)
}
} else {
// If we moved off 100%, cancel any pending completion hold
if (completionTimerRef.current) {
clearTimeout(completionTimerRef.current)
completionTimerRef.current = null
// still allow threshold-based completion for near-bottom if configured
if (clampedProgress >= readingCompleteThreshold) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
onReadingCompleteRef.current?.()
}
}
}
}
}
@@ -114,18 +156,24 @@ export const useReadingPosition = ({
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleScroll)
// Clear save timer on unmount
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
// DON'T clear save timer - let it complete even if tracking is temporarily disabled
// Only clear completion timer since that's tied to the current scroll session
if (completionTimerRef.current) {
clearTimeout(completionTimerRef.current)
}
}
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
}, [enabled, readingCompleteThreshold, scheduleSave, completionHoldMs])
// Reset reading complete state when enabled changes
useEffect(() => {
if (!enabled) {
setIsReadingComplete(false)
hasTriggeredComplete.current = false
lastSaved100Ref.current = false
if (completionTimerRef.current) {
clearTimeout(completionTimerRef.current)
completionTimerRef.current = null
}
}
}, [enabled])
@@ -133,6 +181,6 @@ export const useReadingPosition = ({
position,
isReadingComplete,
progressPercentage: Math.round(position * 100),
saveNow
suppressSavesFor
}
}

View File

@@ -3,7 +3,7 @@ import { IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { EventFactory } from 'applesauce-factory'
import { AccountManager } from 'applesauce-accounts'
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
import { UserSettings, saveSettings, watchSettings, startSettingsStream } from '../services/settingsService'
import { loadFont, getFontFamily } from '../utils/fontLoader'
import { applyTheme } from '../utils/theme'
import { RELAYS } from '../config/relays'
@@ -16,30 +16,28 @@ interface UseSettingsParams {
}
export function useSettings({ relayPool, eventStore, pubkey, accountManager }: UseSettingsParams) {
const [settings, setSettings] = useState<UserSettings>({})
const [settings, setSettings] = useState<UserSettings>({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true })
const [toastMessage, setToastMessage] = useState<string | null>(null)
const [toastType, setToastType] = useState<'success' | 'error'>('success')
// Load settings and set up subscription
// Load settings and set up streaming subscription (non-blocking, EOSE-driven)
useEffect(() => {
if (!relayPool || !pubkey || !eventStore) return
const loadAndWatch = async () => {
try {
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
if (loadedSettings) setSettings(loadedSettings)
} catch (err) {
console.error('Failed to load settings:', err)
}
}
loadAndWatch()
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
if (loadedSettings) setSettings(loadedSettings)
// Start settings stream: seed from store, stream updates to store in background
const stopNetwork = startSettingsStream(relayPool, eventStore, pubkey, RELAYS, (loadedSettings) => {
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
})
return () => subscription.unsubscribe()
// Also watch store reactively for any further updates
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
})
return () => {
subscription.unsubscribe()
stopNetwork()
}
}, [relayPool, pubkey, eventStore])
// Apply settings to document
@@ -48,7 +46,6 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
const root = document.documentElement.style
const fontKey = settings.readingFont || 'system'
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize, theme: settings.theme })
// Apply theme with color variants (defaults to 'system' if not set)
applyTheme(
@@ -59,9 +56,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
// Load font first and wait for it to be ready
if (fontKey !== 'system') {
console.log('⏳ Waiting for font to load...')
await loadFont(fontKey)
console.log('✅ Font loaded, applying styles')
}
// Apply font settings after font is loaded
@@ -73,10 +68,19 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
// Set link colors for dark and light themes separately
const darkLinkColor = settings.linkColorDark || '#38bdf8'
const lightLinkColor = settings.linkColorLight || '#3b82f6'
root.setProperty('--color-link-dark', darkLinkColor)
root.setProperty('--color-link-light', lightLinkColor)
// Set paragraph alignment
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
console.log('✅ All styles applied')
// Set image width and max-height based on full-width setting
root.setProperty('--image-width', settings.fullWidthImages ? '100%' : 'auto')
root.setProperty('--image-max-height', settings.fullWidthImages ? 'none' : '70vh')
}
applyStyles()

View File

@@ -0,0 +1,288 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
// Web Speech API types
type SpeechSynthesisVoice = {
name: string
voiceURI: string
lang: string
localService: boolean
default: boolean
}
export interface UseTTSOptions {
defaultLang?: string
defaultRate?: number
defaultPitch?: number
defaultVolume?: number
}
export interface UseTTS {
supported: boolean
speaking: boolean
paused: boolean
voices: SpeechSynthesisVoice[]
voice: SpeechSynthesisVoice | null
rate: number
pitch: number
volume: number
setVoice: (v: SpeechSynthesisVoice | null) => void
setRate: (r: number) => void
setPitch: (p: number) => void
setVolume: (v: number) => void
speak: (text: string, langOverride?: string) => void
pause: () => void
resume: () => void
stop: () => void
}
export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
const synth = typeof window !== 'undefined' ? window.speechSynthesis : undefined
const supported = !!synth
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
const [voice, setVoice] = useState<SpeechSynthesisVoice | null>(null)
const [speaking, setSpeaking] = useState(false)
const [paused, setPaused] = useState(false)
const [rate, setRate] = useState(options.defaultRate ?? 2.1)
const [pitch, setPitch] = useState(options.defaultPitch ?? 1)
const [volume, setVolume] = useState(options.defaultVolume ?? 1)
const defaultLang = options.defaultLang || (typeof navigator !== 'undefined' ? navigator.language : 'en')
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null)
const spokenTextRef = useRef<string>('')
const charIndexRef = useRef<number>(0)
// Chunking state to reliably speak long texts from web URLs
const chunksRef = useRef<string[]>([])
const chunkIndexRef = useRef<number>(0)
const globalOffsetRef = useRef<number>(0)
const langRef = useRef<string | undefined>(undefined)
// Update rate when defaultRate option changes
useEffect(() => {
if (options.defaultRate !== undefined) {
setRate(options.defaultRate)
}
}, [options.defaultRate])
// Load voices (async in many browsers)
useEffect(() => {
if (!supported) return
const load = () => {
const v = synth!.getVoices()
setVoices(v)
if (!voice && v.length) {
const byLang = v.find(x => x.lang?.toLowerCase().startsWith(defaultLang.toLowerCase()))
setVoice(byLang || v[0] || null)
}
}
load()
const handleVoicesChanged = () => load()
synth!.addEventListener('voiceschanged', handleVoicesChanged)
return () => {
synth!.removeEventListener('voiceschanged', handleVoicesChanged)
}
}, [supported, defaultLang, voice, synth])
const createUtterance = useCallback((text: string, langOverride?: string): SpeechSynthesisUtterance => {
const SpeechSynthesisUtteranceConstructor = (window as Window & typeof globalThis).SpeechSynthesisUtterance
const u = new SpeechSynthesisUtteranceConstructor(text) as SpeechSynthesisUtterance
const resolvedLang = langOverride || voice?.lang || defaultLang
u.lang = resolvedLang
if (langOverride) {
const match = voices.find(v => v.lang?.toLowerCase().startsWith(langOverride.toLowerCase()))
if (match) {
u.voice = match
} else if (voice) {
u.voice = voice
}
} else if (voice) {
u.voice = voice
}
u.rate = rate
u.pitch = pitch
u.volume = volume
const self = u
u.onstart = () => {
if (utteranceRef.current !== self) return
setSpeaking(true)
setPaused(false)
}
u.onpause = () => {
if (utteranceRef.current !== self) return
setPaused(true)
}
u.onresume = () => {
if (utteranceRef.current !== self) return
setPaused(false)
}
u.onend = () => {
if (utteranceRef.current !== self) return
// Continue with next chunk if available
const hasMore = chunkIndexRef.current < (chunksRef.current.length - 1)
if (hasMore) {
chunkIndexRef.current++
charIndexRef.current += self.text.length
const nextChunk = chunksRef.current[chunkIndexRef.current]
const nextUtterance = createUtterance(nextChunk, langRef.current)
utteranceRef.current = nextUtterance
synth!.speak(nextUtterance)
} else {
setSpeaking(false)
setPaused(false)
}
}
u.onerror = () => {
if (utteranceRef.current !== self) return
setSpeaking(false)
setPaused(false)
}
u.onboundary = (ev: SpeechSynthesisEvent) => {
if (utteranceRef.current !== self) return
if (typeof ev.charIndex === 'number') {
const newIndex = globalOffsetRef.current + ev.charIndex
if (newIndex > charIndexRef.current) {
charIndexRef.current = newIndex
}
}
}
return u
}, [voice, defaultLang, rate, pitch, volume, voices, synth])
const splitIntoChunks = useCallback((text: string, maxLen = 2400): string[] => {
const normalized = text.replace(/\s+/g, ' ').trim()
if (normalized.length <= maxLen) return [normalized]
const sentences = normalized.split(/(?<=[.!?])\s+/)
const chunks: string[] = []
let current = ''
for (const s of sentences) {
if ((current + (current ? ' ' : '') + s).length > maxLen) {
if (current) chunks.push(current)
if (s.length > maxLen) {
// Hard split very long sentence
for (let i = 0; i < s.length; i += maxLen) {
chunks.push(s.slice(i, i + maxLen))
}
current = ''
} else {
current = s
}
} else {
current = current ? `${current} ${s}` : s
}
}
if (current) chunks.push(current)
return chunks
}, [])
const startSpeakingChunks = useCallback((text: string) => {
chunksRef.current = splitIntoChunks(text)
chunkIndexRef.current = 0
globalOffsetRef.current = 0
const first = chunksRef.current[0] || ''
const u = createUtterance(first, langRef.current)
utteranceRef.current = u
synth!.speak(u)
}, [createUtterance, splitIntoChunks, synth])
const stop = useCallback(() => {
if (!supported) return
synth!.cancel()
setSpeaking(false)
setPaused(false)
utteranceRef.current = null
charIndexRef.current = 0
spokenTextRef.current = ''
chunksRef.current = []
chunkIndexRef.current = 0
globalOffsetRef.current = 0
}, [supported, synth])
const speak = useCallback((text: string, langOverride?: string) => {
if (!supported || !text?.trim()) return
synth!.cancel()
spokenTextRef.current = text
charIndexRef.current = 0
langRef.current = langOverride
startSpeakingChunks(text)
}, [supported, synth, startSpeakingChunks])
const pause = useCallback(() => {
if (!supported) return
if (synth!.speaking && !synth!.paused) {
synth!.pause()
setPaused(true)
}
}, [supported, synth])
const resume = useCallback(() => {
if (!supported) return
if (synth!.speaking && synth!.paused) {
synth!.resume()
setPaused(false)
}
}, [supported, synth])
// Update rate in real-time: while speaking, restart from last boundary with new rate.
useEffect(() => {
if (!supported) return
if (!utteranceRef.current) return
if (synth!.speaking && !synth!.paused) {
const fullText = spokenTextRef.current
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length))
const remainingText = fullText.slice(startIndex)
synth!.cancel()
// restart chunked from current global index
spokenTextRef.current = remainingText
charIndexRef.current = 0
// keep current language selection; no change needed here
startSpeakingChunks(remainingText)
return
}
if (utteranceRef.current) {
utteranceRef.current.rate = rate
}
}, [rate, supported, synth, startSpeakingChunks])
const updateRate = useCallback((newRate: number) => {
setRate(newRate)
if (!supported) return
if (!utteranceRef.current) return
if (synth!.speaking && !synth!.paused) {
const fullText = spokenTextRef.current
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length - 1))
const remainingText = fullText.slice(startIndex)
synth!.cancel()
const u = createUtterance(remainingText)
// ensure the new rate is applied immediately on the new utterance
u.rate = newRate
utteranceRef.current = u
synth!.speak(u)
} else if (utteranceRef.current) {
utteranceRef.current.rate = newRate
}
}, [supported, synth, createUtterance])
// stop TTS when unmounting
useEffect(() => stop, [stop])
return useMemo(() => ({
supported,
speaking,
paused,
voices,
voice,
rate,
setRate: updateRate,
pitch, setPitch,
volume, setVolume,
setVoice,
speak, pause, resume, stop
}), [supported, speaking, paused, voices, voice, rate, updateRate, pitch, volume, setVoice, speak, pause, resume, stop])
}

View File

@@ -6,17 +6,59 @@ import './index.css'
import 'react-loading-skeleton/dist/skeleton.css'
// Register Service Worker for PWA functionality
// With injectRegister: null, we need to register manually
// With devOptions.enabled: true, vite-plugin-pwa serves SW in dev mode too
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js', { type: 'module' })
const swPath = '/sw.js'
// Check if already registered/active first
navigator.serviceWorker.getRegistrations().then(async (registrations) => {
if (registrations.length > 0) {
return registrations[0]
}
// Not registered yet, try to register
// In dev mode, use the dev Service Worker for testing
if (import.meta.env.DEV) {
const devSwPath = '/sw-dev.js'
try {
// Check if dev SW exists
const response = await fetch(devSwPath)
const contentType = response.headers.get('content-type') || ''
const isJavaScript = contentType.includes('javascript') || contentType.includes('application/javascript')
if (response.ok && isJavaScript) {
return await navigator.serviceWorker.register(devSwPath, { scope: '/' })
} else {
console.warn('[sw-registration] Development Service Worker not available')
return null
}
} catch (err) {
console.warn('[sw-registration] Could not load development Service Worker:', err)
return null
}
} else {
// In production, just register directly
return await navigator.serviceWorker.register(swPath)
}
})
.then(registration => {
console.log('✅ Service Worker registered:', registration.scope)
if (!registration) return
// Check for updates periodically
setInterval(() => {
registration.update()
}, 60 * 60 * 1000) // Check every hour
// Wait for Service Worker to activate
if (registration.installing) {
registration.installing.addEventListener('statechange', () => {
// Service Worker state changed
})
}
// Check for updates periodically (production only)
if (import.meta.env.PROD) {
setInterval(() => {
registration.update()
}, 60 * 60 * 1000) // Check every hour
}
// Handle service worker updates
registration.addEventListener('updatefound', () => {
@@ -25,9 +67,6 @@ if ('serviceWorker' in navigator) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New service worker available
console.log('🔄 New version available! Reload to update.')
// Optionally show a toast notification
const updateAvailable = new CustomEvent('sw-update-available')
window.dispatchEvent(updateAvailable)
}
@@ -36,9 +75,22 @@ if ('serviceWorker' in navigator) {
})
})
.catch(error => {
console.error('❌ Service Worker registration failed:', error)
console.error('[sw-registration] ❌ Service Worker registration failed:', error)
console.error('[sw-registration] Error details:', {
message: error.message,
name: error.name,
stack: error.stack
})
// In dev mode, this is expected if vite-plugin-pwa isn't serving the SW
if (import.meta.env.DEV) {
console.warn('[sw-registration] ⚠️ This is expected in dev mode if vite-plugin-pwa is not serving the SW file')
console.warn('[sw-registration] Image caching will not work in dev mode - test in production build')
}
})
})
} else {
console.warn('[sw-registration] ⚠️ Service Workers not supported in this browser')
}
ReactDOM.createRoot(document.getElementById('root')!).render(

View File

@@ -0,0 +1,197 @@
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { ARCHIVE_EMOJI } from './reactionService'
import { nip19 } from 'nostr-tools'
type MarkedChangeCallback = (markedIds: Set<string>) => void
class ArchiveController {
private markedIds: Set<string> = new Set()
private lastLoadedPubkey: string | null = null
private listeners: MarkedChangeCallback[] = []
private generation = 0
private timelineSubscription: { unsubscribe: () => void } | null = null
private pendingEventIds: Set<string> = new Set()
onMarked(cb: MarkedChangeCallback): () => void {
this.listeners.push(cb)
// Emit current state immediately to new subscribers
cb(new Set(this.markedIds))
return () => {
this.listeners = this.listeners.filter(l => l !== cb)
}
}
private emit(): void {
const snapshot = new Set(this.markedIds)
this.listeners.forEach(cb => cb(snapshot))
}
mark(id: string): void {
if (!this.markedIds.has(id)) {
this.markedIds.add(id)
this.emit()
}
}
unmark(id: string): void {
if (this.markedIds.delete(id)) {
this.emit()
}
}
isMarked(id: string): boolean {
return this.markedIds.has(id)
}
getMarkedIds(): string[] {
return Array.from(this.markedIds)
}
isLoadedFor(pubkey: string): boolean {
return this.lastLoadedPubkey === pubkey
}
reset(): void {
this.generation++
if (this.timelineSubscription) {
try { this.timelineSubscription.unsubscribe() } catch { /* ignore */ }
this.timelineSubscription = null
}
this.markedIds = new Set()
this.pendingEventIds = new Set()
this.lastLoadedPubkey = null
this.emit()
}
async start(options: {
relayPool: RelayPool
eventStore: IEventStore
pubkey: string
force?: boolean
}): Promise<void> {
const { relayPool, eventStore, pubkey, force = false } = options
const startGen = this.generation
if (!force && this.isLoadedFor(pubkey)) {
return
}
// Mark as loaded immediately (fetch runs non-blocking)
this.lastLoadedPubkey = pubkey
// Handlers for streaming queries
const handleUrlReaction = (evt: NostrEvent) => {
if (evt.content !== ARCHIVE_EMOJI) return
const rTag = evt.tags.find(t => t[0] === 'r')?.[1]
if (!rTag) return
this.markedIds.add(rTag)
this.emit()
}
const handleEventReaction = (evt: NostrEvent) => {
if (evt.content !== ARCHIVE_EMOJI) return
// Direct coordinate tag ('a') - can be mapped immediately
const aTag = evt.tags.find(t => t[0] === 'a')?.[1]
if (aTag) {
try {
const [kindStr, pubkey, identifier] = aTag.split(':')
const kind = Number(kindStr)
if (kind === KINDS.BlogPost && pubkey && identifier) {
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
this.markedIds.add(naddr)
this.emit()
return
}
} catch { /* ignore malformed a-tag */ }
}
const eTag = evt.tags.find(t => t[0] === 'e')?.[1]
if (!eTag) return
this.pendingEventIds.add(eTag)
}
try {
// Stream kind:17 and kind:7 in parallel
const [kind17, kind7] = await Promise.all([
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { onEvent: handleUrlReaction }),
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { onEvent: handleEventReaction })
])
if (startGen !== this.generation) return
// Include EOSE events
kind17.forEach(handleUrlReaction)
kind7.forEach(handleEventReaction)
if (this.pendingEventIds.size > 0) {
// Fetch referenced articles (kind:30023) and map event IDs to naddr
const ids = Array.from(this.pendingEventIds)
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids })
for (const article of articleEvents) {
const dTag = article.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) continue
try {
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: article.pubkey, identifier: dTag })
this.markedIds.add(naddr)
} catch {
// skip invalid
}
}
this.emit()
}
// Try immediate mapping via eventStore for any still-pending e-ids
if (this.pendingEventIds.size > 0) {
const stillPending = new Set<string>()
for (const eId of this.pendingEventIds) {
try {
const store = eventStore as unknown as { getEvent?: (id: string) => NostrEvent | undefined }
const evt: NostrEvent | undefined = typeof store.getEvent === 'function' ? store.getEvent(eId) : undefined
if (evt && evt.kind === KINDS.BlogPost) {
const dTag = evt.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
this.markedIds.add(naddr)
}
} else {
stillPending.add(eId)
}
} catch (e) { stillPending.add(eId) }
}
this.pendingEventIds = stillPending
if (stillPending.size > 0) {
// Subscribe to future 30023 arrivals to finalize mapping
if (this.timelineSubscription) {
try { this.timelineSubscription.unsubscribe() } catch { /* ignore */ }
this.timelineSubscription = null
}
const sub$ = eventStore.timeline({ kinds: [KINDS.BlogPost] })
const genAtSub = this.generation
this.timelineSubscription = sub$.subscribe((events: NostrEvent[]) => {
if (genAtSub !== this.generation) return
for (const evt of events) {
if (!this.pendingEventIds.has(evt.id)) continue
const dTag = evt.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) continue
try {
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
this.markedIds.add(naddr)
this.pendingEventIds.delete(evt.id)
this.emit()
} catch { /* ignore */ }
}
})
}
}
} catch (err) {
// Non-blocking fetch; ignore errors here
}
}
}
export const archiveController = new ArchiveController()

View File

@@ -34,11 +34,13 @@ function getCacheKey(naddr: string): string {
return `${CACHE_PREFIX}${naddr}`
}
function getFromCache(naddr: string): ArticleContent | null {
export function getFromCache(naddr: string): ArticleContent | null {
try {
const cacheKey = getCacheKey(naddr)
const cached = localStorage.getItem(cacheKey)
if (!cached) return null
if (!cached) {
return null
}
const { content, timestamp }: CachedArticle = JSON.parse(cached)
const age = Date.now() - timestamp
@@ -48,14 +50,52 @@ function getFromCache(naddr: string): ArticleContent | null {
return null
}
console.log('📦 Loaded article from cache:', naddr)
return content
} catch {
} catch (err) {
// Silently handle cache read errors
return null
}
}
function saveToCache(naddr: string, content: ArticleContent): void {
/**
* Caches an article event to localStorage for offline access
* @param event - The Nostr event to cache
* @param settings - Optional user settings
*/
export function cacheArticleEvent(event: NostrEvent, settings?: UserSettings): void {
try {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
if (!dTag || event.kind !== 30023) return
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: event.pubkey,
identifier: dTag
})
const articleContent: ArticleContent = {
title: getArticleTitle(event) || 'Untitled Article',
markdown: event.content,
image: getArticleImage(event),
published: getArticlePublished(event),
summary: getArticleSummary(event),
author: event.pubkey,
event
}
saveToCache(naddr, articleContent, settings)
} catch (err) {
// Silently fail cache saves - quota exceeded, invalid data, etc.
}
}
export function saveToCache(naddr: string, content: ArticleContent, settings?: UserSettings): void {
// Respect user settings: if image caching is disabled, we could skip article caching too
// However, for offline-first design, we default to caching unless explicitly disabled
// Future: could add explicit enableArticleCache setting
// For now, we cache aggressively but handle errors gracefully
// Note: settings parameter reserved for future use
void settings // Mark as intentionally unused for now
try {
const cacheKey = getCacheKey(naddr)
const cached: CachedArticle = {
@@ -63,10 +103,9 @@ function saveToCache(naddr: string, content: ArticleContent): void {
timestamp: Date.now()
}
localStorage.setItem(cacheKey, JSON.stringify(cached))
console.log('💾 Saved article to cache:', naddr)
} catch (err) {
console.warn('Failed to cache article:', err)
// Silently fail if storage is full or unavailable
// Silently fail - don't block the UI if caching fails
// Handles quota exceeded, invalid data, and other errors gracefully
}
}
@@ -99,10 +138,10 @@ export async function fetchArticleByNaddr(
const pointer = decoded.data as AddressPointer
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local)
const baseRelays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: RELAYS
// Define relays to query - use union of relay hints from naddr and configured relays
// This avoids failures when naddr contains stale/unreachable relay hints
const hintedRelays = (pointer.relays && pointer.relays.length > 0) ? pointer.relays : []
const baseRelays = Array.from(new Set<string>([...hintedRelays, ...RELAYS]))
const orderedRelays = prioritizeLocalRelays(baseRelays)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
@@ -116,7 +155,28 @@ export async function fetchArticleByNaddr(
// Parallel local+remote, stream immediate, collect up to first from each
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000)
const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()))
const events = collected as NostrEvent[]
let events = collected as NostrEvent[]
// Fallback: if nothing found, try a second round against a set of reliable public relays
if (events.length === 0) {
const reliableRelays = Array.from(new Set<string>([
'wss://relay.nostr.band',
'wss://relay.primal.net',
'wss://relay.damus.io',
'wss://nos.lol',
...remoteRelays // keep any configured remote relays
]))
const { remote$: fallback$ } = createParallelReqStreams(
relayPool,
[], // no local
reliableRelays,
filter,
1500,
12000
)
const fallbackCollected = await lastValueFrom(fallback$.pipe(take(1), rxToArray()))
events = fallbackCollected as NostrEvent[]
}
if (events.length === 0) {
throw new Error('Article not found')
@@ -145,7 +205,7 @@ export async function fetchArticleByNaddr(
}
// Save to cache before returning
saveToCache(naddr, content)
saveToCache(naddr, content, settings)
// Image caching is handled automatically by Service Worker

View File

@@ -1,12 +1,8 @@
import { RelayPool } from 'applesauce-relay'
import { Helpers, EventStore } from 'applesauce-core'
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
import { NostrEvent } from 'nostr-tools'
import { EventPointer } from 'nostr-tools/nip19'
import { merge } from 'rxjs'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { collectBookmarksFromEvents } from './bookmarkProcessing'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import {
@@ -64,11 +60,8 @@ class BookmarkController {
}> = new Map()
private isLoading = false
private hydrationGeneration = 0
// Event loaders for efficient batching
private eventStore = new EventStore()
private eventLoader: ReturnType<typeof createEventLoader> | null = null
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
private externalEventStore: EventStore | null = null
private relayPool: RelayPool | null = null
onRawEvent(cb: RawEventCallback): () => void {
this.rawEventListeners.push(cb)
@@ -117,96 +110,164 @@ class BookmarkController {
}
/**
* Hydrate events by IDs using EventLoader (auto-batching, streaming)
* Hydrate events by IDs using queryEvents (local-first, streaming)
*/
private hydrateByIds(
private async hydrateByIds(
ids: string[],
idToEvent: Map<string, NostrEvent>,
onProgress: () => void,
generation: number
): void {
if (!this.eventLoader) {
console.warn('[bookmark] ⚠️ EventLoader not initialized')
): Promise<void> {
if (!this.relayPool) {
return
}
// Filter to unique IDs not already hydrated
const unique = Array.from(new Set(ids)).filter(id => !idToEvent.has(id))
if (unique.length === 0) {
console.log('[bookmark] 🔧 All IDs already hydrated, skipping')
return
}
console.log('[bookmark] 🔧 Hydrating', unique.length, 'IDs using EventLoader')
// Convert IDs to EventPointers
const pointers: EventPointer[] = unique.map(id => ({ id }))
// Use EventLoader - it auto-batches and streams results
merge(...pointers.map(this.eventLoader)).subscribe({
next: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
idToEvent.set(event.id, event)
// Also index by coordinate for addressable events
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
idToEvent.set(coordinate, event)
// Fetch events using local-first queryEvents
await queryEvents(
this.relayPool,
{ ids: unique },
{
onEvent: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
idToEvent.set(event.id, event)
// Also index by coordinate for addressable events
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
idToEvent.set(coordinate, event)
}
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
}
onProgress()
},
error: (error) => {
console.error('[bookmark] ❌ EventLoader error:', error)
}
})
)
}
/**
* Hydrate addressable events by coordinates using AddressLoader (auto-batching, streaming)
* Hydrate addressable events by coordinates using queryEvents (local-first, streaming)
*/
private hydrateByCoordinates(
private async hydrateByCoordinates(
coords: Array<{ kind: number; pubkey: string; identifier: string }>,
idToEvent: Map<string, NostrEvent>,
onProgress: () => void,
generation: number
): void {
if (!this.addressLoader) {
console.warn('[bookmark] ⚠️ AddressLoader not initialized')
): Promise<void> {
if (!this.relayPool) {
return
}
if (coords.length === 0) return
if (coords.length === 0) {
return
}
console.log('[bookmark] 🔧 Hydrating', coords.length, 'coordinates using AddressLoader')
// Convert coordinates to AddressPointers
const pointers = coords.map(c => ({
kind: c.kind,
pubkey: c.pubkey,
identifier: c.identifier
}))
// Use AddressLoader - it auto-batches and streams results
merge(...pointers.map(this.addressLoader)).subscribe({
next: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
idToEvent.set(coordinate, event)
idToEvent.set(event.id, event)
onProgress()
},
error: (error) => {
console.error('[bookmark] ❌ AddressLoader error:', error)
// Group by kind and pubkey for efficient batching
const filtersByKind = new Map<number, Map<string, string[]>>()
for (const coord of coords) {
if (!filtersByKind.has(coord.kind)) {
filtersByKind.set(coord.kind, new Map())
}
})
const byPubkey = filtersByKind.get(coord.kind)!
if (!byPubkey.has(coord.pubkey)) {
byPubkey.set(coord.pubkey, [])
}
byPubkey.get(coord.pubkey)!.push(coord.identifier || '')
}
// Kick off all queries in parallel (fire-and-forget)
const promises: Promise<void>[] = []
for (const [kind, byPubkey] of filtersByKind) {
for (const [pubkey, identifiers] of byPubkey) {
// Separate empty and non-empty identifiers
const nonEmptyIdentifiers = identifiers.filter(id => id && id.length > 0)
const hasEmptyIdentifier = identifiers.some(id => !id || id.length === 0)
// Fetch events with non-empty d-tags
if (nonEmptyIdentifiers.length > 0) {
promises.push(
queryEvents(
this.relayPool,
{ kinds: [kind], authors: [pubkey], '#d': nonEmptyIdentifiers },
{
onEvent: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
idToEvent.set(coordinate, event)
idToEvent.set(event.id, event)
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
}
}
).then(() => {
// Query completed successfully
}).catch(() => {
// Silent error - individual query failed
})
)
}
// Fetch events with empty d-tag separately (without '#d' filter)
if (hasEmptyIdentifier) {
promises.push(
queryEvents(
this.relayPool,
{ kinds: [kind], authors: [pubkey] },
{
onEvent: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
// Only process events with empty d-tag
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
if (dTag !== '') return
const coordinate = `${event.kind}:${event.pubkey}:`
idToEvent.set(coordinate, event)
idToEvent.set(event.id, event)
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
}
}
).then(() => {
// Query completed successfully
}).catch(() => {
// Silent error - individual query failed
})
)
}
}
}
// Wait for all queries to complete
await Promise.all(promises)
}
private async buildAndEmitBookmarks(
@@ -223,10 +284,6 @@ class BookmarkController {
return this.decryptedResults.has(getEventKey(evt))
})
const unencryptedCount = allEvents.filter(evt => !hasEncryptedContent(evt)).length
const decryptedCount = readyEvents.length - unencryptedCount
console.log('[bookmark] 📋 Building bookmarks:', unencryptedCount, 'unencrypted,', decryptedCount, 'decrypted, of', allEvents.length, 'total')
if (readyEvents.length === 0) {
this.bookmarksListeners.forEach(cb => cb([]))
return
@@ -237,17 +294,14 @@ class BookmarkController {
const unencryptedEvents = readyEvents.filter(evt => !hasEncryptedContent(evt))
const decryptedEvents = readyEvents.filter(evt => hasEncryptedContent(evt))
console.log('[bookmark] 🔧 Processing', unencryptedEvents.length, 'unencrypted events')
// Process unencrypted events
const { publicItemsAll: publicUnencrypted, privateItemsAll: privateUnencrypted, newestCreatedAt, latestContent, allTags } =
await collectBookmarksFromEvents(unencryptedEvents, activeAccount, signerCandidate)
console.log('[bookmark] 🔧 Unencrypted returned:', publicUnencrypted.length, 'public,', privateUnencrypted.length, 'private')
// Merge in decrypted results
let publicItemsAll = [...publicUnencrypted]
let privateItemsAll = [...privateUnencrypted]
console.log('[bookmark] 🔧 Merging', decryptedEvents.length, 'decrypted events')
decryptedEvents.forEach(evt => {
const eventKey = getEventKey(evt)
const decrypted = this.decryptedResults.get(eventKey)
@@ -256,52 +310,60 @@ class BookmarkController {
privateItemsAll = [...privateItemsAll, ...decrypted.privateItems]
}
})
console.log('[bookmark] 🔧 Total after merge:', publicItemsAll.length, 'public,', privateItemsAll.length, 'private')
const allItems = [...publicItemsAll, ...privateItemsAll]
console.log('[bookmark] 🔧 Total items to process:', allItems.length)
const deduped = dedupeBookmarksById(allItems)
// Separate hex IDs from coordinates
// Separate hex IDs from coordinates for fetching
const noteIds: string[] = []
const coordinates: string[] = []
allItems.forEach(i => {
if (/^[0-9a-f]{64}$/i.test(i.id)) {
noteIds.push(i.id)
} else if (i.id.includes(':')) {
coordinates.push(i.id)
// Request hydration for all items that don't have content yet
deduped.forEach(i => {
// If item has no content, we need to fetch it
if (!i.content || i.content.length === 0) {
if (/^[0-9a-f]{64}$/i.test(i.id)) {
noteIds.push(i.id)
} else if (i.id.includes(':')) {
coordinates.push(i.id)
}
}
})
// Helper to build and emit bookmarks
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
console.log('[bookmark] 🔧 Building final bookmarks list...')
const allBookmarks = dedupeBookmarksById([
// Now hydrate the ORIGINAL items (which may have duplicates), using the deduplicated results
// This preserves the original public/private split while still getting all the content
const allBookmarks = [
...hydrateItems(publicItemsAll, idToEvent),
...hydrateItems(privateItemsAll, idToEvent)
])
console.log('[bookmark] 🔧 After hydration and dedup:', allBookmarks.length, 'bookmarks')
console.log('[bookmark] 🔧 Enriching and sorting...')
]
const enriched = allBookmarks.map(b => ({
...b,
tags: b.tags || [],
content: b.content || ''
content: b.content || this.externalEventStore?.getEvent(b.id)?.content || '', // Fallback to eventStore content
created_at: (b.created_at ?? this.externalEventStore?.getEvent(b.id)?.created_at ?? null)
}))
const sortedBookmarks = enriched
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
console.log('[bookmark] 🔧 Sorted:', sortedBookmarks.length, 'bookmarks')
.map(b => ({
...b,
urlReferences: extractUrlsFromContent(b.content)
}))
.sort((a, b) => {
// Sort by display time: created_at, else listUpdatedAt. Newest first. Nulls last.
const aTs = (a.created_at ?? a.listUpdatedAt ?? -Infinity)
const bTs = (b.created_at ?? b.listUpdatedAt ?? -Infinity)
return bTs - aTs
})
console.log('[bookmark] 🔧 Creating final Bookmark object...')
const bookmark: Bookmark = {
id: `${activeAccount.pubkey}-bookmarks`,
title: `Bookmarks (${sortedBookmarks.length})`,
url: '',
content: latestContent,
created_at: newestCreatedAt || Math.floor(Date.now() / 1000),
created_at: newestCreatedAt || 0,
tags: allTags,
bookmarkCount: sortedBookmarks.length,
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
@@ -310,18 +372,14 @@ class BookmarkController {
encryptedContent: undefined
}
console.log('[bookmark] 📋 Built bookmark with', sortedBookmarks.length, 'items')
console.log('[bookmark] 📤 Emitting to', this.bookmarksListeners.length, 'listeners')
this.bookmarksListeners.forEach(cb => cb([bookmark]))
}
// Emit immediately with empty metadata (show placeholders)
const idToEvent: Map<string, NostrEvent> = new Map()
console.log('[bookmark] 🚀 Emitting initial bookmarks with placeholders (IDs only)...')
emitBookmarks(idToEvent)
// Now fetch events progressively in background using batched hydrators
console.log('[bookmark] 🔧 Background hydration:', noteIds.length, 'note IDs and', coordinates.length, 'coordinates')
// Now fetch events progressively in background using local-first queries
const generation = this.hydrationGeneration
const onProgress = () => emitBookmarks(idToEvent)
@@ -336,14 +394,16 @@ class BookmarkController {
}
})
// Kick off batched hydration (streaming, non-blocking)
// EventLoader and AddressLoader handle batching and streaming automatically
this.hydrateByIds(noteIds, idToEvent, onProgress, generation)
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation)
// Kick off hydration (streaming, non-blocking, local-first)
// Fire-and-forget - don't await, let it run in background
this.hydrateByIds(noteIds, idToEvent, onProgress, generation).catch(() => {
// Silent error - hydration will retry or show partial results
})
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation).catch(() => {
// Silent error - hydration will retry or show partial results
})
} catch (error) {
console.error('[bookmark] ❌ Failed to build bookmarks:', error)
console.error('[bookmark] ❌ Error details:', error instanceof Error ? error.message : String(error))
console.error('[bookmark] ❌ Stack:', error instanceof Error ? error.stack : 'no stack')
console.error('Failed to build bookmarks:', error)
this.bookmarksListeners.forEach(cb => cb([]))
}
}
@@ -352,11 +412,15 @@ class BookmarkController {
relayPool: RelayPool
activeAccount: unknown
accountManager: { getActive: () => unknown }
eventStore?: EventStore
}): Promise<void> {
const { relayPool, activeAccount, accountManager } = options
const { relayPool, activeAccount, accountManager, eventStore } = options
// Store references for hydration
this.relayPool = relayPool
this.externalEventStore = eventStore || null
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
console.error('[bookmark] Invalid activeAccount')
return
}
@@ -365,19 +429,7 @@ class BookmarkController {
// Increment generation to cancel any in-flight hydration
this.hydrationGeneration++
// Initialize loaders for this session
console.log('[bookmark] 🔧 Initializing EventLoader and AddressLoader with', RELAYS.length, 'relays')
this.eventLoader = createEventLoader(relayPool, {
eventStore: this.eventStore,
extraRelays: RELAYS
})
this.addressLoader = createAddressLoader(relayPool, {
eventStore: this.eventStore,
extraRelays: RELAYS
})
this.setLoading(true)
console.log('[bookmark] 🔍 Starting bookmark load for', account.pubkey.slice(0, 8))
try {
// Get signer for auto-decryption
@@ -405,7 +457,6 @@ class BookmarkController {
// Add/update event
this.currentEvents.set(key, evt)
console.log('[bookmark] 📨 Event:', evt.kind, evt.id.slice(0, 8), 'encrypted:', hasEncryptedContent(evt))
// Emit raw event for Debug UI
this.emitRawEvent(evt)
@@ -415,12 +466,13 @@ class BookmarkController {
if (!isEncrypted) {
// For unencrypted events, build bookmarks immediately (progressive update)
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
.catch(err => console.error('[bookmark] ❌ Failed to update after event:', err))
.catch(() => {
// Silent error - will retry on next event
})
}
// Auto-decrypt if event has encrypted content (fire-and-forget, non-blocking)
if (isEncrypted) {
console.log('[bookmark] 🔓 Auto-decrypting event', evt.id.slice(0, 8))
// Don't await - let it run in background
collectBookmarksFromEvents([evt], account, signerCandidate)
.then(({ publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags }) => {
@@ -433,10 +485,6 @@ class BookmarkController {
latestContent,
allTags
})
console.log('[bookmark] ✅ Auto-decrypted:', evt.id.slice(0, 8), {
public: publicItemsAll.length,
private: privateItemsAll.length
})
// Emit decrypt complete for Debug UI
this.decryptCompleteListeners.forEach(cb =>
@@ -445,10 +493,12 @@ class BookmarkController {
// Rebuild bookmarks with newly decrypted content (progressive update)
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
.catch(err => console.error('[bookmark] ❌ Failed to update after decrypt:', err))
.catch(() => {
// Silent error - will retry on next event
})
})
.catch((error) => {
console.error('[bookmark] ❌ Auto-decrypt failed:', evt.id.slice(0, 8), error)
.catch(() => {
// Silent error - decrypt failed
})
}
}
@@ -457,9 +507,8 @@ class BookmarkController {
// Final update after EOSE
await this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
console.log('[bookmark] ✅ Bookmark load complete')
} catch (error) {
console.error('[bookmark] ❌ Failed to load bookmarks:', error)
console.error('Failed to load bookmarks:', error)
this.bookmarksListeners.forEach(cb => cb([]))
} finally {
this.setLoading(false)

View File

@@ -15,28 +15,30 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
}
const unique = Array.from(byId.values())
// Separate web bookmarks (kind:39701) from list-based bookmarks
const webBookmarks = unique.filter(e => e.kind === 39701)
const bookmarkLists = unique
.filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd'))
// Deduplicate replaceable events (kind:30003, 30001, 39701) by d-tag
const byD = new Map<string, NostrEvent>()
for (const e of unique) {
if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001) {
if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001 || e.kind === 39701) {
const d = (e.tags || []).find((t: string[]) => t[0] === 'd')?.[1] || ''
const prev = byD.get(d)
if (!prev || (e.created_at || 0) > (prev.created_at || 0)) byD.set(d, e)
}
}
const setsAndNamedLists = Array.from(byD.values())
// Separate web bookmarks from bookmark sets/lists
const allReplaceable = Array.from(byD.values())
const webBookmarks = allReplaceable.filter(e => e.kind === 39701)
const setsAndNamedLists = allReplaceable.filter(e => e.kind !== 39701)
const out: NostrEvent[] = []
if (latestBookmarkList) out.push(latestBookmarkList)
out.push(...setsAndNamedLists)
// Add web bookmarks as individual events
// Add deduplicated web bookmarks as individual events
out.push(...webBookmarks)
return out
}

Some files were not shown because too many files have changed in this diff Show More