Compare commits

..

163 Commits

Author SHA1 Message Date
Gigi
104332fd94 chore: bump version to 0.10.33 2025-11-05 23:08:14 +01:00
Gigi
e736c9f5b9 Merge pull request #36 from dergigi/mobile-highlighting
Fix mobile text selection detection for highlighting
2025-11-05 23:07:33 +01:00
Gigi
103e104cb2 chore: remove unused React import from VideoEmbedProcessor 2025-11-05 23:05:14 +01:00
Gigi
5389397e9b fix(mobile): use selectionchange event for immediate text selection detection
Replace mouseup/touchend handlers with selectionchange event listener
for more reliable mobile text selection detection. This fixes the issue
where the highlight button required an extra tap to become active on
mobile devices.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Now we check the localStorage cache immediately after EventStore,
before querying relays. This allows instant article loading from cache
on refresh without unnecessary relay queries.
2025-10-31 00:34:42 +01:00
Gigi
683ea27526 docs: update CHANGELOG for v0.10.26 2025-10-31 00:28:10 +01:00
Gigi
fa052483b2 chore: bump version to 0.10.26 2025-10-31 00:27:14 +01:00
Gigi
0ae9e6321e Merge pull request #29 from dergigi/flight-mode-shenanigans
Fix flight mode detection and persist highlight metadata
2025-10-31 00:26:08 +01:00
Gigi
5623f2e595 chore: remove debug console.log statements
- Remove debug console.log from AddBookmarkModal.tsx (modal fetch and description extraction)
- Remove console.debug from relayManager.ts (relay closing errors)
- Keep console.warn and console.error for legitimate error handling
2025-10-31 00:24:42 +01:00
Gigi
2c94c1e3f0 fix: remove unused variables to resolve lint errors
- Remove unused relayNames variable from HighlightItem.tsx
- Remove unused failedRelays variable from highlightCreationService.ts
- All linting and type checks now pass
2025-10-31 00:18:58 +01:00
Gigi
19dc2f70f2 feat: persist highlight metadata and offline events to localStorage
- Add localStorage persistence for highlightMetadataCache Map
- Add localStorage persistence for offlineCreatedEvents Set
- Load both caches from localStorage on module initialization
- Save to localStorage whenever caches are updated
- Update metadata cache during sync operations (isSyncing changes)
- Ensures airplane icon displays correctly after page reloads
- Gracefully handles localStorage errors and corrupted data
2025-10-31 00:17:13 +01:00
Gigi
5013ccc552 perf: remove excessive debug logging for better performance
- Remove debug logs from highlight creation, publishing, and UI rendering
- Keep only essential error logging
- Improves performance by reducing console spam
- Flight mode detection still works via fallback mechanisms
2025-10-31 00:12:04 +01:00
Gigi
29eed3395f fix: prioritize isLocalOnly check to show airplane icon
- Check isLocalOnly first before checking publishedRelays length
- Show airplane icon if isLocalOnly is true, even if publishedRelays is empty
- This ensures flight mode highlights show airplane icon via offline sync fallback
- Add debug logs to track cache storage and retrieval
- Fixes issue where airplane icon doesn't show when creating highlights offline
2025-10-31 00:09:09 +01:00
Gigi
d6da27c634 fix: resolve all linting errors and type issues
- Remove unused setShowOfflineIndicator calls
- Remove unused areAllRelaysLocal import
- Fix duplicate key 'failedRelays' in console.log
- Replace 'any' types with proper HighlightEvent interface
- Add eslint-disable comments for intentionally excluded useEffect dependencies
- All linting errors resolved, type checks pass
2025-10-31 00:04:24 +01:00
Gigi
5551b52bce fix: replace require() with ES module imports
- Add isEventOfflineCreated and isLocalRelay to imports
- Remove require() calls that don't work in ES modules
- Fixes ReferenceError: require is not defined
2025-10-31 00:02:29 +01:00
Gigi
af7eb48893 fix: add fallback logic for detecting flight mode highlights
- Add isEventOfflineCreated function to check offline sync service
- Use offline sync service as fallback if isLocalOnly is undefined
- Also check if publishedRelays only contains local relays
- This provides multiple fallback mechanisms to detect flight mode highlights
- Should finally fix the airplane icon not showing
2025-10-31 00:01:09 +01:00
Gigi
51ce79f13a fix: use metadata cache to preserve highlight properties across EventStore
- Create highlightMetadataCache to store isLocalOnly and publishedRelays
- Properties stored as __highlightProps are lost during EventStore serialization
- Cache persists across storage/retrieval cycles
- eventToHighlight now checks cache first before __highlightProps
- This should finally fix the airplane icon not showing for flight mode highlights
2025-10-30 23:56:53 +01:00
Gigi
bcfc04c35c debug: add logging to investigate tooltip showing all relays
- Add debug log to see what highlight data is available when rendering
- Check if publishedRelays or seenOnRelays are being used
- This will help identify why tooltip shows all relays instead of just published ones
2025-10-30 23:54:07 +01:00
Gigi
d6911b2acb fix: publish only to connected relays to avoid long timeouts
- Only publish to currently connected relays instead of all configured relays
- This prevents waiting for disconnected/offline relays to timeout
- Improves performance significantly in flight mode
- Handle case when no relays are connected
2025-10-30 23:53:44 +01:00
Gigi
2a869f11e0 fix: prevent duplicate highlights and remove excessive logging
- Add deduplication when adding highlights via onHighlightCreated
- Remove excessive UI logging that was causing performance issues
- Fixes duplicate highlight warning and improves render performance
2025-10-30 21:29:35 +01:00
Gigi
deab9974fa fix: remove excessive logging from eventToHighlight
- Remove console.log that was spamming console with EVENT-TO-HIGHLIGHT logs
- This function is called frequently during renders, causing performance issues
- Keep the function logic but remove the logging
2025-10-30 21:05:45 +01:00
Gigi
49872337f3 fix: manually set highlight properties after eventToHighlight
- Set publishedRelays, isLocalOnly, and isSyncing directly on highlight object
- This bypasses the __highlightProps mechanism which wasn't working
- Add logging to verify properties are set correctly
- This should finally fix the airplane icon not showing in flight mode
2025-10-30 21:01:18 +01:00
Gigi
389b4de0eb debug: add logging to eventToHighlight conversion
- Add [EVENT-TO-HIGHLIGHT] logs to see if __highlightProps are being preserved
- Add [HIGHLIGHT-CREATION] logs before calling eventToHighlight
- This will help identify why isLocalOnly and publishedRelays are undefined in final highlight
2025-10-30 20:59:43 +01:00
Gigi
959ccac857 fix: store event in EventStore after updating properties
- Move eventStore.add() call to AFTER updating __highlightProps with final values
- This ensures highlights loaded from EventStore have correct isLocalOnly and publishedRelays
- Reduce UI logging spam by only logging when values are meaningful
- This should fix the airplane icon not showing and reduce excessive re-renders
2025-10-30 20:56:50 +01:00
Gigi
78c58693a5 debug: enhance logging in HighlightItem to debug icon issue
- Log entire highlight object to see what properties are actually present
- Log publishedRelayCount and actualPublishedRelaysInUI for clarity
- Add conditionResult to show what icon should be displayed
- This will help identify why airplane icon isn't showing despite isLocalOnly being true
2025-10-30 20:54:15 +01:00
Gigi
ab81fe5030 debug: add logging to useHighlightCreation hook
- Add [HIGHLIGHT-CREATION] logs to track highlight creation flow
- Log when createHighlight function is called
- Log highlight properties after creation to verify isLocalOnly is set
- This will help debug why [HIGHLIGHT-PUBLISH] logs are missing
2025-10-30 20:48:53 +01:00
Gigi
6bae30070e fix: preserve isLocalOnly and publishedRelays in eventToHighlight
- Attach custom properties to event as __highlightProps before storing
- Update eventToHighlight to preserve these properties when converting
- This ensures highlights loaded from EventStore maintain flight mode status
- Fixes issue where isLocalOnly was undefined in UI component
- The airplane icon should now show correctly for flight mode highlights
2025-10-30 20:44:58 +01:00
Gigi
1f6a904717 debug: add comprehensive logging for flight mode detection
- Add detailed logs with [HIGHLIGHT-PUBLISH] prefix for publication process
- Add detailed logs with [HIGHLIGHT-UI] prefix for UI rendering
- Log relay responses, success/failure analysis, and flight mode reasoning
- Log icon decision making process in UI component
- This will help debug why airplane icon isn't showing in flight mode
2025-10-30 20:42:02 +01:00
Gigi
9379475d1c feat: implement proper relay response tracking for flight mode
- Use pool.publish() which returns individual relay responses
- Track which relays actually accepted the event (response.ok === true)
- Set isLocalOnly = true only when only local relays accepted the event
- This provides accurate flight mode detection based on actual publishing success
- Debug logging shows all relay responses for troubleshooting
2025-10-30 20:41:24 +01:00
Gigi
77a5f4bd2a refactor: implement proper flight mode detection for highlights
- Always publish to all relays but track which ones are actually connected
- isLocalOnly = true when only local relays are connected (flight mode)
- Store event in EventStore first for immediate UI display
- Track actually connected relays instead of guessing based on connection status
- This should fix the airplane icon not showing in flight mode
2025-10-30 20:40:20 +01:00
Gigi
4fa01231cd fix: determine isLocalOnly before publishing, not after
- Move connection status check before publishEvent call
- Set isLocalOnly based on actual connection state at creation time
- This ensures airplane icon shows correctly in flight mode
- Previous logic was checking after publishing, which was too late
2025-10-30 20:35:51 +01:00
Gigi
1cd85507a7 debug: add logging to highlight creation isLocalOnly logic
- Add console.log to debug why isLocalOnly is not being set correctly
- Fix logic: isLocalOnly should be true when only local relays are connected
- Previous logic was checking expectedSuccessRelays instead of actual connections
- This will help identify why airplane icon doesn't show in flight mode
2025-10-30 20:34:23 +01:00
Gigi
b6f151c711 refactor: use isLocalOnly instead of isOfflineCreated
- isLocalOnly is more accurate - covers both offline and online-but-local-only scenarios
- Update tooltip to 'Local relays only - will sync when remote relays available'
- Better semantic meaning: highlights only published to local relays
- Covers cases where user is online but only connected to local relays
2025-10-30 20:32:43 +01:00
Gigi
e3d924f3fc refactor: remove redundant isLocalOnly flag
- Remove isLocalOnly field from Highlight type and creation logic
- Use only isOfflineCreated flag for flight mode highlights
- Simplify UI logic to check only isOfflineCreated
- Both flags were set to the same value, making them redundant
- Cleaner, more maintainable code with single source of truth
2025-10-30 20:31:09 +01:00
Gigi
5914df23d3 fix: show airplane icon for flight mode highlights
- Simplify logic to check highlight.isOfflineCreated || highlight.isLocalOnly
- Show airplane icon with 'Created offline - will sync when online' tooltip
- Remove complex showOfflineIndicator state management
- Fixes issue where flight mode highlights showed highlighter icon instead of plane icon
2025-10-30 20:30:04 +01:00
Gigi
2083c2b8c6 fix: remove eventStore and setter functions from useEffect dependencies
- Remove eventStore from useArticleLoader and useExternalUrlLoader dependencies
- Remove setter functions from dependencies as they shouldn't change
- Only keep naddr/url and previewData/cachedUrlHighlights as dependencies
- This prevents content loaders from re-running when going offline
- Fixes the core issue where loading skeleton appears immediately on offline detection
2025-10-30 20:23:23 +01:00
Gigi
35a8411d9b fix: check EventStore before setting loading state
- Move EventStore check before setReaderLoading(true) call
- Only show loading skeleton if content not found in store and no preview data
- This prevents loading skeleton from appearing when cached content is available
- Fixes the core issue where offline mode shows loading skeleton with cached content
2025-10-30 20:21:59 +01:00
Gigi
15b3b5b990 fix: remove relayPool dependency from content loaders
- Remove relayPool from useEffect dependencies in useArticleLoader and useExternalUrlLoader
- This prevents content reloading when relay status changes (going offline/online)
- Content loaders now only re-run when the actual content identifier changes
- Fixes issue where loading skeleton appears when going offline with cached content
2025-10-30 20:20:07 +01:00
Gigi
ad56acb712 fix: prevent unnecessary relay queries when article content is cached
- Return early from useArticleLoader when content is found in EventStore
- This prevents loading skeleton from showing when going offline with cached content
- Improves offline experience by using locally cached article content
2025-10-30 20:18:06 +01:00
Gigi
2f5fe87fc8 docs: update CHANGELOG for v0.10.25 2025-10-25 01:59:51 +02:00
51 changed files with 2594 additions and 1578 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ 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 '../src/utils/profileUtils'
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
@@ -117,14 +118,14 @@ async function fetchArticleMetadata(naddr: string): Promise<ArticleMetadata | nu
const summary = getArticleSummary(article) || 'Read this article on Boris'
const image = getArticleImage(article) || '/boris-social-1200.png'
// Extract author name from profile
// Extract author name from profile using centralized utility
let authorName = pointer.pubkey.slice(0, 8) + '...'
if (profileEvents.length > 0) {
try {
const profileData = JSON.parse(profileEvents[0].content)
authorName = profileData.display_name || profileData.name || authorName
} catch {
// Use fallback
const displayName = extractProfileDisplayName(profileEvents[0])
if (displayName && !displayName.startsWith('@')) {
authorName = displayName
} else if (displayName) {
authorName = displayName.substring(1) // Remove @ prefix
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.10.25",
"version": "0.10.33",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",

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

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import { BlogPostPreview } from '../services/exploreService'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { isKnownBot } from '../config/bots'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
interface BlogPostCardProps {
post: BlogPostPreview
@@ -18,8 +19,13 @@ interface BlogPostCardProps {
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress, hideBotByName = true }) => {
const profile = useEventModel(Models.ProfileModel, [post.author])
const displayName = profile?.name || profile?.display_name ||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
// Note: Images are lazy-loaded (loading="lazy" below), so they'll be fetched
// when they come into view. The Service Worker will cache them automatically.
// No need to preload all images at once - this causes ERR_INSUFFICIENT_RESOURCES
// when there are many blog posts.
const displayName = getProfileDisplayName(profile, post.author)
const rawName = (profile?.name || profile?.display_name || '').toLowerCase()
// Hide bot authors by name/display_name
@@ -41,7 +47,7 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
progressColor = 'var(--color-text)' // Neutral text color (started)
}
// Debug log - reading progress shown as visual indicator
if (readingProgress !== undefined) {
// Reading progress display

View File

@@ -11,6 +11,7 @@ import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { classifyUrl } from '../utils/helpers'
import { ViewMode } from './Bookmarks'
import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
import { CompactView } from './BookmarkViews/CompactView'
import { LargeView } from './BookmarkViews/LargeView'
import { CardView } from './BookmarkViews/CardView'
@@ -62,12 +63,15 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
const authorNpub = npubEncode(bookmark.pubkey)
// Get display name for author
// Get display name for author using centralized utility
const getAuthorDisplayName = () => {
if (authorProfile?.name) return authorProfile.name
if (authorProfile?.display_name) return authorProfile.display_name
if (authorProfile?.nip05) return authorProfile.nip05
return short(bookmark.pubkey) // fallback to short pubkey
const displayName = getProfileDisplayName(authorProfile, bookmark.pubkey)
// getProfileDisplayName returns npub format for fallback, but we want short pubkey format
// So check if it's the fallback format and use short() instead
if (displayName.startsWith('@') && displayName.includes('...')) {
return short(bookmark.pubkey)
}
return displayName
}
// Get content type icon based on bookmark kind and URL classification

View File

@@ -247,10 +247,19 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
/>
{allIndividualBookmarks.length > 0 && (
<BookmarkFilters
selectedFilter={selectedFilter}
onFilterChange={setSelectedFilter}
/>
<div className="bookmark-filters-wrapper">
<BookmarkFilters
selectedFilter={selectedFilter}
onFilterChange={setSelectedFilter}
/>
<CompactButton
icon={faPlus}
onClick={() => setShowAddModal(true)}
title="Add web bookmark"
ariaLabel="Add web bookmark"
className="bookmark-section-action"
/>
</div>
)}
{!activeAccount ? (
@@ -287,15 +296,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
<div key={section.key} className="bookmarks-section">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h3 className="bookmarks-section-title" style={{ margin: 0, padding: '1.5rem 0.5rem 0.375rem', flex: 1 }}>{section.title}</h3>
{section.key === 'web' && activeAccount && (
<CompactButton
icon={faPlus}
onClick={() => setShowAddModal(true)}
title="Add web bookmark"
ariaLabel="Add web bookmark"
className="bookmark-section-action"
/>
)}
</div>
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{section.items.map((individualBookmark, index) => (

View File

@@ -2,8 +2,11 @@ import React, { useMemo, useEffect, useRef } from 'react'
import { useParams, useLocation, useNavigate } from 'react-router-dom'
import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks'
import { Helpers } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
const { getPubkeyFromDecodeResult } = Helpers
import { useSettings } from '../hooks/useSettings'
import { useArticleLoader } from '../hooks/useArticleLoader'
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
@@ -79,16 +82,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({
// Extract tab from profile routes
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
// Decode npub or nprofile to pubkey for profile view
// Decode npub or nprofile to pubkey for profile view using applesauce helper
let profilePubkey: string | undefined
if (npub && showProfile) {
try {
const decoded = nip19.decode(npub)
if (decoded.type === 'npub') {
profilePubkey = decoded.data
} else if (decoded.type === 'nprofile') {
profilePubkey = decoded.data.pubkey
}
profilePubkey = getPubkeyFromDecodeResult(decoded)
} catch (err) {
console.error('Failed to decode npub/nprofile:', err)
}
@@ -100,6 +99,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({
previousLocationRef.current = location.pathname
}
}, [location.pathname, showSettings, showMe, showExplore, showProfile])
// Reset scroll to top when navigating to profile routes
useEffect(() => {
if (showProfile) {
// Reset scroll position when navigating to profile pages
// Use requestAnimationFrame to ensure it happens after DOM updates
requestAnimationFrame(() => {
window.scrollTo({ top: 0, behavior: 'instant' })
})
}
}, [location.pathname, showProfile])
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
@@ -229,7 +239,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({
currentArticle,
selectedUrl,
readerContent,
onHighlightCreated: (highlight) => setHighlights(prev => [highlight, ...prev]),
onHighlightCreated: (highlight) => setHighlights(prev => {
// Deduplicate by checking if highlight with this ID already exists
const exists = prev.some(h => h.id === highlight.id)
if (exists) {
return prev // Don't add duplicate
}
return [highlight, ...prev]
}),
settings
})

View File

@@ -133,7 +133,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
return selectedUrl || `${title || ''}:${(markdown || html || '').length}`
}, [selectedUrl, title, markdown, html])
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
const { contentRef } = useHighlightInteractions({
onHighlightClick,
selectedHighlightId,
onTextSelection,
@@ -263,6 +263,23 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const restoreKey = `${articleIdentifier}-${isTrackingEnabled}`
const hasAttemptedRestoreRef = useRef<string | null>(null)
// Reset scroll position and restore ref when article changes
useEffect(() => {
if (!articleIdentifier) return
// Suppress saves during navigation to prevent saving 0% position
// The 500ms suppression covers the scroll reset and initial render
if (suppressSavesForRef.current) {
suppressSavesForRef.current(500)
}
// Reset scroll to top when article identifier changes
// This prevents showing wrong scroll position from previous article
window.scrollTo({ top: 0, behavior: 'instant' })
// Reset restore attempt tracking for new article
hasAttemptedRestoreRef.current = null
}, [articleIdentifier])
useEffect(() => {
if (!isTextContent || !activeAccount || !articleIdentifier) {
return
@@ -798,8 +815,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
html={finalHtml}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
className="reader-markdown"
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
) : (
<div className="reader-markdown">
@@ -813,8 +828,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
html={finalHtml || html || ''}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
className="reader-html"
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
)}

View File

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

View File

@@ -5,6 +5,7 @@ import { Models } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { fetchArticleTitle } from '../services/articleTitleResolver'
import { Highlight } from '../types/highlights'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
interface HighlightCitationProps {
highlight: Highlight
@@ -79,7 +80,8 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
loadTitle()
}, [highlight.eventReference, relayPool])
const authorName = authorProfile?.name || authorProfile?.display_name
// Use centralized profile display name utility
const authorName = authorPubkey ? getProfileDisplayName(authorProfile, authorPubkey) : undefined
// For nostr-native content with article reference
if (highlight.eventReference && (authorName || articleTitle)) {

View File

@@ -7,8 +7,8 @@ import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { Hooks } from 'applesauce-react'
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
import { areAllRelaysLocal } from '../utils/helpers'
import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService'
import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers'
import { getActiveRelayUrls } from '../services/relayManager'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
@@ -18,6 +18,7 @@ import CompactButton from './CompactButton'
import { HighlightCitation } from './HighlightCitation'
import { useNavigate } from 'react-router-dom'
import NostrMentionLink from './NostrMentionLink'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
// Helper to detect if a URL is an image
const isImageUrl = (url: string): boolean => {
@@ -114,7 +115,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const itemRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
@@ -128,17 +128,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
// Get display name for the user
const getUserDisplayName = () => {
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
return getProfileDisplayName(profile, highlight.pubkey)
}
// Update offline indicator when highlight prop changes
useEffect(() => {
if (highlight.isOfflineCreated && !isSyncing) {
setShowOfflineIndicator(true)
}
}, [highlight.isOfflineCreated, isSyncing])
// Listen to sync state changes
useEffect(() => {
@@ -147,8 +139,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
setIsSyncing(syncingState)
// When sync completes successfully, update highlight to show all relays
if (!syncingState) {
setShowOfflineIndicator(false)
// Update the highlight with all relays after successful sync
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
const updatedHighlight = {
@@ -292,9 +282,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
onHighlightUpdate(updatedHighlight)
}
// Update local state
setShowOfflineIndicator(false)
} catch (error) {
console.error('❌ Failed to rebroadcast:', error)
} finally {
@@ -313,8 +300,37 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
}
// Always show relay list, use plane icon for local-only
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
// Check if this highlight was only published to local relays
let isLocalOnly = highlight.isLocalOnly
const publishedRelays = highlight.publishedRelays || []
// Fallback 1: Check if this highlight was marked for offline sync (flight mode)
if (isLocalOnly === undefined) {
if (isEventOfflineCreated(highlight.id)) {
isLocalOnly = true
}
}
// Fallback 2: If publishedRelays only contains local relays, it's local-only
if (isLocalOnly === undefined && publishedRelays.length > 0) {
const hasOnlyLocalRelays = publishedRelays.every(url => isLocalRelay(url))
const hasRemoteRelays = publishedRelays.some(url => !isLocalRelay(url))
if (hasOnlyLocalRelays && !hasRemoteRelays) {
isLocalOnly = true
}
}
// If isLocalOnly is true (from any fallback), show airplane icon
if (isLocalOnly === true) {
return {
icon: faPlane,
tooltip: publishedRelays.length > 0
? 'Local relays only - will sync when remote relays available'
: 'Created in flight mode - will sync when remote relays available',
spin: false
}
}
// Show highlighter icon with relay info if available
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
@@ -322,7 +338,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: isLocalOrOffline ? faPlane : faHighlighter,
icon: faHighlighter,
tooltip: relayNames.join('\n'),
spin: false
}

View File

@@ -1,7 +1,12 @@
import React from 'react'
import React, { useMemo } from 'react'
import { nip19 } from 'nostr-tools'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { Hooks } from 'applesauce-react'
import { Models, Helpers } from 'applesauce-core'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
import { isProfileInCacheOrStore } from '../utils/profileLoadingUtils'
const { getPubkeyFromDecodeResult } = Helpers
interface NostrMentionLinkProps {
nostrUri: string
@@ -20,25 +25,31 @@ const NostrMentionLink: React.FC<NostrMentionLinkProps> = ({
}) => {
// Decode the nostr URI first
let decoded: ReturnType<typeof nip19.decode> | null = null
let pubkey: string | undefined
try {
const identifier = nostrUri.replace(/^nostr:/, '')
decoded = nip19.decode(identifier)
// Extract pubkey for profile fetching (works for npub and nprofile)
if (decoded.type === 'npub') {
pubkey = decoded.data
} else if (decoded.type === 'nprofile') {
pubkey = decoded.data.pubkey
}
} catch (error) {
// Decoding failed, will fallback to shortened identifier
}
// Extract pubkey for profile fetching using applesauce helper (works for npub and nprofile)
const pubkey = decoded ? getPubkeyFromDecodeResult(decoded) : undefined
const eventStore = Hooks.useEventStore()
// Fetch profile at top level (Rules of Hooks)
const profile = useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null)
// Check if profile is in cache or eventStore for loading detection
const isInCacheOrStore = useMemo(() => {
if (!pubkey) return false
return isProfileInCacheOrStore(pubkey, eventStore)
}, [pubkey, eventStore])
// Show loading if profile doesn't exist and not in cache/store (for npub/nprofile)
// pubkey will be undefined for non-profile types, so no need for explicit type check
const isLoading = !profile && pubkey && !isInCacheOrStore
// If decoding failed, show shortened identifier
if (!decoded) {
const identifier = nostrUri.replace(/^nostr:/, '')
@@ -49,37 +60,30 @@ const NostrMentionLink: React.FC<NostrMentionLinkProps> = ({
)
}
// Helper function to render profile links (used for both npub and nprofile)
const renderProfileLink = (pubkey: string) => {
const npub = nip19.npubEncode(pubkey)
const displayName = getProfileDisplayName(profile, pubkey)
const linkClassName = isLoading ? `${className} profile-loading` : className
return (
<a
href={`/p/${npub}`}
className={linkClassName}
onClick={onClick}
>
@{displayName}
</a>
)
}
// Render based on decoded type
// If we have a pubkey (from npub/nprofile), render profile link directly
if (pubkey) {
return renderProfileLink(pubkey)
}
switch (decoded.type) {
case 'npub': {
const pk = decoded.data
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...`
return (
<a
href={`/p/${nip19.npubEncode(pk)}`}
className={className}
onClick={onClick}
>
@{displayName}
</a>
)
}
case 'nprofile': {
const { pubkey: pk } = decoded.data
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...`
const npub = nip19.npubEncode(pk)
return (
<a
href={`/p/${npub}`}
className={className}
onClick={onClick}
>
@{displayName}
</a>
)
}
case 'naddr': {
const { kind, pubkey: pk, identifier: addrIdentifier } = decoded.data
// Check if it's a blog post (kind:30023)

View File

@@ -80,7 +80,13 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
<>
<div className="reader-hero-image">
{cachedImage ? (
<img src={cachedImage} alt={title || 'Article image'} />
<img
src={cachedImage}
alt={title || 'Article image'}
onError={(e) => {
console.error('[reader-header] Image failed to load:', cachedImage, e)
}}
/>
) : (
<div className="reader-hero-placeholder">
<FontAwesomeIcon icon={faNewspaper} />

View File

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

View File

@@ -1,5 +1,13 @@
import React from 'react'
import NostrMentionLink from './NostrMentionLink'
import { Tokens } from 'applesauce-content/helpers'
// Helper to add timestamps to error logs
const ts = () => {
const now = new Date()
const ms = now.getMilliseconds().toString().padStart(3, '0')
return `${now.toLocaleTimeString('en-US', { hour12: false })}.${ms}`
}
interface RichContentProps {
content: string
@@ -18,18 +26,31 @@ const RichContent: React.FC<RichContentProps> = ({
content,
className = 'bookmark-content'
}) => {
// Pattern to match:
// 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.)
// 2. Plain nostr identifiers (npub1..., nprofile1..., note1..., etc.)
// 3. http(s) URLs
const pattern = /(nostr:[a-z0-9]+|npub1[a-z0-9]+|nprofile1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|naddr1[a-z0-9]+|https?:\/\/[^\s]+)/gi
try {
// Pattern to match:
// 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.) using applesauce Tokens.nostrLink
// 2. http(s) URLs
const nostrPattern = Tokens.nostrLink
const urlPattern = /https?:\/\/[^\s]+/gi
const combinedPattern = new RegExp(`(${nostrPattern.source}|${urlPattern.source})`, 'gi')
const parts = content.split(combinedPattern)
const parts = content.split(pattern)
return (
// Helper to check if a string is a nostr identifier (without mutating regex state)
const isNostrIdentifier = (str: string): boolean => {
const testPattern = new RegExp(nostrPattern.source, nostrPattern.flags)
return testPattern.test(str)
}
return (
<div className={className}>
{parts.map((part, index) => {
// Handle nostr: URIs
// Skip empty or undefined parts
if (!part) {
return null
}
// Handle nostr: URIs - Tokens.nostrLink matches both formats
if (part.startsWith('nostr:')) {
return (
<NostrMentionLink
@@ -39,10 +60,8 @@ const RichContent: React.FC<RichContentProps> = ({
)
}
// Handle plain nostr identifiers (add nostr: prefix)
if (
part.match(/^(npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+$/i)
) {
// Handle plain nostr identifiers (Tokens.nostrLink matches these too)
if (isNostrIdentifier(part)) {
return (
<NostrMentionLink
key={index}
@@ -70,7 +89,11 @@ const RichContent: React.FC<RichContentProps> = ({
return <React.Fragment key={index}>{part}</React.Fragment>
})}
</div>
)
)
} catch (err) {
console.error(`[${ts()}] [npub-resolve] RichContent: Error rendering:`, err)
return <div className={className}>Error rendering content</div>
}
}
export default RichContent

View File

@@ -7,6 +7,8 @@ import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import IconButton from './IconButton'
import { faBooks } from '../icons/customIcons'
import { preloadImage } from '../hooks/useImageCache'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
interface SidebarHeaderProps {
onToggleCollapse: () => void
@@ -28,14 +30,18 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
const getUserDisplayName = () => {
if (!activeAccount) return 'Unknown User'
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
if (profile?.nip05) return profile.nip05
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
return getProfileDisplayName(profile, activeAccount.pubkey)
}
const profileImage = getProfileImage()
// Preload profile image for offline access
useEffect(() => {
if (profileImage) {
preloadImage(profileImage)
}
}, [profileImage])
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {

View File

@@ -10,6 +10,7 @@ import { Models } from 'applesauce-core'
import { useEventModel } from 'applesauce-react/hooks'
import { useNavigate } from 'react-router-dom'
import { nip19 } from 'nostr-tools'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
interface SupportProps {
relayPool: RelayPool
@@ -182,7 +183,7 @@ const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) =>
const profile = useEventModel(Models.ProfileModel, [supporter.pubkey])
const picture = profile?.picture
const name = profile?.name || profile?.display_name || `${supporter.pubkey.slice(0, 8)}...`
const name = getProfileDisplayName(profile, supporter.pubkey)
const handleClick = () => {
const npub = nip19.npubEncode(supporter.pubkey)

View File

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

View File

@@ -6,8 +6,9 @@ import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { Helpers } from 'applesauce-core'
import { queryEvents } from '../services/dataFetch'
import { fetchArticleByNaddr } from '../services/articleService'
import { fetchArticleByNaddr, getFromCache, saveToCache } from '../services/articleService'
import { fetchHighlightsForArticle } from '../services/highlightService'
import { preloadImage } from './useImageCache'
import { ReadableContent } from '../services/readerService'
import { Highlight } from '../types/highlights'
import { NostrEvent } from 'nostr-tools'
@@ -72,11 +73,201 @@ export function useArticleLoader({
useEffect(() => {
mountedRef.current = true
if (!relayPool || !naddr) return
// First check: naddr is required
if (!naddr) {
setReaderContent(undefined)
return
}
// Clear readerContent immediately to prevent showing stale content from previous article
// This ensures images from previous articles don't flash briefly
setReaderContent(undefined)
// Synchronously check cache sources BEFORE checking relayPool
// This prevents showing loading skeletons when content is immediately available
// and fixes the race condition where relayPool isn't ready yet
let foundInCache = false
try {
// Check localStorage cache first (synchronous, doesn't need relayPool)
const cachedArticle = getFromCache(naddr)
if (cachedArticle) {
foundInCache = true
const title = cachedArticle.title || 'Untitled Article'
setCurrentTitle(title)
setReaderContent({
title,
markdown: cachedArticle.markdown,
image: cachedArticle.image,
summary: cachedArticle.summary,
published: cachedArticle.published,
url: `nostr:${naddr}`
})
const dTag = cachedArticle.event.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(cachedArticle.event.id)
setCurrentArticle?.(cachedArticle.event)
setReaderLoading(false)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
// Preload image if available to ensure it's cached by Service Worker
// This ensures images are available when offline
if (cachedArticle.image) {
preloadImage(cachedArticle.image)
}
// Store in EventStore for future lookups
if (eventStore) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eventStore.add?.(cachedArticle.event as unknown as any)
} catch {
// Silently ignore store errors
}
}
// Fetch highlights in background (don't block UI)
// Only fetch highlights if relayPool is available
if (mountedRef.current && relayPool) {
const dTag = cachedArticle.event.tags.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coord = dTag ? `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}` : undefined
const eventId = cachedArticle.event.id
if (coord && eventId) {
setHighlightsLoading(true)
fetchHighlightsForArticle(
relayPool,
coord,
eventId,
(highlight) => {
if (!mountedRef.current) return
setHighlights((prev: Highlight[]) => {
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
const next = [highlight, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
},
settings,
false,
eventStore || undefined
).then(() => {
if (mountedRef.current) {
setHighlightsLoading(false)
}
}).catch(() => {
if (mountedRef.current) {
setHighlightsLoading(false)
}
})
}
}
// Return early - we have cached content, no need to query relays
return
}
} catch (err) {
// If cache check fails, fall through to async loading
console.warn('[article-loader] Cache check failed:', err)
}
// Check EventStore synchronously (also doesn't need relayPool)
let foundInEventStore = false
if (eventStore && !foundInCache) {
try {
// Decode naddr to get the coordinate
const decoded = nip19.decode(naddr)
if (decoded.type === 'naddr') {
const pointer = decoded.data as AddressPointer
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
const storedEvent = eventStore.getEvent?.(coordinate)
if (storedEvent) {
foundInEventStore = true
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
setCurrentTitle(title)
const image = Helpers.getArticleImage(storedEvent)
const summary = Helpers.getArticleSummary(storedEvent)
const published = Helpers.getArticlePublished(storedEvent)
setReaderContent({
title,
markdown: storedEvent.content,
image,
summary,
published,
url: `nostr:${naddr}`
})
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(storedEvent.id)
setCurrentArticle?.(storedEvent)
setReaderLoading(false)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
// Fetch highlights in background if relayPool is available
if (relayPool) {
const coord = dTag ? `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}` : undefined
const eventId = storedEvent.id
if (coord && eventId) {
setHighlightsLoading(true)
fetchHighlightsForArticle(
relayPool,
coord,
eventId,
(highlight) => {
if (!mountedRef.current) return
setHighlights((prev: Highlight[]) => {
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
const next = [highlight, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
},
settings,
false,
eventStore || undefined
).then(() => {
if (mountedRef.current) {
setHighlightsLoading(false)
}
}).catch(() => {
if (mountedRef.current) {
setHighlightsLoading(false)
}
})
}
}
// Return early - we have EventStore content, no need to query relays yet
// But we might want to fetch from relays in background if relayPool becomes available
return
}
}
} catch (err) {
// Ignore store errors, fall through to relay query
console.warn('[article-loader] EventStore check failed:', err)
}
}
// Only return early if we have no content AND no relayPool to fetch from
if (!relayPool && !foundInCache && !foundInEventStore) {
setReaderLoading(true)
setReaderContent(undefined)
return
}
// If we have relayPool, proceed with async loading
if (!relayPool) {
return
}
const loadArticle = async () => {
const requestId = ++currentRequestIdRef.current
if (!mountedRef.current) return
if (!mountedRef.current) {
return
}
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
@@ -85,19 +276,28 @@ export function useArticleLoader({
// when we know the article coordinate
setHighlightsLoading(false) // Don't show loading yet
// If we have preview data from navigation, show it immediately (no skeleton!)
// Note: Cache and EventStore were already checked synchronously above
// This async function only runs if we need to fetch from relays
// At this point, we've checked EventStore and cache - neither had content
// Only show loading skeleton if we also don't have preview data
if (previewData) {
// If we have preview data from navigation, show it immediately (no skeleton!)
setCurrentTitle(previewData.title)
setReaderContent({
title: previewData.title,
markdown: '', // Will be loaded from store or relay
markdown: '', // Will be loaded from relay
image: previewData.image,
summary: previewData.summary,
published: previewData.published,
url: `nostr:${naddr}`
})
setReaderLoading(false) // Turn off loading immediately - we have the preview!
// Don't preload image here - it should already be cached from BlogPostCard
// Preloading again would be redundant and could cause unnecessary network requests
} else {
// No cache, no EventStore, no preview data - need to load from relays
setReaderLoading(true)
setReaderContent(undefined)
}
@@ -118,44 +318,15 @@ export function useArticleLoader({
let firstEmitted = false
let latestEvent: NostrEvent | null = null
// Check eventStore first for instant load (from bookmark cards, explore, etc.)
if (eventStore) {
try {
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
const storedEvent = eventStore.getEvent?.(coordinate)
if (storedEvent) {
latestEvent = storedEvent as NostrEvent
firstEmitted = true
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
setCurrentTitle(title)
const image = Helpers.getArticleImage(storedEvent)
const summary = Helpers.getArticleSummary(storedEvent)
const published = Helpers.getArticlePublished(storedEvent)
setReaderContent({
title,
markdown: storedEvent.content,
image,
summary,
published,
url: `nostr:${naddr}`
})
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(storedEvent.id)
setCurrentArticle?.(storedEvent)
setReaderLoading(false)
}
} catch (err) {
// Ignore store errors, fall through to relay query
}
}
// Stream local-first via queryEvents; rely on EOSE (no timeouts)
const events = await queryEvents(relayPool, filter, {
onEvent: (evt) => {
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
if (!mountedRef.current) {
return
}
if (currentRequestIdRef.current !== requestId) {
return
}
// Store in event store for future local reads
try {
@@ -174,10 +345,11 @@ export function useArticleLoader({
if (!firstEmitted) {
firstEmitted = true
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
setCurrentTitle(title)
const image = Helpers.getArticleImage(evt)
const summary = Helpers.getArticleSummary(evt)
const published = Helpers.getArticlePublished(evt)
setCurrentTitle(title)
setReaderContent({
title,
markdown: evt.content,
@@ -192,20 +364,41 @@ export function useArticleLoader({
setCurrentArticleEventId(evt.id)
setCurrentArticle?.(evt)
setReaderLoading(false)
// Save to cache immediately when we get the first event
// Don't wait for queryEvents to complete in case it hangs
const articleContent = {
title,
markdown: evt.content,
image,
summary,
published,
author: evt.pubkey,
event: evt
}
saveToCache(naddr, articleContent, settings)
// Preload image to ensure it's cached by Service Worker
if (image) {
preloadImage(image)
}
}
}
})
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
if (!mountedRef.current || currentRequestIdRef.current !== requestId) {
return
}
// Finalize with newest version if it's newer than what we first rendered
const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent
if (finalEvent) {
const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article'
setCurrentTitle(title)
const image = Helpers.getArticleImage(finalEvent)
const summary = Helpers.getArticleSummary(finalEvent)
const published = Helpers.getArticlePublished(finalEvent)
setCurrentTitle(title)
setReaderContent({
title,
markdown: finalEvent.content,
@@ -220,6 +413,23 @@ export function useArticleLoader({
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(finalEvent.id)
setCurrentArticle?.(finalEvent)
// Save to cache for future loads (if we haven't already saved from first event)
// Only save if this is a different/newer event than what we first rendered
// Note: We already saved from first event, so only save if this is different
if (!firstEmitted) {
// First event wasn't emitted, so save now
const articleContent = {
title,
markdown: finalEvent.content,
image,
summary,
published,
author: finalEvent.pubkey,
event: finalEvent
}
saveToCache(naddr, articleContent)
}
} else {
// As a last resort, fall back to the legacy helper (which includes cache)
const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current)
@@ -305,19 +515,13 @@ export function useArticleLoader({
return () => {
mountedRef.current = false
}
// Include relayPool in dependencies so effect re-runs when it becomes available
// This fixes the race condition where articles don't load on direct navigation
// We guard against unnecessary re-renders by checking cache/EventStore first
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
naddr,
relayPool,
eventStore,
previewData,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed,
setHighlights,
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId,
setCurrentArticle
relayPool
])
}

View File

@@ -6,6 +6,8 @@ import { ReadableContent } from '../services/readerService'
import { eventManager } from '../services/eventManager'
import { fetchProfiles } from '../services/profileService'
import { useDocumentTitle } from './useDocumentTitle'
import { getNpubFallbackDisplay } from '../utils/nostrUriResolver'
import { extractProfileDisplayName } from '../utils/profileUtils'
interface UseEventLoaderProps {
eventId?: string
@@ -40,7 +42,7 @@ export function useEventLoader({
// Initial title
let title = `Note (${event.kind})`
if (event.kind === 1) {
title = `Note by @${event.pubkey.slice(0, 8)}...`
title = `Note by ${getNpubFallbackDisplay(event.pubkey)}`
}
// Emit immediately
@@ -62,11 +64,12 @@ export function useEventLoader({
// First, try to get from event store cache
const storedProfile = eventStore.getEvent(event.pubkey + ':0')
if (storedProfile) {
try {
const obj = JSON.parse(storedProfile.content || '{}') as { name?: string; display_name?: string; nip05?: string }
resolved = obj.display_name || obj.name || obj.nip05 || ''
} catch {
// ignore parse errors
const displayName = extractProfileDisplayName(storedProfile as NostrEvent)
if (displayName && !displayName.startsWith('@')) {
// Remove @ prefix if present (we'll add it when displaying)
resolved = displayName
} else if (displayName) {
resolved = displayName.substring(1) // Remove @ prefix
}
}
@@ -75,15 +78,15 @@ export function useEventLoader({
const profiles = await fetchProfiles(relayPool, eventStore as unknown as IEventStore, [event.pubkey])
if (profiles && profiles.length > 0) {
const latest = profiles.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
try {
const obj = JSON.parse(latest.content || '{}') as { name?: string; display_name?: string; nip05?: string }
resolved = obj.display_name || obj.name || obj.nip05 || ''
} catch {
// ignore parse errors
const displayName = extractProfileDisplayName(latest)
if (displayName && !displayName.startsWith('@')) {
resolved = displayName
} else if (displayName) {
resolved = displayName.substring(1) // Remove @ prefix
}
}
}
if (resolved) {
const updatedTitle = `Note by @${resolved}`
setCurrentTitle(updatedTitle)

View File

@@ -165,19 +165,12 @@ export function useExternalUrlLoader({
return () => {
mountedRef.current = false
}
// Dependencies intentionally excluded to prevent re-renders when relay/eventStore state changes
// This fixes the loading skeleton appearing when going offline (flight mode)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
url,
relayPool,
eventStore,
cachedUrlHighlights,
setReaderContent,
setReaderLoading,
setIsCollapsed,
setSelectedUrl,
setHighlights,
setCurrentArticleCoordinate,
setCurrentArticleEventId,
setHighlightsLoading
cachedUrlHighlights
])
// Keep UI highlights synced with cached store updates without reloading content

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,8 +71,9 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
// Set paragraph alignment
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
// Set image max-width based on full-width setting
root.setProperty('--image-max-width', settings.fullWidthImages ? 'none' : '100%')
// Set image width and max-height based on full-width setting
root.setProperty('--image-width', settings.fullWidthImages ? '100%' : 'auto')
root.setProperty('--image-max-height', settings.fullWidthImages ? 'none' : '70vh')
}

View File

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

View File

@@ -34,11 +34,13 @@ function getCacheKey(naddr: string): string {
return `${CACHE_PREFIX}${naddr}`
}
function getFromCache(naddr: string): ArticleContent | null {
export function getFromCache(naddr: string): ArticleContent | null {
try {
const cacheKey = getCacheKey(naddr)
const cached = localStorage.getItem(cacheKey)
if (!cached) return null
if (!cached) {
return null
}
const { content, timestamp }: CachedArticle = JSON.parse(cached)
const age = Date.now() - timestamp
@@ -49,12 +51,51 @@ function getFromCache(naddr: string): ArticleContent | null {
}
return content
} catch {
} catch (err) {
// Silently handle cache read errors
return null
}
}
function saveToCache(naddr: string, content: ArticleContent): void {
/**
* Caches an article event to localStorage for offline access
* @param event - The Nostr event to cache
* @param settings - Optional user settings
*/
export function cacheArticleEvent(event: NostrEvent, settings?: UserSettings): void {
try {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
if (!dTag || event.kind !== 30023) return
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: event.pubkey,
identifier: dTag
})
const articleContent: ArticleContent = {
title: getArticleTitle(event) || 'Untitled Article',
markdown: event.content,
image: getArticleImage(event),
published: getArticlePublished(event),
summary: getArticleSummary(event),
author: event.pubkey,
event
}
saveToCache(naddr, articleContent, settings)
} catch (err) {
// Silently fail cache saves - quota exceeded, invalid data, etc.
}
}
export function saveToCache(naddr: string, content: ArticleContent, settings?: UserSettings): void {
// Respect user settings: if image caching is disabled, we could skip article caching too
// However, for offline-first design, we default to caching unless explicitly disabled
// Future: could add explicit enableArticleCache setting
// For now, we cache aggressively but handle errors gracefully
// Note: settings parameter reserved for future use
void settings // Mark as intentionally unused for now
try {
const cacheKey = getCacheKey(naddr)
const cached: CachedArticle = {
@@ -63,8 +104,8 @@ function saveToCache(naddr: string, content: ArticleContent): void {
}
localStorage.setItem(cacheKey, JSON.stringify(cached))
} catch (err) {
console.warn('Failed to cache article:', err)
// Silently fail if storage is full or unavailable
// Silently fail - don't block the UI if caching fails
// Handles quota exceeded, invalid data, and other errors gracefully
}
}
@@ -164,7 +205,7 @@ export async function fetchArticleByNaddr(
}
// Save to cache before returning
saveToCache(naddr, content)
saveToCache(naddr, content, settings)
// Image caching is handled automatically by Service Worker

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -88,7 +88,7 @@ export function applyRelaySetToPool(
} catch (error) {
// Suppress errors when closing relays that haven't fully connected yet
// This can happen when switching relay sets before connections establish
console.debug('[relay-manager] Ignoring error when closing relay:', url, error)
// Silently ignore
}
relayPool.relays.delete(url)
}

View File

@@ -57,13 +57,14 @@
.reader .reader-markdown h1, .reader .reader-markdown h2, .reader .reader-markdown h3, .reader .reader-markdown h4, .reader .reader-markdown h5, .reader .reader-markdown h6 { text-align: left !important; }
/* Tame images from external content */
.reader .reader-html img, .reader .reader-markdown img {
max-width: var(--image-max-width, 100%);
max-height: 70vh;
width: var(--image-width, auto);
max-width: 100%;
max-height: var(--image-max-height, 70vh);
height: auto;
width: auto;
display: block;
margin: 0.75rem auto;
border-radius: 6px;
object-fit: contain;
}
/* Headlines with Tailwind typography */
.reader-markdown h1, .reader-html h1 {
@@ -192,9 +193,11 @@
}
.reader-markdown img, .reader-html img {
width: var(--image-width, auto) !important;
max-width: 100% !important;
width: auto !important;
max-height: var(--image-max-height, 70vh) !important;
height: auto;
object-fit: contain;
}
}
@@ -270,3 +273,21 @@
/* Reading Progress Indicator - now using Tailwind utilities in component */
/* Profile loading state - subtle opacity pulse animation */
.profile-loading {
opacity: 0.6;
animation: profile-loading-pulse 1.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.profile-loading {
animation: none;
opacity: 0.7;
}
}
@keyframes profile-loading-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,5 @@
// Extract pubkeys from nprofile strings in content
import { READING_PROGRESS } from '../config/kinds'
export const extractNprofilePubkeys = (content: string): string[] => {
const nprofileRegex = /nprofile1[a-z0-9]+/gi
const matches = content.match(nprofileRegex) || []
const unique = new Set<string>(matches)
return Array.from(unique)
}
export type UrlType = 'video' | 'image' | 'youtube' | 'article'
export interface UrlClassification {

View File

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

View File

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

View File

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

View File

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

40
src/utils/profileUtils.ts Normal file
View File

@@ -0,0 +1,40 @@
import { NostrEvent } from 'nostr-tools'
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

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