Compare commits

...

114 Commits

Author SHA1 Message Date
Gigi
a0ec89458c Merge pull request #50 from dergigi/fathom
Add Fathom script
2025-11-30 22:15:31 +01:00
Gigi
d8849b2d81 chore(analytics): adjust Fathom comment placement 2025-11-30 22:13:38 +01:00
Gigi
a431bbea6c chore(analytics): simplify Fathom comments 2025-11-30 22:13:21 +01:00
Gigi
3cbad434d6 chore(analytics): add Fathom analytics script 2025-11-30 22:12:19 +01:00
Gigi
4d3047476d Merge pull request #49 from dergigi/highlights-feed-etc
Improve highlights rendering and explore layout
2025-11-26 23:40:55 +01:00
Gigi
bf81cd51b7 style: use multi-column layout for explore writings 2025-11-26 23:35:44 +01:00
Gigi
d50276adca style: align explore width with reader 2025-11-26 23:15:45 +01:00
Gigi
785be6aa9e style: make highlight text wrapping more aggressive 2025-11-26 23:12:48 +01:00
Gigi
934bee2d62 feat: render highlight context and colored highlight text 2025-11-26 22:32:47 +01:00
Gigi
00eb9ae55b style: use single column layout on explore 2025-11-26 22:28:27 +01:00
Gigi
61968c8892 docs: update CHANGELOG for v0.11.1 2025-11-22 02:18:25 +01:00
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
53 changed files with 4084 additions and 539 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 # Nostr configuration for publish-markdown.sh script
# This should be a valid naddr1... string (NIP-19 encoded address pointer to a kind:30023 long-form article) # Copy this file to .env and fill in your values
VITE_DEFAULT_ARTICLE_NADDR=naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew
# 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 primal-web-app
Amber Amber
.env
scripts/.env
.vercel

View File

@@ -7,6 +7,109 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.11.1] - 2025-11-22
### Added
- Three-dot menu to profile view
- Clickable quote text in highlights to navigate to article
- Profile navigation from highlight author cards
- Improved relay hint selection to exclude non-content relays
### Fixed
- Profile header horizontal padding matches tabs width
- Profile menu positioning inside card
- Highlight quote button navigation reliability
- Highlight menu cutoff when only one highlight
- Article loading reuses Explore article events for immediate display
- Removed unused variables and imports
### Refactored
- Unified relay configuration with typed registry
- Improved relay hint selection and relay management
## [0.11.0] - 2025-11-07
### Added
- Configurable link color setting for article links
- Basic markdown syntax test files for testing
- Article tags and image alt text to OpenGraph metadata
- Storage-backed OpenGraph previews with Upstash Redis
- Always render OpenGraph meta for `/a/:naddr` routes with redirect script for browsers
- Script to publish markdown test files to Nostr using nak
- npm script for publishing test markdown files
### Changed
- Default link color changed to Sky Blue (#38bdf8)
- Link color setting renamed to `--color-link` with dark/light theme support
- Use single link color setting with theme-aware palette
- Increased paragraph spacing in reader view to 1.5rem
- Increased top margin on headlines in reader view
- Improved link visibility in dark mode with lighter indigo-400 color
- Default Nostr gateway changed to njump.to
- Node runtime pinned to 22.x via package.json engines
- Simplified OpenGraph fetch by removing timeout wrapper and background refresh
### Fixed
- Use sentinel query param for OpenGraph redirect to preserve `/a/:naddr` paths
- Gate `/a/:naddr` rewrite to crawlers to prevent refresh redirect
- Update preview link color when link color setting changes
- Store separate link colors for dark and light themes
- Remove unused LINK_COLORS import from ColorPicker
- Increase relay fetch timeout from 3s to 5s for better reliability
- Improve Redis initialization and add debugging for metadata fetch
- Add .js extensions to ESM imports for Vercel compatibility
- Move OpenGraph service files to api/services for Vercel compatibility
- Resolve linting and type errors
- Remove user-agent restriction from article OpenGraph rewrite
- Inline profile display name helper to avoid src import in serverless
- Move profile helpers to lib and import from API and src to fix serverless import resolution
### Performance
- Implement early-return article fetch with micro-wait author
- Increase relay request timeouts (7s article, 5s profile) to improve reliability
- Remove gateway fetch, use relays with short timeout
### Refactored
- Use relative path for preview link to work on localhost
- Move link to 3rd paragraph and remove 4th paragraph from preview
- Update preview link to use real article link instead of sample text
- Move profile helpers to shared lib module to keep code DRY
### Documentation
- Remove Development section from README
- Update source links to point to specific files
- Add source links to basic markdown test files
- Add footnotes explaining Bitcoin frequency notation
- Add explanatory paragraphs to each test table
- Add test account npub and profile link to .env.example
- Add comprehensive documentation for publish-markdown script
## [0.10.33] - 2025-11-05
### Fixed
- Mobile text selection detection for highlight button using selectionchange event
- Normalized index mapping algorithm for whitespace handling in highlights
### Changed
- Allow nested mark elements for overlapping highlights
- Remove unused React import from VideoEmbedProcessor
### Performance
- Optimize highlight application by collecting text nodes once instead of per highlight (O(n×m) -> O(n+m))
- Add caching for highlighted HTML results with TTL and size limits
## [0.10.32] - 2025-11-02 ## [0.10.32] - 2025-11-02
### Added ### Added

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

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

View File

@@ -1,208 +1,13 @@
import type { VercelRequest, VercelResponse } from '@vercel/node' import type { VercelRequest, VercelResponse } from '@vercel/node'
import { RelayPool } from 'applesauce-relay' import { getArticleMeta, setArticleMeta } from './services/ogStore.js'
import { nip19 } from 'nostr-tools' import { fetchArticleMetadataViaRelays } from './services/articleMeta.js'
import { AddressPointer } from 'nostr-tools/nip19' import { generateHtml } from './services/ogHtml.js'
import { NostrEvent, Filter } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { extractProfileDisplayName } from '../src/utils/profileUtils'
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;')
}
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void { function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`) res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
res.setHeader('Content-Type', 'text/html; charset=utf-8') 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 using centralized utility
let authorName = pointer.pubkey.slice(0, 8) + '...'
if (profileEvents.length > 0) {
const displayName = extractProfileDisplayName(profileEvents[0])
if (displayName && !displayName.startsWith('@')) {
authorName = displayName
} else if (displayName) {
authorName = displayName.substring(1) // Remove @ prefix
}
}
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) { export default async function handler(req: VercelRequest, res: VercelResponse) {
const naddr = (req.query.naddr as string | undefined)?.trim() const naddr = (req.query.naddr as string | undefined)?.trim()
@@ -210,89 +15,46 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
return res.status(400).json({ error: 'Missing naddr parameter' }) 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' const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
if (debugEnabled) { if (debugEnabled) {
res.setHeader('X-Boris-Debug', '1') res.setHeader('X-Boris-Debug', '1')
} }
// If it's a regular browser (not a bot), serve HTML that loads SPA // Try Redis cache first
// Use history.replaceState to set the URL before the SPA boots let meta = await getArticleMeta(naddr).catch((err) => {
if (!isCrawlerRequest) { console.error('Failed to get article meta from Redis:', err)
const articlePath = `/a/${naddr}` return null
// Serve a minimal HTML that sets up the URL and loads the SPA })
const html = `<!DOCTYPE html> let cacheMaxAge = 86400
<html lang="en">
<head> if (!meta) {
<meta charset="UTF-8"> // Cache miss: fetch from relays (let it use its natural timeouts)
<link rel="icon" type="image/x-icon" href="/favicon.ico"> try {
<meta name="viewport" content="width=device-width, initial-scale=1"> meta = await fetchArticleMetadataViaRelays(naddr)
<title>Boris - Loading Article...</title>
<script> if (meta) {
// Set the URL to the article path before SPA loads // Store in Redis and use it
if (window.location.pathname !== '${articlePath}') { await setArticleMeta(naddr, meta).catch((err) => {
history.replaceState(null, '', '${articlePath}'); 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 // Generate and send HTML
const now = Date.now() const html = generateHtml(naddr, meta)
const cached = memoryCache.get(naddr) setCacheHeaders(res, cacheMaxAge)
if (cached && cached.expires > now) {
setCacheHeaders(res) if (debugEnabled) {
if (debugEnabled) { // Debug mode enabled
// 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)
} }
return res.status(200).send(html)
} }

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

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

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

@@ -0,0 +1,80 @@
import type { ArticleMetadata } from './ogStore.js'
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
export function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
const baseUrl = 'https://read.withboris.com'
const articleUrl = `${baseUrl}/a/${naddr}`
const title = meta?.title || 'Boris Read, Highlight, Explore'
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
const author = meta?.author || 'Boris'
const imageAlt = meta?.imageAlt || title
// Generate article:tag meta tags
const articleTags = meta?.tags && meta.tags.length > 0
? meta.tags.map((tag) => ` <meta property="article:tag" content="${escapeHtml(tag)}" />`).join('\n')
: ''
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f172a" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeHtml(description)}" />
<link rel="canonical" href="${articleUrl}" />
<!-- Open Graph / Social Media -->
<meta property="og:type" content="article" />
<meta property="og:url" content="${articleUrl}" />
<meta property="og:title" content="${escapeHtml(title)}" />
<meta property="og:description" content="${escapeHtml(description)}" />
<meta property="og:image" content="${escapeHtml(image)}" />
<meta property="og:image:alt" content="${escapeHtml(imageAlt)}" />
<meta property="og:site_name" content="Boris" />
${meta?.published ? ` <meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
<meta property="article:author" content="${escapeHtml(author)}" />
${articleTags}
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="${articleUrl}" />
<meta name="twitter:title" content="${escapeHtml(title)}" />
<meta name="twitter:description" content="${escapeHtml(description)}" />
<meta name="twitter:image" content="${escapeHtml(image)}" />
</head>
<body>
<noscript>
<p>Redirecting to <a href="/a/${naddr}">Boris</a>...</p>
</noscript>
<script>
(function(){
try {
var p = '/a/${naddr}';
if (window.location.pathname !== p) {
history.replaceState(null, '', p);
}
var sep = window.location.search ? '&' : '?';
window.location.replace(p + sep + '_spa=1');
} catch (e) {}
})();
</script>
</body>
</html>`
}

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

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

View File

@@ -27,7 +27,10 @@
<meta name="twitter:title" content="Boris - Read, Highlight, Explore" /> <meta name="twitter:title" content="Boris - Read, Highlight, Explore" />
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." /> <meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" /> <meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
<!-- Fathom -->
<script src="https://cdn.usefathom.com/script.js" data-site="LLSGRVAP" defer></script>
<!-- Default to system theme until settings load from Nostr --> <!-- Default to system theme until settings load from Nostr -->
<script> <script>
document.documentElement.className = 'theme-system'; document.documentElement.className = 'theme-system';

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", "name": "boris",
"version": "0.10.23", "version": "0.10.33",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "boris", "name": "boris",
"version": "0.10.23", "version": "0.10.33",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.0.2", "@fortawesome/react-fontawesome": "^3.0.2",
"@treeee/youtube-caption-extractor": "^1.5.5", "@treeee/youtube-caption-extractor": "^1.5.5",
"@upstash/redis": "^1.35.6",
"@vercel/node": "^5.3.26", "@vercel/node": "^5.3.26",
"applesauce-accounts": "^4.0.0", "applesauce-accounts": "^4.0.0",
"applesauce-content": "^4.0.0", "applesauce-content": "^4.0.0",
@@ -37,12 +38,14 @@
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tinyld": "^1.3.4", "tinyld": "^1.3.4",
"use-pull-to-refresh": "^2.4.1" "use-pull-to-refresh": "^2.4.1",
"ws": "^8.18.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.14", "@tailwindcss/postcss": "^4.1.14",
"@types/react": "^18.2.43", "@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17", "@types/react-dom": "^18.2.17",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0", "@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
@@ -56,6 +59,9 @@
"vite": "^5.0.8", "vite": "^5.0.8",
"vite-plugin-pwa": "^1.0.3", "vite-plugin-pwa": "^1.0.3",
"workbox-window": "^7.3.0" "workbox-window": "^7.3.0"
},
"engines": {
"node": "22.x"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@@ -102,6 +108,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3", "@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", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==", "integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0" "@fortawesome/fontawesome-common-types": "7.1.0"
}, },
@@ -3553,6 +3561,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.0.2" "csstype": "^3.0.2"
@@ -3595,6 +3604,16 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT" "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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
@@ -3637,6 +3656,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0", "@typescript-eslint/types": "6.21.0",
@@ -3799,6 +3819,15 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC" "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": { "node_modules/@vercel/build-utils": {
"version": "12.1.2", "version": "12.1.2",
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-12.1.2.tgz", "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-12.1.2.tgz",
@@ -3927,7 +3956,8 @@
"version": "16.18.11", "version": "16.18.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.11.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.11.tgz",
"integrity": "sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==", "integrity": "sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@vercel/node/node_modules/esbuild": { "node_modules/@vercel/node/node_modules/esbuild": {
"version": "0.14.47", "version": "0.14.47",
@@ -4012,6 +4042,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -4088,6 +4119,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -4640,6 +4672,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.9", "baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746", "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.", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@@ -9711,6 +9745,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -9857,6 +9892,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -9869,6 +9905,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -10434,6 +10471,7 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
@@ -11189,6 +11227,7 @@
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/source-map": "^0.3.3", "@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0", "acorn": "^8.15.0",
@@ -11265,6 +11304,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -11517,6 +11557,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -11544,6 +11585,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/undici": {
"version": "5.28.4", "version": "5.28.4",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
@@ -11560,8 +11607,7 @@
"version": "7.14.0", "version": "7.14.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/unicode-canonical-property-names-ecmascript": { "node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1", "version": "2.0.1",
@@ -11842,6 +11888,7 @@
"integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
@@ -12227,6 +12274,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -12271,6 +12319,7 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"rollup": "dist/bin/rollup" "rollup": "dist/bin/rollup"
}, },
@@ -12519,6 +12568,27 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -1,14 +1,18 @@
{ {
"name": "boris", "name": "boris",
"version": "0.10.33", "version": "0.11.1",
"description": "A minimal nostr client for bookmark management", "description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/", "homepage": "https://read.withboris.com/",
"type": "module", "type": "module",
"engines": {
"node": "22.x"
},
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "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": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/fontawesome-svg-core": "^7.1.0",
@@ -16,6 +20,7 @@
"@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.0.2", "@fortawesome/react-fontawesome": "^3.0.2",
"@treeee/youtube-caption-extractor": "^1.5.5", "@treeee/youtube-caption-extractor": "^1.5.5",
"@upstash/redis": "^1.35.6",
"@vercel/node": "^5.3.26", "@vercel/node": "^5.3.26",
"applesauce-accounts": "^4.0.0", "applesauce-accounts": "^4.0.0",
"applesauce-content": "^4.0.0", "applesauce-content": "^4.0.0",
@@ -40,12 +45,14 @@
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tinyld": "^1.3.4", "tinyld": "^1.3.4",
"use-pull-to-refresh": "^2.4.1" "use-pull-to-refresh": "^2.4.1",
"ws": "^8.18.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.14", "@tailwindcss/postcss": "^4.1.14",
"@types/react": "^18.2.43", "@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17", "@types/react-dom": "^18.2.17",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0", "@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
@@ -97,6 +104,15 @@
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
"prefer-const": "error", "prefer-const": "error",
"no-var": "error" "no-var": "error"
} },
"overrides": [
{
"files": ["api/**/*.ts"],
"env": {
"node": true,
"browser": false
}
}
]
} }
} }

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 // Handle user relay list and blocked relays when account changes
const userRelaysSub = accounts.active$.subscribe((account) => { const userRelaysSub = accounts.active$.subscribe((account) => {
if (account) { if (account) {
@@ -604,20 +629,6 @@ function App() {
// Apply initial set immediately // Apply initial set immediately
applyRelaySetToPool(pool, initialRelays) 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 // Begin loading blocked relays in background
const blockedPromise = loadBlockedRelays(pool, pubkey) const blockedPromise = loadBlockedRelays(pool, pubkey)
@@ -649,43 +660,16 @@ function App() {
applyRelaySetToPool(pool, finalRelays) applyRelaySetToPool(pool, finalRelays)
updateKeepAlive() updateKeepAlive()
updateAddressLoader()
// Update address loader with new relays
const activeRelays = getActiveRelayUrls(pool)
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: activeRelays
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
}).catch((error) => { }).catch((error) => {
console.error('[relay-init] Failed to load user relay list (continuing with initial set):', 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 // Continue with initial relay set on error - no need to change anything
}) })
} else { } else {
// User logged out - reset to hardcoded relays // User logged out - reset to hardcoded relays
applyRelaySetToPool(pool, RELAYS) applyRelaySetToPool(pool, RELAYS)
updateKeepAlive(RELAYS)
updateAddressLoader(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
} }
}) })
@@ -755,6 +739,16 @@ function App() {
} }
}, [showToast]) }, [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) { if (!eventStore || !accountManager || !relayPool) {
return ( return (
<div className="loading"> <div className="loading">

View File

@@ -53,6 +53,10 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
// Reading progress display // 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 ( return (
<Link <Link
to={href} to={href}
@@ -62,7 +66,9 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
image: post.image, image: post.image,
summary: post.summary, summary: post.summary,
published: post.published published: post.published
} },
articleCoordinate,
eventId: post.event.id
}} }}
className={`blog-post-card ${level ? `level-${level}` : ''}`} className={`blog-post-card ${level ? `level-${level}` : ''}`}
style={{ textDecoration: 'none', color: 'inherit' }} style={{ textDecoration: 'none', color: 'inherit' }}

View File

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

View File

@@ -12,6 +12,8 @@ import { nip19 } from 'nostr-tools'
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways' import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { getActiveRelayUrls } from '../services/relayManager' import { getActiveRelayUrls } from '../services/relayManager'
import { isContentRelay } from '../config/relays'
import { isLocalRelay } from '../utils/helpers'
import { IAccount } from 'applesauce-accounts' import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
@@ -432,9 +434,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || '' const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : [] const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayHints = activeRelays.filter(r => const relayHints = activeRelays
!r.includes('localhost') && !r.includes('127.0.0.1') .filter(url => !isLocalRelay(url))
).slice(0, 3) .filter(url => isContentRelay(url))
.slice(0, 3)
const naddr = nip19.naddrEncode({ const naddr = nip19.naddrEncode({
kind: 30023, kind: 30023,

View File

@@ -595,7 +595,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
case 'highlights': case 'highlights':
if (showSkeletons) { if (showSkeletons) {
return ( return (
<div className="explore-grid"> <div className="explore-grid single-column">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
<HighlightSkeleton key={i} /> <HighlightSkeleton key={i} />
))} ))}
@@ -607,7 +607,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
<span>No highlights to show for the selected scope.</span> <span>No highlights to show for the selected scope.</span>
</div> </div>
) : ( ) : (
<div className="explore-grid"> <div className="explore-grid single-column">
{classifiedHighlights.map((highlight) => ( {classifiedHighlights.map((highlight) => (
<HighlightItem <HighlightItem
key={highlight.id} key={highlight.id}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 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 { faComments } from '@fortawesome/free-regular-svg-icons'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { useEventModel } from 'applesauce-react/hooks' import { useEventModel } from 'applesauce-react/hooks'
@@ -10,6 +10,7 @@ import { Hooks } from 'applesauce-react'
import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService' import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService'
import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers' import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers'
import { getActiveRelayUrls } from '../services/relayManager' import { getActiveRelayUrls } from '../services/relayManager'
import { isContentRelay, getContentRelays, getFallbackContentRelays } from '../config/relays'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils' import { formatDateCompact } from '../utils/bookmarkUtils'
import { createDeletionRequest } from '../services/deletionService' import { createDeletionRequest } from '../services/deletionService'
@@ -179,14 +180,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
} }
}, [showMenu, showDeleteConfirm]) }, [showMenu, showDeleteConfirm])
const handleItemClick = () => { // Navigate to the article that this highlight references and scroll to the highlight
// If onHighlightClick is provided, use it (legacy behavior) const navigateToArticle = () => {
if (onHighlightClick) { // Always try to navigate if we have a reference - quote button should always work
onHighlightClick(highlight.id)
return
}
// Otherwise, navigate to the article that this highlight references
if (highlight.eventReference) { if (highlight.eventReference) {
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier) // Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
const parts = highlight.eventReference.split(':') const parts = highlight.eventReference.split(':')
@@ -209,9 +205,14 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
openHighlights: true 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 to external URL with highlight ID to trigger scroll
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, { navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
state: { state: {
@@ -219,16 +220,57 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
openHighlights: true 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 = () => { const getHighlightLinks = () => {
// Encode the highlight event itself (kind 9802) as a nevent // Encode the highlight event itself (kind 9802) as a nevent
// Get non-local relays for the hint // Relay hint selection priority:
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : [] // 1. Published relays (where we successfully published the event)
const relayHints = activeRelays.filter(r => // 2. Seen relays (where we observed the event)
!r.includes('localhost') && !r.includes('127.0.0.1') // 3. Configured content relays (deterministic fallback)
).slice(0, 3) // Include up to 3 relay hints // 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({ const nevent = nip19.neventEncode({
id: highlight.id, id: highlight.id,
@@ -434,6 +476,71 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
handleConfirmDelete() 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()
}
}
const renderHighlightText = () => {
const { content, context } = highlight
if (context && context.length > 0) {
const index = context.indexOf(content)
if (index >= 0) {
const before = context.slice(0, index)
const after = context.slice(index + content.length)
return (
<>
{before}
<span className="highlight-core">{content}</span>
{after}
</>
)
}
// Fallback: show context and the core highlight separately
return (
<>
<span className="highlight-context-prefix">{context}</span>
<br />
<span className="highlight-core">{content}</span>
</>
)
}
return <span className="highlight-core">{content}</span>
}
return ( return (
<> <>
<div <div
@@ -483,15 +590,37 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
<CompactButton <CompactButton
className="highlight-quote-button" className="highlight-quote-button"
icon={faQuoteLeft} icon={faQuoteLeft}
title="Quote" title="Go to quote in article"
onClick={(e) => e.stopPropagation()} onClick={(e) => {
e.stopPropagation()
e.preventDefault()
if (onHighlightClick) {
onHighlightClick(highlight.id)
} else {
navigateToArticle()
}
}}
/> />
{/* relay indicator lives in footer for consistent padding/alignment */} {/* relay indicator lives in footer for consistent padding/alignment */}
<div className="highlight-content"> <div className="highlight-content">
<blockquote className="highlight-text"> <blockquote
{highlight.content} className="highlight-text"
onClick={(e) => {
e.stopPropagation()
if (onHighlightClick) {
onHighlightClick(highlight.id)
} else {
navigateToArticle()
}
}}
style={{ cursor: 'pointer' }}
title="Go to quote in article"
>
{renderHighlightText()}
</blockquote> </blockquote>
{showCitation && ( {showCitation && (
@@ -524,9 +653,13 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
/> />
)} )}
<span className="highlight-author"> <CompactButton
className="highlight-author"
onClick={handleAuthorClick}
title="View profile"
>
{getUserDisplayName()} {getUserDisplayName()}
</span> </CompactButton>
</div> </div>
<div className="highlight-menu-wrapper" ref={menuRef}> <div className="highlight-menu-wrapper" ref={menuRef}>
@@ -565,6 +698,20 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
{showMenu && ( {showMenu && (
<div className="highlight-menu"> <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 <button
className="highlight-menu-item" className="highlight-menu-item"
onClick={handleOpenPortal} onClick={handleOpenPortal}

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 { 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 { IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
@@ -9,6 +9,7 @@ import { HighlightItem } from './HighlightItem'
import { BlogPostPreview } from '../services/exploreService' import { BlogPostPreview } from '../services/exploreService'
import { KINDS } from '../config/kinds' import { KINDS } from '../config/kinds'
import AuthorCard from './AuthorCard' import AuthorCard from './AuthorCard'
import CompactButton from './CompactButton'
import BlogPostCard from './BlogPostCard' import BlogPostCard from './BlogPostCard'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons' import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { useStoreTimeline } from '../hooks/useStoreTimeline' import { useStoreTimeline } from '../hooks/useStoreTimeline'
@@ -20,6 +21,7 @@ import { Hooks } from 'applesauce-react'
import { readingProgressController } from '../services/readingProgressController' import { readingProgressController } from '../services/readingProgressController'
import { writingsController } from '../services/writingsController' import { writingsController } from '../services/writingsController'
import { highlightsController } from '../services/highlightsController' import { highlightsController } from '../services/highlightsController'
import { getProfileUrl } from '../config/nostrGateways'
interface ProfileProps { interface ProfileProps {
relayPool: RelayPool relayPool: RelayPool
@@ -38,6 +40,8 @@ const Profile: React.FC<ProfileProps> = ({
const activeAccount = Hooks.useActiveAccount() const activeAccount = Hooks.useActiveAccount()
const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights') const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights')
const [refreshTrigger, setRefreshTrigger] = useState(0) const [refreshTrigger, setRefreshTrigger] = useState(0)
const [showProfileMenu, setShowProfileMenu] = useState(false)
const profileMenuRef = useRef<HTMLDivElement>(null)
// Reading progress state (naddr -> progress 0-1) // Reading progress state (naddr -> progress 0-1)
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map()) const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
@@ -168,6 +172,68 @@ const Profile: React.FC<ProfileProps> = ({
const npub = nip19.npubEncode(pubkey) const npub = nip19.npubEncode(pubkey)
const showSkeletons = cachedHighlights.length === 0 && sortedWritings.length === 0 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 = () => { const renderTabContent = () => {
switch (activeTab) { switch (activeTab) {
case 'highlights': case 'highlights':
@@ -236,7 +302,51 @@ const Profile: React.FC<ProfileProps> = ({
pullPosition={pullPosition} pullPosition={pullPosition}
/> />
<div className="explore-header"> <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"> <div className="me-tabs">
<button <button

View File

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

View File

@@ -5,7 +5,7 @@ import IconButton from '../IconButton'
import ColorPicker from '../ColorPicker' import ColorPicker from '../ColorPicker'
import FontSelector from '../FontSelector' import FontSelector from '../FontSelector'
import { getFontFamily } from '../../utils/fontLoader' import { getFontFamily } from '../../utils/fontLoader'
import { hexToRgb } from '../../utils/colorHelpers' import { hexToRgb, LINK_COLORS_DARK, LINK_COLORS_LIGHT } from '../../utils/colorHelpers'
interface ReadingDisplaySettingsProps { interface ReadingDisplaySettingsProps {
settings: UserSettings settings: UserSettings
@@ -14,6 +14,23 @@ interface ReadingDisplaySettingsProps {
const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ settings, onUpdate }) => { const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ settings, onUpdate }) => {
const previewFontFamily = getFontFamily(settings.readingFont || 'source-serif-4') 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 ( return (
<div className="settings-section"> <div className="settings-section">
@@ -109,6 +126,17 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
</div> </div>
</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"> <div className="setting-group setting-inline">
<label className="setting-label">Font Size</label> <label className="setting-label">Font Size</label>
<div className="setting-control"> <div className="setting-control">
@@ -179,14 +207,16 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
fontFamily: previewFontFamily, fontFamily: previewFontFamily,
fontSize: `${settings.fontSize || 21}px`, fontSize: `${settings.fontSize || 21}px`,
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00'), '--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} } as React.CSSProperties}
> >
<h3>The Quick Brown Fox</h3> <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>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>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>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>
<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>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
* Nostr gateway URLs for viewing events and profiles on the web * 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 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) * Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
*/ */
export function getNostrUrl(identifier: string): string { 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}` return `${NOSTR_GATEWAY}/${identifier}`
} }

View File

@@ -1,21 +1,101 @@
import { normalizeRelayUrl } from '../utils/helpers'
/** /**
* Centralized relay configuration * Centralized relay configuration
* Single set of relays used throughout the application * Single set of relays used throughout the application
*/ */
// All relays including local relays export type RelayRole = 'local-cache' | 'default' | 'fallback' | 'non-content' | 'bunker'
export const RELAYS = [
'ws://localhost:10547', export interface RelayConfig {
'ws://localhost:4869', url: string
'wss://relay.nsec.app', roles: RelayRole[]
'wss://relay.damus.io', }
'wss://nos.lol',
'wss://relay.nostr.band', /**
'wss://wot.dergigi.com', * Central relay registry with role annotations
'wss://relay.snort.social', */
'wss://nostr-pub.wellorder.net', const RELAY_CONFIGS: RelayConfig[] = [
'wss://purplepag.es', { url: 'ws://localhost:10547', roles: ['local-cache'] },
'wss://relay.primal.net', { url: 'ws://localhost:4869', roles: ['local-cache'] },
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87', { 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

@@ -22,6 +22,12 @@ interface PreviewData {
published?: number published?: number
} }
interface NavigationState {
previewData?: PreviewData
articleCoordinate?: string
eventId?: string
}
interface UseArticleLoaderProps { interface UseArticleLoaderProps {
naddr: string | undefined naddr: string | undefined
relayPool: RelayPool | null relayPool: RelayPool | null
@@ -63,8 +69,11 @@ export function useArticleLoader({
// Track in-flight request to prevent stale updates from previous naddr // Track in-flight request to prevent stale updates from previous naddr
const currentRequestIdRef = useRef(0) const currentRequestIdRef = useRef(0)
// Extract preview data from navigation state (from blog post cards) // Extract navigation state (from blog post cards)
const previewData = (location.state as { previewData?: PreviewData })?.previewData 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 // Track the current article title for document title
const [currentTitle, setCurrentTitle] = useState<string | undefined>() const [currentTitle, setCurrentTitle] = useState<string | undefined>()
@@ -83,6 +92,179 @@ export function useArticleLoader({
// This ensures images from previous articles don't flash briefly // This ensures images from previous articles don't flash briefly
setReaderContent(undefined) 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 // Synchronously check cache sources BEFORE checking relayPool
// This prevents showing loading skeletons when content is immediately available // This prevents showing loading skeletons when content is immediately available
// and fixes the race condition where relayPool isn't ready yet // and fixes the race condition where relayPool isn't ready yet
@@ -173,7 +355,7 @@ export function useArticleLoader({
// Check EventStore synchronously (also doesn't need relayPool) // Check EventStore synchronously (also doesn't need relayPool)
let foundInEventStore = false let foundInEventStore = false
if (eventStore && !foundInCache) { if (eventStore && !foundInCache && !foundInNavState) {
try { try {
// Decode naddr to get the coordinate // Decode naddr to get the coordinate
const decoded = nip19.decode(naddr) const decoded = nip19.decode(naddr)
@@ -251,7 +433,7 @@ export function useArticleLoader({
} }
// Only return early if we have no content AND no relayPool to fetch from // Only return early if we have no content AND no relayPool to fetch from
if (!relayPool && !foundInCache && !foundInEventStore) { if (!relayPool && !foundInCache && !foundInEventStore && !foundInNavState) {
setReaderLoading(true) setReaderLoading(true)
setReaderContent(undefined) setReaderContent(undefined)
return return

View File

@@ -68,6 +68,12 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316') root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea') 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 // Set paragraph alignment
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify') root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')

View File

@@ -4,7 +4,7 @@ import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19' import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core' 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 { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
import { merge, toArray as rxToArray } from 'rxjs' import { merge, toArray as rxToArray } from 'rxjs'
import { UserSettings } from './settingsService' import { UserSettings } from './settingsService'
@@ -138,13 +138,6 @@ export async function fetchArticleByNaddr(
const pointer = decoded.data as AddressPointer 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 // Fetch the article event
const filter = { const filter = {
kinds: [pointer.kind], kinds: [pointer.kind],
@@ -152,24 +145,45 @@ export async function fetchArticleByNaddr(
'#d': [pointer.identifier] '#d': [pointer.identifier]
} }
// Parallel local+remote, stream immediate, collect up to first from each let events: NostrEvent[] = []
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[]
// 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) { if (events.length === 0) {
const reliableRelays = Array.from(new Set<string>([ const fallbackRelays = getFallbackContentRelays()
'wss://relay.nostr.band',
'wss://relay.primal.net',
'wss://relay.damus.io',
'wss://nos.lol',
...remoteRelays // keep any configured remote relays
]))
const { remote$: fallback$ } = createParallelReqStreams( const { remote$: fallback$ } = createParallelReqStreams(
relayPool, relayPool,
[], // no local [], // no local for fallback
reliableRelays, fallbackRelays,
filter, filter,
1500, 1500,
12000 12000

View File

@@ -1,6 +1,7 @@
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch' import { queryEvents } from './dataFetch'
import { normalizeRelayUrl } from '../utils/helpers'
export interface UserRelayInfo { export interface UserRelayInfo {
url: string url: string
@@ -144,35 +145,55 @@ export function computeRelaySet(params: {
alwaysIncludeLocal alwaysIncludeLocal
} = params } = 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 relaySet = new Set<string>()
const blockedSet = new Set(blocked) const normalizedRelaySet = new Set<string>()
// Helper to check if relay should be included // Helper to check if relay should be included (using normalized URLs)
const shouldInclude = (url: string): boolean => { const shouldInclude = (normalizedUrl: string): boolean => {
// Always include local relays // Always include local relays
if (alwaysIncludeLocal.includes(url)) return true if (normalizedLocal.has(normalizedUrl)) return true
// Otherwise check if blocked // Otherwise check if blocked
return !blockedSet.has(url) return !normalizedBlocked.has(normalizedUrl)
} }
// Add hardcoded relays // Add hardcoded relays (normalized)
for (const url of hardcoded) { 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) { 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) { 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) { 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) return Array.from(relaySet)

View File

@@ -1,20 +1,17 @@
import { RelayPool } from 'applesauce-relay' 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 * Local relays that are always included
*/ */
export const ALWAYS_LOCAL_RELAYS = [ export const ALWAYS_LOCAL_RELAYS = getLocalRelays()
'ws://localhost:10547',
'ws://localhost:4869'
]
/** /**
* 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 = [ export const HARDCODED_RELAYS = getFallbackContentRelays()
'wss://relay.nostr.band'
]
/** /**
* Gets active relay URLs from the relay pool * Gets active relay URLs from the relay pool
@@ -24,76 +21,84 @@ export function getActiveRelayUrls(relayPool: RelayPool): string[] {
return prioritizeLocalRelays(urls) return prioritizeLocalRelays(urls)
} }
/** export interface RelaySetChangeSummary {
* Normalizes a relay URL to match what applesauce-relay stores internally added: string[]
* Adds trailing slash for URLs without a path removed: string[]
*/
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
}
} }
/** /**
* Applies a new relay set to the pool: adds missing relays, removes extras * 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( export function applyRelaySetToPool(
relayPool: RelayPool, relayPool: RelayPool,
finalUrls: string[] finalUrls: string[],
): void { options?: { preserveAlwaysLocal?: boolean }
// Normalize all URLs to match pool's internal format ): RelaySetChangeSummary {
const currentUrls = new Set(Array.from(relayPool.relays.keys())) const preserveLocal = options?.preserveAlwaysLocal !== false // default true
const normalizedTargetUrls = new Set(finalUrls.map(normalizeRelayUrl))
// Ensure local relays are always included
// Add new relays (use original URLs for adding, not normalized) const urlsWithLocal = preserveLocal
const toAdd = finalUrls.filter(url => !currentUrls.has(normalizeRelayUrl(url))) ? Array.from(new Set([...finalUrls, ...ALWAYS_LOCAL_RELAYS]))
: finalUrls
if (toAdd.length > 0) { // Normalize all URLs consistently for comparison
relayPool.group(toAdd) 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[] = [] const toRemove: string[] = []
for (const url of currentUrls) { for (const currentUrl of relayPool.relays.keys()) {
// Check if this normalized URL is in the target set const normalizedCurrentUrl = normalizeRelayUrl(currentUrl)
if (!normalizedTargetUrls.has(url)) { if (!normalizedTarget.has(normalizedCurrentUrl)) {
// Also check if it's a local relay (check both normalized and original forms) // Always preserve local relays
const isLocal = ALWAYS_LOCAL_RELAYS.some(localUrl => if (!preserveLocal || !normalizedLocal.has(normalizedCurrentUrl)) {
normalizeRelayUrl(localUrl) === url || localUrl === url toRemove.push(currentUrl)
)
if (!isLocal) {
toRemove.push(url)
} }
} }
} }
// Apply changes
if (toAdd.length > 0) {
relayPool.group(toAdd)
}
for (const url of toRemove) { for (const url of toRemove) {
const relay = relayPool.relays.get(url) const relay = relayPool.relays.get(url)
if (relay) { if (relay) {
try { try {
// Only close if relay is actually connected or attempting to connect
// This helps avoid WebSocket warnings for connections that never started
relay.close() relay.close()
} catch (error) { } catch (error) {
// Suppress errors when closing relays that haven't fully connected yet // Suppress errors when closing relays that haven't fully connected yet
// This can happen when switching relay sets before connections establish
// Silently ignore
} }
relayPool.relays.delete(url) 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. ttsLanguageMode?: 'system' | 'content' | string // default: 'content', can also be language code like 'en', 'es', etc.
// Text-to-Speech settings // Text-to-Speech settings
ttsDefaultSpeed?: number // default: 2.1 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-text-muted: #71717a; /* zinc-500 */
--color-primary: #6366f1; /* indigo-500 */ --color-primary: #6366f1; /* indigo-500 */
--color-primary-hover: #4f46e5; /* indigo-600 */ --color-primary-hover: #4f46e5; /* indigo-600 */
--color-link: var(--color-link-dark, #38bdf8); /* sky-400 */
} }
/* Light theme */ /* Light theme */
@@ -73,6 +74,7 @@
--color-text-muted: #6b7280; /* gray-500 */ --color-text-muted: #6b7280; /* gray-500 */
--color-primary: #4f46e5; /* indigo-600 */ --color-primary: #4f46e5; /* indigo-600 */
--color-primary-hover: #4338ca; /* indigo-700 */ --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 colors for light theme - use same Tailwind colors */
--highlight-color-mine: #fde047; /* yellow-300 */ --highlight-color-mine: #fde047; /* yellow-300 */
@@ -97,6 +99,7 @@
--color-text-muted: #71717a; --color-text-muted: #71717a;
--color-primary: #6366f1; --color-primary: #6366f1;
--color-primary-hover: #4f46e5; --color-primary-hover: #4f46e5;
--color-link: var(--color-link-dark, #38bdf8);
} }
} }
@@ -112,6 +115,7 @@
--color-text-muted: #6b7280; --color-text-muted: #6b7280;
--color-primary: #4f46e5; --color-primary: #4f46e5;
--color-primary-hover: #4338ca; --color-primary-hover: #4338ca;
--color-link: var(--color-link-light, #3b82f6);
/* Standard highlight colors */ /* Standard highlight colors */
--highlight-color-mine: #fde047; --highlight-color-mine: #fde047;

View File

@@ -263,7 +263,12 @@
.large-read-button:hover { background: var(--color-primary-hover); } .large-read-button:hover { background: var(--color-primary-hover); }
/* Blog cards (Explore) */ /* Blog cards (Explore) */
.explore-container { padding: 2rem; max-width: 1400px; margin: 0 auto; min-height: 100vh; } .explore-container {
padding: 2rem;
max-width: var(--main-max-width);
margin: 0 auto;
min-height: 100vh;
}
.explore-header { text-align: center; margin-bottom: 3rem; } .explore-header { text-align: center; margin-bottom: 3rem; }
.explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: var(--color-primary); display: flex; align-items: center; justify-content: center; gap: 1rem; } .explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: var(--color-primary); display: flex; align-items: center; justify-content: center; gap: 1rem; }
.explore-subtitle { font-size: 1.125rem; color: var(--color-text-secondary); margin: 0; } .explore-subtitle { font-size: 1.125rem; color: var(--color-text-secondary); margin: 0; }
@@ -272,7 +277,15 @@
.explore-loading { min-height: 0; padding: 0.25rem 0; } .explore-loading { min-height: 0; padding: 0.25rem 0; }
.explore-error { color: rgb(239 68 68); /* red-500 */ } .explore-error { color: rgb(239 68 68); /* red-500 */ }
.explore-empty { color: var(--color-text-secondary); } .explore-empty { color: var(--color-text-secondary); }
.explore-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 2rem; margin-top: 2rem; } .explore-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.explore-grid.single-column {
grid-template-columns: 1fr;
}
.blog-post-card { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; overflow: hidden; transition: all 0.3s ease; cursor: pointer; display: flex; flex-direction: column; height: 100%; } .blog-post-card { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; overflow: hidden; transition: all 0.3s ease; cursor: pointer; display: flex; flex-direction: column; height: 100%; }
.blog-post-card:hover { border-color: var(--color-primary); transform: translateY(-4px); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); } .blog-post-card:hover { border-color: var(--color-primary); transform: translateY(-4px); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); }
.blog-post-card.level-mine { border-color: color-mix(in srgb, var(--highlight-color-mine, #fde047) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #fde047) 25%, transparent); } .blog-post-card.level-mine { border-color: color-mix(in srgb, var(--highlight-color-mine, #fde047) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #fde047) 25%, transparent); }

View File

@@ -43,6 +43,13 @@
word-wrap: break-word; word-wrap: break-word;
text-align: var(--paragraph-alignment, justify); 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-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-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
.setting-select:focus { outline: none; border-color: var(--color-primary); } .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 */ /* Align highlight list width with profile card width on /my */
.me-highlights-list { padding-left: 0; padding-right: 0; } .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 */ /* Hide tab labels on mobile to save space */
@media (max-width: 768px) { @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-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; } .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) { @media (max-width: 768px) {
.author-card-container { .author-card-container {
padding: 1.5rem 1rem; padding: 1.5rem 1rem;
@@ -26,5 +91,13 @@
.author-card-avatar svg { font-size: 2rem; } .author-card-avatar svg { font-size: 2rem; }
.author-card-name { font-size: 0.95rem; } .author-card-name { font-size: 0.95rem; }
.author-card-bio { font-size: 0.85rem; -webkit-line-clamp: 2; } .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

@@ -71,7 +71,7 @@
font-size: 2.25rem; /* text-4xl */ font-size: 2.25rem; /* text-4xl */
font-weight: 700; font-weight: 700;
line-height: 1.2; line-height: 1.2;
margin-top: 2rem; margin-top: 5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
color: var(--color-text); color: var(--color-text);
} }
@@ -79,7 +79,7 @@
font-size: 1.875rem; /* text-3xl */ font-size: 1.875rem; /* text-3xl */
font-weight: 600; font-weight: 600;
line-height: 1.3; line-height: 1.3;
margin-top: 1.75rem; margin-top: 4.5rem;
margin-bottom: 0.875rem; margin-bottom: 0.875rem;
color: var(--color-text); color: var(--color-text);
} }
@@ -87,7 +87,7 @@
font-size: 1.5rem; /* text-2xl */ font-size: 1.5rem; /* text-2xl */
font-weight: 600; font-weight: 600;
line-height: 1.4; line-height: 1.4;
margin-top: 1.5rem; margin-top: 4rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
color: var(--color-text); color: var(--color-text);
} }
@@ -95,7 +95,7 @@
font-size: 1.25rem; /* text-xl */ font-size: 1.25rem; /* text-xl */
font-weight: 600; font-weight: 600;
line-height: 1.4; line-height: 1.4;
margin-top: 1.25rem; margin-top: 3.5rem;
margin-bottom: 0.625rem; margin-bottom: 0.625rem;
color: var(--color-text); color: var(--color-text);
} }
@@ -103,7 +103,7 @@
font-size: 1.125rem; /* text-lg */ font-size: 1.125rem; /* text-lg */
font-weight: 600; font-weight: 600;
line-height: 1.4; line-height: 1.4;
margin-top: 1rem; margin-top: 3rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: var(--color-text); color: var(--color-text);
} }
@@ -111,12 +111,13 @@
font-size: 1rem; /* text-base */ font-size: 1rem; /* text-base */
font-weight: 600; font-weight: 600;
line-height: 1.4; line-height: 1.4;
margin-top: 1rem; margin-top: 3rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: var(--color-text); color: var(--color-text);
} }
.reader-markdown p { margin: 0.5rem 0; } .reader-markdown p { margin: 1.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-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 */ /* Lists */
.reader-markdown ul, .reader-html ul { .reader-markdown ul, .reader-html ul {
list-style-type: disc; list-style-type: disc;
@@ -159,7 +160,7 @@
opacity: 0.69; opacity: 0.69;
margin: 2.5rem 0; 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 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 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; } .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; }
@@ -170,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 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 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; } .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 */ /* Mobile: prevent code blocks from causing horizontal overflow */
@media (max-width: 768px) { @media (max-width: 768px) {
.reader-markdown pre, .reader-html pre { .reader-markdown pre, .reader-html pre {
@@ -190,6 +234,12 @@
display: block; display: block;
max-width: 100%; max-width: 100%;
overflow-x: auto; 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 { .reader-markdown img, .reader-html img {

View File

@@ -102,7 +102,7 @@
.highlights-empty svg { color: var(--color-text-muted); margin-bottom: 0.5rem; } .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; } .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 { 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: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); } .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); }
@@ -149,7 +149,40 @@
.highlight-item.level-nostrverse .highlight-quote-icon { color: var(--highlight-color-nostrverse, #9333ea); } .highlight-item.level-nostrverse .highlight-quote-icon { color: var(--highlight-color-nostrverse, #9333ea); }
.highlight-content { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 2.25rem 0.75rem 2.5rem; } .highlight-content { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 2.25rem 0.75rem 2.5rem; }
.highlight-text { margin: 0; padding: 0 0 0 1.25rem; font-style: italic; color: var(--color-text); line-height: 1.6; border-left: none; font-size: 0.95rem; } .highlight-text {
margin: 0;
padding: 0 0 0 1.25rem;
font-style: italic;
color: var(--color-text);
line-height: 1.6;
border-left: none;
font-size: 0.95rem;
/* Aggressive wrapping for long words/URLs inside highlights */
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
hyphens: auto;
}
.highlight-core {
background: color-mix(in srgb, var(--highlight-color, #fde047) 35%, transparent);
padding: 0 0.1em;
border-radius: 3px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.highlight-item.level-mine .highlight-core {
background: color-mix(in srgb, var(--highlight-color-mine, #fde047) 40%, transparent);
}
.highlight-item.level-friends .highlight-core {
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 35%, transparent);
}
.highlight-item.level-nostrverse .highlight-core {
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 35%, transparent);
}
.highlight-context-prefix {
display: block;
margin-bottom: 0.35rem;
}
.highlight-citation { margin-left: 1.25rem; font-size: 0.8rem; color: var(--color-text-secondary); font-style: normal; padding-top: 0.25rem; } .highlight-citation { margin-left: 1.25rem; font-size: 0.8rem; color: var(--color-text-secondary); font-style: normal; padding-top: 0.25rem; }
.highlight-comment { margin-top: 0.5rem; padding: 0.75rem; border-radius: 4px; font-size: 0.875rem; color: var(--color-text); line-height: 1.5; display: flex; gap: 0.5rem; align-items: flex-start; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; min-width: 0; } .highlight-comment { margin-top: 0.5rem; padding: 0.75rem; border-radius: 4px; font-size: 0.875rem; color: var(--color-text); line-height: 1.5; display: flex; gap: 0.5rem; align-items: flex-start; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; min-width: 0; }
.highlight-comment-icon { flex-shrink: 0; margin-top: 0.125rem; } .highlight-comment-icon { flex-shrink: 0; margin-top: 0.125rem; }
@@ -177,7 +210,10 @@
padding: 0.25rem; /* CompactButton base */ padding: 0.25rem; /* CompactButton base */
} }
.highlight-menu-wrapper { position: relative; flex-shrink: 0; display: flex; align-items: center; } .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 { 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:hover { background: rgba(99, 102, 241, 0.15); color: var(--color-text); }
.highlight-menu-item:disabled { opacity: 0.5; cursor: not-allowed; } .highlight-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }

View File

@@ -15,3 +15,23 @@ export const HIGHLIGHT_COLORS = [
{ name: 'Blue', value: '#3b82f6' }, // blue-500 { name: 'Blue', value: '#3b82f6' }, // blue-500
{ name: 'Purple', value: '#9333ea' } // purple-600 { 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

@@ -39,6 +39,24 @@ export const classifyUrl = (url: string | undefined): UrlClassification => {
return { type: 'article' } 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) * Checks if a relay URL is a local relay (localhost or 127.0.0.1)
*/ */

View File

@@ -1,40 +1,2 @@
import { NostrEvent } from 'nostr-tools' export { extractProfileDisplayName } from '../../lib/profile'
import { getNpubFallbackDisplay } from './nostrUriResolver'
/**
* Extract display name from a profile event (kind:0) with consistent priority order
* Priority: name || display_name || nip05 || npub fallback
*
* @param profileEvent The profile event (kind:0) to extract name from
* @returns Display name string, or empty string if event is invalid
*/
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
}
// Consistent priority: name || display_name || nip05
if (profileData.name) return profileData.name
if (profileData.display_name) return profileData.display_name
if (profileData.nip05) return profileData.nip05
// Fallback to npub if no name fields
return getNpubFallbackDisplay(profileEvent.pubkey)
} catch (error) {
// If JSON parsing fails, use npub fallback
try {
return getNpubFallbackDisplay(profileEvent.pubkey)
} catch {
// If npub encoding also fails, return empty string
return ''
}
}
}

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": [ "rewrites": [
{ {
"source": "/a/:naddr", "source": "/a/:naddr",
"destination": "/index.html",
"has": [ "has": [
{ { "type": "query", "key": "_spa", "value": "1" }
"type": "header", ]
"key": "user-agent", },
"value": ".*(bot|crawl|spider|slurp|facebook|twitter|linkedin|whatsapp|telegram|slack|discord|preview).*" {
} "source": "/a/:naddr",
],
"destination": "/api/article-og?naddr=:naddr" "destination": "/api/article-og?naddr=:naddr"
}, },
{ {