Compare commits

...

266 Commits

Author SHA1 Message Date
Gigi
bd0dcbb7f2 chore: bump version to 0.11.1 2025-11-22 02:17:47 +01:00
Gigi
645e1f2b18 Merge pull request #48 from dergigi/profile-three-dot-menu
Add three-dot menu to profile view
2025-11-22 02:16:58 +01:00
Gigi
02de0e7011 fix: remove horizontal padding from profile header to match tabs width 2025-11-22 02:13:02 +01:00
Gigi
e491f7e385 fix: move profile menu to top-right inside card 2025-11-22 02:10:24 +01:00
Gigi
62e5b2b0af fix: move profile menu inside card bottom-right
- Wrap AuthorCard in profile-card-with-menu container
- Use CompactButton for ellipsis menu aligned with highlight cards
- Position menu button at bottom-right inside card and open menu upward
2025-11-22 02:04:08 +01:00
Gigi
be03b9c9cc feat: add three-dot menu to profile view
- Add menu button with options: Copy Link, Share, Open with njump, Open with Native App
- Position menu next to AuthorCard in profile header
- Add click-outside detection to close menu
- Style menu consistently with other menus in the app
2025-11-22 01:59:02 +01:00
Gigi
3da6a70f77 Merge pull request #47 from dergigi/fix-article-loading
Fix highlights navigation and article loading
2025-11-22 01:57:35 +01:00
Gigi
a2dc928681 fix(highlights): make go-to-quote use highlight selection
- Prefer onHighlightClick for quote button, quote text, and menu item
- Fall back to navigation when no highlight-click handler is provided
- Ensures reliable scroll-to-quote behavior in the reader
2025-11-22 01:54:39 +01:00
Gigi
1f88201c18 fix(highlights): ensure quote button always navigates to quote
- Add explicit preventDefault to quote button click handler
- Improve navigateToArticle error handling and logging
- Ensure quote button always attempts navigation when clicked
2025-11-22 01:47:14 +01:00
Gigi
85e93b69aa fix(highlights): prevent menu cutoff when only one highlight
- Add bottom padding to highlights-list to ensure menu has space
- Make menu open upward for last highlight item when space is limited
- Prevents three-dot menu from being clipped by container overflow
2025-11-22 01:45:30 +01:00
Gigi
5cede24650 feat(highlights): make quote clickable to navigate to article
- Extract navigateToArticle helper function for reusability
- Make quote button navigate to article and scroll to highlight
- Make quote text (blockquote) clickable to navigate to article
- Add 'Go to quote' menu item in ellipsis menu
- All quote interactions now navigate to article with highlight scroll
2025-11-22 01:44:18 +01:00
Gigi
2348361d1d feat(highlights): add profile navigation from highlight author
- Make author name clickable to navigate to profile view
- Add 'View profile' option in highlight ellipsis menu
- Implement navigateToProfile helper with error handling
- Use existing /p/:npub routing infrastructure
2025-11-22 01:38:37 +01:00
Gigi
c134c3db57 fix: remove unused events variable in useArticleLoader 2025-11-22 01:29:56 +01:00
Gigi
18dbc521ee fix: reuse Explore article events to load articles immediately
- Add articleCoordinate and eventId to BlogPostCard navigation state
- Update useArticleLoader to check navigation state first before cache/EventStore
- Hydrate article content immediately from eventStore when coming from Explore
- Preserve existing cache/EventStore paths for deep links
- Add background query to check for newer replaceable versions without blocking UI
- Guard updates with requestId to prevent race conditions

This fixes the issue where articles opened from Explore would hang on loading
skeleton when queryEvents never completes. Now articles load instantly by reusing
the full event that Explore already fetched and cached.
2025-11-22 01:29:06 +01:00
Gigi
8600c09344 Merge pull request #46 from dergigi/relay-hints
refactor: improve relay hint selection and relay management
2025-11-22 01:21:40 +01:00
Gigi
efb6b56c3b refactor: improve relay hint selection and relay management
- Extract updateKeepAlive and updateAddressLoader helpers in App.tsx for better code reuse
- Improve relay hint selection in HighlightItem with priority: published > seen > configured relays
- Add URL normalization for consistent relay comparison across services
- Unify relay set approach in articleService (hints + configured relays together)
- Improve relay deduplication in relayListService using normalized URLs
- Move normalizeRelayUrl to helpers.ts for shared use
- Update isContentRelay to use normalized URLs for comparison
- Use getFallbackContentRelays for HARDCODED_RELAYS in relayManager
2025-11-22 01:16:10 +01:00
Gigi
cc22524466 fix: remove unused getDefaultRelays import 2025-11-22 00:29:31 +01:00
Gigi
bca1ee2b2e refactor(relays): unify relay config with typed registry and improve hint usage
- Create typed RelayRole and RelayConfig interface in relays.ts
- Add centralized RELAY_CONFIGS registry with role annotations
- Add helper getters: getLocalRelays(), getDefaultRelays(), getContentRelays(), getFallbackContentRelays()
- Maintain backwards compatibility with RELAYS and NON_CONTENT_RELAYS constants
- Refactor relayManager to use new registry helpers
- Harden applyRelaySetToPool with consistent normalization and local relay preservation
- Add RelaySetChangeSummary return type for debugging
- Improve articleService to prioritize and filter relay hints from naddr
- Use centralized fallback content relay helpers instead of hard-coded arrays
2025-11-16 18:30:39 +00:00
Gigi
4d18c84243 feat: improve relay hint selection to exclude non-content relays
- Add NON_CONTENT_RELAYS list and isContentRelay helper to classify relays
- Update ContentPanel to filter out non-content relays (e.g., relay.nsec.app) from naddr hints
- Update HighlightItem to prefer publishedRelays/seenOnRelays and filter using isContentRelay
- Ensures relay.nsec.app and other auth/utility relays are never suggested as content hints
2025-11-15 20:26:37 +00:00
Gigi
c1b171d188 docs: update CHANGELOG for v0.11.0 2025-11-07 23:19:25 +01:00
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
88 changed files with 6517 additions and 2060 deletions

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

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,207 +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://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 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'
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()
@@ -209,89 +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) {
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) {
// Debug mode enabled
}
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) {
// Debug mode enabled
}
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) {
// Debug mode enabled
}
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) {
// Debug mode enabled
}
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 })
}

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

82
package-lock.json generated
View File

@@ -1,18 +1,19 @@
{
"name": "boris",
"version": "0.10.23",
"version": "0.10.33",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boris",
"version": "0.10.23",
"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",
@@ -37,12 +38,14 @@
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"tinyld": "^1.3.4",
"use-pull-to-refresh": "^2.4.1"
"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",
@@ -56,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": {
@@ -102,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",
@@ -2262,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"
},
@@ -3553,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"
@@ -3595,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",
@@ -3637,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",
@@ -3799,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",
@@ -3927,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",
@@ -4012,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"
@@ -4088,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"
},
@@ -4640,6 +4672,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -5876,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",
@@ -9711,6 +9745,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -9857,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"
},
@@ -9869,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"
@@ -10434,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"
}
@@ -11189,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",
@@ -11265,6 +11304,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11517,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"
@@ -11544,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",
@@ -11560,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",
@@ -11842,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",
@@ -12227,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",
@@ -12271,6 +12319,7 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -12519,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.10.25",
"version": "0.11.1",
"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",
@@ -40,12 +45,14 @@
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"tinyld": "^1.3.4",
"use-pull-to-refresh": "^2.4.1"
"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",
@@ -97,6 +104,15 @@
"@typescript-eslint/no-explicit-any": "warn",
"prefer-const": "error",
"no-var": "error"
}
},
"overrides": [
{
"files": ["api/**/*.ts"],
"env": {
"node": true,
"browser": false
}
}
]
}
}

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

@@ -576,6 +576,31 @@ function App() {
}
})
// Helper to update keep-alive subscription based on current active relays
const updateKeepAlive = (relayUrls?: string[]) => {
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
poolWithSub._keepAliveSubscription.unsubscribe()
}
const targetRelays = relayUrls || getActiveRelayUrls(pool)
const newKeepAliveSub = pool.subscription(targetRelays, { kinds: [0], limit: 0 }).subscribe({
next: () => {},
error: () => {}
})
poolWithSub._keepAliveSubscription = newKeepAliveSub
}
// Helper to update address loader based on current active relays
const updateAddressLoader = (relayUrls?: string[]) => {
const targetRelays = relayUrls || getActiveRelayUrls(pool)
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: targetRelays
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
}
// Handle user relay list and blocked relays when account changes
const userRelaysSub = accounts.active$.subscribe((account) => {
if (account) {
@@ -604,20 +629,6 @@ function App() {
// 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)
@@ -649,43 +660,16 @@ function App() {
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
updateAddressLoader()
}).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
updateKeepAlive(RELAYS)
updateAddressLoader(RELAYS)
}
})
@@ -755,6 +739,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

@@ -88,13 +88,6 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
fetchOpenGraph(normalizedUrl).catch(() => null) // Don't fail if OpenGraph fetch fails
])
console.log('🔍 Modal fetch debug:', {
url: normalizedUrl,
hasContent: !!content,
hasOgData: !!ogData,
ogDataKeys: ogData ? Object.keys(ogData) : null
})
lastFetchedUrlRef.current = normalizedUrl
let extractedAnything = false
@@ -121,13 +114,6 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
if (!description && ogData) {
const extractedDesc = ogData['og:description'] || ogData['twitter:description'] || ogData.description
console.log('🔍 Description extraction debug:', {
currentDescription: description,
hasOgData: !!ogData,
extractedDesc: extractedDesc,
willSetDescription: !!extractedDesc
})
if (extractedDesc) {
setDescription(extractedDesc)
extractedAnything = true

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

@@ -7,6 +7,7 @@ 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
@@ -18,8 +19,13 @@ interface BlogPostCardProps {
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
@@ -41,12 +47,16 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
progressColor = 'var(--color-text)' // Neutral text color (started)
}
// Debug log - reading progress shown as visual indicator
if (readingProgress !== undefined) {
// Reading progress display
}
// Build article coordinate for navigation state (kind:pubkey:dTag)
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = dTag ? `${post.event.kind}:${post.author}:${dTag}` : undefined
return (
<Link
to={href}
@@ -56,7 +66,9 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
image: post.image,
summary: post.summary,
published: post.published
}
},
articleCoordinate,
eventId: post.event.id
}}
className={`blog-post-card ${level ? `level-${level}` : ''}`}
style={{ textDecoration: 'none', color: 'inherit' }}

View File

@@ -11,6 +11,7 @@ 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'
@@ -62,12 +63,15 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
const authorNpub = npubEncode(bookmark.pubkey)
// 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

View File

@@ -247,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 ? (
@@ -287,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) => (

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'
@@ -79,16 +82,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({
// 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)
}
@@ -100,6 +99,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({
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()
@@ -229,7 +239,14 @@ 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
})

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

@@ -12,6 +12,8 @@ import { nip19 } from 'nostr-tools'
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
import { RelayPool } from 'applesauce-relay'
import { getActiveRelayUrls } from '../services/relayManager'
import { isContentRelay } from '../config/relays'
import { isLocalRelay } from '../utils/helpers'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights'
@@ -133,7 +135,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
return selectedUrl || `${title || ''}:${(markdown || html || '').length}`
}, [selectedUrl, title, markdown, html])
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
const { contentRef } = useHighlightInteractions({
onHighlightClick,
selectedHighlightId,
onTextSelection,
@@ -263,6 +265,23 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
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
@@ -415,9 +434,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayHints = activeRelays.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3)
const relayHints = activeRelays
.filter(url => !isLocalRelay(url))
.filter(url => isContentRelay(url))
.slice(0, 3)
const naddr = nip19.naddrEncode({
kind: 30023,
@@ -798,8 +818,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
html={finalHtml}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
className="reader-markdown"
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
) : (
<div className="reader-markdown">
@@ -813,8 +831,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
html={finalHtml || html || ''}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
className="reader-html"
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
)}

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

@@ -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
@@ -79,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

@@ -1,15 +1,16 @@
import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt, faUser } from '@fortawesome/free-solid-svg-icons'
import { faComments } from '@fortawesome/free-regular-svg-icons'
import { Highlight } from '../types/highlights'
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 { areAllRelaysLocal } from '../utils/helpers'
import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService'
import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers'
import { getActiveRelayUrls } from '../services/relayManager'
import { isContentRelay, getContentRelays, getFallbackContentRelays } from '../config/relays'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
import { createDeletionRequest } from '../services/deletionService'
@@ -18,6 +19,7 @@ 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 => {
@@ -114,7 +116,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)
@@ -128,17 +129,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(() => {
@@ -147,8 +140,6 @@ 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 && relayPool) {
const updatedHighlight = {
@@ -189,14 +180,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
}, [showMenu, showDeleteConfirm])
const handleItemClick = () => {
// If onHighlightClick is provided, use it (legacy behavior)
if (onHighlightClick) {
onHighlightClick(highlight.id)
return
}
// Otherwise, navigate to the article that this highlight references
// Navigate to the article that this highlight references and scroll to the highlight
const navigateToArticle = () => {
// Always try to navigate if we have a reference - quote button should always work
if (highlight.eventReference) {
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
const parts = highlight.eventReference.split(':')
@@ -219,9 +205,14 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
openHighlights: true
}
})
return
}
}
} else if (highlight.urlReference) {
// If eventReference is just an event ID (not a coordinate), we can't navigate to it
// as we don't have enough info to construct the article URL
}
if (highlight.urlReference) {
// Navigate to external URL with highlight ID to trigger scroll
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
state: {
@@ -229,16 +220,57 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
openHighlights: true
}
})
return
}
// If we get here, there's no valid reference to navigate to
// This shouldn't happen for valid highlights, but we'll log it for debugging
console.warn('Cannot navigate to article: highlight has no valid eventReference or urlReference', highlight.id)
}
const handleItemClick = () => {
// If onHighlightClick is provided, use it (legacy behavior)
if (onHighlightClick) {
onHighlightClick(highlight.id)
return
}
// Otherwise, navigate to the article that this highlight references
navigateToArticle()
}
const getHighlightLinks = () => {
// Encode the highlight event itself (kind 9802) as a nevent
// Get non-local relays for the hint
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
// Relay hint selection priority:
// 1. Published relays (where we successfully published the event)
// 2. Seen relays (where we observed the event)
// 3. Configured content relays (deterministic fallback)
// All candidates are deduplicated, filtered to content-capable remote relays, and limited to 3
const publishedRelays = highlight.publishedRelays || []
const seenOnRelays = highlight.seenOnRelays || []
// Determine base candidates: prefer published, then seen, then configured relays
let candidates: string[]
if (publishedRelays.length > 0) {
// Prefer published relays, but include seen relays as backup
candidates = Array.from(new Set([...publishedRelays, ...seenOnRelays]))
.sort((a, b) => a.localeCompare(b))
} else if (seenOnRelays.length > 0) {
candidates = seenOnRelays
} else {
// Fallback to deterministic configured content relays
const contentRelays = getContentRelays()
const fallbackRelays = getFallbackContentRelays()
candidates = Array.from(new Set([...contentRelays, ...fallbackRelays]))
}
// Filter to content-capable remote relays (exclude local and non-content relays)
// Then take up to 3 for relay hints
const relayHints = candidates
.filter(url => !isLocalRelay(url))
.filter(url => isContentRelay(url))
.slice(0, 3)
const nevent = nip19.neventEncode({
id: highlight.id,
@@ -292,9 +324,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
onHighlightUpdate(updatedHighlight)
}
// Update local state
setShowOfflineIndicator(false)
} catch (error) {
console.error('❌ Failed to rebroadcast:', error)
} finally {
@@ -313,8 +342,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) {
@@ -322,7 +380,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: isLocalOrOffline ? faPlane : faHighlighter,
icon: faHighlighter,
tooltip: relayNames.join('\n'),
spin: false
}
@@ -418,6 +476,39 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
handleConfirmDelete()
}
// Navigate to author's profile
const navigateToProfile = (tab?: 'highlights' | 'writings') => {
try {
const npub = nip19.npubEncode(highlight.pubkey)
const path = tab === 'writings' ? `/p/${npub}/writings` : `/p/${npub}`
navigate(path)
} catch (err) {
console.error('Failed to encode npub for profile navigation:', err)
}
}
const handleAuthorClick = (e: React.MouseEvent) => {
e.stopPropagation()
navigateToProfile()
}
const handleMenuViewProfile = (e: React.MouseEvent) => {
e.stopPropagation()
setShowMenu(false)
navigateToProfile()
}
const handleMenuGoToQuote = (e: React.MouseEvent) => {
e.stopPropagation()
setShowMenu(false)
if (onHighlightClick) {
onHighlightClick(highlight.id)
} else {
navigateToArticle()
}
}
return (
<>
<div
@@ -467,14 +558,36 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
<CompactButton
className="highlight-quote-button"
icon={faQuoteLeft}
title="Quote"
onClick={(e) => e.stopPropagation()}
title="Go to quote in article"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
if (onHighlightClick) {
onHighlightClick(highlight.id)
} else {
navigateToArticle()
}
}}
/>
{/* relay indicator lives in footer for consistent padding/alignment */}
<div className="highlight-content">
<blockquote className="highlight-text">
<blockquote
className="highlight-text"
onClick={(e) => {
e.stopPropagation()
if (onHighlightClick) {
onHighlightClick(highlight.id)
} else {
navigateToArticle()
}
}}
style={{ cursor: 'pointer' }}
title="Go to quote in article"
>
{highlight.content}
</blockquote>
@@ -508,9 +621,13 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
/>
)}
<span className="highlight-author">
<CompactButton
className="highlight-author"
onClick={handleAuthorClick}
title="View profile"
>
{getUserDisplayName()}
</span>
</CompactButton>
</div>
<div className="highlight-menu-wrapper" ref={menuRef}>
@@ -549,6 +666,20 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
{showMenu && (
<div className="highlight-menu">
<button
className="highlight-menu-item"
onClick={handleMenuGoToQuote}
>
<FontAwesomeIcon icon={faQuoteLeft} />
<span>Go to quote</span>
</button>
<button
className="highlight-menu-item"
onClick={handleMenuViewProfile}
>
<FontAwesomeIcon icon={faUser} />
<span>View profile</span>
</button>
<button
className="highlight-menu-item"
onClick={handleOpenPortal}

View File

@@ -1,7 +1,12 @@
import React from 'react'
import React, { useMemo } from 'react'
import { nip19 } from 'nostr-tools'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
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
@@ -20,25 +25,31 @@ const NostrMentionLink: React.FC<NostrMentionLinkProps> = ({
}) => {
// Decode the nostr URI first
let decoded: ReturnType<typeof nip19.decode> | null = null
let pubkey: string | undefined
try {
const identifier = nostrUri.replace(/^nostr:/, '')
decoded = nip19.decode(identifier)
// Extract pubkey for profile fetching (works for npub and nprofile)
if (decoded.type === 'npub') {
pubkey = decoded.data
} else if (decoded.type === 'nprofile') {
pubkey = decoded.data.pubkey
}
} 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:/, '')
@@ -49,37 +60,30 @@ const NostrMentionLink: React.FC<NostrMentionLinkProps> = ({
)
}
// 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 'npub': {
const pk = decoded.data
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...`
return (
<a
href={`/p/${nip19.npubEncode(pk)}`}
className={className}
onClick={onClick}
>
@{displayName}
</a>
)
}
case 'nprofile': {
const { pubkey: pk } = decoded.data
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...`
const npub = nip19.npubEncode(pk)
return (
<a
href={`/p/${npub}`}
className={className}
onClick={onClick}
>
@{displayName}
</a>
)
}
case 'naddr': {
const { kind, pubkey: pk, identifier: addrIdentifier } = decoded.data
// Check if it's a blog post (kind:30023)

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
import { faHighlighter, faPenToSquare, faEllipsisH, faCopy, faShare, faExternalLinkAlt, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import { IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
@@ -9,6 +9,7 @@ import { HighlightItem } from './HighlightItem'
import { BlogPostPreview } from '../services/exploreService'
import { KINDS } from '../config/kinds'
import AuthorCard from './AuthorCard'
import CompactButton from './CompactButton'
import BlogPostCard from './BlogPostCard'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { useStoreTimeline } from '../hooks/useStoreTimeline'
@@ -20,6 +21,7 @@ import { Hooks } from 'applesauce-react'
import { readingProgressController } from '../services/readingProgressController'
import { writingsController } from '../services/writingsController'
import { highlightsController } from '../services/highlightsController'
import { getProfileUrl } from '../config/nostrGateways'
interface ProfileProps {
relayPool: RelayPool
@@ -38,6 +40,8 @@ const Profile: React.FC<ProfileProps> = ({
const activeAccount = Hooks.useActiveAccount()
const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights')
const [refreshTrigger, setRefreshTrigger] = useState(0)
const [showProfileMenu, setShowProfileMenu] = useState(false)
const profileMenuRef = useRef<HTMLDivElement>(null)
// Reading progress state (naddr -> progress 0-1)
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
@@ -168,6 +172,68 @@ const Profile: React.FC<ProfileProps> = ({
const npub = nip19.npubEncode(pubkey)
const showSkeletons = cachedHighlights.length === 0 && sortedWritings.length === 0
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (profileMenuRef.current && !profileMenuRef.current.contains(event.target as Node)) {
setShowProfileMenu(false)
}
}
if (showProfileMenu) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [showProfileMenu])
// Profile menu handlers
const handleMenuToggle = () => {
setShowProfileMenu(!showProfileMenu)
}
const handleCopyProfileLink = async () => {
try {
const borisUrl = `${window.location.origin}/p/${npub}`
await navigator.clipboard.writeText(borisUrl)
setShowProfileMenu(false)
} catch (e) {
console.warn('Copy failed', e)
}
}
const handleShareProfile = async () => {
try {
const borisUrl = `${window.location.origin}/p/${npub}`
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: 'Profile',
url: borisUrl
})
} else {
await navigator.clipboard.writeText(borisUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowProfileMenu(false)
}
}
const handleOpenPortal = () => {
const portalUrl = getProfileUrl(npub)
window.open(portalUrl, '_blank', 'noopener,noreferrer')
setShowProfileMenu(false)
}
const handleOpenNative = () => {
const nativeUrl = `nostr:${npub}`
window.location.href = nativeUrl
setShowProfileMenu(false)
}
const renderTabContent = () => {
switch (activeTab) {
case 'highlights':
@@ -236,7 +302,51 @@ const Profile: React.FC<ProfileProps> = ({
pullPosition={pullPosition}
/>
<div className="explore-header">
<AuthorCard authorPubkey={pubkey} clickable={false} />
<div className="profile-header-wrapper">
<div className="profile-card-with-menu">
<AuthorCard authorPubkey={pubkey} clickable={false} />
<div className="profile-card-menu-wrapper" ref={profileMenuRef}>
<CompactButton
icon={faEllipsisH}
onClick={handleMenuToggle}
title="More options"
ariaLabel="Profile menu"
/>
{showProfileMenu && (
<div className="profile-card-menu">
<button
className="profile-card-menu-item"
onClick={handleCopyProfileLink}
>
<FontAwesomeIcon icon={faCopy} />
<span>Copy Link</span>
</button>
<button
className="profile-card-menu-item"
onClick={handleShareProfile}
>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
<button
className="profile-card-menu-item"
onClick={handleOpenPortal}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open with njump</span>
</button>
<button
className="profile-card-menu-item"
onClick={handleOpenNative}
>
<FontAwesomeIcon icon={faMobileAlt} />
<span>Open with Native App</span>
</button>
</div>
)}
</div>
</div>
</div>
<div className="me-tabs">
<button

View File

@@ -80,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} />

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

@@ -1,5 +1,13 @@
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
@@ -18,18 +26,31 @@ const RichContent: React.FC<RichContentProps> = ({
content,
className = 'bookmark-content'
}) => {
// Pattern to match:
// 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.)
// 2. Plain nostr identifiers (npub1..., nprofile1..., note1..., etc.)
// 3. http(s) URLs
const pattern = /(nostr:[a-z0-9]+|npub1[a-z0-9]+|nprofile1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|naddr1[a-z0-9]+|https?:\/\/[^\s]+)/gi
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)
const parts = content.split(pattern)
return (
// 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) => {
// Handle nostr: URIs
// Skip empty or undefined parts
if (!part) {
return null
}
// Handle nostr: URIs - Tokens.nostrLink matches both formats
if (part.startsWith('nostr:')) {
return (
<NostrMentionLink
@@ -39,10 +60,8 @@ const RichContent: React.FC<RichContentProps> = ({
)
}
// Handle plain nostr identifiers (add nostr: prefix)
if (
part.match(/^(npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+$/i)
) {
// Handle plain nostr identifiers (Tokens.nostrLink matches these too)
if (isNostrIdentifier(part)) {
return (
<NostrMentionLink
key={index}
@@ -70,7 +89,11 @@ const RichContent: React.FC<RichContentProps> = ({
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

@@ -51,6 +51,8 @@ const DEFAULT_SETTINGS: UserSettings = {
ttsDetectContentLanguage: true,
ttsLanguageMode: 'content',
ttsDefaultSpeed: 2.1,
linkColorDark: '#38bdf8',
linkColorLight: '#3b82f6',
}
interface SettingsProps {

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">
@@ -109,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">
@@ -179,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

@@ -7,6 +7,8 @@ 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
@@ -28,14 +30,18 @@ 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) => {

View File

@@ -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
@@ -182,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)

View File

@@ -1,4 +1,4 @@
import React, { useMemo, forwardRef } from 'react'
import { useMemo, forwardRef } from 'react'
import ReactPlayer from 'react-player'
import { classifyUrl } from '../utils/helpers'
@@ -6,8 +6,6 @@ interface VideoEmbedProcessorProps {
html: string
renderVideoLinksAsEmbeds: boolean
className?: string
onMouseUp?: (e: React.MouseEvent) => void
onTouchEnd?: (e: React.TouchEvent) => void
}
/**
@@ -17,9 +15,7 @@ interface VideoEmbedProcessorProps {
const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>(({
html,
renderVideoLinksAsEmbeds,
className,
onMouseUp,
onTouchEnd
className
}, ref) => {
// Process HTML and extract video URLs in a single pass to keep them in sync
const { processedHtml, videoUrls } = useMemo(() => {
@@ -109,8 +105,6 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
ref={ref}
className={className}
dangerouslySetInnerHTML={{ __html: processedHtml }}
onMouseUp={onMouseUp}
onTouchEnd={onTouchEnd}
/>
)
}
@@ -119,7 +113,7 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
const parts = processedHtml.split(/(__VIDEO_EMBED_\d+__)/)
return (
<div ref={ref} className={className} onMouseUp={onMouseUp} onTouchEnd={onTouchEnd}>
<div ref={ref} className={className}>
{parts.map((part, index) => {
const videoMatch = part.match(/^__VIDEO_EMBED_(\d+)__$/)
if (videoMatch) {

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

@@ -1,21 +1,101 @@
import { normalizeRelayUrl } from '../utils/helpers'
/**
* Centralized relay configuration
* Single set of relays used throughout the application
*/
// All relays including local relays
export const RELAYS = [
'ws://localhost:10547',
'ws://localhost:4869',
'wss://relay.nsec.app',
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://wot.dergigi.com',
'wss://relay.snort.social',
'wss://nostr-pub.wellorder.net',
'wss://purplepag.es',
'wss://relay.primal.net',
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87',
export type RelayRole = 'local-cache' | 'default' | 'fallback' | 'non-content' | 'bunker'
export interface RelayConfig {
url: string
roles: RelayRole[]
}
/**
* Central relay registry with role annotations
*/
const RELAY_CONFIGS: RelayConfig[] = [
{ url: 'ws://localhost:10547', roles: ['local-cache'] },
{ url: 'ws://localhost:4869', roles: ['local-cache'] },
{ url: 'wss://relay.nsec.app', roles: ['default', 'non-content'] },
{ url: 'wss://relay.damus.io', roles: ['default', 'fallback'] },
{ url: 'wss://nos.lol', roles: ['default', 'fallback'] },
{ url: 'wss://relay.nostr.band', roles: ['default', 'fallback'] },
{ url: 'wss://wot.dergigi.com', roles: ['default'] },
{ url: 'wss://relay.snort.social', roles: ['default'] },
{ url: 'wss://nostr-pub.wellorder.net', roles: ['default'] },
{ url: 'wss://purplepag.es', roles: ['default'] },
{ url: 'wss://relay.primal.net', roles: ['default', 'fallback'] },
{ url: 'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87', roles: ['default'] },
]
/**
* Get all local cache relays (localhost relays)
*/
export function getLocalRelays(): string[] {
return RELAY_CONFIGS
.filter(config => config.roles.includes('local-cache'))
.map(config => config.url)
}
/**
* Get all default relays (main public relays)
*/
export function getDefaultRelays(): string[] {
return RELAY_CONFIGS
.filter(config => config.roles.includes('default'))
.map(config => config.url)
}
/**
* Get fallback content relays (last resort public relays for content fetching)
* These are reliable public relays that should be tried when other methods fail
*/
export function getFallbackContentRelays(): string[] {
return RELAY_CONFIGS
.filter(config => config.roles.includes('fallback'))
.map(config => config.url)
}
/**
* Get relays suitable for content fetching (excludes non-content relays like auth/signer relays)
*/
export function getContentRelays(): string[] {
return RELAY_CONFIGS
.filter(config => !config.roles.includes('non-content'))
.map(config => config.url)
}
/**
* Get relays that should NOT be used as content hints
*/
export function getNonContentRelays(): string[] {
return RELAY_CONFIGS
.filter(config => config.roles.includes('non-content'))
.map(config => config.url)
}
/**
* All relays including local relays (backwards compatibility)
*/
export const RELAYS = [
...getLocalRelays(),
...getDefaultRelays(),
]
/**
* Relays that should NOT be used as content hints (backwards compatibility)
*/
export const NON_CONTENT_RELAYS = getNonContentRelays()
/**
* Check if a relay URL is suitable for use as a content hint
* Returns true for relays that are reasonable for posts/highlights
*/
export function isContentRelay(url: string): boolean {
const normalized = normalizeRelayUrl(url)
const nonContentRelays = getNonContentRelays().map(normalizeRelayUrl)
return !nonContentRelays.includes(normalized)
}

View File

@@ -6,8 +6,9 @@ import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { Helpers } from 'applesauce-core'
import { queryEvents } from '../services/dataFetch'
import { fetchArticleByNaddr } from '../services/articleService'
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'
@@ -21,6 +22,12 @@ interface PreviewData {
published?: number
}
interface NavigationState {
previewData?: PreviewData
articleCoordinate?: string
eventId?: string
}
interface UseArticleLoaderProps {
naddr: string | undefined
relayPool: RelayPool | null
@@ -62,8 +69,11 @@ export function useArticleLoader({
// 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
// Extract navigation state (from blog post cards)
const navState = (location.state as NavigationState | null) || {}
const previewData = navState.previewData
const navArticleCoordinate = navState.articleCoordinate
const navEventId = navState.eventId
// Track the current article title for document title
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
@@ -72,11 +82,374 @@ export function useArticleLoader({
useEffect(() => {
mountedRef.current = true
if (!relayPool || !naddr) return
// First check: naddr is required
if (!naddr) {
setReaderContent(undefined)
return
}
// Clear readerContent immediately to prevent showing stale content from previous article
// This ensures images from previous articles don't flash briefly
setReaderContent(undefined)
// FIRST: Check navigation state for article coordinate/eventId (from Explore)
// This allows immediate hydration when coming from Explore without refetching
let foundInNavState = false
if (eventStore && (navArticleCoordinate || navEventId)) {
try {
let storedEvent: NostrEvent | undefined
// Try coordinate first (most reliable for replaceable events)
if (navArticleCoordinate) {
storedEvent = eventStore.getEvent?.(navArticleCoordinate) as NostrEvent | undefined
}
// Fallback to eventId if coordinate lookup failed
if (!storedEvent && navEventId) {
// Note: eventStore.getEvent might not support eventId lookup directly
// We'll decode naddr to get coordinate as fallback
try {
const decoded = nip19.decode(naddr)
if (decoded.type === 'naddr') {
const pointer = decoded.data as AddressPointer
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
storedEvent = eventStore.getEvent?.(coordinate) as NostrEvent | undefined
}
} catch {
// Ignore decode errors
}
}
if (storedEvent) {
foundInNavState = true
const title = Helpers.getArticleTitle(storedEvent) || previewData?.title || 'Untitled Article'
setCurrentTitle(title)
const image = Helpers.getArticleImage(storedEvent) || previewData?.image
const summary = Helpers.getArticleSummary(storedEvent) || previewData?.summary
const published = Helpers.getArticlePublished(storedEvent) || previewData?.published
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)
// Preload image if available
if (image) {
preloadImage(image)
}
// 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)
}
})
}
}
// Start background query to check for newer replaceable version
// but don't block UI - we already have content
if (relayPool) {
const backgroundRequestId = ++currentRequestIdRef.current
const originalCreatedAt = storedEvent.created_at
// Fire and forget background fetch
;(async () => {
try {
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') return
const pointer = decoded.data as AddressPointer
const filter = {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier]
}
await queryEvents(relayPool, filter, {
onEvent: (evt) => {
if (!mountedRef.current || currentRequestIdRef.current !== backgroundRequestId) return
// Store in event store
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eventStore?.add?.(evt as unknown as any)
} catch {
// Ignore store errors
}
// Only update if this is a newer version than what we loaded
if (evt.created_at > originalCreatedAt) {
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)
// Update cache
const articleContent = {
title,
markdown: evt.content,
image,
summary,
published,
author: evt.pubkey,
event: evt
}
saveToCache(naddr, articleContent, settings)
}
}
})
} catch (err) {
// Silently ignore background fetch errors - we already have content
console.warn('[article-loader] Background fetch failed:', err)
}
})()
}
// Return early - we have content from navigation state
return
}
} catch (err) {
// If navigation state lookup fails, fall through to cache/EventStore
console.warn('[article-loader] Navigation state lookup failed:', err)
}
}
// 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,
markdown: cachedArticle.markdown,
image: cachedArticle.image,
summary: cachedArticle.summary,
published: cachedArticle.published,
url: `nostr:${naddr}`
})
const dTag = cachedArticle.event.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(cachedArticle.event.id)
setCurrentArticle?.(cachedArticle.event)
setReaderLoading(false)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
// 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
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 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 && !foundInNavState) {
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 && !foundInNavState) {
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
if (!mountedRef.current) {
return
}
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
@@ -85,19 +458,28 @@ export function useArticleLoader({
// when we know the article coordinate
setHighlightsLoading(false) // Don't show loading yet
// If we have preview data from navigation, show it immediately (no skeleton!)
// 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 store or relay
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)
}
@@ -118,44 +500,15 @@ export function useArticleLoader({
let firstEmitted = false
let latestEvent: NostrEvent | null = null
// Check eventStore first for instant load (from bookmark cards, explore, etc.)
if (eventStore) {
try {
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
const storedEvent = eventStore.getEvent?.(coordinate)
if (storedEvent) {
latestEvent = storedEvent as NostrEvent
firstEmitted = 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)
}
} catch (err) {
// Ignore store errors, fall through to relay query
}
}
// 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
if (!mountedRef.current) {
return
}
if (currentRequestIdRef.current !== requestId) {
return
}
// Store in event store for future local reads
try {
@@ -174,10 +527,11 @@ export function useArticleLoader({
if (!firstEmitted) {
firstEmitted = true
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
setCurrentTitle(title)
const image = Helpers.getArticleImage(evt)
const summary = Helpers.getArticleSummary(evt)
const published = Helpers.getArticlePublished(evt)
setCurrentTitle(title)
setReaderContent({
title,
markdown: evt.content,
@@ -192,20 +546,41 @@ export function useArticleLoader({
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
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'
setCurrentTitle(title)
const image = Helpers.getArticleImage(finalEvent)
const summary = Helpers.getArticleSummary(finalEvent)
const published = Helpers.getArticlePublished(finalEvent)
setCurrentTitle(title)
setReaderContent({
title,
markdown: finalEvent.content,
@@ -220,6 +595,23 @@ export function useArticleLoader({
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)
@@ -305,19 +697,13 @@ export function useArticleLoader({
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,
relayPool,
eventStore,
previewData,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed,
setHighlights,
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId,
setCurrentArticle
relayPool
])
}

View File

@@ -6,6 +6,8 @@ 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
@@ -40,7 +42,7 @@ export function useEventLoader({
// Initial title
let title = `Note (${event.kind})`
if (event.kind === 1) {
title = `Note by @${event.pubkey.slice(0, 8)}...`
title = `Note by ${getNpubFallbackDisplay(event.pubkey)}`
}
// Emit immediately
@@ -62,11 +64,12 @@ export function useEventLoader({
// First, try to get from event store cache
const storedProfile = eventStore.getEvent(event.pubkey + ':0')
if (storedProfile) {
try {
const obj = JSON.parse(storedProfile.content || '{}') as { name?: string; display_name?: string; nip05?: string }
resolved = obj.display_name || obj.name || obj.nip05 || ''
} catch {
// ignore parse errors
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
}
}
@@ -75,15 +78,15 @@ export function useEventLoader({
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]
try {
const obj = JSON.parse(latest.content || '{}') as { name?: string; display_name?: string; nip05?: string }
resolved = obj.display_name || obj.name || obj.nip05 || ''
} catch {
// ignore parse errors
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)

View File

@@ -165,19 +165,12 @@ export function useExternalUrlLoader({
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,
relayPool,
eventStore,
cachedUrlHighlights,
setReaderContent,
setReaderLoading,
setIsCollapsed,
setSelectedUrl,
setHighlights,
setCurrentArticleCoordinate,
setCurrentArticleEventId,
setHighlightsLoading
cachedUrlHighlights
])
// Keep UI highlights synced with cached store updates without reloading content

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,7 +61,6 @@ export const useHighlightCreation = ({
? currentArticle.content
: readerContent?.markdown || readerContent?.html
const newHighlight = await createHighlight(
text,
source,
@@ -73,7 +73,6 @@ export const useHighlightCreation = ({
)
// 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

@@ -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,58 +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(() => {
// Always clear previous render immediately to avoid showing stale content while processing
setRenderedHtml('')
setProcessedMarkdown('')
if (!markdown) {
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)
} 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)
const rafId = requestAnimationFrame(() => {
if (previewRef.current && !isCancelled) {
const html = previewRef.current.innerHTML
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()
@@ -77,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,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

@@ -68,11 +68,18 @@ 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')
// Set image max-width based on full-width setting
root.setProperty('--image-max-width', settings.fullWidthImages ? 'none' : '100%')
// 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')
}

View File

@@ -5,16 +5,60 @@ import './styles/tailwind.css'
import './index.css'
import 'react-loading-skeleton/dist/skeleton.css'
// Register Service Worker for PWA functionality (production only)
if ('serviceWorker' in navigator && import.meta.env.PROD) {
// 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')
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 => {
// Check for updates periodically
setInterval(() => {
registration.update()
}, 60 * 60 * 1000) // Check every hour
if (!registration) return
// 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', () => {
@@ -31,9 +75,22 @@ if ('serviceWorker' in navigator && import.meta.env.PROD) {
})
})
.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

@@ -4,7 +4,7 @@ import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { getContentRelays, getFallbackContentRelays, isContentRelay } from '../config/relays'
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
import { merge, toArray as rxToArray } from 'rxjs'
import { UserSettings } from './settingsService'
@@ -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
@@ -49,12 +51,51 @@ function getFromCache(naddr: string): ArticleContent | null {
}
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,8 +104,8 @@ function saveToCache(naddr: string, content: ArticleContent): void {
}
localStorage.setItem(cacheKey, JSON.stringify(cached))
} 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
}
}
@@ -97,13 +138,6 @@ export async function fetchArticleByNaddr(
const pointer = decoded.data as AddressPointer
// 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)
// Fetch the article event
const filter = {
kinds: [pointer.kind],
@@ -111,24 +145,45 @@ export async function fetchArticleByNaddr(
'#d': [pointer.identifier]
}
// 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()))
let events = collected as NostrEvent[]
let events: NostrEvent[] = []
// Fallback: if nothing found, try a second round against a set of reliable public relays
// Build unified relay set: hints + configured content relays
// Filter hinted relays to only content-capable relays
const hintedRelays = (pointer.relays && pointer.relays.length > 0)
? pointer.relays.filter(isContentRelay)
: []
// Get configured content relays
const contentRelays = getContentRelays()
// Union of hinted and configured relays (deduplicated)
const unifiedRelays = Array.from(new Set([...hintedRelays, ...contentRelays]))
if (unifiedRelays.length > 0) {
const orderedUnified = prioritizeLocalRelays(unifiedRelays)
const { local: localUnified, remote: remoteUnified } = partitionRelays(orderedUnified)
const { local$, remote$ } = createParallelReqStreams(
relayPool,
localUnified,
remoteUnified,
filter,
1200,
6000
)
const collected = await lastValueFrom(
merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray())
)
events = collected as NostrEvent[]
}
// Last resort: try fallback content relays (most 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 fallbackRelays = getFallbackContentRelays()
const { remote$: fallback$ } = createParallelReqStreams(
relayPool,
[], // no local
reliableRelays,
[], // no local for fallback
fallbackRelays,
filter,
1500,
12000
@@ -164,7 +219,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

@@ -3,6 +3,7 @@ import { NostrEvent } from 'nostr-tools'
import { Helpers, IEventStore } from 'applesauce-core'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { cacheArticleEvent } from './articleService'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -75,6 +76,9 @@ export const fetchBlogPostsFromAuthors = async (
}
onPost(post)
}
// Cache article content in localStorage for offline access
cacheArticleEvent(event)
}
}
}
@@ -105,7 +109,6 @@ export const fetchBlogPostsFromAuthors = async (
return timeB - timeA // Most recent first
})
return blogPosts
} catch (error) {
console.error('Failed to fetch blog posts:', error)

View File

@@ -7,13 +7,22 @@ import { Helpers, IEventStore } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { Highlight } from '../types/highlights'
import { UserSettings } from './settingsService'
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
import { publishEvent } from './writeService'
import { isLocalRelay } from '../utils/helpers'
import { setHighlightMetadata } from './highlightEventProcessor'
// Boris pubkey for zap splits
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
export const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
// Extended event type with highlight metadata
interface HighlightEvent extends NostrEvent {
__highlightProps?: {
publishedRelays?: string[]
isLocalOnly?: boolean
isSyncing?: boolean
}
}
const {
getHighlightText,
getHighlightContext,
@@ -118,25 +127,111 @@ export async function createHighlight(
// Sign the event
const signedEvent = await factory.sign(highlightEvent)
// Use unified write service to store and publish
await publishEvent(relayPool, eventStore, signedEvent)
// Initialize custom properties on the event (will be updated after publishing)
;(signedEvent as HighlightEvent).__highlightProps = {
publishedRelays: [],
isLocalOnly: false,
isSyncing: false
}
// Check current connection status for UI feedback
// Get only connected relays to avoid long timeouts
const connectedRelays = Array.from(relayPool.relays.values())
.filter(relay => relay.connected)
.map(relay => relay.url)
let publishResponses: { ok: boolean; message?: string; from: string }[] = []
let isLocalOnly = false
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
const expectedSuccessRelays = hasRemoteConnection
? RELAYS
: RELAYS.filter(isLocalRelay)
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
// Convert to Highlight with relay tracking info and return IMMEDIATELY
try {
// Publish only to connected relays to avoid long timeouts
if (connectedRelays.length === 0) {
isLocalOnly = true
} else {
publishResponses = await relayPool.publish(connectedRelays, signedEvent)
}
// Determine which relays successfully accepted the event
const successfulRelays = publishResponses
.filter(response => response.ok)
.map(response => response.from)
const successfulLocalRelays = successfulRelays.filter(url => isLocalRelay(url))
const successfulRemoteRelays = successfulRelays.filter(url => !isLocalRelay(url))
// isLocalOnly is true if only local relays accepted the event
isLocalOnly = successfulLocalRelays.length > 0 && successfulRemoteRelays.length === 0
// Handle case when no relays were connected
const successfulRelaysList = publishResponses.length > 0
? publishResponses
.filter(response => response.ok)
.map(response => response.from)
: []
// Store metadata in cache (persists across EventStore serialization)
setHighlightMetadata(signedEvent.id, {
publishedRelays: successfulRelaysList,
isLocalOnly,
isSyncing: false
})
// Also update the event with the actual properties (for backwards compatibility)
;(signedEvent as HighlightEvent).__highlightProps = {
publishedRelays: successfulRelaysList,
isLocalOnly,
isSyncing: false
}
// Store the event in EventStore AFTER updating with final properties
eventStore.add(signedEvent)
// Mark for offline sync if we're in local-only mode
if (isLocalOnly) {
const { markEventAsOfflineCreated } = await import('./offlineSyncService')
markEventAsOfflineCreated(signedEvent.id)
}
} catch (error) {
console.error('❌ [HIGHLIGHT-PUBLISH] Failed to publish highlight to relays:', error)
// If publishing fails completely, assume local-only mode
isLocalOnly = true
// Store metadata in cache (persists across EventStore serialization)
setHighlightMetadata(signedEvent.id, {
publishedRelays: [],
isLocalOnly: true,
isSyncing: false
})
// Also update the event with the error state (for backwards compatibility)
;(signedEvent as HighlightEvent).__highlightProps = {
publishedRelays: [],
isLocalOnly: true,
isSyncing: false
}
// Store the event in EventStore AFTER updating with final properties
eventStore.add(signedEvent)
const { markEventAsOfflineCreated } = await import('./offlineSyncService')
markEventAsOfflineCreated(signedEvent.id)
}
// Convert to Highlight with relay tracking info
const highlight = eventToHighlight(signedEvent)
highlight.publishedRelays = expectedSuccessRelays
// Manually set the properties since __highlightProps might not be working
const finalPublishedRelays = publishResponses.length > 0
? publishResponses
.filter(response => response.ok)
.map(response => response.from)
: []
highlight.publishedRelays = finalPublishedRelays
highlight.isLocalOnly = isLocalOnly
highlight.isOfflineCreated = isLocalOnly
highlight.isSyncing = false
return highlight
}

View File

@@ -2,6 +2,15 @@ import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { Highlight } from '../types/highlights'
// Extended event type with highlight metadata
interface HighlightEvent extends NostrEvent {
__highlightProps?: {
publishedRelays?: string[]
isLocalOnly?: boolean
isSyncing?: boolean
}
}
const {
getHighlightText,
getHighlightContext,
@@ -12,6 +21,66 @@ const {
getHighlightAttributions
} = Helpers
const METADATA_CACHE_KEY = 'highlightMetadataCache'
type HighlightMetadata = {
publishedRelays?: string[]
isLocalOnly?: boolean
isSyncing?: boolean
}
/**
* Load highlight metadata from localStorage
*/
function loadHighlightMetadataFromStorage(): Map<string, HighlightMetadata> {
try {
const raw = localStorage.getItem(METADATA_CACHE_KEY)
if (!raw) return new Map()
const parsed = JSON.parse(raw) as Record<string, HighlightMetadata>
return new Map(Object.entries(parsed))
} catch {
// Silently fail on parse errors or if storage is unavailable
return new Map()
}
}
/**
* Save highlight metadata to localStorage
*/
function saveHighlightMetadataToStorage(cache: Map<string, HighlightMetadata>): void {
try {
const record = Object.fromEntries(cache.entries())
localStorage.setItem(METADATA_CACHE_KEY, JSON.stringify(record))
} catch {
// Silently fail if storage is full or unavailable
}
}
/**
* Cache for highlight metadata that persists across EventStore serialization
* Key: event ID, Value: { publishedRelays, isLocalOnly, isSyncing }
*/
const highlightMetadataCache = loadHighlightMetadataFromStorage()
/**
* Store highlight metadata for an event ID
*/
export function setHighlightMetadata(
eventId: string,
metadata: HighlightMetadata
): void {
highlightMetadataCache.set(eventId, metadata)
saveHighlightMetadataToStorage(highlightMetadataCache)
}
/**
* Get highlight metadata for an event ID
*/
export function getHighlightMetadata(eventId: string): HighlightMetadata | undefined {
return highlightMetadataCache.get(eventId)
}
/**
* Convert a NostrEvent to a Highlight object
*/
@@ -28,6 +97,12 @@ export function eventToHighlight(event: NostrEvent): Highlight {
const eventReference = sourceEventPointer?.id ||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
// Check cache first (persists across EventStore serialization)
const cachedMetadata = getHighlightMetadata(event.id)
// Fall back to __highlightProps if cache doesn't have it (for backwards compatibility)
const customProps = cachedMetadata || (event as HighlightEvent).__highlightProps || {}
return {
id: event.id,
pubkey: event.pubkey,
@@ -38,7 +113,11 @@ export function eventToHighlight(event: NostrEvent): Highlight {
urlReference: sourceUrl,
author,
context,
comment
comment,
// Preserve custom properties if they exist
publishedRelays: customProps.publishedRelays,
isLocalOnly: customProps.isLocalOnly,
isSyncing: customProps.isSyncing
}
}

View File

@@ -5,7 +5,8 @@
* Service Worker automatically caches images on fetch
*/
const CACHE_NAME = 'boris-image-cache-v1'
// Must match the cache name in src/sw.ts
const CACHE_NAME = 'boris-images'
/**
* Clear all cached images

View File

@@ -5,6 +5,7 @@ import { BlogPostPreview } from './exploreService'
import { Highlight } from '../types/highlights'
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
import { queryEvents } from './dataFetch'
import { cacheArticleEvent } from './articleService'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -57,6 +58,9 @@ export const fetchNostrverseBlogPosts = async (
}
onPost(post)
}
// Cache article content in localStorage for offline access
cacheArticleEvent(event)
}
}
}
@@ -79,7 +83,6 @@ export const fetchNostrverseBlogPosts = async (
return timeB - timeA // Most recent first
})
return blogPosts
} catch (error) {
console.error('Failed to fetch nostrverse blog posts:', error)

View File

@@ -3,11 +3,42 @@ import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { isLocalRelay } from '../utils/helpers'
import { setHighlightMetadata, getHighlightMetadata } from './highlightEventProcessor'
const OFFLINE_EVENTS_KEY = 'offlineCreatedEvents'
let isSyncing = false
/**
* Load offline events from localStorage
*/
function loadOfflineEventsFromStorage(): Set<string> {
try {
const raw = localStorage.getItem(OFFLINE_EVENTS_KEY)
if (!raw) return new Set()
const parsed = JSON.parse(raw) as string[]
return new Set(parsed)
} catch {
// Silently fail on parse errors or if storage is unavailable
return new Set()
}
}
/**
* Save offline events to localStorage
*/
function saveOfflineEventsToStorage(events: Set<string>): void {
try {
const array = Array.from(events)
localStorage.setItem(OFFLINE_EVENTS_KEY, JSON.stringify(array))
} catch {
// Silently fail if storage is full or unavailable
}
}
// Track events created during offline period
const offlineCreatedEvents = new Set<string>()
const offlineCreatedEvents = loadOfflineEventsFromStorage()
// Track events currently being synced
const syncingEvents = new Set<string>()
@@ -20,6 +51,14 @@ const syncStateListeners: Array<(eventId: string, isSyncing: boolean) => void> =
*/
export function markEventAsOfflineCreated(eventId: string): void {
offlineCreatedEvents.add(eventId)
saveOfflineEventsToStorage(offlineCreatedEvents)
}
/**
* Check if an event was created during offline period (flight mode)
*/
export function isEventOfflineCreated(eventId: string): boolean {
return offlineCreatedEvents.has(eventId)
}
/**
@@ -87,6 +126,7 @@ export async function syncLocalEventsToRemote(
if (eventsToSync.length === 0) {
isSyncing = false
offlineCreatedEvents.clear()
saveOfflineEventsToStorage(offlineCreatedEvents)
return
}
@@ -95,10 +135,17 @@ export async function syncLocalEventsToRemote(
new Map(eventsToSync.map(e => [e.id, e])).values()
)
// Mark all events as syncing
// Mark all events as syncing and update metadata
uniqueEvents.forEach(event => {
syncingEvents.add(event.id)
notifySyncStateChange(event.id, true)
// Update metadata cache to reflect syncing state
const existingMetadata = getHighlightMetadata(event.id)
setHighlightMetadata(event.id, {
...existingMetadata,
isSyncing: true
})
})
// Publish to remote relays
@@ -118,13 +165,32 @@ export async function syncLocalEventsToRemote(
syncingEvents.delete(eventId)
offlineCreatedEvents.delete(eventId)
notifySyncStateChange(eventId, false)
// Update metadata cache: sync complete, no longer local-only
const existingMetadata = getHighlightMetadata(eventId)
setHighlightMetadata(eventId, {
...existingMetadata,
isSyncing: false,
isLocalOnly: false
})
})
// Save updated offline events set to localStorage
saveOfflineEventsToStorage(offlineCreatedEvents)
// Clear syncing state for failed events
uniqueEvents.forEach(event => {
if (!successfulIds.includes(event.id)) {
syncingEvents.delete(event.id)
notifySyncStateChange(event.id, false)
// Update metadata cache: sync failed, still local-only
const existingMetadata = getHighlightMetadata(event.id)
setHighlightMetadata(event.id, {
...existingMetadata,
isSyncing: false
// Keep isLocalOnly as true (sync failed)
})
}
})
} catch (error) {

View File

@@ -1,78 +1,325 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray, tap } from 'rxjs'
import { lastValueFrom, merge, Observable, toArray, tap } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { rebroadcastEvents } from './rebroadcastService'
import { UserSettings } from './settingsService'
interface CachedProfile {
event: NostrEvent
timestamp: number
lastAccessed: number // For LRU eviction
}
const PROFILE_CACHE_TTL = 30 * 24 * 60 * 60 * 1000 // 30 days in milliseconds (profiles change less frequently than articles)
const PROFILE_CACHE_PREFIX = 'profile_cache_'
const MAX_CACHED_PROFILES = 1000 // Limit number of cached profiles to prevent quota issues
let quotaExceededLogged = false // Only log quota error once per session
// Request deduplication: track in-flight fetch requests by sorted pubkey array
// Key: sorted, comma-separated pubkeys, Value: Promise for that fetch
const inFlightRequests = new Map<string, Promise<NostrEvent[]>>()
function getProfileCacheKey(pubkey: string): string {
return `${PROFILE_CACHE_PREFIX}${pubkey}`
}
/**
* Get a cached profile from localStorage
* Returns null if not found, expired, or on error
* Updates lastAccessed timestamp for LRU eviction
*/
export function getCachedProfile(pubkey: string): NostrEvent | null {
try {
const cacheKey = getProfileCacheKey(pubkey)
const cached = localStorage.getItem(cacheKey)
if (!cached) {
return null
}
const data: CachedProfile = JSON.parse(cached)
const age = Date.now() - data.timestamp
if (age > PROFILE_CACHE_TTL) {
localStorage.removeItem(cacheKey)
return null
}
// Update lastAccessed for LRU eviction (but don't fail if update fails)
try {
data.lastAccessed = Date.now()
localStorage.setItem(cacheKey, JSON.stringify(data))
} catch {
// Ignore update errors, still return the profile
}
return data.event
} catch (err) {
// Silently handle cache read errors (quota, invalid data, etc.)
return null
}
}
/**
* Get all cached profile keys for eviction
*/
function getAllCachedProfileKeys(): Array<{ key: string; lastAccessed: number }> {
const keys: Array<{ key: string; lastAccessed: number }> = []
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && key.startsWith(PROFILE_CACHE_PREFIX)) {
try {
const cached = localStorage.getItem(key)
if (cached) {
const data: CachedProfile = JSON.parse(cached)
keys.push({
key,
lastAccessed: data.lastAccessed || data.timestamp || 0
})
}
} catch {
// Skip invalid entries
}
}
}
} catch {
// Ignore errors during enumeration
}
return keys
}
/**
* Evict oldest profiles (LRU) to free up space
* Removes the oldest accessed profiles until we're under the limit
*/
function evictOldProfiles(targetCount: number): void {
try {
const keys = getAllCachedProfileKeys()
if (keys.length <= targetCount) {
return
}
// Sort by lastAccessed (oldest first) and remove oldest
keys.sort((a, b) => a.lastAccessed - b.lastAccessed)
const toRemove = keys.slice(0, keys.length - targetCount)
for (const { key } of toRemove) {
localStorage.removeItem(key)
}
} catch {
// Silently fail eviction
}
}
/**
* Cache a profile to localStorage
* Handles errors gracefully (quota exceeded, invalid data, etc.)
* Implements LRU eviction when cache is full
*/
export function cacheProfile(profile: NostrEvent): void {
try {
if (profile.kind !== 0) {
return // Only cache kind:0 (profile) events
}
const cacheKey = getProfileCacheKey(profile.pubkey)
// Check if we need to evict before caching
const existingKeys = getAllCachedProfileKeys()
if (existingKeys.length >= MAX_CACHED_PROFILES) {
// Check if this profile is already cached
const alreadyCached = existingKeys.some(k => k.key === cacheKey)
if (!alreadyCached) {
// Evict oldest profiles to make room (keep 90% of max)
evictOldProfiles(Math.floor(MAX_CACHED_PROFILES * 0.9))
}
}
const cached: CachedProfile = {
event: profile,
timestamp: Date.now(),
lastAccessed: Date.now()
}
localStorage.setItem(cacheKey, JSON.stringify(cached))
} catch (err) {
// Handle quota exceeded by evicting and retrying once
if (err instanceof DOMException && err.name === 'QuotaExceededError') {
if (!quotaExceededLogged) {
console.warn(`[npub-cache] localStorage quota exceeded, evicting old profiles...`)
quotaExceededLogged = true
}
// Try evicting more aggressively and retry
try {
evictOldProfiles(Math.floor(MAX_CACHED_PROFILES * 0.5))
const cached: CachedProfile = {
event: profile,
timestamp: Date.now(),
lastAccessed: Date.now()
}
localStorage.setItem(getProfileCacheKey(profile.pubkey), JSON.stringify(cached))
} catch {
// Silently fail if still can't cache - don't block the UI
}
}
// Silently handle other caching errors (invalid data, etc.)
}
}
/**
* Batch load multiple profiles from localStorage cache
* Returns a Map of pubkey -> NostrEvent for all found profiles
*/
export function loadCachedProfiles(pubkeys: string[]): Map<string, NostrEvent> {
const cached = new Map<string, NostrEvent>()
for (const pubkey of pubkeys) {
const profile = getCachedProfile(pubkey)
if (profile) {
cached.set(pubkey, profile)
}
}
return cached
}
/**
* Fetches profile metadata (kind:0) for a list of pubkeys
* Stores profiles in the event store and optionally to local relays
* Checks localStorage cache first, then fetches from relays for missing/expired profiles
* Stores profiles in the event store and caches to localStorage
* Implements request deduplication to prevent duplicate relay requests for the same pubkey sets
*/
export const fetchProfiles = async (
relayPool: RelayPool,
eventStore: IEventStore,
pubkeys: string[],
settings?: UserSettings
settings?: UserSettings,
onEvent?: (event: NostrEvent) => void
): Promise<NostrEvent[]> => {
try {
if (pubkeys.length === 0) {
return []
}
const uniquePubkeys = Array.from(new Set(pubkeys))
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
// Keep only the most recent profile for each pubkey
const profilesByPubkey = new Map<string, NostrEvent>()
const processEvent = (event: NostrEvent) => {
const existing = profilesByPubkey.get(event.pubkey)
if (!existing || event.created_at > existing.created_at) {
profilesByPubkey.set(event.pubkey, event)
// Store in event store immediately
eventStore.add(event)
const uniquePubkeys = Array.from(new Set(pubkeys)).sort()
// Check for in-flight request with same pubkey set (deduplication)
const requestKey = uniquePubkeys.join(',')
const existingRequest = inFlightRequests.get(requestKey)
if (existingRequest) {
return existingRequest
}
// Create the fetch promise and track it
const fetchPromise = (async () => {
// First, check localStorage cache for all requested profiles
const cachedProfiles = loadCachedProfiles(uniquePubkeys)
const profilesByPubkey = new Map<string, NostrEvent>()
// Add cached profiles to the map and EventStore
for (const [pubkey, profile] of cachedProfiles.entries()) {
profilesByPubkey.set(pubkey, profile)
// Ensure cached profiles are also in EventStore for consistency
eventStore.add(profile)
}
// Determine which pubkeys need to be fetched from relays
const pubkeysToFetch = uniquePubkeys.filter(pubkey => !cachedProfiles.has(pubkey))
// If all profiles are cached, return early
if (pubkeysToFetch.length === 0) {
return Array.from(profilesByPubkey.values())
}
}
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [0], authors: uniquePubkeys })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => processEvent(event)),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
// Fetch missing profiles from relays
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
const hasPurplePages = relayUrls.some(url => url.includes('purplepag.es'))
if (!hasPurplePages) {
console.warn(`[fetch-profiles] purplepag.es not in active relay pool, adding it temporarily`)
// Add purplepag.es if it's not in the pool (it might not have connected yet)
const purplePagesUrl = 'wss://purplepag.es'
if (!relayPool.relays.has(purplePagesUrl)) {
relayPool.group([purplePagesUrl])
}
// Ensure it's included in the remote relays for this fetch
if (!remoteRelays.includes(purplePagesUrl)) {
remoteRelays.push(purplePagesUrl)
}
}
const fetchedPubkeys = new Set<string>()
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [0], authors: uniquePubkeys })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => processEvent(event)),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const processEvent = (event: NostrEvent) => {
fetchedPubkeys.add(event.pubkey)
const existing = profilesByPubkey.get(event.pubkey)
if (!existing || event.created_at > existing.created_at) {
profilesByPubkey.set(event.pubkey, event)
// Store in event store immediately
eventStore.add(event)
// Cache to localStorage for future use
cacheProfile(event)
}
}
await lastValueFrom(merge(local$, remote$).pipe(toArray()))
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [0], authors: pubkeysToFetch })
.pipe(
onlyEvents(),
onEvent ? tap((event: NostrEvent) => onEvent(event)) : tap(() => {}),
tap((event: NostrEvent) => processEvent(event)),
completeOnEose()
)
: new Observable<NostrEvent>((sub) => sub.complete())
const profiles = Array.from(profilesByPubkey.values())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [0], authors: pubkeysToFetch })
.pipe(
onlyEvents(),
onEvent ? tap((event: NostrEvent) => onEvent(event)) : tap(() => {}),
tap((event: NostrEvent) => processEvent(event)),
completeOnEose()
)
: new Observable<NostrEvent>((sub) => sub.complete())
// Rebroadcast profiles to local/all relays based on settings
if (profiles.length > 0) {
await rebroadcastEvents(profiles, relayPool, settings)
}
await lastValueFrom(merge(local$, remote$).pipe(toArray()))
return profiles
const profiles = Array.from(profilesByPubkey.values())
const missingPubkeys = pubkeysToFetch.filter(p => !fetchedPubkeys.has(p))
if (missingPubkeys.length > 0) {
console.warn(`[fetch-profiles] ${missingPubkeys.length} profiles not found on relays:`, missingPubkeys.map(p => p.slice(0, 16) + '...'))
}
// Note: We don't preload all profile images here to avoid ERR_INSUFFICIENT_RESOURCES
// Profile images will be cached by Service Worker when they're actually displayed.
// Only the logged-in user's profile image is preloaded (in SidebarHeader).
// Rebroadcast profiles to local/all relays based on settings
// Only rebroadcast newly fetched profiles, not cached ones
const newlyFetchedProfiles = profiles.filter(p => pubkeysToFetch.includes(p.pubkey))
if (newlyFetchedProfiles.length > 0) {
await rebroadcastEvents(newlyFetchedProfiles, relayPool, settings)
}
return profiles
})()
// Track the request
inFlightRequests.set(requestKey, fetchPromise)
// Clean up when request completes (success or failure)
fetchPromise.finally(() => {
inFlightRequests.delete(requestKey)
})
return fetchPromise
} catch (error) {
console.error('Failed to fetch profiles:', error)
console.error('[fetch-profiles] Failed to fetch profiles:', error)
return []
}
}

View File

@@ -1,6 +1,7 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch'
import { normalizeRelayUrl } from '../utils/helpers'
export interface UserRelayInfo {
url: string
@@ -144,35 +145,55 @@ export function computeRelaySet(params: {
alwaysIncludeLocal
} = params
// Normalize all URLs for consistent comparison and deduplication
const normalizedBlocked = new Set(blocked.map(normalizeRelayUrl))
const normalizedLocal = new Set(alwaysIncludeLocal.map(normalizeRelayUrl))
const relaySet = new Set<string>()
const blockedSet = new Set(blocked)
const normalizedRelaySet = new Set<string>()
// Helper to check if relay should be included
const shouldInclude = (url: string): boolean => {
// Helper to check if relay should be included (using normalized URLs)
const shouldInclude = (normalizedUrl: string): boolean => {
// Always include local relays
if (alwaysIncludeLocal.includes(url)) return true
if (normalizedLocal.has(normalizedUrl)) return true
// Otherwise check if blocked
return !blockedSet.has(url)
return !normalizedBlocked.has(normalizedUrl)
}
// Add hardcoded relays
// Add hardcoded relays (normalized)
for (const url of hardcoded) {
if (shouldInclude(url)) relaySet.add(url)
const normalized = normalizeRelayUrl(url)
if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) {
normalizedRelaySet.add(normalized)
relaySet.add(url) // Keep original URL for output
}
}
// Add bunker relays
// Add bunker relays (normalized)
for (const url of bunker) {
if (shouldInclude(url)) relaySet.add(url)
const normalized = normalizeRelayUrl(url)
if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) {
normalizedRelaySet.add(normalized)
relaySet.add(url) // Keep original URL for output
}
}
// Add user relays (treating 'both' and 'read' as applicable for queries)
// Add user relays (normalized)
for (const relay of userList) {
if (shouldInclude(relay.url)) relaySet.add(relay.url)
const normalized = normalizeRelayUrl(relay.url)
if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) {
normalizedRelaySet.add(normalized)
relaySet.add(relay.url) // Keep original URL for output
}
}
// Always ensure local relays are present
// Always ensure local relays are present (normalized check)
for (const url of alwaysIncludeLocal) {
relaySet.add(url)
const normalized = normalizeRelayUrl(url)
if (!normalizedRelaySet.has(normalized)) {
normalizedRelaySet.add(normalized)
relaySet.add(url) // Keep original URL for output
}
}
return Array.from(relaySet)

View File

@@ -1,20 +1,17 @@
import { RelayPool } from 'applesauce-relay'
import { prioritizeLocalRelays } from '../utils/helpers'
import { prioritizeLocalRelays, normalizeRelayUrl } from '../utils/helpers'
import { getLocalRelays, getFallbackContentRelays } from '../config/relays'
/**
* Local relays that are always included
*/
export const ALWAYS_LOCAL_RELAYS = [
'ws://localhost:10547',
'ws://localhost:4869'
]
export const ALWAYS_LOCAL_RELAYS = getLocalRelays()
/**
* Hardcoded relays that are always included
* Hardcoded relays that are always included (minimal reliable set)
* Derived from RELAY_CONFIGS fallback relays
*/
export const HARDCODED_RELAYS = [
'wss://relay.nostr.band'
]
export const HARDCODED_RELAYS = getFallbackContentRelays()
/**
* Gets active relay URLs from the relay pool
@@ -24,76 +21,84 @@ export function getActiveRelayUrls(relayPool: RelayPool): string[] {
return prioritizeLocalRelays(urls)
}
/**
* Normalizes a relay URL to match what applesauce-relay stores internally
* Adds trailing slash for URLs without a path
*/
function normalizeRelayUrl(url: string): string {
try {
const parsed = new URL(url)
// If the pathname is empty or just "/", ensure it ends with "/"
if (parsed.pathname === '' || parsed.pathname === '/') {
return url.endsWith('/') ? url : url + '/'
}
return url
} catch {
// If URL parsing fails, return as-is
return url
}
export interface RelaySetChangeSummary {
added: string[]
removed: string[]
}
/**
* Applies a new relay set to the pool: adds missing relays, removes extras
* Always preserves local relays even if not in finalUrls
* @returns Summary of changes for debugging
*/
export function applyRelaySetToPool(
relayPool: RelayPool,
finalUrls: string[]
): void {
// Normalize all URLs to match pool's internal format
const currentUrls = new Set(Array.from(relayPool.relays.keys()))
const normalizedTargetUrls = new Set(finalUrls.map(normalizeRelayUrl))
finalUrls: string[],
options?: { preserveAlwaysLocal?: boolean }
): RelaySetChangeSummary {
const preserveLocal = options?.preserveAlwaysLocal !== false // default true
// Add new relays (use original URLs for adding, not normalized)
const toAdd = finalUrls.filter(url => !currentUrls.has(normalizeRelayUrl(url)))
// Ensure local relays are always included
const urlsWithLocal = preserveLocal
? Array.from(new Set([...finalUrls, ...ALWAYS_LOCAL_RELAYS]))
: finalUrls
if (toAdd.length > 0) {
relayPool.group(toAdd)
// Normalize all URLs consistently for comparison
const normalizedCurrent = new Set(
Array.from(relayPool.relays.keys()).map(normalizeRelayUrl)
)
const normalizedTarget = new Set(urlsWithLocal.map(normalizeRelayUrl))
// Map normalized URLs back to original for adding
const normalizedToOriginal = new Map<string, string>()
for (const url of urlsWithLocal) {
normalizedToOriginal.set(normalizeRelayUrl(url), url)
}
// Remove relays not in target (but always keep local relays)
// Find relays to add (not in current pool)
const toAdd: string[] = []
for (const normalizedUrl of normalizedTarget) {
if (!normalizedCurrent.has(normalizedUrl)) {
const originalUrl = normalizedToOriginal.get(normalizedUrl) || normalizedUrl
toAdd.push(originalUrl)
}
}
// Find relays to remove (not in target, but preserve local relays)
const normalizedLocal = new Set(ALWAYS_LOCAL_RELAYS.map(normalizeRelayUrl))
const toRemove: string[] = []
for (const url of currentUrls) {
// Check if this normalized URL is in the target set
if (!normalizedTargetUrls.has(url)) {
// Also check if it's a local relay (check both normalized and original forms)
const isLocal = ALWAYS_LOCAL_RELAYS.some(localUrl =>
normalizeRelayUrl(localUrl) === url || localUrl === url
)
if (!isLocal) {
toRemove.push(url)
for (const currentUrl of relayPool.relays.keys()) {
const normalizedCurrentUrl = normalizeRelayUrl(currentUrl)
if (!normalizedTarget.has(normalizedCurrentUrl)) {
// Always preserve local relays
if (!preserveLocal || !normalizedLocal.has(normalizedCurrentUrl)) {
toRemove.push(currentUrl)
}
}
}
// Apply changes
if (toAdd.length > 0) {
relayPool.group(toAdd)
}
for (const url of toRemove) {
const relay = relayPool.relays.get(url)
if (relay) {
try {
// Only close if relay is actually connected or attempting to connect
// This helps avoid WebSocket warnings for connections that never started
relay.close()
} catch (error) {
// Suppress errors when closing relays that haven't fully connected yet
// This can happen when switching relay sets before connections establish
console.debug('[relay-manager] Ignoring error when closing relay:', url, error)
}
relayPool.relays.delete(url)
}
}
// Return summary for debugging (useful for understanding relay churn)
if (import.meta.env.DEV && (toAdd.length > 0 || toRemove.length > 0)) {
console.debug('[relay-pool] Changes:', { added: toAdd, removed: toRemove })
}
return { added: toAdd, removed: toRemove }
}

View File

@@ -74,6 +74,9 @@ export interface UserSettings {
ttsLanguageMode?: 'system' | 'content' | string // default: 'content', can also be language code like 'en', 'es', etc.
// Text-to-Speech settings
ttsDefaultSpeed?: number // default: 2.1
// Link color for article content (theme-specific)
linkColorDark?: string // default: #38bdf8 (sky-400)
linkColorLight?: string // default: #3b82f6 (blue-500)
}
/**

View File

@@ -57,6 +57,7 @@
--color-text-muted: #71717a; /* zinc-500 */
--color-primary: #6366f1; /* indigo-500 */
--color-primary-hover: #4f46e5; /* indigo-600 */
--color-link: var(--color-link-dark, #38bdf8); /* sky-400 */
}
/* Light theme */
@@ -73,6 +74,7 @@
--color-text-muted: #6b7280; /* gray-500 */
--color-primary: #4f46e5; /* indigo-600 */
--color-primary-hover: #4338ca; /* indigo-700 */
--color-link: var(--color-link-light, #3b82f6); /* blue-500 */
/* Highlight colors for light theme - use same Tailwind colors */
--highlight-color-mine: #fde047; /* yellow-300 */
@@ -97,6 +99,7 @@
--color-text-muted: #71717a;
--color-primary: #6366f1;
--color-primary-hover: #4f46e5;
--color-link: var(--color-link-dark, #38bdf8);
}
}
@@ -112,6 +115,7 @@
--color-text-muted: #6b7280;
--color-primary: #4f46e5;
--color-primary-hover: #4338ca;
--color-link: var(--color-link-light, #3b82f6);
/* Standard highlight colors */
--highlight-color-mine: #fde047;

View File

@@ -43,6 +43,13 @@
word-wrap: break-word;
text-align: var(--paragraph-alignment, justify);
}
.preview-content a {
color: var(--color-link);
text-decoration: none;
}
.preview-content a:hover {
text-decoration: underline;
}
.setting-select { width: 100%; padding: 0.5rem 1.75rem 0.5rem 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
.setting-select:focus { outline: none; border-color: var(--color-primary); }

View File

@@ -73,7 +73,8 @@
/* Align highlight list width with profile card width on /my */
.me-highlights-list { padding-left: 0; padding-right: 0; }
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
.explore-header .profile-header-wrapper { max-width: 600px; margin: 0 auto; width: 100%; }
.explore-header .author-card { max-width: none; margin: 0; width: auto; flex: 1; }
/* Hide tab labels on mobile to save space */
@media (max-width: 768px) {

View File

@@ -10,6 +10,71 @@
.author-card-name { font-size: 1rem; font-weight: 600; color: var(--color-text); margin-bottom: 0.5rem; text-align: left; }
.author-card-bio { font-size: 0.9rem; color: var(--color-text-secondary); line-height: 1.5; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; text-align: left; }
/* Profile header */
.profile-header-wrapper {
display: flex;
justify-content: center;
padding: 2rem 1rem 0;
}
/* Remove horizontal padding when inside explore-header to match tabs width */
.explore-header .profile-header-wrapper {
padding-left: 0;
padding-right: 0;
}
.profile-card-with-menu {
position: relative;
max-width: 600px;
width: 100%;
}
/* Profile card menu - inside card, bottom-right */
.profile-card-menu-wrapper {
position: absolute;
right: 1.25rem;
top: 1.25rem;
}
.profile-card-menu {
position: absolute;
right: 0;
top: calc(100% + 4px);
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-subtle);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
min-width: 180px;
overflow: hidden;
}
.profile-card-menu-item {
width: 100%;
background: none;
border: none;
color: var(--color-text);
padding: 0.75rem 1rem;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
white-space: nowrap;
}
.profile-card-menu-item:hover {
background: rgba(99, 102, 241, 0.15);
color: var(--color-text);
}
.profile-card-menu-item svg {
font-size: 0.875rem;
flex-shrink: 0;
}
@media (max-width: 768px) {
.author-card-container {
padding: 1.5rem 1rem;
@@ -26,5 +91,13 @@
.author-card-avatar svg { font-size: 2rem; }
.author-card-name { font-size: 0.95rem; }
.author-card-bio { font-size: 0.85rem; -webkit-line-clamp: 2; }
}
.profile-header-wrapper {
padding-top: 1.5rem;
}
.profile-card-menu-wrapper {
right: 1rem;
top: 1rem;
}
}

View File

@@ -57,20 +57,21 @@
.reader .reader-markdown h1, .reader .reader-markdown h2, .reader .reader-markdown h3, .reader .reader-markdown h4, .reader .reader-markdown h5, .reader .reader-markdown h6 { text-align: left !important; }
/* Tame images from external content */
.reader .reader-html img, .reader .reader-markdown img {
max-width: var(--image-max-width, 100%);
max-height: 70vh;
width: var(--image-width, auto);
max-width: 100%;
max-height: var(--image-max-height, 70vh);
height: auto;
width: auto;
display: block;
margin: 0.75rem auto;
border-radius: 6px;
object-fit: contain;
}
/* Headlines with Tailwind typography */
.reader-markdown h1, .reader-html h1 {
font-size: 2.25rem; /* text-4xl */
font-weight: 700;
line-height: 1.2;
margin-top: 2rem;
margin-top: 5rem;
margin-bottom: 1rem;
color: var(--color-text);
}
@@ -78,7 +79,7 @@
font-size: 1.875rem; /* text-3xl */
font-weight: 600;
line-height: 1.3;
margin-top: 1.75rem;
margin-top: 4.5rem;
margin-bottom: 0.875rem;
color: var(--color-text);
}
@@ -86,7 +87,7 @@
font-size: 1.5rem; /* text-2xl */
font-weight: 600;
line-height: 1.4;
margin-top: 1.5rem;
margin-top: 4rem;
margin-bottom: 0.75rem;
color: var(--color-text);
}
@@ -94,7 +95,7 @@
font-size: 1.25rem; /* text-xl */
font-weight: 600;
line-height: 1.4;
margin-top: 1.25rem;
margin-top: 3.5rem;
margin-bottom: 0.625rem;
color: var(--color-text);
}
@@ -102,7 +103,7 @@
font-size: 1.125rem; /* text-lg */
font-weight: 600;
line-height: 1.4;
margin-top: 1rem;
margin-top: 3rem;
margin-bottom: 0.5rem;
color: var(--color-text);
}
@@ -110,12 +111,13 @@
font-size: 1rem; /* text-base */
font-weight: 600;
line-height: 1.4;
margin-top: 1rem;
margin-top: 3rem;
margin-bottom: 0.5rem;
color: var(--color-text);
}
.reader-markdown p { margin: 0.5rem 0; }
.reader-html p, .reader-html div, .reader-html span, .reader-html li, .reader-html td, .reader-html th { font-size: 1em !important; }
.reader-markdown p { margin: 1.5rem 0; }
.reader-html p { margin: 1.5rem 0; }
.reader-html div, .reader-html span, .reader-html li, .reader-html td, .reader-html th { font-size: 1em !important; }
/* Lists */
.reader-markdown ul, .reader-html ul {
list-style-type: disc;
@@ -158,7 +160,7 @@
opacity: 0.69;
margin: 2.5rem 0;
}
.reader-markdown a { color: var(--color-primary); text-decoration: none; }
.reader-markdown a { color: var(--color-link); text-decoration: none; }
.reader-markdown a:hover { text-decoration: underline; }
.reader-markdown code { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
.reader-markdown pre { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; line-height: 1.5; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
@@ -169,6 +171,49 @@
.reader-html pre { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
.reader-html code { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
.reader-html pre code { background: transparent; border: none; padding: 0; display: block; }
/* Tables - subtle styling that matches the app theme */
.reader-markdown table, .reader-html table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
background: var(--color-bg);
}
.reader-markdown thead, .reader-html thead {
background: var(--color-bg-elevated);
}
.reader-markdown th, .reader-html th {
padding: 0.75rem 1rem;
text-align: left;
font-weight: 600;
color: var(--color-text);
border-bottom: 2px solid var(--color-border);
font-size: 0.95em;
}
.reader-markdown td, .reader-html td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border-subtle);
color: var(--color-text);
vertical-align: top;
}
.reader-markdown tbody tr:last-child td, .reader-html tbody tr:last-child td {
border-bottom: none;
}
/* Subtle row striping for better readability */
.reader-markdown tbody tr:nth-child(even), .reader-html tbody tr:nth-child(even) {
background: var(--color-bg-subtle);
}
/* Table alignment support */
.reader-markdown th[align="center"], .reader-html th[align="center"],
.reader-markdown td[align="center"], .reader-html td[align="center"] {
text-align: center;
}
.reader-markdown th[align="right"], .reader-html th[align="right"],
.reader-markdown td[align="right"], .reader-html td[align="right"] {
text-align: right;
}
/* Mobile: prevent code blocks from causing horizontal overflow */
@media (max-width: 768px) {
.reader-markdown pre, .reader-html pre {
@@ -189,12 +234,20 @@
display: block;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Reduce padding on mobile for better fit */
.reader-markdown table td, .reader-html table td,
.reader-markdown table th, .reader-html table th {
padding: 0.5rem 0.75rem;
}
.reader-markdown img, .reader-html img {
width: var(--image-width, auto) !important;
max-width: 100% !important;
width: auto !important;
max-height: var(--image-max-height, 70vh) !important;
height: auto;
object-fit: contain;
}
}
@@ -270,3 +323,21 @@
/* Reading Progress Indicator - now using Tailwind utilities in component */
/* Profile loading state - subtle opacity pulse animation */
.profile-loading {
opacity: 0.6;
animation: profile-loading-pulse 1.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.profile-loading {
animation: none;
opacity: 0.7;
}
}
@keyframes profile-loading-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}

View File

@@ -102,7 +102,7 @@
.highlights-empty svg { color: var(--color-text-muted); margin-bottom: 0.5rem; }
.empty-hint { font-size: 0.875rem; color: var(--color-text-muted); margin-top: 0.5rem; }
.highlights-list { overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
.highlights-list { overflow-y: auto; padding: 1rem; padding-bottom: 10rem; display: flex; flex-direction: column; gap: 0.75rem; }
.highlight-item { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 0; display: flex; transition: border-color 0.2s ease; position: relative; }
.highlight-item:hover { border-color: var(--color-primary); }
.highlight-item.selected { border-color: var(--color-primary); background: var(--color-bg-elevated); box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3); }
@@ -177,7 +177,10 @@
padding: 0.25rem; /* CompactButton base */
}
.highlight-menu-wrapper { position: relative; flex-shrink: 0; display: flex; align-items: center; }
.highlight-menu { position: absolute; right: 0; top: calc(100% + 4px); background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 160px; overflow: hidden; }
.highlight-menu { position: absolute; right: 0; top: calc(100% + 4px); bottom: auto; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 160px; overflow: hidden; }
/* Open menu upward when there's not enough space below */
.highlight-menu-wrapper:last-child .highlight-menu,
.highlight-item:last-child .highlight-menu-wrapper .highlight-menu { top: auto; bottom: calc(100% + 4px); }
.highlight-menu-item { width: 100%; background: none; border: none; color: var(--color-text); padding: 0.625rem 0.875rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.625rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
.highlight-menu-item:hover { background: rgba(99, 102, 241, 0.15); color: var(--color-text); }
.highlight-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }

View File

@@ -267,6 +267,42 @@
.read-inline-btn:hover { background: rgb(22 163 74); /* green-600 */ }
/* Bookmark filters */
.bookmark-filters-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
}
.bookmark-filters-wrapper > .bookmark-filters {
padding: 0;
border-bottom: none;
background: transparent;
}
.bookmark-filters-wrapper > .compact-button {
background: transparent;
color: var(--color-text-secondary);
border: none;
padding: 0.375rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
min-width: 32px;
min-height: 32px;
}
.bookmark-filters-wrapper > .compact-button:hover {
color: var(--color-text);
background: var(--color-bg-elevated);
}
.bookmark-filters {
display: flex;
gap: 0.5rem;

View File

@@ -23,13 +23,15 @@ sw.skipWaiting()
clientsClaim()
// Runtime cache: Cross-origin images
// This preserves the existing image caching behavior
// Runtime cache: All images (cross-origin and same-origin)
// Cache both external images and any internal image assets
registerRoute(
({ request, url }) => {
const isImage = request.destination === 'image' ||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
return isImage && url.origin !== sw.location.origin
// Cache all images, not just cross-origin ones
// This ensures article images from any source get cached
return isImage
},
new StaleWhileRevalidate({
cacheName: 'boris-images',
@@ -41,6 +43,11 @@ registerRoute(
new CacheableResponsePlugin({
statuses: [0, 200],
}),
{
cacheWillUpdate: async ({ response }) => {
return response.ok ? response : null
}
}
],
})
)

View File

@@ -15,11 +15,10 @@ export interface Highlight {
comment?: string // optional comment about the highlight
// Level classification (computed based on user's context)
level?: HighlightLevel
// Relay tracking for offline/local-only highlights
// Relay tracking for local-only highlights
publishedRelays?: string[] // URLs of relays where this was published (for user-created highlights)
seenOnRelays?: string[] // URLs of relays where this event was fetched from
isLocalOnly?: boolean // true if only published to local relays
isOfflineCreated?: boolean // true if created while in flight mode (offline)
isSyncing?: boolean // true if currently being synced to remote relays
}

View File

@@ -15,3 +15,23 @@ export const HIGHLIGHT_COLORS = [
{ name: 'Blue', value: '#3b82f6' }, // blue-500
{ name: 'Purple', value: '#9333ea' } // purple-600
]
// Tailwind color palette for link colors - optimized for dark themes
export const LINK_COLORS_DARK = [
{ name: 'Sky Blue', value: '#38bdf8' }, // sky-400
{ name: 'Cyan', value: '#22d3ee' }, // cyan-400
{ name: 'Light Blue', value: '#60a5fa' }, // blue-400
{ name: 'Indigo Light', value: '#818cf8' }, // indigo-400
{ name: 'Blue', value: '#3b82f6' }, // blue-500
{ name: 'Purple', value: '#9333ea' } // purple-600
]
// Tailwind color palette for link colors - optimized for light themes
export const LINK_COLORS_LIGHT = [
{ name: 'Blue', value: '#3b82f6' }, // blue-500
{ name: 'Indigo', value: '#6366f1' }, // indigo-500
{ name: 'Purple', value: '#9333ea' }, // purple-600
{ name: 'Sky Blue', value: '#0ea5e9' }, // sky-500 (darker for light bg)
{ name: 'Cyan', value: '#06b6d4' }, // cyan-500 (darker for light bg)
{ name: 'Teal', value: '#14b8a6' } // teal-500
]

View File

@@ -1,13 +1,5 @@
// Extract pubkeys from nprofile strings in content
import { READING_PROGRESS } from '../config/kinds'
export const extractNprofilePubkeys = (content: string): string[] => {
const nprofileRegex = /nprofile1[a-z0-9]+/gi
const matches = content.match(nprofileRegex) || []
const unique = new Set<string>(matches)
return Array.from(unique)
}
export type UrlType = 'video' | 'image' | 'youtube' | 'article'
export interface UrlClassification {
@@ -47,6 +39,24 @@ export const classifyUrl = (url: string | undefined): UrlClassification => {
return { type: 'article' }
}
/**
* Normalizes a relay URL to match what applesauce-relay stores internally
* Adds trailing slash for URLs without a path
*/
export function normalizeRelayUrl(url: string): string {
try {
const parsed = new URL(url)
// If the pathname is empty or just "/", ensure it ends with "/"
if (parsed.pathname === '' || parsed.pathname === '/') {
return url.endsWith('/') ? url : url + '/'
}
return url
} catch {
// If URL parsing fails, return as-is
return url
}
}
/**
* Checks if a relay URL is a local relay (localhost or 127.0.0.1)
*/

View File

@@ -64,10 +64,36 @@ export function tryMarkInTextNodes(
let actualIndex = index
if (useNormalized) {
// Map normalized index back to original text
let normalizedIdx = 0
for (let i = 0; i < text.length && normalizedIdx < index; i++) {
if (!/\s/.test(text[i]) || (i > 0 && !/\s/.test(text[i-1]))) normalizedIdx++
actualIndex = i + 1
// Build normalized text while tracking original positions
let normalizedPos = 0
let prevWasWs = false
for (let i = 0; i < text.length; i++) {
const ch = text[i]
const isWs = /\s/.test(ch)
if (isWs) {
// Whitespace: count only at start of whitespace sequence
if (!prevWasWs) {
if (normalizedPos === index) {
actualIndex = i
break
}
normalizedPos++
}
prevWasWs = true
} else {
// Non-whitespace: count each character
if (normalizedPos === index) {
actualIndex = i
break
}
normalizedPos++
prevWasWs = false
}
}
// If we didn't find exact match, use last position
if (normalizedPos < index) {
actualIndex = text.length
}
}

View File

@@ -1,6 +1,54 @@
import { Highlight } from '../../types/highlights'
import { tryMarkInTextNodes } from './domUtils'
interface CacheEntry {
html: string
timestamp: number
}
// Simple in-memory cache for highlighted HTML results
const highlightCache = new Map<string, CacheEntry>()
const CACHE_TTL = 5 * 60 * 1000 // 5 minutes
const MAX_CACHE_SIZE = 50 // FIFO eviction after this many entries
/**
* Generate cache key from content and highlights
*/
function getCacheKey(html: string, highlights: Highlight[], highlightStyle: string): string {
// Create a stable key from content hash (first 200 chars) and highlight IDs
const contentHash = html.slice(0, 200).replace(/\s+/g, ' ').trim()
const highlightIds = highlights
.map(h => h.id)
.sort()
.join(',')
return `${contentHash.length}:${highlightIds}:${highlightStyle}`
}
/**
* Clean up old cache entries and enforce size limit
*/
function cleanupCache(): void {
const now = Date.now()
const entries = Array.from(highlightCache.entries())
// Remove expired entries
for (const [key, entry] of entries) {
if (now - entry.timestamp > CACHE_TTL) {
highlightCache.delete(key)
}
}
// Enforce size limit with FIFO eviction (oldest first)
if (highlightCache.size > MAX_CACHE_SIZE) {
const sortedEntries = Array.from(highlightCache.entries())
.sort((a, b) => a[1].timestamp - b[1].timestamp)
const toRemove = sortedEntries.slice(0, highlightCache.size - MAX_CACHE_SIZE)
for (const [key] of toRemove) {
highlightCache.delete(key)
}
}
}
/**
* Apply highlights to HTML content by injecting mark tags using DOM manipulation
*/
@@ -13,19 +61,24 @@ export function applyHighlightsToHTML(
return html
}
// Check cache
const cacheKey = getCacheKey(html, highlights, highlightStyle)
const cached = highlightCache.get(cacheKey)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.html
}
// Clean up cache periodically
cleanupCache()
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html
// CRITICAL: Remove any existing highlight marks to start with clean HTML
// This prevents old broken highlights from corrupting the new rendering
const existingMarks = tempDiv.querySelectorAll('mark[data-highlight-id]')
existingMarks.forEach(mark => {
// Replace the mark with its text content
const textNode = document.createTextNode(mark.textContent || '')
mark.parentNode?.replaceChild(textNode, mark)
})
// Collect all text nodes once before processing highlights (performance optimization)
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
const textNodes: Text[] = []
let node: Node | null
while ((node = walker.nextNode())) textNodes.push(node as Text)
for (const highlight of highlights) {
const searchText = highlight.content.trim()
@@ -34,14 +87,6 @@ export function applyHighlightsToHTML(
continue
}
// Collect all text nodes
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
const textNodes: Text[] = []
let node: Node | null
while ((node = walker.nextNode())) textNodes.push(node as Text)
// Try exact match first, then normalized match
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
@@ -51,7 +96,14 @@ export function applyHighlightsToHTML(
}
}
const result = tempDiv.innerHTML
return tempDiv.innerHTML
// Store in cache
highlightCache.set(cacheKey, {
html: result,
timestamp: Date.now()
})
return result
}

View File

@@ -1,40 +1,51 @@
import { decode, npubEncode, noteEncode } from 'nostr-tools/nip19'
import { getNostrUrl } from '../config/nostrGateways'
import { Tokens } from 'applesauce-content/helpers'
import { getContentPointers } from 'applesauce-factory/helpers'
import { encodeDecodeResult } from 'applesauce-core/helpers'
import { Helpers } from 'applesauce-core'
const { getPubkeyFromDecodeResult } = Helpers
/**
* Regular expression to match nostr: URIs and bare NIP-19 identifiers
* Uses applesauce Tokens.nostrLink which includes word boundary checks
* Matches: nostr:npub1..., nostr:note1..., nostr:nprofile1..., nostr:nevent1..., nostr:naddr1...
* Also matches bare identifiers without the nostr: prefix
*/
const NOSTR_URI_REGEX = /(?:nostr:)?((npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi
const NOSTR_URI_REGEX = Tokens.nostrLink
/**
* Extract all nostr URIs from text
* Extract all nostr URIs from text using applesauce helpers
*/
export function extractNostrUris(text: string): string[] {
const matches = text.match(NOSTR_URI_REGEX)
if (!matches) return []
// Extract just the NIP-19 identifier (without nostr: prefix)
return matches.map(match => {
const cleanMatch = match.replace(/^nostr:/, '')
return cleanMatch
})
try {
const pointers = getContentPointers(text)
const result: string[] = []
pointers.forEach(pointer => {
try {
const encoded = encodeDecodeResult(pointer)
if (encoded) {
result.push(encoded)
}
} catch {
// Ignore encoding errors, continue processing other pointers
}
})
return result
} catch {
return []
}
}
/**
* Extract all naddr (article) identifiers from text
* Extract all naddr (article) identifiers from text using applesauce helpers
*/
export function extractNaddrUris(text: string): string[] {
const allUris = extractNostrUris(text)
return allUris.filter(uri => {
try {
const decoded = decode(uri)
return decoded.type === 'naddr'
} catch {
return false
}
})
const pointers = getContentPointers(text)
return pointers
.filter(pointer => pointer.type === 'naddr')
.map(pointer => encodeDecodeResult(pointer))
}
/**
@@ -77,13 +88,14 @@ export function getNostrUriLabel(encoded: string): string {
try {
const decoded = decode(encoded)
// Use applesauce helper to extract pubkey for npub/nprofile
const pubkey = getPubkeyFromDecodeResult(decoded)
if (pubkey) {
// Use shared fallback display function and add @ for label
return `@${getNpubFallbackDisplay(pubkey)}`
}
switch (decoded.type) {
case 'npub':
return `@${encoded.slice(0, 12)}...`
case 'nprofile': {
const npub = npubEncode(decoded.data.pubkey)
return `@${npub.slice(0, 12)}...`
}
case 'note':
return `note:${encoded.slice(5, 12)}...`
case 'nevent': {
@@ -107,17 +119,161 @@ export function getNostrUriLabel(encoded: string): string {
}
}
/**
* Get a standardized fallback display name for a pubkey when profile has no name
* Returns npub format: abc1234... (without @ prefix)
* Components should add @ prefix when rendering mentions/links
* @param pubkey The pubkey in hex format
* @returns Formatted npub display string without @ prefix
*/
export function getNpubFallbackDisplay(pubkey: string): string {
try {
const npub = npubEncode(pubkey)
// Remove "npub1" prefix (5 chars) and show next 7 chars
return `${npub.slice(5, 12)}...`
} catch {
// Fallback to shortened pubkey if encoding fails
return `${pubkey.slice(0, 8)}...`
}
}
/**
* Get display name for a profile with consistent priority order
* Returns: profile.name || profile.display_name || profile.nip05 || npub fallback
* This function works with parsed profile objects (from useEventModel)
* For NostrEvent objects, use extractProfileDisplayName from profileUtils
* @param profile Profile object with optional name, display_name, and nip05 fields
* @param pubkey The pubkey in hex format (required for fallback)
* @returns Display name string
*/
export function getProfileDisplayName(
profile: { name?: string; display_name?: string; nip05?: string } | null | undefined,
pubkey: string
): string {
// Consistent priority order: name || display_name || nip05 || fallback
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
if (profile?.nip05) return profile.nip05
return getNpubFallbackDisplay(pubkey)
}
/**
* Process markdown to replace nostr URIs while skipping those inside markdown links
* This prevents nested markdown link issues when nostr identifiers appear in URLs
*/
function replaceNostrUrisSafely(
markdown: string,
getReplacement: (encoded: string) => string
): string {
// Track positions where we're inside a markdown link URL
// Use a parser approach to correctly handle URLs with brackets/parentheses
const linkRanges: Array<{ start: number, end: number }> = []
// Find all markdown link URLs by looking for ]( pattern and tracking to matching )
let i = 0
while (i < markdown.length) {
// Look for ]( pattern that starts a markdown link URL
const urlStartMatch = markdown.indexOf('](', i)
if (urlStartMatch === -1) break
const urlStart = urlStartMatch + 2 // Position after "]("
// Now find the matching closing parenthesis
// We need to account for nested parentheses and escaped characters
let pos = urlStart
let depth = 1 // We're inside one set of parentheses
let urlEnd = -1
while (pos < markdown.length && depth > 0) {
const char = markdown[pos]
const nextChar = pos + 1 < markdown.length ? markdown[pos + 1] : ''
// Check for escaped characters
if (char === '\\' && nextChar) {
pos += 2 // Skip escaped character
continue
}
if (char === '(') {
depth++
} else if (char === ')') {
depth--
if (depth === 0) {
urlEnd = pos
break
}
}
pos++
}
if (urlEnd !== -1) {
linkRanges.push({
start: urlStart,
end: urlEnd
})
i = urlEnd + 1
} else {
// No matching closing paren found, skip this one
i = urlStart + 1
}
}
// Check if a position is inside any markdown link URL
const isInsideLinkUrl = (pos: number): boolean => {
return linkRanges.some(range => pos >= range.start && pos < range.end)
}
// Replace nostr URIs, but skip those inside link URLs
// Also check if nostr URI is part of any URL pattern (http/https URLs)
// Callback params: (match, encoded, type, offset, string)
const result = markdown.replace(NOSTR_URI_REGEX, (match, encoded, _type, offset, fullString) => {
const matchEnd = offset + match.length
// Check if this match is inside a markdown link URL
// Check both start and end positions to ensure we catch the whole match
const startInside = isInsideLinkUrl(offset)
const endInside = isInsideLinkUrl(matchEnd - 1) // Check end position
if (startInside || endInside) {
// Don't replace - return original match
return match
}
// Also check if the nostr URI is part of an HTTP/HTTPS URL pattern
// This catches cases where the source markdown has URLs like https://example.com/naddr1...
// before they're formatted as markdown links
const contextBefore = fullString.slice(Math.max(0, offset - 200), offset)
const contextAfter = fullString.slice(matchEnd, Math.min(fullString.length, matchEnd + 10))
// Check if we're inside an http/https URL (looking for https?:// pattern before the match)
// and the match is followed by valid URL characters (not whitespace or closing paren)
const urlPatternBefore = /https?:\/\/[^\s)]*$/i
const isInHttpUrl = urlPatternBefore.test(contextBefore)
const isValidUrlContinuation = !contextAfter.match(/^[\s)]/) // Not followed by space or closing paren
if (isInHttpUrl && isValidUrlContinuation) {
// Don't replace - return original match
return match
}
// encoded is already the NIP-19 identifier without nostr: prefix (from capture group)
const replacement = getReplacement(encoded)
return replacement
})
return result
}
/**
* Replace nostr: URIs in markdown with proper markdown links
* This converts: nostr:npub1... to [label](link)
*/
export function replaceNostrUrisInMarkdown(markdown: string): string {
return markdown.replace(NOSTR_URI_REGEX, (match) => {
// Extract just the NIP-19 identifier (without nostr: prefix)
const encoded = match.replace(/^nostr:/, '')
return replaceNostrUrisSafely(markdown, (encoded) => {
const link = createNostrLink(encoded)
const label = getNostrUriLabel(encoded)
return `[${label}](${link})`
})
}
@@ -132,9 +288,7 @@ export function replaceNostrUrisInMarkdownWithTitles(
markdown: string,
articleTitles: Map<string, string>
): string {
return markdown.replace(NOSTR_URI_REGEX, (match) => {
// Extract just the NIP-19 identifier (without nostr: prefix)
const encoded = match.replace(/^nostr:/, '')
return replaceNostrUrisSafely(markdown, (encoded) => {
const link = createNostrLink(encoded)
// For articles, use the resolved title if available
@@ -154,14 +308,120 @@ export function replaceNostrUrisInMarkdownWithTitles(
})
}
/**
* Replace nostr: URIs in markdown with proper markdown links, using resolved profile names and article titles
* This converts: nostr:npub1... to [@username](link) and nostr:naddr1... to [Article Title](link)
* Labels update progressively as profiles load
* @param markdown The markdown content to process
* @param profileLabels Map of pubkey (hex) -> display name (e.g., pubkey -> @username)
* @param articleTitles Map of naddr -> title for resolved articles
* @param profileLoading Map of pubkey (hex) -> boolean indicating if profile is loading
*/
export function replaceNostrUrisInMarkdownWithProfileLabels(
markdown: string,
profileLabels: Map<string, string> = new Map(),
articleTitles: Map<string, string> = new Map(),
profileLoading: Map<string, boolean> = new Map()
): string {
return replaceNostrUrisSafely(markdown, (encoded) => {
const link = createNostrLink(encoded)
// For articles, use the resolved title if available
try {
const decoded = decode(encoded)
if (decoded.type === 'naddr' && articleTitles.has(encoded)) {
const title = articleTitles.get(encoded)!
return `[${title}](${link})`
}
// For npub/nprofile, extract pubkey using applesauce helper
const pubkey = getPubkeyFromDecodeResult(decoded)
if (pubkey) {
// Check if we have a resolved profile name using pubkey as key
// Use the label if: 1) we have a label, AND 2) profile is not currently loading (false or undefined)
const isLoading = profileLoading.get(pubkey)
const hasLabel = profileLabels.has(pubkey)
// Use resolved label if we have one and profile is not loading
// isLoading can be: true (loading), false (loaded), or undefined (never was loading)
// We only avoid using the label if isLoading === true
if (isLoading !== true && hasLabel) {
const displayName = profileLabels.get(pubkey)!
return `[${displayName}](${link})`
}
// If loading or no resolved label yet, use fallback (will show loading via post-processing)
const label = getNostrUriLabel(encoded)
return `[${label}](${link})`
}
} catch (error) {
// Ignore decode errors, fall through to default label
}
// For other types or if not resolved, use default label (shortened npub format)
const label = getNostrUriLabel(encoded)
return `[${label}](${link})`
})
}
/**
* Post-process rendered HTML to add loading class to profile links that are still loading
* This is necessary because HTML inside markdown links doesn't render correctly
* @param html The rendered HTML string
* @param profileLoading Map of pubkey (hex) -> boolean indicating if profile is loading
* @returns HTML with profile-loading class added to loading profile links
*/
export function addLoadingClassToProfileLinks(
html: string,
profileLoading: Map<string, boolean>
): string {
if (profileLoading.size === 0) {
return html
}
// Find all <a> tags with href starting with /p/ (profile links)
const result = html.replace(/<a\s+[^>]*?href="\/p\/([^"]+)"[^>]*?>/g, (match, npub: string) => {
try {
// Decode npub or nprofile to get pubkey using applesauce helper
const decoded: ReturnType<typeof decode> = decode(npub)
const pubkey = getPubkeyFromDecodeResult(decoded)
if (pubkey) {
// Check if this profile is loading
const isLoading = profileLoading.get(pubkey)
if (isLoading === true) {
// Add profile-loading class if not already present
if (!match.includes('profile-loading')) {
// Insert class before the closing >
const classMatch = /class="([^"]*)"/.exec(match)
if (classMatch) {
const updated = match.replace(/class="([^"]*)"/, `class="$1 profile-loading"`)
return updated
} else {
const updated = match.replace(/(<a\s+[^>]*?)>/, '$1 class="profile-loading">')
return updated
}
}
}
}
} catch (error) {
// Ignore processing errors
}
return match
})
return result
}
/**
* Replace nostr: URIs in HTML with clickable links
* This is used when processing HTML content directly
*/
export function replaceNostrUrisInHTML(html: string): string {
return html.replace(NOSTR_URI_REGEX, (match) => {
// Extract just the NIP-19 identifier (without nostr: prefix)
const encoded = match.replace(/^nostr:/, '')
return html.replace(NOSTR_URI_REGEX, (_match, encoded) => {
// encoded is already the NIP-19 identifier without nostr: prefix (from capture group)
const link = createNostrLink(encoded)
const label = getNostrUriLabel(encoded)

View File

@@ -0,0 +1,27 @@
import { IEventStore } from 'applesauce-core'
import { loadCachedProfiles } from '../services/profileService'
/**
* Check if a profile exists in cache or eventStore
* Used to determine if profile loading state should be shown
* @param pubkey The pubkey in hex format
* @param eventStore Optional eventStore instance
* @returns true if profile exists in cache or eventStore, false otherwise
*/
export function isProfileInCacheOrStore(
pubkey: string,
eventStore?: IEventStore | null
): boolean {
if (!pubkey) return false
// Check cache first
const cached = loadCachedProfiles([pubkey])
if (cached.has(pubkey)) {
return true
}
// Check eventStore
const eventStoreProfile = eventStore?.getEvent(pubkey + ':0')
return !!eventStoreProfile
}

View File

@@ -0,0 +1,2 @@
export { extractProfileDisplayName } from '../../lib/profile'

View File

@@ -0,0 +1,217 @@
# Basic Blockquotes Test
This file tests blockquote syntax using the `>` character.
## Basic Blockquotes
Blockquotes are created by placing a `>` character at the start of a line, followed by a space.
> This is a blockquote.
> This is another blockquote with multiple sentences. It demonstrates that blockquotes can contain extended text. The entire blockquote should be rendered with appropriate styling to distinguish it from regular paragraphs.
## Multiple Paragraph Blockquotes
Blockquotes can span multiple paragraphs by placing `>` at the start of each paragraph.
> This is the first paragraph in a blockquote.
>
> This is the second paragraph in the same blockquote.
> First paragraph.
>
> Second paragraph.
>
> Third paragraph.
## Blockquotes with Formatting
Blockquotes can contain inline formatting like bold, italic, and code.
> This blockquote contains **bold text**.
> This blockquote contains *italic text*.
> This blockquote contains ***bold and italic text***.
> This blockquote contains `code text`.
> This blockquote contains **bold**, *italic*, and `code` all together.
## Blockquotes with Links
Blockquotes can contain links.
> This blockquote contains a [link to example.com](https://example.com).
> This blockquote contains a [reference link][ref].
[ref]: https://example.com
## Nested Blockquotes
Blockquotes can be nested by using multiple `>` characters.
> This is the first level of a blockquote.
>
> > This is a nested blockquote.
>
> Back to the first level.
> First level.
>
> > Second level.
>
> > > Third level.
>
> > Back to second level.
>
> Back to first level.
## Blockquotes with Lists
Blockquotes can contain lists.
> This is a blockquote with a list:
>
> - First item
> - Second item
> - Third item
> This is a blockquote with a numbered list:
>
> 1. First item
> 2. Second item
> 3. Third item
> This is a blockquote with a nested list:
>
> - First item
> - Nested item
> - Another nested item
> - Second item
## Blockquotes with Code
Blockquotes can contain inline code and code blocks.
> This blockquote contains `inline code`.
> This blockquote contains a code block:
>
> ```
> Code block here
> More code
> ```
> This blockquote contains a code block with language:
>
> ```javascript
> function example() {
> return "Hello";
> }
> ```
## Blockquotes with Headings
Blockquotes can contain headings.
> # Heading Level 1
>
> ## Heading Level 2
>
> ### Heading Level 3
## Blockquotes with Horizontal Rules
Blockquotes can contain horizontal rules.
> This is text before the rule.
>
> ---
>
> This is text after the rule.
## Multiple Blockquotes
Multiple blockquotes can appear consecutively.
> This is the first blockquote.
> This is the second blockquote.
> This is the third blockquote.
## Blockquotes in Context
Blockquotes can appear alongside regular paragraphs and other elements.
This is a regular paragraph before the blockquote.
> This is a blockquote between paragraphs.
This is a regular paragraph after the blockquote.
## Empty Blockquotes
An empty blockquote can be created, though it may not render visibly.
>
## Blockquotes with Special Characters
Blockquotes can contain special characters and punctuation.
> This blockquote has numbers: 123, 456, 789.
> This blockquote has symbols: !@#$%^&*().
> This blockquote has quotes: "Hello" and 'World'.
> This blockquote has parentheses (like this) and brackets [like this].
## Long Blockquotes
Blockquotes can contain very long text that wraps across multiple lines.
> This is a very long blockquote that contains a substantial amount of text. It demonstrates how blockquotes handle extended content that might wrap across multiple visual lines in the rendered output. The blockquote should maintain its styling and indentation even when the text extends beyond a single line.
## Blockquotes with Mixed Content
Blockquotes can contain a mix of different content types.
> This blockquote contains **bold text**, *italic text*, and `code`.
>
> It also contains a [link](https://example.com).
>
> - And a list item
> - Another list item
>
> And more regular text.
## Edge Cases
### Blockquote with Only Spaces
>
### Blockquote with Trailing Spaces
> Blockquote with trailing spaces.
### Very Short Blockquote
> A
### Blockquote with Only Special Characters
> !@#$%^&*()
### Blockquote Marker Without Space
>This might not render correctly in some processors.
---
**Source:** [basic-blockquotes.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-blockquotes.md)

310
test/markdown/basic-code.md Normal file
View File

@@ -0,0 +1,310 @@
# Basic Code Test
This file tests inline code and code block syntax in markdown.
## Inline Code
Inline code is created using backticks (`` ` ``) around the text.
This paragraph contains `inline code`.
You can use `inline code` anywhere in a sentence.
`Code` can appear at the start of a sentence.
A sentence can end with `code`.
## Code Blocks
Code blocks are created using triple backticks (``` ``` ```) on lines before and after the code.
```
This is a code block.
It can contain multiple lines.
Each line is preserved as written.
```
## Code Blocks with Language
Code blocks can specify a programming language for syntax highlighting.
```javascript
function example() {
return "Hello, World!";
}
```
```python
def example():
return "Hello, World!"
```
```html
<div>
<p>Hello, World!</p>
</div>
```
```css
.example {
color: blue;
font-size: 16px;
}
```
## Multiple Code Blocks
Multiple code blocks can appear consecutively.
```
First code block
```
```
Second code block
```
```
Third code block
```
## Code Blocks with Formatting
Code blocks preserve all formatting, including spaces and indentation.
```
This code block
has indentation
that should be preserved
```
```
function example() {
if (condition) {
return value;
}
}
```
## Code Blocks with Special Characters
Code blocks can contain special characters and symbols.
```
!@#$%^&*()
[]{}()
<>
```
```
function test() {
console.log("Hello, World!");
return 123;
}
```
## Inline Code with Special Characters
Inline code can contain special characters.
This has `code with !@#$%` in it.
This has `code with ()[]{}` in it.
This has `code with <>&` in it.
## Escaping Backticks in Inline Code
To include a backtick in inline code, use double backticks.
This contains `` `backtick` `` in the code.
This contains `` `code with backticks` `` in it.
## Code Blocks with Empty Lines
Code blocks can contain empty lines.
```
First line
Third line
```
```
Line one
Line three
Line five
```
## Code Blocks with Only Whitespace
Code blocks can contain only whitespace.
```
```
```
```
## Inline Code in Different Contexts
Inline code can appear in various contexts.
### In Paragraphs
This paragraph has `inline code` in it.
### In Lists
- Item with `inline code`
- Another item with `code`
1. Ordered item with `inline code`
2. Another ordered item with `code`
### In Blockquotes
> This blockquote contains `inline code`.
### In Headings
### Heading with `Code`
## Code Blocks in Different Contexts
### Code Blocks After Paragraphs
This is a paragraph.
```
Code block after paragraph
```
### Code Blocks Before Paragraphs
```
Code block before paragraph
```
This is a paragraph.
### Code Blocks Between Paragraphs
This is the first paragraph.
```
Code block in the middle
```
This is the second paragraph.
### Code Blocks in Lists
- List item before code block
```
Code block in list
```
- List item after code block
### Code Blocks in Blockquotes
> This is a blockquote with a code block:
>
> ```
> Code block in blockquote
> ```
## Long Code Blocks
Code blocks can contain very long lines of code.
```
This is a very long line of code that extends far beyond the normal width and should demonstrate how code blocks handle extended content that might require horizontal scrolling or wrapping depending on the rendering implementation.
```
```
function veryLongFunctionNameThatExtendsBeyondNormalWidth(parameterOne, parameterTwo, parameterThree, parameterFour) {
return parameterOne + parameterTwo + parameterThree + parameterFour;
}
```
## Code Blocks with Many Lines
Code blocks can contain many lines of code.
```javascript
function example() {
let x = 1;
let y = 2;
let z = 3;
let a = 4;
let b = 5;
let c = 6;
let d = 7;
let e = 8;
let f = 9;
let g = 10;
return x + y + z + a + b + c + d + e + f + g;
}
```
## Edge Cases
### Inline Code with Only Spaces
` `
### Inline Code with Only Special Characters
`!@#$%^&*()`
### Very Short Inline Code
`` `a` ``
### Inline Code at Word Boundaries
`code`word
word`code`
### Code Block with Only One Line
```
Single line code block
```
### Code Block with Trailing Spaces
```
Code block with trailing spaces
```
### Unclosed Code Block
```
This code block is not closed properly
### Code Block with Language but No Code
```javascript
```
### Inline Code with Backticks
`` `code` ``
`` ``code`` ``
---
**Source:** [basic-code.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-code.md)

View File

@@ -0,0 +1,194 @@
# Basic Emphasis Test
This file tests bold and italic text formatting using asterisks and underscores.
## Bold Text
Bold text is created using two asterisks or two underscores before and after the text.
I just love **bold text**.
I also love __bold text with underscores__.
**Bold text** can appear at the start of a sentence.
A sentence can end with **bold text**.
A sentence can have **bold text** in the middle.
## Italic Text
Italic text is created using one asterisk or one underscore before and after the text.
Italicized text is the *cat's meow*.
Italicized text is also the _cat's meow_.
*Italic text* can appear at the start of a sentence.
A sentence can end with *italic text*.
A sentence can have *italic text* in the middle.
## Bold and Italic Together
To emphasize text with both bold and italics, use three asterisks or three underscores.
This text is ***bold and italic***.
This text is also ___bold and italic___.
***Bold and italic*** can appear at the start of a sentence.
A sentence can end with ***bold and italic***.
A sentence can have ***bold and italic*** in the middle.
## Mid-Word Emphasis
You can emphasize the middle of a word for emphasis.
Love**is**bold
Love*is*italic
Love***is***bolditalic
## Best Practices
### Use Asterisks for Mid-Word Emphasis
For compatibility, use asterisks when emphasizing the middle of a word.
Love**is**bold (correct)
Love__is__bold (may not work in all processors)
A*cat*meow (correct)
A_cat_meow (may not work in all processors)
### Spacing Around Emphasis
Emphasis markers should be directly adjacent to the text being emphasized.
This is **correct** spacing.
This is ** incorrect ** spacing.
This is *correct* spacing.
This is * incorrect * spacing.
## Multiple Emphasis in One Paragraph
A single paragraph can contain multiple instances of bold, italic, and combined emphasis.
This paragraph has **bold text**, *italic text*, and ***bold italic text*** all together. You can use **multiple bold** sections and *multiple italic* sections in the same paragraph.
## Emphasis with Punctuation
Emphasis works correctly with adjacent punctuation marks.
**Bold text**, with a comma.
**Bold text.** With a period.
**Bold text!** With an exclamation.
**Bold text?** With a question mark.
*Italic text*, with a comma.
*Italic text.* With a period.
*Italic text!* With an exclamation.
*Italic text?* With a question mark.
## Emphasis at Word Boundaries
Emphasis can appear at the start or end of words.
**Start** of a word.
End of a **word**.
*Start* of a word.
End of a *word*.
## Emphasis with Links
Emphasis can be combined with links.
This is a [**bold link**](https://example.com).
This is a [*italic link*](https://example.com).
This is a [***bold italic link***](https://example.com).
## Emphasis with Code
Emphasis cannot be used inside code blocks, but can appear alongside inline code.
This has `code` and **bold** together.
This has `code` and *italic* together.
## Nested Emphasis
You cannot nest emphasis of the same type, but you can combine different types.
***Bold and italic*** is valid.
**Bold with *italic inside* bold** is valid.
*Italic with **bold inside** italic* is valid.
## Edge Cases
### Emphasis with Only Spaces
** **
* *
### Emphasis with Special Characters
**Bold with !@#$%**
*Italic with !@#$%*
### Very Short Emphasis
****
***
**
*
### Emphasis Markers Without Closing
**Bold text without closing
*Italic text without closing
### Emphasis with Numbers
**123**
*456*
### Emphasis with Only Punctuation
**!!!**
*???*
---
**Source:** [basic-emphasis.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-emphasis.md)

View File

@@ -0,0 +1,219 @@
# Basic Escaping Test
This file tests character escaping in markdown using backslashes to display literal characters that would otherwise have special meaning.
## Escaping Special Characters
You can escape special markdown characters by placing a backslash (`\`) before them.
### Backslash
To display a literal backslash, escape it: \\
### Backtick
To display a literal backtick, escape it: \`
### Asterisk
To display a literal asterisk, escape it: \*
### Underscore
To display a literal underscore, escape it: \_
### Curly Braces
To display literal curly braces, escape them: \{ \}
### Square Brackets
To display literal square brackets, escape them: \[ \]
### Angle Brackets
To display literal angle brackets, escape them: \< \>
### Parentheses
To display literal parentheses, escape them: \( \)
### Pound Sign
To display a literal pound sign (hash), escape it: \#
### Plus Sign
To display a literal plus sign, escape it: \+
### Minus Sign
To display a literal minus sign (hyphen), escape it: \-
### Dot
To display a literal dot (period), escape it: \.
### Exclamation Mark
To display a literal exclamation mark, escape it: \!
### Pipe
To display a literal pipe character, escape it: \|
## Escaping in Different Contexts
### Escaping in Paragraphs
This paragraph contains escaped characters: \*asterisk\*, \_underscore\_, \`backtick\`.
### Escaping in Headings
#### Heading with \*Escaped\* Characters
#### Heading with \_Escaped\_ Characters
### Escaping in Lists
- Item with \*escaped asterisk\*
- Item with \_escaped underscore\_
- Item with \`escaped backtick\`
1. Ordered item with \*escaped\*
2. Another item with \_escaped\_
### Escaping in Blockquotes
> This blockquote contains \*escaped\* characters.
> This blockquote has \_escaped\_ underscores.
### Escaping in Links
You cannot escape characters inside link syntax, but you can escape them in the link text context.
This is a [link with \*escaped\* text](https://example.com).
## Multiple Escaped Characters
You can escape multiple characters in sequence.
\*\*This would be bold if not escaped\*\*
\*\*\*This would be bold and italic if not escaped\*\*\*
\`\`This would be code if not escaped\`\`
## Escaping vs. Not Escaping
### Without Escaping
This text has **bold** and *italic* formatting.
### With Escaping
This text has \*\*escaped bold\*\* and \*escaped italic\* markers.
## Escaping Special Characters in Code
Inside code blocks and inline code, characters are already literal and don't need escaping.
```
This code block contains *asterisks* and _underscores_ without escaping.
```
This paragraph contains `inline code with *asterisks*` that don't need escaping.
## Escaping at Word Boundaries
Escaped characters can appear at the start or end of words.
\*Start of word
End of word\*
\_Start of word
End of word\_
## Escaping with Punctuation
Escaped characters work correctly with adjacent punctuation.
\*Asterisk\*, with comma.
\*Asterisk\*. With period.
\*Asterisk\*! With exclamation.
\_Underscore\_, with comma.
\_Underscore\_. With period.
## Edge Cases
### Escaping Non-Special Characters
Escaping characters that don't have special meaning in markdown typically results in a literal backslash followed by the character.
\a
\b
\c
### Multiple Backslashes
\\\\
\\\\\\
### Escaping Spaces
Escaping a space typically doesn't have a special effect: \
### Escaping Newlines
Escaping a newline (backslash at end of line) may create a line break in some processors, but this is not part of basic markdown syntax.
### Escaping in Different Positions
Start: \*text
Middle: text\*text
End: text\*
### Escaping Special Character Sequences
\*\*\*
\`\`\`
\-\-\-
## Real-World Examples
### Escaping in Documentation
When writing documentation about markdown, you often need to escape characters to show the syntax.
To create bold text, use \*\*two asterisks\*\*.
To create italic text, use \*one asterisk\*.
To create inline code, use \`backticks\`.
### Escaping in Examples
Here's how to escape a backtick: \`
Here's how to escape an asterisk: \*
Here's how to escape an underscore: \_
---
**Source:** [basic-escaping.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-escaping.md)

View File

@@ -0,0 +1,140 @@
# Basic Headings Test
This file tests markdown heading syntax, including all heading levels and alternate syntax forms.
## Heading Levels
Headings are created using number signs (`#`) followed by a space and the heading text. The number of `#` symbols determines the heading level.
# Heading Level 1
## Heading Level 2
### Heading Level 3
#### Heading Level 4
##### Heading Level 5
###### Heading Level 6
## Best Practices
Always include a space between the number signs and the heading text for compatibility across markdown processors.
# Correct: Space after #
#Incorrect: No space after #
## Blank Lines
For best compatibility, include blank lines before and after headings.
This paragraph is before the heading.
# Heading with blank lines
This paragraph is after the heading.
Without blank lines, this might not render correctly.
# Heading without blank lines
This text might be treated as part of the heading.
## Alternate Syntax (Setext)
Heading level 1 can also be created using equals signs (`=`) on the line below the text.
Heading Level 1
===============
Heading level 2 can be created using hyphens (`-`) on the line below the text.
Heading Level 2
---------------
## Headings with Formatting
Headings can contain inline formatting like bold and italic text.
### Heading with **Bold** Text
### Heading with *Italic* Text
### Heading with ***Bold and Italic*** Text
### Heading with `Code` Text
## Headings with Links
Headings can contain links.
### Heading with [Link](https://example.com)
### Heading with [Reference Link][ref]
[ref]: https://example.com
## Long Headings
This tests how headings handle very long text that might wrap across multiple lines on smaller screens or in narrow containers.
# This is a very long heading that contains many words and should demonstrate how the markdown processor handles headings that extend beyond a single line of text
## Special Characters in Headings
Headings can contain various special characters and punctuation.
### Heading with Numbers: 123
### Heading with Symbols: !@#$%^&*()
### Heading with Quotes: "Hello World"
### Heading with Parentheses (Like This)
### Heading with Brackets [Like This]
### Heading with Braces {Like This}
## Multiple Headings
Multiple headings of the same or different levels can appear consecutively.
# First H1
# Second H1
## First H2
## Second H2
### First H3
### Second H3
## Edge Cases
### Heading with Only Spaces
#
### Heading with Trailing Spaces
# Heading with trailing spaces
### Heading Starting with Number Sign
# #Heading that starts with a number sign
### Very Short Heading
# A
### Heading with Only Special Characters
# !@#$%^&*()
---
**Source:** [basic-headings.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-headings.md)

View File

@@ -0,0 +1,194 @@
# Basic Horizontal Rules Test
This file tests horizontal rule syntax using hyphens, asterisks, and underscores.
## Basic Horizontal Rules
Horizontal rules are created using three or more hyphens, asterisks, or underscores on their own line.
---
***
___
## Minimum Characters
At least three characters are required, but more can be used.
---
----
-----
---
## Different Characters
Horizontal rules can be created with hyphens, asterisks, or underscores.
---
***
___
## Horizontal Rules in Context
Horizontal rules can appear between paragraphs and other elements.
This is a paragraph before the horizontal rule.
---
This is a paragraph after the horizontal rule.
## Multiple Horizontal Rules
Multiple horizontal rules can appear consecutively.
---
---
---
## Horizontal Rules with Formatting
Horizontal rules are standalone elements and cannot contain formatting, but they can appear alongside formatted content.
This paragraph has **bold text**.
---
This paragraph has *italic text*.
## Horizontal Rules with Lists
Horizontal rules can appear before and after lists.
---
- First item
- Second item
- Third item
---
## Horizontal Rules with Blockquotes
Horizontal rules can appear before and after blockquotes.
---
> This is a blockquote.
---
## Horizontal Rules with Code Blocks
Horizontal rules can appear before and after code blocks.
---
```
Code block here
```
---
## Horizontal Rules with Headings
Horizontal rules can appear before and after headings.
---
# Heading Level 1
---
## Heading Level 2
---
## Spacing Around Horizontal Rules
Horizontal rules should be on their own line with blank lines before and after for best compatibility.
Paragraph before.
---
Paragraph after.
## Edge Cases
### Horizontal Rule with Spaces
- - -
* * *
_ _ _
### Horizontal Rule with Only Two Characters
--
**
__
### Horizontal Rule with Mixed Characters
-*_
*-_
_*-
### Horizontal Rule with Trailing Spaces
---
***
___
### Horizontal Rule with Leading Spaces
---
***
___
### Very Long Horizontal Rules
-----------------------------------
***********************************
___________________________________
### Horizontal Rule Between Other Elements
# Heading
---
## Another Heading
---
### Third Heading
---
Paragraph text.
---
**Source:** [basic-horizontal-rules.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-horizontal-rules.md)

View File

@@ -0,0 +1,95 @@
# Basic Markdown Syntax Test Index
This directory contains test files for basic markdown syntax features. Each file focuses on a specific aspect of the [Markdown Guide's Basic Syntax](https://www.markdownguide.org/basic-syntax/).
## Test Files
### [basic-headings.md](./basic-headings.md)
Tests heading syntax including:
- All heading levels (H1 through H6)
- Setext-style headings (alternate syntax)
- Headings with formatting and links
- Best practices for spacing and blank lines
### [basic-paragraphs-line-breaks.md](./basic-paragraphs-line-breaks.md)
Tests paragraph separation and line break syntax:
- Paragraph creation with blank lines
- Line breaks using trailing spaces
- HTML line breaks using `<br>` tag
- Best practices for paragraph formatting
### [basic-emphasis.md](./basic-emphasis.md)
Tests bold and italic text formatting:
- Bold text with `**` and `__`
- Italic text with `*` and `_`
- Combined bold and italic
- Mid-word emphasis best practices
### [basic-blockquotes.md](./basic-blockquotes.md)
Tests blockquote syntax:
- Basic blockquotes with `>`
- Multiple paragraph blockquotes
- Nested blockquotes
- Blockquotes containing lists, code, and formatting
### [basic-lists.md](./basic-lists.md)
Tests ordered and unordered list syntax:
- Unordered lists with `-`, `*`, and `+`
- Ordered lists with numbers
- Nested lists
- Lists with formatting, links, and code
### [basic-code.md](./basic-code.md)
Tests inline code and code block syntax:
- Inline code with backticks
- Code blocks with triple backticks
- Code blocks with language specification
- Escaping backticks in inline code
### [basic-horizontal-rules.md](./basic-horizontal-rules.md)
Tests horizontal rule syntax:
- Horizontal rules with `---`, `***`, and `___`
- Minimum character requirements
- Horizontal rules in various contexts
### [basic-links-and-images.md](./basic-links-and-images.md)
Tests link and image syntax:
- Inline links and reference links
- Links with titles
- Images with alt text and titles
- Linking images
- URL encoding best practices
### [basic-escaping.md](./basic-escaping.md)
Tests character escaping:
- Escaping special markdown characters with backslashes
- Escaping in different contexts
- All escapable characters per the Markdown Guide
## Usage
These test files can be:
- Viewed in the app's markdown reader
- Published to Nostr relays using `./scripts/publish-markdown.sh`
### Publishing a Test File
```bash
# Publish a specific file
./scripts/publish-markdown.sh basic-headings.md [wss://relay.example.com]
# Interactive mode (choose from all files)
./scripts/publish-markdown.sh
```
## Related Files
- [tables.md](./tables.md) - Tests markdown table syntax (GFM feature, not basic syntax)
## Notes
- These files test only **basic markdown syntax** as defined in the original Markdown specification
- Extended syntax features (like tables, footnotes, task lists) are not included here
- Each file starts with an H1 heading for title extraction by the publish script
- Files are kept under 420 lines per project conventions

View File

@@ -0,0 +1,217 @@
# Basic Links and Images Test
This file tests link and image syntax in markdown, including inline links, reference links, and images.
## Inline Links
Inline links are created using square brackets for the link text followed by parentheses containing the URL.
This is an [inline link](https://example.com).
You can have [multiple links](https://example.com) in the [same paragraph](https://example.org).
## Links with Titles
Links can include an optional title that appears as a tooltip when hovering.
This is a [link with title](https://example.com "Example Website").
This is another [link with title](https://example.org 'Another Example').
This link uses (parentheses) for the title: [link](https://example.com (Title in Parentheses)).
## Reference Links
Reference links use a two-part syntax: the link text in square brackets, and the URL definition elsewhere.
This is a [reference link][example].
This is another [reference link][another].
[example]: https://example.com
[another]: https://example.org
## Reference Links with Titles
Reference links can also include titles.
This is a [reference link with title][titled].
[titled]: https://example.com "Link Title"
This is another [reference link with title][titled2].
[titled2]: https://example.org 'Another Title'
## Implicit Reference Links
You can use the link text itself as the reference identifier.
This is an [implicit reference link][implicit reference link].
[implicit reference link]: https://example.com
## Links in Different Contexts
### Links in Paragraphs
This paragraph contains a [link](https://example.com) in the middle of the text.
### Links in Lists
- Item with [link](https://example.com)
- Another item with [link](https://example.org)
1. Ordered item with [link](https://example.com)
2. Another ordered item with [link](https://example.org)
### Links in Blockquotes
> This blockquote contains a [link](https://example.com).
### Links in Headings
### Heading with [Link](https://example.com)
## Links with Formatting
Links can contain formatting like bold and italic.
This is a [**bold link**](https://example.com).
This is a [*italic link*](https://example.com).
This is a [***bold italic link***](https://example.com).
## Images
Images are created using an exclamation mark followed by square brackets for alt text and parentheses for the image URL.
![Alt text](https://example.com/image.jpg)
![Image with description](https://example.com/photo.png)
## Images with Titles
Images can include optional titles.
![Alt text](https://example.com/image.jpg "Image Title")
![Another image](https://example.com/photo.png 'Another Title')
## Reference Style Images
Images can use reference-style syntax.
![Reference image][img1]
![Another reference image][img2]
[img1]: https://example.com/image.jpg
[img2]: https://example.com/photo.png "Photo Title"
## Linking Images
To create a link that wraps an image, enclose the image syntax in square brackets followed by the link URL in parentheses.
[![Linked image](https://example.com/image.jpg)](https://example.com)
[![Another linked image](https://example.com/photo.png)](https://example.org "Link Title")
## Images in Different Contexts
### Images in Paragraphs
This paragraph contains an image: ![Inline image](https://example.com/image.jpg)
### Images in Lists
- Item with image: ![List image](https://example.com/image.jpg)
- Another item with image: ![Another image](https://example.com/photo.png)
### Images in Blockquotes
> This blockquote contains an image: ![Blockquote image](https://example.com/image.jpg)
## Relative and Absolute URLs
Links can use both relative and absolute URLs.
This is a [relative link](../page.html).
This is an [absolute link](https://example.com/page.html).
This is a [protocol-relative link](//example.com/page.html).
## Links with Special Characters
Links can contain special characters, but URLs with spaces should be encoded.
This is a [link with encoded space](https://example.com/my%20page.html).
This is a [link with parentheses](https://example.com/page%28with%29parentheses.html).
## Edge Cases
### Empty Link Text
[](https://example.com)
### Link with Only Spaces
[ ](https://example.com)
### Image with Empty Alt Text
![](https://example.com/image.jpg)
### Image with Only Spaces in Alt Text
![ ](https://example.com/image.jpg)
### Link Without URL
[Link text]()
### Reference Link Without Definition
This is a [broken reference link][broken].
### Very Long URLs
This is a [link with a very long URL](https://example.com/very/long/path/to/a/resource/that/extends/beyond/normal/width/and/tests/how/the/renderer/handles/extended/urls.html).
### Links with Numbers
[Link 123](https://example.com)
[123 Link](https://example.com)
### Images with Special Characters in Alt Text
![Image with !@#$%](https://example.com/image.jpg)
![Image with "quotes"](https://example.com/image.jpg)
## Best Practices
### URL Encoding
For compatibility, encode spaces in URLs with `%20`.
✅ [Correct link](https://example.com/my%20page.html)
❌ [Incorrect link](https://example.com/my page.html)
### Parentheses in URLs
Encode opening parenthesis as `%28` and closing parenthesis as `%29`.
✅ [Correct link](https://example.com/page%28with%29parentheses.html)
❌ [Incorrect link](https://example.com/page(with)parentheses.html)
---
**Source:** [basic-links-and-images.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-links-and-images.md)

View File

@@ -0,0 +1,249 @@
# Basic Lists Test
This file tests ordered and unordered list syntax in markdown.
## Unordered Lists
Unordered lists are created using hyphens (`-`), asterisks (`*`), or plus signs (`+`) followed by a space.
- First item
- Second item
- Third item
* First item
* Second item
* Third item
+ First item
+ Second item
+ Third item
## Ordered Lists
Ordered lists are created using numbers followed by a period and a space.
1. First item
2. Second item
3. Third item
## List Items with Multiple Paragraphs
List items can contain multiple paragraphs by indenting subsequent paragraphs.
- First item
This is a second paragraph in the first item.
- Second item
This is a second paragraph in the second item.
This is a third paragraph in the second item.
## Nested Lists
Lists can be nested by indenting list items.
- First level item
- Second level item
- Another second level item
- Back to first level
1. First ordered item
- Nested unordered item
- Another nested unordered item
2. Second ordered item
- Nested unordered item
- Third level item
- Unordered item
1. Nested ordered item
2. Another nested ordered item
- Another unordered item
## Lists with Formatting
List items can contain inline formatting like bold, italic, and code.
- Item with **bold text**
- Item with *italic text*
- Item with ***bold and italic text***
- Item with `code text`
- Item with **bold**, *italic*, and `code` together
1. Ordered item with **bold text**
2. Ordered item with *italic text*
3. Ordered item with `code text`
## Lists with Links
List items can contain links.
- Item with [inline link](https://example.com)
- Item with [reference link][ref]
[ref]: https://example.com
1. Ordered item with [link](https://example.com)
2. Another ordered item with [link](https://example.com)
## Lists with Code
List items can contain inline code and code blocks.
- Item with `inline code`
- Item with code block:
```
Code block here
More code
```
1. Ordered item with `inline code`
2. Ordered item with code block:
```javascript
function example() {
return "Hello";
}
```
## Lists with Blockquotes
List items can contain blockquotes.
- Item with blockquote:
> This is a blockquote inside a list item.
1. Ordered item with blockquote:
> This is a blockquote inside an ordered list item.
## Lists with Other Lists
Lists can contain other lists as nested items.
- First item
- Nested unordered list
- Deeper nesting
- Another nested item
- Second item
1. Nested ordered list
2. Another ordered item
- Even deeper nesting
## Ordered Lists with Different Start Numbers
Ordered lists can start with any number, but markdown processors typically normalize them.
1. First item
2. Second item
3. Third item
5. Starting at five
6. Second item
7. Third item
10. Starting at ten
11. Second item
12. Third item
## Mixed List Types
You can mix ordered and unordered lists at the same level.
- Unordered item
- Another unordered item
1. Ordered item
2. Another ordered item
- Back to unordered
## Long List Items
List items can contain extended text that wraps across multiple lines.
- This is a very long list item that contains a substantial amount of text. It demonstrates how list items handle extended content that might wrap across multiple visual lines in the rendered output. The list item should maintain proper formatting and indentation.
- Another long item with multiple sentences. Each sentence flows naturally into the next. The entire item should render as a cohesive unit with appropriate line wrapping based on the container width.
## Lists with Special Characters
List items can contain special characters and punctuation.
- Item with numbers: 123, 456, 789
- Item with symbols: !@#$%^&*()
- Item with quotes: "Hello" and 'World'
- Item with parentheses (like this) and brackets [like this]
1. Ordered item with numbers: 123
2. Ordered item with symbols: !@#$%^&*()
3. Ordered item with quotes: "Hello"
## Empty List Items
An empty list item can be created, though it may not render visibly.
-
1.
## Edge Cases
### List with Only Spaces
-
### List Item with Trailing Spaces
- Item with trailing spaces.
### Very Short List Items
- A
- B
- C
### List with Only Special Characters
- !@#$%^&*()
- !@#$%^&*()
### List Marker Without Space
-This might not render correctly in some processors.
1.This might not render correctly in some processors.
### Single Item Lists
- Only one item
1. Only one item
### Lists with Many Items
This tests how lists handle a larger number of items.
1. First item
2. Second item
3. Third item
4. Fourth item
5. Fifth item
6. Sixth item
7. Seventh item
8. Eighth item
9. Ninth item
10. Tenth item
11. Eleventh item
12. Twelfth item
13. Thirteenth item
14. Fourteenth item
15. Fifteenth item
---
**Source:** [basic-lists.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-lists.md)

View File

@@ -0,0 +1,156 @@
# Basic Paragraphs and Line Breaks Test
This file tests paragraph separation and line break syntax in markdown.
## Paragraphs
Paragraphs are created by separating blocks of text with blank lines. A paragraph is one or more consecutive lines of text separated by blank lines.
This is the first paragraph. It contains multiple sentences. Each sentence flows naturally into the next. The paragraph continues until a blank line appears.
This is the second paragraph. It is separated from the first paragraph by a blank line. Paragraphs should render as distinct blocks of text with appropriate spacing between them.
This is the third paragraph. It demonstrates that multiple paragraphs can appear consecutively, each separated by a blank line.
## Single Line Paragraphs
A paragraph can consist of a single line of text.
This is a single-line paragraph.
This is another single-line paragraph.
## Paragraphs with Multiple Sentences
Paragraphs can contain multiple sentences. Each sentence ends with appropriate punctuation. The sentences flow together as a cohesive unit. This paragraph demonstrates that natural paragraph structure is preserved.
## Line Breaks
To create a line break within a paragraph, end a line with two or more spaces followed by a return.
This is the first line.
This is the second line created with trailing spaces.
This demonstrates that line breaks create a new line within the same paragraph, rather than starting a new paragraph.
## HTML Line Breaks
If your markdown processor supports HTML, you can use the `<br>` tag for line breaks.
This is the first line.<br>
This is the second line created with an HTML break tag.
This is another line.<br>
And another line after the break.
## Best Practices
### Don't Indent Paragraphs
Paragraphs should not be indented with spaces or tabs unless they are part of a list.
This is a correctly formatted paragraph without indentation.
This paragraph is incorrectly indented with spaces.
This paragraph is correctly formatted again.
### Blank Lines Between Paragraphs
Always use blank lines to separate paragraphs for compatibility.
This paragraph is correctly separated.
This paragraph is also correctly separated.
Without a blank line, this might not render as a separate paragraph.
This text might be treated as part of the previous paragraph.
## Multiple Line Breaks
Multiple line breaks within a paragraph can be created using trailing spaces or HTML breaks.
Line one.
Line two.
Line three.
Line one.<br>
Line two.<br>
Line three.
## Paragraphs with Formatting
Paragraphs can contain inline formatting like bold, italic, and code.
This paragraph contains **bold text** and *italic text* and `code text`.
This paragraph demonstrates that formatting works correctly within paragraph boundaries.
## Paragraphs with Links
Paragraphs can contain links and other inline elements.
This paragraph contains a [link to example.com](https://example.com) and another [reference link][ref].
[ref]: https://example.com
## Long Paragraphs
This paragraph contains a substantial amount of text to test how the markdown processor handles longer paragraphs. It includes multiple sentences that flow together naturally. The paragraph should render as a single cohesive block of text with appropriate line wrapping based on the container width. This tests that paragraph rendering works correctly even with extended content that might wrap across multiple visual lines in the rendered output.
## Paragraphs with Special Characters
Paragraphs can contain various special characters and punctuation marks.
This paragraph has numbers: 123, 456, 789.
This paragraph has symbols: !@#$%^&*().
This paragraph has quotes: "Hello" and 'World'.
This paragraph has parentheses (like this) and brackets [like this].
## Empty Paragraphs
An empty line creates a paragraph break, but multiple empty lines should still create a single paragraph break.
Paragraph before empty lines.
Paragraph after empty lines.
## Paragraphs with Code
Paragraphs can contain inline code and code blocks.
This paragraph contains `inline code` within the text flow.
This paragraph appears before a code block.
```
Code block here
```
This paragraph appears after a code block.
## Edge Cases
### Paragraph with Only Whitespace
### Paragraph with Trailing Spaces
This paragraph has trailing spaces.
### Very Short Paragraph
A.
### Paragraph with Only Special Characters
!@#$%^&*()
---
**Source:** [basic-paragraphs-line-breaks.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-paragraphs-line-breaks.md)

166
test/markdown/tables.md Normal file
View File

@@ -0,0 +1,166 @@
# Markdown Tables Test
This file contains various markdown table examples to test table parsing and rendering.
## Basic Table
This is a simple two-column table with two data rows. It tests basic table structure and rendering without any special formatting or alignment.
| Column 1 | Column 2 |
| ------------- | ------------- |
| Cell 1, Row 1 | Cell 2, Row 1 |
| Cell 1, Row 2 | Cell 2, Row 2 |
## Table with Alignment
This table demonstrates text alignment options in markdown tables. The first column is left-aligned (default), the second is centered using `:---:`, and the third is right-aligned using `---:`. This tests that the CSS alignment rules work correctly.
| Left | Centered | Right |
| :----------- | :--------------: | -------------------------: |
| This is left | Text is centered | And this is right-aligned |
| More text | Even more text | And even more to the right |
## Table with Formatting
This table contains various markdown formatting within cells: italic text using asterisks, bold text using double asterisks, and inline code using backticks. This tests that formatting is preserved and rendered correctly within table cells.
| Name | Location | Food |
| ------- | ------------ | ------- |
| *Alice* | **New York** | `Pizza` |
| Bob | Paris | Crepes |
## Table with Links
This table includes markdown links within cells. It tests that hyperlinks are properly rendered and clickable within table cells, and that link styling matches the app's theme.
| Name | Website | Description |
| ----- | -------------------------- | --------------------- |
| Alice | [GitHub](https://github.com) | Code repository |
| Bob | [Nostr](https://nostr.com) | Decentralized network |
## Table with Code Blocks
This table contains inline code examples in cells. It tests that code formatting (monospace font, background, borders) is properly applied within table cells and doesn't conflict with table styling.
| Language | Example |
| -------- | -------------------------- |
| Python | `print("Hello, World!")` |
| JavaScript | `console.log("Hello")` |
| SQL | `SELECT * FROM users` |
## Wide Table (Testing Horizontal Scroll)
This table has eight columns to test horizontal scrolling behavior on mobile devices and smaller screens. The table should allow users to scroll horizontally to view all columns while maintaining proper styling and readability.
| Column 1 | Column 2 | Column 3 | Column 4 | Column 5 | Column 6 | Column 7 | Column 8 |
| -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- |
| Data 1 | Data 2 | Data 3 | Data 4 | Data 5 | Data 6 | Data 7 | Data 8 |
| More | Content | Here | To | Test | Scrolling| Behavior | Mobile |
## Table with Mixed Content
This table combines various content types: currency values, emoji indicators, and descriptive text. It tests how different content types render together within table cells and ensures proper spacing and alignment.
| Item | Price | Status | Notes |
| ---- | ----- | ------ | ------------------------------ |
| Apple | $1.00 | ✅ In stock | Fresh from the farm |
| Banana | $0.50 | ⚠️ Low stock | Last few left |
| Orange | $1.25 | ❌ Out of stock | Coming next week |
## Table with Empty Cells
This table contains empty cells to test how the table styling handles missing data. Empty cells should still maintain proper borders and spacing, ensuring the table structure remains intact.
| Name | Email | Phone |
| ---- | ----- | ----- |
| Alice | alice@example.com | |
| Bob | | 555-1234 |
| Charlie | charlie@example.com | 555-5678 |
## Table with Long Text
This table tests text wrapping behavior with varying column widths. The third column contains a long paragraph that should wrap to multiple lines within the cell while maintaining proper padding and readability. This is especially important for responsive design.
| Short | Medium Length Column | Very Long Column That Contains A Lot Of Text And Should Wrap Properly |
| ----- | -------------------- | -------------------------------------------------------------------- |
| A | This is medium text | This is a very long piece of text that should wrap to multiple lines when displayed in the table cell. It should maintain proper formatting and readability. |
## Table with Numbers
This table contains 21 rows of ranked data with numeric scores and percentages. It's useful for testing row striping, scrolling behavior with longer tables, and ensuring that numeric alignment and formatting remain consistent throughout a larger dataset.
| Rank | Name | Score | Percentage |
| ---- | ---- | ----- | ---------- |
| 1 | Alice | 95 | 95% |
| 2 | Bob | 87 | 87% |
| 3 | Charlie | 82 | 82% |
| 4 | David | 78 | 78% |
| 5 | Emma | 75 | 75% |
| 6 | Frank | 72 | 72% |
| 7 | Grace | 70 | 70% |
| 8 | Henry | 68 | 68% |
| 9 | Ivy | 65 | 65% |
| 10 | Jack | 63 | 63% |
| 11 | Kate | 60 | 60% |
| 12 | Liam | 58 | 58% |
| 13 | Mia | 55 | 55% |
| 14 | Noah | 53 | 53% |
| 15 | Olivia | 50 | 50% |
| 16 | Paul | 48 | 48% |
| 17 | Quinn | 45 | 45% |
| 18 | Ryan | 43 | 43% |
| 19 | Sarah | 40 | 40% |
| 20 | Tom | 38 | 38% |
| 21 | Uma | 35 | 35% |
## Table with Special Characters
This table contains escaped special characters that have meaning in markdown syntax. It tests that these characters are properly escaped and displayed as literal characters rather than being interpreted as markdown syntax.
| Symbol | Name | Usage |
| ------ | ---- | ----- |
| `\|` | Pipe | Used in markdown tables |
| `\*` | Asterisk | Used for bold/italic |
| `\#` | Hash | Used for headings |
## Table with Headers Only
This table contains only header rows with no data rows. It tests edge case handling for tables without content, ensuring that the header styling is still applied correctly even when there's no body content.
| Header 1 | Header 2 | Header 3 |
| -------- | -------- | -------- |
## Single Column Table
This is a minimal table with only one column. It tests how table styling handles narrow tables and ensures that single-column layouts are properly formatted with appropriate borders and spacing.
| Item |
| ---- |
| First |
| Second |
| Third |
## A Table from a Real Article
This one is from [Bitcoin is Time](https://read.withboris.com/a/naddr1qq8ky6t5vdhkjm3dd9ej6arfd4jsygrwg6zz9hahfftnsup23q3mnv5pdz46hpj4l2ktdpfu6rhpthhwjvpsgqqqw4rsdan6ej) which broke and is the reason why this document exists.
| Clock | Tick Frequency |
| --------------------------|-----------------------------------------|
| Grandfather's clock | ~0.5 Hz |
| Metronome | ~0.67 Hz to ~4.67 Hz |
| Quartz watch | 32768 Hz |
| Caesium-133 atomic clock | 9,192,631,770 Hz |
| Bitcoin | 1 block (0.00000192901 Hz* to ∞ Hz**) |
\* first block (6 days)
\*\* timestamps between blocks can show a negative delta
## Table with Nested Formatting
This table demonstrates complex nested formatting combinations within cells, including bold and italic text together, code blocks containing links, and strikethrough text. It tests that multiple formatting types can coexist properly within table cells.
| Description | Example |
| ----------- | ------- |
| Bold and italic | ***Important*** |
| Code and link | `[Click here](https://example.com)` |
| Strikethrough | ~~Old price~~ |

View File

@@ -1,14 +1,23 @@
{
"version": 2,
"functions": {
"api/article-og.ts": {
"maxDuration": 10
},
"api/article-og-refresh.ts": {
"maxDuration": 10
}
},
"rewrites": [
{
"source": "/a/:naddr",
"destination": "/index.html",
"has": [
{
"type": "header",
"key": "user-agent",
"value": ".*(bot|crawl|spider|slurp|facebook|twitter|linkedin|whatsapp|telegram|slack|discord|preview).*"
}
],
{ "type": "query", "key": "_spa", "value": "1" }
]
},
{
"source": "/a/:naddr",
"destination": "/api/article-og?naddr=:naddr"
},
{

View File

@@ -139,7 +139,10 @@ export default defineConfig({
},
devOptions: {
enabled: true,
type: 'module'
type: 'module',
// Use generateSW strategy for dev mode to enable SW testing
// This creates a working SW in dev mode, while injectManifest is used in production
navigateFallback: 'index.html'
}
})
],