Browse Source

more refactoring

imwald
Silberengel 1 month ago
parent
commit
5093d4bd3b
  1. 115
      CITATION_TEST_CONTENT.adoc
  2. 80
      CITATION_TEST_README.md
  3. 139
      FILES_TO_UPDATE.md
  4. 71
      LOGGING.md
  5. 189
      MIGRATION_GUIDE.md
  6. 502
      PROXY_SETUP.md
  7. 14
      README.md
  8. 160
      REFACTORING_COMPLETE.md
  9. 80
      REFACTORING_PLAN.md
  10. 2
      scripts/README-deploy.md
  11. 30
      src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx
  12. 30
      src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx
  13. 4
      src/components/Embedded/EmbeddedNote.tsx
  14. 134
      src/components/NoteList/index.tsx
  15. 17
      src/components/PostEditor/PostRelaySelector.tsx
  16. 57
      src/components/Relay/index.tsx
  17. 68
      src/components/SessionRelaysTab/index.tsx
  18. 11
      src/constants.ts
  19. 16
      src/features/feed/runtime.test.ts
  20. 35
      src/features/feed/runtime.ts
  21. 28
      src/hooks/useContainerWidth.ts
  22. 93
      src/hooks/useProfileZapPollParticipation.tsx
  23. 23
      src/hooks/useRelayConnectionRows.ts
  24. 30
      src/hooks/useViewerInboxRelayUrls.ts
  25. 46
      src/hooks/useViewerInboxRelayUrlsAndAggr.ts
  26. 13
      src/i18n/locales/cs.ts
  27. 11
      src/i18n/locales/de.ts
  28. 13
      src/i18n/locales/en.ts
  29. 13
      src/i18n/locales/es.ts
  30. 13
      src/i18n/locales/fr.ts
  31. 13
      src/i18n/locales/nl.ts
  32. 13
      src/i18n/locales/pl.ts
  33. 13
      src/i18n/locales/ru.ts
  34. 13
      src/i18n/locales/tr.ts
  35. 13
      src/i18n/locales/zh.ts
  36. 43
      src/lib/favorites-feed-relays.ts
  37. 17
      src/lib/index-relay-http.ts
  38. 12
      src/lib/live-activities.ts
  39. 93
      src/lib/nostr-land-aggr.ts
  40. 8
      src/lib/rss-article.ts
  41. 6
      src/lib/url.ts
  42. 4
      src/pages/primary/RelayPage/index.tsx
  43. 4
      src/pages/secondary/RelayPage/index.tsx
  44. 38
      src/services/client-query.service.ts
  45. 73
      src/services/client.service.ts
  46. 10
      src/services/mention-event-search.service.ts
  47. 2
      src/services/note-stats.service.ts
  48. 10
      src/services/relay-notice-fetch-failure.ts

115
CITATION_TEST_CONTENT.adoc

@ -1,115 +0,0 @@
= Test Article: Citation Embedding Examples
Author Name
2024-01-15
This article demonstrates all citation types and display methods that can be embedded in AsciiDoc articles.
IMPORTANT: Replace all placeholder nevent IDs with actual citation event IDs from your Nostr relays.
== Citation Format
All citations use the format: `[[citation::TYPE::NEVENT_ID]]`
The TYPE can be:
- `inline` - renders inline within text
- `foot` - creates a footnote
- `foot-end` - creates a footnote that links to an endnote
- `end` - appears at the end in references section
- `quote` - block-level citation card
- `prompt-inline` - inline prompt citation
- `prompt-end` - prompt citation in references section
== Internal Citations (Kind 30)
Internal citations reference other Nostr events.
=== Inline Internal Citation
Here's an inline citation: [[citation::inline::nevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhsqgqpl98djyt2eln4uy4dlx2l6a6eyum8acgqz3vfnqptkx54suyyn5u59v88]]
You can have multiple inline citations in one sentence: The first citation [[citation::inline::nevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyry7njqtmu366utn3422xt84hv6pg3h5vsac602xne63hw7hmrjew8x37w3]] and the second citation [[citation::inline::nevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyr977llj62ttqp3zw5dhdp3mdswng5ge7hfgdsz2vc7f5w5889w857hmzhr]] both reference Nostr events.
=== Footnote Internal Citation
This sentence has a footnote citation.footnote: [[citation::foot::nevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhsqgqpl98djyt2eln4uy4dlx2l6a6eyum8acgqz3vfnqptkx54suyyn5u59v88]]
=== Endnote Internal Citation
This paragraph uses an endnote citation that will appear in the references section [[citation::end::nevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyry7njqtmu366utn3422xt84hv6pg3h5vsac602xne63hw7hmrjew8x37w3]].
=== Block Quote Internal Citation
For block-level display of citations:
[[citation::quote::nevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyry7njqtmu366utn3422xt84hv6pg3h5vsac602xne63hw7hmrjew8x37w3]]
== External Web Citations (Kind 31)
External citations reference web resources.
=== Inline External Citation
Here's an inline external citation: [[citation::inline::nevent1qvzqqqqqrupzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyqsdgj88q2tswy2stc9p5xfaf200kr9le8m75se084upkrqkex9yvwk8faj]]
=== Footnote-End External Citation
This creates a footnote that links to an endnote.footnote: [[[citation::foot-end::nevent1qvzqqqqqrupzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyqsdgj88q2tswy2stc9p5xfaf200kr9le8m75se084upkrqkex9yvwk8faj]]]
=== Endnote External Citation
This paragraph references a web source [[citation::end::nevent1qvzqqqqqrupzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyqsdgj88q2tswy2stc9p5xfaf200kr9le8m75se084upkrqkex9yvwk8faj]].
== Hardcopy Citations (Kind 32)
Hardcopy citations reference printed materials like books and journals.
=== Inline Hardcopy Citation
Here's an inline hardcopy citation: [[citation::inline::nevent1qvzqqqqqyqpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcprfmhxue69uhkvun9v4kxz7fwwdhhvcnfwshxsmmnwshsqgyzg2dv4w5dpsalmm28qvn3t0gsl09u0m5ar4jfupzkrt5t0fh2vgzych2c]]
=== Endnote Hardcopy Citation
This references a book [[citation::end::nevent1qvzqqqqqyqpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcprfmhxue69uhkvun9v4kxz7fwwdhhvcnfwshxsmmnwshsqgyzg2dv4w5dpsalmm28qvn3t0gsl09u0m5ar4jfupzkrt5t0fh2vgzych2c]].
=== Block Quote Hardcopy Citation
For important book references:
[[citation::quote::nevent1qvzqqqqqyqpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcprfmhxue69uhkvun9v4kxz7fwwdhhvcnfwshxsmmnwshsqgyzg2dv4w5dpsalmm28qvn3t0gsl09u0m5ar4jfupzkrt5t0fh2vgzych2c]]
== Prompt Citations (Kind 33)
Prompt citations reference AI/LLM interactions.
=== Inline Prompt Citation
Here's an inline prompt citation: [[citation::prompt-inline::nevent1qvzqqqqqyypzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyprf7ddefkkvdedvredu83pqqn7payvvcnrqp2s72zrx823x0wpezqzpst2]]
=== Endnote Prompt Citation
This paragraph discusses AI-generated content [[citation::prompt-end::nevent1qvzqqqqqyypzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyprf7ddefkkvdedvredu83pqqn7payvvcnrqp2s72zrx823x0wpezqzpst2]].
== Mixed Citation Usage
You can mix different citation types in the same paragraph. For example, this sentence references both an external source [[citation::inline::nevent1qvzqqqqqrupzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyqsdgj88q2tswy2stc9p5xfaf200kr9le8m75se084upkrqkex9yvwk8faj]] and an internal Nostr event [[citation::inline::nevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhsqgqpl98djyt2eln4uy4dlx2l6a6eyum8acgqz3vfnqptkx54suyyn5u59v88]].
Here's a combination with footnotes and endnotes: This sentence has a footnote.footnote: [[[citation::foot::nevent1qqst8cju0m99ner9ucsu0fw3p0p4v8x0nctvmfx03u67welhnevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyry7njqtmu366utn3422xt84hv6pg3h5vsac602xne63hw7hmrjew8x37w34z5h27wwp4m8x7ttswf0lk2wr8gs4lw9z34vamnwvaz7tmwdaehgu3wvfhkummwvaz7tmjv4kxz7fwdehk6k6]]] and this sentence references an endnote [[citation::end::nevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyry7njqtmu366utn3422xt84hv6pg3h5vsac602xne63hw7hmrjew8x37w3]].
== Citation Display Types Summary
. *Inline* (`inline`, `prompt-inline`): Renders inline within the text as clickable citation text
. *Footnotes* (`foot`): Creates superscript numbers that link to footnotes
. *Foot-End* (`foot-end`): Creates footnotes that link to endnotes at the end
. *Endnotes* (`end`, `prompt-end`): References appear at the end of the document in a references section
. *Quotes* (`quote`): Block-level citation cards for emphasis
== How to Use This Test Document
. Replace all placeholder `nevent1qq...` IDs with actual citation event IDs from your Nostr relays
. Create citations using the Post Editor for kinds 30, 31, 32, and 33
. Copy the nevent ID (or note ID) of your created citation event
. Replace the placeholder IDs in this document
. Publish as an AsciiDoc article (kind 30818) or Wiki Article (kind 30817)
. Verify all citation types render correctly
NOTE: All citation event IDs in this document are placeholder examples. You must replace them with real citation event IDs to test properly.

80
CITATION_TEST_README.md

@ -1,80 +0,0 @@
# Citation Test Content Guide
## File: `CITATION_TEST_CONTENT.adoc`
This file contains comprehensive test examples for embedding all citation types in AsciiDoc articles.
## Citation Format
All citations use plain format (passthrough markers are added automatically during processing):
```
[[citation::TYPE::NEVENT_ID]]
```
## Citation Types Tested
### 1. Internal Citations (Kind 30)
- `inline` - Inline citation within text
- `foot` - Footnote citation
- `end` - Endnote in references section
- `quote` - Block-level citation card
### 2. External Web Citations (Kind 31)
- `inline` - Inline citation
- `foot-end` - Footnote linking to endnote
- `end` - Endnote in references
### 3. Hardcopy Citations (Kind 32)
- `inline` - Inline citation
- `end` - Endnote in references
- `quote` - Block-level citation card
### 4. Prompt Citations (Kind 33)
- `prompt-inline` - Inline prompt citation
- `prompt-end` - Prompt citation in references section
## How to Use
1. **Create Citation Events**: Use the Post Editor to create citations:
- Internal Citation (kind 30)
- External Citation (kind 31)
- Hardcopy Citation (kind 32)
- Prompt Citation (kind 33)
2. **Get Citation IDs**: After creating citations, copy their nevent IDs (or note IDs)
3. **Replace Placeholders**: In the test document, replace all `nevent1qq...` placeholder IDs with your actual citation event IDs
4. **Test in Article**:
- Create a new AsciiDoc article (kind 30818) or Wiki Article (kind 30817)
- Paste the test content (with real citation IDs)
- Publish and verify all citation types render correctly
## Citation Display Types
- **inline** / **prompt-inline**: Renders as clickable text inline
- **foot**: Creates superscript footnote numbers
- **foot-end**: Creates footnotes that link to endnotes
- **end** / **prompt-end**: Appears in References section at end
- **quote**: Block-level citation card for emphasis
## Testing Checklist
- [ ] Internal citations render inline
- [ ] Internal citations render as footnotes
- [ ] Internal citations appear in references section
- [ ] External citations render correctly
- [ ] Hardcopy citations render correctly
- [ ] Prompt citations render correctly (inline and end)
- [ ] Block quote citations display as cards
- [ ] Mixed citations in same paragraph work
- [ ] All citation types are clickable and navigate correctly
## Notes
- Citation IDs can be in format: `nevent1...`, `note1...`, or hex IDs
- All citations must exist on your Nostr relays to render properly
- Endnotes automatically collect at the end in a "References" section
- Footnotes appear at the bottom of the page/section

139
FILES_TO_UPDATE.md

@ -1,139 +0,0 @@
# Files That Should Use Central Services
## Summary
After refactoring `client.service.ts` into focused services, these files should be updated to use the new central services instead of direct client.service calls or bypassing the service layer.
## High Priority Updates
### 1. `src/hooks/useFetchProfile.tsx`
**Current**: Uses `client.getProfileFromIndexedDB()` and `client.fetchProfile()`
**Should Use**: `replaceableEventService.fetchReplaceableEvent()` or new ProfileService
**Benefit**: Gets cache-warming and refresh benefits
### 2. `src/hooks/useFetchEvent.tsx`
**Current**: Directly accesses `client.eventCacheMap` (line 26)
**Should Use**: `eventService.fetchEvent()` and `eventService.getSessionEventsMatchingSearch()`
**Benefit**: Proper encapsulation, better caching
### 3. `src/components/Note/PublicationIndex/PublicationIndex.tsx`
**Current**:
- Directly uses `indexedDb.getReplaceableEvent()` (line 686)
- Uses `client.fetchEvent()` (line 707)
- Has custom `fetchEventFromRelay()` function
**Should Use**:
- `replaceableEventService.fetchReplaceableEvent()`
- `eventService.fetchEvent()`
- `queryService.fetchEvents()` instead of custom relay fetching
**Benefit**: Consistent caching and race-based fetching
### 4. `src/services/note-stats.service.ts`
**Current**: Uses `client.fetchEvents()` (line 128)
**Should Use**: `queryService.fetchEvents()`
**Benefit**: Race-based fetching, better performance
### 5. `src/components/Profile/ProfileBookmarksAndHashtags.tsx`
**Current**:
- Uses `client.fetchEvents()` directly (line 292)
- Uses `client.fetchInterestListEvent()` (line 300)
**Should Use**:
- `queryService.fetchEvents()`
- `replaceableEventService.fetchReplaceableEvent(pubkey, 10015)`
**Benefit**: Consistent query strategies
### 6. `src/components/SimpleNoteFeed/index.tsx`
**Current**: Uses `client.fetchEvents()` (line 89)
**Should Use**: `queryService.fetchEvents()`
**Benefit**: Race-based fetching for better performance
## Medium Priority Updates
### 7. `src/services/mention-event-search.service.ts`
**Current**: Likely uses `client.getSessionEventsMatchingSearch()`
**Should Use**: `eventService.getSessionEventsMatchingSearch()`
**Benefit**: Proper service encapsulation
### 8. `src/components/Bookstr/BookstrContent.tsx`
**Current**: Uses `client.fetchBookstrEvents()`
**Should Use**: `macroService.fetchMacroEvents()` (with type='bookstr')
**Benefit**: Uses new MacroService architecture
### 9. `src/services/relay-selection.service.ts`
**Current**: Uses `client.fetchRelayList()` and `client.getSessionSuccessfulPublishRelayUrlsForRandomPool()`
**Should Use**: New RelayService (to be created)
**Benefit**: Proper relay management
### 10. `src/providers/NostrProvider/index.tsx`
**Current**: Extensive use of `client.fetchRelayList()`, `client.fetchEvents()`, etc.
**Should Use**: All new services
**Benefit**: Cache-warming integration, better performance
## Low Priority (Internal Services)
### 11. `src/services/gif.service.ts`
**Check**: If it uses `client.fetchEvents()` directly
**Should Use**: `queryService.fetchEvents()`
### 12. `src/services/lightning.service.ts`
**Check**: If it fetches events directly
**Should Use**: Appropriate service
### 13. `src/components/Embedded/EmbeddedNote.tsx`
**Check**: If it uses `client.fetchEvent()` directly
**Should Use**: `eventService.fetchEvent()`
## Cache Integration Opportunities
### Files That Should Use CacheService
1. **`src/providers/NostrProvider/index.tsx`**
- Add cache-warming on login
- Use `cacheService.warmupCache()` in initialization
- Use `cacheService.getProfileWithRefresh()` for profiles
- Use `cacheService.getRelayListWithRefresh()` for relay lists
2. **`src/hooks/useFetchProfile.tsx`**
- Use `cacheService.getProfileWithRefresh()` instead of manual cache checking
- Gets automatic background refresh for stale profiles
3. **`src/hooks/useFetchRelayList.tsx`**
- Use `cacheService.getRelayListWithRefresh()` instead of manual cache checking
## Direct IndexedDB Access to Replace
### Files Accessing IndexedDB Directly (Should Use Services)
1. **`src/components/Note/PublicationIndex/PublicationIndex.tsx`**
- Line 686: `indexedDb.getReplaceableEvent()` → Use `replaceableEventService`
- Line 930: `indexedDb.getPublicationEvent()` → Use appropriate service
- Line 934: `indexedDb.getEventFromPublicationStore()` → Use `eventService`
2. **`src/components/Profile/index.tsx`**
- Check for direct IndexedDB access for payment info
- Should use `replaceableEventService.fetchReplaceableEvent(pubkey, ExtendedKind.PAYMENT_INFO)`
## Migration Order
1. **Phase 1**: Update hooks (`useFetchProfile`, `useFetchEvent`, `useFetchRelayList`)
- These are used everywhere, so fixing them benefits all components
2. **Phase 2**: Update core components (`Profile`, `PublicationIndex`)
- High-impact components that users interact with frequently
3. **Phase 3**: Update services (`note-stats`, `mention-event-search`)
- Internal services that can be updated without UI changes
4. **Phase 4**: Update providers (`NostrProvider`)
- Add cache-warming and refresh strategies
5. **Phase 5**: Update remaining components
- Lower priority, but should be done for consistency
## Testing Checklist
After migration, verify:
- [ ] Profiles load quickly (cache-first)
- [ ] Events load quickly (race-based fetching)
- [ ] Cache refreshes in background for stale data
- [ ] No duplicate network requests
- [ ] Cache-warming works on login
- [ ] Background refresh doesn't block UI

71
LOGGING.md

@ -1,39 +1,29 @@
# Logging System # Logging
This document describes the logging system implemented to reduce console noise and improve performance. Imwald uses `src/lib/logger.ts` for application logs. Prefer it over direct `console.*` calls in shared code.
## Overview ## Overview
The application now uses a centralized logging system that: Current behavior:
- Reduces console noise in production - Development without debug mode: `info`, `warn`, and `error` are logged with formatted prefixes.
- Provides conditional debug logging - Development with debug mode: `debug`, `info`, `warn`, `error`, component logs, and performance logs are available.
- Improves performance by removing debug logs in production builds - Production: only `warn` and `error` are emitted, without formatted timestamp/caller strings.
- Allows developers to enable debug logging when needed
## Usage ## Usage
### For Developers ### Browser Console
In development mode, you can control logging from the browser console: In development mode, you can control logging from the browser console:
```javascript ```javascript
// Enable debug logging imwaldLogger.setDebugMode(true)
imwaldDebug.enable()
// Disable debug logging imwaldLogger.setDebugMode(false)
imwaldDebug.disable()
// Check current status imwaldLogger.isDebugEnabled()
imwaldDebug.status()
// Use debug logging directly
imwaldDebug.log('Debug message', data)
imwaldDebug.warn('Warning message', data)
imwaldDebug.error('Error message', data)
imwaldDebug.perf('Performance message', data)
``` ```
(`jumbleDebug` is still exposed as an alias for compatibility.) `jumbleLogger` is still exposed as a legacy alias in development.
### For Code ### For Code
@ -45,23 +35,23 @@ import logger from '@/lib/logger'
// Debug logging (only shows in dev mode with debug enabled) // Debug logging (only shows in dev mode with debug enabled)
logger.debug('Debug information', data) logger.debug('Debug information', data)
// Info logging (always shows) // Info logging (development only by default)
logger.info('Important information', data) logger.info('Important information', data)
// Warning logging (always shows) // Warning logging
logger.warn('Warning message', data) logger.warn('Warning message', data)
// Error logging (always shows) // Error logging
logger.error('Error message', data) logger.error('Error message', data)
// Performance logging (only in dev mode) // Performance logging (development only)
logger.perf('Performance metric', data) logger.perf('Performance metric', data)
``` ```
## Log Levels ## Log Levels
- **debug**: Development debugging information (disabled in production) - **debug**: Development debugging information (disabled in production)
- **info**: Important application information (always enabled) - **info**: Development application information
- **warn**: Warning messages (always enabled) - **warn**: Warning messages (always enabled)
- **error**: Error messages (always enabled) - **error**: Error messages (always enabled)
- **perf**: Performance metrics (development only) - **perf**: Performance metrics (development only)
@ -74,38 +64,13 @@ The logger automatically configures itself based on:
2. **Local Storage**: `imwald-debug=true` enables debug mode (legacy: `jumble-debug=true`) 2. **Local Storage**: `imwald-debug=true` enables debug mode (legacy: `jumble-debug=true`)
3. **Environment Variable**: `VITE_DEBUG=true` enables debug mode 3. **Environment Variable**: `VITE_DEBUG=true` enables debug mode
## Performance Impact
- **Production**: Debug logs are completely removed, improving performance
- **Development**: Debug logs are conditionally enabled, reducing noise
- **Console Operations**: Reduced console.log calls improve browser performance
## Migration
The following files have been updated to use the new logging system:
- `src/providers/FeedProvider.tsx` - Feed initialization and switching
- `src/pages/primary/DiscussionsPage/index.tsx` - Vote counting and event fetching
- `src/services/client.service.ts` - Relay operations and circuit breaker
- `src/providers/NostrProvider/index.tsx` - Event signing and validation
- `src/components/Note/index.tsx` - Component rendering
- `src/PageManager.tsx` - Page rendering
## Benefits
1. **Reduced Console Noise**: Debug logs are hidden by default
2. **Better Performance**: Fewer console operations in production
3. **Developer Control**: Easy to enable debug logging when needed
4. **Consistent Logging**: Centralized logging with consistent format
5. **Production Ready**: Debug logs are completely removed in production builds
## Debug Mode ## Debug Mode
To enable debug mode: To enable debug mode:
1. **In Browser Console** (development only): 1. **In Browser Console** (development only):
```javascript ```javascript
imwaldDebug.enable() imwaldLogger.setDebugMode(true)
``` ```
2. **Via Local Storage**: 2. **Via Local Storage**:
@ -118,4 +83,4 @@ To enable debug mode:
VITE_DEBUG=true npm run dev VITE_DEBUG=true npm run dev
``` ```
Debug mode will show all debug-level logs with timestamps and log levels. Debug mode shows debug-level logs with timestamps, levels, and caller hints.

189
MIGRATION_GUIDE.md

@ -1,189 +0,0 @@
# Migration Guide: ClientService Refactoring
## Overview
The `client.service.ts` (4313 lines) has been refactored into focused service modules. This guide helps migrate existing code to use the new services.
## New Service Architecture
### 1. QueryService (`client-query.service.ts`)
**Purpose**: Core query/subscription logic with race-based fetching
**Key Methods**:
- `query(urls, filter, onevent, options)` - Core query with race strategies
- `subscribe(urls, filter, callbacks)` - Relay subscriptions
- `fetchEvents(urls, filter, options)` - Fetch events with caching
- `trackEventSeenOn(eventId, relay)` - Track where events were seen
- `getSeenEventRelayUrls(eventId)` - Get relays that saw an event
**Migration**: Most internal usage, but if you're calling `query` or `subscribe` directly, use `queryService` instead.
### 2. EventService (`client-events.service.ts`)
**Purpose**: Single event fetching and caching
**Key Methods**:
- `fetchEvent(id)` - Fetch single event by ID
- `fetchEventForceRetry(eventId)` - Force retry fetch
- `fetchEventWithExternalRelays(eventId, externalRelays)` - Fetch with specific relays
- `addEventToCache(event)` - Add to session cache
- `getSessionEventsMatchingSearch(query, limit, allowedKinds)` - Search session cache
- `clearCaches()` - Clear all caches
**Migration**: Replace `client.fetchEvent()` with `eventService.fetchEvent()`
### 3. ReplaceableEventService (`client-replaceable-events.service.ts`)
**Purpose**: Replaceable events (profiles, relay lists, follow lists, etc.)
**Key Methods**:
- `fetchReplaceableEvent(pubkey, kind, d?)` - Fetch replaceable event
- `fetchReplaceableEventsFromBigRelays(pubkeys, kind)` - Batch fetch
- `updateReplaceableEventCache(event)` - Update cache
- `clearCaches()` - Clear caches
**Migration**: Replace `client.fetchProfileEvent()`, `client.fetchRelayListEvent()`, etc. with `replaceableEventService.fetchReplaceableEvent()`
### 4. MacroService (`client-macro.service.ts`)
**Purpose**: Macro-specific events (Bookstr, Wikistr, etc.)
**Key Methods**:
- `fetchMacroEvents(filters)` - Fetch macro events
- `getCachedMacroEvents(filters)` - Get from cache
**Migration**: Replace `client.fetchBookstrEvents()` with `macroService.fetchMacroEvents()`
### 5. CacheService (`client-cache.service.ts`)
**Purpose**: Universal cache-warming and refresh strategy
**Key Methods**:
- `warmupCache(config, fetchFn)` - Warm up cache on login
- `scheduleRefresh(pubkey, kind, fetchFn)` - Schedule background refresh
- `getProfileWithRefresh(pubkey, fetchFn)` - Get profile with auto-refresh
- `getRelayListWithRefresh(pubkey, fetchFn)` - Get relay list with auto-refresh
- `isStale(pubkey, kind, cachedAt)` - Check if cache is stale
- `startPeriodicRefresh(refreshFn)` - Start periodic refresh
**Migration**: Use for cache-warming on login and background refresh
## Files That Need Updates
### High Priority (Direct client.service usage)
1. **`src/providers/NostrProvider/index.tsx`**
- Uses: `client.fetchRelayList()`, `client.fetchProfileEvent()`, `client.fetchEvents()`
- Update: Use `replaceableEventService`, `eventService`, `queryService`
2. **`src/hooks/useFetchProfile.tsx`**
- Uses: `client.fetchProfile()`, `client.getProfileFromIndexedDB()`
- Update: Use `replaceableEventService` or new profile service
3. **`src/hooks/useFetchEvent.tsx`**
- Uses: `client.fetchEvent()`
- Update: Use `eventService.fetchEvent()`
4. **`src/hooks/useFetchRelayList.tsx`**
- Uses: `client.fetchRelayList()`
- Update: Use `replaceableEventService` or new relay service
5. **`src/components/Profile/index.tsx`**
- Uses: `client.fetchPaymentInfoEvent()`, `client.fetchEvents()`
- Update: Use `replaceableEventService`, `queryService`
6. **`src/components/Profile/ProfileBookmarksAndHashtags.tsx`**
- Uses: `client.fetchEvents()`, `client.fetchInterestListEvent()`
- Update: Use `queryService`, `replaceableEventService`
### Medium Priority (Indirect usage)
7. **`src/services/note-stats.service.ts`**
- Uses: `client.fetchEvents()`
- Update: Use `queryService.fetchEvents()`
8. **`src/services/mention-event-search.service.ts`**
- Uses: `client.getSessionEventsMatchingSearch()`
- Update: Use `eventService.getSessionEventsMatchingSearch()`
9. **`src/components/Bookstr/BookstrContent.tsx`**
- Uses: `client.fetchBookstrEvents()`
- Update: Use `macroService.fetchMacroEvents()`
10. **`src/components/Note/PublicationIndex/PublicationIndex.tsx`**
- Uses: `client.fetchEvent()`, `indexedDb.getReplaceableEvent()`
- Update: Use `eventService.fetchEvent()`, `replaceableEventService`
### Low Priority (Internal services)
11. **`src/services/relay-selection.service.ts`**
- Uses: `client.fetchRelayList()`
- Update: Use `replaceableEventService` or new relay service
12. **`src/services/relay-info.service.ts`**
- Uses: `client.fetchEvents()`
- Update: Use `queryService.fetchEvents()`
## Migration Pattern
### Before:
```typescript
import client from '@/services/client.service'
const profile = await client.fetchProfile(pubkey)
const event = await client.fetchEvent(eventId)
const relayList = await client.fetchRelayList(pubkey)
```
### After:
```typescript
import { eventService, replaceableEventService } from '@/services/client.service'
const profileEvent = await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Metadata)
const event = await eventService.fetchEvent(eventId)
const relayListEvent = await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.RelayList)
```
## Integration in Main ClientService
The main `client.service.ts` will be refactored to:
1. Instantiate all sub-services
2. Delegate method calls to appropriate services
3. Maintain backward compatibility during transition
4. Gradually remove old implementations
## Cache Warming Integration
Add to `NostrProvider` initialization:
```typescript
import cacheService from '@/services/client-cache.service'
// On login/initialization
await cacheService.warmupCache({
profilePubkeys: [account.pubkey, ...recentInteractions],
relayListPubkeys: [account.pubkey],
warmupFollowLists: true,
warmupMuteLists: true
}, {
fetchProfile: (id) => replaceableEventService.fetchReplaceableEvent(...),
fetchRelayList: (pubkey) => relayService.fetchRelayList(pubkey),
// ...
})
// Start periodic refresh
cacheService.startPeriodicRefresh(async (pubkey, kind) => {
await replaceableEventService.fetchReplaceableEvent(pubkey, kind)
})
```
## Benefits
1. **Performance**: Race-based fetching reduces wait times from 10-30s to 1-3s
2. **Cache efficiency**: Universal cache-warming and refresh strategy
3. **Maintainability**: Focused services are easier to understand and modify
4. **Testability**: Services can be tested independently
5. **Extensibility**: Easy to add new macro types or event types
## Next Steps
1. Complete remaining service extractions (ProfileService, RelayService, TimelineService)
2. Update main `client.service.ts` to orchestrate sub-services
3. Migrate high-priority files first
4. Test thoroughly
5. Remove old code once migration is complete

502
PROXY_SETUP.md

@ -1,515 +1,51 @@
# Proxy Server Setup for Production # Proxy Setup
## Problem Imwald uses same-origin proxy paths in production so browsers do not need cross-origin CORS exceptions.
The proxy server isn't working because `VITE_PROXY_SERVER` is being set to `http://localhost:8090` during the Docker build, which won't work from the browser on a remote server.
## Solution ## Link Preview / RSS Proxy
When building and deploying on the remote server, you need to build the Docker image with the correct build argument. Set `VITE_PROXY_SERVER` at build time. The current client contract is:
### For Manual Docker Run Commands ```text
${VITE_PROXY_SERVER}/sites/?url=<encoded-url>
**IMPORTANT:** `VITE_PROXY_SERVER` must be set during Docker BUILD (as a build argument), NOT at runtime. It gets baked into the JavaScript bundle.
Rebuild the Imwald image with the correct proxy URL:
```bash
# Build with the correct proxy URL (baked into the JS bundle)
#
# IMPORTANT: Since users access via HTTPS (https://jumble.imwald.eu),
# the proxy URL MUST also use HTTPS or browsers will block it (mixed content).
#
# You have two options:
#
# Option 1: Configure HTTPS for port 8090 (requires SSL certificate)
# docker build --build-arg VITE_PROXY_SERVER=https://jumble.imwald.eu:8090 ...
#
# Option 2: Route proxy through your reverse proxy (recommended if you have nginx/Apache)
# Configure reverse proxy to route /proxy/* to http://localhost:8090/
# The code constructs: ${proxyServer}/sites/${encodeURIComponent(url)}
# So /proxy/sites/... is forwarded to http://localhost:8090/sites/...
# Then use: https://jumble.imwald.eu/proxy
#
# Using Option 2 (recommended - route through Apache reverse proxy):
docker build \
--build-arg VITE_PROXY_SERVER=https://jumble.imwald.eu/proxy \
-t silberengel/imwald-jumble:12 \
.
# Then push to Docker Hub
docker push silberengel/imwald-jumble:12
# Then on the remote server, pull and restart:
docker stop imwald-jumble
docker rm imwald-jumble
docker pull silberengel/imwald-jumble:12
# Run with the same command (NO env vars needed for proxy - it's already in the bundle)
docker run -d \
--name imwald-jumble \
--network jumble-network \
-p 0.0.0.0:32768:80 \
--restart unless-stopped \
silberengel/imwald-jumble:12
```
**Note on Docker Network:**
You only need to create the network once (it persists). Check if it exists first:
```bash
# Check if network exists
docker network ls | grep jumble-network
# If it doesn't exist, create it (only needed once)
docker network create jumble-network
```
### For Docker Compose
### 1. Set Environment Variables Before Building
```bash
# Use the reverse proxy route if you've configured Apache/nginx
export JUMBLE_PROXY_SERVER_URL="https://jumble.imwald.eu/proxy"
export JUMBLE_SOCIAL_URL="https://jumble.imwald.eu"
```
### 2. Rebuild the Docker Image
```bash
docker-compose build --no-cache
``` ```
### 3. Restart the Containers For the public deployment this is normally:
```bash ```bash
docker-compose down VITE_PROXY_SERVER=https://jumble.imwald.eu
docker-compose up -d
``` ```
## How to Check if it's Working Apache/nginx should route `/sites/` to the OG proxy container, before the SPA catch-all.
1. After deploying, open the browser console on `https://jumble.imwald.eu` Apache example:
2. Navigate to a page with a URL that should show OpenGraph data
3. Look for `[WebService]` log messages that will show:
- Whether the proxy server is configured
- What URL is being used to fetch metadata
- Any errors (CORS, network, etc.)
## Read-aloud / Piper TTS (same-origin `/api/piper-tts`)
The client uses **`POST /api/piper-tts`** on the **same host** as the app (default build: `VITE_READ_ALOUD_TTS_URL=/api/piper-tts`) so the browser does not need cross-origin CORS.
**Backend:** Wyoming Piper (`silberengel/wyoming-piper`, TCP **10200**) has no HTTP API. You need a small bridge that accepts `POST /api/piper-tts` and talks Wyoming over TCP.
- **In this repo:** `services/piper-tts-proxy/` — HTTP server with the same JSON/WAV contract as the old aitherboard route. Build from repo root:
- `docker build -f services/piper-tts-proxy/Dockerfile -t imwald-piper-tts-proxy .`
- Run on **the same Docker network** as the `piper-tts` container so hostname **`piper-tts`** resolves, e.g.:
- `docker network create piper-stack` (once)
- `docker network connect piper-stack piper-tts`
- `docker run -d --name imwald-piper-tts-proxy --restart unless-stopped --network piper-stack -p 127.0.0.1:9876:9876 -e PIPER_TTS_HOST=piper-tts -e PIPER_TTS_PORT=10200 imwald-piper-tts-proxy`
- Alternatively, publish Wyoming on the host (`-p 127.0.0.1:10200:10200` on `piper-tts`) and run the proxy with `--network host` and `PIPER_TTS_HOST=127.0.0.1`.
Add these **before** the catch-all `ProxyPass /` to the Imwald static container (same ordering as `/sites/`). Example **full** fragment (matches a typical TLS vhost; adjust `ServerName` / SSL paths as you do today):
```apache ```apache
ProxyPreserveHost On
ProxyRequests Off
# WebSocket upgrade handling - CRITICAL for Nostr apps
RewriteEngine On
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://127.0.0.1:8089/$1" [P,L]
# OG / link preview (before catch-all)
ProxyPass /sites/ http://127.0.0.1:8090/sites/ ProxyPass /sites/ http://127.0.0.1:8090/sites/
ProxyPassReverse /sites/ http://127.0.0.1:8090/sites/ ProxyPassReverse /sites/ http://127.0.0.1:8090/sites/
# Read-aloud Piper — same-origin /api/piper-tts → imwald-piper-tts-proxy (HTTP) → Wyoming piper-tts:10200 (before catch-all)
ProxyPass /api/piper-tts http://127.0.0.1:9876/api/piper-tts
ProxyPassReverse /api/piper-tts http://127.0.0.1:9876/api/piper-tts
# Static SPA (catch-all — must be last)
ProxyPass / http://127.0.0.1:8089/ ProxyPass / http://127.0.0.1:8089/
ProxyPassReverse / http://127.0.0.1:8089/ ProxyPassReverse / http://127.0.0.1:8089/
ProxyAddHeaders On
Header always set X-Forwarded-Proto "https"
Header always set X-Forwarded-Port "443"
```
Use the port where **`imwald-piper-tts-proxy`** listens (example **`9876`**). Reload Apache, then test:
```bash
curl -sS -o /tmp/t.wav -w "%{http_code}\n" -H "Content-Type: application/json" \
-d '{"text":"test","speed":1}' "https://jumble.imwald.eu/api/piper-tts"
```
Expect **200** and a WAV file. **Local dev:** `npm run dev` proxies `/api/piper-tts``http://127.0.0.1:9876` in `vite.config.ts`.
Rebuild the Imwald image after changing `VITE_READ_ALOUD_TTS_URL`; `Dockerfile` passes `ARG`/`ENV` `VITE_READ_ALOUD_TTS_URL` into `npm run build`.
## LanguageTool (same-origin `/api/languagetool`)
The advanced event lab can call **`POST /v2/check`** on a self-hosted [LanguageTool](https://github.com/languagetool-org/languagetool) server. Set **`VITE_LANGUAGE_TOOL_URL=/api/languagetool`** at build time and proxy to your LT HTTP port (default **8010**).
Apache (before the catch-all `ProxyPass /`):
```apache
ProxyPass /api/languagetool http://127.0.0.1:8010
ProxyPassReverse /api/languagetool http://127.0.0.1:8010
``` ```
**Local dev:** `vite.config.ts` proxies `/api/languagetool``http://127.0.0.1:8010` with path rewrite so `/api/languagetool/v2/check` reaches LT’s `/v2/check`. ## Optional Same-Origin APIs
If `VITE_LANGUAGE_TOOL_URL` is empty, grammar hints in the lab are disabled.
### Docker sidecars (LanguageTool + LibreTranslate) These are enabled by build-time URLs:
The repo defines optional Compose services on **host ports 8010** and **5000** (same targets as `vite.config.ts`). They use profile **`editor-tools`** so a plain `docker compose up` does not pull them unless you ask.
```bash ```bash
# Dev compose (with relay, etc.): start only grammar + translate VITE_READ_ALOUD_TTS_URL=/api/piper-tts
docker compose -f docker-compose.dev.yml --profile editor-tools up -d languagetool libretranslate
# Or npm alias
npm run docker:editor-tools
```
Then point Vite at the proxies (e.g. **`.env.local`**):
```
VITE_LANGUAGE_TOOL_URL=/api/languagetool VITE_LANGUAGE_TOOL_URL=/api/languagetool
VITE_TRANSLATE_URL=/api/translate VITE_TRANSLATE_URL=/api/translate
``` ```
**Production:** `docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d` starts the full stack (including LanguageTool on **127.0.0.1:8010** and LibreTranslate on **127.0.0.1:5000**). Run `bash scripts/ensure-libretranslate-dirs.sh` once on the server (LibreTranslate UID **1032** on `.local-libretranslate`, Piper ONNX into `.local-piper-data` and the **`_piper-stack-data`** Docker volume when it exists). Proxy `/api/languagetool` and `/api/translate` from Apache/nginx to those ports, and bake the client with `LANGUAGE_TOOL_URL=/api/languagetool` and `TRANSLATE_URL=/api/translate` when running `./scripts/build-and-push-prod.sh`. Proxy targets:
**Notes:** LanguageTool’s JVM image often needs **~1–2GiB** RAM. LibreTranslate **does not listen on port 5000 until models are ready**; without **`LT_LOAD_ONLY`** it may pull **many gigabytes** first, so the Vite proxy can show **`ECONNRESET` on `/translate`** while booting. Compose loads **`LT_LOAD_ONLY`** from **`scripts/libretranslate-lt.default.env`** (same file is read by **`scripts/ensure-libretranslate-dirs.sh`** and **`scripts/prune-libretranslate-packages.sh`**). Edit that file to add or remove codes, then recreate LibreTranslate; first start downloads packs for every listed code. For a one-off prune without editing the file, run **`export LT_LOAD_ONLY=…`** before **`npm run docker:prune-libretranslate-packages`**. **`LT_UPDATE_MODELS`** defaults to **`true`** so if you **expand** `LT_LOAD_ONLY` later, a **recreated** container still **installs missing** Argos packages into the bind-mounted `.local-libretranslate` tree (otherwise an older en/de-only cache sticks). Set **`LT_UPDATE_MODELS=false`** after everything is installed if you want faster routine restarts. Models are stored under **`.local-libretranslate/share`** and **`.local-libretranslate/cache`** (gitignored) with **bind mounts** so they survive **`docker compose down`**, image updates, and container recreate. **`scripts/ensure-libretranslate-dirs.sh`** (run automatically by **`npm run dev:all`**, **`npm run stack:remote`**, **`npm run docker:editor-tools`**, etc.) creates those dirs and **`chown`s them to UID 1032** via a short **Alpine** container so the LibreTranslate user can write. If you start **`libretranslate` by hand**, run **`npm run docker:prep-libretranslate`** once first. First download can still take **several minutes**; use **`docker logs -f jumble-libretranslate`** until **`curl http://127.0.0.1:5000/languages`** returns JSON. If logs show **`Cannot update models`** / **`Unavailable language codes: …`**, one bad token in **`LT_LOAD_ONLY` aborts the whole install** (you stay on whatever was already on disk, often en/de only). **Norwegian** must be **`nb`** (Bokmål), not ISO **`no`**. After you shrink **`LT_LOAD_ONLY`**, run **`npm run docker:prune-libretranslate-packages`** to remove leftover Argos package dirs under **`.local-libretranslate/share/argos-translate/packages`** (and unused **MiniSBD** `.onnx` files); the script briefly stops **`jumble-libretranslate`** or **`imwald-libretranslate`**.
## LibreTranslate (same-origin `/api/translate`)
Optional **`VITE_TRANSLATE_URL=/api/translate`** for `POST /translate` (LibreTranslate-compatible). Example Apache:
```apache ```apache
ProxyPass /api/piper-tts http://127.0.0.1:9876/api/piper-tts
ProxyPassReverse /api/piper-tts http://127.0.0.1:9876/api/piper-tts
ProxyPass /api/languagetool http://127.0.0.1:8010
ProxyPassReverse /api/languagetool http://127.0.0.1:8010
ProxyPass /api/translate http://127.0.0.1:5000 ProxyPass /api/translate http://127.0.0.1:5000
ProxyPassReverse /api/translate http://127.0.0.1:5000 ProxyPassReverse /api/translate http://127.0.0.1:5000
``` ```
**Local dev:** `vite.config.ts` proxies `/api/translate``http://127.0.0.1:5000` with path rewrite. For the full production workflow, use `scripts/README-deploy.md` and `docker-compose.prod.yml`.
If `VITE_TRANSLATE_URL` is empty, translate actions in the advanced lab are hidden.
## Update Proxy Server's ALLOW_ORIGIN
Since users access via `https://jumble.imwald.eu`, you need to update the proxy server's `ALLOW_ORIGIN`:
```bash
# Stop the proxy container
docker stop imwald-jumble-proxy
docker rm imwald-jumble-proxy
# Restart with correct ALLOW_ORIGIN (must match how users access the frontend)
docker run -d \
--name imwald-jumble-proxy \
--network jumble-network \
-p 0.0.0.0:8090:8080 \
-e ALLOW_ORIGIN=https://jumble.imwald.eu \
-e ENABLE_PPROF=true \
--restart unless-stopped \
ghcr.io/danvergara/jumble-proxy-server:latest
```
## HTTPS Certificate Setup for Port 8090
Since users access via `https://jumble.imwald.eu`, you need HTTPS for the proxy to avoid mixed content errors.
**Option A: SSL Certificate for Port 8090**
If you want to access the proxy directly on port 8090 with HTTPS, you'll need an SSL certificate:
```bash
# Using Let's Encrypt with certbot
certbot certonly --standalone -d jumble.imwald.eu --expand
# Then configure your reverse proxy or the proxy server itself to use the certificate
# The exact steps depend on your setup (nginx, Apache, or direct in the proxy container)
```
**Option B: Route Through Reverse Proxy (Recommended)**
If you already have a reverse proxy (nginx/Apache) handling HTTPS for `jumble.imwald.eu`, route the proxy through it:
### Apache Reverse Proxy Setup
1. **Enable required Apache modules:**
```bash
sudo a2enmod proxy
sudo a2enmod proxy_http
sudo a2enmod rewrite
sudo a2enmod headers
sudo systemctl restart apache2
```
2. **Add reverse proxy configuration to your Apache virtual host** (typically in `/etc/apache2/sites-available/jumble.imwald.eu-le-ssl.conf` or similar):
```apache
<IfModule mod_ssl.c>
<VirtualHost 217.154.126.125:443>
ServerName jumble.imwald.eu
ServerAlias www.jumble.imwald.eu
# Reverse Proxy Configuration
# Proxy for the jumble-proxy-server (must come BEFORE the catch-all / rule)
# The code constructs: ${proxyServer}/sites/${encodeURIComponent(url)}
# So /proxy/sites/... needs to be forwarded to http://127.0.0.1:8090/sites/...
# IMPORTANT: Use Location block to scope headers properly for /proxy/ path only
<Location /proxy/>
ProxyPreserveHost Off
ProxyPass http://127.0.0.1:8090/
ProxyPassReverse http://127.0.0.1:8090/
# Unset forwarded headers that might make the proxy server use Host header instead of URL path
RequestHeader unset X-Forwarded-Host
RequestHeader unset X-Forwarded-Server
RequestHeader set Host "127.0.0.1:8090"
</Location>
# Reverse Proxy for the main Imwald app (needs Host header preserved)
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:32768/
ProxyPassReverse / http://127.0.0.1:32768/
# Headers for proper proxying
Header always set X-Forwarded-Proto https
Header always set X-Forwarded-Port 443
Include /etc/letsencrypt/options-ssl-apache.conf
SSLCertificateFile /etc/letsencrypt/live/jumble.imwald.eu/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/jumble.imwald.eu/privkey.pem
</VirtualHost>
</IfModule>
```
**Important:** The code constructs URLs like `https://jumble.imwald.eu/proxy/sites/https%3A%2F%2Fexample.com`. Apache receives `/proxy/sites/https%3A%2F%2Fexample.com` and forwards it to `http://127.0.0.1:8090/sites/https%3A%2F%2Fexample.com` (strips `/proxy` prefix).
3. **Enable the site (if not already enabled):**
```bash
sudo a2ensite jumble.imwald.eu-le-ssl.conf
```
4. **Reload Apache:**
```bash
sudo apache2ctl configtest # Check for errors first
sudo systemctl reload apache2
```
5. **Test the proxy route:**
```bash
# Test with a real URL - the code constructs /proxy/sites/{encoded-url}
curl https://jumble.imwald.eu/proxy/sites/https%3A%2F%2Fexample.com
# Should return example.com's HTML, NOT jumble.imwald.eu's HTML
# If you see Imwald HTML, the proxy server is using the Host header instead of the URL path
```
**If the test returns Imwald HTML instead of the requested site's HTML:**
The proxy server is using the `Host` header (`jumble.imwald.eu`) to determine what to fetch. Update your Apache config to use `ProxyPreserveHost Off` for the `/proxy/` path:
```apache
# In your Apache config, change from:
ProxyPreserveHost On
ProxyPass /proxy/ http://127.0.0.1:8090/
# To:
ProxyPreserveHost Off
ProxyPass /proxy/ http://127.0.0.1:8090/
ProxyPassReverse /proxy/ http://127.0.0.1:8090/
# Then set it back to On for the main app:
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:32768/
```
Then reload Apache and test again.
6. **Build with the proxy URL:**
```bash
docker build \
--build-arg VITE_PROXY_SERVER=https://jumble.imwald.eu/proxy \
-t silberengel/imwald-jumble:12 \
.
```
**Note:** The proxy URL in `VITE_PROXY_SERVER` should be `https://jumble.imwald.eu/proxy` (without trailing slash), and the code will append `/sites/...` automatically.
## Important Notes
- The `VITE_PROXY_SERVER` value is baked into the JavaScript bundle during build time
- You MUST rebuild the Docker image if you change `VITE_PROXY_SERVER`
- The proxy server's `ALLOW_ORIGIN` must match the frontend URL users access (`https://jumble.imwald.eu`)
- Mixed content: HTTPS pages cannot load HTTP resources - both must use HTTPS
- If using direct port access (8090), you need an SSL certificate for that port
## Troubleshooting
### OG proxy (`docker logs …og-proxy`): `fetch failed` / `Retryable error`
The **wikistr** image uses Node **`fetch`** to load the target URL. Inside Docker, transient DNS failures often show up as **`TypeError: fetch failed`** with cause **`getaddrinfo EAI_AGAIN`** (the proxy then retries and can spam logs). Compose sets **`dns: [1.1.1.1, 8.8.8.8]`** on **`og-proxy`** so lookups do not rely only on Docker’s internal resolver. If your network blocks third-party DNS, remove or override that **`dns`** block (compose override file or forked image).
Quick checks:
```bash
docker exec jumble-og-proxy getent hosts example.com
docker exec jumble-og-proxy node -e "fetch('https://example.com').then(r=>console.log('HTTP',r.status)).catch(e=>console.error(e.cause||e))"
curl -sS -o /dev/null -w '%{http_code}\n' -H 'Origin: http://localhost:5173' 'http://127.0.0.1:8090/sites/?url=https%3A%2F%2Fexample.com'
```
### If Proxy Returns Imwald HTML Instead of Requested Site
If you've set `ProxyPreserveHost Off` but still get Imwald HTML, test the proxy server directly:
**1. Test the proxy server directly (bypassing Apache):**
```bash
# Test direct connection to proxy on port 8090
curl http://127.0.0.1:8090/sites/https%3A%2F%2Fexample.com
# Should return example.com's HTML, NOT jumble.imwald.eu's HTML
```
**2. Check proxy server logs:**
```bash
docker logs imwald-jumble-proxy --tail 50
# Look for what URL the proxy is trying to fetch
# This will show if the proxy server is receiving the correct path or if it's using Host header
```
**2b. Check what request the proxy server is actually receiving:**
```bash
# Enable verbose logging in Apache to see what it's forwarding
# Or check what the proxy receives by making a test request and watching logs:
docker logs -f imwald-jumble-proxy &
# Then in another terminal:
curl -v https://jumble.imwald.eu/proxy/sites/https%3A%2F%2Fexample.com
# Look at the proxy logs to see what URL it tried to fetch
```
**3. Check if Apache is receiving `/proxy/` requests:**
```bash
# Watch Apache access log to see if /proxy/ requests reach Apache
sudo tail -f /var/log/apache2/access.log | grep proxy
# Then in another terminal, make a request:
curl https://jumble.imwald.eu/proxy/sites/https%3A%2F%2Fexample.com
# Check if the request appears in Apache logs
```
**3b. Check what Apache is forwarding:**
```bash
# Check Apache error log for proxy-related entries
sudo tail -f /var/log/apache2/error.log
# Then make a request and see what Apache is doing
```
**3c. Verify Apache config is being used:**
```bash
# Check that your Location block syntax is correct:
sudo apache2ctl -S | grep jumble.imwald.eu
# Make sure the site is enabled and config syntax is correct:
sudo apache2ctl configtest
```
**4. Possible Issues:**
**IMPORTANT: If you see `Server: nginx` in response headers, nginx is in front of Apache!**
If you see `Server: nginx/1.29.3` or `X-Powered-By: PleskLin` in the response headers, nginx reverse proxy is handling requests before Apache. You need to configure nginx directly (bypassing Plesk interface) to pass `/proxy/` to Apache, or route it directly to the proxy server.
**If nginx is in front of Apache (bypassing Plesk interface):**
Since nginx is handling requests before Apache, configure nginx directly by editing nginx config files.
**Option A: Configure nginx to pass `/proxy/` through to Apache:**
1. Find nginx config for your domain:
```bash
# Plesk usually stores configs here:
/etc/nginx/conf.d/vhost.conf
# or
/etc/nginx/conf.d/jumble.imwald.eu.conf
# or check Plesk's nginx vhosts:
/etc/nginx/plesk.conf.d/vhosts/jumble.imwald.eu.conf
```
2. Edit the nginx config file for `jumble.imwald.eu`
3. Add this location block BEFORE any catch-all location:
```nginx
location /proxy/ {
# Forward to Apache (check what port Apache is listening on)
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```
**Note:** Replace `8080` with the port Apache is actually listening on. Check with:
```bash
sudo netstat -tlnp | grep apache
# or
sudo ss -tlnp | grep apache
```
4. Test nginx config: `sudo nginx -t`
5. Reload nginx: `sudo systemctl reload nginx`
**Then Apache will handle it with your existing Apache config.**
**Option B: Configure nginx to route `/proxy/` directly to the proxy server (simpler):**
1. Edit nginx config for `jumble.imwald.eu` (same location as above)
2. Add this location block BEFORE any catch-all location:
```nginx
location /proxy/ {
proxy_pass http://127.0.0.1:8090/;
proxy_set_header Host 127.0.0.1:8090;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Don't send X-Forwarded-Host which might confuse the proxy
proxy_set_header X-Forwarded-Host "";
}
```
3. Test nginx config: `sudo nginx -t`
4. Reload nginx: `sudo systemctl reload nginx`
This bypasses Apache entirely for `/proxy/` requests and goes directly to the proxy server.
**Other possible issues:**
- The proxy server might be using the `X-Forwarded-Host` header instead of the URL path
- The proxy server might need a specific Host header value
- The proxy server might not be correctly parsing `/sites/...` path
**5. Unset forwarded headers that might confuse the proxy server:**
```apache
# The proxy server might be using X-Forwarded-Host instead of the URL path
# Unset or modify these headers for the /proxy/ path:
ProxyPass /proxy/ http://127.0.0.1:8090/
ProxyPassReverse /proxy/ http://127.0.0.1:8090/
ProxyPreserveHost Off
# Unset forwarded headers that might interfere
RequestHeader unset X-Forwarded-Host
RequestHeader unset X-Forwarded-Server
RequestHeader set Host "127.0.0.1:8090"
```
### Other Console Errors
If you see errors in the console:
- `[WebService] No proxy server configured` - `VITE_PROXY_SERVER` is undefined or empty
- `[WebService] CORS/Network error` - The proxy URL might be wrong, or CORS isn't configured
- `[WebService] Failed to fetch metadata` - The proxy server might not be running or accessible

14
README.md

@ -1,9 +1,5 @@
<div align="center"> <div align="center">
<picture> <img src="./resources/banner.png" alt="Imwald" width="650" />
<source media="(prefers-color-scheme: dark)" srcset="./resources/logo-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="./resources/logo-light.svg">
<img src="./resources/logo-light.svg" alt="Imwald logo" width="400" />
</picture>
<p>logo designed by <a href="http://wolfertdan.com/">Daniel David</a></p> <p>logo designed by <a href="http://wolfertdan.com/">Daniel David</a></p>
</div> </div>
@ -11,17 +7,15 @@
**Maintainer: [Silberengel](https://github.com/Silberengel)** · Evolved from [Cody Tseng’s Jumble](https://github.com/CodyTseng/jumble) **Maintainer: [Silberengel](https://github.com/Silberengel)** · Evolved from [Cody Tseng’s Jumble](https://github.com/CodyTseng/jumble)
A Nostr web client focused on relay feeds, discovery, and spells. **Imwald** keeps the same core ideas as upstream, with a substantial navigation and information-architecture rewrite (see below). The public instance lives at [jumble.imwald.eu](https://jumble.imwald.eu). A Nostr web client focused on relay feeds, discovery, and spells. The public instance lives at [jumble.imwald.eu](https://jumble.imwald.eu).
--- ---
## Major rewrite (this line) ## Product Shape
High-level changes versus a “stock” Jumble-style layout:
### Home vs feed ### Home vs feed
- **Home** is the **Explore** experience: relay directory, **Following’s Favorites**, and related discovery — not a duplicate of your main timeline. - **Home** is the **Explore** experience: relay directory, Following’s Favorites, and related discovery.
- **Feed** is a dedicated primary area for **favorite relays**, displaying their diverse social content as a feed: short text notes (microblogging), longform articles, wiki pages, media notes, calendar entries, etc. - **Feed** is a dedicated primary area for **favorite relays**, displaying their diverse social content as a feed: short text notes (microblogging), longform articles, wiki pages, media notes, calendar entries, etc.
### RSS ### RSS

160
REFACTORING_COMPLETE.md

@ -1,160 +0,0 @@
# ClientService Refactoring - Completion Summary
## Overview
The monolithic `client.service.ts` (originally 4312 lines) has been successfully refactored into a modular architecture with focused sub-services.
## Results
### File Size Reduction
- **Before**: 4312 lines
- **After**: 2119 lines
- **Reduction**: 50.8% (2193 lines removed/refactored)
### Services Created
1. **QueryService** (`client-query.service.ts`) - 437 lines
- Core query/subscription logic
- Race-based fetching strategies (replaceableRace, immediateReturn)
- Relay connection management
- Event tracking (seenOnRelays)
- Concurrent subscription management
2. **EventService** (`client-events.service.ts`) - 267 lines
- Single event fetching by ID (hex, note1, nevent1, naddr1)
- Event caching with DataLoader
- Session cache management
- Force retry and external relay fetching
3. **ReplaceableEventService** (`client-replaceable-events.service.ts`) - 230 lines
- Replaceable event fetching (profiles, relay lists, follow lists, etc.)
- Batch operations with DataLoader
- Cache coordination with IndexedDB
4. **MacroService** (`client-macro.service.ts`) - 310 lines
- Macro-specific event fetching (Bookstr, Wikistr, extensible)
- Macro metadata extraction
- Specialized filtering and verse range expansion
- Cache-first strategy with background refresh
5. **CacheService** (`client-cache.service.ts`) - 311 lines
- Universal cache-warming strategy
- Cache refresh scheduling
- TTL management
- Background refresh coordination
## Architecture
### Service Dependencies
```
ClientService (orchestrator)
├── QueryService (core query logic)
├── EventService (depends on QueryService)
├── ReplaceableEventService (depends on QueryService)
├── MacroService (depends on QueryService)
└── CacheService (standalone, used by providers)
```
### Delegation Pattern
The main `ClientService` now acts as an orchestrator:
- **39+ method delegations** to sub-services
- Maintains backward compatibility
- Handles complex orchestration (publishing, timeline subscriptions)
- Manages cross-cutting concerns (relay selection, profile search)
## Key Improvements
### 1. Performance
- **Race-based fetching**: Replaceable events use 2-second wait strategy
- **Immediate return**: Single events by ID return on first match
- **Batch operations**: DataLoader batching reduces network calls
- **Cache-first**: IndexedDB checked before network requests
### 2. Maintainability
- **Focused services**: Each service has a single responsibility
- **Clear boundaries**: Services are testable in isolation
- **Reduced complexity**: Main service is 50% smaller
- **Better organization**: Related functionality grouped together
### 3. Extensibility
- **MacroService**: Easy to add new macro types (Wikistr, etc.)
- **QueryService**: Centralized query logic for all event types
- **ReplaceableEventService**: Handles all replaceable event kinds uniformly
## What Remains in ClientService
The following responsibilities remain in `ClientService` as they represent core orchestration:
1. **Publishing** (`publishEvent`, `determineTargetRelays`)
- Complex relay selection logic
- Publish statistics and failure tracking
- Authentication handling
2. **Timeline Subscriptions** (`subscribeTimeline`)
- Complex state management
- Progressive loading
- Timeline reference tracking
3. **Profile Search** (`searchProfiles`, `searchProfilesFromLocal`)
- FlexSearch index management
- Local profile search
4. **Relay List Merging** (`fetchRelayLists`)
- Complex merging of cache relays with regular relay lists
- Offline-first strategy
## Code Quality
### Linter Status
- ✅ **0 errors**
- ✅ **0 warnings**
- ✅ All unused imports removed
- ✅ All unused methods removed
- ✅ All duplicate implementations removed
### Logger Integration
- ✅ Efficient logger implementation
- ✅ Development: Browser console
- ✅ Production: Console GUI in Imwald app
- ✅ Performance logging included
## Migration Status
### Completed
- ✅ All sub-services created and integrated
- ✅ Main service refactored to orchestrate sub-services
- ✅ Legacy code removed
- ✅ Code cleaned and optimized
### Remaining (Optional)
The following files could be updated to use sub-services directly (see `FILES_TO_UPDATE.md`):
- Hooks: `useFetchProfile`, `useFetchEvent`, `useFetchRelayList`
- Components: `Profile`, `PublicationIndex`, `ProfileBookmarksAndHashtags`
- Services: `note-stats.service`, `mention-event-search.service`
- Providers: `NostrProvider` (for cache-warming integration)
These updates are **optional** as the current delegation pattern maintains backward compatibility.
## Testing Recommendations
1. **Unit Tests**: Test each service independently
2. **Integration Tests**: Test service interactions
3. **Performance Tests**: Verify race-based fetching improvements
4. **Cache Tests**: Verify cache-warming and refresh strategies
## Next Steps (Optional)
1. **Cache-Warming Integration**: Add cache-warming to `NostrProvider` on login
2. **Direct Service Usage**: Update high-priority files to use services directly
3. **Additional Services**: Consider extracting TimelineService or RelayService if needed
4. **Documentation**: Add JSDoc comments to public methods
## Conclusion
The refactoring is **complete and production-ready**. The codebase is now:
- ✅ **Clean**: 0 linter errors/warnings
- ✅ **Performant**: Race-based fetching, cache-first strategy
- ✅ **Robust**: Proper error handling, logging
- ✅ **Maintainable**: Focused services, clear boundaries
- ✅ **Extensible**: Easy to add new features
The main `ClientService` now serves as a clean orchestrator, delegating to specialized sub-services while maintaining backward compatibility.

80
REFACTORING_PLAN.md

@ -1,80 +0,0 @@
# ClientService Refactoring Plan
## Overview
Breaking down the 4313-line `client.service.ts` into focused, maintainable services with universal cache-warming strategy.
## Service Architecture
### 1. **QueryService** (`client-query.service.ts`) ✅
- Core query/subscription logic
- Race-based fetching strategies
- Relay connection management
- Event tracking
### 2. **CacheService** (`client-cache.service.ts`) ✅
- Universal cache-warming strategy
- Cache refresh scheduling
- TTL management
- Background refresh coordination
### 3. **EventService** (`client-events.service.ts`) ✅
- Single event fetching
- Event caching
- Session cache management
- DataLoader integration
### 4. **ReplaceableEventService** (`client-replaceable-events.service.ts`) ✅
- Replaceable event fetching (profiles, relay lists, etc.)
- Batch operations
- Cache coordination
### 5. **MacroService** (`client-macro.service.ts`) ✅
- Macro-specific event fetching (Bookstr, etc.)
- Macro metadata extraction
- Specialized filtering
- Extensible for future macro types
### 6. **CacheService** (`client-cache.service.ts`) ✅
- Universal cache-warming strategy
- Cache refresh scheduling
- TTL management
- Background refresh coordination
### Note on Additional Services
The following services were considered but are currently handled within `ClientService` as orchestration logic:
- **Profile search/index**: Handled in `ClientService` with delegation to `ReplaceableEventService` for fetching
- **Relay management**: Publishing and relay selection remain in `ClientService` as core orchestration
- **Timeline subscriptions**: Complex state management remains in `ClientService` but uses `QueryService` and `EventService`
## Cache Strategy
### Cache-Warming
- On login: Warm up current user's profile, relay list, follow list
- On feed load: Warm up profiles for visible pubkeys (batch, limited to 50)
- Background: Periodically refresh stale entries
### Cache-Refreshing
- Stale detection: Check `addedAt` timestamp vs refresh thresholds
- Background refresh: Non-blocking, queued refresh for stale entries
- Periodic refresh: Every 5 minutes, check and refresh stale profiles
### TTLs
- Profiles: 30 min cache, 15 min refresh threshold
- Payment info: 5 min cache, 2 min refresh threshold
- Relay lists: 15 min cache, 10 min refresh threshold
- Follow/Mute lists: 60 min cache, 30 min refresh threshold
## Integration Strategy
1. Create service instances in main `ClientService`
2. Inject dependencies (QueryService into others)
3. Maintain backward compatibility during transition
4. Gradually migrate methods to use new services
5. Remove old code once migration complete
## Performance Benefits
- **Faster initial load**: Cache-warming pre-fetches critical data
- **Better responsiveness**: Background refresh keeps cache fresh without blocking UI
- **Reduced network calls**: Smart cache invalidation prevents unnecessary fetches
- **Improved maintainability**: Focused services are easier to test and modify

2
scripts/README-deploy.md

@ -63,7 +63,7 @@ That starts the **full** stack in `docker-compose.prod.yml` (app, NIP-66 monitor
Apache (or nginx) must still proxy `/api/languagetool``127.0.0.1:8010` and `/api/translate``127.0.0.1:5000`. Apache (or nginx) must still proxy `/api/languagetool``127.0.0.1:8010` and `/api/translate``127.0.0.1:5000`.
**Shared host:** if you already run another `og-proxy` on `127.0.0.0.1:8090` or another Wyoming Piper on the same ports, `docker compose up -d` can fail with a port or name conflict. Either stop the duplicates or start only the pieces you need, e.g. `docker compose up -d jumble jumble-nip66-monitor languagetool libretranslate` (and point `PIPER_TTS_HOST` / Apache at your existing Piper stack if applicable). **Shared host:** if you already run another `og-proxy` on `127.0.0.1:8090` or another Wyoming Piper on the same ports, `docker compose up -d` can fail with a port or name conflict. Either stop the duplicates or start only the pieces you need, e.g. `docker compose up -d jumble jumble-nip66-monitor languagetool libretranslate` (and point `PIPER_TTS_HOST` / Apache at your existing Piper stack if applicable).
Equivalent one-liner: `npm run stack:remote` Equivalent one-liner: `npm run stack:remote`

30
src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx

@ -19,25 +19,19 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon' import RelayIcon from '../RelayIcon'
function rowMuted(connected: boolean, sessionStriked: boolean) { function rowMuted(connected: boolean) {
return !connected || sessionStriked return !connected
} }
function rowTitle( function rowTitle(url: string, connected: boolean, t: (k: string) => string) {
url: string,
connected: boolean,
sessionStriked: boolean,
t: (k: string) => string
) {
const base = simplifyUrl(url) const base = simplifyUrl(url)
if (sessionStriked) return `${base}${t('Relay session striked')}`
if (!connected) return `${base}${t('Not connected')}` if (!connected) return `${base}${t('Not connected')}`
return base return base
} }
/** /**
* Same interaction pattern as {@link SeenOnButton}: Server + counts, menu lists relays with {@link RelayIcon}. * Same interaction pattern as {@link SeenOnButton}: Server + counts, menu lists relays with {@link RelayIcon}.
* Shows favorites + default/inbox relays; disconnected or session-striked relays are muted. * Shows favorites + default/inbox relays; disconnected relays are muted.
*/ */
export function ActiveRelaysTitlebarButton() { export function ActiveRelaysTitlebarButton() {
const { t } = useTranslation() const { t } = useTranslation()
@ -76,8 +70,8 @@ export function ActiveRelaysTitlebarButton() {
</Button> </Button>
) )
const rowClass = (connected: boolean, sessionStriked: boolean) => const rowClass = (connected: boolean) =>
cn(rowMuted(connected, sessionStriked) && 'opacity-45 text-muted-foreground') cn(rowMuted(connected) && 'opacity-45 text-muted-foreground')
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
@ -101,12 +95,12 @@ export function ActiveRelaysTitlebarButton() {
) : null} ) : null}
</DrawerHeader> </DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-1 py-2 pb-4"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-1 py-2 pb-4">
{rows.map(({ url, connected, sessionStriked }) => ( {rows.map(({ url, connected }) => (
<Button <Button
className={cn('h-auto w-full justify-start gap-3 p-4 text-base', rowClass(connected, sessionStriked))} className={cn('h-auto w-full justify-start gap-3 p-4 text-base', rowClass(connected))}
variant="ghost" variant="ghost"
key={url} key={url}
title={rowTitle(url, connected, sessionStriked, t)} title={rowTitle(url, connected, t)}
onClick={() => { onClick={() => {
setDrawerOpen(false) setDrawerOpen(false)
setTimeout(() => navigateToRelay(toRelay(url)), 50) setTimeout(() => navigateToRelay(toRelay(url)), 50)
@ -129,12 +123,12 @@ export function ActiveRelaysTitlebarButton() {
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>{t('Active relays')}</DropdownMenuLabel> <DropdownMenuLabel>{t('Active relays')}</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{rows.map(({ url, connected, sessionStriked }) => ( {rows.map(({ url, connected }) => (
<DropdownMenuItem <DropdownMenuItem
key={url} key={url}
title={rowTitle(url, connected, sessionStriked, t)} title={rowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))} onClick={() => navigateToRelay(toRelay(url))}
className={cn('min-w-52 gap-2', rowClass(connected, sessionStriked))} className={cn('min-w-52 gap-2', rowClass(connected))}
> >
<RelayIcon url={url} /> <RelayIcon url={url} />
{simplifyUrl(url)} {simplifyUrl(url)}

30
src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx

@ -17,22 +17,16 @@ import RelayIcon from '../RelayIcon'
const MAX_ICONS = 14 const MAX_ICONS = 14
function rowMuted(connected: boolean, sessionStriked: boolean) { function rowMuted(connected: boolean) {
return !connected || sessionStriked return !connected
} }
function rowMenuClass(connected: boolean, sessionStriked: boolean) { function rowMenuClass(connected: boolean) {
return cn(rowMuted(connected, sessionStriked) && 'opacity-50 text-muted-foreground') return cn(rowMuted(connected) && 'opacity-50 text-muted-foreground')
} }
function rowTitle( function rowTitle(url: string, connected: boolean, t: (k: string) => string) {
url: string,
connected: boolean,
sessionStriked: boolean,
t: (k: string) => string
) {
const base = simplifyUrl(url) const base = simplifyUrl(url)
if (sessionStriked) return `${base}${t('Relay session striked')}`
if (!connected) return `${base}${t('Not connected')}` if (!connected) return `${base}${t('Not connected')}`
return base return base
} }
@ -63,7 +57,7 @@ export function ConnectedRelaysSidebarStrip({ className }: { className?: string
{t('Active relays')} {t('Active relays')}
</p> </p>
<div className="flex flex-wrap justify-center gap-1 xl:justify-start"> <div className="flex flex-wrap justify-center gap-1 xl:justify-start">
{shown.map(({ url, connected, sessionStriked }) => ( {shown.map(({ url, connected }) => (
<Button <Button
key={url} key={url}
type="button" type="button"
@ -71,10 +65,10 @@ export function ConnectedRelaysSidebarStrip({ className }: { className?: string
size="sm" size="sm"
className={cn( className={cn(
'h-5 w-5 min-h-5 min-w-5 shrink-0 rounded-full p-0 hover:bg-muted/80', 'h-5 w-5 min-h-5 min-w-5 shrink-0 rounded-full p-0 hover:bg-muted/80',
rowMuted(connected, sessionStriked) && 'opacity-40 grayscale' rowMuted(connected) && 'opacity-40 grayscale'
)} )}
title={rowTitle(url, connected, sessionStriked, t)} title={rowTitle(url, connected, t)}
aria-label={rowTitle(url, connected, sessionStriked, t)} aria-label={rowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))} onClick={() => navigateToRelay(toRelay(url))}
> >
<RelayIcon url={url} className="h-5 w-5" iconSize={11} /> <RelayIcon url={url} className="h-5 w-5" iconSize={11} />
@ -99,11 +93,11 @@ export function ConnectedRelaysSidebarStrip({ className }: { className?: string
{t('More relays', { count: overflow })} {t('More relays', { count: overflow })}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{overflowRows.map(({ url, connected, sessionStriked }) => ( {overflowRows.map(({ url, connected }) => (
<DropdownMenuItem <DropdownMenuItem
key={url} key={url}
className={cn('min-w-0 gap-2', rowMenuClass(connected, sessionStriked))} className={cn('min-w-0 gap-2', rowMenuClass(connected))}
title={rowTitle(url, connected, sessionStriked, t)} title={rowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))} onClick={() => navigateToRelay(toRelay(url))}
> >
<RelayIcon url={url} className="h-5 w-5 shrink-0" iconSize={11} /> <RelayIcon url={url} className="h-5 w-5 shrink-0" iconSize={11} />

4
src/components/Embedded/EmbeddedNote.tsx

@ -18,7 +18,7 @@ import client, { eventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import nip66Service from '@/services/nip66.service' import nip66Service from '@/services/nip66.service'
import { navigationEventStore } from '@/services/navigation-event-store' import { navigationEventStore } from '@/services/navigation-event-store'
import { useViewerInboxRelayUrlsAndAggrEligibility } from '@/hooks/useViewerInboxRelayUrlsAndAggr' import { useViewerInboxRelayUrls } from '@/hooks/useViewerInboxRelayUrls'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { useFavoriteRelays } from '@/providers/favorite-relays-context' import { useFavoriteRelays } from '@/providers/favorite-relays-context'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
@ -215,7 +215,7 @@ function EmbeddedNoteFetched({
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const { addReplies } = useReply() const { addReplies } = useReply()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { inboxRelayUrls } = useViewerInboxRelayUrlsAndAggrEligibility() const { inboxRelayUrls } = useViewerInboxRelayUrls()
const [event, setEvent] = useState<Event | undefined>(undefined) const [event, setEvent] = useState<Event | undefined>(undefined)
const [isFetching, setIsFetching] = useState(true) const [isFetching, setIsFetching] = useState(true)
const eventRef = useRef<Event | undefined>(undefined) const eventRef = useRef<Event | undefined>(undefined)

134
src/components/NoteList/index.tsx

@ -92,10 +92,14 @@ import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
import MediaGridItem from '../MediaGridItem' import MediaGridItem from '../MediaGridItem'
import { import {
buildFeedSessionSnapshotKey, buildFeedSessionSnapshotKey,
createFeedDescriptor,
legacyFeedSubscriptionKey, legacyFeedSubscriptionKey,
stableFeedKindKey stableFeedKindKey
} from '@/features/feed/descriptor' } from '@/features/feed/descriptor'
import { mapNoteListSubRequestsForTimeline } from '@/features/feed/note-list-requests' import { mapNoteListSubRequestsForTimeline } from '@/features/feed/note-list-requests'
import { createFetchEventsFeedRuntimeLoader } from '@/features/feed/client-loader'
import { FeedRuntime } from '@/features/feed/runtime'
import { buildFeedDiagnosticsSnapshot, logFeedDiagnostics } from '@/features/feed/diagnostics'
const LIMIT = 150 // Per-shard REQ limit for timeline + loadMore (larger batches = fewer round-trips) const LIMIT = 150 // Per-shard REQ limit for timeline + loadMore (larger batches = fewer round-trips)
const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds
@ -1756,13 +1760,35 @@ const NoteList = forwardRef(
toast.error(t('Feed full search invalid feed')) toast.error(t('Feed full search invalid feed'))
return return
} }
const raw = await client.fetchEvents(relayUrls, finalFilter, { const runtime = new FeedRuntime({
descriptorKey: `feed-full-search:${timelineSubscriptionKey}`,
sortEvents: (a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id),
cap: FEED_FULL_SEARCH_MERGE_CAP
})
const runtimeSnapshot = await runtime.load(
createFetchEventsFeedRuntimeLoader(client, {
subRequests: [{ urls: relayUrls, filter: finalFilter }],
cache: true, cache: true,
globalTimeout: 22_000, globalTimeout: 22_000,
eoseTimeout: 3500, eoseTimeout: 3500,
firstRelayResultGraceMs: false firstRelayResultGraceMs: false
}) })
const merged = mergeEventBatchesById([], raw, FEED_FULL_SEARCH_MERGE_CAP) )
logFeedDiagnostics(
'feed-full-search',
buildFeedDiagnosticsSnapshot({
descriptor: createFeedDescriptor({
surface: 'search',
id: timelineSubscriptionKey,
mode: 'one-shot',
requests: [{ urls: relayUrls, filter: finalFilter }],
pagination: { enabled: false }
}),
relayPolicy: { urls: relayUrls, dropped: [] },
runtime: runtimeSnapshot
})
)
const merged = mergeEventBatchesById([], runtimeSnapshot.rows, FEED_FULL_SEARCH_MERGE_CAP)
setFeedFullSearchEvents(merged) setFeedFullSearchEvents(merged)
setShowCount(revealBatchSize ?? SHOW_COUNT) setShowCount(revealBatchSize ?? SHOW_COUNT)
scrollToTop() scrollToTop()
@ -1790,6 +1816,7 @@ const NoteList = forwardRef(
seeAllFeedEvents, seeAllFeedEvents,
areAlgoRelays, areAlgoRelays,
revealBatchSize, revealBatchSize,
timelineSubscriptionKey,
scrollToTop, scrollToTop,
t t
]) ])
@ -2407,30 +2434,44 @@ const NoteList = forwardRef(
oneShotFirstRelayGraceMs === undefined oneShotFirstRelayGraceMs === undefined
? FIRST_RELAY_RESULT_GRACE_MS ? FIRST_RELAY_RESULT_GRACE_MS
: oneShotFirstRelayGraceMs : oneShotFirstRelayGraceMs
const batches = await Promise.all( const runtime = new FeedRuntime({
mappedSubRequests.map(({ urls, filter }) => descriptorKey: timelineSubscriptionKey,
client.fetchEvents(urls, filter, { sortEvents: (a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id)
firstRelayResultGraceMs: firstRelayGraceResolved, })
const runtimeSnapshot = await runtime.load(
createFetchEventsFeedRuntimeLoader(client, {
subRequests: mappedSubRequests,
cache: true,
globalTimeout: oneShotGlobalTimeoutMs, globalTimeout: oneShotGlobalTimeoutMs,
eoseTimeout: oneShotEoseTimeoutMs, eoseTimeout: oneShotEoseTimeoutMs,
cache: true firstRelayResultGraceMs: firstRelayGraceResolved
}) }),
) userPulledRefresh
) )
if (!effectActive || timelineEffectStale()) return undefined if (!effectActive || timelineEffectStale()) return undefined
if (batches.some((b) => b.length > 0)) { logFeedDiagnostics(
oneShotDebugLabel ?? 'note-list-one-shot',
buildFeedDiagnosticsSnapshot({
descriptor: createFeedDescriptor({
surface: 'custom',
id: timelineSubscriptionKey,
mode: 'one-shot',
requests: mappedSubRequests,
pagination: { enabled: false }
}),
relayPolicy: {
urls: Array.from(new Set(mappedSubRequests.flatMap((request) => request.urls))),
dropped: []
},
runtime: runtimeSnapshot
})
)
if (runtimeSnapshot.rawCount > 0) {
feedRelayReturnedAnyEventRef.current = true feedRelayReturnedAnyEventRef.current = true
} }
const byId = new Map<string, Event>()
for (const ev of batches.flat()) {
const prev = byId.get(ev.id)
if (!prev || ev.created_at > prev.created_at) {
byId.set(ev.id, ev)
}
}
const cap = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP const cap = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP
const isProgressiveLayers = !!progressiveWarmupQueryRef.current?.trim() const isProgressiveLayers = !!progressiveWarmupQueryRef.current?.trim()
let relayOnly = [...byId.values()].sort((a, b) => b.created_at - a.created_at) let relayOnly = [...runtimeSnapshot.rows]
if (!isProgressiveLayers) { if (!isProgressiveLayers) {
relayOnly = relayOnly.slice(0, cap) relayOnly = relayOnly.slice(0, cap)
} }
@ -2463,19 +2504,18 @@ const NoteList = forwardRef(
} }
if (oneShotDebugLabel) { if (oneShotDebugLabel) {
const f0 = mappedSubRequests[0]?.filter const f0 = mappedSubRequests[0]?.filter
const batchEventCounts = batches.map((b) => b.length)
const rawTotal = batchEventCounts.reduce((s, n) => s + n, 0)
logger.info(`[${oneShotDebugLabel}] one-shot fetch merged`, { logger.info(`[${oneShotDebugLabel}] one-shot fetch merged`, {
relayUrlsPerSub: mappedSubRequests.map((r) => r.urls.length), relayUrlsPerSub: mappedSubRequests.map((r) => r.urls.length),
batchEventCounts, rawTotal: runtimeSnapshot.rawCount,
rawTotal, dedupedCount: runtimeSnapshot.rawCount,
dedupedCount: byId.size, hiddenByRuntime: runtimeSnapshot.hiddenCount,
emptyReason: runtimeSnapshot.emptyReason,
afterCap: merged.length, afterCap: merged.length,
cap, cap,
filterAuthors: f0?.authors, filterAuthors: f0?.authors,
filterKinds: f0?.kinds, filterKinds: f0?.kinds,
filterLimit: f0?.limit, filterLimit: f0?.limit,
...(rawTotal === 0 ...(runtimeSnapshot.rawCount === 0
? { ? {
emptyHint: emptyHint:
'All sub-batches returned 0 events: relays may not index these kinds for this author, the query may have timed out before slow relays EOSEd, or posts are kind 1 with links (this tab uses native media kinds only: picture, NIP-71 video regular/addressable, voice).' 'All sub-batches returned 0 events: relays may not index these kinds for this author, the query may have timed out before slow relays EOSEd, or posts are kind 1 with links (this tab uses native media kinds only: picture, NIP-71 video regular/addressable, voice).'
@ -2489,13 +2529,12 @@ const NoteList = forwardRef(
} }
if (oneShotDebugLabel && isProgressiveLayers) { if (oneShotDebugLabel && isProgressiveLayers) {
const f0 = mappedSubRequests[0]?.filter const f0 = mappedSubRequests[0]?.filter
const batchEventCounts = batches.map((b) => b.length)
const rawTotal = batchEventCounts.reduce((s, n) => s + n, 0)
logger.info(`[${oneShotDebugLabel}] one-shot progressive relay merge`, { logger.info(`[${oneShotDebugLabel}] one-shot progressive relay merge`, {
relayUrlsPerSub: mappedSubRequests.map((r) => r.urls.length), relayUrlsPerSub: mappedSubRequests.map((r) => r.urls.length),
batchEventCounts, rawTotal: runtimeSnapshot.rawCount,
rawTotal, dedupedCount: runtimeSnapshot.rawCount,
dedupedCount: byId.size, hiddenByRuntime: runtimeSnapshot.hiddenCount,
emptyReason: runtimeSnapshot.emptyReason,
filterAuthors: f0?.authors, filterAuthors: f0?.authors,
filterKinds: f0?.kinds, filterKinds: f0?.kinds,
filterLimit: f0?.limit filterLimit: f0?.limit
@ -3584,10 +3623,39 @@ const NoteList = forwardRef(
let newEvents: Event[] = [] let newEvents: Event[] = []
try { try {
const until = latestEvents.length ? latestEvents[latestEvents.length - 1].created_at - 1 : dayjs().unix() const until = latestEvents.length ? latestEvents[latestEvents.length - 1].created_at - 1 : dayjs().unix()
newEvents = await client.loadMoreTimeline( const pageRuntime = new FeedRuntime({
latestTimelineKey, descriptorKey: `timeline:${latestTimelineKey}`,
until, sortEvents: (a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id)
LIMIT })
pageRuntime.seed(latestEvents, { hasMore: latestHasMore, nextCursor: until })
const pageSnapshot = await pageRuntime.loadMore(
async ({ cursor }) => {
newEvents = await client.loadMoreTimeline(latestTimelineKey, cursor ?? until, LIMIT)
return {
relayEvents: newEvents,
hasMore: newEvents.length > 0,
nextCursor: newEvents.length
? Math.min(...newEvents.map((event) => event.created_at)) - 1
: cursor
}
}
)
logFeedDiagnostics(
'note-list-load-more',
buildFeedDiagnosticsSnapshot({
descriptor: createFeedDescriptor({
surface: 'custom',
id: latestTimelineKey,
mode: 'live',
requests: subRequestsRef.current,
pagination: { enabled: true }
}),
relayPolicy: {
urls: Array.from(new Set(subRequestsRef.current.flatMap((request) => request.urls))),
dropped: []
},
runtime: pageSnapshot
})
) )
// CRITICAL FIX: Be extremely conservative about stopping the feed // CRITICAL FIX: Be extremely conservative about stopping the feed

17
src/components/PostEditor/PostRelaySelector.tsx

@ -19,7 +19,6 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet' import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { computePrePublishRelayCapPreview, type TPrePublishRelayCapPreview } from '@/lib/pre-publish-relay-cap' import { computePrePublishRelayCapPreview, type TPrePublishRelayCapPreview } from '@/lib/pre-publish-relay-cap'
import client from '@/services/client.service'
/** Stable default when `mentions` is omitted — inline `= []` is a new array every render and retriggers effects. */ /** Stable default when `mentions` is omitted — inline `= []` is a new array every render and retriggers effects. */
const NO_MENTIONS: string[] = [] const NO_MENTIONS: string[] = []
@ -266,22 +265,6 @@ export default function PostRelaySelector({
} }
}, [selectedRelayUrls, hasManualSelection, isLoading, describeRelaySelection]) }, [selectedRelayUrls, hasManualSelection, isLoading, describeRelaySelection])
/** Picker lists exclude global read-only relays; session-strike skips are cleared when a relay is newly chosen so publishes honor the list. */
const prevSelectedNormalizedRef = useRef<Set<string>>(new Set())
useEffect(() => {
const norm = (u: string) => normalizeAnyRelayUrl(u) || u
const prev = prevSelectedNormalizedRef.current
const newlyAdded: string[] = []
for (const url of selectedRelayUrls) {
const n = norm(url)
if (!prev.has(n)) newlyAdded.push(url)
}
prevSelectedNormalizedRef.current = new Set(selectedRelayUrls.map(norm))
if (newlyAdded.length > 0) {
client.clearSessionRelayStrikesForUrls(newlyAdded)
}
}, [selectedRelayUrls])
// Update parent component with selected relays // Update parent component with selected relays
useEffect(() => { useEffect(() => {
// An event is "protected" if we have selected relays that aren't the default user write relays // An event is "protected" if we have selected relays that aren't the default user write relays

57
src/components/Relay/index.tsx

@ -5,11 +5,10 @@ import SearchInput from '@/components/SearchInput'
import { useFetchRelayInfo } from '@/hooks' import { useFetchRelayInfo } from '@/hooks'
import type { TPrimaryPageName } from '@/PageManager' import type { TPrimaryPageName } from '@/PageManager'
import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants' import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants'
import { isHttpRelayUrl, normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import client, { JUMBLE_SESSION_RELAY_STRIKES_CHANGED } from '@/services/client.service'
import type { TFeedSubRequest } from '@/types' import type { TFeedSubRequest } from '@/types'
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import NotFound from '../NotFound' import NotFound from '../NotFound'
@ -20,30 +19,12 @@ const Relay = forwardRef<
const { t } = useTranslation() const { t } = useTranslation()
const { addRelayUrls, removeRelayUrls } = useCurrentRelays() const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
const normalizedUrl = useMemo(() => (url ? normalizeAnyRelayUrl(url) : undefined), [url]) const normalizedUrl = useMemo(() => (url ? normalizeAnyRelayUrl(url) : undefined), [url])
const isHttpRelay = useMemo(() => !!normalizedUrl && isHttpRelayUrl(normalizedUrl), [normalizedUrl])
const { relayInfo } = useFetchRelayInfo(normalizedUrl) const { relayInfo } = useFetchRelayInfo(normalizedUrl)
const [searchInput, setSearchInput] = useState('') const [searchInput, setSearchInput] = useState('')
const [debouncedInput, setDebouncedInput] = useState(searchInput) const [debouncedInput, setDebouncedInput] = useState(searchInput)
const internalNoteListRef = useRef<TNoteListRef>(null) const internalNoteListRef = useRef<TNoteListRef>(null)
const noteListRef = ref ?? internalNoteListRef const noteListRef = ref ?? internalNoteListRef
const strikeThreshold = client.getSessionRelayFailureStrikeThreshold()
const readStrikeCount = useCallback(() => {
if (!normalizedUrl) return 0
return client.getSessionRelayStrikeCountForUrl(normalizedUrl)
}, [normalizedUrl])
const [strikeCount, setStrikeCount] = useState(0)
useEffect(() => {
setStrikeCount(readStrikeCount())
}, [readStrikeCount])
useEffect(() => {
const sync = () => setStrikeCount(readStrikeCount())
window.addEventListener(JUMBLE_SESSION_RELAY_STRIKES_CHANGED, sync)
return () => window.removeEventListener(JUMBLE_SESSION_RELAY_STRIKES_CHANGED, sync)
}, [readStrikeCount])
useEffect(() => { useEffect(() => {
if (normalizedUrl) { if (normalizedUrl) {
addRelayUrls([normalizedUrl]) addRelayUrls([normalizedUrl])
@ -53,20 +34,6 @@ const Relay = forwardRef<
} }
}, [normalizedUrl]) }, [normalizedUrl])
/**
* Session strikes skip a relay for reads until cleared. Refresh in the titlebar already clears; without this,
* opening the panel on a striked relay subscribed too late or showed an empty feed while the banner confused users.
* Runs after child effects so the NoteList ref is ready for {@link refresh}.
*/
useEffect(() => {
if (!normalizedUrl) return
if (!client.clearSessionRelayStrikeForUrl(normalizedUrl)) return
setStrikeCount(0)
if (typeof noteListRef !== 'function') {
noteListRef.current?.refresh()
}
}, [normalizedUrl])
useEffect(() => { useEffect(() => {
const handler = setTimeout(() => { const handler = setTimeout(() => {
setDebouncedInput(searchInput) setDebouncedInput(searchInput)
@ -108,7 +75,7 @@ const Relay = forwardRef<
: { limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT } : { limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT }
} }
] ]
}, [normalizedUrl, isHttpRelay, debouncedInput]) }, [normalizedUrl, debouncedInput])
if (!normalizedUrl) { if (!normalizedUrl) {
return <NotFound /> return <NotFound />
@ -117,24 +84,6 @@ const Relay = forwardRef<
return ( return (
<div className={className}> <div className={className}>
<RelayInfo url={normalizedUrl} className="pt-3" /> <RelayInfo url={normalizedUrl} className="pt-3" />
{strikeCount > 0 ? (
<div
className="mx-4 mb-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-foreground dark:border-amber-400/35"
role="status"
>
<p className="font-medium">
{strikeCount >= strikeThreshold
? t('relaySessionStrikes.bannerSkipped', { threshold: strikeThreshold })
: t('relaySessionStrikes.bannerWarning', {
count: strikeCount,
threshold: strikeThreshold
})}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('relaySessionStrikes.refreshHint', { refresh: t('Refresh') })}
</p>
</div>
) : null}
{relayInfo?.supported_nips?.includes(50) && ( {relayInfo?.supported_nips?.includes(50) && (
<div className="px-4 py-2"> <div className="px-4 py-2">
<SearchInput <SearchInput

68
src/components/SessionRelaysTab/index.tsx

@ -3,16 +3,14 @@ import relayInfoService from '@/services/relay-info.service'
import { isHttpRelayUrl } from '@/lib/url' import { isHttpRelayUrl } from '@/lib/url'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { RefreshCw, CheckCircle2, XCircle, Zap, RotateCcw } from 'lucide-react' import { RefreshCw, CheckCircle2, Zap } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import type { TRelayInfo } from '@/types' import type { TRelayInfo } from '@/types'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
type SessionDebug = { type SessionDebug = {
strikedUrls: string[]
scoredRelays: { url: string; successCount: number; avgLatencyMs: number }[] scoredRelays: { url: string; successCount: number; avgLatencyMs: number }[]
presetWorking: string[] presetWorking: string[]
presetStriked: string[]
} }
function loadDebug(): SessionDebug { function loadDebug(): SessionDebug {
@ -38,8 +36,6 @@ export default function SessionRelaysTab() {
const urls = Array.from( const urls = Array.from(
new Set([ new Set([
...debug.presetWorking, ...debug.presetWorking,
...debug.presetStriked,
...debug.strikedUrls,
...debug.scoredRelays.map((r) => r.url) ...debug.scoredRelays.map((r) => r.url)
]) ])
) )
@ -58,11 +54,6 @@ export default function SessionRelaysTab() {
} }
}, [debug]) }, [debug])
const clearStrikeForUrl = (url: string) => {
client.clearSessionRelayStrikeForUrl(url)
refresh()
}
const formatRelayAddress = (url: string) => { const formatRelayAddress = (url: string) => {
try { try {
const u = new URL(url) const u = new URL(url)
@ -145,38 +136,6 @@ export default function SessionRelaysTab() {
</ul> </ul>
</section> </section>
<section className="space-y-2">
<h3 className="text-sm font-medium flex items-center gap-2">
<XCircle className="h-4 w-4 text-amber-600 dark:text-amber-500" />
{t('Session relays preset striked')}
</h3>
<p className="text-muted-foreground text-xs">
{t('Session relays preset striked hint')}
</p>
<ul className="rounded-lg border bg-muted/30 p-3 space-y-2 text-sm">
{debug.presetStriked.length === 0 ? (
<li className="text-muted-foreground">{t('None')}</li>
) : (
debug.presetStriked.map((url) => (
<li key={url} className="flex items-center justify-between gap-2">
<RelayNameWithTransport url={url} />
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 gap-1 px-2 text-xs"
title={t('Session relays clear strike hint')}
onClick={() => clearStrikeForUrl(url)}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden />
{t('Session relays clear strike')}
</Button>
</li>
))
)}
</ul>
</section>
<section className="space-y-2"> <section className="space-y-2">
<h3 className="text-sm font-medium flex items-center gap-2"> <h3 className="text-sm font-medium flex items-center gap-2">
<Zap className="h-4 w-4 text-blue-600 dark:text-blue-400" /> <Zap className="h-4 w-4 text-blue-600 dark:text-blue-400" />
@ -201,31 +160,6 @@ export default function SessionRelaysTab() {
</ul> </ul>
</section> </section>
{debug.strikedUrls.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">
{t('Session relays all striked')}
</h3>
<ul className="rounded-lg border bg-muted/30 p-3 space-y-2 text-sm">
{debug.strikedUrls.map((url) => (
<li key={url} className="flex items-center justify-between gap-2 text-muted-foreground">
<RelayNameWithTransport url={url} />
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 gap-1 px-2 text-xs text-foreground"
title={t('Session relays clear strike hint')}
onClick={() => clearStrikeForUrl(url)}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden />
{t('Session relays clear strike')}
</Button>
</li>
))}
</ul>
</section>
)}
</div> </div>
) )
} }

11
src/constants.ts

@ -8,9 +8,6 @@ export const IMWALD_API_BASE_URL =
(import.meta.env.VITE_JUMBLE_API_BASE_URL as string | undefined)?.trim() || (import.meta.env.VITE_JUMBLE_API_BASE_URL as string | undefined)?.trim() ||
'https://api.jumble.imwald.eu' 'https://api.jumble.imwald.eu'
/** @deprecated Use {@link IMWALD_API_BASE_URL} */
export const JUMBLE_API_BASE_URL = IMWALD_API_BASE_URL
/** Git Republic web UI for repository links; override with VITE_GITREPUBLIC_WEB_BASE_URL for self-hosted. */ /** Git Republic web UI for repository links; override with VITE_GITREPUBLIC_WEB_BASE_URL for self-hosted. */
export const GITREPUBLIC_WEB_BASE_URL = ( export const GITREPUBLIC_WEB_BASE_URL = (
(import.meta.env.VITE_GITREPUBLIC_WEB_BASE_URL as string | undefined) ?? 'https://gitrepublic.imwald.eu' (import.meta.env.VITE_GITREPUBLIC_WEB_BASE_URL as string | undefined) ?? 'https://gitrepublic.imwald.eu'
@ -215,12 +212,6 @@ export const HTTP_TIMELINE_POLL_INTERVAL_MS = 45_000
/** Subtracted from the polling `since` cursor so borderline events are not missed between polls. */ /** Subtracted from the polling `since` cursor so borderline events are not missed between polls. */
export const HTTP_TIMELINE_POLL_SINCE_OVERLAP_SEC = 120 export const HTTP_TIMELINE_POLL_SINCE_OVERLAP_SEC = 120
/** Legacy name: was used to cap spell NoteList skeleton time; loading now ends on EOSE / first events / safety timeouts. Kept for forks. */
export const SPELL_FEED_LOADING_MAX_MS = 1000
/** @deprecated Alias of {@link SPELL_FEED_LOADING_MAX_MS}. */
export const SPELL_FEED_FIRST_RELAY_GRACE_MS = SPELL_FEED_LOADING_MAX_MS
/** /**
* Implicit query feed grace ({@link FIRST_RELAY_RESULT_GRACE_MS}) applies only when the largest `limit` among * Implicit query feed grace ({@link FIRST_RELAY_RESULT_GRACE_MS}) applies only when the largest `limit` among
* filters is at least this value. Omitting `limit` counts as 0 (no implicit grace). * filters is at least this value. Omitting `limit` counts as 0 (no implicit grace).
@ -973,8 +964,6 @@ export const ZAP_STREAM_WATCH_URL_REGEX =
export const IMWALD_MAINTAINER_PUBKEY = export const IMWALD_MAINTAINER_PUBKEY =
'f4eb8e62add1340b9cadcd9861e669b2e907cea534e0f7f3ac974c11c758a51a' 'f4eb8e62add1340b9cadcd9861e669b2e907cea534e0f7f3ac974c11c758a51a'
/** @deprecated Use {@link IMWALD_MAINTAINER_PUBKEY} */
export const JUMBLE_PUBKEY = IMWALD_MAINTAINER_PUBKEY
export const CODY_PUBKEY = '8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883' export const CODY_PUBKEY = '8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883'
export const SILBERENGEL_PUBKEY = 'fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1' export const SILBERENGEL_PUBKEY = 'fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1'

16
src/features/feed/runtime.test.ts

@ -96,4 +96,20 @@ describe('FeedRuntime', () => {
expect(next.paginationStatus).toBe('exhausted') expect(next.paginationStatus).toBe('exhausted')
expect(next.hasMore).toBe(false) expect(next.hasMore).toBe(false)
}) })
it('can seed existing rows before loading an older page', async () => {
const runtime = new FeedRuntime({ descriptorKey: 'feed-a' })
runtime.seed([evt('new', 20)], { hasMore: true, nextCursor: 19 })
const next = await runtime.loadMore(async ({ cursor }) => {
expect(cursor).toBe(19)
return {
relayEvents: [evt('old', 10)],
hasMore: true
}
})
expect(next.rows.map((row) => row.id)).toEqual(['new', 'old'])
expect(next.paginationStatus).toBe('idle')
})
}) })

35
src/features/feed/runtime.ts

@ -52,6 +52,7 @@ export type FeedRuntimeState = FeedRuntimeSnapshot & {
export type FeedRuntimeAction = export type FeedRuntimeAction =
| { type: 'start'; descriptorKey: string; generation: number; refresh: boolean; keepRowsStale?: boolean } | { type: 'start'; descriptorKey: string; generation: number; refresh: boolean; keepRowsStale?: boolean }
| { type: 'seed'; events: Event[]; stale?: boolean; hasMore?: boolean; nextCursor?: number }
| { type: 'cache'; events: Event[]; stale: boolean } | { type: 'cache'; events: Event[]; stale: boolean }
| { type: 'relayBatch'; events: Event[]; relayOutcomes?: FeedRelayOutcome[]; fresh?: boolean } | { type: 'relayBatch'; events: Event[]; relayOutcomes?: FeedRelayOutcome[]; fresh?: boolean }
| { type: 'relayDone'; relayOutcomes?: FeedRelayOutcome[]; hasMore?: boolean; nextCursor?: number } | { type: 'relayDone'; relayOutcomes?: FeedRelayOutcome[]; hasMore?: boolean; nextCursor?: number }
@ -83,6 +84,12 @@ export type FeedRuntimeLoadResult = {
nextCursor?: number nextCursor?: number
} }
export type FeedRuntimeSeedOptions = {
stale?: boolean
hasMore?: boolean
nextCursor?: number
}
export type FeedRuntimeLoader = (args: { export type FeedRuntimeLoader = (args: {
descriptorKey: string descriptorKey: string
generation: number generation: number
@ -181,6 +188,19 @@ export function feedRuntimeReducer(
switch (action.type) { switch (action.type) {
case 'reset': case 'reset':
return createInitialFeedRuntimeState(action.descriptorKey) return createInitialFeedRuntimeState(action.descriptorKey)
case 'seed':
return derive(
{
...state,
rawRows: action.events,
stale: action.stale ?? false,
hasMore: action.hasMore ?? state.hasMore,
nextCursor: action.nextCursor ?? state.nextCursor,
paginationStatus: action.hasMore === false ? 'exhausted' : state.paginationStatus,
pageError: undefined
},
options
)
case 'start': { case 'start': {
const keepRows = action.keepRowsStale ? state.rawRows : [] const keepRows = action.keepRowsStale ? state.rawRows : []
return derive( return derive(
@ -292,6 +312,21 @@ export class FeedRuntime {
return snapshot return snapshot
} }
seed(events: Event[], options: FeedRuntimeSeedOptions = {}): FeedRuntimeSnapshot {
this.state = feedRuntimeReducer(
this.state,
{
type: 'seed',
events,
stale: options.stale,
hasMore: options.hasMore,
nextCursor: options.nextCursor
},
this.options
)
return this.snapshot()
}
async load(loader: FeedRuntimeLoader, refresh = false): Promise<FeedRuntimeSnapshot> { async load(loader: FeedRuntimeLoader, refresh = false): Promise<FeedRuntimeSnapshot> {
this.abortController?.abort() this.abortController?.abort()
const generation = ++this.generation const generation = ++this.generation

28
src/hooks/useContainerWidth.ts

@ -1,28 +0,0 @@
import { RefObject, useEffect, useState } from 'react'
/**
* Tracks the rendered width of `ref`'s element via ResizeObserver.
* Returns `undefined` until the first measurement fires.
* Use this when you need a component to respond to its *own* container width
* rather than the viewport width (e.g. inside a split-pane layout where
* `isSmallScreen` is still `false` but the column is narrow).
*/
export function useContainerWidth(ref: RefObject<Element | null>): number | undefined {
const [width, setWidth] = useState<number | undefined>(undefined)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry) setWidth(entry.contentRect.width)
})
observer.observe(el)
// Initialise synchronously so there's no render flash
setWidth(el.getBoundingClientRect().width)
return () => observer.disconnect()
}, [ref])
return width
}

93
src/hooks/useProfileZapPollParticipation.tsx

@ -1,93 +0,0 @@
import { ExtendedKind, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import {
filterZapPollVoteReceiptsForVoter,
getPollIdFromZapReceipt,
parseZapPollEvent,
userZapPollVoteOption
} from '@/lib/zap-poll'
import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service'
import { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react'
function participationRelayUrls(): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const u of [...SEARCHABLE_RELAY_URLS, ...FAST_READ_RELAY_URLS]) {
const n = normalizeUrl(u) || u
if (!n || seen.has(n)) continue
seen.add(n)
out.push(n)
}
return out.slice(0, 14)
}
export type TZapPollProfileRow = {
poll: Event
voteReceipt: Event
optionIndex: number
}
/**
* Zap poll votes by `profilePubkey` (kind 9735 with P=profile and k=6969 in embedded zap request),
* resolved to kind 6969 poll events for profile timeline merge.
*/
export function useProfileZapPollParticipation(profilePubkey: string | undefined) {
const [rows, setRows] = useState<TZapPollProfileRow[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
if (!profilePubkey) {
setRows([])
return
}
setLoading(true)
try {
const urls = participationRelayUrls()
const receipts = await client.fetchEvents(urls, {
kinds: [kinds.Zap],
'#p': [profilePubkey.trim().toLowerCase()],
limit: 300
})
const voteReceipts = filterZapPollVoteReceiptsForVoter(receipts, profilePubkey)
const pollIds = [...new Set(voteReceipts.map(getPollIdFromZapReceipt).filter(Boolean) as string[])]
if (pollIds.length === 0) {
setRows([])
return
}
const polls = await client.fetchEvents(urls, {
kinds: [ExtendedKind.ZAP_POLL],
ids: pollIds,
limit: pollIds.length
})
const pollById = new Map(polls.map((p) => [p.id, p]))
const built: TZapPollProfileRow[] = []
for (const vr of voteReceipts) {
const pid = getPollIdFromZapReceipt(vr)
if (!pid) continue
const poll = pollById.get(pid)
if (!poll) continue
const pollMeta = parseZapPollEvent(poll)
if (!pollMeta) continue
const opt = userZapPollVoteOption(poll, pollMeta, profilePubkey, [vr])
if (opt === undefined) continue
built.push({ poll, voteReceipt: vr, optionIndex: opt })
}
built.sort((a, b) => b.voteReceipt.created_at - a.voteReceipt.created_at)
setRows(built)
} catch {
setRows([])
} finally {
setLoading(false)
}
}, [profilePubkey])
useEffect(() => {
void load()
}, [load])
const pollIdsVoted = useMemo(() => new Set(rows.map((r) => r.poll.id)), [rows])
return { rows, loading, reload: load, pollIdsVoted }
}

23
src/hooks/useRelayConnectionRows.ts

@ -2,7 +2,7 @@ import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS } from '@/constants'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import client, { JUMBLE_SESSION_RELAY_STRIKES_CHANGED } from '@/services/client.service' import client from '@/services/client.service'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
const POLL_MS = 1500 const POLL_MS = 1500
@ -31,18 +31,15 @@ export type TRelayConnectionRow = {
url: string url: string
/** WebSocket in the pool is open. */ /** WebSocket in the pool is open. */
connected: boolean connected: boolean
/** Session strike threshold reached — app skips this relay for reads/publishes until cleared. */
sessionStriked: boolean
} }
/** /**
* Relays to show in active relays UI: favorites + NIP-65 read/write + defaults + fast-read, * Relays to show in active relays UI: favorites + NIP-65 read/write + defaults + fast-read,
* then any pool-connected URL not already listed. {@link row.connected} reflects the live WebSocket; * then any pool-connected URL not already listed. {@link row.connected} reflects the live WebSocket.
* {@link row.sessionStriked} reflects {@link client.isSessionRelayStrikedForReads}.
*/ */
export function useRelayConnectionRows(): { export function useRelayConnectionRows(): {
rows: TRelayConnectionRow[] rows: TRelayConnectionRow[]
/** Relays that are both socket-connected and not session-striked (usable for REQs this session). */ /** Relays that currently have an open WebSocket connection. */
connectedCount: number connectedCount: number
} { } {
const { relayList } = useNostr() const { relayList } = useNostr()
@ -50,7 +47,6 @@ export function useRelayConnectionRows(): {
const [connectedCanon, setConnectedCanon] = useState<Set<string>>(() => const [connectedCanon, setConnectedCanon] = useState<Set<string>>(() =>
new Set(client.getConnectedRelayUrls().map(canon)) new Set(client.getConnectedRelayUrls().map(canon))
) )
const [strikesEpoch, setStrikesEpoch] = useState(0)
useEffect(() => { useEffect(() => {
const tick = () => setConnectedCanon(new Set(client.getConnectedRelayUrls().map(canon))) const tick = () => setConnectedCanon(new Set(client.getConnectedRelayUrls().map(canon)))
@ -59,12 +55,6 @@ export function useRelayConnectionRows(): {
return () => clearInterval(id) return () => clearInterval(id)
}, []) }, [])
useEffect(() => {
const bump = () => setStrikesEpoch((n) => n + 1)
window.addEventListener(JUMBLE_SESSION_RELAY_STRIKES_CHANGED, bump)
return () => window.removeEventListener(JUMBLE_SESSION_RELAY_STRIKES_CHANGED, bump)
}, [])
return useMemo(() => { return useMemo(() => {
const inbox = [...(relayList?.read ?? []), ...(relayList?.write ?? [])] const inbox = [...(relayList?.read ?? []), ...(relayList?.write ?? [])]
const base = mergeUniquePreserveOrder( const base = mergeUniquePreserveOrder(
@ -77,8 +67,7 @@ export function useRelayConnectionRows(): {
const rowFor = (url: string, socketConnected: boolean): TRelayConnectionRow => ({ const rowFor = (url: string, socketConnected: boolean): TRelayConnectionRow => ({
url, url,
connected: socketConnected, connected: socketConnected
sessionStriked: client.isSessionRelayStrikedForReads(url)
}) })
const rows: TRelayConnectionRow[] = base.map((url) => const rows: TRelayConnectionRow[] = base.map((url) =>
@ -91,7 +80,7 @@ export function useRelayConnectionRows(): {
rows.push(rowFor(url, true)) rows.push(rowFor(url, true))
} }
const connectedCount = rows.filter((r) => r.connected && !r.sessionStriked).length const connectedCount = rows.filter((r) => r.connected).length
return { rows, connectedCount } return { rows, connectedCount }
}, [favoriteRelays, relayList?.read, relayList?.write, connectedCanon, strikesEpoch]) }, [favoriteRelays, relayList?.read, relayList?.write, connectedCanon])
} }

30
src/hooks/useViewerInboxRelayUrls.ts

@ -0,0 +1,30 @@
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { useNostrOptional } from '@/providers/nostr-context'
import client from '@/services/client.service'
import { useEffect, useState } from 'react'
/** Viewer NIP-65 read inboxes (incl. HTTP read) for embed / thread fan-out. */
export function useViewerInboxRelayUrls(): {
inboxRelayUrls: string[]
} {
const nostr = useNostrOptional()
const pk = nostr?.pubkey?.trim()
const [inboxRelayUrls, setInboxRelayUrls] = useState<string[]>([])
useEffect(() => {
if (!pk) {
setInboxRelayUrls([])
return
}
let cancelled = false
void client.peekRelayListFromStorage(pk).then((rl) => {
if (cancelled) return
setInboxRelayUrls(userReadRelaysWithHttp(rl).slice(0, 14))
})
return () => {
cancelled = true
}
}, [pk])
return { inboxRelayUrls }
}

46
src/hooks/useViewerInboxRelayUrlsAndAggr.ts

@ -1,46 +0,0 @@
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { viewerMayUseNostrLandAggr } from '@/lib/nostr-land-aggr'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostrOptional } from '@/providers/nostr-context'
import client from '@/services/client.service'
import type { TRelayList } from '@/types'
import { useEffect, useMemo, useState } from 'react'
/**
* Viewer NIP-65 read inboxes (incl. HTTP read) for embed / thread fan-out, plus whether they may use
* {@link AGGR_NOSTR_LAND_WSS} (nostr.land in favorites or NIP-65 lists).
*/
export function useViewerInboxRelayUrlsAndAggrEligibility(): {
inboxRelayUrls: string[]
allowNostrLandAggr: boolean
} {
const nostr = useNostrOptional()
const pk = nostr?.pubkey?.trim()
const { favoriteRelays } = useFavoriteRelays()
const [inboxRelayUrls, setInboxRelayUrls] = useState<string[]>([])
const [peekedNip65, setPeekedNip65] = useState<TRelayList | null>(null)
useEffect(() => {
if (!pk) {
setInboxRelayUrls([])
setPeekedNip65(null)
return
}
let cancelled = false
void client.peekRelayListFromStorage(pk).then((rl) => {
if (cancelled) return
setPeekedNip65(rl)
setInboxRelayUrls(userReadRelaysWithHttp(rl).slice(0, 14))
})
return () => {
cancelled = true
}
}, [pk])
const allowNostrLandAggr = useMemo(
() => viewerMayUseNostrLandAggr(favoriteRelays ?? [], peekedNip65 ?? undefined),
[favoriteRelays, peekedNip65]
)
return { inboxRelayUrls, allowNostrLandAggr }
}

13
src/i18n/locales/cs.ts

@ -513,7 +513,6 @@ export default {
"Seen on": "Seen on", "Seen on": "Seen on",
"Active relays": "Active relays", "Active relays": "Active relays",
"Not connected": "Not connected", "Not connected": "Not connected",
"Relay session striked": "Skipped this session (too many connection failures)",
"More relays": "+{{count}} relays", "More relays": "+{{count}} relays",
"Temporarily display this reply": "Temporarily display this reply", "Temporarily display this reply": "Temporarily display this reply",
"Note not found": "Note not found", "Note not found": "Note not found",
@ -604,19 +603,11 @@ export default {
relayType_contextual: "Reply/PM", relayType_contextual: "Reply/PM",
relayType_randomly_selected: "Random (optional)", relayType_randomly_selected: "Random (optional)",
"Session relays": "Session relays", "Session relays": "Session relays",
"Session relays tab description": "Relay logic for this session: working and striked preset relays, and scored random relays (used to prefer faster, proven relays when adding random relays to publish). Striked relays are skipped for reads and publishes until reload.", "Session relays tab description": "Relay logic for this session: preset relays and scored random relays used to prefer faster, proven relays when adding random relays to publish.",
"Session relays preset working": "Working preset relays", "Session relays preset working": "Working preset relays",
"Session relays preset working hint": "Preset relays (from app defaults) that have not reached the session failure threshold (2 failures).", "Session relays preset working hint": "Preset relays from app defaults.",
"Session relays preset striked": "Striked preset relays",
"Session relays preset striked hint": "Preset relays that have reached 2 connection or publish failures this session and are skipped for reads and writes until you reload the app.",
"Session relays scored random": "Scored random relays", "Session relays scored random": "Scored random relays",
"Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.", "Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.",
"Session relays all striked": "All striked relays (any source)",
"Session relays clear strike": "Allow again",
"Session relays clear strike hint": "Remove this relay from the session block list; it will be used again until new connection failures.",
"relaySessionStrikes.bannerWarning": "This relay has {{count}} session strike(s) (limit {{threshold}}) after connection or query failures.",
"relaySessionStrikes.bannerSkipped": "This relay has reached the session failure limit ({{threshold}} strikes) and is skipped for reads and publishes in this tab.",
"relaySessionStrikes.refreshHint": "Use {{refresh}} to clear strikes for this relay and load the feed again.",
successes: "successes", successes: "successes",
None: "None", None: "None",
"Cache & offline storage": "Cache & offline storage", "Cache & offline storage": "Cache & offline storage",

11
src/i18n/locales/de.ts

@ -533,7 +533,6 @@ export default {
"Seen on": "Gesehen auf", "Seen on": "Gesehen auf",
"Active relays": "Aktive Relays", "Active relays": "Aktive Relays",
"Not connected": "Nicht verbunden", "Not connected": "Nicht verbunden",
"Relay session striked": "Diese Sitzung übersprungen (zu viele Verbindungsfehler)",
"More relays": "+{{count}} Relays", "More relays": "+{{count}} Relays",
"Temporarily display this reply": "Antwort vorübergehend anzeigen", "Temporarily display this reply": "Antwort vorübergehend anzeigen",
"Note not found": "Die Notiz wurde nicht gefunden", "Note not found": "Die Notiz wurde nicht gefunden",
@ -626,17 +625,9 @@ export default {
"Session relays": "Session-Relays", "Session relays": "Session-Relays",
"Session relays tab description": "Relay-Logik für diese Session: funktionierende und gestrichene Preset-Relays sowie bewertete Zufallsrelays. Gestrichene Relays werden für Lesen und Schreiben bis zum Neuladen der App übersprungen.", "Session relays tab description": "Relay-Logik für diese Session: funktionierende und gestrichene Preset-Relays sowie bewertete Zufallsrelays. Gestrichene Relays werden für Lesen und Schreiben bis zum Neuladen der App übersprungen.",
"Session relays preset working": "Funktionierende Preset-Relays", "Session relays preset working": "Funktionierende Preset-Relays",
"Session relays preset working hint": "Preset-Relays (App-Standard), die die Session-Fehlerschwelle (2 Fehler) noch nicht erreicht haben.", "Session relays preset working hint": "Preset-Relays aus den App-Standards.",
"Session relays preset striked": "Gestrichene Preset-Relays",
"Session relays preset striked hint": "Preset-Relays mit 2 Verbindungs- oder Publish-Fehlern in dieser Session; werden für Lesen und Schreiben bis zum Neuladen übersprungen.",
"Session relays scored random": "Bewertete Zufallsrelays", "Session relays scored random": "Bewertete Zufallsrelays",
"Session relays scored random hint": "Relays, die in dieser Session mindestens ein Publish angenommen haben; werden beim Auswählen von Zufallsrelays bevorzugt. Sortiert nach durchschnittlicher Latenz.", "Session relays scored random hint": "Relays, die in dieser Session mindestens ein Publish angenommen haben; werden beim Auswählen von Zufallsrelays bevorzugt. Sortiert nach durchschnittlicher Latenz.",
"Session relays all striked": "Alle gestrichenen Relays (alle Quellen)",
"Session relays clear strike": "Wieder zulassen",
"Session relays clear strike hint": "Relay aus der Session-Sperrliste nehmen; es wird wieder genutzt, bis neue Verbindungsfehler auftreten.",
"relaySessionStrikes.bannerWarning": "Dieses Relay hat {{count}} Session-Strike(s) (Limit {{threshold}}) nach Verbindungs- oder Abfragefehlern.",
"relaySessionStrikes.bannerSkipped": "Dieses Relay hat die Session-Fehlergrenze ({{threshold}} Strikes) erreicht und wird in diesem Tab für Lesen und Publizieren übersprungen.",
"relaySessionStrikes.refreshHint": "Mit {{refresh}} werden die Strikes für dieses Relay zurückgesetzt und der Feed erneut geladen.",
successes: "Erfolge", successes: "Erfolge",
None: "Keine", None: "Keine",
"Cache & offline storage": "Cache & Offline-Speicher", "Cache & offline storage": "Cache & Offline-Speicher",

13
src/i18n/locales/en.ts

@ -537,7 +537,6 @@ export default {
"Seen on": "Seen on", "Seen on": "Seen on",
"Active relays": "Active relays", "Active relays": "Active relays",
"Not connected": "Not connected", "Not connected": "Not connected",
"Relay session striked": "Skipped this session (too many connection failures)",
"More relays": "+{{count}} relays", "More relays": "+{{count}} relays",
"Temporarily display this reply": "Temporarily display this reply", "Temporarily display this reply": "Temporarily display this reply",
"Note not found": "Note not found", "Note not found": "Note not found",
@ -628,19 +627,11 @@ export default {
relayType_contextual: "Reply/PM", relayType_contextual: "Reply/PM",
relayType_randomly_selected: "Random (optional)", relayType_randomly_selected: "Random (optional)",
"Session relays": "Session relays", "Session relays": "Session relays",
"Session relays tab description": "Relay logic for this session: working and striked preset relays, and scored random relays (used to prefer faster, proven relays when adding random relays to publish). Striked relays are skipped for reads and publishes until reload.", "Session relays tab description": "Relay logic for this session: preset relays and scored random relays used to prefer faster, proven relays when adding random relays to publish.",
"Session relays preset working": "Working preset relays", "Session relays preset working": "Working preset relays",
"Session relays preset working hint": "Preset relays (from app defaults) that have not reached the session failure threshold (2 failures).", "Session relays preset working hint": "Preset relays from app defaults.",
"Session relays preset striked": "Striked preset relays",
"Session relays preset striked hint": "Preset relays that have reached 2 connection or publish failures this session and are skipped for reads and writes until you reload the app.",
"Session relays scored random": "Scored random relays", "Session relays scored random": "Scored random relays",
"Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.", "Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.",
"Session relays all striked": "All striked relays (any source)",
"Session relays clear strike": "Allow again",
"Session relays clear strike hint": "Remove this relay from the session block list; it will be used again until new connection failures.",
"relaySessionStrikes.bannerWarning": "This relay has {{count}} session strike(s) (limit {{threshold}}) after connection or query failures.",
"relaySessionStrikes.bannerSkipped": "This relay has reached the session failure limit ({{threshold}} strikes) and is skipped for reads and publishes in this tab.",
"relaySessionStrikes.refreshHint": "Use {{refresh}} to clear strikes for this relay and load the feed again.",
successes: "successes", successes: "successes",
None: "None", None: "None",
"Cache & offline storage": "Cache & offline storage", "Cache & offline storage": "Cache & offline storage",

13
src/i18n/locales/es.ts

@ -513,7 +513,6 @@ export default {
"Seen on": "Visto en", "Seen on": "Visto en",
"Active relays": "Active relays", "Active relays": "Active relays",
"Not connected": "Not connected", "Not connected": "Not connected",
"Relay session striked": "Skipped this session (too many connection failures)",
"More relays": "+{{count}} relays", "More relays": "+{{count}} relays",
"Temporarily display this reply": "Mostrar temporalmente esta respuesta", "Temporarily display this reply": "Mostrar temporalmente esta respuesta",
"Note not found": "No se encontró la nota", "Note not found": "No se encontró la nota",
@ -604,19 +603,11 @@ export default {
relayType_contextual: "Reply/PM", relayType_contextual: "Reply/PM",
relayType_randomly_selected: "Random (optional)", relayType_randomly_selected: "Random (optional)",
"Session relays": "Session relays", "Session relays": "Session relays",
"Session relays tab description": "Relay logic for this session: working and striked preset relays, and scored random relays (used to prefer faster, proven relays when adding random relays to publish).", "Session relays tab description": "Relay logic for this session: preset relays and scored random relays used to prefer faster, proven relays when adding random relays to publish.",
"Session relays preset working": "Working preset relays", "Session relays preset working": "Working preset relays",
"Session relays preset working hint": "Preset relays (from app defaults) that have not reached 3 publish failures this session.", "Session relays preset working hint": "Preset relays from app defaults.",
"Session relays preset striked": "Striked preset relays",
"Session relays preset striked hint": "Preset relays that have reached 3 publish failures this session and are skipped for the rest of the session.",
"Session relays scored random": "Scored random relays", "Session relays scored random": "Scored random relays",
"Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.", "Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.",
"Session relays all striked": "All striked relays (any source)",
"Session relays clear strike": "Allow again",
"Session relays clear strike hint": "Remove this relay from the session block list; it will be used again until new connection failures.",
"relaySessionStrikes.bannerWarning": "This relay has {{count}} session strike(s) (limit {{threshold}}) after connection or query failures.",
"relaySessionStrikes.bannerSkipped": "This relay has reached the session failure limit ({{threshold}} strikes) and is skipped for reads and publishes in this tab.",
"relaySessionStrikes.refreshHint": "Use {{refresh}} to clear strikes for this relay and load the feed again.",
successes: "successes", successes: "successes",
None: "None", None: "None",
"Cache & offline storage": "Cache & offline storage", "Cache & offline storage": "Cache & offline storage",

13
src/i18n/locales/fr.ts

@ -513,7 +513,6 @@ export default {
"Seen on": "Vu sur", "Seen on": "Vu sur",
"Active relays": "Active relays", "Active relays": "Active relays",
"Not connected": "Not connected", "Not connected": "Not connected",
"Relay session striked": "Skipped this session (too many connection failures)",
"More relays": "+{{count}} relays", "More relays": "+{{count}} relays",
"Temporarily display this reply": "Afficher temporairement cette réponse", "Temporarily display this reply": "Afficher temporairement cette réponse",
"Note not found": "Note introuvable", "Note not found": "Note introuvable",
@ -604,19 +603,11 @@ export default {
relayType_contextual: "Reply/PM", relayType_contextual: "Reply/PM",
relayType_randomly_selected: "Random (optional)", relayType_randomly_selected: "Random (optional)",
"Session relays": "Session relays", "Session relays": "Session relays",
"Session relays tab description": "Relay logic for this session: working and striked preset relays, and scored random relays (used to prefer faster, proven relays when adding random relays to publish).", "Session relays tab description": "Relay logic for this session: preset relays and scored random relays used to prefer faster, proven relays when adding random relays to publish.",
"Session relays preset working": "Working preset relays", "Session relays preset working": "Working preset relays",
"Session relays preset working hint": "Preset relays (from app defaults) that have not reached 3 publish failures this session.", "Session relays preset working hint": "Preset relays from app defaults.",
"Session relays preset striked": "Striked preset relays",
"Session relays preset striked hint": "Preset relays that have reached 3 publish failures this session and are skipped for the rest of the session.",
"Session relays scored random": "Scored random relays", "Session relays scored random": "Scored random relays",
"Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.", "Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.",
"Session relays all striked": "All striked relays (any source)",
"Session relays clear strike": "Allow again",
"Session relays clear strike hint": "Remove this relay from the session block list; it will be used again until new connection failures.",
"relaySessionStrikes.bannerWarning": "This relay has {{count}} session strike(s) (limit {{threshold}}) after connection or query failures.",
"relaySessionStrikes.bannerSkipped": "This relay has reached the session failure limit ({{threshold}} strikes) and is skipped for reads and publishes in this tab.",
"relaySessionStrikes.refreshHint": "Use {{refresh}} to clear strikes for this relay and load the feed again.",
successes: "successes", successes: "successes",
None: "None", None: "None",
"Cache & offline storage": "Cache & offline storage", "Cache & offline storage": "Cache & offline storage",

13
src/i18n/locales/nl.ts

@ -513,7 +513,6 @@ export default {
"Seen on": "Seen on", "Seen on": "Seen on",
"Active relays": "Active relays", "Active relays": "Active relays",
"Not connected": "Not connected", "Not connected": "Not connected",
"Relay session striked": "Skipped this session (too many connection failures)",
"More relays": "+{{count}} relays", "More relays": "+{{count}} relays",
"Temporarily display this reply": "Temporarily display this reply", "Temporarily display this reply": "Temporarily display this reply",
"Note not found": "Note not found", "Note not found": "Note not found",
@ -604,19 +603,11 @@ export default {
relayType_contextual: "Reply/PM", relayType_contextual: "Reply/PM",
relayType_randomly_selected: "Random (optional)", relayType_randomly_selected: "Random (optional)",
"Session relays": "Session relays", "Session relays": "Session relays",
"Session relays tab description": "Relay logic for this session: working and striked preset relays, and scored random relays (used to prefer faster, proven relays when adding random relays to publish). Striked relays are skipped for reads and publishes until reload.", "Session relays tab description": "Relay logic for this session: preset relays and scored random relays used to prefer faster, proven relays when adding random relays to publish.",
"Session relays preset working": "Working preset relays", "Session relays preset working": "Working preset relays",
"Session relays preset working hint": "Preset relays (from app defaults) that have not reached the session failure threshold (2 failures).", "Session relays preset working hint": "Preset relays from app defaults.",
"Session relays preset striked": "Striked preset relays",
"Session relays preset striked hint": "Preset relays that have reached 2 connection or publish failures this session and are skipped for reads and writes until you reload the app.",
"Session relays scored random": "Scored random relays", "Session relays scored random": "Scored random relays",
"Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.", "Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.",
"Session relays all striked": "All striked relays (any source)",
"Session relays clear strike": "Allow again",
"Session relays clear strike hint": "Remove this relay from the session block list; it will be used again until new connection failures.",
"relaySessionStrikes.bannerWarning": "This relay has {{count}} session strike(s) (limit {{threshold}}) after connection or query failures.",
"relaySessionStrikes.bannerSkipped": "This relay has reached the session failure limit ({{threshold}} strikes) and is skipped for reads and publishes in this tab.",
"relaySessionStrikes.refreshHint": "Use {{refresh}} to clear strikes for this relay and load the feed again.",
successes: "successes", successes: "successes",
None: "None", None: "None",
"Cache & offline storage": "Cache & offline storage", "Cache & offline storage": "Cache & offline storage",

13
src/i18n/locales/pl.ts

@ -513,7 +513,6 @@ export default {
"Seen on": "Widziany na", "Seen on": "Widziany na",
"Active relays": "Active relays", "Active relays": "Active relays",
"Not connected": "Not connected", "Not connected": "Not connected",
"Relay session striked": "Skipped this session (too many connection failures)",
"More relays": "+{{count}} relays", "More relays": "+{{count}} relays",
"Temporarily display this reply": "Tymczasowo wyświetl tę odpowiedź", "Temporarily display this reply": "Tymczasowo wyświetl tę odpowiedź",
"Note not found": "Nie znaleziono wpisu", "Note not found": "Nie znaleziono wpisu",
@ -604,19 +603,11 @@ export default {
relayType_contextual: "Reply/PM", relayType_contextual: "Reply/PM",
relayType_randomly_selected: "Random (optional)", relayType_randomly_selected: "Random (optional)",
"Session relays": "Session relays", "Session relays": "Session relays",
"Session relays tab description": "Relay logic for this session: working and striked preset relays, and scored random relays (used to prefer faster, proven relays when adding random relays to publish).", "Session relays tab description": "Relay logic for this session: preset relays and scored random relays used to prefer faster, proven relays when adding random relays to publish.",
"Session relays preset working": "Working preset relays", "Session relays preset working": "Working preset relays",
"Session relays preset working hint": "Preset relays (from app defaults) that have not reached 3 publish failures this session.", "Session relays preset working hint": "Preset relays from app defaults.",
"Session relays preset striked": "Striked preset relays",
"Session relays preset striked hint": "Preset relays that have reached 3 publish failures this session and are skipped for the rest of the session.",
"Session relays scored random": "Scored random relays", "Session relays scored random": "Scored random relays",
"Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.", "Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.",
"Session relays all striked": "All striked relays (any source)",
"Session relays clear strike": "Allow again",
"Session relays clear strike hint": "Remove this relay from the session block list; it will be used again until new connection failures.",
"relaySessionStrikes.bannerWarning": "This relay has {{count}} session strike(s) (limit {{threshold}}) after connection or query failures.",
"relaySessionStrikes.bannerSkipped": "This relay has reached the session failure limit ({{threshold}} strikes) and is skipped for reads and publishes in this tab.",
"relaySessionStrikes.refreshHint": "Use {{refresh}} to clear strikes for this relay and load the feed again.",
successes: "successes", successes: "successes",
None: "None", None: "None",
"Cache & offline storage": "Cache & offline storage", "Cache & offline storage": "Cache & offline storage",

13
src/i18n/locales/ru.ts

@ -513,7 +513,6 @@ export default {
"Seen on": "Просмотрено на", "Seen on": "Просмотрено на",
"Active relays": "Active relays", "Active relays": "Active relays",
"Not connected": "Not connected", "Not connected": "Not connected",
"Relay session striked": "Skipped this session (too many connection failures)",
"More relays": "+{{count}} relays", "More relays": "+{{count}} relays",
"Temporarily display this reply": "Временно отобразить этот ответ", "Temporarily display this reply": "Временно отобразить этот ответ",
"Note not found": "Заметка не найдена", "Note not found": "Заметка не найдена",
@ -604,19 +603,11 @@ export default {
relayType_contextual: "Reply/PM", relayType_contextual: "Reply/PM",
relayType_randomly_selected: "Random (optional)", relayType_randomly_selected: "Random (optional)",
"Session relays": "Session relays", "Session relays": "Session relays",
"Session relays tab description": "Relay logic for this session: working and striked preset relays, and scored random relays (used to prefer faster, proven relays when adding random relays to publish).", "Session relays tab description": "Relay logic for this session: preset relays and scored random relays used to prefer faster, proven relays when adding random relays to publish.",
"Session relays preset working": "Working preset relays", "Session relays preset working": "Working preset relays",
"Session relays preset working hint": "Preset relays (from app defaults) that have not reached 3 publish failures this session.", "Session relays preset working hint": "Preset relays from app defaults.",
"Session relays preset striked": "Striked preset relays",
"Session relays preset striked hint": "Preset relays that have reached 3 publish failures this session and are skipped for the rest of the session.",
"Session relays scored random": "Scored random relays", "Session relays scored random": "Scored random relays",
"Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.", "Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.",
"Session relays all striked": "All striked relays (any source)",
"Session relays clear strike": "Allow again",
"Session relays clear strike hint": "Remove this relay from the session block list; it will be used again until new connection failures.",
"relaySessionStrikes.bannerWarning": "This relay has {{count}} session strike(s) (limit {{threshold}}) after connection or query failures.",
"relaySessionStrikes.bannerSkipped": "This relay has reached the session failure limit ({{threshold}} strikes) and is skipped for reads and publishes in this tab.",
"relaySessionStrikes.refreshHint": "Use {{refresh}} to clear strikes for this relay and load the feed again.",
successes: "successes", successes: "successes",
None: "None", None: "None",
"Cache & offline storage": "Cache & offline storage", "Cache & offline storage": "Cache & offline storage",

13
src/i18n/locales/tr.ts

@ -513,7 +513,6 @@ export default {
"Seen on": "Seen on", "Seen on": "Seen on",
"Active relays": "Active relays", "Active relays": "Active relays",
"Not connected": "Not connected", "Not connected": "Not connected",
"Relay session striked": "Skipped this session (too many connection failures)",
"More relays": "+{{count}} relays", "More relays": "+{{count}} relays",
"Temporarily display this reply": "Temporarily display this reply", "Temporarily display this reply": "Temporarily display this reply",
"Note not found": "Note not found", "Note not found": "Note not found",
@ -604,19 +603,11 @@ export default {
relayType_contextual: "Reply/PM", relayType_contextual: "Reply/PM",
relayType_randomly_selected: "Random (optional)", relayType_randomly_selected: "Random (optional)",
"Session relays": "Session relays", "Session relays": "Session relays",
"Session relays tab description": "Relay logic for this session: working and striked preset relays, and scored random relays (used to prefer faster, proven relays when adding random relays to publish). Striked relays are skipped for reads and publishes until reload.", "Session relays tab description": "Relay logic for this session: preset relays and scored random relays used to prefer faster, proven relays when adding random relays to publish.",
"Session relays preset working": "Working preset relays", "Session relays preset working": "Working preset relays",
"Session relays preset working hint": "Preset relays (from app defaults) that have not reached the session failure threshold (2 failures).", "Session relays preset working hint": "Preset relays from app defaults.",
"Session relays preset striked": "Striked preset relays",
"Session relays preset striked hint": "Preset relays that have reached 2 connection or publish failures this session and are skipped for reads and writes until you reload the app.",
"Session relays scored random": "Scored random relays", "Session relays scored random": "Scored random relays",
"Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.", "Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.",
"Session relays all striked": "All striked relays (any source)",
"Session relays clear strike": "Allow again",
"Session relays clear strike hint": "Remove this relay from the session block list; it will be used again until new connection failures.",
"relaySessionStrikes.bannerWarning": "This relay has {{count}} session strike(s) (limit {{threshold}}) after connection or query failures.",
"relaySessionStrikes.bannerSkipped": "This relay has reached the session failure limit ({{threshold}} strikes) and is skipped for reads and publishes in this tab.",
"relaySessionStrikes.refreshHint": "Use {{refresh}} to clear strikes for this relay and load the feed again.",
successes: "successes", successes: "successes",
None: "None", None: "None",
"Cache & offline storage": "Cache & offline storage", "Cache & offline storage": "Cache & offline storage",

13
src/i18n/locales/zh.ts

@ -513,7 +513,6 @@ export default {
"Seen on": "来自", "Seen on": "来自",
"Active relays": "Active relays", "Active relays": "Active relays",
"Not connected": "Not connected", "Not connected": "Not connected",
"Relay session striked": "Skipped this session (too many connection failures)",
"More relays": "+{{count}} relays", "More relays": "+{{count}} relays",
"Temporarily display this reply": "临时显示此回复", "Temporarily display this reply": "临时显示此回复",
"Note not found": "未找到该笔记", "Note not found": "未找到该笔记",
@ -604,19 +603,11 @@ export default {
relayType_contextual: "Reply/PM", relayType_contextual: "Reply/PM",
relayType_randomly_selected: "Random (optional)", relayType_randomly_selected: "Random (optional)",
"Session relays": "Session relays", "Session relays": "Session relays",
"Session relays tab description": "Relay logic for this session: working and striked preset relays, and scored random relays (used to prefer faster, proven relays when adding random relays to publish).", "Session relays tab description": "Relay logic for this session: preset relays and scored random relays used to prefer faster, proven relays when adding random relays to publish.",
"Session relays preset working": "Working preset relays", "Session relays preset working": "Working preset relays",
"Session relays preset working hint": "Preset relays (from app defaults) that have not reached 3 publish failures this session.", "Session relays preset working hint": "Preset relays from app defaults.",
"Session relays preset striked": "Striked preset relays",
"Session relays preset striked hint": "Preset relays that have reached 3 publish failures this session and are skipped for the rest of the session.",
"Session relays scored random": "Scored random relays", "Session relays scored random": "Scored random relays",
"Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.", "Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.",
"Session relays all striked": "All striked relays (any source)",
"Session relays clear strike": "Allow again",
"Session relays clear strike hint": "Remove this relay from the session block list; it will be used again until new connection failures.",
"relaySessionStrikes.bannerWarning": "This relay has {{count}} session strike(s) (limit {{threshold}}) after connection or query failures.",
"relaySessionStrikes.bannerSkipped": "This relay has reached the session failure limit ({{threshold}} strikes) and is skipped for reads and publishes in this tab.",
"relaySessionStrikes.refreshHint": "Use {{refresh}} to clear strikes for this relay and load the feed again.",
successes: "successes", successes: "successes",
None: "None", None: "None",
"Cache & offline storage": "Cache & offline storage", "Cache & offline storage": "Cache & offline storage",

43
src/lib/favorites-feed-relays.ts

@ -17,7 +17,6 @@ import {
relayUrlsLocalsFirst relayUrlsLocalsFirst
} from '@/lib/relay-url-priority' } from '@/lib/relay-url-priority'
import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy'
import { stripNostrLandAggrRelay } from '@/lib/nostr-land-aggr'
import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize' import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
const blockedSet = (blockedRelays: string[]) => const blockedSet = (blockedRelays: string[]) =>
@ -51,7 +50,7 @@ export function getFavoritesFeedRelayUrls(
}) })
const base = visible.length > 0 ? visible : DEFAULT_FAVORITE_RELAYS const base = visible.length > 0 ? visible : DEFAULT_FAVORITE_RELAYS
return feedRelayPolicyUrls( return feedRelayPolicyUrls(
[{ source: 'favorites', urls: stripNostrLandAggrRelay(base) }], [{ source: 'favorites', urls: base }],
{ {
operation: 'favorites-feed', operation: 'favorites-feed',
blockedRelays, blockedRelays,
@ -128,16 +127,6 @@ export type ReadRelayPriorityOptions = {
* relays in `SOCIAL_KIND_BLOCKED_RELAY_URLS` before capping. * relays in `SOCIAL_KIND_BLOCKED_RELAY_URLS` before capping.
*/ */
applySocialKindBlockedFilter?: boolean applySocialKindBlockedFilter?: boolean
/**
* When false, ignore each subrequests `urls` and use only the shared prioritized stack (rare).
* Default true.
*/
mergeSubrequestRelayUrls?: boolean
/**
* When true, fold `r.urls` into the author-outbox tier only (no extra first layer). Use for GIF / explicit spell relays
* that should rank with author outboxes, not ahead of user inboxes. Default false: prepend `r.urls` before user tiers.
*/
mergeSubrequestRelaysIntoAuthorTier?: boolean
} }
/** /**
@ -216,9 +205,7 @@ export function buildProfilePageReadRelayUrls(
/** /**
* Per subrequest: shared inbox author/favorites fast read stack, normalized, user-blocked and (when applicable) * Per subrequest: shared inbox author/favorites fast read stack, normalized, user-blocked and (when applicable)
* social-kind-blocked stripped, deduped, capped. Subrequest `urls` are prepended first by default (following shards); * social-kind-blocked stripped, deduped, capped. Subrequest `urls` are prepended first so explicit shard hints win.
* set {@link ReadRelayPriorityOptions.mergeSubrequestRelaysIntoAuthorTier} to fold them into the author tier only
* (e.g. curated GIF / spell relay lists).
*/ */
export function augmentSubRequestsWithFavoritesFastReadAndInbox( export function augmentSubRequestsWithFavoritesFastReadAndInbox(
requests: TFeedSubRequest[], requests: TFeedSubRequest[],
@ -234,8 +221,6 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox(
if (n) userReadSocialExempt.add(n) if (n) userReadSocialExempt.add(n)
} }
return requests.map((r) => { return requests.map((r) => {
const useSubUrls = options?.mergeSubrequestRelayUrls !== false
const foldIntoAuthor = options?.mergeSubrequestRelaysIntoAuthorTier === true
const applySocial = const applySocial =
options?.applySocialKindBlockedFilter !== undefined options?.applySocialKindBlockedFilter !== undefined
? options.applySocialKindBlockedFilter ? options.applySocialKindBlockedFilter
@ -243,37 +228,19 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox(
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)
if (!useSubUrls) {
return {
...r,
urls: buildPrioritizedReadRelayUrls({
userReadRelays: userInboxReadRelays,
userWriteRelays: options?.userWriteRelays ?? [],
authorWriteRelays: options?.authorWriteRelays ?? [],
favoriteRelays: favorites,
blockedRelays,
maxRelays: max,
applySocialKindBlockedFilter: applySocial
})
}
}
const authorOnly = dedupeNormalizeRelayUrlsOrdered(options?.authorWriteRelays ?? []) const authorOnly = dedupeNormalizeRelayUrlsOrdered(options?.authorWriteRelays ?? [])
const authorTier = foldIntoAuthor
? dedupeNormalizeRelayUrlsOrdered([...authorOnly, ...r.urls])
: authorOnly
const coreLayers = buildReadRelayPriorityLayers({ const coreLayers = buildReadRelayPriorityLayers({
userReadRelays: userInboxReadRelays, userReadRelays: userInboxReadRelays,
userWriteRelays: options?.userWriteRelays ?? [], userWriteRelays: options?.userWriteRelays ?? [],
authorWriteRelays: authorTier, authorWriteRelays: authorOnly,
favoriteRelays: favorites favoriteRelays: favorites
}) })
const layers = foldIntoAuthor ? coreLayers : [relayUrlsLocalsFirst(r.urls), ...coreLayers] const layers = [relayUrlsLocalsFirst(r.urls), ...coreLayers]
const policyLayers: FeedRelayLayer[] = layers.map((urls, index) => ({ const policyLayers: FeedRelayLayer[] = layers.map((urls, index) => ({
source: index === 0 && !foldIntoAuthor ? 'explicit' : index === 0 ? 'viewer-read' : 'fallback', source: index === 0 ? 'explicit' : index === 1 ? 'viewer-read' : 'fallback',
urls urls
})) }))
return { return {

17
src/lib/index-relay-http.ts

@ -175,9 +175,6 @@ function rawToVerifiedEvent(raw: Record<string, unknown>): NEvent | null {
/** /**
* Query one HTTP index relay. Runs one POST per filter when given an array. * Query one HTTP index relay. Runs one POST per filter when given an array.
* When every filter attempt fails with an HTTP response or a non-transport error and no events are returned,
* {@link options.onHardFailure} runs once (session strike parity with WebSocket relays). Pure browser transport
* failures (e.g. CORS on direct `https://` index POST) do not call it.
*/ */
function devHttpIndexRelayBaseForFetch(baseUrl: string): string { function devHttpIndexRelayBaseForFetch(baseUrl: string): string {
const n = normalizeHttpRelayUrl(baseUrl) || baseUrl const n = normalizeHttpRelayUrl(baseUrl) || baseUrl
@ -187,15 +184,13 @@ function devHttpIndexRelayBaseForFetch(baseUrl: string): string {
export async function queryIndexRelay( export async function queryIndexRelay(
baseUrl: string, baseUrl: string,
filter: Filter | Filter[], filter: Filter | Filter[],
options?: { signal?: AbortSignal; onHardFailure?: () => void } options?: { signal?: AbortSignal }
): Promise<NEvent[]> { ): Promise<NEvent[]> {
const base = devHttpIndexRelayBaseForFetch(baseUrl) const base = devHttpIndexRelayBaseForFetch(baseUrl)
const endpoint = indexRelayFilterUrl(base) const endpoint = indexRelayFilterUrl(base)
const filters = Array.isArray(filter) ? filter : [filter] const filters = Array.isArray(filter) ? filter : [filter]
const out: NEvent[] = [] const out: NEvent[] = []
const seen = new Set<string>() const seen = new Set<string>()
/** Only set when the server returned HTTP (!ok) or a non-transport exception — not browser-only CORS / “failed to fetch”. */
let strikeWorthyHttpFailure = false
for (const f of filters) { for (const f of filters) {
const body = nostrFilterToIndexRelayBody(filterForIndexRelay(f)) const body = nostrFilterToIndexRelayBody(filterForIndexRelay(f))
try { try {
@ -210,7 +205,6 @@ export async function queryIndexRelay(
timeoutMs: 25_000 timeoutMs: 25_000
}) })
if (!res.ok) { if (!res.ok) {
strikeWorthyHttpFailure = true
if (isDevViteIndexRelayProxyPath(endpoint)) { if (isDevViteIndexRelayProxyPath(endpoint)) {
let detail = '' let detail = ''
try { try {
@ -251,19 +245,10 @@ export async function queryIndexRelay(
if (isIndexRelayTransportFailure(e)) { if (isIndexRelayTransportFailure(e)) {
handleFilterTransportFailure(endpoint, e) handleFilterTransportFailure(endpoint, e)
} else { } else {
strikeWorthyHttpFailure = true
warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request error', { endpoint, error: e }) warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request error', { endpoint, error: e })
} }
} }
} }
if (strikeWorthyHttpFailure && out.length === 0 && filters.length > 0) {
// In dev, transport failures on the Vite loopback proxy (relay unreachable / proxy not yet ready)
// should not record session strikes — the relay may be temporarily down or the dev server
// needs a restart. Only real application errors (4xx/5xx from a live relay) trigger strikes in dev.
if (!isDevViteIndexRelayProxyPath(endpoint)) {
options?.onHardFailure?.()
}
}
return out return out
} }

12
src/lib/live-activities.ts

@ -36,18 +36,6 @@ export type LiveActivitiesFetchEventsFn = (
/** NIP-53 live streaming (30311), meeting space (30312), meeting (30313). */ /** NIP-53 live streaming (30311), meeting space (30312), meeting (30313). */
export const LIVE_ACTIVITY_KINDS = [30311, 30312, 30313] as const export const LIVE_ACTIVITY_KINDS = [30311, 30312, 30313] as const
/**
* @deprecated Home embeds no longer consult the kind picker. Kept for callers that still want
* is live activity in the users selected kinds? (e.g. optional UI); prefer inlining that check.
*/
export function liveActivityKindsEnabledInPicker(
showKinds: readonly number[],
feedKindFilterBypass: boolean
): boolean {
if (feedKindFilterBypass) return true
return LIVE_ACTIVITY_KINDS.some((k) => showKinds.includes(k))
}
/** /**
* Stable NIP-33 address `kind:pubkey:d` for a live-activity replaceable event (carousel dedupe / user hide list). * Stable NIP-33 address `kind:pubkey:d` for a live-activity replaceable event (carousel dedupe / user hide list).
* Must match {@link parseLiveActivityEvent} `address` and {@link dedupeLatestForLiveTicker} keys exactly * Must match {@link parseLiveActivityEvent} `address` and {@link dedupeLatestForLiveTicker} keys exactly

93
src/lib/nostr-land-aggr.ts

@ -1,93 +1,2 @@
import type { TRelayList } from '@/types' /** nostr.land aggregator used by read relay policy. */
import { normalizeAnyRelayUrl } from '@/lib/url'
/** Paid / subscription relay — presence in favorites or NIP-65 lists implies access to the matching aggregator. */
export const NOSTR_LAND_WSS = 'wss://nostr.land'
/** Aggregator for nostr.land subscribers only; others get auth / policy errors if contacted. */
export const AGGR_NOSTR_LAND_WSS = 'wss://aggr.nostr.land' export const AGGR_NOSTR_LAND_WSS = 'wss://aggr.nostr.land'
function canonWs(url: string): string {
return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
}
const NOSTR_LAND_CANON = canonWs(NOSTR_LAND_WSS)
const AGGR_CANON = canonWs(AGGR_NOSTR_LAND_WSS)
/** True if this URL is the nostr.land websocket (normalized). */
export function isNostrLandWsUrl(url: string | undefined | null): boolean {
if (!url?.trim()) return false
return canonWs(url) === NOSTR_LAND_CANON
}
/** True if this URL is the nostr.land aggregator websocket (normalized). */
export function isAggrNostrLandWsUrl(url: string | undefined | null): boolean {
if (!url?.trim()) return false
return canonWs(url) === AGGR_CANON
}
/** True if any normalized URL equals nostr.land. */
export function relayUrlListMentionsNostrLand(urls: readonly string[] | undefined): boolean {
if (!urls?.length) return false
for (const u of urls) {
if (isNostrLandWsUrl(u)) return true
}
return false
}
/** True if nostr.land appears in any NIP-65 / HTTP relay list slice. */
export function nip65RelayListMentionsNostrLand(rl: TRelayList | null | undefined): boolean {
if (!rl) return false
return (
relayUrlListMentionsNostrLand(rl.read) ||
relayUrlListMentionsNostrLand(rl.write) ||
relayUrlListMentionsNostrLand(rl.httpRead) ||
relayUrlListMentionsNostrLand(rl.httpWrite)
)
}
/**
* Subscriber may use {@link AGGR_NOSTR_LAND_WSS}: they listed nostr.land in kind-10012 favorites or NIP-65 lists.
*/
export function viewerMayUseNostrLandAggr(
favoriteRelays: readonly string[] | undefined,
nip65: TRelayList | null | undefined
): boolean {
if (relayUrlListMentionsNostrLand(favoriteRelays)) return true
return nip65RelayListMentionsNostrLand(nip65 ?? undefined)
}
/**
* Drop {@link AGGR_NOSTR_LAND_WSS} when the viewer is not a nostr.land subscriber; otherwise ensure it appears once.
*/
export function applyNostrLandAggrRelayPolicy(urls: readonly string[], allowAggr: boolean): string[] {
const out: string[] = []
const seen = new Set<string>()
const push = (u: string) => {
const c = canonWs(u)
if (!c || seen.has(c)) return
if (!allowAggr && c === AGGR_CANON) return
seen.add(c)
out.push(normalizeAnyRelayUrl(u) || u.trim())
}
for (const u of urls) {
push(u)
}
if (allowAggr && !seen.has(AGGR_CANON)) {
out.unshift(AGGR_NOSTR_LAND_WSS)
}
return out
}
/** Remove the aggregator from relay stacks that must stay strictly user-curated (favorites feed). */
export function stripNostrLandAggrRelay(urls: readonly string[]): string[] {
const out: string[] = []
const seen = new Set<string>()
for (const u of urls) {
const c = canonWs(u)
if (!c || c === AGGR_CANON || seen.has(c)) continue
seen.add(c)
out.push(normalizeAnyRelayUrl(u) || u.trim())
}
return out
}

8
src/lib/rss-article.ts

@ -144,14 +144,6 @@ export function expandArticleUrlThreadQueryValues(canonicalUrl: string): string[
return [...out] return [...out]
} }
/**
* Values for a REQ `#r` filter on kind 9802 / kind 7 when the thread key is a canonical article URL.
* @deprecated Prefer {@link expandArticleUrlThreadQueryValues} same values.
*/
export function computeRTagFilterValuesForArticleThread(canonicalUrl: string): string[] {
return expandArticleUrlThreadQueryValues(canonicalUrl)
}
/** True if `urlFromEvent` refers to the same article as `canonicalThreadKey` (after normalization + variant match). */ /** True if `urlFromEvent` refers to the same article as `canonicalThreadKey` (after normalization + variant match). */
export function articleUrlMatchesThreadScope(urlFromEvent: string, canonicalThreadKey: string): boolean { export function articleUrlMatchesThreadScope(urlFromEvent: string, canonicalThreadKey: string): boolean {
const key = canonicalizeRssArticleUrl(canonicalThreadKey) const key = canonicalizeRssArticleUrl(canonicalThreadKey)

6
src/lib/url.ts

@ -82,10 +82,10 @@ export function normalizeAnyRelayUrl(url: string): string {
} }
/** /**
* Stable key for per-relay session counters (strikes, publish stats): HTTP NIP-86 bases map to the same hosts * Stable key for per-relay session stats: HTTP NIP-86 bases map to the same hosts
* `wss://…` URL so `https://nos.lol` and `wss://nos.lol` share one bucket (fixes preset vs all striked mismatch). * `wss://…` URL so `https://nos.lol` and `wss://nos.lol` share one bucket.
*/ */
export function canonicalRelayStrikeKey(url: string): string { export function canonicalRelaySessionKey(url: string): string {
const stepped = (normalizeAnyRelayUrl(url) || url.trim()).trim() const stepped = (normalizeAnyRelayUrl(url) || url.trim()).trim()
if (!stepped) return '' if (!stepped) return ''
if (isHttpRelayUrl(stepped)) { if (isHttpRelayUrl(stepped)) {

4
src/pages/primary/RelayPage/index.tsx

@ -4,7 +4,6 @@ import Relay from '@/components/Relay'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { TPageRef } from '@/types' import { TPageRef } from '@/types'
import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url' import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url'
import client from '@/services/client.service'
import { Server } from 'lucide-react' import { Server } from 'lucide-react'
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react' import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react'
@ -14,9 +13,8 @@ const RelayPage = forwardRef<TPageRef, { url?: string }>(({ url }, ref) => {
const feedRef = useRef<TNoteListRef>(null) const feedRef = useRef<TNoteListRef>(null)
const runRefresh = useCallback(() => { const runRefresh = useCallback(() => {
if (normalizedUrl) client.clearSessionRelayStrikeForUrl(normalizedUrl)
feedRef.current?.refresh() feedRef.current?.refresh()
}, [normalizedUrl]) }, [])
useImperativeHandle( useImperativeHandle(
ref, ref,

4
src/pages/secondary/RelayPage/index.tsx

@ -4,7 +4,6 @@ import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url' import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url'
import client from '@/services/client.service'
import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react'
import NotFoundPage from '../NotFoundPage' import NotFoundPage from '../NotFoundPage'
@ -15,9 +14,8 @@ const RelayPage = forwardRef(({ url, index, hideTitlebar = false }: { url?: stri
const title = useMemo(() => (url ? simplifyUrl(url) : undefined), [url]) const title = useMemo(() => (url ? simplifyUrl(url) : undefined), [url])
const bumpFeed = useCallback(() => { const bumpFeed = useCallback(() => {
if (normalizedUrl) client.clearSessionRelayStrikeForUrl(normalizedUrl)
feedRef.current?.refresh() feedRef.current?.refresh()
}, [normalizedUrl]) }, [])
useEffect(() => { useEffect(() => {
if (!hideTitlebar) { if (!hideTitlebar) {

38
src/services/client-query.service.ts

@ -28,7 +28,7 @@ import { isIndexRelayTransportFailure, queryIndexRelay } from '@/lib/index-relay
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { isHttpRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url' import { isHttpRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url'
import { RelaySubscribeOpBatch } from '@/services/relay-operation-log.service' import { RelaySubscribeOpBatch } from '@/services/relay-operation-log.service'
import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-strike' import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch-failure'
import type { Filter, Event as NEvent } from 'nostr-tools' import type { Filter, Event as NEvent } from 'nostr-tools'
import { SimplePool, EventTemplate, VerifiedEvent, nip19 } from 'nostr-tools' import { SimplePool, EventTemplate, VerifiedEvent, nip19 } from 'nostr-tools'
import type { AbstractRelay } from 'nostr-tools/abstract-relay' import type { AbstractRelay } from 'nostr-tools/abstract-relay'
@ -160,23 +160,17 @@ export interface SubscribeCallbacks {
} }
export type QueryServiceRelaySessionOptions = { export type QueryServiceRelaySessionOptions = {
/** Skip opening REQ/publish paths to this normalized URL for the rest of the page session. */ /** NOTICE "failed to fetch events" and similar backend failures. */
shouldSkipRelayForSession?: (normalizedUrl: string) => boolean onRelayNoticeFetchFailure?: (normalizedUrl: string, noticeMessage: string) => void
/** After failed `ensureRelay` (timeout / connection error), increment client session strike counter. */
onRelayConnectionFailure?: (normalizedUrl: string) => void
/** NOTICE "failed to fetch events" (and similar) → same strike treatment as connection failure. */
onRelayNoticeStrike?: (normalizedUrl: string, noticeMessage: string) => void
} }
export class QueryService { export class QueryService {
private pool: SimplePool private pool: SimplePool
private signer?: ISigner private signer?: ISigner
private signerType?: TSignerType private signerType?: TSignerType
private shouldSkipRelayForSession?: (normalizedUrl: string) => boolean
private onRelayConnectionFailure?: (normalizedUrl: string) => void
/** Optional: ingest every resolved `query()` result (e.g. session event LRU). */ /** Optional: ingest every resolved `query()` result (e.g. session event LRU). */
private onQueryResultIngest?: (events: NEvent[]) => void private onQueryResultIngest?: (events: NEvent[]) => void
private onRelayNoticeStrike?: (normalizedUrl: string, noticeMessage: string) => void private onRelayNoticeFetchFailure?: (normalizedUrl: string, noticeMessage: string) => void
/** Max concurrent REQ subscriptions per relay URL (see {@link MAX_CONCURRENT_SUBS_PER_RELAY}). */ /** Max concurrent REQ subscriptions per relay URL (see {@link MAX_CONCURRENT_SUBS_PER_RELAY}). */
private static readonly SUB_SLOT_CAP_PER_RELAY = MAX_CONCURRENT_SUBS_PER_RELAY private static readonly SUB_SLOT_CAP_PER_RELAY = MAX_CONCURRENT_SUBS_PER_RELAY
@ -209,9 +203,7 @@ export class QueryService {
constructor(pool: SimplePool, relaySession?: QueryServiceRelaySessionOptions) { constructor(pool: SimplePool, relaySession?: QueryServiceRelaySessionOptions) {
this.pool = pool this.pool = pool
this.shouldSkipRelayForSession = relaySession?.shouldSkipRelayForSession this.onRelayNoticeFetchFailure = relaySession?.onRelayNoticeFetchFailure
this.onRelayConnectionFailure = relaySession?.onRelayConnectionFailure
this.onRelayNoticeStrike = relaySession?.onRelayNoticeStrike
} }
/** Wire after {@link EventService} exists: each `query()` / `fetchEvents` event is ingested from `onevent` (session LRU). */ /** Wire after {@link EventService} exists: each `query()` / `fetchEvents` event is ingested from `onevent` (session LRU). */
@ -354,7 +346,7 @@ export class QueryService {
.map((u) => normalizeHttpRelayUrl(u) || u) .map((u) => normalizeHttpRelayUrl(u) || u)
.filter(Boolean) .filter(Boolean)
) )
).filter((base) => !this.shouldSkipRelayForSession?.(base)) )
const wsQueryUrls = urls.filter((u) => !isHttpRelayUrl(u)) const wsQueryUrls = urls.filter((u) => !isHttpRelayUrl(u))
return await new Promise<NEvent[]>((resolve) => { return await new Promise<NEvent[]>((resolve) => {
@ -377,10 +369,7 @@ export class QueryService {
: Promise.allSettled( : Promise.allSettled(
httpRelayBases.map(async (base) => { httpRelayBases.map(async (base) => {
try { try {
const evts = await queryIndexRelay(base, effectiveFilter, { const evts = await queryIndexRelay(base, effectiveFilter, { signal: abortHttp.signal })
signal: abortHttp.signal,
onHardFailure: () => this.onRelayConnectionFailure?.(base)
})
for (const evt of evts) { for (const evt of evts) {
if (resolved) return if (resolved) return
eventCount++ eventCount++
@ -611,13 +600,6 @@ export class QueryService {
relays = relayUrlsStripExtendedTagReqBlocked([...FAST_READ_RELAY_URLS]) relays = relayUrlsStripExtendedTagReqBlocked([...FAST_READ_RELAY_URLS])
} }
} }
if (this.shouldSkipRelayForSession) {
relays = relays.filter((url) => {
const n = normalizeUrl(url) || url
return !this.shouldSkipRelayForSession!(n)
})
}
relays = relays.filter((url) => !isHttpRelayUrl(url)) relays = relays.filter((url) => !isHttpRelayUrl(url))
if (relays.length === 0) { if (relays.length === 0) {
@ -702,9 +684,8 @@ export class QueryService {
relay = await this.pool.ensureRelay(url, { relay = await this.pool.ensureRelay(url, {
connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS
}) })
patchRelayNoticeForFetchFailures(relay, relayKey, this.onRelayNoticeStrike) patchRelayNoticeForFetchFailures(relay, relayKey, this.onRelayNoticeFetchFailure)
} catch (err) { } catch (err) {
this.onRelayConnectionFailure?.(relayKey)
this.releaseSubSlot(relayKey) this.releaseSubSlot(relayKey)
handleClose(i, (err as Error)?.message ?? String(err)) handleClose(i, (err as Error)?.message ?? String(err))
return return
@ -749,10 +730,9 @@ export class QueryService {
liveRelay = await this.pool.ensureRelay(url, { liveRelay = await this.pool.ensureRelay(url, {
connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS
}) })
patchRelayNoticeForFetchFailures(liveRelay, relayKey, this.onRelayNoticeStrike) patchRelayNoticeForFetchFailures(liveRelay, relayKey, this.onRelayNoticeFetchFailure)
} catch (err) { } catch (err) {
nip42ResubscribePending.delete(i) nip42ResubscribePending.delete(i)
this.onRelayConnectionFailure?.(relayKey)
this.releaseSubSlot(relayKey) this.releaseSubSlot(relayKey)
handleClose(i, (err as Error)?.message ?? String(err)) handleClose(i, (err as Error)?.message ?? String(err))
return return

73
src/services/client.service.ts

@ -129,7 +129,7 @@ import {
urlIsNonLocalForRemoteViewer urlIsNonLocalForRemoteViewer
} from '@/lib/relay-list-sanitize' } from '@/lib/relay-list-sanitize'
import { import {
canonicalRelayStrikeKey, canonicalRelaySessionKey,
isHttpRelayUrl, isHttpRelayUrl,
isLocalNetworkUrl, isLocalNetworkUrl,
normalizeAnyRelayUrl, normalizeAnyRelayUrl,
@ -170,7 +170,7 @@ import indexedDb from './indexed-db.service'
import { invalidateArchiveFootprintCache } from './event-archive.service' import { invalidateArchiveFootprintCache } from './event-archive.service'
import { notifyLiveActivitiesPrewarmComplete } from './live-activities-prewarm-bridge' import { notifyLiveActivitiesPrewarmComplete } from './live-activities-prewarm-bridge'
import nip66Service from './nip66.service' import nip66Service from './nip66.service'
import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-strike' import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch-failure'
import { import {
compactFilterForRelayLog, compactFilterForRelayLog,
RelayOpTerminalRow, RelayOpTerminalRow,
@ -182,9 +182,6 @@ import { EventService } from './client-events.service'
import { ReplaceableEventService } from './client-replaceable-events.service' import { ReplaceableEventService } from './client-replaceable-events.service'
import { MacroService, createBookstrService } from './client-macro.service' import { MacroService, createBookstrService } from './client-macro.service'
/** Fired on `window` when session relay strike counts change (subscribe in single-relay UI). */
export const JUMBLE_SESSION_RELAY_STRIKES_CHANGED = 'jumble:session-relay-strikes-changed' as const
/** Live timeline REQ: EOSE caps “connected but silent” relays. */ /** Live timeline REQ: EOSE caps “connected but silent” relays. */
const SUBSCRIBE_RELAY_EOSE_TIMEOUT_MS = 4800 const SUBSCRIBE_RELAY_EOSE_TIMEOUT_MS = 4800
/** Coalesce pre-EOSE timeline snapshots; `setTimeout` so updates still run when rAF is throttled (background tab). */ /** Coalesce pre-EOSE timeline snapshots; `setTimeout` so updates still run when rAF is throttled (background tab). */
@ -334,7 +331,7 @@ class ClientService extends EventTarget {
// Initialize sub-services // Initialize sub-services
this.queryService = new QueryService(this.pool, { this.queryService = new QueryService(this.pool, {
onRelayNoticeStrike: (normalizedUrl, noticeMessage) => onRelayNoticeFetchFailure: (normalizedUrl, noticeMessage) =>
this.logRelayNoticeFetchFailure(normalizedUrl, noticeMessage) this.logRelayNoticeFetchFailure(normalizedUrl, noticeMessage)
}) })
this.eventService = new EventService(this.queryService) this.eventService = new EventService(this.queryService)
@ -1138,45 +1135,16 @@ class ClientService extends EventTarget {
/** NOTICE "failed to fetch events" — logged only (no session relay blocking). */ /** NOTICE "failed to fetch events" — logged only (no session relay blocking). */
private logRelayNoticeFetchFailure(url: string, noticeMessage: string) { private logRelayNoticeFetchFailure(url: string, noticeMessage: string) {
const n = canonicalRelayStrikeKey(url) const n = canonicalRelaySessionKey(url)
logger.debug('[Relay] NOTICE failed-fetch', { logger.debug('[Relay] NOTICE failed-fetch', {
url: n ?? url, url: n ?? url,
noticeSnippet: noticeMessage.slice(0, 220) noticeSnippet: noticeMessage.slice(0, 220)
}) })
} }
/** Legacy API: session strikes removed; always zero. */
getSessionRelayStrikeCountForUrl(_url: string): number {
return 0
}
getSessionRelayFailureStrikeThreshold(): number {
return 4
}
/** Legacy API: session strikes removed; relays are never skipped for reads for flaky connections. */
isSessionRelayStrikedForReads(_url: string): boolean {
return false
}
/** No-op: use relay block list in settings instead of automatic session strikes. */
clearSessionRelayStrikes(): void {}
clearSessionRelayStrikeForUrl(_url: string): boolean {
return false
}
clearSessionRelayStrikesForUrls(_urls: string[]): number {
return 0
}
private relayUrlsAfterStrikesOrRecover(urls: string[]): string[] {
return Array.from(new Set(urls))
}
/** Record a successful publish and its latency for session-based preference when selecting random relays. */ /** Record a successful publish and its latency for session-based preference when selecting random relays. */
recordPublishSuccess(url: string, latencyMs: number) { recordPublishSuccess(url: string, latencyMs: number) {
const n = canonicalRelayStrikeKey(url) const n = canonicalRelaySessionKey(url)
if (!n) return if (!n) return
const cur = this.sessionRelayPublishStats.get(n) const cur = this.sessionRelayPublishStats.get(n)
if (cur) { if (cur) {
@ -1196,7 +1164,7 @@ class ClientService extends EventTarget {
const out: string[] = [] const out: string[] = []
for (const [url, stats] of this.sessionRelayPublishStats.entries()) { for (const [url, stats] of this.sessionRelayPublishStats.entries()) {
if (stats.successCount < 1) continue if (stats.successCount < 1) continue
const n = canonicalRelayStrikeKey(url) const n = canonicalRelaySessionKey(url)
if (!n || readOnlySet.has(n)) continue if (!n || readOnlySet.has(n)) continue
out.push(n) out.push(n)
} }
@ -1209,14 +1177,10 @@ class ClientService extends EventTarget {
return out return out
} }
/** /** Session-only debug for Settings: scored publish relays. */
* Session-only debug for Settings: scored publish relays (no automatic session strikes).
*/
getSessionRelayDebug(): { getSessionRelayDebug(): {
strikedUrls: string[]
scoredRelays: { url: string; successCount: number; avgLatencyMs: number }[] scoredRelays: { url: string; successCount: number; avgLatencyMs: number }[]
presetWorking: string[] presetWorking: string[]
presetStriked: string[]
} { } {
const presetSet = new Set<string>() const presetSet = new Set<string>()
for (const u of [ for (const u of [
@ -1226,7 +1190,7 @@ class ClientService extends EventTarget {
...SEARCHABLE_RELAY_URLS ...SEARCHABLE_RELAY_URLS
]) { ]) {
const n = normalizeUrl(u) || u const n = normalizeUrl(u) || u
if (n) presetSet.add(canonicalRelayStrikeKey(n)) if (n) presetSet.add(canonicalRelaySessionKey(n))
} }
const preset = Array.from(presetSet) const preset = Array.from(presetSet)
const scoredRelays = Array.from(this.sessionRelayPublishStats.entries()).map(([url, s]) => ({ const scoredRelays = Array.from(this.sessionRelayPublishStats.entries()).map(([url, s]) => ({
@ -1235,7 +1199,7 @@ class ClientService extends EventTarget {
avgLatencyMs: Math.round(s.sumLatencyMs / s.successCount) avgLatencyMs: Math.round(s.sumLatencyMs / s.successCount)
})) }))
scoredRelays.sort((a, b) => a.avgLatencyMs - b.avgLatencyMs) scoredRelays.sort((a, b) => a.avgLatencyMs - b.avgLatencyMs)
return { strikedUrls: [], scoredRelays, presetWorking: preset, presetStriked: [] } return { scoredRelays, presetWorking: preset }
} }
/** /**
@ -1251,14 +1215,14 @@ class ClientService extends EventTarget {
const preferred: string[] = [] const preferred: string[] = []
const rest: string[] = [] const rest: string[] = []
for (const url of unique) { for (const url of unique) {
const sk = canonicalRelayStrikeKey(url) const sk = canonicalRelaySessionKey(url)
const stats = sk ? this.sessionRelayPublishStats.get(sk) : undefined const stats = sk ? this.sessionRelayPublishStats.get(sk) : undefined
if (stats && stats.successCount >= 1) preferred.push(url) if (stats && stats.successCount >= 1) preferred.push(url)
else rest.push(url) else rest.push(url)
} }
preferred.sort((a, b) => { preferred.sort((a, b) => {
const sa = this.sessionRelayPublishStats.get(canonicalRelayStrikeKey(a)) const sa = this.sessionRelayPublishStats.get(canonicalRelaySessionKey(a))
const sb = this.sessionRelayPublishStats.get(canonicalRelayStrikeKey(b)) const sb = this.sessionRelayPublishStats.get(canonicalRelaySessionKey(b))
if (!sa || !sb) return 0 if (!sa || !sb) return 0
if (sb.successCount !== sa.successCount) return sb.successCount - sa.successCount if (sb.successCount !== sa.successCount) return sb.successCount - sa.successCount
const avgA = sa.sumLatencyMs / sa.successCount const avgA = sa.sumLatencyMs / sa.successCount
@ -1301,7 +1265,7 @@ class ClientService extends EventTarget {
return true return true
}) })
filtered = Array.from(new Set(filtered)) filtered = Array.from(new Set(filtered))
filtered = this.relayUrlsAfterStrikesOrRecover(filtered) filtered = Array.from(new Set(filtered))
const countAfterFiltersBeforeCap = filtered.length const countAfterFiltersBeforeCap = filtered.length
filtered = await this.capPublishRelayUrlsForPublish( filtered = await this.capPublishRelayUrlsForPublish(
filtered, filtered,
@ -1323,7 +1287,7 @@ class ClientService extends EventTarget {
maxPublishRelays: MAX_PUBLISH_RELAYS, maxPublishRelays: MAX_PUBLISH_RELAYS,
fromPickerOrDetermineCount: relayUrls.length, fromPickerOrDetermineCount: relayUrls.length,
afterMergeWithYourOutboxes: mergedRelayUrls.length, afterMergeWithYourOutboxes: mergedRelayUrls.length,
afterReadonlySocialAndStrikeFilter: countAfterFiltersBeforeCap, afterReadonlySocialFilter: countAfterFiltersBeforeCap,
finalContactedRelayCount: uniqueRelayUrls.length, finalContactedRelayCount: uniqueRelayUrls.length,
finalRelays: uniqueRelayUrls, finalRelays: uniqueRelayUrls,
explain: explain:
@ -2120,8 +2084,7 @@ class ClientService extends EventTarget {
) { ) {
const originalDedupedRelays = Array.from(new Set(urls)) const originalDedupedRelays = Array.from(new Set(urls))
let relays = originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) let relays = originalDedupedRelays.filter((url) => !isHttpRelayUrl(url))
// While offline, silently drop every non-local relay so nothing is added to // While offline, silently drop every non-local relay so nothing is added to groupedRequests.
// groupedRequests and no session strike is recorded for a connectivity-induced failure.
if (!navigator.onLine) { if (!navigator.onLine) {
relays = relays.filter((url) => isLocalNetworkUrl(url)) relays = relays.filter((url) => isLocalNetworkUrl(url))
} }
@ -2156,7 +2119,7 @@ class ClientService extends EventTarget {
relays = relayUrlsStripExtendedTagReqBlocked([...FAST_READ_RELAY_URLS]) relays = relayUrlsStripExtendedTagReqBlocked([...FAST_READ_RELAY_URLS])
} }
} }
relays = this.relayUrlsAfterStrikesOrRecover(relays) relays = Array.from(new Set(relays))
// eslint-disable-next-line @typescript-eslint/no-this-alias // eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this const that = this
@ -2971,9 +2934,9 @@ class ClientService extends EventTarget {
const stripped = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)) const stripped = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url))
relays = relaysAfterSocialKindBlockedStrip(wsOriginal, stripped) relays = relaysAfterSocialKindBlockedStrip(wsOriginal, stripped)
} }
relays = this.relayUrlsAfterStrikesOrRecover(relays) relays = Array.from(new Set(relays))
let queryRelays = dedupeNormalizeRelayUrlsOrdered([...relays, ...httpRelayBases]) let queryRelays = dedupeNormalizeRelayUrlsOrdered([...relays, ...httpRelayBases])
/** If every candidate was session-striked / filtered away, still hit public read mirrors so REQ does not no-op. */ /** If every candidate was filtered away, still hit public read mirrors so REQ does not no-op. */
if (queryRelays.length === 0) { if (queryRelays.length === 0) {
queryRelays = dedupeNormalizeRelayUrlsOrdered([...FAST_READ_RELAY_URLS]) queryRelays = dedupeNormalizeRelayUrlsOrdered([...FAST_READ_RELAY_URLS])
} }

10
src/services/mention-event-search.service.ts

@ -195,16 +195,6 @@ export async function searchCitationEventsForPicker(
return searchEventsForPicker(query, limit, 'nevent', CITATION_PICKER_KINDS) return searchEventsForPicker(query, limit, 'nevent', CITATION_PICKER_KINDS)
} }
/**
* @deprecated Use searchEventsForPicker(query, limit, 'nevent') instead.
*/
export async function searchNotesForPicker(
query: string,
limit: number = DEFAULT_NOTES_LIMIT
): Promise<NEvent[]> {
return searchEventsForPicker(query, limit, 'nevent')
}
/** /**
* Search for npubs for @-mentions. Uses same pattern as note search: cache (follow + local index) then relays. * Search for npubs for @-mentions. Uses same pattern as note search: cache (follow + local index) then relays.
* Delegates to client which already does follow-list local index relay search. * Delegates to client which already does follow-list local index relay search.

2
src/services/note-stats.service.ts

@ -480,7 +480,7 @@ class NoteStatsService {
const add = (url: string | undefined) => { const add = (url: string | undefined) => {
if (!url) return if (!url) return
// Must use normalizeAnyRelayUrl, not normalizeUrl: the latter converts http(s):// // Must use normalizeAnyRelayUrl, not normalizeUrl: the latter converts http(s)://
// index relay URLs into ws(s):// which then hit the WebSocket pool and get session strikes. // index relay URLs into ws(s):// which then hit the WebSocket pool.
const n = normalizeAnyRelayUrl(url) const n = normalizeAnyRelayUrl(url)
if (!n || blocked.has(n.toLowerCase()) || seen.has(n)) return if (!n || blocked.has(n.toLowerCase()) || seen.has(n)) return
seen.add(n) seen.add(n)

10
src/services/relay-notice-strike.ts → src/services/relay-notice-fetch-failure.ts

@ -2,25 +2,25 @@ import type { AbstractRelay } from 'nostr-tools/abstract-relay'
const patched = new WeakSet<object>() const patched = new WeakSet<object>()
/** NOTICE bodies that indicate the relay backend failed to serve the REQ — count as a session strike. */ /** NOTICE bodies that indicate the relay backend failed to serve the REQ. */
const FAILED_FETCH_EVENTS = /failed to fetch events/i const FAILED_FETCH_EVENTS = /failed to fetch events/i
/** /**
* One-time patch: relay NOTICE "failed to fetch events" session strike (same as connection failure). * One-time patch: relay NOTICE "failed to fetch events" -> diagnostic callback.
* Safe to call on every ensureRelay; only the first patch per relay instance applies. * Safe to call on every ensureRelay; only the first patch per relay instance applies.
*/ */
export function patchRelayNoticeForFetchFailures( export function patchRelayNoticeForFetchFailures(
relay: AbstractRelay, relay: AbstractRelay,
relayKey: string, relayKey: string,
onStrike?: (normalizedUrl: string, noticeMessage: string) => void onFailure?: (normalizedUrl: string, noticeMessage: string) => void
): void { ): void {
if (!onStrike || patched.has(relay as object)) return if (!onFailure || patched.has(relay as object)) return
patched.add(relay as object) patched.add(relay as object)
const previous = relay.onnotice.bind(relay) const previous = relay.onnotice.bind(relay)
relay.onnotice = (msg: string) => { relay.onnotice = (msg: string) => {
if (typeof msg === 'string' && FAILED_FETCH_EVENTS.test(msg)) { if (typeof msg === 'string' && FAILED_FETCH_EVENTS.test(msg)) {
try { try {
onStrike(relayKey, msg) onFailure(relayKey, msg)
} catch { } catch {
/* ignore */ /* ignore */
} }
Loading…
Cancel
Save