Compare commits

..

No commits in common. '2cadd4b2a934ca17ba0db659f6018750ccee48c6' and 'da4b2cb1db95fe84d548d6d3994c1fbc71a3f885' have entirely different histories.

  1. 165
      .cursor/plans/nostr-archives-rollout.md
  2. 21
      .cursor/rules/nostr-archives-integration.mdc
  3. 2
      .env.development
  4. 3
      Dockerfile
  5. 3
      PROXY_SETUP.md
  6. 2
      docker-compose.prod.yml
  7. 182
      docs/performance-fixes.md
  8. 11
      package-lock.json
  9. 3
      package.json
  10. 4
      scripts/README-deploy.md
  11. 5
      scripts/build-and-push-prod.sh
  12. 270
      src/PageManager.tsx
  13. 64
      src/components/AccountList/index.tsx
  14. 31
      src/components/AccountManager/index.tsx
  15. 115
      src/components/AccountQuickSwitchMenuItems.tsx
  16. 10
      src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx
  17. 25
      src/components/AnonUserAvatar.tsx
  18. 27
      src/components/BottomNavigationBar/WriteButton.tsx
  19. 7
      src/components/CacheBrowser/CacheBrowserDialog.tsx
  20. 34
      src/components/ConnectedRelays/ActiveRelaysDropdownSection.tsx
  21. 106
      src/components/ConnectedRelays/ActiveRelaysIconGrid.tsx
  22. 13
      src/components/ConnectedRelays/active-relays-display.ts
  23. 22
      src/components/Content/index.tsx
  24. 4
      src/components/ContentPreview/FollowPackPreview.tsx
  25. 54
      src/components/EmojiPicker/index.tsx
  26. 36
      src/components/EmojiPickerDialog/index.tsx
  27. 135
      src/components/Explore/ExploreFavoriteRelays.tsx
  28. 77
      src/components/Explore/ExplorePopularRelays.tsx
  29. 239
      src/components/Explore/ExploreRelayReviews.tsx
  30. 146
      src/components/Explore/index.tsx
  31. 8
      src/components/FavoriteRelaysSetting/AddNewRelay.tsx
  32. 10
      src/components/FavoriteRelaysSetting/BlockedRelayItem.tsx
  33. 15
      src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx
  34. 39
      src/components/FavoriteRelaysSetting/RelayItem.tsx
  35. 18
      src/components/FavoriteRelaysSetting/RelaySet.tsx
  36. 35
      src/components/FavoriteRelaysSetting/RelayUrl.tsx
  37. 2
      src/components/FavoriteRelaysSetting/index.tsx
  38. 47
      src/components/FeedFilterToolbarRow/index.tsx
  39. 30
      src/components/FeedRelaysIconRow/index.tsx
  40. 16
      src/components/FollowButton/index.tsx
  41. 94
      src/components/FollowingFavoriteRelayList/index.tsx
  42. 9
      src/components/FountainEmbeddedPlayer/index.tsx
  43. 485
      src/components/GifPicker/index.tsx
  44. 145
      src/components/HelpAndAccountMenu.tsx
  45. 86
      src/components/Image/index.tsx
  46. 9
      src/components/ImageGallery/index.tsx
  47. 5
      src/components/ImageWithLightbox/index.tsx
  48. 7
      src/components/KindFilter/index.tsx
  49. 152
      src/components/Library/LibraryPublicationGrid.tsx
  50. 77
      src/components/Library/LibrarySearchBar.tsx
  51. 92
      src/components/LibraryIndexCacheSettings/index.tsx
  52. 19
      src/components/LoginDialog/index.tsx
  53. 15
      src/components/MediaPlayer/index.tsx
  54. 14
      src/components/MuteButton/index.tsx
  55. 51
      src/components/Nip07ExtensionKeyMismatchToast/index.tsx
  56. 236
      src/components/NormalFeed/index.tsx
  57. 12
      src/components/Note/ArticleCardCoverImage.tsx
  58. 11
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  59. 10
      src/components/Note/CommunityDefinition.tsx
  60. 10
      src/components/Note/GroupMetadata.tsx
  61. 8
      src/components/Note/LiveEvent.tsx
  62. 5
      src/components/Note/LongFormCard.tsx
  63. 41
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  64. 49
      src/components/Note/MusicTrackNote.tsx
  65. 16
      src/components/Note/NsfwNote.tsx
  66. 54
      src/components/Note/PublicationBooklistButton.tsx
  67. 119
      src/components/Note/PublicationCard.tsx
  68. 38
      src/components/Note/PublicationCoverFallback.tsx
  69. 87
      src/components/Note/PublicationCoverImage.tsx
  70. 301
      src/components/Note/PublicationIndexBody.tsx
  71. 208
      src/components/Note/PublicationIndexMetadata.tsx
  72. 314
      src/components/Note/SelectionHighlightTrigger.tsx
  73. 5
      src/components/Note/WikiCard.tsx
  74. 54
      src/components/Note/index.tsx
  75. 2
      src/components/NoteCard/MainNoteCard.tsx
  76. 5
      src/components/NoteDrawer/index.tsx
  77. 707
      src/components/NoteList/index.tsx
  78. 3
      src/components/NoteOptions/EditOrCloneEventDialog.tsx
  79. 128
      src/components/NoteOptions/useMenuActions.tsx
  80. 41
      src/components/NoteStats/LikeButton.tsx
  81. 19
      src/components/NoteStats/ReplyButton.tsx
  82. 27
      src/components/NoteStats/RepostButton.tsx
  83. 110
      src/components/NoteStats/SeenOnButton.tsx
  84. 8
      src/components/NoteStats/ZapButton.tsx
  85. 3
      src/components/NoteStats/index.tsx
  86. 106
      src/components/NotificationThreadWatchButtons/index.tsx
  87. 542
      src/components/PostEditor/PostContent.tsx
  88. 171
      src/components/PostEditor/PostEditorAdvancedPanel.tsx
  89. 62
      src/components/PostEditor/PostRelaySelector.tsx
  90. 7
      src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx
  91. 27
      src/components/PostEditor/PostTextarea/Mention/MentionList.tsx
  92. 70
      src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx
  93. 71
      src/components/PostEditor/PostTextarea/Mention/NeventPickerProvider.tsx
  94. 17
      src/components/PostEditor/PostTextarea/Mention/mention-range.test.ts
  95. 8
      src/components/PostEditor/PostTextarea/Mention/nevent-picker-context.ts
  96. 247
      src/components/PostEditor/PostTextarea/Mention/suggestion.ts
  97. 2
      src/components/PostEditor/PostTextarea/Mention/useNeventPicker.ts
  98. 103
      src/components/PostEditor/PostTextarea/Preview.tsx
  99. 205
      src/components/PostEditor/PostTextarea/index.tsx
  100. 90
      src/components/PostEditor/PostTextarea/suggestion-popup.ts
  101. Some files were not shown because too many files have changed in this diff Show More

165
.cursor/plans/nostr-archives-rollout.md

@ -1,165 +0,0 @@
# Nostr Archives integration — rollout checkpoint
**Last updated:** 2026-06-03
**Purpose:** Preserve plan position across agent “Fix” runs and chat resets. Resume from **Next step** below.
**API docs:** https://nostrarchives.com/docs
**Rule file:** `.cursor/rules/nostr-archives-integration.mdc`
---
## Status summary
| Phase | Description | Status |
|-------|-------------|--------|
| **0** | Foundation (API client, ingest, graceful failure, search relay constant) | **DONE** |
| **1** | Profile: follower count + paginated followers list | **DONE** |
| **2** | `search.nostrarchives.com` in `SEARCHABLE_RELAY_URLS` | **DONE** (in Phase 0) |
| **3** | General notes search: local + Archives REST merge | **DONE** |
| **4** | Note stats: prefetch `/v1/events/{id}/interactions` | **DONE** |
| **5a** | `/v1/search/suggest` in profile picker | **DONE** |
| **5b** | `POST /v1/profiles/metadata` in search/thread UIs | **DONE** |
| **6** | Note page: layered load (session → IDB → Archives → relays) | **DONE** |
**Other recent work (same branch, separate from Archives):** Post editor TipTap blank-field fix (stable extensions + placeholder shell) — see commit history on `imwald`.
---
## Cross-cutting rules (all phases)
1. **Persist verified events** — Any Archives payload with valid Nostr events goes through `persistArchivesEventsIfNew` / `persistArchivesPayloadEvents` → session cache + IndexedDB archive (`client.addEventToCache` → `queuePersistSeenEvent`). Skip if already in session or archive. Slim rows without valid `sig` are not persisted; use `getEventById` or relay backfill for offline.
2. **Graceful failure**`nostrArchivesApi` returns `TArchivesApiResult`; never throw. On failure: hide Archives-only UI or fall back to relays/local. Circuit breaker (2 failures → 60s). Setting: `storage.getUseNostrArchivesApi()` (default on). Hook: `useNostrArchivesAvailable()`.
3. **Rate limit** — 100 req/min client budget via `nostrArchivesApi` only; batch metadata and interaction prefetch.
---
## Phase 0 — DONE (foundation files)
| File | Role |
|------|------|
| `src/constants.ts` | `NOSTR_ARCHIVES_API_BASE_URL`, `NOSTR_ARCHIVES_SEARCH_RELAY_URL`, rate limit, `StorageKey.USE_NOSTR_ARCHIVES_API`, search relay in `SEARCHABLE_RELAY_URLS` |
| `src/types/nostr-archives.ts` | `TArchivesApiResult`, social, interactions, metadata, note page types |
| `src/lib/nostr-archives-event.ts` | Strip enrichment fields; `archivesJsonToVerifiedEvent()` |
| `src/lib/nostr-archives-ingest.ts` | `persistArchivesEventsIfNew`, `persistArchivesPayloadEvents` |
| `src/services/nostr-archives-api.service.ts` | HTTP client, circuit breaker, all endpoint stubs |
| `src/services/local-storage.service.ts` | `getUseNostrArchivesApi` / `setUseNostrArchivesApi` |
| `src/hooks/useNostrArchivesAvailable.ts` | UI availability |
| `.cursor/rules/nostr-archives-integration.mdc` | Agent constraints |
**Service methods ready:** `getEventInteractions`, `getSocialGraph`, `getEventById`, `getNotePage`, `searchNotes`, `searchGeneral`, `searchSuggest`, `fetchProfilesMetadata`.
---
## Phase 1 — DONE: Followers on profile
| File | Role |
|------|------|
| `src/hooks/useNostrArchivesSocial.ts` | Counts via `getSocialGraph` (`followers_limit=0`) |
| `src/components/Profile/SmartFollowers.tsx` | Count + link; hidden when API unavailable or no count |
| `src/components/Profile/index.tsx` | Renders `SmartFollowers` next to `SmartFollowings` |
| `src/pages/secondary/FollowersListPage/index.tsx` | Paginated list (100/page), infinite scroll |
| `src/lib/link.ts` | `toFollowersList` |
| `src/routes.tsx`, `PageManager.tsx`, `navigation.service.ts` | Route + mobile/desktop nav |
| i18n `en.ts` / `de.ts` | Followers strings + indexer hint |
**Offline:** `!social.ok` or `!useNostrArchivesAvailable()` → hide count and list unavailable message on list page.
---
## Phase 3 — DONE: General notes search
| File | Role |
|------|------|
| `src/lib/nostr-archives-search.ts` | `searchArchivesNotesForGeneralSearch``/v1/notes/search` |
| `src/components/SearchResult/FullTextSearchByRelay.tsx` | Parallel local + Archives rows; merged hits; source badges |
**Offline:** Archives progress row hidden when `!useNostrArchivesAvailable()`; local search unchanged.
**Deferred:** `searchGeneral` `resolved` entity navigation (profile/note picker) — not wired in notes full-text UI yet.
---
## Phase 4 — DONE: Interaction prefetch
| File | Role |
|------|------|
| `src/lib/note-stats-archives-prefetch.ts` | Batched queue → `getEventInteractions` |
| `src/services/note-stats.service.ts` | `archivesInteractions` on `TNoteStats`, `applyArchivesInteractionCounts`, display helpers |
| `src/components/NoteStats/index.tsx` | `prefetchArchivesInteractions` when near viewport |
| Stat buttons | `displayListCountWithArchives` / `displayZapSatsWithArchives`, `noteStatsHasResolvableCounts` |
**Offline:** Prefetch no-op; relay stats unchanged.
---
## Phase 5 — Profiles medium
### 5a — DONE
- `src/lib/archives-profile-metadata.ts``archivesMetadataToProfile`
- `client.searchProfilesStaged``searchSuggest` after local, before profile relays
### 5b — DONE
| File | Role |
|------|------|
| `src/lib/profile-metadata-batch.ts` | `fetchProfilesMetadataBatch` — Archives POST metadata, relay gap fill |
| `FullTextSearchByRelay.tsx` | Search merged profile provider |
| `ThreadProfileBatchProvider.tsx` | Thread/note panel batch |
| `ProfileList/index.tsx` | Followers list + other pubkey lists |
| `ProfileListBySearch/index.tsx` | Profile search results prefetch |
---
## Phase 6 — DONE: Note page pipeline
| File | Role |
|------|------|
| `src/lib/note-page-load-pipeline.ts` | `resolveNoteEventFromArchives`, `fetchArchivesNotePageBundle`, `prewarmArchivesNotePage` |
| `src/lib/thread-context-local.ts` | Archives REST after IDB archive, before publication store |
| `src/hooks/useFetchEvent.tsx` | Local stores + Archives before relay `fetchEvent` |
| `src/pages/secondary/NotePage/index.tsx` | Background `prewarmArchivesNotePage` (replies + interaction counts) |
**Deferred:** optional IDB bundle cache service; NoteCard hover prewarm (no hover handler in card today).
---
## Suggested PR order (unchanged)
1. ~~PR0+2: Foundation + search relay~~ (done)
2. **PR1: Phase 1 followers** ← resume here after review fixes
3. PR3: Notes search merge
4. PR4: Interactions prefetch
5. PR5: Suggest
6. PR6: Metadata batch
7. PR7: Note page pipeline
---
## Agent review / Fix button — scratch pad
_Use this section to note review findings so the next session does not lose context._
### Open issues (fill in when Fix runs)
_None — minor gaps addressed 2026-06-03 (settings toggle, search resolved, note bundle profiles, feed metadata batch)._
### Fixes applied
- **`ARCHIVES_ENGAGEMENT_KEYS` included `'event'`** — stripped nested `{ event: … }` wrappers before `archivesJsonToVerifiedEvent` could unwrap them. Removed `'event'` from the set; test in `src/lib/nostr-archives-event.test.ts`.
- **`isPersistableNostrEventShape` allowed `NaN` kind** — `typeof NaN === 'number'` passed; now `Number.isFinite(ev.kind)` (and early return in `archivesJsonToVerifiedEvent` after coercion).
- **Duplicate `getEventById` in `useFetchEvent`** — removed second `resolveNoteEventFromArchives` call; `resolveThreadContextEventFromLocalStores` already hits Archives REST.
- **Settings UI** — General settings toggle for `useNostrArchivesApi`; `nostrArchivesApi.notifySettingsChanged()` on change.
- **`searchGeneral` resolved** — `tryResolveSearchViaArchives` in SearchPage submit path; `src/lib/nostr-archives-search-resolved.ts`.
- **Note page bundle profiles**`ThreadProfileBatchProvider.seedProfiles` + `prewarmArchivesNotePage` callback on NotePage.
- **Feed profile batch**`NoteList` uses `fetchProfilesMetadataBatch`.
---
## Resume prompt (paste into a new chat)
```
Continue Nostr Archives rollout from `.cursor/plans/nostr-archives-rollout.md`.
Nostr Archives rollout phases 0–6 are complete. Use this file for review fixes or follow-ups (e.g. `searchGeneral` resolved navigation, optional note bundle IDB cache).
Respect `.cursor/rules/nostr-archives-integration.mdc`.
Check "Agent review / Fix button" section for any open issues first.
```

21
.cursor/rules/nostr-archives-integration.mdc

@ -1,21 +0,0 @@
# Nostr Archives integration
When calling `nostrArchivesApi` or adding Archives-only UI:
## Persist events
- Any **verified** Nostr event from Archives must go through `persistArchivesEventsIfNew` / `persistArchivesPayloadEvents` (or API methods that call `getAndPersist`).
- That writes to **session cache** (`client.addEventToCache`) and **IndexedDB archive** (`queuePersistSeenEvent` via ingest).
- Skip duplicates: already in session or archive row.
- Unverified / slim API rows (no valid `sig`) are not persisted; use relay fetch as fallback.
## Graceful failure
- API methods return `TArchivesApiResult` — **never throw** to UI.
- When `ok: false` or `!nostrArchivesApi.isAvailable()`: hide Archives-only widgets or use existing relay/local paths.
- Circuit breaker opens after 2 failures for 60s; respect `storage.getUseNostrArchivesApi()`.
- Do not block core flows (post, reply, feed) on Archives.
## Rate limit
- Use `nostrArchivesApi` service only (100 req/min client budget). Batch metadata and interaction prefetch.

2
.env.development

@ -4,5 +4,3 @@ VITE_PROXY_SERVER=/sites
VITE_READ_ALOUD_TTS_URL=/api/piper-tts VITE_READ_ALOUD_TTS_URL=/api/piper-tts
VITE_LANGUAGE_TOOL_URL=/api/languagetool VITE_LANGUAGE_TOOL_URL=/api/languagetool
VITE_TRANSLATE_URL=/api/translate VITE_TRANSLATE_URL=/api/translate
# Wikistr AsciiDoctor sidecar (EPUB/PDF); same API as unfold — proxy to localhost:8091 in dev.
VITE_ASCIIDOCTOR_SERVER_URL=/api/asciidoctor

3
Dockerfile

@ -13,9 +13,6 @@ ENV VITE_LANGUAGE_TOOL_URL=${VITE_LANGUAGE_TOOL_URL}
ARG VITE_TRANSLATE_URL ARG VITE_TRANSLATE_URL
ENV VITE_TRANSLATE_URL=${VITE_TRANSLATE_URL} ENV VITE_TRANSLATE_URL=${VITE_TRANSLATE_URL}
ARG VITE_ASCIIDOCTOR_SERVER_URL
ENV VITE_ASCIIDOCTOR_SERVER_URL=${VITE_ASCIIDOCTOR_SERVER_URL}
ARG APP_VERSION=unknown ARG APP_VERSION=unknown
ARG GIT_COMMIT=unknown ARG GIT_COMMIT=unknown
ARG BUILD_TIME ARG BUILD_TIME

3
PROXY_SETUP.md

@ -35,7 +35,6 @@ These are enabled by build-time URLs:
VITE_READ_ALOUD_TTS_URL=/api/piper-tts VITE_READ_ALOUD_TTS_URL=/api/piper-tts
VITE_LANGUAGE_TOOL_URL=/api/languagetool VITE_LANGUAGE_TOOL_URL=/api/languagetool
VITE_TRANSLATE_URL=/api/translate VITE_TRANSLATE_URL=/api/translate
VITE_ASCIIDOCTOR_SERVER_URL=/api/asciidoctor
``` ```
Proxy targets: Proxy targets:
@ -47,8 +46,6 @@ ProxyPass /api/languagetool http://127.0.0.1:8010
ProxyPassReverse /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
ProxyPass /api/asciidoctor/ http://127.0.0.1:8091/
ProxyPassReverse /api/asciidoctor/ http://127.0.0.1:8091/
``` ```
For the full production workflow, use `scripts/README-deploy.md` and `docker-compose.prod.yml`. For the full production workflow, use `scripts/README-deploy.md` and `docker-compose.prod.yml`.

2
docker-compose.prod.yml

@ -17,7 +17,7 @@
# - Set NIP66_MONITOR_NPUB (npub1... derived from the same key) so the relay info page shows the monitor's avatar and handle in the NIP-66 liveliness section. # - Set NIP66_MONITOR_NPUB (npub1... derived from the same key) so the relay info page shows the monitor's avatar and handle in the NIP-66 liveliness section.
# #
# Apache (or nginx) must proxy same-origin paths baked into the SPA, e.g. /api/languagetool → http://127.0.0.1:8010 # Apache (or nginx) must proxy same-origin paths baked into the SPA, e.g. /api/languagetool → http://127.0.0.1:8010
# and /api/translate → http://127.0.0.1:5000 and /api/asciidoctor → http://127.0.0.1:8091. Build the app with: # and /api/translate → http://127.0.0.1:5000. Build the app with:
# LANGUAGE_TOOL_URL=/api/languagetool TRANSLATE_URL=/api/translate ./scripts/build-and-push-prod.sh # LANGUAGE_TOOL_URL=/api/languagetool TRANSLATE_URL=/api/translate ./scripts/build-and-push-prod.sh
services: services:

182
docs/performance-fixes.md

@ -1,182 +0,0 @@
# Performance fixes (audit follow-up)
This document records seven fixes identified during a sluggishness audit. They are ordered by impact and should be applied in sequence.
## Executive summary
Sluggishness came mainly from **main-thread work that scales with feed size** and **React context churn** that re-renders large subtrees. Orphaned files added maintenance noise but did not affect runtime.
The largest win is in `NoteList`: expensive per-event work ran inside a filter that scans up to **2,500 events** on every dependency change.
---
## Fix 1 — Hoist `pinnedEventHexIdSet` out of `shouldHideEvent`
**File:** `src/components/NoteList/index.tsx`
**Problem:** `shouldHideEvent` rebuilt `pinnedEventHexIdSet` (with `decode()` per pin) **inside** the per-event callback. That callback is used in a `useMemo` that scans up to `MAX_TIMELINE_EVENTS_SCAN_FOR_VISIBLE` (2500) rows, so pin decoding ran up to 2,500× per filter pass.
**Fix:** Compute `pinnedEventHexIdSet` once in a `useMemo` keyed on `pinnedEventIds`. `shouldHideEvent` reads the memoized set.
**Expected impact:** Largest feed-filter win; small diff.
---
## Fix 2 — Cap profile-prefetch walks to visible + lookahead
**File:** `src/components/NoteList/index.tsx`
**Problem:** A debounced `useEffect` walked **all** of `timelineEventsForFilter` and `newEvents` on every buffer change, then enqueued profile/emoji prefetch for every author found.
**Fix:** Walk only `filteredEvents.slice(0, showCount + prefetchMargin)` (capped, e.g. 120 rows) plus a small slice of `newEvents` (e.g. 32). Keep the existing note-stats slice for visible rows.
**Expected impact:** Fewer redundant kind-0 fetches and less main-thread work when the timeline buffer grows.
---
## Fix 3 — Memoize `MuteListProvider` and `ContentPolicyProvider` context values
**Files:**
- `src/providers/MuteListProvider.tsx`
- `src/providers/ContentPolicyProvider.tsx`
**Problem:** Both providers passed a new `value` object every render. Handler functions were recreated each render. Consumers include `NoteList`, `Note`, `Image`, `VideoPlayer`, etc., so parent re-renders cascaded through the feed.
**Fix:**
- Wrap mute handlers in `useCallback`.
- Wrap policy updaters in `useCallback`.
- Wrap provider `value` in `useMemo` with stable dependencies.
**Expected impact:** Fewer wide subtree re-renders when unrelated parent state changes.
---
## Fix 4 — Cap `iterateProfileEvents` IndexedDB scan
**File:** `src/services/indexed-db.service.ts`
**Problem:** `iterateProfileEvents` loaded **all** kind-0 rows from IndexedDB into a single `events[]` array before chunked callback processing. Large offline caches caused a big allocation on first @-mention search or session prewarm.
**Fix:** Cap rows collected during the cursor pass (`MAX_PROFILE_EVENTS_ITERATE`, e.g. 8,000). Log when truncated. Keep chunked `requestAnimationFrame` yields during callback processing.
**Expected impact:** Bounded memory and faster startup on accounts with very large profile caches.
---
## Fix 5 — Tune `ReplaceableEventService` batching during scroll
**Files:**
- `src/lib/scroll-activity.service.ts` (new)
- `src/components/NoteList/index.tsx`
- `src/services/client-replaceable-events.service.ts`
**Problem:** `maxBatchSize: 200` with 100ms batching queued many kind-0 fetches during fast scrolling, competing with timeline REQs (console: `Large batch detected, limiting processing`).
**Fix:**
- Add a lightweight scroll-activity signal (`markScrolling()` from feed scroll handlers).
- Lower `maxBatchSize` (64) and lengthen `batchScheduleFn` delay while scrolling (200ms vs 100ms idle).
**Expected impact:** Profile fetches defer during scroll; timeline paint stays responsive.
---
## Fix 6 — Delete unused files and junk
**Problem:** Knip reported 16 unused files plus `src/services/Untitled` (accidental single-character junk). No runtime cost, but bundle/clarity noise.
**Files removed:**
| Cluster | Files |
|---------|--------|
| Connected relays UI | `ActiveRelaysDropdownSection.tsx`, `ActiveRelaysIconGrid.tsx`, `active-relays-display.ts` |
| Old Explore tabs | `Explore/index.tsx`, `ExploreFavoriteRelays.tsx`, `ExplorePopularRelays.tsx`, `ExploreRelayReviews.tsx` |
| Other UI | `FollowingFavoriteRelayList`, `SeenOnButton`, `NotificationThreadWatchButtons`, `ProfileTimeline`, `Tabs/index.tsx`, `ProfileSearchBar`, `QuickZapSwitch` |
| Hooks | `useBtcUsdRate.ts`, `useRelayConnectionRows.ts` |
| Junk | `src/services/Untitled` |
**Note:** `useProfileTimeline` hook and `ExploreRelayDirectory` remain in use; only dead wrappers were removed.
---
## Fix 7 — YouTube iframe API poll timeout
**File:** `src/lib/youtube-iframe-api.ts`
**Problem:** When the YouTube script tag was already present but `YT.Player` never became available, `requestAnimationFrame` polled forever.
**Fix:** Cap polling (e.g. 5s / ~300 frames). Resolve the promise anyway so embeds fail gracefully instead of leaking rAF work.
---
## Verification
After applying all fixes:
```bash
npm run test -- --run src/lib/profile-author-warmup-spec.test.ts src/components/PostEditor/PostTextarea/Mention/mention-range.test.ts
npx tsc --noEmit
```
Manual checks:
1. Home feed scroll — no jank spike when many authors are visible.
2. @-mention search — still finds cached profiles after IDB cap.
3. YouTube embeds — still load when API is healthy; no infinite rAF when blocked.
---
## Phase 2 — Archives API was additive, not a replacement (2026-06-07)
**Why it still felt sluggish:** Nostr Archives REST was wired in parallel with relays for profiles/search, but **feed note stats still fired full relay REQ waves** for every visible card even when Archives already had interaction counts. Archives also consumed the shared **100 req/min** budget via sequential interaction prefetch, while relay work continued unchanged.
### Fix A — Feed cards: Archives-only stats
**File:** `src/components/NoteStats/index.tsx`
Background feed cards (`!foregroundStats`) now call `prefetchArchivesInteractions` only. Relay `fetchNoteStats` runs on foreground surfaces (open note, thread, interactions panel).
### Fix B — Parallel Archives interaction prefetch
**File:** `src/lib/note-stats-archives-prefetch.ts`
Prefetch runs with concurrency 5 instead of one note at a time.
### Fix C — Archives-first profile batch
**File:** `src/lib/profile-metadata-batch.ts`
Await Archives metadata (400ms cap), then relay-fetch **only pubkeys Archives did not return** — avoids duplicate kind-0 storms.
### Fix D — Remove 120× `subscribeNoteStats` in NoteList
**File:** `src/components/NoteList/index.tsx`
Dropped per-visible-note stats subscriptions that re-triggered profile batches on every Archives count update.
### Fix E — Stabilize superchat filter
**File:** `src/components/NoteList/index.tsx`
`feedAttestedSuperchatIds` read from a ref inside `shouldHideEvent` so attestation updates do not rescan 2500 rows.
### Fix F — Cap Archives blocking on thread resolve
**File:** `src/lib/thread-context-local.ts`
Archives event lookup races with a 700ms cap before falling through to local stores / relays.
---
## Related audit items (not in this pass)
| Area | Notes |
|------|--------|
| `DeepBrowsingProvider` + `Tabs` | Scroll updates context → tab chrome re-renders |
| `NostrProvider` | Account hydration causes broad tree updates |
| `useFeedAttestedSuperchatIds` | New attestations invalidate `shouldHideEvent` → full feed re-scan |
| `NoteList` stats subscriptions | Up to ~120 `subscribeNoteStats` listeners |
| `RssFeedList` | 20s IndexedDB poll while mounted |
| `client.service` | 45s HTTP timeline polling on index relays |
| Knip unused exports | Mostly shadcn re-exports; low priority |
The `while (true)` worker pool in `client.service.ts` (~line 390) is intentional and bounded.

11
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.21.3", "version": "23.17.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.21.3", "version": "23.17.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",
@ -66,7 +66,6 @@
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"embla-carousel-wheel-gestures": "^8.1.0", "embla-carousel-wheel-gestures": "^8.1.0",
"emoji-picker-element": "^1.29.1", "emoji-picker-element": "^1.29.1",
"emoji-picker-element-data": "^1.8.0",
"flexsearch": "^0.7.43", "flexsearch": "^0.7.43",
"highlight.js": "^11.9.0", "highlight.js": "^11.9.0",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
@ -9623,12 +9622,6 @@
"integrity": "sha512-TOiHzu9Dqib3x4MwcAi3wi3RdyT4SoeB4b15AvH1ks4SBwTl7DeebhZ0d3x6dNi4XfNU7IGRZ7NBQllj0RqwrQ==", "integrity": "sha512-TOiHzu9Dqib3x4MwcAi3wi3RdyT4SoeB4b15AvH1ks4SBwTl7DeebhZ0d3x6dNi4XfNU7IGRZ7NBQllj0RqwrQ==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/emoji-picker-element-data": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/emoji-picker-element-data/-/emoji-picker-element-data-1.8.0.tgz",
"integrity": "sha512-VfRuRJNEDLS1JKlNS4olaqhjX5S1nnZ+ZHG73b/dV8QeZyi0yPruTPEE72EmF6XO3k/9hj3lybMIYMOYXb/57A==",
"license": "Apache-2.0"
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",

3
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.21.4", "version": "23.17.3",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",
@ -96,7 +96,6 @@
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"embla-carousel-wheel-gestures": "^8.1.0", "embla-carousel-wheel-gestures": "^8.1.0",
"emoji-picker-element": "^1.29.1", "emoji-picker-element": "^1.29.1",
"emoji-picker-element-data": "^1.8.0",
"flexsearch": "^0.7.43", "flexsearch": "^0.7.43",
"highlight.js": "^11.9.0", "highlight.js": "^11.9.0",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",

4
scripts/README-deploy.md

@ -59,9 +59,9 @@ docker compose -f docker-compose.prod.yml up -d
That starts the **full** stack in `docker-compose.prod.yml` (app, NIP-66 monitor, OG proxy, Piper, LanguageTool, LibreTranslate). The SPA is on **port 8089**; see `docker-compose.prod.yml` header for Apache paths. That starts the **full** stack in `docker-compose.prod.yml` (app, NIP-66 monitor, OG proxy, Piper, LanguageTool, LibreTranslate). The SPA is on **port 8089**; see `docker-compose.prod.yml` header for Apache paths.
**Grammar + translate + export:** `./scripts/build-and-push-prod.sh` now bakes in **`/api/languagetool`**, **`/api/translate`**, and **`/api/asciidoctor`** by default (same as `.env.development`). Override with `LANGUAGE_TOOL_URL` / `TRANSLATE_URL` / `ASCIIDOCTOR_SERVER_URL` if your paths differ; set any to empty to omit that feature from the bundle. **Grammar + translate:** `./scripts/build-and-push-prod.sh` now bakes in **`/api/languagetool`** and **`/api/translate`** by default (same as `.env.development`). Override with `LANGUAGE_TOOL_URL` / `TRANSLATE_URL` if your paths differ; set either to empty to omit that feature from the bundle.
Apache (or nginx) must still proxy `/api/languagetool``127.0.0.1:8010`, `/api/translate``127.0.0.1:5000`, and `/api/asciidoctor``127.0.0.1:8091` (Wikistr sidecar; not part of `docker-compose.prod.yml`). 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.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).

5
scripts/build-and-push-prod.sh

@ -14,7 +14,6 @@
# Same-origin: Apache proxies /api/piper-tts → aitherboard (e.g. :9876). Override only if you use CORS on another host. # Same-origin: Apache proxies /api/piper-tts → aitherboard (e.g. :9876). Override only if you use CORS on another host.
# LANGUAGE_TOOL_URL — build-arg VITE_LANGUAGE_TOOL_URL (default /api/languagetool if unset). Same-origin Apache → LanguageTool :8010. # LANGUAGE_TOOL_URL — build-arg VITE_LANGUAGE_TOOL_URL (default /api/languagetool if unset). Same-origin Apache → LanguageTool :8010.
# TRANSLATE_URL — build-arg VITE_TRANSLATE_URL (default /api/translate if unset). Same-origin Apache → LibreTranslate :5000. # TRANSLATE_URL — build-arg VITE_TRANSLATE_URL (default /api/translate if unset). Same-origin Apache → LibreTranslate :5000.
# ASCIIDOCTOR_SERVER_URL — build-arg VITE_ASCIIDOCTOR_SERVER_URL (default /api/asciidoctor if unset). Same-origin Apache → Wikistr sidecar :8091.
# Set either to an empty string to omit that feature from the bundle: LANGUAGE_TOOL_URL= TRANSLATE_URL= ./scripts/build-and-push-prod.sh # Set either to an empty string to omit that feature from the bundle: LANGUAGE_TOOL_URL= TRANSLATE_URL= ./scripts/build-and-push-prod.sh
set -e set -e
@ -35,18 +34,16 @@ READ_ALOUD_TTS_URL="${READ_ALOUD_TTS_URL:-/api/piper-tts}"
# Match .env.development and PROXY_SETUP.md (`:-` would force defaults even when empty; use `-` so LANGUAGE_TOOL_URL= disables). # Match .env.development and PROXY_SETUP.md (`:-` would force defaults even when empty; use `-` so LANGUAGE_TOOL_URL= disables).
LANGUAGE_TOOL_URL="${LANGUAGE_TOOL_URL-/api/languagetool}" LANGUAGE_TOOL_URL="${LANGUAGE_TOOL_URL-/api/languagetool}"
TRANSLATE_URL="${TRANSLATE_URL-/api/translate}" TRANSLATE_URL="${TRANSLATE_URL-/api/translate}"
ASCIIDOCTOR_SERVER_URL="${ASCIIDOCTOR_SERVER_URL-/api/asciidoctor}"
GIT_COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" GIT_COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "Building main app (version: $VERSION, commit: $GIT_COMMIT, VITE_PROXY_SERVER=$PROXY_ORIGIN, VITE_READ_ALOUD_TTS_URL=$READ_ALOUD_TTS_URL, VITE_LANGUAGE_TOOL_URL=$LANGUAGE_TOOL_URL, VITE_TRANSLATE_URL=$TRANSLATE_URL, VITE_ASCIIDOCTOR_SERVER_URL=$ASCIIDOCTOR_SERVER_URL)" echo "Building main app (version: $VERSION, commit: $GIT_COMMIT, VITE_PROXY_SERVER=$PROXY_ORIGIN, VITE_READ_ALOUD_TTS_URL=$READ_ALOUD_TTS_URL, VITE_LANGUAGE_TOOL_URL=$LANGUAGE_TOOL_URL, VITE_TRANSLATE_URL=$TRANSLATE_URL)"
docker build \ docker build \
--build-arg "VITE_PROXY_SERVER=$PROXY_ORIGIN" \ --build-arg "VITE_PROXY_SERVER=$PROXY_ORIGIN" \
--build-arg "VITE_READ_ALOUD_TTS_URL=$READ_ALOUD_TTS_URL" \ --build-arg "VITE_READ_ALOUD_TTS_URL=$READ_ALOUD_TTS_URL" \
--build-arg "VITE_LANGUAGE_TOOL_URL=$LANGUAGE_TOOL_URL" \ --build-arg "VITE_LANGUAGE_TOOL_URL=$LANGUAGE_TOOL_URL" \
--build-arg "VITE_TRANSLATE_URL=$TRANSLATE_URL" \ --build-arg "VITE_TRANSLATE_URL=$TRANSLATE_URL" \
--build-arg "VITE_ASCIIDOCTOR_SERVER_URL=$ASCIIDOCTOR_SERVER_URL" \
--build-arg "APP_VERSION=$VERSION" \ --build-arg "APP_VERSION=$VERSION" \
--build-arg "GIT_COMMIT=$GIT_COMMIT" \ --build-arg "GIT_COMMIT=$GIT_COMMIT" \
--build-arg "BUILD_TIME=$BUILD_TIME" \ --build-arg "BUILD_TIME=$BUILD_TIME" \

270
src/PageManager.tsx

@ -96,7 +96,6 @@ const MePageLazy = lazy(() => import('./pages/primary/MePage'))
const ProfilePageLazy = lazy(() => import('./pages/primary/ProfilePage')) const ProfilePageLazy = lazy(() => import('./pages/primary/ProfilePage'))
const RelayPageLazy = lazy(() => import('./pages/primary/RelayPage')) const RelayPageLazy = lazy(() => import('./pages/primary/RelayPage'))
const SearchPageLazy = lazy(() => import('./pages/primary/SearchPage')) const SearchPageLazy = lazy(() => import('./pages/primary/SearchPage'))
const LibraryPageLazy = lazy(() => import('./pages/primary/LibraryPage'))
const RssPageLazy = lazy(() => import('./pages/primary/RssPage')) const RssPageLazy = lazy(() => import('./pages/primary/RssPage'))
const SettingsPrimaryPageLazy = lazy(() => import('./pages/primary/SettingsPrimaryPage')) const SettingsPrimaryPageLazy = lazy(() => import('./pages/primary/SettingsPrimaryPage'))
const CalendarPrimaryPageLazy = lazy(() => import('./pages/primary/CalendarPrimaryPage')) const CalendarPrimaryPageLazy = lazy(() => import('./pages/primary/CalendarPrimaryPage'))
@ -110,7 +109,6 @@ const PostSignupBackupRedirectLazy = lazy(() => import('@/components/PostSignupB
/** Mobile primary-note overlay: lazy so these pages are not in the main bundle (routes use the same modules → shared async chunks). */ /** Mobile primary-note overlay: lazy so these pages are not in the main bundle (routes use the same modules → shared async chunks). */
const SecondaryProfilePageLazy = lazy(() => import('@/pages/secondary/ProfilePage')) const SecondaryProfilePageLazy = lazy(() => import('@/pages/secondary/ProfilePage'))
const PrimaryFollowingListPageLazy = lazy(() => import('@/pages/secondary/FollowingListPage')) const PrimaryFollowingListPageLazy = lazy(() => import('@/pages/secondary/FollowingListPage'))
const PrimaryFollowersListPageLazy = lazy(() => import('@/pages/secondary/FollowersListPage'))
const PrimaryMuteListPageLazy = lazy(() => import('@/pages/secondary/MuteListPage')) const PrimaryMuteListPageLazy = lazy(() => import('@/pages/secondary/MuteListPage'))
const PrimaryBookmarkListPageLazy = lazy(() => import('@/pages/secondary/BookmarkListPage')) const PrimaryBookmarkListPageLazy = lazy(() => import('@/pages/secondary/BookmarkListPage'))
const PrimaryNotificationThreadFollowListPageLazy = lazy(() => const PrimaryNotificationThreadFollowListPageLazy = lazy(() =>
@ -147,7 +145,6 @@ const PRIMARY_PAGE_REF_MAP = {
profile: createRef<TPageRef>(), profile: createRef<TPageRef>(),
relay: createRef<TPageRef>(), relay: createRef<TPageRef>(),
search: createRef<TPageRef>(), search: createRef<TPageRef>(),
library: createRef<TPageRef>(),
rss: createRef<TPageRef>(), rss: createRef<TPageRef>(),
settings: createRef<TPageRef>(), settings: createRef<TPageRef>(),
spells: createRef<TPageRef>(), spells: createRef<TPageRef>(),
@ -187,11 +184,6 @@ const getPrimaryPageMap = () => ({
<SearchPageLazy ref={PRIMARY_PAGE_REF_MAP.search} /> <SearchPageLazy ref={PRIMARY_PAGE_REF_MAP.search} />
</Suspense> </Suspense>
), ),
library: (
<Suspense fallback={primaryPageLazyFallback}>
<LibraryPageLazy ref={PRIMARY_PAGE_REF_MAP.library} />
</Suspense>
),
rss: ( rss: (
<Suspense fallback={primaryPageLazyFallback}> <Suspense fallback={primaryPageLazyFallback}>
<RssPageLazy ref={PRIMARY_PAGE_REF_MAP.rss} /> <RssPageLazy ref={PRIMARY_PAGE_REF_MAP.rss} />
@ -307,7 +299,6 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str
// Pages that should preserve context in the URL // Pages that should preserve context in the URL
const contextualPages: TPrimaryPageName[] = [ const contextualPages: TPrimaryPageName[] = [
'search', 'search',
'library',
'profile', 'profile',
'feed', 'feed',
'spells', 'spells',
@ -331,7 +322,6 @@ function buildRssArticleUrl(
const key = encodeRssArticlePathSegment(articleUrl) const key = encodeRssArticlePathSegment(articleUrl)
const contextualPages: TPrimaryPageName[] = [ const contextualPages: TPrimaryPageName[] = [
'search', 'search',
'library',
'profile', 'profile',
'feed', 'feed',
'spells', 'spells',
@ -454,7 +444,7 @@ function extractValidNoteId(raw: string): string | null {
function parseNoteUrl(url: string): { noteId: string; context?: string } | null { function parseNoteUrl(url: string): { noteId: string; context?: string } | null {
// Match patterns like /discussions/notes/{noteId} or /notes/{noteId} // Match patterns like /discussions/notes/{noteId} or /notes/{noteId}
const contextualMatch = url.match( const contextualMatch = url.match(
/\/(discussions|search|library|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/ /\/(discussions|search|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/
) )
if (contextualMatch) { if (contextualMatch) {
const noteId = extractValidNoteId(contextualMatch[2]) const noteId = extractValidNoteId(contextualMatch[2])
@ -473,9 +463,10 @@ function parseNoteUrl(url: string): { noteId: string; context?: string } | null
return null return null
} }
// Fixed: Note navigation uses full-screen stack on mobile, sheet (single-pane) or side panel (double-pane) on desktop // Fixed: Note navigation uses drawer on mobile/single-pane, secondary panel on double-pane desktop
export function useSmartNoteNavigation() { export function useSmartNoteNavigation() {
const { push: pushSecondaryPage } = useSecondaryPage() const { push: pushSecondaryPage } = useSecondaryPage()
const { openDrawer } = useNoteDrawer()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { current: currentPrimaryPage } = usePrimaryPage() const { current: currentPrimaryPage } = usePrimaryPage()
@ -488,8 +479,32 @@ export function useSmartNoteNavigation() {
} }
const { noteId } = parsed const { noteId } = parsed
primeNoteNavigationCache(noteId, event, relatedEvents) navigationEventStore.clear()
if (event) {
navigationEventStore.setEvent(event, noteId)
client.addEventToCache(event)
await prefetchThreadContextForNavigation(event).then((prefetched) => {
for (const ev of prefetched) {
client.addEventToCache(ev)
navigationEventStore.setEvent(ev)
}
})
}
// Pre-cache related events (parent, root) and nostr embeds so NotePage avoids skeletons.
if (relatedEvents?.length) {
for (const ev of relatedEvents) {
if (ev && ev !== event) {
client.addEventToCache(ev)
navigationEventStore.setEvent(ev)
}
}
}
if (event) {
client.prefetchEmbeddedEventsForParents(
[event, ...(relatedEvents ?? []).filter((ev) => ev && ev !== event)]
)
}
// Build contextual URL based on current page // Build contextual URL based on current page
const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage) const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage)
@ -500,8 +515,10 @@ export function useSmartNoteNavigation() {
// Desktop: check panel mode // Desktop: check panel mode
const currentPanelMode = storage.getPanelMode() const currentPanelMode = storage.getPanelMode()
if (currentPanelMode === 'single') { if (currentPanelMode === 'single') {
// Single-pane desktop: one sheet driven by the secondary stack (same as relays/settings). // Always push so the secondary stack matches the drawer; otherwise the first note is not on
// the stack and Back after opening a quote only closes the drawer instead of the parent note.
pushSecondaryPage(contextualUrl) pushSecondaryPage(contextualUrl)
openDrawer(noteId, event)
} else { } else {
// Double-pane: use secondary panel // Double-pane: use secondary panel
pushSecondaryPage(contextualUrl) pushSecondaryPage(contextualUrl)
@ -515,10 +532,11 @@ export function useSmartNoteNavigation() {
/** Safe variant for createRoot trees (e.g. AsciidocArticle embedded notes). Returns no-op navigation when outside providers. */ /** Safe variant for createRoot trees (e.g. AsciidocArticle embedded notes). Returns no-op navigation when outside providers. */
export function useSmartNoteNavigationOptional() { export function useSmartNoteNavigationOptional() {
const pushSecondaryPage = useSecondaryPageOptional() const pushSecondaryPage = useSecondaryPageOptional()
const noteDrawer = useNoteDrawerOptional()
const screenSize = useScreenSizeOptional() const screenSize = useScreenSizeOptional()
const primaryPage = usePrimaryPageOptional() const primaryPage = usePrimaryPageOptional()
if (!pushSecondaryPage || !screenSize || !primaryPage) { if (!pushSecondaryPage || !noteDrawer || !screenSize || !primaryPage) {
return { return {
navigateToNote: (url: string, _event?: Event, _relatedEvents?: Event[]) => { navigateToNote: (url: string, _event?: Event, _relatedEvents?: Event[]) => {
window.location.href = url window.location.href = url
@ -527,6 +545,7 @@ export function useSmartNoteNavigationOptional() {
} }
const { push } = pushSecondaryPage const { push } = pushSecondaryPage
const { openDrawer } = noteDrawer
const { isSmallScreen } = screenSize const { isSmallScreen } = screenSize
const { current: currentPrimaryPage } = primaryPage const { current: currentPrimaryPage } = primaryPage
@ -537,7 +556,25 @@ export function useSmartNoteNavigationOptional() {
return return
} }
const { noteId } = parsed const { noteId } = parsed
primeNoteNavigationCache(noteId, event, relatedEvents) navigationEventStore.clear()
if (event) {
navigationEventStore.setEvent(event, noteId)
client.addEventToCache(event)
await prefetchThreadContextForNavigation(event).then((prefetched) => {
for (const ev of prefetched) {
client.addEventToCache(ev)
navigationEventStore.setEvent(ev)
}
})
}
if (relatedEvents?.length) {
for (const ev of relatedEvents) {
if (ev && ev !== event) {
client.addEventToCache(ev)
navigationEventStore.setEvent(ev)
}
}
}
const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage) const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage)
if (isSmallScreen) { if (isSmallScreen) {
push(contextualUrl) push(contextualUrl)
@ -545,6 +582,7 @@ export function useSmartNoteNavigationOptional() {
const currentPanelMode = storage.getPanelMode() const currentPanelMode = storage.getPanelMode()
if (currentPanelMode === 'single') { if (currentPanelMode === 'single') {
push(contextualUrl) push(contextualUrl)
openDrawer(noteId, event)
} else { } else {
push(contextualUrl) push(contextualUrl)
} }
@ -754,27 +792,6 @@ export function useSmartFollowingListNavigation() {
return { navigateToFollowingList } return { navigateToFollowingList }
} }
export function useSmartFollowersListNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
const { push: pushSecondaryPage } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const navigateToFollowersList = (url: string) => {
if (isSmallScreen) {
const profileId = url.replace('/users/', '').replace('/followers', '')
window.history.pushState(null, '', url)
setPrimaryNoteView(
suspensePrimaryPage(<PrimaryFollowersListPageLazy id={profileId} index={0} hideTitlebar={true} />),
'followers'
)
} else {
pushSecondaryPage(url)
}
}
return { navigateToFollowersList }
}
// Fixed: Mute list navigation now uses primary note view on mobile, secondary routing on desktop // Fixed: Mute list navigation now uses primary note view on mobile, secondary routing on desktop
export function useSmartMuteListNavigation() { export function useSmartMuteListNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView() const { setPrimaryNoteView } = usePrimaryNoteView()
@ -1358,6 +1375,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (isSmallScreen || panelMode === 'single') { if (isSmallScreen || panelMode === 'single') {
// Seed stack so in-note navigation (e.g. quotes → back) can pop to this note // Seed stack so in-note navigation (e.g. quotes → back) can pop to this note
pushNoteUrlOnStack(buildNoteUrl(noteId, resolved.name)) pushNoteUrlOnStack(buildNoteUrl(noteId, resolved.name))
if (!isSmallScreen) {
openDrawer(noteId)
}
setTimeout(() => { setTimeout(() => {
setCurrentPrimaryPage(resolved.name) setCurrentPrimaryPage(resolved.name)
@ -1378,6 +1398,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (isSmallScreen || panelMode === 'single') { if (isSmallScreen || panelMode === 'single') {
pushNoteUrlOnStack(contextualUrl) pushNoteUrlOnStack(contextualUrl)
if (!isSmallScreen) {
openDrawer(noteId)
}
return return
} else { } else {
pushNoteUrlOnStack(contextualUrl) pushNoteUrlOnStack(contextualUrl)
@ -1479,12 +1502,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// For relay URLs and other non-note URLs, push to secondary stack // For relay URLs and other non-note URLs, push to secondary stack
// (will be rendered in drawer in single-pane mode, side panel in double-pane mode) // (will be rendered in drawer in single-pane mode, side panel in double-pane mode)
const pathOnlyForSecondary = pathname.split('?')[0].split('#')[0]
if (pathOnlyForSecondary.startsWith('/settings/') && pathOnlyForSecondary !== '/settings') {
setCurrentPrimaryPage('settings')
setPrimaryPages((prev) => mergePrimaryPageEntry(prev, { name: 'settings' }))
}
setSecondaryStack((prevStack) => { setSecondaryStack((prevStack) => {
if (isCurrentPage(prevStack, url)) return prevStack if (isCurrentPage(prevStack, url)) return prevStack
@ -1671,6 +1688,27 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
window.location.pathname + window.location.search + window.location.hash window.location.pathname + window.location.search + window.location.hash
if (locUrl !== '/' && locUrl !== '') { if (locUrl !== '/' && locUrl !== '') {
const synced = syncSecondaryStackWhenPopStateStateIsNull(pre, locUrl) const synced = syncSecondaryStackWhenPopStateStateIsNull(pre, locUrl)
if ((panelMode === 'single' && !isSmallScreen) && drawerOpen && drawerNoteId && synced.length > 0) {
const topItemUrl = synced[synced.length - 1]?.url
if (topItemUrl) {
const topNoteUrlMatch =
topItemUrl.match(
/\/(discussions|search|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/
) || topItemUrl.match(/\/notes\/(.+)$/)
if (topNoteUrlMatch) {
const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1]
.split('?')[0]
.split('#')[0]
if (topNoteId && topNoteId !== drawerNoteId) {
setTimeout(() => {
if (drawerOpen) {
openDrawer(topNoteId)
}
}, 0)
}
}
}
}
return synced return synced
} }
state = { index: -1, url: '/' } state = { index: -1, url: '/' }
@ -1765,6 +1803,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0]
if (noteId) { if (noteId) {
if (isSmallScreen || panelMode === 'single') { if (isSmallScreen || panelMode === 'single') {
if (!isSmallScreen) {
openDrawer(noteId)
}
const built = findAndCreateComponent(state.url, state.index) const built = findAndCreateComponent(state.url, state.index)
if (built.component) { if (built.component) {
return [ return [
@ -1807,6 +1848,29 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
closeDrawer() closeDrawer()
} }
// DO NOT update URL when closing panel - closing should NEVER affect the main page // DO NOT update URL when closing panel - closing should NEVER affect the main page
} else if (newStack.length > 0) {
// Stack still has items - update drawer to show the top item's note (for mobile/single-pane)
// Only update drawer if drawer is currently open (not in the process of closing)
if (panelMode === 'single' && !isSmallScreen && drawerOpen && drawerNoteId) {
// Extract noteId from top item's URL or from state.url
const topItemUrl = newStack[newStack.length - 1]?.url || state?.url
if (topItemUrl) {
const topNoteUrlMatch = topItemUrl.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/) ||
topItemUrl.match(/\/notes\/(.+)$/)
if (topNoteUrlMatch) {
const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1].split('?')[0].split('#')[0]
if (topNoteId && topNoteId !== drawerNoteId) {
// Use setTimeout to ensure drawer update happens after stack state is committed
setTimeout(() => {
// Double-check drawer is still open before updating
if (drawerOpen) {
openDrawer(topNoteId)
}
}, 0)
}
}
}
}
} }
// If newStack.length === 0, we're closing - don't reopen the drawer // If newStack.length === 0, we're closing - don't reopen the drawer
return newStack return newStack
@ -1942,7 +2006,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
if ( if (
primaryViewType === 'following' || primaryViewType === 'following' ||
primaryViewType === 'followers' ||
primaryViewType === 'others-relay-settings' primaryViewType === 'others-relay-settings'
) { ) {
const currentPath = window.location.pathname.split('?')[0].split('#')[0] const currentPath = window.location.pathname.split('?')[0].split('#')[0]
@ -1977,14 +2040,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
if (isCurrentPage(secondaryStackRef.current, url)) { if (isCurrentPage(secondaryStackRef.current, url)) {
const top = secondaryStackRef.current[secondaryStackRef.current.length - 1] const top = secondaryStackRef.current[secondaryStackRef.current.length - 1]
if (top && !top.component) {
const restored = ensureStackItemComponent(top)
if (restored.component) {
const next = [...secondaryStackRef.current.slice(0, -1), restored]
secondaryStackRef.current = next
setSecondaryStack(next)
}
}
if (isSmallScreen && top) { if (isSmallScreen && top) {
window.history.pushState({ index: top.index, url }, '', url) window.history.pushState({ index: top.index, url }, '', url)
} }
@ -1993,9 +2048,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
recentSecondaryPushRef.current = { url, at: now } recentSecondaryPushRef.current = { url, at: now }
// Mobile overlays the feed — keep stats/live updates on the visible timeline. noteStatsService.setBackgroundStatsPaused(true)
if (!isSmallScreen) { if (!isSmallScreen) {
noteStatsService.setBackgroundStatsPaused(true)
client.interruptBackgroundQueries() client.interruptBackgroundQueries()
} }
@ -2042,15 +2096,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (isCurrentPage(prevStack, url)) { if (isCurrentPage(prevStack, url)) {
const top = prevStack[prevStack.length - 1] const top = prevStack[prevStack.length - 1]
if (top && !top.component) {
const restored = ensureStackItemComponent(top)
if (restored.component) {
if (isSmallScreen) {
window.history.pushState({ index: restored.index, url }, '', url)
}
return [...prevStack.slice(0, -1), restored]
}
}
if (isSmallScreen && top) { if (isSmallScreen && top) {
window.history.pushState({ index: top.index, url }, '', url) window.history.pushState({ index: top.index, url }, '', url)
} }
@ -2170,6 +2215,16 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
return next return next
} }
const syncDrawerToSecondaryStackTop = (stack: TStackItem[]) => {
if (isSmallScreen || panelMode !== 'single') return
const top = stack[stack.length - 1]
if (!top) return
const noteId = noteHexIdFromSecondaryNoteUrl(top.url)
if (!noteId) return
openDrawer(noteId, navigationEventStore.peekEvent(noteId))
}
/** UI-first back: sync stack / drawer immediately, then align browser history. */
const popSecondaryPage = () => { const popSecondaryPage = () => {
const now = Date.now() const now = Date.now()
if (now - lastPopSecondaryPageAt < POP_SECONDARY_PAGE_DEBOUNCE_MS) return if (now - lastPopSecondaryPageAt < POP_SECONDARY_PAGE_DEBOUNCE_MS) return
@ -2182,10 +2237,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const stackLen = secondaryStackRef.current.length const stackLen = secondaryStackRef.current.length
// Mobile / single-pane: one code path — stack drives the overlay (sheet on desktop, full-screen on mobile) // Mobile / single-pane: one code path — drawer + stack share the same close behavior
if (isSmallScreen || panelMode === 'single') { if (isSmallScreen || panelMode === 'single') {
if (stackLen > 1) { if (stackLen > 1) {
popOneSecondaryStackFrame() const next = popOneSecondaryStackFrame()
syncDrawerToSecondaryStackTop(next)
ignorePopStateRef.current = true ignorePopStateRef.current = true
window.history.back() window.history.back()
} else { } else {
@ -2252,9 +2308,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const shouldBeOpen = const shouldBeOpen =
panelMode === 'single' && panelMode === 'single' &&
!isSmallScreen && !isSmallScreen &&
secondaryStack.length > 0 secondaryStack.length > 0 &&
!drawerOpen
setSinglePaneSheetOpen(shouldBeOpen) setSinglePaneSheetOpen(shouldBeOpen)
}, [panelMode, isSmallScreen, secondaryStack.length]) }, [panelMode, isSmallScreen, secondaryStack.length, drawerOpen])
const primaryObscured = const primaryObscured =
secondaryStack.length > 0 || drawerOpen || primaryNoteView != null secondaryStack.length > 0 || drawerOpen || primaryNoteView != null
@ -2265,12 +2322,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const mobileSecondaryOverlaysFeed = const mobileSecondaryOverlaysFeed =
isSmallScreen && secondaryStack.length > 0 && primaryNoteView == null isSmallScreen && secondaryStack.length > 0 && primaryNoteView == null
const primaryFeedStillVisible =
panelMode === 'double' || !primaryObscured || mobileSecondaryOverlaysFeed
useLayoutEffect(() => { useLayoutEffect(() => {
const pauseBackgroundStats = primaryObscured && !primaryFeedStillVisible noteStatsService.setBackgroundStatsPaused(primaryFrozen)
noteStatsService.setBackgroundStatsPaused(pauseBackgroundStats)
if (primaryFrozen) { if (primaryFrozen) {
extendProfileNetworkDeferral(PROFILE_SECONDARY_PANEL_DEFER_MS) extendProfileNetworkDeferral(PROFILE_SECONDARY_PANEL_DEFER_MS)
// Keep in-flight REQ on double-pane and mobile feed overlay; interrupt only when primary is unmounted. // Keep in-flight REQ on double-pane and mobile feed overlay; interrupt only when primary is unmounted.
@ -2279,7 +2332,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
client.interruptBackgroundQueries() client.interruptBackgroundQueries()
} }
} }
}, [primaryObscured, primaryFeedStillVisible, isSmallScreen, panelMode, primaryNoteView]) }, [primaryFrozen, isSmallScreen, panelMode, primaryNoteView])
const primaryPageContextValue = useMemo( const primaryPageContextValue = useMemo(
(): PrimaryPageContextValue => ({ (): PrimaryPageContextValue => ({
@ -2500,7 +2553,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
{/* Generic drawer for secondary stack in single-pane mode (for relay pages, etc.) */} {/* Generic drawer for secondary stack in single-pane mode (for relay pages, etc.) */}
{panelMode === 'single' && {panelMode === 'single' &&
!isSmallScreen && !isSmallScreen &&
secondaryStack.length > 0 && ( secondaryStack.length > 0 &&
!drawerOpen && (
<Sheet <Sheet
open={singlePaneSheetOpen} open={singlePaneSheetOpen}
registerWithModalManager={false} registerWithModalManager={false}
@ -2514,15 +2568,22 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
> >
<SheetContent <SheetContent
side="right" side="right"
className="flex h-full w-full flex-col overflow-hidden p-0 sm:max-w-[1042px]" className="w-full sm:max-w-[1042px] overflow-y-auto p-0"
hideClose hideClose
onPointerDownOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)} onPointerDownOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)}
onInteractOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)} onInteractOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)}
> >
<TopSecondaryStackPane <div className="h-full">
item={secondaryStack[secondaryStack.length - 1]!} {secondaryStack.map((item, index) => {
className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden" const isLast = index === secondaryStack.length - 1
/> if (!isLast) return null
return (
<div key={item.index}>
{item.component}
</div>
)
})}
</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
)} )}
@ -2570,57 +2631,12 @@ export function SecondaryPageLink({
) )
} }
/** Re-mount a stack frame when LRU eviction cleared `component` (otherwise the panel is blank). */
function ensureStackItemComponent(item: TStackItem): TStackItem {
if (item.component) return item
const { component, ref } = findAndCreateComponent(item.url, item.index)
if (!component) return item
return { ...item, component, ref }
}
function primeNoteNavigationCache(
noteId: string,
event?: Event,
relatedEvents?: Event[]
): void {
navigationEventStore.clear()
if (event) {
navigationEventStore.setEvent(event, noteId)
client.addEventToCache(event)
void prefetchThreadContextForNavigation(event).then((prefetched) => {
for (const ev of prefetched) {
client.addEventToCache(ev)
navigationEventStore.setEvent(ev)
}
})
}
if (relatedEvents?.length) {
for (const ev of relatedEvents) {
if (ev && ev !== event) {
client.addEventToCache(ev)
navigationEventStore.setEvent(ev)
}
}
}
if (event) {
void client.prefetchEmbeddedEventsForParents(
[event, ...(relatedEvents ?? []).filter((ev) => ev && ev !== event)]
)
}
}
function isCurrentPage(stack: TStackItem[], url: string) { function isCurrentPage(stack: TStackItem[], url: string) {
const currentPage = stack[stack.length - 1] const currentPage = stack[stack.length - 1]
if (!currentPage) return false if (!currentPage) return false
const match = logger.component('PageManager', 'isCurrentPage check', { currentUrl: currentPage.url, newUrl: url, match: currentPage.url === url })
currentPage.url === url || secondaryPanelUrlsMatch(currentPage.url, url) return currentPage.url === url
logger.component('PageManager', 'isCurrentPage check', {
currentUrl: currentPage.url,
newUrl: url,
match
})
return match
} }
/** Route elements are `<Suspense><LazyPage /></Suspense>` — props must be applied to the lazy leaf, not Suspense. */ /** Route elements are `<Suspense><LazyPage /></Suspense>` — props must be applied to the lazy leaf, not Suspense. */

64
src/components/AccountList/index.tsx

@ -1,15 +1,13 @@
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { isRedundantAccountPick, isSameAccount } from '@/lib/account' import { isSameAccount } from '@/lib/account'
import { formatPubkey, hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { formatPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { TAccountPointer, TSignerType } from '@/types' import { TAccountPointer, TSignerType } from '@/types'
import { Trash2 } from 'lucide-react' import { Trash2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { SimpleUserAvatar } from '../UserAvatar' import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username' import { SimpleUsername } from '../Username'
@ -25,14 +23,7 @@ export default function AccountList({
* dialogs fighting over focus trapping). */ * dialogs fighting over focus trapping). */
closeDialog?: () => void closeDialog?: () => void
}) { }) {
const { t } = useTranslation() const { accounts, account, switchAccount, removeAccount } = useNostr()
const {
accounts,
account,
switchAccount,
removeAccount,
retryNip07SignerForPreferredAccount
} = useNostr()
const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null) const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null)
return ( return (
@ -42,48 +33,19 @@ export default function AccountList({
key={`${act.pubkey}-${act.signerType}`} key={`${act.pubkey}-${act.signerType}`}
className={cn( className={cn(
'relative rounded-lg', 'relative rounded-lg',
account && act.pubkey === account?.pubkey ? 'border border-primary' : 'clickable'
hexPubkeysEqual(
normalizeHexPubkey(act.pubkey),
normalizeHexPubkey(account.pubkey)
) &&
(act.signerType === account.signerType ||
(account.signerType === 'npub' && act.signerType === 'nip-07'))
? 'border border-primary'
: 'clickable'
)} )}
onClick={() => { onClick={() => {
void (async () => { if (isSameAccount(act, account)) return
if (isRedundantAccountPick(act, account)) { setSwitchingAccount(act)
if (account?.signerType === 'npub' && act.signerType === 'nip-07') { if (act.signerType === 'ncryptsec') {
setSwitchingAccount(act) closeDialog?.()
await switchAccount(act) }
const ok = await retryNip07SignerForPreferredAccount() switchAccount(act)
if (ok) { .then(() => {
toast.success(t('accountSwitch.extensionConnected'))
afterSwitch()
} else {
toast.error(t('accountSwitch.extensionRetryFailed'))
}
setSwitchingAccount(null)
}
return
}
setSwitchingAccount(act)
if (act.signerType === 'ncryptsec') {
closeDialog?.()
}
try {
const switched = await switchAccount(act)
if (!switched) {
toast.error(t('notificationsSwitchAccountFailed'))
return
}
if (act.signerType !== 'ncryptsec') afterSwitch() if (act.signerType !== 'ncryptsec') afterSwitch()
} finally { })
setSwitchingAccount(null) .finally(() => setSwitchingAccount(null))
}
})()
}} }}
> >
<div className="flex justify-between items-center p-2"> <div className="flex justify-between items-center p-2">

31
src/components/AccountManager/index.tsx

@ -5,8 +5,7 @@ import { Separator } from '@/components/ui/separator'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { generateSecretKey } from 'nostr-tools' import { generateSecretKey } from 'nostr-tools'
import { nsecEncode } from 'nostr-tools/nip19' import { nsecEncode } from 'nostr-tools/nip19'
import { useState, useCallback } from 'react' import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import AccountList from '../AccountList' import AccountList from '../AccountList'
@ -42,26 +41,9 @@ function AccountManagerNav({
close?: () => void close?: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { nip07Login, nsecLogin, accounts, isNip07LoginInFlight, requestAccountNetworkHydrate } = const { nip07Login, nsecLogin, accounts } = useNostr()
useNostr()
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [signingUp, setSigningUp] = useState(false) const [signingUp, setSigningUp] = useState(false)
const [extensionLoginPending, setExtensionLoginPending] = useState(false)
const handleExtensionLogin = useCallback(async () => {
setExtensionLoginPending(true)
try {
const pubkey = await nip07Login()
if (pubkey) {
await requestAccountNetworkHydrate()
close?.()
}
} catch {
// nip07Login toasts and rethrows
} finally {
setExtensionLoginPending(false)
}
}, [nip07Login, close])
const handleSignUp = async () => { const handleSignUp = async () => {
setSigningUp(true) setSigningUp(true)
@ -85,14 +67,7 @@ function AccountManagerNav({
</div> </div>
<div className="space-y-2 mt-4"> <div className="space-y-2 mt-4">
{!!window.nostr && ( {!!window.nostr && (
<Button <Button onClick={() => nip07Login().then(() => close?.())} className="w-full">
onClick={() => void handleExtensionLogin()}
disabled={extensionLoginPending || isNip07LoginInFlight}
className="w-full"
>
{extensionLoginPending || isNip07LoginInFlight ? (
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden />
) : null}
{t('Login with Browser Extension')} {t('Login with Browser Extension')}
</Button> </Button>
)} )}

115
src/components/AccountQuickSwitchMenuItems.tsx

@ -1,115 +0,0 @@
import { AnonUserAvatar } from '@/components/AnonUserAvatar'
import { SimpleUserAvatar } from '@/components/UserAvatar'
import { SimpleUsername } from '@/components/Username'
import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator
} from '@/components/ui/dropdown-menu'
import {
accountPointerKey,
createAnonAccountPointer,
isAnonAccount,
isRedundantAccountPick,
isSameAccountPubkey,
listSwitchableAccounts
} from '@/lib/account'
import { formatPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import type { TAccountPointer } from '@/types'
import { Check } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
const anonAccount = createAnonAccountPointer()
export function AccountQuickSwitchMenuItems({ onAfterSwitch }: { onAfterSwitch?: () => void }) {
const { t } = useTranslation()
const {
accounts,
account,
isAnonSession,
switchAccount,
retryNip07SignerForPreferredAccount
} = useNostr()
const rows = listSwitchableAccounts(accounts)
if (rows.length === 0 && !isAnonSession) return null
const handleSwitch = async (act: TAccountPointer) => {
if (isAnonAccount(act)) {
if (isAnonSession) {
onAfterSwitch?.()
return
}
await switchAccount(act)
onAfterSwitch?.()
return
}
if (isRedundantAccountPick(act, account)) {
if (account?.signerType === 'npub' && act.signerType === 'nip-07') {
// switchAccount may return a pubkey even when it fell back to read-only npub — always try reconnect.
await switchAccount(act)
const ok = await retryNip07SignerForPreferredAccount()
if (ok) {
toast.success(t('accountSwitch.extensionConnected'))
onAfterSwitch?.()
} else {
toast.error(t('accountSwitch.extensionUnavailable'))
}
}
return
}
const switched = await switchAccount(act)
if (!switched) {
toast.error(t('notificationsSwitchAccountFailed'))
return
}
onAfterSwitch?.()
}
return (
<>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
{t('notificationsViewAsAccount')}
</DropdownMenuLabel>
<DropdownMenuItem className="gap-2" onClick={() => void handleSwitch(anonAccount)}>
<AnonUserAvatar size="small" className="size-8" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{t('accountSwitch.anon')}</span>
<span className="block truncate text-xs text-muted-foreground">
{t('accountSwitch.anonHintShort')}
</span>
</span>
<Check className={cn('size-4 shrink-0', isAnonSession ? 'opacity-100' : 'opacity-0')} aria-hidden />
</DropdownMenuItem>
{rows.map((act) => {
const active =
!isAnonSession &&
account != null &&
isSameAccountPubkey(act, account) &&
(account.signerType === act.signerType ||
(account.signerType === 'npub' && act.signerType === 'nip-07'))
return (
<DropdownMenuItem
key={accountPointerKey(act)}
className="gap-2"
onClick={() => void handleSwitch(act)}
>
<SimpleUserAvatar userId={act.pubkey} size="small" className="shrink-0" />
<span className="min-w-0 flex-1">
<SimpleUsername userId={act.pubkey} className="block truncate text-sm font-medium" />
<span className="block truncate text-xs text-muted-foreground">
{formatPubkey(act.pubkey)}
</span>
</span>
<Check className={cn('size-4 shrink-0', active ? 'opacity-100' : 'opacity-0')} aria-hidden />
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator />
</>
)
}

10
src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx

@ -30,7 +30,6 @@ import {
serializePublishPreviewLabJson, serializePublishPreviewLabJson,
type AdvancedEventLabSlice type AdvancedEventLabSlice
} from '@/lib/advanced-event-lab-slice' } from '@/lib/advanced-event-lab-slice'
import type { TContentWarningDraftOptions } from '@/lib/content-warning'
import { translateAdvancedLabMarkup } from '@/lib/advanced-lab-markup-protect' import { translateAdvancedLabMarkup } from '@/lib/advanced-lab-markup-protect'
import { import {
warmTranslateLanguagesOnce, warmTranslateLanguagesOnce,
@ -202,8 +201,6 @@ export type AdvancedEventLabDialogProps = {
previewEmojiTags?: string[][] previewEmojiTags?: string[][]
/** When true (default), JSON preview includes the Imwald `client` tag like publish. */ /** When true (default), JSON preview includes the Imwald `client` tag like publish. */
addClientTag?: boolean addClientTag?: boolean
/** Composer Advanced panel content-warning settings (merged into JSON preview). */
contentWarning?: TContentWarningDraftOptions
} }
function useDarkModeFlag(): boolean { function useDarkModeFlag(): boolean {
@ -237,8 +234,7 @@ export default function AdvancedEventLabDialog({
draftPersistenceKey = null, draftPersistenceKey = null,
previewAuthorPubkey = null, previewAuthorPubkey = null,
previewEmojiTags, previewEmojiTags,
addClientTag = true, addClientTag = true
contentWarning
}: AdvancedEventLabDialogProps) { }: AdvancedEventLabDialogProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
/** `useTranslation().t` can change identity every render; never list it as a layout-effect dep (editor remount loop). */ /** `useTranslation().t` can change identity every render; never list it as a layout-effect dep (editor remount loop). */
@ -320,10 +316,10 @@ export default function AdvancedEventLabDialog({
content, content,
tags: editableRowsToLabTags(labTagRows) tags: editableRowsToLabTags(labTagRows)
}, },
{ addClientTag, contentWarning } { addClientTag }
) )
) )
}, [kindEditable, initial, labTagRows, addClientTag, contentWarning]) }, [kindEditable, initial, labTagRows, addClientTag])
useEffect(() => { useEffect(() => {
refreshLabJsonPreviewRef.current = refreshLabJsonPreview refreshLabJsonPreviewRef.current = refreshLabJsonPreview

25
src/components/AnonUserAvatar.tsx

@ -1,25 +0,0 @@
import { cn } from '@/lib/utils'
import { UserRound } from 'lucide-react'
export function AnonUserAvatar({
size = 'small',
className
}: {
size?: 'small' | 'medium'
className?: string
}) {
const dim = size === 'small' ? 'size-8' : 'size-10'
const icon = size === 'small' ? 'size-4' : 'size-5'
return (
<div
className={cn(
'flex shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground ring-1 ring-border/60',
dim,
className
)}
aria-hidden
>
<UserRound className={icon} strokeWidth={2} />
</div>
)
}

27
src/components/BottomNavigationBar/WriteButton.tsx

@ -6,32 +6,29 @@ import { useEffect, useState } from 'react'
import BottomNavigationBarItem from './BottomNavigationBarItem' import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function WriteButton() { export default function WriteButton() {
const { checkLogin, canSignEvents } = useNostr() const { checkLogin } = useNostr()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
useEffect(() => { useEffect(() => {
if (!canSignEvents) return
const onRequest = () => { const onRequest = () => {
checkLogin(() => setOpen(true)) checkLogin(() => setOpen(true))
} }
postEditorService.addEventListener('requestOpenNewPost', onRequest) postEditorService.addEventListener('requestOpenNewPost', onRequest)
return () => postEditorService.removeEventListener('requestOpenNewPost', onRequest) return () => postEditorService.removeEventListener('requestOpenNewPost', onRequest)
}, [canSignEvents, checkLogin]) }, [checkLogin])
return ( return (
<> <>
{canSignEvents ? ( <BottomNavigationBarItem
<BottomNavigationBarItem onClick={(e) => {
onClick={(e) => { e.stopPropagation()
e.stopPropagation() checkLogin(() => {
checkLogin(() => { setOpen(true)
setOpen(true) })
}) }}
}} >
> <PencilLine />
<PencilLine /> </BottomNavigationBarItem>
</BottomNavigationBarItem>
) : null}
<PostEditor open={open} setOpen={setOpen} /> <PostEditor open={open} setOpen={setOpen} />
</> </>
) )

7
src/components/CacheBrowser/CacheBrowserDialog.tsx

@ -6,7 +6,6 @@ import { Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Copy,
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import indexedDb, { isLikelyCachedNostrEvent, StoreNames, type TCachedEventSearchHit } from '@/services/indexed-db.service' import indexedDb, { isLikelyCachedNostrEvent, StoreNames, type TCachedEventSearchHit } from '@/services/indexed-db.service'
import { clearAllLibraryIndexCaches } from '@/lib/library-publication-index'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
@ -178,11 +177,7 @@ export default function CacheBrowserDialog({
if (!selectedStore) return if (!selectedStore) return
if (!confirm(t('Are you sure you want to delete all items from this store?'))) return if (!confirm(t('Are you sure you want to delete all items from this store?'))) return
try { try {
if (selectedStore === StoreNames.LIBRARY_PUBLICATION_INDEX) { await indexedDb.clearStore(selectedStore)
await clearAllLibraryIndexCaches()
} else {
await indexedDb.clearStore(selectedStore)
}
setStoreItems([]) setStoreItems([])
void loadCacheInfo() void loadCacheInfo()
toast.success(t('All items deleted successfully')) toast.success(t('All items deleted successfully'))

34
src/components/ConnectedRelays/ActiveRelaysDropdownSection.tsx

@ -0,0 +1,34 @@
import {
DropdownMenuLabel,
DropdownMenuSeparator
} from '@/components/ui/dropdown-menu'
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows'
import { useTranslation } from 'react-i18next'
import { ActiveRelaysIconGrid } from './ActiveRelaysIconGrid'
/** Compact active-relay icons in the account (user badge) dropdown. */
export function ActiveRelaysDropdownSection() {
const { t } = useTranslation()
const { rows, connectedCount } = useRelayConnectionRows()
if (rows.length === 0) return null
const countSummary = `${connectedCount}/${rows.length}`
return (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="flex items-baseline justify-between gap-2 text-xs font-normal">
<span>{t('Active relays')}</span>
<span className="tabular-nums text-muted-foreground">{countSummary}</span>
</DropdownMenuLabel>
<div
className="px-2 pb-2"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<ActiveRelaysIconGrid />
</div>
</>
)
}

106
src/components/ConnectedRelays/ActiveRelaysIconGrid.tsx

@ -0,0 +1,106 @@
import { useSmartRelayNavigation } from '@/PageManager'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows'
import { toRelay } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
import {
ACTIVE_RELAYS_MAX_ICONS,
activeRelayRowMuted,
activeRelayRowTitle
} from './active-relays-display'
/**
* Compact relay status: icon buttons only (no hostname labels).
*/
export function ActiveRelaysIconGrid({ className }: { className?: string }) {
const { t } = useTranslation()
const { navigateToRelay } = useSmartRelayNavigation()
const { rows } = useRelayConnectionRows()
const shown = rows.slice(0, ACTIVE_RELAYS_MAX_ICONS)
const overflowRows = rows.slice(ACTIVE_RELAYS_MAX_ICONS)
const overflow = overflowRows.length
if (rows.length === 0) {
return (
<p className={cn('text-xs text-muted-foreground', className)} title={t('Active relays')}>
</p>
)
}
return (
<div className={cn('flex flex-wrap gap-1', className)} title={t('Active relays')}>
{shown.map(({ url, connected }) => (
<Button
key={url}
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 w-7 min-h-7 min-w-7 shrink-0 rounded-full p-0 hover:bg-muted/80',
activeRelayRowMuted(connected) && 'opacity-40 grayscale'
)}
title={activeRelayRowTitle(url, connected, t)}
aria-label={activeRelayRowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))}
>
<RelayIcon url={url} className="h-6 w-6" iconSize={12} />
</Button>
))}
{overflow > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 min-h-7 min-w-7 shrink-0 rounded-full bg-muted px-1.5 py-0 text-[0.65rem] font-medium tabular-nums text-muted-foreground hover:bg-muted/80 hover:text-foreground"
title={t('More relays', { count: overflow })}
aria-label={t('More relays', { count: overflow })}
>
+{overflow}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="right"
className="w-auto max-w-[min(18rem,calc(100vw-1.5rem))] p-2"
>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground py-1">
{t('More relays', { count: overflow })}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="flex flex-wrap gap-1 max-w-[16rem]">
{overflowRows.map(({ url, connected }) => (
<Button
key={url}
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 w-7 min-h-7 min-w-7 shrink-0 rounded-full p-0 hover:bg-muted/80',
activeRelayRowMuted(connected) && 'opacity-40 grayscale'
)}
title={activeRelayRowTitle(url, connected, t)}
aria-label={activeRelayRowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))}
>
<RelayIcon url={url} className="h-6 w-6" iconSize={12} />
</Button>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
)
}

13
src/components/ConnectedRelays/active-relays-display.ts

@ -0,0 +1,13 @@
import { simplifyUrl } from '@/lib/url'
export const ACTIVE_RELAYS_MAX_ICONS = 14
export function activeRelayRowMuted(connected: boolean) {
return !connected
}
export function activeRelayRowTitle(url: string, connected: boolean, t: (k: string) => string) {
const base = simplifyUrl(url)
if (!connected) return `${base}${t('Not connected')}`
return base
}

22
src/components/Content/index.tsx

@ -92,7 +92,6 @@ export default function Content({
mustLoadMedia?: boolean mustLoadMedia?: boolean
}) { }) {
const _content = event?.content ?? content const _content = event?.content ?? content
const authorPubkey = event?.pubkey
const iArticleUrl = useMemo(() => (event ? getHttpUrlFromITags(event) : undefined), [event]) const iArticleUrl = useMemo(() => (event ? getHttpUrlFromITags(event) : undefined), [event])
const iArticleCleaned = useMemo( const iArticleCleaned = useMemo(
() => (iArticleUrl ? cleanUrl(iArticleUrl) || iArticleUrl : ''), () => (iArticleUrl ? cleanUrl(iArticleUrl) || iArticleUrl : ''),
@ -486,7 +485,7 @@ export default function Content({
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}> <div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
{iArticleUrl && ( {iArticleUrl && (
<div className="mb-2 max-w-full"> <div className="mb-2 max-w-full">
<WebPreview url={iArticleUrl} className="w-full" authorPubkey={authorPubkey} /> <WebPreview url={iArticleUrl} className="w-full" />
</div> </div>
)} )}
{/* Render images that appear in content in a single carousel at the top */} {/* Render images that appear in content in a single carousel at the top */}
@ -498,7 +497,6 @@ export default function Content({
start={0} start={0}
end={imagesInContent.length} end={imagesInContent.length}
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/> />
)} )}
@ -512,7 +510,6 @@ export default function Content({
start={0} start={0}
end={carouselImages.length} end={carouselImages.length}
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/> />
)} )}
@ -523,7 +520,6 @@ export default function Content({
src={video.url} src={video.url}
className="w-full max-w-full" className="w-full max-w-full"
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
deferLoadUntilClick={deferLongVideoLoad} deferLoadUntilClick={deferLongVideoLoad}
poster={video.image || video.thumb} poster={video.image || video.thumb}
blurHash={video.blurHash} blurHash={video.blurHash}
@ -538,7 +534,6 @@ export default function Content({
src={audio.url} src={audio.url}
className="mt-2" className="mt-2"
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
poster={audio.thumb} poster={audio.thumb}
blurHash={audio.blurHash} blurHash={audio.blurHash}
/> />
@ -551,7 +546,6 @@ export default function Content({
url={url} url={url}
className="mt-2" className="mt-2"
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/> />
))} ))}
@ -561,7 +555,6 @@ export default function Content({
url={url} url={url}
className="mt-2" className="mt-2"
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/> />
))} ))}
@ -571,7 +564,6 @@ export default function Content({
url={url} url={url}
className="mt-2" className="mt-2"
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/> />
))} ))}
@ -581,7 +573,6 @@ export default function Content({
url={url} url={url}
className="mt-2" className="mt-2"
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/> />
))} ))}
@ -622,7 +613,6 @@ export default function Content({
key={index} key={index}
src={cleanedUrl} src={cleanedUrl}
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
deferLoadUntilClick={deferLongVideoLoad} deferLoadUntilClick={deferLongVideoLoad}
poster={tagMediaInfo?.image || tagMediaInfo?.thumb} poster={tagMediaInfo?.image || tagMediaInfo?.thumb}
blurHash={tagMediaInfo?.blurHash} blurHash={tagMediaInfo?.blurHash}
@ -652,7 +642,6 @@ export default function Content({
key={`url-media-${index}`} key={`url-media-${index}`}
src={cleanedUrl} src={cleanedUrl}
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
deferLoadUntilClick={deferLongVideoLoad} deferLoadUntilClick={deferLongVideoLoad}
poster={poster} poster={poster}
blurHash={mediaInfo?.blurHash} blurHash={mediaInfo?.blurHash}
@ -678,7 +667,6 @@ export default function Content({
start={0} start={0}
end={1} end={1}
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/> />
) )
} }
@ -741,7 +729,6 @@ export default function Content({
url={node.data} url={node.data}
className="mt-2" className="mt-2"
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/> />
) )
} }
@ -752,7 +739,6 @@ export default function Content({
url={node.data} url={node.data}
className="mt-2" className="mt-2"
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/> />
) )
} }
@ -763,7 +749,6 @@ export default function Content({
url={node.data} url={node.data}
className="mt-2" className="mt-2"
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/> />
) )
} }
@ -774,7 +759,6 @@ export default function Content({
url={node.data} url={node.data}
className="mt-2" className="mt-2"
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/> />
) )
} }
@ -797,7 +781,7 @@ export default function Content({
<div className="space-y-3 mt-6 pt-4 border-t"> <div className="space-y-3 mt-6 pt-4 border-t">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Links</h3> <h3 className="text-sm font-semibold text-muted-foreground mb-3">Links</h3>
{contentLinks.map((url, index) => ( {contentLinks.map((url, index) => (
<WebPreview key={`content-${index}-${url}`} url={url} className="w-full" authorPubkey={authorPubkey} /> <WebPreview key={`content-${index}-${url}`} url={url} className="w-full" />
))} ))}
</div> </div>
)} )}
@ -807,7 +791,7 @@ export default function Content({
<div className="space-y-3 mt-6 pt-4 border-t"> <div className="space-y-3 mt-6 pt-4 border-t">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Related Links</h3> <h3 className="text-sm font-semibold text-muted-foreground mb-3">Related Links</h3>
{tagLinks.map((url, index) => ( {tagLinks.map((url, index) => (
<WebPreview key={`tag-${index}-${url}`} url={url} className="w-full" authorPubkey={authorPubkey} /> <WebPreview key={`tag-${index}-${url}`} url={url} className="w-full" />
))} ))}
</div> </div>
)} )}

4
src/components/ContentPreview/FollowPackPreview.tsx

@ -39,7 +39,7 @@ export default function FollowPackPreview({
className?: string className?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, canManageIdentity } = useNostr() const { pubkey } = useNostr()
const followList = useFollowListOptional() const followList = useFollowListOptional()
const followings = followList?.followings ?? [] const followings = followList?.followings ?? []
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
@ -169,7 +169,7 @@ export default function FollowPackPreview({
) : null} ) : null}
</div> </div>
{!canManageIdentity ? ( {!pubkey ? (
<p className="text-sm text-muted-foreground">{t('Please log in to follow')}</p> <p className="text-sm text-muted-foreground">{t('Please log in to follow')}</p>
) : !followList ? null : ( ) : !followList ? null : (
<Button <Button

54
src/components/EmojiPicker/index.tsx

@ -1,6 +1,3 @@
import { EMOJI_PICKER_DATA_SOURCE } from '@/lib/emoji-picker-data-source'
import { cn } from '@/lib/utils'
import { preloadEmojiPickerModule } from '@/lib/emoji-picker-preload'
import { DEFAULT_LIKE_REACTION_CONTENT, DEFAULT_LIKE_REACTION_DISPLAY_EMOJI, DEFAULT_SUGGESTED_EMOJIS } from '@/lib/like-reaction-emojis' import { DEFAULT_LIKE_REACTION_CONTENT, DEFAULT_LIKE_REACTION_DISPLAY_EMOJI, DEFAULT_SUGGESTED_EMOJIS } from '@/lib/like-reaction-emojis'
import { recordEmojiUsed } from '@/lib/recently-used-emojis' import { recordEmojiUsed } from '@/lib/recently-used-emojis'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -15,23 +12,18 @@ export { DEFAULT_SUGGESTED_EMOJIS as EMOJI_PICKER_REACTIONS } from '@/lib/like-r
export default function EmojiPicker({ export default function EmojiPicker({
onEmojiClick, onEmojiClick,
reactionsDefaultOpen, reactionsDefaultOpen,
reactions, reactions
layout = 'popover'
}: { }: {
onEmojiClick: (emoji: string | TEmoji | undefined, event: Event) => void onEmojiClick: (emoji: string | TEmoji | undefined, event: Event) => void
reactionsDefaultOpen?: boolean reactionsDefaultOpen?: boolean
reactions?: string[] reactions?: string[]
/** `drawer` fills the mobile sheet; `popover` uses a fixed height for dropdowns. */
layout?: 'drawer' | 'popover'
}) { }) {
const inDrawer = layout === 'drawer'
const { themeSetting } = useTheme() const { themeSetting } = useTheme()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const [mode, setMode] = useState<'reactions' | 'full'>( const [mode, setMode] = useState<'reactions' | 'full'>(
reactionsDefaultOpen ? 'reactions' : 'full' reactionsDefaultOpen ? 'reactions' : 'full'
) )
const [customEmojiTick, setCustomEmojiTick] = useState(0) const [customEmojiTick, setCustomEmojiTick] = useState(0)
const [pickerReady, setPickerReady] = useState(false)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const pickerRef = useRef<(HTMLElement & { customEmoji: unknown[] }) | null>(null) const pickerRef = useRef<(HTMLElement & { customEmoji: unknown[] }) | null>(null)
@ -51,17 +43,15 @@ export default function EmojiPicker({
if (mode !== 'full') return if (mode !== 'full') return
let cancelled = false let cancelled = false
setPickerReady(false)
preloadEmojiPickerModule().then(({ Picker }) => { import('emoji-picker-element').then(({ Picker }) => {
if (cancelled || !containerRef.current) return if (cancelled || !containerRef.current) return
const picker = new Picker({ const picker = new Picker() as HTMLElement & { customEmoji: unknown[] }
dataSource: EMOJI_PICKER_DATA_SOURCE,
customEmoji: customEmojis
}) as HTMLElement & { customEmoji: unknown[] }
pickerRef.current = picker pickerRef.current = picker
picker.customEmoji = customEmojis
if (themeSetting === 'dark') { if (themeSetting === 'dark') {
picker.className = 'dark' picker.className = 'dark'
} else if (themeSetting === 'light') { } else if (themeSetting === 'light') {
@ -69,15 +59,6 @@ export default function EmojiPicker({
} }
picker.style.width = '100%' picker.style.width = '100%'
picker.style.minWidth = '280px'
picker.style.maxWidth = '350px'
if (inDrawer) {
picker.style.height = '100%'
picker.style.minHeight = '0'
} else {
picker.style.height = 'min(350px, 50dvh)'
picker.style.minHeight = '280px'
}
picker.style.setProperty('--num-columns', '8') picker.style.setProperty('--num-columns', '8')
const handleClick = (e: Event) => { const handleClick = (e: Event) => {
@ -123,18 +104,16 @@ export default function EmojiPicker({
picker.addEventListener('emoji-click', handleClick) picker.addEventListener('emoji-click', handleClick)
containerRef.current.appendChild(picker) containerRef.current.appendChild(picker)
if (!cancelled) setPickerReady(true)
}) })
return () => { return () => {
cancelled = true cancelled = true
setPickerReady(false)
if (pickerRef.current) { if (pickerRef.current) {
pickerRef.current.remove() pickerRef.current.remove()
pickerRef.current = null pickerRef.current = null
} }
} }
}, [mode, inDrawer]) }, [mode])
useEffect(() => { useEffect(() => {
if (pickerRef.current) { if (pickerRef.current) {
@ -207,26 +186,9 @@ export default function EmojiPicker({
} }
return ( return (
<div <div className="flex w-full min-w-0 flex-col">
className={cn(
'flex w-full min-w-0 flex-col',
inDrawer && 'min-h-0 flex-1'
)}
>
{ownEmojisRow} {ownEmojisRow}
<div <div ref={containerRef} className="min-h-0 w-full flex-1 overflow-hidden" />
ref={containerRef}
className={cn(
'relative w-full min-w-[280px] max-w-[350px]',
inDrawer ? 'min-h-0 flex-1' : 'h-[min(320px,45dvh)] min-h-[240px] shrink-0'
)}
>
{!pickerReady ? (
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
</div>
) : null}
</div>
</div> </div>
) )
} }

36
src/components/EmojiPickerDialog/index.tsx

@ -4,10 +4,9 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { preloadEmojiPicker } from '@/lib/emoji-picker-preload'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import { useCallback, useEffect, useState } from 'react' import { useState } from 'react'
import EmojiPicker from '../EmojiPicker' import EmojiPicker from '../EmojiPicker'
export default function EmojiPickerDialog({ export default function EmojiPickerDialog({
@ -22,35 +21,15 @@ export default function EmojiPickerDialog({
}) { }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
/** Keep picker mounted after first open so emoji-picker-element is not cold-started every time. */
const [pickerMounted, setPickerMounted] = useState(false)
useEffect(() => {
if (open) setPickerMounted(true)
}, [open])
useEffect(() => {
void preloadEmojiPicker()
}, [])
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next)
}, [])
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<Drawer <Drawer open={open} onOpenChange={setOpen} handleOnly shouldScaleBackground={false}>
open={open}
onOpenChange={handleOpenChange}
handleOnly
shouldScaleBackground={false}
repositionInputs={false}
>
<DrawerTrigger asChild>{children}</DrawerTrigger> <DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent <DrawerContent
dragHandle="vaul" dragHandle="vaul"
portalContainer={portalContainer} portalContainer={portalContainer}
className="flex h-[min(72dvh,calc(100dvh-5rem))] max-h-[min(72dvh,calc(100dvh-5rem))] flex-col overflow-hidden px-2" className="max-h-[min(88dvh,calc(100dvh-5rem))] px-2"
onPointerDownOutside={(e) => { onPointerDownOutside={(e) => {
const t = e.target as HTMLElement | null const t = e.target as HTMLElement | null
if (t?.closest?.('[data-vaul-overlay]')) return if (t?.closest?.('[data-vaul-overlay]')) return
@ -60,10 +39,9 @@ export default function EmojiPickerDialog({
<DrawerHeader className="sr-only"> <DrawerHeader className="sr-only">
<DrawerTitle>Emoji Picker</DrawerTitle> <DrawerTitle>Emoji Picker</DrawerTitle>
</DrawerHeader> </DrawerHeader>
<div className="flex min-h-0 w-full max-w-[100vw] flex-1 flex-col overflow-hidden"> <div className="flex w-full max-w-[100vw] min-w-0 min-h-0 max-h-[min(72dvh,calc(100dvh-6rem))] flex-col overflow-hidden pb-1">
{pickerMounted ? ( {open ? (
<EmojiPicker <EmojiPicker
layout="drawer"
onEmojiClick={(emoji, e) => { onEmojiClick={(emoji, e) => {
e.stopPropagation() e.stopPropagation()
setOpen(false) setOpen(false)
@ -78,11 +56,11 @@ export default function EmojiPickerDialog({
} }
return ( return (
<DropdownMenu open={open} onOpenChange={handleOpenChange}> <DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
side="top" side="top"
className="p-0 w-[min(100vw-1rem,350px)] max-w-[calc(100vw-1rem)] overflow-hidden flex flex-col" className="p-0 w-[min(100vw-1rem,350px)] max-w-[calc(100vw-1rem)] overflow-hidden"
portalContainer={portalContainer} portalContainer={portalContainer}
> >
<EmojiPicker <EmojiPicker

135
src/components/Explore/ExploreFavoriteRelays.tsx

@ -0,0 +1,135 @@
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '@/components/RelaySimpleInfo'
import { Button } from '@/components/ui/button'
import { DEFAULT_FAVORITE_RELAYS } from '@/constants'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import { useFetchRelayInfo } from '@/hooks'
import { toRelay, toRelaySettings } from '@/lib/link'
import { normalizeUrl, simplifyUrl } from '@/lib/url'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSecondaryPage, useSmartRelayNavigation } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { cn } from '@/lib/utils'
import { Newspaper, Settings } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
function FavoriteRelayCard({ url }: { url: string }) {
const { navigateToRelay } = useSmartRelayNavigation()
const { relayInfo, isFetching } = useFetchRelayInfo(url)
if (isFetching) {
return (
<RelaySimpleInfoSkeleton className="h-full min-h-[5.5rem] rounded-lg border bg-card p-3 shadow-sm" />
)
}
if (!relayInfo) {
return (
<button
type="button"
className={cn(
'clickable flex h-full min-h-[5.5rem] min-w-[220px] max-w-[280px] shrink-0 flex-col justify-center rounded-lg border bg-card p-3 text-left shadow-sm',
'transition-colors hover:bg-accent/40'
)}
onClick={() => navigateToRelay(toRelay(url))}
>
<div className="truncate font-mono text-sm font-semibold">{simplifyUrl(url)}</div>
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{url}</div>
</button>
)
}
return (
<RelaySimpleInfo
relayInfo={relayInfo}
className={cn(
'clickable h-full min-h-[5.5rem] min-w-[220px] max-w-[280px] shrink-0 rounded-lg border bg-card p-3 shadow-sm',
'transition-colors hover:bg-accent/40'
)}
onClick={(e) => {
e.stopPropagation()
navigateToRelay(toRelay(relayInfo.url))
}}
/>
)
}
/**
* Horizontal strip of favorite relays (non-blocked), or {@link DEFAULT_FAVORITE_RELAYS} when none.
*/
export default function ExploreFavoriteRelays() {
const { t } = useTranslation()
const { navigate } = usePrimaryPage()
const { push } = useSecondaryPage()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
const blockedSet = useMemo(
() => new Set(blockedRelays.map((b) => normalizeUrl(b) || b)),
[blockedRelays]
)
const { urls, usingDefaults } = useMemo(() => {
const visible = favoriteRelays.filter((r) => {
const k = normalizeUrl(r) || r
return k && !blockedSet.has(k)
})
if (visible.length > 0) {
return { urls: visible, usingDefaults: false }
}
if (!useGlobalRelayBootstrap) {
return { urls: [], usingDefaults: false }
}
const defaultsFiltered = DEFAULT_FAVORITE_RELAYS.filter((r) => {
const k = normalizeUrl(r) || r
return k && !blockedSet.has(k)
})
return {
urls: defaultsFiltered.length > 0 ? defaultsFiltered : DEFAULT_FAVORITE_RELAYS,
usingDefaults: true
}
}, [favoriteRelays, blockedSet, useGlobalRelayBootstrap])
if (urls.length === 0) return null
return (
<section className="min-w-0 px-2 pb-4 pt-1" aria-label={t('Favorite Relays')}>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2 px-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="text-base font-semibold tracking-tight">{t('Favorite Relays')}</h2>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 font-medium"
onClick={() => navigate('feed')}
>
<Newspaper className="size-4 shrink-0" strokeWidth={2.5} />
<span>{t('Favorite Relays')}</span>
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
aria-label={t('Relays and Storage Settings')}
title={t('Relays and Storage Settings')}
onClick={() => push(toRelaySettings('favorite-relays'))}
>
<Settings className="size-4 shrink-0" strokeWidth={2.5} />
</Button>
</div>
{usingDefaults ? (
<span className="text-xs text-muted-foreground">{t('Using app default relays')}</span>
) : null}
</div>
<div className="flex gap-3 overflow-x-auto overflow-y-hidden pb-4 pt-0.5 snap-x snap-mandatory [scrollbar-gutter:stable]">
{urls.map((url) => (
<div key={url} className="snap-start">
<FavoriteRelayCard url={url} />
</div>
))}
</div>
</section>
)
}

77
src/components/Explore/ExplorePopularRelays.tsx

@ -0,0 +1,77 @@
import { buildExplorePopularRelayUrls } from '@/lib/explore-popular-relays'
import { toRelay } from '@/lib/link'
import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url'
import { useSmartRelayNavigation } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import indexedDb from '@/services/indexed-db.service'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
/**
* Lightweight Explore relay list: URLs from the viewer's NIP-65 / favorites / defaults and optional
* cached NIP-66 data no GitHub collections fetch and no NIP-11 storm on mount.
*/
export default function ExplorePopularRelays() {
const { t } = useTranslation()
const { relayList } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { navigateToRelay } = useSmartRelayNavigation()
const [nip66Cached, setNip66Cached] = useState<string[]>([])
useEffect(() => {
let cancelled = false
void indexedDb
.getPublicLivelyRelayUrlsCache()
.then((c) => {
if (!cancelled && c?.urls?.length) setNip66Cached(c.urls)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
const urls = useMemo(
() =>
buildExplorePopularRelayUrls({
relayList,
favoriteRelays,
blockedRelays,
nip66CachedUrls: nip66Cached
}),
[relayList, favoriteRelays, blockedRelays, nip66Cached]
)
if (urls.length === 0) {
return (
<p className="px-4 py-6 text-sm text-muted-foreground">{t('No relays in your lists yet.')}</p>
)
}
return (
<section className="min-w-0 pb-6" aria-label={t('Popular relays')}>
<h2 className="mb-2 px-4 text-base font-semibold tracking-tight">{t('Popular relays')}</h2>
<p className="mb-3 px-4 text-sm text-muted-foreground">
{t('From your mailbox, favorites, and cached relay lists on this device.')}
</p>
<ul className="grid min-w-0 gap-2 px-2 md:grid-cols-2 md:px-4">
{urls.map((url) => {
const key = normalizeAnyRelayUrl(url) || url
return (
<li key={key}>
<button
type="button"
className="flex w-full min-w-0 flex-col rounded-lg border bg-card px-3 py-2.5 text-left shadow-sm transition-colors hover:bg-accent/40"
onClick={() => navigateToRelay(toRelay(url))}
>
<span className="truncate font-mono text-sm font-semibold">{simplifyUrl(url)}</span>
<span className="mt-0.5 truncate text-xs text-muted-foreground">{url}</span>
</button>
</li>
)
})}
</ul>
</section>
)
}

239
src/components/Explore/ExploreRelayReviews.tsx

@ -0,0 +1,239 @@
import RelayIcon from '@/components/RelayIcon'
import RelayReviewCard from '@/components/RelayInfo/RelayReviewCard'
import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants'
import { useFetchRelayInfo } from '@/hooks'
import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata'
import {
dedupeRelayReviewsNewestFirst,
loadCachedRelayReviews
} from '@/lib/explore-relay-reviews'
import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays'
import { toRelay } from '@/lib/link'
import { isExploreBrowsableRelayUrl } from '@/lib/explore-popular-relays'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds'
import { useSmartRelayNavigation } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import type { Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
function RelayGroupHeader({ url, reviewCount }: { url: string; reviewCount: number }) {
const { navigateToRelay } = useSmartRelayNavigation()
const { relayInfo } = useFetchRelayInfo(url)
return (
<button
type="button"
className="flex w-full min-w-0 items-center gap-2 px-4 md:px-4 pt-4 pb-2 border-b text-left hover:opacity-75 transition-opacity"
onClick={() => navigateToRelay(toRelay(url))}
>
<RelayIcon url={url} skipRelayInfoFetch className="h-8 w-8 shrink-0 rounded-sm" iconSize={16} />
<div className="min-w-0 flex-1">
{relayInfo?.name && (
<div className="truncate font-semibold text-sm leading-tight">{relayInfo.name}</div>
)}
<div className="flex items-center gap-1.5 min-w-0">
<div className="truncate font-mono text-xs text-muted-foreground leading-tight">{url}</div>
<span className="shrink-0 text-xs text-muted-foreground">
· {reviewCount} {reviewCount === 1 ? 'review' : 'reviews'}
</span>
</div>
</div>
</button>
)
}
const REVIEW_QUERY_LIMIT = 100
const SHOW_COUNT = 20
/** Fewer sockets + faster aggregate EOSE than full inbox stack; read-only mirrors prepended then capped. */
const EXPLORE_REVIEWS_MAX_RELAYS = 12
/** After all relays EOSE, wait longer than default so slow mirrors can flush events (default query eose is 500ms). */
const EXPLORE_REVIEWS_EOSE_TAIL_MS = 4500
function stableRelayInputsKey(
favoriteRelays: string[],
blockedRelays: string[],
relayList: { read?: string[]; write?: string[]; httpRead?: string[] } | null | undefined,
cacheRelayListEvent: Event | null | undefined
): string {
const normSortJoin = (urls: string[]) =>
[...urls]
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.sort((a, b) => a.localeCompare(b))
.join('|')
return [
normSortJoin(favoriteRelays),
normSortJoin(blockedRelays),
normSortJoin(userReadInboxUrls(relayList, cacheRelayListEvent)),
normSortJoin(userWriteOutboxUrls(relayList, cacheRelayListEvent))
].join('::')
}
export default function ExploreRelayReviews() {
const { t } = useTranslation()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { relayList, cacheRelayListEvent } = useNostr()
const relayInputsKey = useMemo(
() => stableRelayInputsKey(favoriteRelays, blockedRelays, relayList, cacheRelayListEvent),
[favoriteRelays, blockedRelays, relayList, cacheRelayListEvent]
)
const relayUrls = useMemo(() => {
const stacked = appendCuratedReadOnlyRelays(
getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
userReadInboxUrls(relayList, cacheRelayListEvent),
{
userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent),
maxRelays: EXPLORE_REVIEWS_MAX_RELAYS,
applySocialKindBlockedFilter: false
}
),
blockedRelays
)
const sliced = stacked.slice(0, EXPLORE_REVIEWS_MAX_RELAYS)
const normalized = sliced
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter((u): u is string => Boolean(u) && isExploreBrowsableRelayUrl(u))
normalized.sort((a, b) => a.localeCompare(b))
return normalized
// eslint-disable-next-line react-hooks/exhaustive-deps -- relayInputsKey is a content hash of favorites/blocked/NIP-65; relayList identity churn must not re-open REQ sockets.
}, [relayInputsKey])
const relayUrlsKey = relayInputsKey
const [loading, setLoading] = useState(true)
const [events, setEvents] = useState<Event[]>([])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement>(null)
const fetchGenRef = useRef(0)
useEffect(() => {
const gen = ++fetchGenRef.current
let cancelled = false
setLoading(true)
setEvents([])
setShowCount(SHOW_COUNT)
void (async () => {
const cached = await loadCachedRelayReviews(REVIEW_QUERY_LIMIT)
if (!cancelled && fetchGenRef.current === gen && cached.length > 0) {
setEvents(cached)
}
try {
const raw = await client.fetchEvents(
relayUrls,
{ kinds: [ExtendedKind.RELAY_REVIEW], limit: REVIEW_QUERY_LIMIT },
{
onevent: (e) => {
if (cancelled || fetchGenRef.current !== gen) return
if (e.kind === ExtendedKind.RELAY_REVIEW && getRelayUrlFromRelayReviewEvent(e)) {
setEvents((prev) => dedupeRelayReviewsNewestFirst([...prev, e]))
}
},
firstRelayResultGraceMs: false,
globalTimeout: 12_000,
eoseTimeout: EXPLORE_REVIEWS_EOSE_TAIL_MS,
cache: true
}
)
if (cancelled || fetchGenRef.current !== gen) return
const withRelay = raw.filter(
(e) => e.kind === ExtendedKind.RELAY_REVIEW && getRelayUrlFromRelayReviewEvent(e)
)
setEvents((prev) => dedupeRelayReviewsNewestFirst([...prev, ...withRelay]))
} catch {
if (!cancelled && fetchGenRef.current === gen) setEvents([])
} finally {
if (!cancelled && fetchGenRef.current === gen) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [relayUrlsKey])
useEffect(() => {
const options = { root: null, rootMargin: '120px', threshold: 0 }
const observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting && showCount < events.length) {
setShowCount((prev) => prev + SHOW_COUNT)
}
}, options)
const el = bottomRef.current
if (el) observer.observe(el)
return () => {
if (el) observer.unobserve(el)
}
}, [showCount, events.length])
const visible = events.slice(0, showCount)
const groupedVisible = useMemo(() => {
const groups = new Map<string, Event[]>()
for (const event of visible) {
const url = getRelayUrlFromRelayReviewEvent(event)
if (!url || !isExploreBrowsableRelayUrl(url)) continue
if (!groups.has(url)) groups.set(url, [])
groups.get(url)!.push(event)
}
return Array.from(groups.entries())
}, [visible])
const showInitialSkeleton = loading && events.length === 0
const showEmptyAfterLoad = !loading && events.length === 0
return (
<div className="min-w-0 pt-1 pb-8">
{showInitialSkeleton ? (
<div className="grid min-w-0 md:px-4 md:grid-cols-2 md:gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-40 rounded-lg border md:border" />
))}
</div>
) : showEmptyAfterLoad ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">{t('no relays found')}</p>
) : (
<>
{groupedVisible.map(([relayUrl, relayEvents]) => (
<div key={relayUrl} className="mb-4">
<RelayGroupHeader url={relayUrl} reviewCount={relayEvents.length} />
<div className="grid min-w-0 md:px-4 md:grid-cols-2 md:gap-3 mt-2">
{relayEvents.map((event) => (
<RelayReviewCard
key={event.id}
event={event}
showRelayInfo={false}
className="border-b md:border md:border-border"
/>
))}
</div>
</div>
))}
{loading ? (
<div
className="mt-4 grid min-w-0 gap-3 md:grid-cols-2 md:px-4"
aria-busy="true"
aria-live="polite"
>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-28 rounded-lg border md:border" />
))}
</div>
) : null}
{showCount < events.length ? <div ref={bottomRef} className="h-4" aria-hidden /> : null}
{!loading && showCount >= events.length ? (
<p className="mt-3 text-center text-sm text-muted-foreground">{t('no more relays')}</p>
) : null}
</>
)}
</div>
)
}

146
src/components/Explore/index.tsx

@ -0,0 +1,146 @@
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchRelayInfo } from '@/hooks'
import { toRelay } from '@/lib/link'
import { useSmartRelayNavigation } from '@/PageManager'
import relayInfoService from '@/services/relay-info.service'
import { TAwesomeRelayCollection } from '@/types'
import { useEffect, useState } from 'react'
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo'
import logger from '@/lib/logger'
export default function Explore() {
const [collections, setCollections] = useState<TAwesomeRelayCollection[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
let timeoutId: ReturnType<typeof setTimeout> | null = null
// Add timeout to prevent hanging forever
timeoutId = setTimeout(() => {
if (!cancelled) {
logger.warn('[Explore] Timeout loading relay collections after 10 seconds')
setError('Timeout loading relay collections')
setCollections([]) // Set empty array to stop showing skeletons
}
}, 10000) // 10 second timeout
logger.debug('[Explore] Fetching awesome relay collections')
relayInfoService.getAwesomeRelayCollections()
.then((data) => {
if (!cancelled) {
if (timeoutId) clearTimeout(timeoutId)
logger.debug('[Explore] Loaded collections', { count: data?.length || 0 })
setCollections(data || [])
}
})
.catch((err) => {
if (!cancelled) {
if (timeoutId) clearTimeout(timeoutId)
logger.error('[Explore] Error loading collections', { error: err })
setError(err instanceof Error ? err.message : 'Failed to load relay collections')
setCollections([]) // Set empty array to stop showing skeletons
}
})
return () => {
cancelled = true
if (timeoutId) clearTimeout(timeoutId)
}
}, [])
if (collections === null) {
return (
<div>
<div className="p-4 max-md:border-b">
<Skeleton className="h-6 w-20" />
</div>
<div className="grid md:px-4 md:grid-cols-2 md:gap-2">
<RelaySimpleInfoSkeleton className="h-auto px-4 py-3 md:rounded-lg md:border" />
</div>
</div>
)
}
if (error) {
return (
<div className="p-4">
<div className="text-red-500 mb-2">Error: {error}</div>
<button
onClick={() => {
setCollections(null)
setError(null)
// Trigger reload
relayInfoService.getAwesomeRelayCollections()
.then(setCollections)
.catch((err) => {
setError(err instanceof Error ? err.message : 'Failed to load')
setCollections([])
})
}}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Retry
</button>
</div>
)
}
if (collections.length === 0) {
return (
<div className="p-4 text-center text-muted-foreground">
No relay collections available
</div>
)
}
return (
<div className="min-w-0 w-full overflow-x-hidden space-y-6 pb-8">
{collections.map((collection) => (
<RelayCollection key={collection.id} collection={collection} />
))}
</div>
)
}
function RelayCollection({ collection }: { collection: TAwesomeRelayCollection }) {
return (
<div className="min-w-0">
<div className="px-4 pt-3 pb-3.5 text-2xl font-semibold max-md:border-b min-w-0 break-words">
{collection.name}
</div>
<div className="grid min-w-0 md:px-4 md:grid-cols-2 md:gap-3">
{collection.relays.map((url) => (
<RelayItem key={url} url={url} />
))}
</div>
</div>
)
}
function RelayItem({ url }: { url: string }) {
const { navigateToRelay } = useSmartRelayNavigation()
const { relayInfo, isFetching } = useFetchRelayInfo(url)
if (isFetching) {
return <RelaySimpleInfoSkeleton className="h-auto px-4 py-3 border-b md:rounded-lg md:border" />
}
if (!relayInfo) {
return null
}
return (
<div className="min-w-0">
<RelaySimpleInfo
key={relayInfo.url}
className="clickable h-auto px-4 py-3 border-b md:rounded-lg md:border min-w-0"
relayInfo={relayInfo}
onClick={(e) => {
e.stopPropagation()
navigateToRelay(toRelay(relayInfo.url))
}}
/>
</div>
)
}

8
src/components/FavoriteRelaysSetting/AddNewRelay.tsx

@ -53,19 +53,19 @@ export default function AddNewRelay() {
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex gap-2 items-center">
<Input <Input
placeholder={t('Add a new relay')} placeholder={t('Add a new relay')}
value={input} value={input}
onChange={handleNewRelayInputChange} onChange={handleNewRelayInputChange}
onKeyDown={handleNewRelayInputKeyDown} onKeyDown={handleNewRelayInputKeyDown}
className={`min-w-0 flex-1 ${errorMsg ? 'border-destructive' : ''}`} className={errorMsg ? 'border-destructive' : ''}
/> />
<Button className="shrink-0 sm:w-auto" onClick={saveRelay} disabled={isLoading || !input.trim()}> <Button onClick={saveRelay} disabled={isLoading || !input.trim()}>
{isLoading ? t('Adding...') : t('Add')} {isLoading ? t('Adding...') : t('Add')}
</Button> </Button>
</div> </div>
{errorMsg && <div className="text-destructive text-sm">{errorMsg}</div>} {errorMsg && <div className="text-destructive text-sm pl-8">{errorMsg}</div>}
</div> </div>
) )
} }

10
src/components/FavoriteRelaysSetting/BlockedRelayItem.tsx

@ -29,19 +29,19 @@ export default function BlockedRelayItem({ relay }: { relay: string }) {
return ( return (
<div <div
className="relative group clickable flex min-w-0 items-start gap-2 rounded-lg border p-2 select-none sm:items-center" className="relative group clickable flex gap-2 border rounded-lg p-2 pr-2.5 items-center justify-between select-none"
onClick={() => push(toRelay(relay))} onClick={() => push(toRelay(relay))}
> >
<div className="flex min-w-0 flex-1 items-start gap-2"> <div className="flex items-center gap-2 flex-1">
<RelayIcon url={relay} skipRelayInfoFetch className="mt-0.5 shrink-0" /> <RelayIcon url={relay} skipRelayInfoFetch />
<div className="min-w-0 flex-1 break-all text-sm font-semibold leading-snug">{relay}</div> <div className="flex-1 w-0 truncate font-semibold">{relay}</div>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleUnblock} onClick={handleUnblock}
disabled={isLoading} disabled={isLoading}
className="h-8 w-8 shrink-0 p-0" className="h-8 w-8 p-0"
> >
{isLoading ? ( {isLoading ? (
<Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden /> <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />

15
src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx

@ -16,8 +16,6 @@ import {
sortableKeyboardCoordinates, sortableKeyboardCoordinates,
verticalListSortingStrategy verticalListSortingStrategy
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable'
import { ensureTrendingInFavoriteRelayList } from '@/lib/wisp-trending-relay'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import RelayItem from './RelayItem' import RelayItem from './RelayItem'
@ -26,11 +24,8 @@ export default function FavoriteRelayList() {
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { favoriteRelays, blockedRelays, reorderFavoriteRelays, favoriteRelaysFromPublishedList } = const { favoriteRelays, blockedRelays, reorderFavoriteRelays, favoriteRelaysFromPublishedList } =
useFavoriteRelays() useFavoriteRelays()
const displayRelays = useMemo( // Show all relays including blocked ones (they'll be marked visually)
() => ensureTrendingInFavoriteRelayList(favoriteRelays),
[favoriteRelays]
)
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor), useSensor(PointerSensor),
@ -71,9 +66,9 @@ export default function FavoriteRelayList() {
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]} modifiers={[restrictToVerticalAxis, restrictToParentElement]}
> >
<SortableContext items={displayRelays} strategy={verticalListSortingStrategy}> <SortableContext items={favoriteRelays} strategy={verticalListSortingStrategy}>
<div className="grid min-w-0 gap-2"> <div className="grid gap-2">
{displayRelays.map((relay) => ( {favoriteRelays.map((relay) => (
<RelayItem key={relay} relay={relay} isBlocked={blockedRelays.includes(relay)} /> <RelayItem key={relay} relay={relay} isBlocked={blockedRelays.includes(relay)} />
))} ))}
</div> </div>

39
src/components/FavoriteRelaysSetting/RelayItem.tsx

@ -22,33 +22,32 @@ export default function RelayItem({ relay, isBlocked = false }: { relay: string;
return ( return (
<div <div
className={`relative group clickable flex min-w-0 gap-1 rounded-lg border p-2 select-none sm:gap-2 ${isBlocked ? 'opacity-60' : ''}`} className={`relative group clickable flex gap-2 border rounded-lg p-2 pr-2.5 items-center justify-between select-none ${isBlocked ? 'opacity-60' : ''}`}
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
onClick={() => push(toRelay(relay))} onClick={() => push(toRelay(relay))}
> >
<div <div className="flex items-center gap-1 flex-1">
className="shrink-0 cursor-grab touch-none rounded p-2 hover:bg-muted active:cursor-grabbing" <div
{...attributes} className="cursor-grab active:cursor-grabbing p-2 hover:bg-muted rounded touch-none shrink-0"
{...listeners} {...attributes}
onClick={(e) => e.stopPropagation()} {...listeners}
> >
<GripVertical className="size-4 text-muted-foreground" /> <GripVertical className="size-4 text-muted-foreground" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-1 items-start gap-2">
<RelayIcon url={relay} skipRelayInfoFetch={isBlocked} className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="break-all text-sm font-semibold leading-snug">{relay}</div>
{isBlocked ? (
<span className="mt-0.5 block text-xs text-muted-foreground">({t('blocked')})</span>
) : null}
</div>
</div> </div>
<div className="shrink-0 self-end sm:self-center" onClick={(e) => e.stopPropagation()}> <div className="flex gap-2 items-center flex-1 min-w-0">
<SaveRelayDropdownMenu urls={[relay]} /> <RelayIcon url={relay} skipRelayInfoFetch={isBlocked} />
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-1 truncate font-semibold">{relay}</div>
{isBlocked && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
({t('blocked')})
</span>
)}
</div>
</div> </div>
</div> </div>
<SaveRelayDropdownMenu urls={[relay]} />
</div> </div>
) )
} }

18
src/components/FavoriteRelaysSetting/RelaySet.tsx

@ -42,25 +42,25 @@ export default function RelaySet({ relaySet }: { relaySet: TRelaySet }) {
} }
return ( return (
<div ref={setNodeRef} style={style} className="group relative min-w-0"> <div ref={setNodeRef} style={style} className="relative group">
<div className="w-full min-w-0 rounded-lg border px-2 py-2.5"> <div className="w-full border rounded-lg px-2 py-2.5">
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="flex justify-between items-center">
<div className="flex min-w-0 items-center"> <div className="flex items-center">
<div <div
className="cursor-grab touch-none rounded p-2 hover:bg-muted active:cursor-grabbing" className="cursor-grab active:cursor-grabbing p-2 hover:bg-muted rounded touch-none"
{...attributes} {...attributes}
{...listeners} {...listeners}
> >
<GripVertical className="size-4 text-muted-foreground" /> <GripVertical className="size-4 text-muted-foreground" />
</div> </div>
<div className="flex min-w-0 items-center gap-2"> <div className="flex gap-2 items-center">
<div className="flex h-6 w-6 shrink-0 items-center justify-center"> <div className="flex justify-center items-center w-6 h-6 shrink-0">
<FolderClosed className="size-4" /> <FolderClosed className="size-4" />
</div> </div>
<RelaySetName relaySet={relaySet} /> <RelaySetName relaySet={relaySet} />
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center justify-end gap-1 self-end sm:self-auto"> <div className="flex gap-1">
<RelayUrlsExpandToggle relaySetId={relaySet.id}> <RelayUrlsExpandToggle relaySetId={relaySet.id}>
{t('n relays', { n: relaySet.relayUrls.length })} {t('n relays', { n: relaySet.relayUrls.length })}
</RelayUrlsExpandToggle> </RelayUrlsExpandToggle>
@ -111,7 +111,7 @@ function RelaySetName({ relaySet }: { relaySet: TRelaySet }) {
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="flex min-h-8 min-w-0 items-center break-words font-semibold select-none">{relaySet.name}</div> <div className="h-8 font-semibold flex items-center select-none">{relaySet.name}</div>
) )
} }

35
src/components/FavoriteRelaysSetting/RelayUrl.tsx

@ -81,20 +81,16 @@ export default function RelayUrls({ relaySetId }: { relaySetId: string }) {
<RelayUrl key={index} url={url} onRemove={() => removeRelayUrl(url)} /> <RelayUrl key={index} url={url} onRemove={() => removeRelayUrl(url)} />
))} ))}
</div> </div>
<div className="mt-2 flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center"> <div className="mt-2 flex gap-2">
<Input <Input
className={`min-w-0 flex-1 ${newRelayUrlError ? 'border-destructive' : ''}`} className={newRelayUrlError ? 'border-destructive' : ''}
placeholder={t('Add a new relay')} placeholder={t('Add a new relay')}
value={newRelayUrl} value={newRelayUrl}
onKeyDown={handleRelayUrlInputKeyDown} onKeyDown={handleRelayUrlInputKeyDown}
onChange={handleRelayUrlInputChange} onChange={handleRelayUrlInputChange}
onBlur={saveNewRelayUrl} onBlur={saveNewRelayUrl}
/> />
<Button <Button onClick={saveNewRelayUrl} disabled={isLoading || !newRelayUrl.trim()}>
className="shrink-0 sm:w-auto"
onClick={saveNewRelayUrl}
disabled={isLoading || !newRelayUrl.trim()}
>
{isLoading ? t('Adding...') : t('Add')} {isLoading ? t('Adding...') : t('Add')}
</Button> </Button>
</div> </div>
@ -107,22 +103,21 @@ function RelayUrl({ url, onRemove }: { url: string; onRemove: () => void }) {
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
return ( return (
<div className="flex min-w-0 items-start gap-2 py-0.5 pl-1 pr-1"> <div className="flex items-center justify-between pl-1 pr-3">
<div <div
className="-mx-2 -my-1 flex min-w-0 flex-1 cursor-pointer items-start gap-2 rounded px-2 py-1 hover:bg-muted" className="flex gap-3 items-center flex-1 w-0 cursor-pointer hover:bg-muted rounded px-2 py-1 -mx-2 -my-1"
onClick={() => push(toRelay(url))} onClick={() => push(toRelay(url))}
> >
<RelayIcon url={url} className="mt-0.5 h-4 w-4 shrink-0" iconSize={10} /> <RelayIcon url={url} className="w-4 h-4" iconSize={10} />
<div className="min-w-0 flex-1 break-all text-sm leading-snug text-muted-foreground">{url}</div> <div className="text-muted-foreground text-sm truncate">{url}</div>
</div>
<div className="shrink-0">
<CircleX
size={16}
onClick={onRemove}
className="text-muted-foreground hover:text-destructive cursor-pointer"
/>
</div> </div>
<button
type="button"
className="mt-0.5 shrink-0 text-muted-foreground hover:text-destructive"
onClick={onRemove}
aria-label="Remove relay"
>
<CircleX size={16} />
</button>
</div> </div>
) )
} }

2
src/components/FavoriteRelaysSetting/index.tsx

@ -9,7 +9,7 @@ import RelaySetList from './RelaySetList'
export default function FavoriteRelaysSetting() { export default function FavoriteRelaysSetting() {
return ( return (
<RelaySetsSettingComponentProvider> <RelaySetsSettingComponentProvider>
<div className="min-w-0 w-full space-y-4"> <div className="space-y-4">
<RelaySetList /> <RelaySetList />
<AddNewRelaySet /> <AddNewRelaySet />
<FavoriteRelayList /> <FavoriteRelayList />

47
src/components/FeedFilterToolbarRow/index.tsx

@ -1,47 +0,0 @@
import KindFilter from '@/components/KindFilter'
import { RefreshButton } from '@/components/RefreshButton'
import { cn } from '@/lib/utils'
import type { Ref } from 'react'
/** Sticky/subheader chrome around the feed tool row (home subHeader, in-feed sticky). */
export const feedFilterRowChromeClass =
'w-full min-w-0 border-b border-border/80 bg-background/95 pb-1.5 pt-0.5 backdrop-blur supports-[backdrop-filter]:bg-background/80'
/**
* Single-row feed controls: refresh (optional), kind filter, and 🔍 slot (portaled from {@link NoteList}).
* Use `flex-nowrap` so large text / narrow viewports do not wrap tools onto a second line.
*/
export default function FeedFilterToolbarRow({
showKinds,
onShowKindsChange,
onRefresh,
feedFilterTabRowSlotRef,
includeFeedSearchSlot = false,
className
}: {
showKinds: number[]
onShowKindsChange: (kinds: number[]) => void
onRefresh?: () => void
/** Host element for {@link NoteList} feed-client-filter toggle via portal. */
feedFilterTabRowSlotRef?: Ref<HTMLDivElement>
includeFeedSearchSlot?: boolean
className?: string
}) {
return (
<div
className={cn(
'flex w-full min-w-0 flex-nowrap items-center justify-end gap-0 py-1',
className
)}
>
{onRefresh != null ? <RefreshButton onClick={onRefresh} /> : null}
<KindFilter showKinds={showKinds} onShowKindsChange={onShowKindsChange} />
{includeFeedSearchSlot ? (
<div
ref={feedFilterTabRowSlotRef}
className="flex min-w-0 flex-1 flex-nowrap items-center justify-end gap-1 overflow-hidden"
/>
) : null}
</div>
)
}

30
src/components/FeedRelaysIconRow/index.tsx

@ -1,7 +1,6 @@
import RelayIcon from '@/components/RelayIcon' import RelayIcon from '@/components/RelayIcon'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toRelay } from '@/lib/link' import { toRelay } from '@/lib/link'
import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import { simplifyUrl } from '@/lib/url' import { simplifyUrl } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartRelayNavigation } from '@/PageManager' import { useSmartRelayNavigation } from '@/PageManager'
@ -9,52 +8,35 @@ import { useTranslation } from 'react-i18next'
export function FeedRelaysIconRow({ export function FeedRelaysIconRow({
urls, urls,
className, className
compact = false
}: { }: {
urls: readonly string[] urls: readonly string[]
className?: string className?: string
/** Smaller icons for inline toolbar rows (e.g. next to the feed filter toggle). */
compact?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToRelay } = useSmartRelayNavigation() const { navigateToRelay } = useSmartRelayNavigation()
if (urls.length === 0) return null if (urls.length === 0) return null
const buttonClass = compact
? 'h-5 w-5 min-h-5 min-w-5 shrink-0 rounded-full p-0 hover:bg-muted/80'
: 'h-7 w-7 min-h-7 min-w-7 shrink-0 rounded-full p-0 hover:bg-muted/80'
const iconClass = compact ? 'h-4 w-4' : 'h-6 w-6'
const iconSize = compact ? 8 : 12
return ( return (
<div <div
className={cn('flex min-w-0 flex-nowrap items-center', compact ? 'gap-0.5' : 'gap-1', className)} className={cn('flex min-w-0 flex-wrap items-center gap-1', className)}
role="group" role="group"
aria-label={t('Feed relays', { defaultValue: 'Relays in this feed' })} aria-label={t('Feed relays', { defaultValue: 'Relays in this feed' })}
> >
{urls.map((url) => { {urls.map((url) => {
const label = simplifyUrl(url) const label = simplifyUrl(url)
const isTrending = isWispTrendingNotesRelayUrl(url)
const title = isTrending
? t('Trending on Nostr', { defaultValue: 'Trending on Nostr' })
: label
return ( return (
<Button <Button
key={url} key={url}
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className={buttonClass} className="h-7 w-7 min-h-7 min-w-7 shrink-0 rounded-full p-0 hover:bg-muted/80"
title={title} title={label}
aria-label={ aria-label={t('Open relay feed', { relay: label, defaultValue: `Open ${label} feed` })}
isTrending
? t('Open trending feed', { defaultValue: 'Open trending feed' })
: t('Open relay feed', { relay: label, defaultValue: `Open ${label} feed` })
}
onClick={() => navigateToRelay(toRelay(url))} onClick={() => navigateToRelay(toRelay(url))}
> >
<RelayIcon url={url} className={iconClass} iconSize={iconSize} /> <RelayIcon url={url} className="h-6 w-6" iconSize={12} />
</Button> </Button>
) )
})} })}

16
src/components/FollowButton/index.tsx

@ -14,7 +14,6 @@ import { Skeleton } from '@/components/ui/skeleton'
import { useFollowListOptional } from '@/providers/follow-list-context' import { useFollowListOptional } from '@/providers/follow-list-context'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { useSignGatedControl } from '@/hooks/useSignGatedControl'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -23,7 +22,6 @@ import { toast } from 'sonner'
export default function FollowButton({ pubkey }: { pubkey: string }) { export default function FollowButton({ pubkey }: { pubkey: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey: accountPubkey, checkLogin } = useNostr() const { pubkey: accountPubkey, checkLogin } = useNostr()
const { canManageIdentity, signControlProps } = useSignGatedControl()
const followList = useFollowListOptional() const followList = useFollowListOptional()
const { mutePubkeySet, unmutePubkey } = useMuteList() const { mutePubkeySet, unmutePubkey } = useMuteList()
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
@ -33,9 +31,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey]) const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey])
const isMuted = useMemo(() => muteSetHas(mutePubkeySet, pubkey), [mutePubkeySet, pubkey]) const isMuted = useMemo(() => muteSetHas(mutePubkeySet, pubkey), [mutePubkeySet, pubkey])
if (!followList || !accountPubkey || !canManageIdentity || (pubkey && pubkey === accountPubkey)) { if (!followList || !accountPubkey || (pubkey && pubkey === accountPubkey)) return null
return null
}
const { follow, unfollow } = followList const { follow, unfollow } = followList
@ -96,7 +92,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
<Button <Button
className="rounded-full min-w-28 max-w-full text-destructive whitespace-normal break-words px-3" className="rounded-full min-w-28 max-w-full text-destructive whitespace-normal break-words px-3"
variant="secondary" variant="secondary"
{...signControlProps({ disabled: updating })} disabled={updating}
> >
{updating ? ( {updating ? (
<Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden /> <Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden />
@ -129,7 +125,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
<Button <Button
className="rounded-full min-w-28" className="rounded-full min-w-28"
variant={hover ? 'destructive' : 'secondary'} variant={hover ? 'destructive' : 'secondary'}
{...signControlProps({ disabled: updating })} disabled={updating}
onMouseEnter={() => setHover(true)} onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)} onMouseLeave={() => setHover(false)}
> >
@ -158,11 +154,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
) : ( ) : (
<Button <Button className="rounded-full min-w-28" onClick={handleFollow} disabled={updating}>
className="rounded-full min-w-28"
onClick={handleFollow}
{...signControlProps({ disabled: updating })}
>
{updating ? <Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden /> : t('Follow')} {updating ? <Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden /> : t('Follow')}
</Button> </Button>
) )

94
src/components/FollowingFavoriteRelayList/index.tsx

@ -0,0 +1,94 @@
import { useFetchRelayInfo } from '@/hooks'
import { isExploreBrowsableRelayUrl } from '@/lib/explore-popular-relays'
import { toRelay } from '@/lib/link'
import { useSmartRelayNavigation } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo'
const SHOW_COUNT = 10
export default function FollowingFavoriteRelayList() {
const { t } = useTranslation()
const { pubkey } = useNostr()
const [loading, setLoading] = useState(true)
const [relays, setRelays] = useState<[string, string[]][]>([])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setLoading(true)
const init = async () => {
if (!pubkey) return
const relays = ((await client.fetchFollowingFavoriteRelays(pubkey)) ?? []).filter(([url]) =>
isExploreBrowsableRelayUrl(url)
)
setRelays(relays)
}
init().finally(() => {
setLoading(false)
})
}, [pubkey])
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 1
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && showCount < relays.length) {
setShowCount((prev) => prev + SHOW_COUNT)
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [showCount, relays])
return (
<div className="pb-8">
{relays.slice(0, showCount).map(([url, users]) => (
<RelayItem key={url} url={url} users={users} />
))}
{showCount < relays.length && <div ref={bottomRef} />}
{loading && <RelaySimpleInfoSkeleton className="p-4" />}
{!loading && (
<div className="text-center text-muted-foreground text-sm mt-2">
{relays.length === 0 ? t('no relays found') : t('no more relays')}
</div>
)}
</div>
)
}
function RelayItem({ url, users }: { url: string; users: string[] }) {
const { navigateToRelay } = useSmartRelayNavigation()
const { relayInfo } = useFetchRelayInfo(url)
return (
<RelaySimpleInfo
key={url}
relayInfo={relayInfo}
users={users}
className="clickable p-4 border-b"
onClick={(e) => {
e.stopPropagation()
navigateToRelay(toRelay(url))
}}
/>
)
}

9
src/components/FountainEmbeddedPlayer/index.tsx

@ -8,7 +8,7 @@ import {
} from '@/lib/fountain-url' } from '@/lib/fountain-url'
import { cleanUrl } from '@/lib/url' import { cleanUrl } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useLayoutEffect, useMemo, useState } from 'react' import { useLayoutEffect, useMemo, useState } from 'react'
import LazyMediaTapPlaceholder from '../MediaPlayer/LazyMediaTapPlaceholder' import LazyMediaTapPlaceholder from '../MediaPlayer/LazyMediaTapPlaceholder'
@ -68,15 +68,14 @@ const cardShell = (className?: string) =>
export default function FountainEmbeddedPlayer({ export default function FountainEmbeddedPlayer({
url, url,
className, className,
mustLoad = false, mustLoad = false
authorPubkey
}: { }: {
url: string url: string
className?: string className?: string
mustLoad?: boolean mustLoad?: boolean
authorPubkey?: string | null
}) { }) {
const autoLoadMedia = useShouldAutoLoadMedia(authorPubkey) const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const [userClickedLoad, setUserClickedLoad] = useState(false) const [userClickedLoad, setUserClickedLoad] = useState(false)
const cleanedUrl = useMemo(() => cleanUrl(url) || url, [url]) const cleanedUrl = useMemo(() => cleanUrl(url) || url, [url])
const minHeight = useMemo(() => fountainEmbedMinHeight(cleanedUrl), [cleanedUrl]) const minHeight = useMemo(() => fountainEmbedMinHeight(cleanedUrl), [cleanedUrl])

485
src/components/GifPicker/index.tsx

@ -8,7 +8,6 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserReadInboxUrls } from '@/hooks/useUserMailboxRelayUrls' import { useUserReadInboxUrls } from '@/hooks/useUserMailboxRelayUrls'
@ -42,14 +41,6 @@ const EMPTY_FOLLOWING_PUBKEYS: readonly string[] = []
const GIFBUDDY_SEARCH_URL = (q: string) => const GIFBUDDY_SEARCH_URL = (q: string) =>
q.trim() ? `${GIFBUDDY_URL}gifsearch?q=${encodeURIComponent(q.trim())}` : GIFBUDDY_URL q.trim() ? `${GIFBUDDY_URL}gifsearch?q=${encodeURIComponent(q.trim())}` : GIFBUDDY_URL
type GifPickerTab = 'find' | 'import'
/** Shorter sheet on mobile — tall drawers fight the post composer and keyboard. */
function mobileDrawerHeightPx(): number {
const vh = window.visualViewport?.height ?? window.innerHeight
return Math.min(Math.round(vh * 0.6), Math.round(vh - 120))
}
export default function GifPicker({ export default function GifPicker({
children, children,
onSelect, onSelect,
@ -87,11 +78,6 @@ export default function GifPicker({
const [publishDescription, setPublishDescription] = useState('') const [publishDescription, setPublishDescription] = useState('')
const fileInputRef = useRef<HTMLInputElement | null>(null) const fileInputRef = useRef<HTMLInputElement | null>(null)
const gifbuddyPopupRef = useRef<Window | null>(null) const gifbuddyPopupRef = useRef<Window | null>(null)
const pickerRootRef = useRef<HTMLDivElement>(null)
const [mobileDrawerHeight, setMobileDrawerHeight] = useState<number | undefined>()
const [activeTab, setActiveTab] = useState<GifPickerTab>('find')
/** Keep drawer content mounted until Vaul's close animation finishes (avoids empty-sheet flicker). */
const [drawerContentMounted, setDrawerContentMounted] = useState(false)
const userReadRelays = useUserReadInboxUrls() const userReadRelays = useUserReadInboxUrls()
@ -181,53 +167,13 @@ export default function GifPicker({
void loadGifs() void loadGifs()
}, [open, loadGifs]) }, [open, loadGifs])
useEffect(() => {
if (!open || !isSmallScreen) return
setMobileDrawerHeight(mobileDrawerHeightPx())
}, [open, isSmallScreen])
useEffect(() => {
if (open) setDrawerContentMounted(true)
}, [open])
useEffect(() => {
if (!open) return
setActiveTab('find')
setSearchInput('')
applyLocalFilter('')
}, [open, applyLocalFilter])
const preparePickerClose = useCallback(() => {
loadGenerationRef.current += 1
setLoading(false)
const el = document.activeElement
if (el instanceof HTMLElement && pickerRootRef.current?.contains(el)) {
el.blur()
}
}, [])
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) preparePickerClose()
setOpen(next)
},
[preparePickerClose]
)
const handleDrawerAnimationEnd = useCallback((isOpen: boolean) => {
if (!isOpen) {
setDrawerContentMounted(false)
setMobileDrawerHeight(undefined)
}
}, [])
const handleSelect = useCallback( const handleSelect = useCallback(
(gif: GifMetadata) => { (gif: GifMetadata) => {
const url = (gif.fallbackUrl?.trim() || gif.url).trim() const url = (gif.fallbackUrl?.trim() || gif.url).trim()
if (!url) return if (!url) return
const desc = publishDescription.trim() const desc = publishDescription.trim()
onSelect?.(url) onSelect?.(url)
handleOpenChange(false) setOpen(false)
if (!pubkey || !/^https?:\/\//i.test(url)) return if (!pubkey || !/^https?:\/\//i.test(url)) return
// Fire-and-forget: waiting on every relay can freeze the UI when relays are down. // Fire-and-forget: waiting on every relay can freeze the UI when relays are down.
void publish(buildKind1063GifPublishDraft(url, desc), { void publish(buildKind1063GifPublishDraft(url, desc), {
@ -235,7 +181,7 @@ export default function GifPicker({
}).catch(() => {}) }).catch(() => {})
if (desc) setPublishDescription('') if (desc) setPublishDescription('')
}, },
[pubkey, onSelect, publish, gif1063PublishRelayUrls, publishDescription, handleOpenChange] [pubkey, onSelect, publish, gif1063PublishRelayUrls, publishDescription]
) )
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@ -281,7 +227,7 @@ export default function GifPicker({
/** Open GifBuddy in a new tab (not a popup) so the picker doesn't close from focus loss. Listen for postMessage in case GifBuddy adds embed support. */ /** Open GifBuddy in a new tab (not a popup) so the picker doesn't close from focus loss. Listen for postMessage in case GifBuddy adds embed support. */
const openGifBuddySearch = useCallback(() => { const openGifBuddySearch = useCallback(() => {
const url = GIFBUDDY_SEARCH_URL(pasteUrl || searchInput) const url = GIFBUDDY_SEARCH_URL(searchInput)
const w = window.open(url, '_blank', 'noopener,noreferrer') const w = window.open(url, '_blank', 'noopener,noreferrer')
gifbuddyPopupRef.current = w ?? null gifbuddyPopupRef.current = w ?? null
const handler = (event: MessageEvent) => { const handler = (event: MessageEvent) => {
@ -295,7 +241,7 @@ export default function GifPicker({
window.removeEventListener('message', handler) window.removeEventListener('message', handler)
gifbuddyPopupRef.current = null gifbuddyPopupRef.current = null
onSelect?.(urlToInsert) onSelect?.(urlToInsert)
handleOpenChange(false) setOpen(false)
} }
} }
window.addEventListener('message', handler) window.addEventListener('message', handler)
@ -304,7 +250,7 @@ export default function GifPicker({
gifbuddyPopupRef.current = null gifbuddyPopupRef.current = null
}, 10 * 60 * 1000) }, 10 * 60 * 1000)
if (w) w.addEventListener('beforeunload', () => { clearTimeout(t); window.removeEventListener('message', handler) }) if (w) w.addEventListener('beforeunload', () => { clearTimeout(t); window.removeEventListener('message', handler) })
}, [pasteUrl, searchInput, onSelect, handleOpenChange]) }, [searchInput, onSelect])
const descriptionForPublish = publishDescription.trim() const descriptionForPublish = publishDescription.trim()
@ -314,7 +260,7 @@ export default function GifPicker({
if (!url || !/^https?:\/\//i.test(url)) return if (!url || !/^https?:\/\//i.test(url)) return
onSelect?.(url) onSelect?.(url)
setPasteUrl('') setPasteUrl('')
handleOpenChange(false) setOpen(false)
if (pubkey) { if (pubkey) {
setPublishingPaste(true) setPublishingPaste(true)
try { try {
@ -328,7 +274,7 @@ export default function GifPicker({
setPublishingPaste(false) setPublishingPaste(false)
} }
} }
}, [pasteUrl, pubkey, onSelect, publish, gif1063PublishRelayUrls, descriptionForPublish, handleOpenChange]) }, [pasteUrl, pubkey, onSelect, publish, gif1063PublishRelayUrls, descriptionForPublish])
/** External GIF from a note: publish kind 1063, then insert URL and close (same relays as grid pick). */ /** External GIF from a note: publish kind 1063, then insert URL and close (same relays as grid pick). */
const handleArchiveAndInsert = useCallback( const handleArchiveAndInsert = useCallback(
@ -341,7 +287,7 @@ export default function GifPicker({
const desc = publishDescription.trim() const desc = publishDescription.trim()
setArchivingEventId(gif.eventId) setArchivingEventId(gif.eventId)
onSelect?.(url) onSelect?.(url)
handleOpenChange(false) setOpen(false)
void loadGifs(true) void loadGifs(true)
void publish(buildKind1063GifPublishDraft(url, desc), { void publish(buildKind1063GifPublishDraft(url, desc), {
specifiedRelayUrls: gif1063PublishRelayUrls specifiedRelayUrls: gif1063PublishRelayUrls
@ -352,7 +298,7 @@ export default function GifPicker({
if (desc) setPublishDescription('') if (desc) setPublishDescription('')
}) })
}, },
[pubkey, publish, gif1063PublishRelayUrls, onSelect, loadGifs, publishDescription, handleOpenChange] [pubkey, publish, gif1063PublishRelayUrls, onSelect, loadGifs, publishDescription]
) )
const gifSourceKindTitle = useCallback( const gifSourceKindTitle = useCallback(
@ -386,287 +332,210 @@ export default function GifPicker({
/** In drawer mode we constrain height and make only the GIF grid scroll so the drawer doesn't "sink" */ /** In drawer mode we constrain height and make only the GIF grid scroll so the drawer doesn't "sink" */
const isDrawer = isSmallScreen const isDrawer = isSmallScreen
const renderGifGrid = (items: GifMetadata[], showArchiveActions: boolean) => const gifGrid = loading ? (
loading ? ( <div
<div className="grid grid-cols-2 gap-1 p-2 min-h-[200px]"
className="grid grid-cols-2 gap-1 p-2 min-h-[120px]" role="status"
role="status" aria-busy="true"
aria-busy="true" aria-live="polite"
aria-live="polite" >
> {Array.from({ length: 8 }).map((_, i) => (
{Array.from({ length: 6 }).map((_, i) => ( <Skeleton key={i} className="aspect-square w-full rounded" />
<Skeleton key={i} className="aspect-square w-full rounded" /> ))}
))} </div>
</div> ) : (
) : ( <div className="grid grid-cols-2 gap-1 p-2">
<div className="grid grid-cols-2 gap-1 p-2 min-h-[120px] content-start"> {gifs.map((gif) => {
{items.map((gif) => { const showArchive = gifShouldOfferNip94Archive(gif) && isLoggedIn
const showArchive = showArchiveActions && gifShouldOfferNip94Archive(gif) && isLoggedIn return (
return ( <div key={gif.eventId} className="relative aspect-square rounded overflow-hidden">
<div <button
key={gif.eventId} type="button"
className="relative aspect-square min-h-0 w-full rounded overflow-hidden [contain:layout]" className={cn(
'absolute inset-0 z-0 rounded overflow-hidden border border-transparent hover:border-primary focus-visible:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)}
onClick={() => handleSelect(gif)}
>
<img
src={gif.url}
alt=""
className="w-full h-full object-cover pointer-events-none"
loading="lazy"
onError={(e) => {
const el = e.target as HTMLImageElement
const fallback = gif.fallbackUrl?.trim()
if (fallback && el.dataset.gifFallbackTried !== '1') {
el.dataset.gifFallbackTried = '1'
el.src = fallback
return
}
el.style.display = 'none'
}}
/>
</button>
<span
className="absolute top-1 left-1 z-10 max-w-[calc(100%-2.5rem)] truncate rounded border border-border/80 bg-background/90 px-1 py-px text-[10px] font-medium tabular-nums text-foreground backdrop-blur-sm pointer-events-none shadow-sm"
title={gifSourceKindTitle(gif)}
> >
<button {gifSourceKindShortLabel(gif)}
</span>
{showArchive && (
<Button
type="button" type="button"
className={cn( variant="secondary"
'absolute inset-0 z-0 touch-manipulation rounded overflow-hidden border border-transparent hover:border-primary focus-visible:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:opacity-80' size="icon"
className="absolute bottom-1 right-1 z-10 h-7 w-7 shadow-md"
disabled={archivingEventId === gif.eventId}
title={t(
'Publish kind 1063 (NIP-94) for this GIF and insert the URL into your post'
)} )}
onClick={() => handleSelect(gif)} aria-label={t(
> 'Publish kind 1063 (NIP-94) for this GIF and insert the URL into your post'
<img )}
src={gif.url} onClick={(e) => handleArchiveAndInsert(e, gif)}
alt=""
className="w-full h-full object-cover pointer-events-none"
loading="lazy"
decoding="async"
onError={(e) => {
const el = e.target as HTMLImageElement
const fallback = gif.fallbackUrl?.trim()
if (fallback && el.dataset.gifFallbackTried !== '1') {
el.dataset.gifFallbackTried = '1'
el.src = fallback
return
}
el.style.display = 'none'
}}
/>
</button>
<span
className="absolute top-1 left-1 z-10 max-w-[calc(100%-2.5rem)] truncate rounded border border-border/80 bg-background/90 px-1 py-px text-[10px] font-medium tabular-nums text-foreground backdrop-blur-sm pointer-events-none shadow-sm"
title={gifSourceKindTitle(gif)}
> >
{gifSourceKindShortLabel(gif)} <Download className="size-3.5" />
</span> </Button>
{showArchive && ( )}
<Button </div>
type="button" )
variant="secondary" })}
size="icon"
className="absolute bottom-1 right-1 z-10 h-7 w-7 shadow-md touch-manipulation"
disabled={archivingEventId === gif.eventId}
title={t(
'Publish kind 1063 (NIP-94) for this GIF and insert the URL into your post'
)}
aria-label={t(
'Publish kind 1063 (NIP-94) for this GIF and insert the URL into your post'
)}
onClick={(e) => handleArchiveAndInsert(e, gif)}
>
<Download className="size-3.5" />
</Button>
)}
</div>
)
})}
</div>
)
const scrollableGifGrid = (items: GifMetadata[], showArchiveActions: boolean) =>
isDrawer ? (
<div
className="page-scroll-y min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain touch-pan-y rounded-md border"
data-vaul-no-drag
>
{renderGifGrid(items, showArchiveActions)}
</div>
) : (
<ScrollArea className="h-[520px] w-full rounded-md border">
{renderGifGrid(items, showArchiveActions)}
</ScrollArea>
)
const importPanel = (
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">{t('Paste a GIF URL, upload your own file, or search GifBuddy.')}</p>
<Button
type="button"
variant="outline"
size="sm"
className="w-full touch-manipulation"
onClick={openGifBuddySearch}
>
<ExternalLink className="size-3.5 mr-1.5" />
{t('Search on GifBuddy')}
</Button>
<p className="text-xs text-muted-foreground">
{t('Opens GifBuddy in a new tab. Copy a GIF URL there, then paste it below.')}
</p>
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">{t('Paste URL of a GIF')}</Label>
<div className="flex gap-1">
<Input
placeholder="https://..."
value={pasteUrl}
onChange={(e) => setPasteUrl(e.target.value)}
className="flex-1 min-w-0"
/>
<Button
type="button"
size="sm"
className="shrink-0 touch-manipulation"
disabled={!pasteUrl.trim() || publishingPaste}
onClick={handlePasteUrlInsert}
title={t('Insert URL into your post and publish to Nostr GIF library (NIP-94).')}
>
{publishingPaste ? t('Adding…') : t('Insert')}
</Button>
</div>
</div>
{isLoggedIn && (
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">
{t('Description (optional, for search)')}
</Label>
<Input
placeholder={t('e.g. happy birthday, thumbs up')}
value={publishDescription}
onChange={(e) => setPublishDescription(e.target.value)}
className="min-w-0"
/>
</div>
)}
{isLoggedIn && (
<>
<input
ref={fileInputRef}
type="file"
accept=".gif,image/gif"
className="hidden"
onChange={handleUpload}
/>
<Button
type="button"
variant="secondary"
size="sm"
className="w-full touch-manipulation"
disabled={uploading}
onClick={triggerFileUpload}
>
{uploading ? t('Uploading...') : t('Add your own GIFs')}
</Button>
{uploadError && <p className="text-xs text-destructive text-center">{uploadError}</p>}
</>
)}
</div>
)
const findPanel = (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<p className="shrink-0 text-xs text-muted-foreground">
{t('Search your library and tap a GIF to insert.')}
</p>
<Input
placeholder={t('Search GIFs')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="shrink-0"
/>
{error && <p className="shrink-0 px-1 text-sm text-muted-foreground">{error}</p>}
{scrollableGifGrid(gifs, !isDrawer)}
</div> </div>
) )
const tabbedContent = ( const content = (
<div <div
ref={pickerRootRef}
data-gif-picker-root
className={cn( className={cn(
'flex min-w-0 w-full flex-col gap-2 p-2', 'flex min-w-0 w-full flex-col gap-2 p-2',
isDrawer ? 'min-h-0 flex-1 overflow-hidden' : 'min-w-[280px] max-w-[360px]' isDrawer ? 'min-h-0 flex-1 overflow-hidden' : 'min-w-[280px] max-w-[360px]'
)} )}
> >
<div className="flex shrink-0 items-center gap-2"> <div className="flex items-center gap-1 shrink-0">
<p className="min-w-0 flex-1 truncate text-sm font-medium">{t('Choose a GIF')}</p> <Input
placeholder={t('Search GIFs')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="flex-1"
/>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn('size-8 shrink-0', isDrawer && 'touch-manipulation')} className="shrink-0 size-8"
onClick={(e) => { onClick={() => setOpen(false)}
e.stopPropagation()
handleOpenChange(false)
}}
aria-label={t('Close')} aria-label={t('Close')}
> >
<X className="size-4" /> <X className="size-4" />
</Button> </Button>
</div> </div>
<Tabs {error && (
value={activeTab} <p className="text-sm text-muted-foreground px-1 shrink-0">{error}</p>
onValueChange={(v) => setActiveTab(v as GifPickerTab)} )}
className={cn('flex flex-col', isDrawer && 'min-h-0 flex-1')} <div
className={cn(isDrawer && 'flex min-h-0 flex-1 flex-col')}
{...(isDrawer && { 'data-vaul-no-drag': true })}
> >
<TabsList className="grid h-auto w-full shrink-0 grid-cols-2 gap-0.5 p-1"> {isDrawer ? (
<TabsTrigger <div className="page-scroll-y min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain touch-pan-y rounded-md border">
value="find" {gifGrid}
className={cn('px-1.5 py-1.5 text-xs', isDrawer && 'touch-manipulation')} </div>
> ) : (
{t('Find GIF')} <ScrollArea className="h-[520px] w-full rounded-md border">{gifGrid}</ScrollArea>
</TabsTrigger> )}
<TabsTrigger </div>
value="import" <div className="flex flex-col gap-2 border-t pt-2 shrink-0">
className={cn('px-1.5 py-1.5 text-xs', isDrawer && 'touch-manipulation')} <div className="flex flex-col gap-1.5">
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={openGifBuddySearch}
> >
{t('Import GIF')} <ExternalLink className="size-3.5 mr-1.5" />
</TabsTrigger> {t('Search on GifBuddy')}
</TabsList> </Button>
<TabsContent <p className="text-xs text-muted-foreground">
value="find" {t('Opens in a new tab. Copy a GIF URL there, then paste below. If this picker closed, click “Insert GIF” again to paste.')}
className={cn( </p>
'mt-2 data-[state=inactive]:hidden focus-visible:ring-0 focus-visible:ring-offset-0', <div className="grid gap-1">
isDrawer ? 'flex min-h-0 flex-1 flex-col' : 'flex flex-col' <Label className="text-xs text-muted-foreground">
)} {t('Paste URL of a GIF')}
> </Label>
{findPanel} <div className="flex gap-1">
</TabsContent> <Input
<TabsContent placeholder="https://..."
value="import" value={pasteUrl}
className={cn( onChange={(e) => setPasteUrl(e.target.value)}
'mt-2 data-[state=inactive]:hidden focus-visible:ring-0 focus-visible:ring-offset-0', className="flex-1 min-w-0"
isDrawer ? 'min-h-0 flex-1 overflow-y-auto overscroll-y-contain' : '' />
)} <Button
{...(isDrawer && { 'data-vaul-no-drag': true })} type="button"
> size="sm"
{importPanel} disabled={!pasteUrl.trim() || publishingPaste}
</TabsContent> onClick={handlePasteUrlInsert}
</Tabs> title={t('Insert URL into your post and publish to Nostr GIF library (NIP-94).')}
>
{publishingPaste ? t('Adding…') : t('Insert')}
</Button>
</div>
</div>
</div>
{isLoggedIn && (
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">
{t('Description (optional, for search)')}
</Label>
<Input
placeholder={t('e.g. happy birthday, thumbs up')}
value={publishDescription}
onChange={(e) => setPublishDescription(e.target.value)}
className="min-w-0"
/>
</div>
)}
{isLoggedIn && (
<>
<input
ref={fileInputRef}
type="file"
accept=".gif,image/gif"
className="hidden"
onChange={handleUpload}
/>
<Button
type="button"
variant="secondary"
size="sm"
className="w-full"
disabled={uploading}
onClick={triggerFileUpload}
>
{uploading ? t('Uploading...') : t('Add your own GIFs')}
</Button>
{uploadError && (
<p className="text-xs text-destructive text-center">{uploadError}</p>
)}
</>
)}
</div>
</div> </div>
) )
const content = tabbedContent
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<Drawer <Drawer open={open} onOpenChange={setOpen} handleOnly shouldScaleBackground={false}>
open={open}
onOpenChange={handleOpenChange}
onAnimationEnd={handleDrawerAnimationEnd}
handleOnly
shouldScaleBackground={false}
repositionInputs={false}
>
<DrawerTrigger asChild>{children}</DrawerTrigger> <DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent <DrawerContent
dragHandle="vaul" dragHandle="vaul"
portalContainer={portalContainer} portalContainer={portalContainer}
className="px-2 pb-2" className="max-h-[min(88dvh,calc(100dvh-5rem))] px-2 pb-2"
style={
mobileDrawerHeight != null
? { height: mobileDrawerHeight, maxHeight: mobileDrawerHeight }
: { maxHeight: 'min(60dvh, calc(100dvh - 8rem))' }
}
onPointerDownOutside={(e) => {
const t = e.target as HTMLElement | null
if (t?.closest?.('[data-vaul-overlay]')) return
e.preventDefault()
}}
> >
<DrawerHeader className="sr-only"> <DrawerHeader className="sr-only">
<DrawerTitle>{t('Choose a GIF')}</DrawerTitle> <DrawerTitle>{t('Choose a GIF')}</DrawerTitle>
</DrawerHeader> </DrawerHeader>
<div className="flex h-full min-h-0 w-full min-w-0 max-w-[100vw] flex-col overflow-hidden"> <div className="flex min-h-0 w-full min-w-0 max-w-[100vw] flex-1 flex-col overflow-hidden">
{drawerContentMounted ? content : null} {content}
</div> </div>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
@ -674,7 +543,7 @@ export default function GifPicker({
} }
return ( return (
<DropdownMenu open={open} onOpenChange={handleOpenChange}> <DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="p-0" portalContainer={portalContainer}> <DropdownMenuContent side="top" className="p-0" portalContainer={portalContainer}>
{content} {content}

145
src/components/HelpAndAccountMenu.tsx

@ -11,14 +11,7 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey'
accountPubkeyToHex,
formatPubkey,
formatNpub,
generateImageByPubkey,
hexPubkeysEqual,
pubkeyToNpub
} from '@/lib/pubkey'
import { isVideo } from '@/lib/url' import { isVideo } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { openBrowseCacheFromRegistry } from '@/contexts/cache-browser-context' import { openBrowseCacheFromRegistry } from '@/contexts/cache-browser-context'
@ -27,26 +20,11 @@ import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSmartSettingsNavigation } from '@/PageManager' import { useSmartSettingsNavigation } from '@/PageManager'
import { useFetchProfile } from '@/hooks/useFetchProfile' import { useFetchProfile } from '@/hooks/useFetchProfile'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { AccountQuickSwitchMenuItems } from '@/components/AccountQuickSwitchMenuItems' import { ActiveRelaysDropdownSection } from '@/components/ConnectedRelays/ActiveRelaysDropdownSection'
import { AnonUserAvatar } from '@/components/AnonUserAvatar' import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows'
import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator'
import { ArrowDownUp, Database, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react' import { ArrowDownUp, Database, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react'
import { useCallback, useMemo, useState, type ReactNode } from 'react' import { useCallback, useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { TProfile } from '@/types'
/** Profile for the badge only when it belongs to the active session pubkey (avoids stale name/avatar). */
function profileForActivePubkey(
pubkey: string | undefined,
nostrProfile: TProfile | null,
fetchedProfile: TProfile | null
): TProfile | null {
const pk = pubkey ? accountPubkeyToHex(pubkey) : null
if (!pk) return null
if (fetchedProfile && hexPubkeysEqual(fetchedProfile.pubkey, pk)) return fetchedProfile
if (nostrProfile && hexPubkeysEqual(nostrProfile.pubkey, pk)) return nostrProfile
return null
}
const titlebarAccountMenuContentClassName = const titlebarAccountMenuContentClassName =
'z-[220] w-[min(18rem,calc(100vw-1.5rem))] overflow-y-auto overscroll-contain' 'z-[220] w-[min(18rem,calc(100vw-1.5rem))] overflow-y-auto overscroll-contain'
@ -56,42 +34,22 @@ export type HelpAndAccountMenuVariant = 'sidebar' | 'titlebar'
function AccountDropdownItems({ function AccountDropdownItems({
onSwitchAccount, onSwitchAccount,
onLogoutClick, onLogoutClick,
onBrowseCache, onBrowseCache
onCloseMenu
}: { }: {
onSwitchAccount: () => void onSwitchAccount: () => void
onLogoutClick: () => void onLogoutClick: () => void
onBrowseCache: () => void onBrowseCache: () => void
onCloseMenu?: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate } = usePrimaryPage() const { navigate } = usePrimaryPage()
const { isAnonSession } = useNostr()
const anonIdentityDisabled = t('accountSwitch.anonIdentityDisabled')
return ( return (
<> <>
<ReadOnlySessionIndicator variant="menu" /> <DropdownMenuItem onClick={() => navigate('profile')}>
<AccountQuickSwitchMenuItems onAfterSwitch={onCloseMenu} />
<DropdownMenuItem
disabled={isAnonSession}
title={isAnonSession ? anonIdentityDisabled : undefined}
onClick={() => {
if (isAnonSession) return
navigate('profile')
}}
>
<User className="size-4" /> <User className="size-4" />
{t('Profile')} {t('Profile')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem onClick={() => navigate('settings')}>
disabled={isAnonSession}
title={isAnonSession ? anonIdentityDisabled : undefined}
onClick={() => {
if (isAnonSession) return
navigate('settings')
}}
>
<Settings className="size-4" /> <Settings className="size-4" />
{t('Settings')} {t('Settings')}
</DropdownMenuItem> </DropdownMenuItem>
@ -99,6 +57,7 @@ function AccountDropdownItems({
<Database className="size-4" /> <Database className="size-4" />
{t('Browse Cache')} {t('Browse Cache')}
</DropdownMenuItem> </DropdownMenuItem>
<ActiveRelaysDropdownSection />
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={onSwitchAccount}> <DropdownMenuItem onClick={onSwitchAccount}>
<ArrowDownUp className="size-4" /> <ArrowDownUp className="size-4" />
@ -122,28 +81,22 @@ function SidebarAccountMenu({
onBrowseCache: () => void onBrowseCache: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { account, profile, isAnonSession } = useNostr() const { account, profile } = useNostr()
const { current, display } = usePrimaryPage() const { current, display } = usePrimaryPage()
const [menuOpen, setMenuOpen] = useState(false)
const pubkey = account?.pubkey const pubkey = account?.pubkey
const { profile: fetchedProfile } = useFetchProfile(isAnonSession ? undefined : pubkey) const { profile: fetchedProfile } = useFetchProfile(pubkey)
const resolvedProfile = useMemo(
() => (isAnonSession ? null : profileForActivePubkey(pubkey, profile, fetchedProfile)),
[pubkey, profile, fetchedProfile, isAnonSession]
)
const active = useMemo(() => current === 'profile' && display, [display, current]) const active = useMemo(() => current === 'profile' && display, [display, current])
if (!pubkey && !isAnonSession) return null if (!pubkey) return null
const defaultAvatar = pubkey ? generateImageByPubkey(pubkey) : '' const defaultAvatar = generateImageByPubkey(pubkey)
const npub = pubkey ? pubkeyToNpub(pubkey) : null const npub = pubkeyToNpub(pubkey)
const fallbackUsername = npub ? formatNpub(npub) : pubkey ? formatPubkey(pubkey) : t('accountSwitch.anon') const fallbackUsername = npub ? formatNpub(npub) : formatPubkey(pubkey)
const { username, avatar } = resolvedProfile const resolvedProfile = fetchedProfile ?? profile
? { username: resolvedProfile.username, avatar: resolvedProfile.avatar ?? defaultAvatar } const { username, avatar } = resolvedProfile || { username: fallbackUsername, avatar: defaultAvatar }
: { username: fallbackUsername, avatar: defaultAvatar }
return ( return (
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
type="button" type="button"
@ -156,23 +109,19 @@ function SidebarAccountMenu({
active && 'bg-accent/50' active && 'bg-accent/50'
)} )}
> >
{isAnonSession ? ( {isVideo(avatar ?? '') ? (
<AnonUserAvatar size="medium" className="size-8 shrink-0" />
) : isVideo(avatar ?? '') ? (
<div className="size-8 shrink-0 overflow-hidden rounded-full"> <div className="size-8 shrink-0 overflow-hidden rounded-full">
<video src={avatar} className="h-full w-full object-cover object-center" autoPlay muted loop playsInline /> <video src={avatar} className="h-full w-full object-cover object-center" autoPlay muted loop playsInline />
</div> </div>
) : ( ) : (
<Avatar className="size-8 shrink-0" key={pubkey}> <Avatar className="size-8 shrink-0">
<AvatarImage src={avatar || defaultAvatar} className="object-cover object-center" /> <AvatarImage src={avatar || defaultAvatar} className="object-cover object-center" />
<AvatarFallback delayMs={0}> <AvatarFallback delayMs={0}>
<AvatarIdenticon src={defaultAvatar} /> <AvatarIdenticon src={defaultAvatar} />
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
)} )}
<span className="truncate max-xl:hidden"> <span className="truncate max-xl:hidden">{username}</span>
{isAnonSession ? t('accountSwitch.anon') : username}
</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end" className="z-[220]"> <DropdownMenuContent side="top" align="end" className="z-[220]">
@ -180,7 +129,6 @@ function SidebarAccountMenu({
onSwitchAccount={onSwitchAccount} onSwitchAccount={onSwitchAccount}
onLogoutClick={onLogoutClick} onLogoutClick={onLogoutClick}
onBrowseCache={onBrowseCache} onBrowseCache={onBrowseCache}
onCloseMenu={() => setMenuOpen(false)}
/> />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -197,15 +145,11 @@ function TitlebarAccountMenu({
onBrowseCache: () => void onBrowseCache: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { account, profile, isAnonSession } = useNostr() const { account, profile } = useNostr()
const pubkey = account?.pubkey const pubkey = account?.pubkey
const { profile: fetchedProfile } = useFetchProfile(isAnonSession ? undefined : pubkey) const { profile: fetchedProfile } = useFetchProfile(pubkey)
const resolvedProfile = useMemo( const resolvedProfile = fetchedProfile ?? profile
() => (isAnonSession ? null : profileForActivePubkey(pubkey, profile, fetchedProfile)),
[pubkey, profile, fetchedProfile, isAnonSession]
)
const { current, display } = usePrimaryPage() const { current, display } = usePrimaryPage()
const [menuOpen, setMenuOpen] = useState(false)
const defaultAvatar = useMemo( const defaultAvatar = useMemo(
() => (resolvedProfile?.pubkey ? generateImageByPubkey(resolvedProfile.pubkey) : ''), () => (resolvedProfile?.pubkey ? generateImageByPubkey(resolvedProfile.pubkey) : ''),
[resolvedProfile] [resolvedProfile]
@ -213,7 +157,7 @@ function TitlebarAccountMenu({
const active = useMemo(() => current === 'profile' && display, [display, current]) const active = useMemo(() => current === 'profile' && display, [display, current])
return ( return (
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
@ -222,15 +166,13 @@ function TitlebarAccountMenu({
title={t('Account menu')} title={t('Account menu')}
aria-label={t('Account menu')} aria-label={t('Account menu')}
> >
{isAnonSession ? ( {resolvedProfile ? (
<AnonUserAvatar size="small" className="size-6" />
) : resolvedProfile ? (
isVideo(resolvedProfile.avatar ?? '') ? ( isVideo(resolvedProfile.avatar ?? '') ? (
<div className={cn('w-6 h-6 overflow-hidden rounded-full', active ? 'ring-primary ring-1' : '')}> <div className={cn('w-6 h-6 overflow-hidden rounded-full', active ? 'ring-primary ring-1' : '')}>
<video src={resolvedProfile.avatar} className="h-full w-full object-cover object-center" autoPlay muted loop playsInline /> <video src={resolvedProfile.avatar} className="h-full w-full object-cover object-center" autoPlay muted loop playsInline />
</div> </div>
) : ( ) : (
<Avatar className={cn('w-6 h-6', active ? 'ring-primary ring-1' : '')} key={pubkey}> <Avatar className={cn('w-6 h-6', active ? 'ring-primary ring-1' : '')}>
<AvatarImage <AvatarImage
src={resolvedProfile.avatar || defaultAvatar} src={resolvedProfile.avatar || defaultAvatar}
className="object-cover object-center" className="object-cover object-center"
@ -250,7 +192,6 @@ function TitlebarAccountMenu({
onSwitchAccount={onSwitchAccount} onSwitchAccount={onSwitchAccount}
onLogoutClick={onLogoutClick} onLogoutClick={onLogoutClick}
onBrowseCache={onBrowseCache} onBrowseCache={onBrowseCache}
onCloseMenu={() => setMenuOpen(false)}
/> />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -259,17 +200,37 @@ function TitlebarAccountMenu({
function LoggedOutTitlebarMenu({ onLogin }: { onLogin: () => void }) { function LoggedOutTitlebarMenu({ onLogin }: { onLogin: () => void }) {
const { t } = useTranslation() const { t } = useTranslation()
const { rows } = useRelayConnectionRows()
if (rows.length === 0) {
return (
<Button variant="ghost" size="titlebar-icon" onClick={onLogin} title={t('Login')}>
<UserRound />
</Button>
)
}
return ( return (
<Button variant="ghost" size="titlebar-icon" onClick={onLogin} title={t('Login')}> <DropdownMenu>
<UserRound /> <DropdownMenuTrigger asChild>
</Button> <Button variant="ghost" size="titlebar-icon" title={t('Login')} aria-label={t('Login')}>
<UserRound />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className={titlebarAccountMenuContentClassName}>
<DropdownMenuItem onClick={onLogin}>
<LogIn className="size-4" />
{t('Login')}
</DropdownMenuItem>
<ActiveRelaysDropdownSection />
</DropdownMenuContent>
</DropdownMenu>
) )
} }
/** Sidebar: account / login stack. Titlebar (mobile): compact account or login control. */ /** Sidebar: account / login stack. Titlebar (mobile): compact account or login control. */
export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) { export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) {
const { pubkey, checkLogin, isNip07LoginInFlight, isAnonSession } = useNostr() const { pubkey, checkLogin } = useNostr()
const { navigateToSettings } = useSmartSettingsNavigation() const { navigateToSettings } = useSmartSettingsNavigation()
const onBrowseCache = useCallback(() => { const onBrowseCache = useCallback(() => {
if (!openBrowseCacheFromRegistry()) { if (!openBrowseCacheFromRegistry()) {
@ -280,7 +241,7 @@ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccoun
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
let account: ReactNode let account: ReactNode
if (pubkey || isAnonSession) { if (pubkey) {
account = account =
variant === 'sidebar' ? ( variant === 'sidebar' ? (
<SidebarAccountMenu <SidebarAccountMenu
@ -313,11 +274,7 @@ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccoun
<div className={wrapClass}> <div className={wrapClass}>
{account} {account}
</div> </div>
<LoginDialog <LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
open={loginDialogOpen}
setOpen={setLoginDialogOpen}
blockClose={isNip07LoginInFlight}
/>
<LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} /> <LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} />
</> </>
) )

86
src/components/Image/index.tsx

@ -11,7 +11,7 @@ import {
import { TImetaInfo } from '@/types' import { TImetaInfo } from '@/types'
import { blurHashPlaceholderForMediaUrl } from '@/lib/media-placeholder-blurhash' import { blurHashPlaceholderForMediaUrl } from '@/lib/media-placeholder-blurhash'
import { decode } from 'blurhash' import { decode } from 'blurhash'
import { Image as ImageIcon, ImageOff } from 'lucide-react' import { ImageOff } from 'lucide-react'
import { import {
CSSProperties, CSSProperties,
HTMLAttributes, HTMLAttributes,
@ -22,7 +22,7 @@ import {
useRef, useRef,
useState useState
} from 'react' } from 'react'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
/** Browsers often never fire `onError` for invalid URIs, ORB, or stalled fetches — this forces a visible error. */ /** Browsers often never fire `onError` for invalid URIs, ORB, or stalled fetches — this forces a visible error. */
@ -65,18 +65,15 @@ function extensionWithDotFromUrl(url: string): string {
} }
export default function Image({ export default function Image({
image: { url, blurHash, dim, alt: imetaAlt, fallback, size: fileSizeBytes, x: imetaHash, pubkey }, image: { url, blurHash, dim, alt: imetaAlt, fallback, size: fileSizeBytes, x: imetaHash },
alt, alt,
className = '', className = '',
classNames = {}, classNames = {},
hideIfError = false, hideIfError = false,
/** Called after internal URL fallbacks are exhausted and the image still failed to load. */
onFinalError,
errorPlaceholder = <ImageOff />, errorPlaceholder = <ImageOff />,
style: wrapperStyleProp, style: wrapperStyleProp,
holdUntilClick = false, holdUntilClick = false,
fetchPriority, fetchPriority,
loading = 'eager',
onClick, onClick,
showAltCaption = false, showAltCaption = false,
caption, caption,
@ -97,12 +94,9 @@ export default function Image({
/** Caption below the image; defaults to resolved alt when {@link showAltCaption} is true. */ /** Caption below the image; defaults to resolved alt when {@link showAltCaption} is true. */
caption?: string caption?: string
hideIfError?: boolean hideIfError?: boolean
onFinalError?: () => void
errorPlaceholder?: React.ReactNode errorPlaceholder?: React.ReactNode
/** Passed to the inner `<img>` (e.g. profile banner vs avatar load order). */ /** Passed to the inner `<img>` (e.g. profile banner vs avatar load order). */
fetchPriority?: 'high' | 'low' | 'auto' fetchPriority?: 'high' | 'low' | 'auto'
/** Native lazy loading — use `lazy` for below-the-fold grids; default `eager` for feeds. */
loading?: 'lazy' | 'eager'
/** /**
* When true, the full image is not loaded until the user interacts. * When true, the full image is not loaded until the user interacts.
* The first click runs {@link onClick} (e.g. open lightbox) and also reveals the * The first click runs {@link onClick} (e.g. open lightbox) and also reveals the
@ -114,9 +108,10 @@ export default function Image({
holdUntilClick?: boolean holdUntilClick?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const autoLoadForAuthor = useShouldAutoLoadMedia(pubkey) const contentPolicy = useContentPolicyOptional()
/** Tap-to-load only if the parent asked and policy allows (or there is no policy — trust the parent). */ /** Tap-to-load only if the parent asked and policy allows (or there is no policy — trust the parent). */
const effectiveHoldUntilClick = holdUntilClick && !autoLoadForAuthor const effectiveHoldUntilClick =
holdUntilClick && (contentPolicy !== undefined ? !contentPolicy.autoLoadMedia : true)
const urlOk = !!url?.trim() const urlOk = !!url?.trim()
const [revealed, setRevealed] = useState(!effectiveHoldUntilClick) const [revealed, setRevealed] = useState(!effectiveHoldUntilClick)
@ -135,8 +130,6 @@ export default function Image({
const imgRef = useRef<HTMLImageElement | null>(null) const imgRef = useRef<HTMLImageElement | null>(null)
/** Deduplicate onLoad vs sync cache hit vs decode() — otherwise blurhash can stick when `onLoad` never runs. */ /** Deduplicate onLoad vs sync cache hit vs decode() — otherwise blurhash can stick when `onLoad` never runs. */
const loadSettledRef = useRef(false) const loadSettledRef = useRef(false)
/** When imeta has no `dim`, reserve space from decoded natural size to avoid mobile layout shift. */
const [intrinsicDim, setIntrinsicDim] = useState<{ width: number; height: number } | undefined>()
const finalAlt = imetaAlt || alt const finalAlt = imetaAlt || alt
const imgTitle = const imgTitle =
@ -158,21 +151,6 @@ export default function Image({
const badSrc = !imageUrl?.trim() || !isRenderableMediaUrl(imageUrl.trim()) const badSrc = !imageUrl?.trim() || !isRenderableMediaUrl(imageUrl.trim())
const showErrorState = hasError || badSrc const showErrorState = hasError || badSrc
const effectiveDim =
dim && dim.width > 0 && dim.height > 0 ? dim : intrinsicDim
const captureIntrinsicDim = useCallback(
(el: HTMLImageElement) => {
if (dim && dim.width > 0 && dim.height > 0) return
const w = el.naturalWidth
const h = el.naturalHeight
if (w <= 0 || h <= 0) return
setIntrinsicDim((prev) =>
prev?.width === w && prev?.height === h ? prev : { width: w, height: h }
)
},
[dim]
)
/** NIP-94 blurhash when present; otherwise a stable URL-derived placeholder (many events omit blurhash). */ /** NIP-94 blurhash when present; otherwise a stable URL-derived placeholder (many events omit blurhash). */
const effectiveBlurHash = useMemo(() => { const effectiveBlurHash = useMemo(() => {
@ -193,7 +171,6 @@ export default function Image({
useEffect(() => { useEffect(() => {
setImageUrl(resolvePrimalBlossomPlayableUrl(url ?? '')) setImageUrl(resolvePrimalBlossomPlayableUrl(url ?? ''))
loadSettledRef.current = false loadSettledRef.current = false
setIntrinsicDim(undefined)
wasInitiallyHeldRef.current = effectiveHoldUntilClick wasInitiallyHeldRef.current = effectiveHoldUntilClick
const shouldHold = effectiveHoldUntilClick const shouldHold = effectiveHoldUntilClick
const sessionRevealed = Boolean(url?.trim() && wasMediaUrlRevealed(url)) const sessionRevealed = Boolean(url?.trim() && wasMediaUrlRevealed(url))
@ -218,14 +195,12 @@ export default function Image({
if (loadSettledRef.current) return if (loadSettledRef.current) return
loadSettledRef.current = true loadSettledRef.current = true
clearLoadWatch() clearLoadWatch()
const el = imgRef.current
if (el) captureIntrinsicDim(el)
setIsLoading(false) setIsLoading(false)
setHasError(false) setHasError(false)
// Unmount blurhash/skeleton immediately — keeping z-10 overlay (even at opacity-0) leaves bg-muted/40 // Unmount blurhash/skeleton immediately — keeping z-10 overlay (even at opacity-0) leaves bg-muted/40
// and canvas layers visible as odd tinted bands until delayed teardown. // and canvas layers visible as odd tinted bands until delayed teardown.
setDisplaySkeleton(false) setDisplaySkeleton(false)
}, [captureIntrinsicDim]) }, [])
// Cached images are often `complete` before `onLoad` is attached (feed mounts many cards at once). // Cached images are often `complete` before `onLoad` is attached (feed mounts many cards at once).
useLayoutEffect(() => { useLayoutEffect(() => {
@ -233,10 +208,19 @@ export default function Image({
const el = imgRef.current const el = imgRef.current
if (!el) return if (!el) return
if (el.complete && el.naturalWidth > 0) { if (el.complete && el.naturalWidth > 0) {
captureIntrinsicDim(el)
notifyLoaded() notifyLoaded()
return
} }
}, [revealed, badSrc, imageUrl, notifyLoaded, captureIntrinsicDim]) if (typeof el.decode === 'function') {
let cancelled = false
el.decode().then(() => {
if (!cancelled && el.naturalWidth > 0) notifyLoaded()
}).catch(() => {})
return () => {
cancelled = true
}
}
}, [revealed, badSrc, imageUrl, notifyLoaded])
useEffect(() => { useEffect(() => {
clearLoadWatch() clearLoadWatch()
@ -294,7 +278,6 @@ export default function Image({
setIsLoading(false) setIsLoading(false)
setDisplaySkeleton(false) setDisplaySkeleton(false)
setHasError(true) setHasError(true)
onFinalError?.()
} }
const handleLoad = () => { const handleLoad = () => {
@ -302,9 +285,9 @@ export default function Image({
} }
const reserveStyle = wrapperReserveStyle( const reserveStyle = wrapperReserveStyle(
effectiveDim, dim,
showErrorState, showErrorState,
displaySkeleton && !showErrorState && !effectiveDim displaySkeleton && !showErrorState
) )
const mergedWrapperStyle: CSSProperties | undefined = const mergedWrapperStyle: CSSProperties | undefined =
reserveStyle || wrapperStyleProp reserveStyle || wrapperStyleProp
@ -325,21 +308,16 @@ export default function Image({
} }
const hasHoverTip = Boolean(imgTitle) const hasHoverTip = Boolean(imgTitle)
const showTapToRevealChrome = !showErrorState && !revealed && effectiveHoldUntilClick
const tapToRevealLabel = t('Click to load image')
return ( return (
<span className={cn('block w-full not-prose', classNames.wrapper)}> <span className={cn('block w-full not-prose', classNames.wrapper)}>
<span <span
className={cn( className={cn(
'relative overflow-hidden block w-full rounded-lg bg-background', 'relative overflow-hidden block w-full rounded-lg bg-background',
showTapToRevealChrome && 'group cursor-zoom-in',
hasHoverTip && 'cursor-help ring-1 ring-inset ring-dotted ring-muted-foreground/45' hasHoverTip && 'cursor-help ring-1 ring-inset ring-dotted ring-muted-foreground/45'
)} )}
style={mergedWrapperStyle} style={mergedWrapperStyle}
title={showTapToRevealChrome ? tapToRevealLabel : imgTitle} title={imgTitle}
role={showTapToRevealChrome ? 'button' : undefined}
aria-label={showTapToRevealChrome ? tapToRevealLabel : undefined}
onClick={handleWrapperClick} onClick={handleWrapperClick}
{...props} {...props}
> >
@ -373,27 +351,15 @@ export default function Image({
)} )}
</span> </span>
)} )}
{showTapToRevealChrome && (
<>
<span
className="absolute inset-0 z-[15] bg-gradient-to-t from-black/55 via-black/25 to-black/15 pointer-events-none"
aria-hidden
/>
<span className="absolute inset-0 z-[20] grid place-items-center pointer-events-none" aria-hidden>
<span className="flex size-14 items-center justify-center rounded-full bg-black/55 text-white shadow-md backdrop-blur-[2px] transition-transform group-hover:scale-105">
<ImageIcon className="size-7" strokeWidth={2} />
</span>
</span>
</>
)}
{!showErrorState && revealed && ( {!showErrorState && revealed && (
<img <img
ref={imgRef} ref={imgRef}
src={imageUrl} src={imageUrl}
alt={finalAlt} alt={finalAlt}
referrerPolicy="no-referrer-when-downgrade" referrerPolicy="no-referrer-when-downgrade"
decoding="async" decoding={effectiveHoldUntilClick ? 'async' : 'sync'}
loading={loading} // `lazy` often never starts the request inside nested feed scrollers; always-load should fetch eagerly.
loading="eager"
{...(fetchPriority ? { fetchpriority: fetchPriority } : {})} {...(fetchPriority ? { fetchpriority: fetchPriority } : {})}
draggable={false} draggable={false}
onLoad={handleLoad} onLoad={handleLoad}
@ -403,8 +369,8 @@ export default function Image({
isLoading ? 'opacity-0' : 'opacity-100', isLoading ? 'opacity-0' : 'opacity-100',
className className
)} )}
width={effectiveDim?.width ?? dim?.width} width={dim?.width}
height={effectiveDim?.height ?? dim?.height} height={dim?.height}
/> />
)} )}
{showErrorState && ( {showErrorState && (

9
src/components/ImageGallery/index.tsx

@ -1,7 +1,7 @@
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import modalManager from '@/services/modal-manager.service' import modalManager from '@/services/modal-manager.service'
import { TImetaInfo } from '@/types' import { TImetaInfo } from '@/types'
import { ReactNode, useEffect, useLayoutEffect, useMemo, useState } from 'react' import { ReactNode, useEffect, useLayoutEffect, useMemo, useState } from 'react'
@ -23,18 +23,17 @@ export default function ImageGallery({
images, images,
start = 0, start = 0,
end = images.length, end = images.length,
mustLoad = false, mustLoad = false
authorPubkey
}: { }: {
className?: string className?: string
images: TImetaInfo[] images: TImetaInfo[]
start?: number start?: number
end?: number end?: number
mustLoad?: boolean mustLoad?: boolean
authorPubkey?: string | null
}) { }) {
const id = useMemo(() => `image-gallery-${randomString()}`, []) const id = useMemo(() => `image-gallery-${randomString()}`, [])
const autoLoadMedia = useShouldAutoLoadMedia(authorPubkey ?? images[start]?.pubkey) const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const [index, setIndex] = useState(-1) const [index, setIndex] = useState(-1)
const [lightboxPortalActive, setLightboxPortalActive] = useState(false) const [lightboxPortalActive, setLightboxPortalActive] = useState(false)

5
src/components/ImageWithLightbox/index.tsx

@ -1,6 +1,6 @@
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import modalManager from '@/services/modal-manager.service' import modalManager from '@/services/modal-manager.service'
import { TImetaInfo } from '@/types' import { TImetaInfo } from '@/types'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
@ -28,7 +28,8 @@ export default function ImageWithLightbox({
mustLoad?: boolean mustLoad?: boolean
}) { }) {
const id = useMemo(() => `image-with-lightbox-${randomString()}`, []) const id = useMemo(() => `image-with-lightbox-${randomString()}`, [])
const autoLoadMedia = useShouldAutoLoadMedia(image.pubkey) const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const [index, setIndex] = useState(-1) const [index, setIndex] = useState(-1)
const [lightboxPortalActive, setLightboxPortalActive] = useState(false) const [lightboxPortalActive, setLightboxPortalActive] = useState(false)

7
src/components/KindFilter/index.tsx

@ -168,7 +168,7 @@ export default function KindFilter({
size="titlebar-icon" size="titlebar-icon"
aria-label={t('Filter')} aria-label={t('Filter')}
className={cn( className={cn(
'relative shrink-0 focus:text-foreground', 'relative h-8 w-fit shrink-0 px-1.5 text-xs focus:text-foreground',
!isDifferentFromSaved && !feedKindFilterBypass && 'text-muted-foreground', !isDifferentFromSaved && !feedKindFilterBypass && 'text-muted-foreground',
feedKindFilterBypass && 'text-amber-600 dark:text-amber-400' feedKindFilterBypass && 'text-amber-600 dark:text-amber-400'
)} )}
@ -178,9 +178,10 @@ export default function KindFilter({
} }
}} }}
> >
<ListFilter className="size-5 shrink-0" /> <ListFilter className="size-3.5 shrink-0" />
<span className="ml-1 hidden min-[352px]:inline">{t('Filter')}</span>
{isDifferentFromSaved && ( {isDifferentFromSaved && (
<div className="absolute size-1.5 rounded-full bg-primary right-1.5 top-1.5 ring-1 ring-background" /> <div className="absolute size-1.5 rounded-full bg-primary left-6 top-1.5 ring-1 ring-background" />
)} )}
</Button> </Button>
) )

152
src/components/Library/LibraryPublicationGrid.tsx

@ -1,152 +0,0 @@
import PublicationCard from '@/components/Note/PublicationCard'
import { Skeleton } from '@/components/ui/skeleton'
import { libraryPublicationGridColumnClass, usePanelMode } from '@/hooks/usePanelMode'
import type { LibraryPublicationEntry } from '@/lib/library-publication-index'
import { eventTagAddress } from '@/lib/publication-index'
import { isBooklistNip32Label } from '@/lib/nip32-label'
import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { BookOpen, Bookmark, Highlighter, MessageSquare, Pin, Tag } from 'lucide-react'
import { useTranslation } from 'react-i18next'
function LabelBadgeIcon({ name }: { name: string }) {
if (isBooklistNip32Label(name)) {
return <BookOpen className="size-3" aria-hidden />
}
return <Tag className="size-3" aria-hidden />
}
function EngagementBadges({ entry }: { entry: LibraryPublicationEntry }) {
const { t } = useTranslation()
const otherLabels = entry.labelNames.filter((name) => !isBooklistNip32Label(name))
if (
!entry.hasBooklistLabel &&
otherLabels.length === 0 &&
!entry.hasLabel &&
!entry.hasComment &&
!entry.hasHighlight &&
!entry.hasBookmark &&
!entry.hasPin
) {
return null
}
return (
<div className="flex flex-wrap gap-2 px-1 pb-2">
{entry.hasBooklistLabel ? (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs',
entry.hasMyBooklistLabel
? 'bg-green-500/15 text-green-700 ring-1 ring-green-600/30 dark:bg-green-500/20 dark:text-green-400 dark:ring-green-500/40'
: 'bg-muted text-muted-foreground'
)}
title={
entry.hasMyBooklistLabel
? t('Library badge my booklist')
: t('Library badge booklist')
}
>
<BookOpen className="size-3" aria-hidden />
<span className="sr-only">
{entry.hasMyBooklistLabel
? t('Library badge my booklist')
: t('Library badge booklist')}
</span>
</span>
) : null}
{entry.hasLabel &&
(otherLabels.length > 0 ? (
otherLabels.map((name) => (
<span
key={name}
className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
<LabelBadgeIcon name={name} />
{name}
</span>
))
) : (
!entry.hasBooklistLabel ? (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
<Tag className="size-3" aria-hidden />
{t('Library badge label')}
</span>
) : null
))}
{entry.hasComment && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
<MessageSquare className="size-3" aria-hidden />
{t('Library badge comment')}
</span>
)}
{entry.hasHighlight && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
<Highlighter className="size-3" aria-hidden />
{t('Library badge highlight')}
</span>
)}
{entry.hasBookmark && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
<Bookmark className="size-3" aria-hidden />
{t('Library badge bookmark')}
</span>
)}
{entry.hasPin && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
<Pin className="size-3" aria-hidden />
{t('Library badge pin')}
</span>
)}
</div>
)
}
export default function LibraryPublicationGrid({
entries,
loading,
emptyMessage
}: {
entries: LibraryPublicationEntry[]
loading?: boolean
emptyMessage?: string
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const panelMode = usePanelMode()
const gridCols = libraryPublicationGridColumnClass(isSmallScreen, panelMode)
if (loading) {
return (
<div className={cn('grid gap-4', gridCols)}>
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-48 w-full rounded-lg" />
))}
</div>
)
}
if (entries.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
{emptyMessage ?? t('Library empty')}
</div>
)
}
return (
<div className={cn('grid gap-4', gridCols)}>
{entries.map((entry) => (
<div
key={eventTagAddress(entry.event) ?? entry.event.id}
className={cn(
'flex min-w-0 flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden'
)}
>
<PublicationCard event={entry.event} presentation="library" className="border-0 shadow-none rounded-none" />
<EngagementBadges entry={entry} />
</div>
))}
</div>
)
}

77
src/components/Library/LibrarySearchBar.tsx

@ -1,77 +0,0 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Loader2, Search, Wifi } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function LibrarySearchBar({
searchQuery,
onSearchQueryChange,
showOnlyMine,
onShowOnlyMineChange,
mineFilterLoading,
onSearchRelays,
relaySearchLoading,
disabled
}: {
searchQuery: string
onSearchQueryChange: (value: string) => void
showOnlyMine: boolean
onShowOnlyMineChange: (value: boolean) => void
mineFilterLoading?: boolean
onSearchRelays?: () => void
relaySearchLoading?: boolean
disabled?: boolean
}) {
const { t } = useTranslation()
const canSearchRelays = searchQuery.trim().length > 0 && !relaySearchLoading
return (
<div className="space-y-3">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
placeholder={t('Library search placeholder')}
className="pl-9"
disabled={disabled}
aria-label={t('Library search placeholder')}
/>
</div>
{onSearchRelays ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={disabled || !canSearchRelays}
onClick={onSearchRelays}
>
{relaySearchLoading ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<Wifi className="size-4" aria-hidden />
)}
{t('Library search relays')}
</Button>
) : null}
<div className="flex items-center gap-2">
<Switch
id="library-show-mine"
checked={showOnlyMine}
onCheckedChange={onShowOnlyMineChange}
disabled={disabled}
/>
<Label htmlFor="library-show-mine" className="text-sm text-muted-foreground cursor-pointer">
{t('Library show only my publications')}
</Label>
{mineFilterLoading ? (
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : null}
</div>
</div>
)
}

92
src/components/LibraryIndexCacheSettings/index.tsx

@ -1,92 +0,0 @@
import { Button } from '@/components/ui/button'
import { getLibraryIndexCacheFootprint, getLibraryIndexCacheBudget } from '@/lib/library-index-idb-cache'
import { clearAllLibraryIndexCaches } from '@/lib/library-publication-index'
import { isImwaldElectron, isMobileBrowserProfile } from '@/lib/client-platform'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
function formatMb(bytes: number): string {
return (bytes / (1024 * 1024)).toFixed(1)
}
function platformLabel(): string {
if (isImwaldElectron()) return 'desktop-app'
if (isMobileBrowserProfile()) return 'mobile-web'
return 'desktop-web'
}
export default function LibraryIndexCacheSettings() {
const { t } = useTranslation()
const [footprint, setFootprint] = useState<{ count: number; bytes: number } | null>(null)
const [clearing, setClearing] = useState(false)
const budget = useMemo(() => getLibraryIndexCacheBudget(), [])
const refreshFootprint = useCallback(async () => {
try {
setFootprint(await getLibraryIndexCacheFootprint())
} catch {
setFootprint({ count: 0, bytes: 0 })
}
}, [])
useEffect(() => {
void refreshFootprint()
}, [refreshFootprint])
const handleClear = async () => {
if (!confirm(t('libraryIndexCache.clearConfirm'))) return
setClearing(true)
try {
await clearAllLibraryIndexCaches()
await refreshFootprint()
toast.success(t('libraryIndexCache.clearedToast'))
} catch {
toast.error(t('libraryIndexCache.clearFailed'))
} finally {
setClearing(false)
}
}
const defaultsHint = useMemo(() => {
const p = platformLabel()
if (p === 'mobile-web') {
return t('libraryIndexCache.defaultsMobile', {
entries: budget.maxEntries,
mb: Math.round(budget.maxBytes / (1024 * 1024))
})
}
if (p === 'desktop-app') {
return t('libraryIndexCache.defaultsElectron', {
entries: budget.maxEntries,
mb: Math.round(budget.maxBytes / (1024 * 1024))
})
}
return t('libraryIndexCache.defaultsDesktopWeb', {
entries: budget.maxEntries,
mb: Math.round(budget.maxBytes / (1024 * 1024))
})
}, [budget.maxBytes, budget.maxEntries, t])
return (
<div className="mt-8 space-y-4 border-t border-border pt-6">
<h3 className="text-base font-medium">{t('libraryIndexCache.sectionTitle')}</h3>
<p className="text-muted-foreground text-sm">{t('libraryIndexCache.sectionBlurb')}</p>
<p className="text-muted-foreground text-xs">{defaultsHint}</p>
<p className="text-muted-foreground text-xs">
{t('libraryIndexCache.footprintSummary', {
count: footprint?.count ?? 0,
mb: formatMb(footprint?.bytes ?? 0),
maxEntries: budget.maxEntries,
maxMb: Math.round(budget.maxBytes / (1024 * 1024))
})}
</p>
<Button type="button" variant="secondary" disabled={clearing} onClick={() => void handleClear()}>
{clearing ? t('libraryIndexCache.clearing') : t('libraryIndexCache.clear')}
</Button>
</div>
)
}

19
src/components/LoginDialog/index.tsx

@ -1,32 +1,21 @@
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Dispatch, useCallback } from 'react' import { Dispatch } from 'react'
import AccountManager from '../AccountManager' import AccountManager from '../AccountManager'
export default function LoginDialog({ export default function LoginDialog({
open, open,
setOpen, setOpen
blockClose = false
}: { }: {
open: boolean open: boolean
setOpen: Dispatch<boolean> setOpen: Dispatch<boolean>
/** Keep open while a NIP-07 extension authorize dialog is in progress. */
blockClose?: boolean
}) { }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next && blockClose) return
setOpen(next)
},
[blockClose, setOpen]
)
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<Drawer open={open} onOpenChange={handleOpenChange}> <Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="max-h-[90vh]"> <DrawerContent className="max-h-[90vh]">
<DrawerHeader className="sr-only"> <DrawerHeader className="sr-only">
<DrawerTitle>Account Manager</DrawerTitle> <DrawerTitle>Account Manager</DrawerTitle>
@ -41,7 +30,7 @@ export default function LoginDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-[520px] max-h-[90vh] py-8 overflow-auto"> <DialogContent className="w-[520px] max-h-[90vh] py-8 overflow-auto">
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
<DialogTitle>Account Manager</DialogTitle> <DialogTitle>Account Manager</DialogTitle>

15
src/components/MediaPlayer/index.tsx

@ -6,7 +6,7 @@ import {
resolvePrimalBlossomPlayableUrl resolvePrimalBlossomPlayableUrl
} from '@/lib/url' } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AudioPlayer from '../AudioPlayer' import AudioPlayer from '../AudioPlayer'
@ -48,7 +48,6 @@ export default function MediaPlayer({
className, className,
mustLoad = false, mustLoad = false,
deferLoadUntilClick = false, deferLoadUntilClick = false,
authorPubkey,
poster, poster,
blurHash, blurHash,
fallbackPageUrl fallbackPageUrl
@ -61,8 +60,6 @@ export default function MediaPlayer({
* placeholder. Used for NIP-71 long-form video events in feeds. * placeholder. Used for NIP-71 long-form video events in feeds.
*/ */
deferLoadUntilClick?: boolean deferLoadUntilClick?: boolean
/** Note author; when set, follow-only and related policies apply per author. */
authorPubkey?: string | null
poster?: string poster?: string
/** NIP-94 / imeta blurhash for lazy placeholder when poster is missing */ /** NIP-94 / imeta blurhash for lazy placeholder when poster is missing */
blurHash?: string blurHash?: string
@ -70,8 +67,8 @@ export default function MediaPlayer({
fallbackPageUrl?: string fallbackPageUrl?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const authorAutoLoad = useShouldAutoLoadMedia(authorPubkey) const { autoLoadMedia } = useContentPolicy()
/** Tap-to-load when auto-load is off for this author; cleared when policy switches back to never. */ /** Tap-to-load when {@link autoLoadMedia} is off; cleared when policy switches back to never. */
const [userClickedLoad, setUserClickedLoad] = useState(false) const [userClickedLoad, setUserClickedLoad] = useState(false)
const [mediaType, setMediaType] = useState<MediaSurface>(null) const [mediaType, setMediaType] = useState<MediaSurface>(null)
const [probeFailed, setProbeFailed] = useState(false) const [probeFailed, setProbeFailed] = useState(false)
@ -99,11 +96,11 @@ export default function MediaPlayer({
const effectiveMediaType = mediaType ?? urlEmbedSurfaceHint const effectiveMediaType = mediaType ?? urlEmbedSurfaceHint
const showEmbed = const showEmbed =
mustLoad || (!deferLoadUntilClick && authorAutoLoad) || userClickedLoad mustLoad || (!deferLoadUntilClick && autoLoadMedia) || userClickedLoad
useLayoutEffect(() => { useLayoutEffect(() => {
if (!authorAutoLoad) setUserClickedLoad(false) if (!autoLoadMedia) setUserClickedLoad(false)
}, [authorAutoLoad]) }, [autoLoadMedia])
useEffect(() => { useEffect(() => {
readyOnceRef.current = false readyOnceRef.current = false

14
src/components/MuteButton/index.tsx

@ -9,7 +9,6 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { useSignGatedControl } from '@/hooks/useSignGatedControl'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { BellOff } from 'lucide-react' import { BellOff } from 'lucide-react'
@ -21,13 +20,12 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { pubkey: accountPubkey, checkLogin } = useNostr() const { pubkey: accountPubkey, checkLogin } = useNostr()
const { signControlProps, canManageIdentity } = useSignGatedControl()
const { mutePubkeySet, changing, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = const { mutePubkeySet, changing, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } =
useMuteList() useMuteList()
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const isMuted = useMemo(() => muteSetHas(mutePubkeySet, pubkey), [mutePubkeySet, pubkey]) const isMuted = useMemo(() => muteSetHas(mutePubkeySet, pubkey), [mutePubkeySet, pubkey])
if (!canManageIdentity || !accountPubkey || (pubkey && pubkey === accountPubkey)) return null if (!accountPubkey || (pubkey && pubkey === accountPubkey)) return null
const handleMute = async (e: React.MouseEvent, isPrivate = true) => { const handleMute = async (e: React.MouseEvent, isPrivate = true) => {
e.stopPropagation() e.stopPropagation()
@ -71,7 +69,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
className="rounded-full min-w-20 max-w-full text-destructive whitespace-normal break-words px-3" className="rounded-full min-w-20 max-w-full text-destructive whitespace-normal break-words px-3"
variant="secondary" variant="secondary"
onClick={handleUnmute} onClick={handleUnmute}
{...signControlProps({ disabled: updating || changing })} disabled={updating || changing}
> >
{updating ? ( {updating ? (
<Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden /> <Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden />
@ -86,7 +84,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
<Button <Button
variant="destructive" variant="destructive"
className="w-20 min-w-20 rounded-full" className="w-20 min-w-20 rounded-full"
{...signControlProps({ disabled: updating || changing })} disabled={updating || changing}
> >
{updating ? <Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden /> : t('Mute')} {updating ? <Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden /> : t('Mute')}
</Button> </Button>
@ -102,7 +100,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive" className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
variant="ghost" variant="ghost"
onClick={(e) => handleMute(e, true)} onClick={(e) => handleMute(e, true)}
{...signControlProps({ disabled: updating || changing })} disabled={updating || changing}
> >
{updating ? <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> : t('Mute user privately')} {updating ? <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> : t('Mute user privately')}
</Button> </Button>
@ -110,7 +108,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive" className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
variant="ghost" variant="ghost"
onClick={(e) => handleMute(e, false)} onClick={(e) => handleMute(e, false)}
{...signControlProps({ disabled: updating || changing })} disabled={updating || changing}
> >
{updating ? <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> : t('Mute user publicly')} {updating ? <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> : t('Mute user publicly')}
</Button> </Button>
@ -126,7 +124,6 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => handleMute(e, true)} onClick={(e) => handleMute(e, true)}
disabled={signControlProps().disabled}
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<BellOff /> <BellOff />
@ -134,7 +131,6 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => handleMute(e, false)} onClick={(e) => handleMute(e, false)}
disabled={signControlProps().disabled}
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<BellOff /> <BellOff />

51
src/components/Nip07ExtensionKeyMismatchToast/index.tsx

@ -0,0 +1,51 @@
import { Button } from '@/components/ui/button'
import { X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export function Nip07ExtensionKeyMismatchToast({
toastId,
onReload,
onUseExtensionIdentity
}: {
toastId: string | number
onReload: () => void
onUseExtensionIdentity: () => void
}) {
const { t } = useTranslation()
return (
<div
role="alert"
className="relative w-[min(22rem,calc(100vw-2rem))] max-w-[420px] rounded-lg border border-destructive/50 bg-background p-4 pr-10 text-foreground shadow-lg"
>
<button
type="button"
className="absolute right-2 top-2 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={t('Close')}
onClick={() => toast.dismiss(toastId)}
>
<X className="size-4" aria-hidden />
</button>
<p className="text-sm font-semibold text-destructive">
{t('nip07.extensionKeyMismatchTitle', {
defaultValue: 'Extension key mismatch'
})}
</p>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{t('nip07.extensionKeyMismatchBody', {
defaultValue:
'Your browser extension is using a different key than this tab. Switch keys in the extension, reload the page, or sign in with the extension’s current key.'
})}
</p>
<div className="mt-3 flex flex-col gap-2">
<Button type="button" size="sm" variant="secondary" className="w-full justify-center" onClick={onReload}>
{t('nip07.reloadPage')}
</Button>
<Button type="button" size="sm" className="w-full justify-center" onClick={onUseExtensionIdentity}>
{t('nip07.useExtensionIdentity')}
</Button>
</div>
</div>
)
}

236
src/components/NormalFeed/index.tsx

@ -1,11 +1,15 @@
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import NoteList, { TNoteListRef } from '@/components/NoteList' import NoteList, { TNoteListRef } from '@/components/NoteList'
import FeedFilterToolbarRow, { feedFilterRowChromeClass } from '@/components/FeedFilterToolbarRow' import { RefreshButton } from '@/components/RefreshButton'
import Tabs, { TabDefinition } from '@/components/Tabs'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { HOME_GALLERY_TAB_KINDS, FAST_READ_RELAY_URLS } from '@/constants'
import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import type { TPrimaryPageName } from '@/PageManager' import type { TPrimaryPageName } from '@/PageManager'
import { TFeedSubRequest, TNoteListMode } from '@/types' import { TFeedSubRequest, TNoteListMode } from '@/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { normalizeAnyRelayUrl } from '@/lib/url'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { import {
forwardRef, forwardRef,
@ -16,6 +20,32 @@ import {
useState, useState,
type ReactNode type ReactNode
} from 'react' } from 'react'
import KindFilter from '../KindFilter'
/**
* Home Gallery: favorites (or chip relays) first, then {@link FAST_READ_RELAY_URLS} so NIP-71 / picture
* events are not starved when the users relay set is mostly text timelines. Deduped by normalized URL.
*/
function galleryRelayUrlsMergedWithReadLayer(
favoriteUrls: readonly string[],
mergeGlobalFastRead: boolean
): string[] {
const seen = new Set<string>()
const out: string[] = []
const add = (raw: string) => {
const n = normalizeAnyRelayUrl(raw.trim()) || raw.trim()
if (!n) return
const k = n.toLowerCase()
if (seen.has(k)) return
seen.add(k)
out.push(n)
}
for (const u of favoriteUrls) add(u)
if (mergeGlobalFastRead) {
for (const u of FAST_READ_RELAY_URLS) add(u)
}
return out
}
const NormalFeed = forwardRef<TNoteListRef, { const NormalFeed = forwardRef<TNoteListRef, {
subRequests: TFeedSubRequest[] subRequests: TFeedSubRequest[]
@ -23,9 +53,9 @@ const NormalFeed = forwardRef<TNoteListRef, {
/** When false, NoteList waits before opening timeline REQs (relay algo probe). */ /** When false, NoteList waits before opening timeline REQs (relay algo probe). */
relayCapabilityReady?: boolean relayCapabilityReady?: boolean
isMainFeed?: boolean isMainFeed?: boolean
/** When set (e.g. on Home), filter toolbar is rendered in layout subHeader instead of in-feed. */ /** When set (e.g. on Home), tabs are rendered in layout subHeader instead of in-feed; avoids overlap */
setSubHeader?: (node: React.ReactNode) => void setSubHeader?: (node: React.ReactNode) => void
/** Shown in the subHeader row beside the kind filter (mobile primary feed). */ /** Shown in the subHeader row to the left of the kind filter (mobile primary feed). */
onSubHeaderRefresh?: () => void onSubHeaderRefresh?: () => void
/** /**
* When true with {@link mergeTimelineWhenSubRequestFiltersMatch}, relay URL list can change (e.g. favorites * When true with {@link mergeTimelineWhenSubRequestFiltersMatch}, relay URL list can change (e.g. favorites
@ -33,8 +63,15 @@ const NormalFeed = forwardRef<TNoteListRef, {
*/ */
preserveTimelineOnSubRequestsChange?: boolean preserveTimelineOnSubRequestsChange?: boolean
mergeTimelineWhenSubRequestFiltersMatch?: boolean mergeTimelineWhenSubRequestFiltersMatch?: boolean
/** Home feed: widened relay stack (replies); Notes-only uses {@link subRequests}. */ /** Home Replies can widen relays without changing Notes/Gallery. */
repliesSubRequests?: TFeedSubRequest[] repliesSubRequests?: TFeedSubRequest[]
/**
* When set on the home main feed, Gallery tab REQ uses this relay stack (same as {@link repliesSubRequests})
* instead of OP-only {@link subRequests} URLs.
*/
mainFeedGalleryRelayUrls?: string[]
/** Main Gallery historically widened with fast read relays; home can opt out to stay favorites+trending only. */
widenMainGalleryRelays?: boolean
/** Home following: second subscribe wave (delta relays / new authors); see {@link NoteList}. */ /** Home following: second subscribe wave (delta relays / new authors); see {@link NoteList}. */
followingFeedDeltaSubRequests?: TFeedSubRequest[] followingFeedDeltaSubRequests?: TFeedSubRequest[]
/** Stable subscription identity; see {@link NoteList} `feedSubscriptionKey`. */ /** Stable subscription identity; see {@link NoteList} `feedSubscriptionKey`. */
@ -76,6 +113,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
oneShotAfterMergeComparator?: (a: Event, b: Event) => number oneShotAfterMergeComparator?: (a: Event, b: Event) => number
extraShouldHideEvent?: (ev: Event) => boolean extraShouldHideEvent?: (ev: Event) => boolean
extraShouldHideRepliesEvent?: (ev: Event) => boolean extraShouldHideRepliesEvent?: (ev: Event) => boolean
/** When set with home Gallery, filters rows (e.g. aggr-only) using the widened relay stack. */
extraShouldHideGalleryEvent?: (ev: Event) => boolean
/** Override default cap for merged one-shot batches (wide d-tag / search merges). */ /** Override default cap for merged one-shot batches (wide d-tag / search merges). */
oneShotMergedCap?: number oneShotMergedCap?: number
/** When every relay in the subscribe wave fails before EOSE, merge a one-shot fetch from default read relays (home multi-relay feeds). */ /** When every relay in the subscribe wave fails before EOSE, merge a one-shot fetch from default read relays (home multi-relay feeds). */
@ -83,12 +122,12 @@ const NormalFeed = forwardRef<TNoteListRef, {
/** When the feed is empty and terminal, {@link NoteList} can show an Alexandria search link (hashtag / d-tag pages). */ /** When the feed is empty and terminal, {@link NoteList} can show an Alexandria search link (hashtag / d-tag pages). */
alexandriaEmptyUrl?: string | null alexandriaEmptyUrl?: string | null
/** /**
* Single-relay explore: only events from that relay's live REQ (no session/IDB prime, no prefetch to other relays). * Single-relay explore: only events from that relays live REQ (no session/IDB prime, no prefetch to other relays).
*/ */
relayAuthoritativeFeedOnly?: boolean relayAuthoritativeFeedOnly?: boolean
/** Home favorites: favorites + trending relays for stats / "Seen on". */ /** Home favorites Notes tab: favorites + trending relays for stats / “Seen on”. */
homeFeedSeenOnAllowlistOp?: string[] homeFeedSeenOnAllowlistOp?: string[]
/** Home replies surface: adds NIP-65, cache, and HTTP index read relays. */ /** Home favorites Replies / Gallery: adds NIP-65, cache, and HTTP index read relays. */
homeFeedSeenOnAllowlistReplies?: string[] homeFeedSeenOnAllowlistReplies?: string[]
}>(function NormalFeed( }>(function NormalFeed(
{ {
@ -101,6 +140,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
preserveTimelineOnSubRequestsChange = false, preserveTimelineOnSubRequestsChange = false,
mergeTimelineWhenSubRequestFiltersMatch = false, mergeTimelineWhenSubRequestFiltersMatch = false,
repliesSubRequests, repliesSubRequests,
mainFeedGalleryRelayUrls,
widenMainGalleryRelays = true,
followingFeedDeltaSubRequests, followingFeedDeltaSubRequests,
feedSubscriptionKey, feedSubscriptionKey,
feedTimelineScopeKey, feedTimelineScopeKey,
@ -121,6 +162,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
oneShotAfterMergeComparator, oneShotAfterMergeComparator,
extraShouldHideEvent, extraShouldHideEvent,
extraShouldHideRepliesEvent, extraShouldHideRepliesEvent,
extraShouldHideGalleryEvent,
oneShotMergedCap, oneShotMergedCap,
timelinePublicReadFallback = false, timelinePublicReadFallback = false,
alexandriaEmptyUrl = null, alexandriaEmptyUrl = null,
@ -130,8 +172,23 @@ const NormalFeed = forwardRef<TNoteListRef, {
}, },
ref ref
) { ) {
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111, feedKindFilterBypass } = const { showKinds, showKind1OPs, showKind1Replies, showKind1111, feedKindFilterBypass } =
useKindFilterOrDefaults() useKindFilterOrDefaults()
const [listMode, setListMode] = useState<TNoteListMode>(() => {
const storedMode = storage.getNoteListMode()
if (isMainFeed) {
if (storedMode === 'posts' || storedMode === 'postsAndReplies') {
return storedMode
}
return 'posts'
}
// Non-main feeds only expose Notes / Replies tabs — ignore stored "media" from the home gallery tab.
if (storedMode === 'posts' || storedMode === 'postsAndReplies') {
return storedMode
}
return 'posts'
})
const internalNoteListRef = useRef<TNoteListRef>(null) const internalNoteListRef = useRef<TNoteListRef>(null)
const noteListRef = ref || internalNoteListRef const noteListRef = ref || internalNoteListRef
const [feedFilterTabRowHost, setFeedFilterTabRowHost] = useState<HTMLDivElement | null>(null) const [feedFilterTabRowHost, setFeedFilterTabRowHost] = useState<HTMLDivElement | null>(null)
@ -139,7 +196,9 @@ const NormalFeed = forwardRef<TNoteListRef, {
setFeedFilterTabRowHost((prev) => (Object.is(prev, node) ? prev : node)) setFeedFilterTabRowHost((prev) => (Object.is(prev, node) ? prev : node))
}, []) }, [])
/** Every shard URL is a nostrarchives Wisp "trending notes" stream — OP-only timeline. */ const MEDIA_KINDS = useMemo(() => [...HOME_GALLERY_TAB_KINDS], [])
/** Every shard URL is a nostrarchives Wisp “trending notes” stream — replies/gallery tabs are not applicable. */
const isWispTrendingOnlyFeed = useMemo( const isWispTrendingOnlyFeed = useMemo(
() => () =>
subRequests.length > 0 && subRequests.length > 0 &&
@ -149,27 +208,70 @@ const NormalFeed = forwardRef<TNoteListRef, {
[subRequests] [subRequests]
) )
/** Replies feed by default; Wisp trending stays notes-only. Kind filter can hide replies client-side. */
const listMode: TNoteListMode = isWispTrendingOnlyFeed ? 'posts' : 'postsAndReplies'
useEffect(() => { useEffect(() => {
if (!isMainFeed) return if (!isWispTrendingOnlyFeed) return
if (storage.getNoteListMode() === listMode) return setListMode((m) => (m === 'posts' ? m : 'posts'))
storage.setNoteListMode(listMode) }, [isWispTrendingOnlyFeed])
window.dispatchEvent(new CustomEvent('noteListModeChanged'))
}, [isMainFeed, listMode])
const tabs = useMemo((): TabDefinition[] => {
if (isMainFeed && isWispTrendingOnlyFeed) {
return [{ value: 'posts', label: 'Notes' }]
}
const base: TabDefinition[] = [
{ value: 'posts', label: 'Notes' },
{ value: 'postsAndReplies', label: 'Replies' }
]
if (isMainFeed) base.push({ value: 'media', label: 'Gallery' })
return base
}, [isMainFeed, isWispTrendingOnlyFeed])
/** Replies may widen relays; Gallery swaps kinds and may use {@link mainFeedGalleryRelayUrls} on home. */
const effectiveSubRequests = useMemo(() => { const effectiveSubRequests = useMemo(() => {
if (listMode === 'postsAndReplies' && repliesSubRequests) { if (listMode === 'postsAndReplies' && repliesSubRequests) {
return repliesSubRequests return repliesSubRequests
} }
return subRequests if (listMode !== 'media') return subRequests
}, [listMode, subRequests, repliesSubRequests]) return subRequests.map((req) => ({
...req,
urls:
isMainFeed && mainFeedGalleryRelayUrls && mainFeedGalleryRelayUrls.length > 0
? mainFeedGalleryRelayUrls
: isMainFeed && widenMainGalleryRelays
? galleryRelayUrlsMergedWithReadLayer(req.urls, useGlobalRelayBootstrap)
: req.urls,
filter: { ...req.filter, kinds: MEDIA_KINDS }
}))
}, [
listMode,
subRequests,
repliesSubRequests,
MEDIA_KINDS,
isMainFeed,
widenMainGalleryRelays,
mainFeedGalleryRelayUrls,
useGlobalRelayBootstrap
])
const noteListExtraShouldHide = useMemo(() => { const noteListExtraShouldHide = useMemo(() => {
if (listMode === 'postsAndReplies') return extraShouldHideRepliesEvent if (listMode === 'postsAndReplies') return extraShouldHideRepliesEvent
if (listMode === 'media' && extraShouldHideGalleryEvent) return extraShouldHideGalleryEvent
return extraShouldHideEvent return extraShouldHideEvent
}, [listMode, extraShouldHideRepliesEvent, extraShouldHideEvent]) }, [listMode, extraShouldHideRepliesEvent, extraShouldHideGalleryEvent, extraShouldHideEvent])
const handleListModeChange = useCallback(
(mode: TNoteListMode | string) => {
const noteListMode = mode as TNoteListMode
setListMode(noteListMode)
if (isMainFeed) {
storage.setNoteListMode(noteListMode)
window.dispatchEvent(new CustomEvent('noteListModeChanged'))
}
if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.scrollToTop('smooth')
}
},
[isMainFeed, noteListRef]
)
const handleShowKindsChange = useCallback((_newShowKinds: number[]) => { const handleShowKindsChange = useCallback((_newShowKinds: number[]) => {
if (noteListRef && typeof noteListRef !== 'function') { if (noteListRef && typeof noteListRef !== 'function') {
@ -196,40 +298,75 @@ const NormalFeed = forwardRef<TNoteListRef, {
/** Include kind picker deps for single-relay chips (kindless REQ + client-side kinds). */ /** Include kind picker deps for single-relay chips (kindless REQ + client-side kinds). */
const subHeaderFilterDepsKey = `${allowKindlessRelayExplore ? 'kle' : 'std'}|${showKindsKey}|${feedKindFilterBypass}|${showAllKindsProp ? 'allProp' : 'k'}` const subHeaderFilterDepsKey = `${allowKindlessRelayExplore ? 'kle' : 'std'}|${showKindsKey}|${feedKindFilterBypass}|${showAllKindsProp ? 'allProp' : 'k'}`
const renderFilterToolbarInFeed = !(isMainFeed && setSubHeader) const renderTabsInFeed = !(isMainFeed && setSubHeader) && !allowKindlessRelayExplore
const filterToolbarRow = useMemo( const mergeFilterWithTabsRow =
() => ( showFeedClientFilter && ((isMainFeed && !!setSubHeader) || renderTabsInFeed)
<FeedFilterToolbarRow
showKinds={showKinds} /** Notes / Replies / Gallery switch, plus refresh + kind filter — on Wisp trending only the tool row (no mode tabs). */
onShowKindsChange={handleShowKindsChange} const tabsElement = useMemo(() => {
onRefresh={onSubHeaderRefresh} const kindRowOptions = (
feedFilterTabRowSlotRef={onFeedFilterTabRowSlotRef} <div className="flex items-center gap-0">
includeFeedSearchSlot={showFeedClientFilter} {onSubHeaderRefresh != null && <RefreshButton onClick={onSubHeaderRefresh} />}
<KindFilter showKinds={showKinds} onShowKindsChange={handleShowKindsChange} />
{mergeFilterWithTabsRow ? (
<div ref={onFeedFilterTabRowSlotRef} className="flex items-center" />
) : null}
</div>
)
if (isMainFeed && isWispTrendingOnlyFeed) {
return (
<div className="flex w-full min-w-0 items-center justify-end gap-0 py-1">{kindRowOptions}</div>
)
}
return (
<Tabs
value={listMode}
tabs={tabs}
onTabChange={handleListModeChange}
options={kindRowOptions}
/> />
), )
[onSubHeaderRefresh, showKinds, handleShowKindsChange, showFeedClientFilter] }, [
) isMainFeed,
isWispTrendingOnlyFeed,
listMode,
tabs,
handleListModeChange,
showKinds,
onSubHeaderRefresh,
handleShowKindsChange,
mergeFilterWithTabsRow
])
const tabRowChromeClass =
'w-full min-w-0 border-b border-border/80 bg-background/95 pb-1.5 pt-0.5 backdrop-blur supports-[backdrop-filter]:bg-background/80'
/** /**
* Push the filter toolbar into {@link PrimaryPageLayout} subHeader. Use `useEffect` (not `useLayoutEffect`) so * Push the tab row into {@link PrimaryPageLayout} subHeader. Use `useEffect` (not `useLayoutEffect`) so
* parent `setHomeSubHeader` runs after paint; synchronous layout updates here caused React #185 * parent `setHomeSubHeader` runs after paint; synchronous layout updates here caused React #185
* (maximum update depth) when navigating onto the home feed after other primaries (e.g. notifications). * (maximum update depth) when navigating onto the home feed after other primaries (e.g. notifications).
* Intentionally omit `filterToolbarRow` from deps covered by `subHeaderFilterDepsKey`. * Intentionally omit `tabsElement` from deps covered by `listMode` + `subHeaderFilterDepsKey`.
* Omit `onSubHeaderRefresh` / `onFeedFilterTabRowSlotRef`: only embedded in `filterToolbarRow`; unstable * Omit `onSubHeaderRefresh` / `onFeedFilterTabRowSlotRef`: only embedded in `tabsElement`; unstable
* identities there would retrigger every render and loop with parent state. * identities there would retrigger every render and loop with parent state.
* Do not clear subHeader between dep updates nulling remounts the filter portal slot and retriggers * Do not clear subHeader between dep updates nulling remounts the filter portal slot and retriggers
* NoteList subscriptions / layout churn on the home feed. * NoteList subscriptions / layout churn on the home feed.
*/ */
useEffect(() => { useEffect(() => {
if (!isMainFeed || !setSubHeader) return if (!isMainFeed || !setSubHeader) return
setSubHeader(<div className={feedFilterRowChromeClass}>{filterToolbarRow}</div>) if (mergeFilterWithTabsRow) {
setSubHeader(<div className={tabRowChromeClass}>{tabsElement}</div>)
} else {
setSubHeader(tabsElement)
}
}, [ }, [
isMainFeed, isMainFeed,
setSubHeader, setSubHeader,
listMode,
isWispTrendingOnlyFeed, isWispTrendingOnlyFeed,
subHeaderFilterDepsKey, subHeaderFilterDepsKey,
allowKindlessRelayExplore allowKindlessRelayExplore,
mergeFilterWithTabsRow
]) ])
useEffect(() => { useEffect(() => {
@ -239,10 +376,15 @@ const NormalFeed = forwardRef<TNoteListRef, {
return ( return (
<> <>
{renderFilterToolbarInFeed ? ( {renderTabsInFeed &&
<div className={cn('sticky top-0 z-20', feedFilterRowChromeClass)}>{filterToolbarRow}</div> (mergeFilterWithTabsRow ? (
) : null} <div className={cn('sticky top-0 z-20', tabRowChromeClass)}>{tabsElement}</div>
<div className={cn('min-w-0', renderFilterToolbarInFeed ? 'pt-0' : 'pt-2')}> ) : (
tabsElement
))}
<div
className={cn('min-w-0', mergeFilterWithTabsRow && renderTabsInFeed ? 'pt-0' : 'pt-2')}
>
<NoteList <NoteList
ref={noteListRef} ref={noteListRef}
showKinds={showKinds} showKinds={showKinds}
@ -263,15 +405,15 @@ const NormalFeed = forwardRef<TNoteListRef, {
homeFeedListMode={isMainFeed ? listMode : undefined} homeFeedListMode={isMainFeed ? listMode : undefined}
homeFeedSeenOnAllowlistOp={homeFeedSeenOnAllowlistOp} homeFeedSeenOnAllowlistOp={homeFeedSeenOnAllowlistOp}
homeFeedSeenOnAllowlistReplies={homeFeedSeenOnAllowlistReplies} homeFeedSeenOnAllowlistReplies={homeFeedSeenOnAllowlistReplies}
useFilterAsIs={useFilterAsIs} gridLayout={listMode === 'media'}
clientSideKindFilter={clientSideKindFilter} revealBatchSize={listMode === 'media' && isMainFeed ? 96 : undefined}
allowKindlessRelayExplore={allowKindlessRelayExplore} useFilterAsIs={listMode === 'media' ? true : useFilterAsIs}
showAllKinds={listShowAllKinds} clientSideKindFilter={listMode === 'media' ? false : clientSideKindFilter}
allowKindlessRelayExplore={listMode === 'media' ? false : allowKindlessRelayExplore}
showAllKinds={listMode === 'media' ? true : listShowAllKinds}
showFeedClientFilter={showFeedClientFilter} showFeedClientFilter={showFeedClientFilter}
hostPrimaryPageName={hostPrimaryPageName} hostPrimaryPageName={hostPrimaryPageName}
feedClientFilterTabRowHost={ feedClientFilterTabRowHost={mergeFilterWithTabsRow ? feedFilterTabRowHost : undefined}
showFeedClientFilter ? feedFilterTabRowHost : undefined
}
onSingleRelayKindlessEmpty={onSingleRelayKindlessEmpty} onSingleRelayKindlessEmpty={onSingleRelayKindlessEmpty}
onSingleRelayBrowseEmpty={onSingleRelayBrowseEmpty} onSingleRelayBrowseEmpty={onSingleRelayBrowseEmpty}
feedTopNotice={feedTopNotice} feedTopNotice={feedTopNotice}

12
src/components/Note/ArticleCardCoverImage.tsx

@ -1,6 +1,5 @@
import ContentImage from '@/components/Image' import ContentImage from '@/components/Image'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
@ -10,22 +9,19 @@ import type { Event } from 'nostr-tools'
export default function ArticleCardCoverImage({ export default function ArticleCardCoverImage({
event, event,
imageUrl, imageUrl,
autoLoadMedia: autoLoadMediaProp, autoLoadMedia,
layout, layout,
hideImageIfError = false hideImageIfError = false
}: { }: {
event: Event event: Event
imageUrl?: string imageUrl?: string
/** Deprecated: prefer per-author policy via {@link useShouldAutoLoadMedia}. Kept for callers that pass it. */ autoLoadMedia: boolean
autoLoadMedia?: boolean
layout: 'stacked' | 'row' layout: 'stacked' | 'row'
/** Passed through to {@link ContentImage} when an `image` tag URL exists. */ /** Passed through to {@link ContentImage} when an `image` tag URL exists. */
hideImageIfError?: boolean hideImageIfError?: boolean
}) { }) {
const autoLoadFromPolicy = useShouldAutoLoadMedia(event.pubkey, event)
const autoLoadMedia = autoLoadMediaProp ?? autoLoadFromPolicy
const trimmed = imageUrl?.trim() const trimmed = imageUrl?.trim()
if (trimmed) { if (trimmed && autoLoadMedia) {
return ( return (
<ContentImage <ContentImage
image={{ url: trimmed, pubkey: event.pubkey }} image={{ url: trimmed, pubkey: event.pubkey }}
@ -36,10 +32,10 @@ export default function ArticleCardCoverImage({
} }
classNames={layout === 'row' ? { wrapper: 'w-auto max-w-[400px] shrink-0' } : undefined} classNames={layout === 'row' ? { wrapper: 'w-auto max-w-[400px] shrink-0' } : undefined}
hideIfError={hideImageIfError} hideIfError={hideImageIfError}
holdUntilClick={!autoLoadMedia}
/> />
) )
} }
if (trimmed) return null
return ( return (
<div <div

11
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -350,15 +350,12 @@ export default function AsciidocArticle({
event, event,
className, className,
hideImagesAndInfo = false, hideImagesAndInfo = false,
hideTitle = false,
parentImageUrl, parentImageUrl,
footnotesContainerId footnotesContainerId
}: { }: {
event: Event event: Event
className?: string className?: string
hideImagesAndInfo?: boolean hideImagesAndInfo?: boolean
/** Suppress title headings (e.g. when a parent renders the section title). */
hideTitle?: boolean
parentImageUrl?: string parentImageUrl?: string
footnotesContainerId?: string footnotesContainerId?: string
}) { }) {
@ -1959,8 +1956,8 @@ export default function AsciidocArticle({
`}</style> `}</style>
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}> <div className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}>
{/* Metadata */} {/* Metadata */}
{!hideTitle && !hideImagesAndInfo && metadata.title && <h1 className="break-words">{metadata.title}</h1>} {!hideImagesAndInfo && metadata.title && <h1 className="break-words">{metadata.title}</h1>}
{!hideTitle && !hideImagesAndInfo && !metadata.title && isBookstrEvent && ( {!hideImagesAndInfo && !metadata.title && isBookstrEvent && (
<h1 className="break-words"> <h1 className="break-words">
{bookMetadata.book {bookMetadata.book
? bookMetadata.book ? bookMetadata.book
@ -1987,10 +1984,10 @@ export default function AsciidocArticle({
<p className="break-words">{metadata.summary}</p> <p className="break-words">{metadata.summary}</p>
</blockquote> </blockquote>
)} )}
{!hideTitle && hideImagesAndInfo && metadata.title && ( {hideImagesAndInfo && metadata.title && (
<h2 className="text-2xl font-bold mb-4 leading-tight break-words">{metadata.title}</h2> <h2 className="text-2xl font-bold mb-4 leading-tight break-words">{metadata.title}</h2>
)} )}
{!hideTitle && hideImagesAndInfo && !metadata.title && isBookstrEvent && ( {hideImagesAndInfo && !metadata.title && isBookstrEvent && (
<h2 className="text-2xl font-bold mb-4 leading-tight break-words"> <h2 className="text-2xl font-bold mb-4 leading-tight break-words">
{bookMetadata.book {bookMetadata.book
? bookMetadata.book ? bookMetadata.book

10
src/components/Note/CommunityDefinition.tsx

@ -1,5 +1,5 @@
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { getCommunityDefinitionFromEvent } from '@/lib/event-metadata' import { getCommunityDefinitionFromEvent } from '@/lib/event-metadata'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import ClientSelect from '../ClientSelect' import ClientSelect from '../ClientSelect'
@ -12,7 +12,8 @@ export default function CommunityDefinition({
event: Event event: Event
className?: string className?: string
}) { }) {
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event) const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const metadata = useMemo(() => getCommunityDefinitionFromEvent(event), [event]) const metadata = useMemo(() => getCommunityDefinitionFromEvent(event), [event])
const communityNameComponent = ( const communityNameComponent = (
@ -26,14 +27,13 @@ export default function CommunityDefinition({
return ( return (
<div className={className}> <div className={className}>
<div className="flex gap-4"> <div className="flex gap-4">
{metadata.image ? ( {metadata.image && autoLoadMedia && (
<Image <Image
image={{ url: metadata.image, pubkey: event.pubkey }} image={{ url: metadata.image, pubkey: event.pubkey }}
className="aspect-square bg-foreground h-20" className="aspect-square bg-foreground h-20"
hideIfError hideIfError
holdUntilClick={!autoLoadMedia}
/> />
) : null} )}
<div className="flex-1 w-0 space-y-1"> <div className="flex-1 w-0 space-y-1">
{communityNameComponent} {communityNameComponent}
{communityDescriptionComponent} {communityDescriptionComponent}

10
src/components/Note/GroupMetadata.tsx

@ -1,5 +1,5 @@
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { getGroupMetadataFromEvent } from '@/lib/event-metadata' import { getGroupMetadataFromEvent } from '@/lib/event-metadata'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import ClientSelect from '../ClientSelect' import ClientSelect from '../ClientSelect'
@ -14,7 +14,8 @@ export default function GroupMetadata({
originalNoteId?: string originalNoteId?: string
className?: string className?: string
}) { }) {
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event) const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const metadata = useMemo(() => getGroupMetadataFromEvent(event), [event]) const metadata = useMemo(() => getGroupMetadataFromEvent(event), [event])
const groupNameComponent = ( const groupNameComponent = (
@ -28,14 +29,13 @@ export default function GroupMetadata({
return ( return (
<div className={className}> <div className={className}>
<div className="flex gap-4"> <div className="flex gap-4">
{metadata.picture ? ( {metadata.picture && autoLoadMedia && (
<Image <Image
image={{ url: metadata.picture, pubkey: event.pubkey }} image={{ url: metadata.picture, pubkey: event.pubkey }}
className="aspect-square bg-foreground h-20" className="aspect-square bg-foreground h-20"
hideIfError hideIfError
holdUntilClick={!autoLoadMedia}
/> />
) : null} )}
<div className="flex-1 w-0 space-y-1"> <div className="flex-1 w-0 space-y-1">
{groupNameComponent} {groupNameComponent}
{groupAboutComponent} {groupAboutComponent}

8
src/components/Note/LiveEvent.tsx

@ -9,7 +9,7 @@ import {
preferredLiveJoinUrlForEvent preferredLiveJoinUrlForEvent
} from '@/lib/live-activities' } from '@/lib/live-activities'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useLiveActivitiesOptional } from '@/providers/useLiveActivities' import { useLiveActivitiesOptional } from '@/providers/useLiveActivities'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
@ -26,7 +26,8 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
const liveActivities = useLiveActivitiesOptional() const liveActivities = useLiveActivitiesOptional()
const screenSize = useScreenSizeOptional() const screenSize = useScreenSizeOptional()
const isSmallScreen = screenSize?.isSmallScreen ?? false const isSmallScreen = screenSize?.isSmallScreen ?? false
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event) const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const metadata = useMemo(() => getLiveEventMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLiveEventMetadataFromEvent(event), [event])
const playback = useMemo(() => liveEventInlinePlaybackFromEvent(event), [event]) const playback = useMemo(() => liveEventInlinePlaybackFromEvent(event), [event])
const joinUrl = useMemo(() => preferredLiveJoinUrlForEvent(event), [event]) const joinUrl = useMemo(() => preferredLiveJoinUrlForEvent(event), [event])
@ -86,7 +87,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
<MarkdownArticle <MarkdownArticle
event={summaryMarkdownEvent} event={summaryMarkdownEvent}
hideMetadata hideMetadata
lazyMedia={!autoLoadMedia} lazyMedia={autoLoadMedia}
className="prose-sm max-w-none min-w-0 w-full" className="prose-sm max-w-none min-w-0 w-full"
/> />
</div> </div>
@ -167,7 +168,6 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
src={playback.src} src={playback.src}
poster={inlinePlayerPoster} poster={inlinePlayerPoster}
className="w-full" className="w-full"
authorPubkey={event.pubkey}
fallbackPageUrl={zapStreamFallbackUrl ?? joinUrl ?? undefined} fallbackPageUrl={zapStreamFallbackUrl ?? joinUrl ?? undefined}
/> />
</div> </div>

5
src/components/Note/LongFormCard.tsx

@ -2,7 +2,7 @@ import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link' import { toNote, toNoteList } from '@/lib/link'
import { useSecondaryPageOptional } from '@/PageManager' import { useSecondaryPageOptional } from '@/PageManager'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
@ -31,7 +31,8 @@ export default function LongFormCard({
const push = secondaryPage?.push ?? ((url: string) => { const push = secondaryPage?.push ?? ((url: string) => {
window.location.href = url window.location.href = url
}) })
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event) const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content]) const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content])
const summaryText = (metadata.summary?.trim() || bodyBlurb).trim() const summaryText = (metadata.summary?.trim() || bodyBlurb).trim()

41
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -1,7 +1,6 @@
import { useSecondaryPageOptional, useSmartHashtagNavigationOptional, useSmartRelayNavigationOptional } from '@/PageManager' import { useSecondaryPageOptional, useSmartHashtagNavigationOptional, useSmartRelayNavigationOptional } from '@/PageManager'
import Image from '@/components/Image' import Image from '@/components/Image'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import { MediaAutoLoadEventProvider } from '@/providers/MediaAutoLoadEventContext'
import MediaPlayer from '@/components/MediaPlayer' import MediaPlayer from '@/components/MediaPlayer'
import Wikilink from '@/components/UniversalContent/Wikilink' import Wikilink from '@/components/UniversalContent/Wikilink'
import { BookstrContent } from '@/components/Bookstr' import { BookstrContent } from '@/components/Bookstr'
@ -111,13 +110,6 @@ function resolveImetaForMarkdownImageUrl(
*/ */
const MD_PARAGRAPH_FLOW_CLASS = 'mb-1 last:mb-0' const MD_PARAGRAPH_FLOW_CLASS = 'mb-1 last:mb-0'
/** Paragraph that is only a single `:shortcode:` (custom or native) — often a trailing reaction emoji. */
const EMOJI_ONLY_PARAGRAPH_RE = new RegExp(`^${EMOJI_SHORT_CODE_REGEX.source}$`)
function isEmojiOnlyParagraphText(text: string): boolean {
return EMOJI_ONLY_PARAGRAPH_RE.test(text.trim())
}
/** Author custom emoji image URL → slide index in the note lightbox ({@link lightboxSlideFromImeta}). */ /** Author custom emoji image URL → slide index in the note lightbox ({@link lightboxSlideFromImeta}). */
type TInlineEmojiLightbox = { type TInlineEmojiLightbox = {
imageIndexMap: Map<string, number> imageIndexMap: Map<string, number>
@ -3689,9 +3681,6 @@ function parseMarkdownContentMarked(
const renderParagraph = (token: any, key: string): React.ReactNode => { const renderParagraph = (token: any, key: string): React.ReactNode => {
const rawParagraphText = String(token.text ?? token.raw ?? '') const rawParagraphText = String(token.text ?? token.raw ?? '')
const paragraphText = rawParagraphText.trim() const paragraphText = rawParagraphText.trim()
if (!paragraphText) {
return null
}
const displayMathSplit = splitParagraphByDisplayMath(rawParagraphText) const displayMathSplit = splitParagraphByDisplayMath(rawParagraphText)
if (displayMathSplit) { if (displayMathSplit) {
return ( return (
@ -4563,13 +4552,8 @@ function parseMarkdownContentMarked(
} }
const inlineNodes = renderInlineTokens(paragraphTokens, `${key}-inline`) const inlineNodes = renderInlineTokens(paragraphTokens, `${key}-inline`)
const emojiOnly = isEmojiOnlyParagraphText(paragraphText)
return ( return (
<div <div key={`${key}-p`} role="paragraph" className={MD_PARAGRAPH_FLOW_CLASS}>
key={`${key}-p`}
role="paragraph"
className={emojiOnly ? 'mb-1 last:mb-0 leading-none' : MD_PARAGRAPH_FLOW_CLASS}
>
{inlineNodes} {inlineNodes}
</div> </div>
) )
@ -4589,10 +4573,7 @@ function parseMarkdownContentMarked(
const key = `${keyPrefix}-${i}` const key = `${keyPrefix}-${i}`
switch (token.type) { switch (token.type) {
case 'space': { case 'space': {
const next = tokens[i + 1] const gapEm = spaceTokenExtraGapEm(token)
const nextIsEmojiOnly =
next?.type === 'paragraph' && isEmojiOnlyParagraphText(String(next.text ?? next.raw ?? ''))
const gapEm = nextIsEmojiOnly ? 0 : spaceTokenExtraGapEm(token)
if (gapEm > 0) { if (gapEm > 0) {
nodes.push( nodes.push(
<div <div
@ -5446,7 +5427,6 @@ export default function MarkdownArticle({
event, event,
className, className,
hideMetadata = false, hideMetadata = false,
hideTitle = false,
lazyMedia = true, lazyMedia = true,
parentImageUrl, parentImageUrl,
fullCalendarInvite, fullCalendarInvite,
@ -5455,8 +5435,6 @@ export default function MarkdownArticle({
event: Event event: Event
className?: string className?: string
hideMetadata?: boolean hideMetadata?: boolean
/** Suppress title headings (e.g. when a parent renders the section title). */
hideTitle?: boolean
/** /**
* When true (default), images in the note are held as blur/skeleton placeholders * When true (default), images in the note are held as blur/skeleton placeholders
* until the user opens them in the lightbox. Set to false in full/detail views * until the user opens them in the lightbox. Set to false in full/detail views
@ -6196,7 +6174,6 @@ export default function MarkdownArticle({
}, [metadata.tags, hashtagsInContent]) }, [metadata.tags, hashtagsInContent])
return ( return (
<MediaAutoLoadEventProvider event={event}>
<> <>
<style>{` <style>{`
/* Padding (not margin) so separation does not collapse with the prior list's margin */ /* Padding (not margin) so separation does not collapse with the prior list's margin */
@ -6299,16 +6276,13 @@ export default function MarkdownArticle({
</div> </div>
)} )}
{/* Metadata */} {/* Metadata */}
{!hideTitle && !hideMetadata && metadata.title && ( {!hideMetadata && metadata.title && <h1 className="break-words">{metadata.title}</h1>}
<h1 className="break-words">{metadata.title}</h1>
)}
{!hideMetadata && metadata.summary && ( {!hideMetadata && metadata.summary && (
<blockquote> <blockquote>
<p className="break-words">{metadata.summary}</p> <p className="break-words">{metadata.summary}</p>
</blockquote> </blockquote>
)} )}
{!hideTitle && {!hideMetadata &&
!hideMetadata &&
event.kind === kinds.LongFormArticle && event.kind === kinds.LongFormArticle &&
!metadata.image?.trim() && ( !metadata.image?.trim() && (
<div className="not-prose my-4 flex max-w-[400px] justify-center rounded-lg bg-muted p-6"> <div className="not-prose my-4 flex max-w-[400px] justify-center rounded-lg bg-muted p-6">
@ -6320,15 +6294,13 @@ export default function MarkdownArticle({
/> />
</div> </div>
)} )}
{!hideTitle && {hideMetadata &&
hideMetadata &&
metadata.title && metadata.title &&
event.kind !== ExtendedKind.DISCUSSION && event.kind !== ExtendedKind.DISCUSSION &&
!isNip52CalendarCardKind(event.kind) && ( !isNip52CalendarCardKind(event.kind) && (
<h2 className="text-2xl font-bold mb-4 leading-tight break-words">{metadata.title}</h2> <h2 className="text-2xl font-bold mb-4 leading-tight break-words">{metadata.title}</h2>
)} )}
{!hideTitle && {hideMetadata &&
hideMetadata &&
metadata.title && metadata.title &&
event.kind === kinds.LongFormArticle && event.kind === kinds.LongFormArticle &&
!metadata.image?.trim() && !metadata.image?.trim() &&
@ -6572,6 +6544,5 @@ export default function MarkdownArticle({
document.body document.body
)} )}
</> </>
</MediaAutoLoadEventProvider>
) )
} }

49
src/components/Note/MusicTrackNote.tsx

@ -7,32 +7,12 @@ import {
} from '@/lib/music-track' } from '@/lib/music-track'
import { primalR2aMirrorForBlossomPrimalUrl } from '@/lib/url' import { primalR2aMirrorForBlossomPrimalUrl } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import MarkdownArticle from './MarkdownArticle/MarkdownArticle'
import MediaPlayer from '../MediaPlayer' import MediaPlayer from '../MediaPlayer'
/** Tags already shown on the music card — omit from caption markdown so they are not rendered twice. */
const MUSIC_TRACK_CAPTION_OMIT_TAGS = new Set([
'd',
'title',
'artist',
'url',
'image',
'video',
'album',
'duration',
'format',
'language',
'track_number',
'released',
'explicit',
'alt',
'genre'
])
export default function MusicTrackNote({ export default function MusicTrackNote({
event, event,
className, className,
@ -42,7 +22,8 @@ export default function MusicTrackNote({
className?: string className?: string
loadMedia?: boolean loadMedia?: boolean
}) { }) {
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event) const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const mustLoad = loadMedia || autoLoadMedia const mustLoad = loadMedia || autoLoadMedia
const { t } = useTranslation() const { t } = useTranslation()
@ -56,11 +37,6 @@ export default function MusicTrackNote({
() => (track ? primalR2aMirrorForBlossomPrimalUrl(track.audioUrl) ?? undefined : undefined), () => (track ? primalR2aMirrorForBlossomPrimalUrl(track.audioUrl) ?? undefined : undefined),
[track] [track]
) )
const captionEvent = useMemo(() => {
if (!caption) return null
const tags = event.tags.filter(([name]) => !MUSIC_TRACK_CAPTION_OMIT_TAGS.has(name))
return { ...event, content: caption, tags } as Event
}, [event, caption])
if (!track) { if (!track) {
return ( return (
@ -106,25 +82,12 @@ export default function MusicTrackNote({
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> <p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{t('Music video', { defaultValue: 'Music video' })} {t('Music video', { defaultValue: 'Music video' })}
</p> </p>
<MediaPlayer <MediaPlayer src={track.videoUrl} className="w-full max-w-none" mustLoad={mustLoad} />
src={track.videoUrl}
className="w-full max-w-none"
mustLoad={mustLoad}
authorPubkey={event.pubkey}
/>
</div> </div>
) : null} ) : null}
</div> </div>
{captionEvent ? ( {caption ? (
<div className="mt-2 min-w-0 text-sm text-muted-foreground"> <p className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">{caption}</p>
<MarkdownArticle
event={captionEvent}
hideMetadata
lazyMedia={!mustLoad}
parentImageUrl={track.imageUrl}
className="prose-sm prose-headings:text-muted-foreground prose-p:text-muted-foreground"
/>
</div>
) : null} ) : null}
</div> </div>
) )

16
src/components/Note/NsfwNote.tsx

@ -1,25 +1,13 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { DEFAULT_CONTENT_WARNING_LABEL } from '@/lib/content-warning'
import { Eye } from 'lucide-react' import { Eye } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function NsfwNote({ export default function NsfwNote({ show }: { show: () => void }) {
show,
label
}: {
show: () => void
label?: string | null
}) {
const { t } = useTranslation() const { t } = useTranslation()
const normalized = label?.trim() || DEFAULT_CONTENT_WARNING_LABEL
const heading =
normalized.toLowerCase() === DEFAULT_CONTENT_WARNING_LABEL.toLowerCase()
? t('🔞 NSFW 🔞')
: t('Content warning label', { label: normalized })
return ( return (
<div className="flex flex-col gap-2 items-center text-muted-foreground font-medium my-4"> <div className="flex flex-col gap-2 items-center text-muted-foreground font-medium my-4">
<div className="text-center px-4">{heading}</div> <div>{t('🔞 NSFW 🔞')}</div>
<Button <Button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()

54
src/components/Note/PublicationBooklistButton.tsx

@ -1,54 +0,0 @@
import { Button } from '@/components/ui/button'
import { usePublicationBooklist } from '@/hooks/usePublicationBooklist'
import { cn } from '@/lib/utils'
import { BookOpen, Loader2 } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
export default function PublicationBooklistButton({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const { isOnBooklist, loading, toggling, toggle, canToggle } = usePublicationBooklist(event)
if (!canToggle) return null
return (
<Button
type="button"
variant="outline"
size="sm"
className={cn(
'gap-2',
isOnBooklist
? [
'border-border bg-background text-foreground shadow-none',
'hover:border-border hover:bg-muted hover:text-foreground',
'dark:border-border dark:bg-background dark:text-foreground',
'dark:hover:border-border dark:hover:bg-muted dark:hover:text-foreground'
]
: [
'border-green-600 bg-green-600 text-white shadow-sm',
'hover:border-green-700 hover:bg-green-700 hover:text-white',
'dark:border-green-500 dark:bg-green-600 dark:text-white',
'dark:hover:border-green-400 dark:hover:bg-green-500 dark:hover:text-white',
'focus-visible:ring-green-600/40 dark:focus-visible:ring-green-500/40'
],
className
)}
disabled={loading || toggling}
onClick={() => void toggle()}
>
{loading || toggling ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<BookOpen className="size-4" aria-hidden />
)}
{isOnBooklist ? t('Remove from my booklist') : t('Add to my booklist')}
</Button>
)
}

119
src/components/Note/PublicationCard.tsx

@ -1,74 +1,51 @@
import { ExtendedKind } from '@/constants'
import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb' import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb'
import { extractBookMetadata } from '@/lib/bookstr-parser' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import {
getLongFormArticleMetadataFromEvent,
getPublicationIndexMetadataFromEvent
} from '@/lib/event-metadata'
import { persistLibraryPublicationForReading } from '@/lib/library-publication-index'
import { toNote, toNoteList } from '@/lib/link' import { toNote, toNoteList } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSecondaryPageOptional, useSmartNoteNavigationOptional } from '@/PageManager' import { useSecondaryPageOptional, useSmartNoteNavigationOptional } from '@/PageManager'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import Image from '../Image' import Image from '../Image'
import ArticleCardCoverImage from './ArticleCardCoverImage' import { extractBookMetadata } from '@/lib/bookstr-parser'
import PublicationCoverFallback from './PublicationCoverFallback' import { ExtendedKind } from '@/constants'
import PublicationCoverImage from './PublicationCoverImage'
import PublicationIndexMetadata from './PublicationIndexMetadata'
export default function PublicationCard({ export default function PublicationCard({
event, event,
className, className,
disableNavigation = false, disableNavigation = false
/** Library grid: stacked cover on top, compact cover height. */
presentation = 'default'
}: { }: {
event: Event event: Event
className?: string className?: string
/** When true (e.g. full note view), card is display-only; no navigate-to-note on click. */ /** When true (e.g. full note view), card is display-only; no navigate-to-note on click. */
disableNavigation?: boolean disableNavigation?: boolean
presentation?: 'default' | 'library'
}) { }) {
const screenSize = useScreenSizeOptional() const screenSize = useScreenSizeOptional()
const isSmallScreen = screenSize?.isSmallScreen ?? false const isSmallScreen = screenSize?.isSmallScreen ?? false
const useStackedLayout = presentation === 'library' || isSmallScreen
const coverSize = presentation === 'library' ? 'library' : 'default'
const { navigateToNote } = useSmartNoteNavigationOptional() const { navigateToNote } = useSmartNoteNavigationOptional()
const secondaryPage = useSecondaryPageOptional() const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event) const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const indexMetadata = useMemo(
() => (event.kind === ExtendedKind.PUBLICATION ? getPublicationIndexMetadataFromEvent(event) : null),
[event]
)
const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content]) const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content])
const summaryText = (metadata.summary?.trim() || bodyBlurb).trim() const summaryText = (metadata.summary?.trim() || bodyBlurb).trim()
const bookMetadata = useMemo(() => extractBookMetadata(event), [event]) const bookMetadata = useMemo(() => extractBookMetadata(event), [event])
// Kind 30040 is always a publication index (NKBIP-01). Do not treat `T`/`v` tags as bookstr — const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book
// they mean topic/version there, not NKBIP-08 bible references.
const isBookstrEvent =
event.kind === ExtendedKind.PUBLICATION_CONTENT && !!bookMetadata.book
const isPublicationIndex = event.kind === ExtendedKind.PUBLICATION
const handleCardClick = (e: React.MouseEvent) => { const handleCardClick = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
if (disableNavigation) return if (disableNavigation) return
persistLibraryPublicationForReading(event)
navigateToNote(toNote(event), event) navigateToNote(toNote(event), event)
} }
const titleComponent = metadata.title ? ( const titleComponent = metadata.title ? <div className="text-xl font-semibold break-words min-w-0 sm:line-clamp-2">{metadata.title}</div> : null
<div className="min-w-0 text-xl font-semibold break-words sm:line-clamp-2">{metadata.title}</div>
) : null
const formatBookName = (book: string) => { const formatBookName = (book: string) => {
return book return book
.split('-') .split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ') .join(' ')
} }
@ -106,70 +83,21 @@ export default function PublicationCard({
</div> </div>
) : null ) : null
const cardShellClass = cn( if (isSmallScreen) {
'min-w-0 rounded-lg border transition-colors',
presentation === 'library' ? 'border-0 p-3' : 'border p-4',
disableNavigation ? '' : 'cursor-pointer hover:bg-muted/50'
)
if (isPublicationIndex && indexMetadata) {
const coverImage = indexMetadata.image?.trim()
const coverLayout = useStackedLayout ? 'stacked' : 'row'
const cover = coverImage ? (
<PublicationCoverImage
imageUrl={coverImage}
pubkey={event.pubkey}
autoLoadMedia={autoLoadMedia}
size={coverSize}
layout={coverLayout}
/>
) : (
<PublicationCoverFallback layout={coverLayout} size={coverSize} />
)
if (useStackedLayout) {
return (
<div className={cn('w-full min-w-0', className)}>
<div className={cardShellClass} onClick={disableNavigation ? undefined : handleCardClick}>
{cover}
<PublicationIndexMetadata event={event} variant="compact" />
</div>
</div>
)
}
return ( return (
<div className={cn('w-full min-w-0', className)}> <div className={cn('w-full min-w-0', className)}>
<div <div
className={cn(cardShellClass, 'overflow-hidden')} className={cn(
'min-w-0 rounded-lg border p-4 transition-colors',
disableNavigation ? '' : 'cursor-pointer hover:bg-muted/50'
)}
onClick={disableNavigation ? undefined : handleCardClick} onClick={disableNavigation ? undefined : handleCardClick}
> >
<div className="flex min-w-0 items-start gap-4"> {metadata.image && autoLoadMedia && (
{cover}
<PublicationIndexMetadata event={event} variant="compact" className="min-h-0 min-w-0 flex-1 basis-0" />
</div>
</div>
</div>
)
}
if (isSmallScreen) {
return (
<div className={cn('w-full min-w-0', className)}>
<div className={cardShellClass} onClick={disableNavigation ? undefined : handleCardClick}>
{metadata.image ? (
<Image <Image
image={{ url: metadata.image, pubkey: event.pubkey }} image={{ url: metadata.image, pubkey: event.pubkey }}
className="mb-3 aspect-video w-full max-w-full" className="mb-3 aspect-video w-full max-w-full"
hideIfError hideIfError
holdUntilClick={!autoLoadMedia}
/>
) : (
<ArticleCardCoverImage
event={event}
imageUrl={metadata.image}
autoLoadMedia={autoLoadMedia}
layout="stacked"
/> />
)} )}
<div className="min-w-0 space-y-2 overflow-hidden"> <div className="min-w-0 space-y-2 overflow-hidden">
@ -187,24 +115,19 @@ export default function PublicationCard({
return ( return (
<div className={cn('w-full min-w-0', className)}> <div className={cn('w-full min-w-0', className)}>
<div <div
className={cn(cardShellClass, 'overflow-hidden')} className={cn(
'min-w-0 overflow-hidden rounded-lg border p-4 transition-colors',
disableNavigation ? '' : 'cursor-pointer hover:bg-muted/50'
)}
onClick={disableNavigation ? undefined : handleCardClick} onClick={disableNavigation ? undefined : handleCardClick}
> >
<div className="flex min-w-0 gap-4"> <div className="flex min-w-0 gap-4">
{metadata.image ? ( {metadata.image && autoLoadMedia && (
<Image <Image
image={{ url: metadata.image, pubkey: event.pubkey }} image={{ url: metadata.image, pubkey: event.pubkey }}
classNames={{ wrapper: 'w-auto max-w-[min(400px,42%)] shrink-0 xl:max-w-[400px]' }} classNames={{ wrapper: 'w-auto max-w-[min(400px,42%)] shrink-0 xl:max-w-[400px]' }}
className="aspect-[4/3] h-44 max-h-44 w-auto max-w-[min(400px,42%)] min-w-0 shrink rounded-lg bg-foreground object-cover xl:aspect-video xl:max-w-[400px]" className="aspect-[4/3] h-44 max-h-44 w-auto max-w-[min(400px,42%)] min-w-0 shrink rounded-lg bg-foreground object-cover xl:aspect-video xl:max-w-[400px]"
hideIfError hideIfError
holdUntilClick={!autoLoadMedia}
/>
) : (
<ArticleCardCoverImage
event={event}
imageUrl={metadata.image}
autoLoadMedia={autoLoadMedia}
layout="row"
/> />
)} )}
<div className="min-h-0 min-w-[10rem] flex-1 basis-0 space-y-2 overflow-hidden"> <div className="min-h-0 min-w-[10rem] flex-1 basis-0 space-y-2 overflow-hidden">

38
src/components/Note/PublicationCoverFallback.tsx

@ -1,38 +0,0 @@
import { cn } from '@/lib/utils'
import { BookOpen } from 'lucide-react'
import {
LIBRARY_PUBLICATION_COVER_MAX_CLASS,
PUBLICATION_COVER_MAX_CLASS
} from './PublicationCoverImage'
export default function PublicationCoverFallback({
layout,
size = 'default',
className
}: {
layout: 'stacked' | 'row'
size?: 'library' | 'default'
className?: string
}) {
const isLibrary = size === 'library'
const maxClass = isLibrary ? LIBRARY_PUBLICATION_COVER_MAX_CLASS : PUBLICATION_COVER_MAX_CLASS
const stackedLayoutClass = isLibrary
? 'h-[200px] w-[200px] max-h-[200px] max-w-[200px]'
: 'aspect-[3/4] w-48 max-w-full'
return (
<div
className={cn(
'flex shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground',
maxClass,
layout === 'stacked' ? stackedLayoutClass : 'aspect-[3/4] w-full max-w-[9rem] sm:max-w-[10rem]',
layout === 'stacked' && size === 'default' && 'mb-3',
layout === 'stacked' && size === 'library' && 'mb-2',
className
)}
>
<BookOpen className={size === 'library' ? 'size-8' : 'size-10'} aria-hidden />
</div>
)
}

87
src/components/Note/PublicationCoverImage.tsx

@ -1,87 +0,0 @@
import { gutenbergCoverCandidateUrls } from '@/lib/gutenberg-cover'
import { cn } from '@/lib/utils'
import { useCallback, useEffect, useMemo, useState } from 'react'
import Image from '../Image'
import PublicationCoverFallback from './PublicationCoverFallback'
/** Library grid: larger axis capped at 200px; aspect ratio preserved (no crop). */
export const LIBRARY_PUBLICATION_COVER_MAX_CLASS = 'max-h-[200px] max-w-[200px]'
/** Max cover box in publication detail / note panel (larger axis capped at 400px). */
export const PUBLICATION_COVER_MAX_CLASS = 'max-h-[400px] max-w-[400px]'
export default function PublicationCoverImage({
imageUrl,
pubkey,
autoLoadMedia,
size = 'default',
layout = 'stacked',
className
}: {
imageUrl: string
pubkey: string
autoLoadMedia: boolean
size?: 'library' | 'default'
layout?: 'stacked' | 'row'
className?: string
}) {
const isLibrary = size === 'library'
const maxClass = isLibrary ? LIBRARY_PUBLICATION_COVER_MAX_CLASS : PUBLICATION_COVER_MAX_CLASS
const candidateUrls = useMemo(
() => gutenbergCoverCandidateUrls(imageUrl, isLibrary),
[imageUrl, isLibrary]
)
const [urlIndex, setUrlIndex] = useState(0)
const [exhausted, setExhausted] = useState(false)
const activeUrl = candidateUrls[urlIndex] ?? candidateUrls[0] ?? imageUrl.trim()
useEffect(() => {
setUrlIndex(0)
setExhausted(false)
}, [imageUrl, isLibrary])
const handleImageError = useCallback(() => {
setUrlIndex((index) => {
const next = index + 1
if (next < candidateUrls.length) return next
setExhausted(true)
return index
})
}, [candidateUrls.length])
if (exhausted || !activeUrl) {
return <PublicationCoverFallback layout={layout} size={size} className={className} />
}
const stackedLayoutClass = isLibrary ? 'w-fit max-w-full' : 'w-fit'
// Library grid: always load covers (user opened Bibliothek). Tap-to-reveal on the card would
// fight PublicationCard navigation, leaving blurhash placeholders stuck forever.
const holdCoverUntilClick = isLibrary ? false : !autoLoadMedia
return (
<div
className={cn(
'flex shrink-0 items-center justify-center rounded-lg bg-muted',
maxClass,
layout === 'stacked' ? stackedLayoutClass : 'aspect-[3/4] w-full max-w-[9rem] sm:max-w-[10rem]',
layout === 'stacked' && size === 'default' && 'mb-3',
layout === 'stacked' && isLibrary && 'mb-2',
!isLibrary && 'overflow-hidden',
className
)}
onClick={holdCoverUntilClick ? (e) => e.stopPropagation() : undefined}
>
<Image
key={activeUrl}
image={{ url: activeUrl, pubkey }}
className={cn(maxClass, 'h-auto w-auto object-contain')}
classNames={{ wrapper: cn('block w-fit max-w-full') }}
hideIfError
onFinalError={handleImageError}
holdUntilClick={holdCoverUntilClick}
loading="eager"
/>
</div>
)
}

301
src/components/Note/PublicationIndexBody.tsx

@ -1,301 +0,0 @@
import AsciidocArticle from '@/components/Note/AsciidocArticle/AsciidocArticle'
import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle'
import NoteOptions from '@/components/NoteOptions'
import { DOCUMENT_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, LIBRARY_RELAY_URLS } from '@/constants'
import { useProgressivePublicationContent } from '@/hooks/useProgressivePublicationContent'
import { useNearViewport } from '@/hooks/useNearViewport'
import { orderedPublicationRefsFromIndex } from '@/lib/publication-asciidoc-assembler'
import { publicationRefKey } from '@/lib/publication-section-fetch'
import {
buildPublicationSectionTree,
flattenPublicationSectionTreeForToc,
type PublicationSectionTreeNode
} from '@/lib/publication-section-tree'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { BookOpen, Loader2 } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const ASCIIDOC_CONTENT_KINDS = new Set<number>([
ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.WIKI_ARTICLE
])
type HeadingTag = 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
function SectionHeadingRow({
title,
event,
Heading
}: {
title: string
event?: Event
Heading: HeadingTag
}) {
return (
<div className="flex min-w-0 items-start gap-1">
<Heading className="min-w-0 flex-1 text-base font-semibold break-words text-foreground">
{title}
</Heading>
{event ? <NoteOptions event={event} className="shrink-0 -mr-1 -mt-0.5" /> : null}
</div>
)
}
function SectionContent({ event }: { event: Event }) {
if (ASCIIDOC_CONTENT_KINDS.has(event.kind)) {
return (
<AsciidocArticle className="mt-2" event={event} hideImagesAndInfo hideTitle />
)
}
if (event.kind === kinds.LongFormArticle) {
return <MarkdownArticle className="mt-2" event={event} hideMetadata hideTitle />
}
if ((event.content ?? '').trim()) {
return (
<div className="mt-2 whitespace-pre-wrap break-words text-base text-foreground">
{event.content}
</div>
)
}
return null
}
function SectionLoadingPlaceholder() {
return (
<div className="mt-2 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden />
</div>
)
}
function SectionMissingPlaceholder() {
const { t } = useTranslation()
return (
<p className="mt-2 text-sm italic text-muted-foreground/80">
{t('Publication section missing')}
</p>
)
}
function PublicationSectionNodeView({
node,
failedKeys,
loadingKeys,
onRequestLoad,
onReadAhead
}: {
node: PublicationSectionTreeNode
failedKeys: ReadonlySet<string>
loadingKeys: ReadonlySet<string>
onRequestLoad: (ref: PublicationSectionTreeNode['ref'], indexEvent: Event) => void
onReadAhead: () => void
}) {
const Heading = `h${Math.min(6, node.depth + 2)}` as HeadingTag
const sectionElRef = useRef<HTMLElement>(null)
const refKey = publicationRefKey(node.ref)
const isMissing = Boolean(refKey && failedKeys.has(refKey))
const isLoading = Boolean(refKey && loadingKeys.has(refKey))
const needsLoad = Boolean(refKey && !node.event && !isMissing && !isLoading)
const isNear = useNearViewport(sectionElRef, { enabled: needsLoad, marginPx: 480 })
useEffect(() => {
if (!needsLoad || !isNear) return
onRequestLoad(node.ref, node.indexEvent)
onReadAhead()
}, [needsLoad, isNear, node.ref, node.indexEvent, onRequestLoad, onReadAhead])
return (
<section
ref={sectionElRef}
id={node.sectionId}
className="scroll-mt-24 mt-4 first:mt-0"
aria-busy={isLoading || needsLoad}
>
<SectionHeadingRow title={node.title} event={node.event} Heading={Heading} />
{node.isPublicationBranch && node.event?.content.trim() ? (
<div className="mt-2 whitespace-pre-wrap break-words text-muted-foreground">
{node.event.content.trim()}
</div>
) : null}
{node.isPublicationBranch ? (
node.children.length > 0 ? (
<div className="mt-4 border-l border-border pl-4">
{node.children.map((child) => (
<PublicationSectionNodeView
key={child.path}
node={child}
failedKeys={failedKeys}
loadingKeys={loadingKeys}
onRequestLoad={onRequestLoad}
onReadAhead={onReadAhead}
/>
))}
</div>
) : needsLoad || isLoading ? (
<SectionLoadingPlaceholder />
) : isMissing ? (
<SectionMissingPlaceholder />
) : null
) : isMissing ? (
<SectionMissingPlaceholder />
) : isLoading || needsLoad ? (
<SectionLoadingPlaceholder />
) : node.event ? (
<SectionContent event={node.event} />
) : (
<SectionMissingPlaceholder />
)}
</section>
)
}
function PublicationTableOfContents({
entries,
readingStarted,
onStartReading,
className
}: {
entries: ReturnType<typeof flattenPublicationSectionTreeForToc>
readingStarted: boolean
onStartReading: () => void
className?: string
}) {
const { t } = useTranslation()
const scrollToSection = useCallback(
(id: string) => {
if (!readingStarted) return
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
},
[readingStarted]
)
if (entries.length === 0) return null
return (
<nav
className={cn('rounded-lg border border-border bg-muted/20 p-3', className)}
aria-label={t('Publication table of contents')}
>
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-foreground">
<BookOpen className="size-4 shrink-0 text-muted-foreground" aria-hidden />
{t('Publication table of contents')}
</div>
<ol className="max-h-64 space-y-0.5 overflow-y-auto text-sm">
{entries.map((entry) => (
<li key={entry.path}>
<button
type="button"
className={cn(
'w-full min-w-0 rounded py-1 pr-2 text-left text-muted-foreground',
readingStarted && 'hover:bg-accent hover:text-accent-foreground'
)}
style={{ paddingLeft: `${8 + entry.depth * 14}px` }}
disabled={!readingStarted}
onClick={() => scrollToSection(entry.id)}
>
<span className="break-words">{entry.title}</span>
</button>
</li>
))}
</ol>
{!readingStarted ? (
<button
type="button"
className="mt-3 w-full rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
onClick={onStartReading}
>
{t('Read this book')}
</button>
) : null}
</nav>
)
}
export default function PublicationIndexBody({
event,
className
}: {
event: Event
className?: string
}) {
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { favoriteRelays } = useFavoriteRelays()
const relayUrls = useMemo(
() =>
Array.from(
new Set([
...LIBRARY_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url),
...DOCUMENT_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url),
...currentBrowsingRelayUrls.map((url) => normalizeAnyRelayUrl(url) || url),
...favoriteRelays.map((url) => normalizeAnyRelayUrl(url) || url),
...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url)
])
).filter(Boolean) as string[],
[currentBrowsingRelayUrls, favoriteRelays]
)
const [readingStarted, setReadingStarted] = useState(false)
useEffect(() => {
setReadingStarted(false)
}, [event.id])
const { fetched, failedKeys, loadingKeys, requestLoad, readAhead } =
useProgressivePublicationContent(event, relayUrls, { enabled: readingStarted })
const sectionTree = useMemo(
() => buildPublicationSectionTree(event, fetched),
[event, fetched]
)
const tocEntries = useMemo(
() => flattenPublicationSectionTreeForToc(sectionTree),
[sectionTree]
)
const startReading = useCallback(() => {
setReadingStarted(true)
}, [])
useEffect(() => {
if (!readingStarted) return
const firstId = tocEntries[0]?.id
if (!firstId) return
requestAnimationFrame(() => {
document.getElementById(firstId)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}, [readingStarted, tocEntries])
const hasRefs = orderedPublicationRefsFromIndex(event).length > 0
if (!hasRefs) return null
return (
<div className={cn('min-w-0 space-y-4', className)}>
<PublicationTableOfContents
entries={tocEntries}
readingStarted={readingStarted}
onStartReading={startReading}
/>
{readingStarted ? (
<div>
{sectionTree.map((node) => (
<PublicationSectionNodeView
key={node.path}
node={node}
failedKeys={failedKeys}
loadingKeys={loadingKeys}
onRequestLoad={requestLoad}
onReadAhead={readAhead}
/>
))}
</div>
) : null}
</div>
)
}

208
src/components/Note/PublicationIndexMetadata.tsx

@ -1,208 +0,0 @@
import { ExtendedKind } from '@/constants'
import {
getPublicationIndexMetadataFromEvent,
type PublicationAuthor
} from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPageOptional } from '@/PageManager'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { ExternalLink } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import PublicationCoverFallback from './PublicationCoverFallback'
import PublicationCoverImage from './PublicationCoverImage'
import PublicationBooklistButton from './PublicationBooklistButton'
import PublicationIndexBody from './PublicationIndexBody'
function formatAuthorLine(authors: PublicationAuthor[]): string {
if (authors.length === 0) return ''
return authors
.map(({ name, role }) => {
const normalizedRole = role?.trim().toLowerCase()
if (!normalizedRole || normalizedRole === 'author') return name
return `${name} (${role})`
})
.join(' · ')
}
function formatPublicationType(type: string): string {
return type
.split(/[\s_-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join(' ')
}
function sourceHostname(source: string): string {
try {
return new URL(source).hostname.replace(/^www\./, '')
} catch {
return source
}
}
function MetaChip({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<span
className={cn(
'inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground',
className
)}
>
{children}
</span>
)
}
export default function PublicationIndexMetadata({
event,
variant = 'compact',
showTitle = true,
className
}: {
event: Event
variant?: 'compact' | 'full'
showTitle?: boolean
className?: string
}) {
const { t } = useTranslation()
const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event)
const metadata = useMemo(() => getPublicationIndexMetadataFromEvent(event), [event])
if (event.kind !== ExtendedKind.PUBLICATION) return null
const authorLine = formatAuthorLine(metadata.authors)
const isFull = variant === 'full'
const title =
metadata.title?.trim() ||
event.tags.find((tag) => tag[0] === 'd')?.[1]?.replace(/-/g, ' ') ||
t('Publication Note')
const metaChips: React.ReactNode[] = []
if (metadata.type) {
metaChips.push(<MetaChip key="type">{formatPublicationType(metadata.type)}</MetaChip>)
}
if (metadata.language) {
metaChips.push(<MetaChip key="lang">{metadata.language.toUpperCase()}</MetaChip>)
}
if (metadata.version) {
metaChips.push(<MetaChip key="version">{t('Publication version', { version: metadata.version })}</MetaChip>)
}
if (metadata.sectionCount > 0) {
metaChips.push(
<MetaChip key="sections">
{t('Publication sections', { count: metadata.sectionCount })}
</MetaChip>
)
}
const tagsComponent =
metadata.tags.length > 0 ? (
<div className="flex min-w-0 flex-wrap gap-1">
{metadata.tags.map((tag) => (
<button
key={tag}
type="button"
className="inline-flex max-w-full min-w-0 items-center gap-0.5 rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground"
onClick={(e) => {
e.stopPropagation()
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
}}
>
<span className="shrink-0">#</span>
<span className="min-w-0 truncate">{tag}</span>
</button>
))}
</div>
) : null
return (
<div className={cn('min-w-0 space-y-2', isFull && 'space-y-4', className)}>
{isFull && metadata.image?.trim() ? (
<PublicationCoverImage
imageUrl={metadata.image.trim()}
pubkey={event.pubkey}
autoLoadMedia={autoLoadMedia}
size="default"
layout="stacked"
className="mb-0"
/>
) : isFull ? (
<PublicationCoverFallback layout="stacked" size="default" className="mb-0" />
) : null}
{showTitle ? (
<div
className={cn(
'min-w-0 font-semibold break-words text-foreground',
isFull ? 'text-2xl leading-tight sm:text-3xl' : 'text-xl sm:line-clamp-2'
)}
>
{title}
</div>
) : null}
{authorLine ? (
<div
className={cn(
'min-w-0 break-words text-muted-foreground',
isFull ? 'text-base sm:text-lg' : 'text-sm line-clamp-2'
)}
>
{authorLine}
</div>
) : null}
{metaChips.length > 0 ? (
<div className="flex min-w-0 flex-wrap gap-1.5">{metaChips}</div>
) : null}
{metadata.releaseDate ? (
<div className={cn('text-muted-foreground', isFull ? 'text-sm' : 'text-xs')}>
{t('Publication released', { date: metadata.releaseDate })}
</div>
) : null}
{metadata.summary ? (
<div
className={cn(
'min-w-0 break-words text-muted-foreground',
isFull ? 'text-base leading-relaxed' : 'text-sm line-clamp-3'
)}
>
{metadata.summary}
</div>
) : null}
{metadata.source || tagsComponent || isFull ? (
<div className="flex min-w-0 flex-col gap-2">
{metadata.source ? (
<a
href={metadata.source}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex min-w-0 max-w-full items-center gap-1.5 text-primary hover:underline',
isFull ? 'text-sm' : 'text-xs'
)}
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="size-3.5 shrink-0" aria-hidden />
<span className="truncate">{sourceHostname(metadata.source)}</span>
</a>
) : null}
{tagsComponent}
{isFull ? <PublicationBooklistButton event={event} className="w-fit self-start" /> : null}
</div>
) : null}
{isFull && metadata.sectionCount > 0 ? <PublicationIndexBody event={event} /> : null}
</div>
)
}

314
src/components/Note/SelectionHighlightTrigger.tsx

@ -1,19 +1,12 @@
import { buildHighlightDataFromEvent } from '@/lib/build-highlight-data' import { buildHighlightDataFromEvent } from '@/lib/build-highlight-data'
import { isMobileBrowserProfile } from '@/lib/client-platform'
import { useCreateHighlight } from './CreateHighlightContext' import { useCreateHighlight } from './CreateHighlightContext'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { Highlighter } from 'lucide-react' import { Highlighter } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
/** After finger lift, wait before treating the gesture as finished (handle drag may still run). */
const MOBILE_TOUCH_END_SETTLE_MS = 600
/** After selection stops changing, wait before opening the drawer so handles can extend the range. */
const MOBILE_SELECTION_STABLE_MS = 1600
const DESKTOP_SELECTION_DELAY_MS = 50
function getParagraphContextFromRange(range: Range): string { function getParagraphContextFromRange(range: Range): string {
let node: Node | null = range.commonAncestorContainer let node: Node | null = range.commonAncestorContainer
if (node.nodeType !== Node.ELEMENT_NODE) node = node.parentElement if (node.nodeType !== Node.ELEMENT_NODE) node = node.parentElement
@ -28,46 +21,6 @@ function getParagraphContextFromRange(range: Range): string {
return range.toString().trim() return range.toString().trim()
} }
function isRangeInContainer(range: Range, container: HTMLElement): boolean {
const commonAncestor = range.commonAncestorContainer
if (commonAncestor.nodeType === Node.ELEMENT_NODE) {
if (container.contains(commonAncestor as Element)) return true
} else {
const parent = commonAncestor.parentElement
if (parent && container.contains(parent)) return true
}
try {
const contentRect = container.getBoundingClientRect()
const rangeRect = range.getBoundingClientRect()
return !(
rangeRect.bottom < contentRect.top ||
rangeRect.top > contentRect.bottom ||
rangeRect.right < contentRect.left ||
rangeRect.left > contentRect.right
)
} catch {
return false
}
}
function readSelectionInContainer(container: HTMLElement): {
selectedText: string
paragraphContext: string
rect: DOMRect
} | null {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null
const range = selection.getRangeAt(0)
if (!isRangeInContainer(range, container)) return null
const selectedText = selection.toString().trim()
if (!selectedText) return null
return {
selectedText,
paragraphContext: getParagraphContextFromRange(range),
rect: range.getBoundingClientRect()
}
}
export default function SelectionHighlightTrigger({ export default function SelectionHighlightTrigger({
event, event,
children children
@ -76,259 +29,144 @@ export default function SelectionHighlightTrigger({
children: React.ReactNode children: React.ReactNode
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const openHighlight = useCreateHighlight() const openHighlight = useCreateHighlight()
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const [selectedText, setSelectedText] = useState('') const [toolbar, setToolbar] = useState<{
const [paragraphContext, setParagraphContext] = useState('') selectedText: string
const [toolbarPos, setToolbarPos] = useState<{ top: number; left: number } | null>(null) paragraphContext: string
const [showMobileDrawer, setShowMobileDrawer] = useState(false) top: number
left: number
} | null>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const touchEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) // True while a touch is physically in contact with the screen.
const selectionStableTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const isTouchActiveRef = useRef(false)
const isSelectingRef = useRef(false)
const lastSelectionChangeRef = useRef(0) const evaluateSelection = useCallback(() => {
if (!openHighlight || !containerRef.current) return
const clearUi = useCallback(() => { const sel = window.getSelection()
setSelectedText('') if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
setParagraphContext('') setToolbar(null)
setToolbarPos(null) return
setShowMobileDrawer(false) }
}, []) const range = sel.getRangeAt(0)
if (!containerRef.current.contains(range.commonAncestorContainer)) {
const applySelection = useCallback( setToolbar(null)
(forceShow = false) => { return
if (!openHighlight || !containerRef.current) return }
const hit = readSelectionInContainer(containerRef.current) const selectedText = range.toString().trim()
if (!hit) { if (!selectedText) {
clearUi() setToolbar(null)
return return
} }
setSelectedText(hit.selectedText)
setParagraphContext(hit.paragraphContext)
if (isSmallScreen) {
if (forceShow || !isSelectingRef.current) {
setShowMobileDrawer(true)
setToolbarPos(null)
}
return
}
const toolbarHeight = 44 const rect = range.getBoundingClientRect()
const margin = 8 const toolbarHeight = 44
const top = const margin = 8
hit.rect.top - toolbarHeight < margin ? hit.rect.bottom + margin : hit.rect.top - toolbarHeight // Prefer above the selection; fall back to below if too close to top of viewport.
const rawLeft = hit.rect.left + hit.rect.width / 2 - 80 const top =
const left = Math.max(margin, Math.min(rawLeft, window.innerWidth - 176 - margin)) rect.top - toolbarHeight < margin ? rect.bottom + margin : rect.top - toolbarHeight
setToolbarPos({ top, left }) const rawLeft = rect.left + rect.width / 2 - 80
setShowMobileDrawer(false) const left = Math.max(margin, Math.min(rawLeft, window.innerWidth - 176 - margin))
},
[clearUi, isSmallScreen, openHighlight]
)
const scheduleDesktopSelection = useCallback(() => { setToolbar({ selectedText, paragraphContext: getParagraphContextFromRange(range), top, left })
if (debounceRef.current) clearTimeout(debounceRef.current) }, [openHighlight])
debounceRef.current = setTimeout(() => applySelection(true), DESKTOP_SELECTION_DELAY_MS)
}, [applySelection])
const scheduleMobileStableSelection = useCallback(() => { // Desktop: mouseup fires reliably after text selection by mouse.
lastSelectionChangeRef.current = Date.now() const handleMouseUp = useCallback(() => {
if (selectionStableTimeoutRef.current) clearTimeout(selectionStableTimeoutRef.current) evaluateSelection()
selectionStableTimeoutRef.current = setTimeout(() => { }, [evaluateSelection])
const elapsed = Date.now() - lastSelectionChangeRef.current
if (elapsed >= MOBILE_SELECTION_STABLE_MS && !isSelectingRef.current) {
applySelection(true)
}
}, MOBILE_SELECTION_STABLE_MS)
}, [applySelection])
useEffect(() => { useEffect(() => {
if (!openHighlight) return if (!openHighlight) return
const onMouseUp = (e: MouseEvent) => { const schedule = (delayMs: number) => {
if (isSmallScreen) return if (debounceRef.current) clearTimeout(debounceRef.current)
const el = debounceRef.current = setTimeout(evaluateSelection, delayMs)
e.target instanceof Element ? e.target : e.target instanceof Node ? e.target.parentElement : null
if (el?.closest('[data-selection-highlight-ui]')) return
scheduleDesktopSelection()
} }
// Mobile: finger touches screen — mark active so selectionchange is suppressed during
// the gesture itself (avoids positioning the toolbar mid-drag).
const onTouchStart = () => { const onTouchStart = () => {
if (!isSmallScreen) return isTouchActiveRef.current = true
isSelectingRef.current = true
if (selectionStableTimeoutRef.current) clearTimeout(selectionStableTimeoutRef.current)
setShowMobileDrawer(false)
}
const onTouchMove = () => {
if (!isSmallScreen) return
isSelectingRef.current = true
if (selectionStableTimeoutRef.current) clearTimeout(selectionStableTimeoutRef.current)
setShowMobileDrawer(false)
} }
// Mobile: finger lifts — wait for the browser to settle the selection, then evaluate.
// Shorter delay on coarse pointers; contextmenu (below) is the reliable path when the OS shows the callout.
const onTouchEnd = () => { const onTouchEnd = () => {
if (!isSmallScreen) return isTouchActiveRef.current = false
if (touchEndTimeoutRef.current) clearTimeout(touchEndTimeoutRef.current) schedule(isMobileBrowserProfile() ? 280 : 600)
touchEndTimeoutRef.current = setTimeout(() => {
isSelectingRef.current = false
scheduleMobileStableSelection()
}, MOBILE_TOUCH_END_SETTLE_MS)
} }
// Both: covers keyboard selection (Shift+Arrow) on desktop and selection-handle
// dragging on mobile (which may not generate touch events in our DOM).
const onSelectionChange = () => { const onSelectionChange = () => {
if (isSmallScreen) { if (isTouchActiveRef.current) return
lastSelectionChangeRef.current = Date.now() schedule(80)
if (isSelectingRef.current) return
const selection = window.getSelection()
const hasSelection =
selection &&
!selection.isCollapsed &&
selection.rangeCount > 0 &&
selection.toString().trim().length > 0
if (!hasSelection) {
if (selectionStableTimeoutRef.current) clearTimeout(selectionStableTimeoutRef.current)
clearUi()
return
}
scheduleMobileStableSelection()
return
}
scheduleDesktopSelection()
} }
// When the system opens the text callout / context menu, selection is still valid here; delayed
// touchend/selectionchange often misses on iOS/Android because the selection is cleared before we run.
const onContextMenu = (e: MouseEvent) => { const onContextMenu = (e: MouseEvent) => {
if (!containerRef.current) return if (!containerRef.current) return
const target = e.target const t = e.target
if (!(target instanceof Node) || !containerRef.current.contains(target)) return if (!(t instanceof Node) || !containerRef.current.contains(t)) return
queueMicrotask(() => applySelection(true)) queueMicrotask(() => evaluateSelection())
} }
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('touchstart', onTouchStart, { passive: true }) document.addEventListener('touchstart', onTouchStart, { passive: true })
document.addEventListener('touchmove', onTouchMove, { passive: true })
document.addEventListener('touchend', onTouchEnd, { passive: true }) document.addEventListener('touchend', onTouchEnd, { passive: true })
document.addEventListener('selectionchange', onSelectionChange) document.addEventListener('selectionchange', onSelectionChange)
document.addEventListener('contextmenu', onContextMenu) document.addEventListener('contextmenu', onContextMenu)
return () => { return () => {
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('touchstart', onTouchStart) document.removeEventListener('touchstart', onTouchStart)
document.removeEventListener('touchmove', onTouchMove)
document.removeEventListener('touchend', onTouchEnd) document.removeEventListener('touchend', onTouchEnd)
document.removeEventListener('selectionchange', onSelectionChange) document.removeEventListener('selectionchange', onSelectionChange)
document.removeEventListener('contextmenu', onContextMenu) document.removeEventListener('contextmenu', onContextMenu)
if (debounceRef.current) clearTimeout(debounceRef.current) if (debounceRef.current) clearTimeout(debounceRef.current)
if (touchEndTimeoutRef.current) clearTimeout(touchEndTimeoutRef.current)
if (selectionStableTimeoutRef.current) clearTimeout(selectionStableTimeoutRef.current)
} }
}, [ }, [openHighlight, evaluateSelection])
applySelection,
clearUi,
isSmallScreen,
openHighlight,
scheduleDesktopSelection,
scheduleMobileStableSelection
])
const handleCreateHighlight = useCallback(() => { const handleCreateHighlight = useCallback(() => {
if (!selectedText || !openHighlight) return if (!toolbar || !openHighlight) return
const highlightData = buildHighlightDataFromEvent(event, paragraphContext) const highlightData = buildHighlightDataFromEvent(event, toolbar.paragraphContext)
openHighlight(highlightData, selectedText) openHighlight(highlightData, toolbar.selectedText)
clearUi() setToolbar(null)
window.getSelection()?.removeAllRanges() window.getSelection()?.removeAllRanges()
}, [clearUi, event, openHighlight, paragraphContext, selectedText]) }, [event, toolbar, openHighlight])
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {
clearUi() setToolbar(null)
window.getSelection()?.removeAllRanges() }, [])
}, [clearUi])
if (!openHighlight) return <>{children}</> if (!openHighlight) return <>{children}</>
const showDesktopToolbar = !isSmallScreen && selectedText && toolbarPos
return ( return (
<div ref={containerRef} className="relative select-text"> <div ref={containerRef} onMouseUp={handleMouseUp} className="relative">
{children} {children}
{showDesktopToolbar ? ( {toolbar && (
<> <>
<div <div
className="highlight-button-container fixed z-[150] flex items-center gap-1 rounded-md border bg-background px-2 py-1.5 shadow-lg" className="fixed z-[150] flex items-center gap-1 rounded-md border bg-background px-2 py-1.5 shadow-lg"
data-selection-highlight-ui style={{ top: toolbar.top, left: toolbar.left }}
style={{ top: toolbarPos.top, left: toolbarPos.left }}
> >
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 gap-1.5" className="h-8 gap-1.5"
onClick={(e) => { onClick={handleCreateHighlight}
e.stopPropagation()
handleCreateHighlight()
}}
> >
<Highlighter className="h-4 w-4" /> <Highlighter className="h-4 w-4" />
{t('Create Highlight')} {t('Create Highlight')}
</Button> </Button>
<Button <Button type="button" variant="ghost" size="sm" className="h-8 px-2" onClick={handleDismiss}>
type="button"
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={(e) => {
e.stopPropagation()
handleDismiss()
}}
>
{t('Cancel')} {t('Cancel')}
</Button> </Button>
</div> </div>
<div <div className="fixed inset-0 z-[149]" aria-hidden onClick={handleDismiss} />
className="fixed inset-0 z-[149]"
aria-hidden
data-selection-highlight-ui
onClick={handleDismiss}
/>
</> </>
) : null} )}
{isSmallScreen ? (
<Drawer
open={showMobileDrawer && selectedText.length > 0}
onOpenChange={(open) => {
setShowMobileDrawer(open)
if (!open) handleDismiss()
}}
>
<DrawerContent data-selection-highlight-ui>
<DrawerHeader>
<DrawerTitle>{t('Create Highlight')}</DrawerTitle>
</DrawerHeader>
<div className="space-y-4 p-4 pb-8">
<div className="text-sm text-muted-foreground">{t('Selected text')}:</div>
<div className="break-words rounded-lg bg-muted p-3 text-sm">&ldquo;{selectedText}&rdquo;</div>
<Button
className="w-full"
onClick={(e) => {
e.stopPropagation()
handleCreateHighlight()
}}
>
<Highlighter className="mr-2 h-4 w-4" />
{t('Create Highlight')}
</Button>
</div>
</DrawerContent>
</Drawer>
) : null}
</div> </div>
) )
} }

5
src/components/Note/WikiCard.tsx

@ -2,7 +2,7 @@ import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link' import { toNote, toNoteList } from '@/lib/link'
import { useSecondaryPageOptional } from '@/PageManager' import { useSecondaryPageOptional } from '@/PageManager'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
@ -19,7 +19,8 @@ export default function WikiCard({
const isSmallScreen = screenSize?.isSmallScreen ?? false const isSmallScreen = screenSize?.isSmallScreen ?? false
const secondaryPage = useSecondaryPageOptional() const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event) const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content]) const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content])
const summaryText = (metadata.summary?.trim() || bodyBlurb).trim() const summaryText = (metadata.summary?.trim() || bodyBlurb).trim()

54
src/components/Note/index.tsx

@ -1,5 +1,4 @@
import { useSmartNoteNavigationOptional } from '@/PageManager' import { useSmartNoteNavigationOptional } from '@/PageManager'
import { getContentWarningLabel } from '@/lib/content-warning'
import { ExtendedKind, isMusicTrackKind, isNip71StyleVideoKind, publicAssetUrl } from '@/constants' import { ExtendedKind, isMusicTrackKind, isNip71StyleVideoKind, publicAssetUrl } from '@/constants'
import { isRenderableNoteKind } from '@/lib/note-renderable-kinds' import { isRenderableNoteKind } from '@/lib/note-renderable-kinds'
import { import {
@ -12,7 +11,7 @@ import {
import { mergeNip84MarkedIntervals, renderPlaintextWithNip84MergedMarks } from '@/lib/nip84-op-body-marks' import { mergeNip84MarkedIntervals, renderPlaintextWithNip84MergedMarks } from '@/lib/nip84-op-body-marks'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder' import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { toNote } from '@/lib/link' import { encodeArticleLikePublicationNaddr, openAlexandriaPublicationFromNaddr, toNote } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
DISCUSSION_DOWNVOTE_DISPLAY, DISCUSSION_DOWNVOTE_DISPLAY,
@ -24,7 +23,6 @@ import {
} from '@/hooks/useNotificationReactionDisplay' } from '@/hooks/useNotificationReactionDisplay'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import client from '@/services/client.service' import client from '@/services/client.service'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useMuteListOptional } from '@/contexts/mute-list-context' import { useMuteListOptional } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
@ -66,7 +64,6 @@ import LiveEvent from './LiveEvent'
import MarkdownArticle from './MarkdownArticle/MarkdownArticle' import MarkdownArticle from './MarkdownArticle/MarkdownArticle'
import AsciidocArticle from './AsciidocArticle/AsciidocArticle' import AsciidocArticle from './AsciidocArticle/AsciidocArticle'
import PublicationCard from './PublicationCard' import PublicationCard from './PublicationCard'
import PublicationIndexMetadata from './PublicationIndexMetadata'
import NostrSpecCard from './NostrSpecCard' import NostrSpecCard from './NostrSpecCard'
import WikiCard from './WikiCard' import WikiCard from './WikiCard'
import LongFormCard from './LongFormCard' import LongFormCard from './LongFormCard'
@ -77,6 +74,7 @@ import Poll from './Poll'
import NotificationEventCard from './NotificationEventCard' import NotificationEventCard from './NotificationEventCard'
import ReactionEmojiDisplay from './ReactionEmojiDisplay' import ReactionEmojiDisplay from './ReactionEmojiDisplay'
import UnknownNote from './UnknownNote' import UnknownNote from './UnknownNote'
import { Button } from '@/components/ui/button'
import VideoNote from './VideoNote' import VideoNote from './VideoNote'
import MusicTrackNote from './MusicTrackNote' import MusicTrackNote from './MusicTrackNote'
import RelayReview from './RelayReview' import RelayReview from './RelayReview'
@ -278,7 +276,7 @@ export default function Note({
const parentFetchRelayHints = useMemo(() => relayHintsFromEventTags(event), [event]) const parentFetchRelayHints = useMemo(() => relayHintsFromEventTags(event), [event])
const contentPolicy = useContentPolicyOptional() const contentPolicy = useContentPolicyOptional()
const defaultShowNsfw = contentPolicy?.defaultShowNsfw ?? true const defaultShowNsfw = contentPolicy?.defaultShowNsfw ?? true
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event) const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const [showNsfw, setShowNsfw] = useState(false) const [showNsfw, setShowNsfw] = useState(false)
const muteList = useMuteListOptional() const muteList = useMuteListOptional()
const mutePubkeySet = muteList?.mutePubkeySet ?? new Set<string>() const mutePubkeySet = muteList?.mutePubkeySet ?? new Set<string>()
@ -431,7 +429,7 @@ export default function Note({
} else if (muteSetHas(mutePubkeySet, event.pubkey) && !showMuted) { } else if (muteSetHas(mutePubkeySet, event.pubkey) && !showMuted) {
content = <MutedNote show={() => setShowMuted(true)} /> content = <MutedNote show={() => setShowMuted(true)} />
} else if (!defaultShowNsfw && isNsfwEvent(event) && !showNsfw) { } else if (!defaultShowNsfw && isNsfwEvent(event) && !showNsfw) {
content = <NsfwNote show={() => setShowNsfw(true)} label={getContentWarningLabel(event)} /> content = <NsfwNote show={() => setShowNsfw(true)} />
} else if (isNip25ReactionKind(event.kind)) { } else if (isNip25ReactionKind(event.kind)) {
content = null content = null
} else if (isNip18RepostKind(displayEvent.kind)) { } else if (isNip18RepostKind(displayEvent.kind)) {
@ -454,7 +452,6 @@ export default function Note({
} else if (event.kind === ExtendedKind.WEB_BOOKMARK) { } else if (event.kind === ExtendedKind.WEB_BOOKMARK) {
const href = getWebBookmarkArticleUrl(displayEvent) const href = getWebBookmarkArticleUrl(displayEvent)
const title = displayEvent.tags.find((tag) => tag[0] === 'title')?.[1]?.trim() const title = displayEvent.tags.find((tag) => tag[0] === 'title')?.[1]?.trim()
const description = displayEvent.content?.trim()
content = ( content = (
<> <>
{title ? ( {title ? (
@ -470,12 +467,10 @@ export default function Note({
> >
{href} {href}
</a> </a>
<WebPreview url={href} className="w-full" authorPubkey={event.pubkey} sourceEvent={event} /> <WebPreview url={href} className="w-full" />
</div> </div>
) : null} ) : null}
{description ? ( {displayEvent.content?.trim() ? renderEventContent({ hideMetadata: true }) : null}
<p className="mt-2 text-base whitespace-pre-wrap break-words">{description}</p>
) : null}
</> </>
) )
} else if (event.kind === ExtendedKind.WIKI_ARTICLE) { } else if (event.kind === ExtendedKind.WIKI_ARTICLE) {
@ -492,7 +487,25 @@ export default function Note({
) )
} else if (event.kind === ExtendedKind.PUBLICATION) { } else if (event.kind === ExtendedKind.PUBLICATION) {
if (showFull) { if (showFull) {
content = <PublicationIndexMetadata className="mt-2" event={displayEvent} variant="full" /> const naddrFull = encodeArticleLikePublicationNaddr(displayEvent)
content = (
<div className="mt-2 space-y-3">
<PublicationCard event={displayEvent} disableNavigation />
{naddrFull ? (
<Button
type="button"
size="lg"
className="w-full font-semibold"
onClick={(e) => {
e.stopPropagation()
openAlexandriaPublicationFromNaddr(naddrFull)
}}
>
{t('View on Alexandria')}
</Button>
) : null}
</div>
)
} else { } else {
content = <PublicationCard className="mt-2" event={displayEvent} /> content = <PublicationCard className="mt-2" event={displayEvent} />
} }
@ -545,7 +558,7 @@ export default function Note({
<> <>
{voiceArticleUrl && ( {voiceArticleUrl && (
<div className="mt-2 not-prose max-w-full"> <div className="mt-2 not-prose max-w-full">
<WebPreview url={voiceArticleUrl} className="w-full" authorPubkey={event.pubkey} sourceEvent={event} /> <WebPreview url={voiceArticleUrl} className="w-full" />
</div> </div>
)} )}
<AudioPlayer className="mt-2" src={event.content} /> <AudioPlayer className="mt-2" src={event.content} />
@ -625,20 +638,7 @@ export default function Note({
onClick={disableClick ? undefined : (e) => { onClick={disableClick ? undefined : (e) => {
// Don't navigate if clicking on interactive elements // Don't navigate if clicking on interactive elements
const target = e.target as HTMLElement const target = e.target as HTMLElement
if (window.getSelection()?.toString().trim()) { if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-embedded-note]') || target.closest('[data-parent-note-preview]') || target.closest('[data-user-avatar]') || target.closest('[data-username]')) {
return
}
if (
target.closest('button') ||
target.closest('[role="button"]') ||
target.closest('a') ||
target.closest('[data-embedded-note]') ||
target.closest('[data-parent-note-preview]') ||
target.closest('[data-user-avatar]') ||
target.closest('[data-username]') ||
target.closest('[data-selection-highlight-ui]') ||
target.closest('.highlight-button-container')
) {
return return
} }
e.stopPropagation() e.stopPropagation()

2
src/components/NoteCard/MainNoteCard.tsx

@ -3,7 +3,6 @@ import { ExtendedKind, isNip52CalendarCardKind } from '@/constants'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { preloadNotePageChunk } from '@/pages/secondary/NotePage/NotePageRoute'
import { useSmartNoteNavigationOptional } from '@/PageManager' import { useSmartNoteNavigationOptional } from '@/PageManager'
import client from '@/services/client.service' import client from '@/services/client.service'
import { Pin } from 'lucide-react' import { Pin } from 'lucide-react'
@ -86,7 +85,6 @@ function MainNoteCard({
<div <div
className={className} className={className}
data-event-id={event.id} data-event-id={event.id}
onPointerEnter={preloadNotePageChunk}
onClick={(e) => { onClick={(e) => {
// Don't navigate when user has selected text (e.g. for creating a highlight) // Don't navigate when user has selected text (e.g. for creating a highlight)
const sel = window.getSelection() const sel = window.getSelection()

5
src/components/NoteDrawer/index.tsx

@ -72,7 +72,7 @@ export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }:
<Sheet open={open} onOpenChange={onOpenChange} registerWithModalManager={false}> <Sheet open={open} onOpenChange={onOpenChange} registerWithModalManager={false}>
<SheetContent <SheetContent
side="right" side="right"
className="relative flex h-full w-full flex-col overflow-hidden p-0 sm:max-w-[1042px]" className="relative w-full overscroll-contain sm:max-w-[1042px] overflow-y-auto p-0"
hideClose hideClose
onPointerDownOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)} onPointerDownOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)}
onInteractOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)} onInteractOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)}
@ -83,9 +83,8 @@ export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }:
style={{ width: MOBILE_SWIPE_BACK_EDGE_PX }} style={{ width: MOBILE_SWIPE_BACK_EDGE_PX }}
aria-hidden aria-hidden
/> />
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden touch-pan-y"> <div className="min-h-full touch-pan-y">
<NotePage <NotePage
key={displayNoteId}
id={displayNoteId} id={displayNoteId}
index={currentIndex} index={currentIndex}
hideTitlebar={false} hideTitlebar={false}

707
src/components/NoteList/index.tsx

@ -32,14 +32,11 @@ import { isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal
import { eventSeenOnMatchesAllowlist } from '@/lib/relay-allowlist' import { eventSeenOnMatchesAllowlist } from '@/lib/relay-allowlist'
import { uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls' import { uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls'
import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { collapseStaleAddressableRevisions } from '@/lib/replaceable-revision'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge' import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
import { fetchProfilesMetadataBatch } from '@/lib/profile-metadata-batch'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { useFeedAttestedSuperchatIds } from '@/hooks/useFeedAttestedSuperchatIds' import { useFeedAttestedSuperchatIds } from '@/hooks/useFeedAttestedSuperchatIds'
import { shouldIncludePaymentInFeed } from '@/lib/superchat' import { shouldIncludePaymentInFeed } from '@/lib/superchat'
import { scrollActivity } from '@/lib/scroll-activity.service'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useDeletedEventSafe } from '@/providers/DeletedEventProvider' import { useDeletedEventSafe } from '@/providers/DeletedEventProvider'
@ -114,10 +111,7 @@ import {
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 { import { stripNostrLandAggrFromTimelineSubRequests } from '@/lib/home-feed-relays'
ensureHomeFeedTrendingRelay,
stripNostrLandAggrFromTimelineSubRequests
} from '@/lib/home-feed-relays'
import { createFetchEventsFeedRuntimeLoader } from '@/features/feed/client-loader' import { createFetchEventsFeedRuntimeLoader } from '@/features/feed/client-loader'
import { FeedRuntime } from '@/features/feed/runtime' import { FeedRuntime } from '@/features/feed/runtime'
import { buildFeedDiagnosticsSnapshot, logFeedDiagnostics } from '@/features/feed/diagnostics' import { buildFeedDiagnosticsSnapshot, logFeedDiagnostics } from '@/features/feed/diagnostics'
@ -366,10 +360,9 @@ function mergeEventBatchesById(
for (const e of incoming) { for (const e of incoming) {
byId.set(e.id, e) byId.set(e.id, e)
} }
return collapseStaleAddressableRevisions( return Array.from(byId.values())
Array.from(byId.values()) .sort((a, b) => b.created_at - a.created_at)
.sort((a, b) => b.created_at - a.created_at) .slice(0, cap)
).slice(0, cap)
} }
/** Multi-layer search: keep all existing rows, add new ids only; newer `created_at` wins on duplicate id. No cap. */ /** Multi-layer search: keep all existing rows, add new ids only; newer `created_at` wins on duplicate id. No cap. */
@ -820,8 +813,6 @@ const NoteList = forwardRef(
* sits on that row instead of an extra bar above the list. Omitted on spells / standalone NoteList. * sits on that row instead of an extra bar above the list. Omitted on spells / standalone NoteList.
*/ */
feedClientFilterTabRowHost, feedClientFilterTabRowHost,
/** When set with {@link feedClientFilterTabRowHost}, portaled filter panel renders here (e.g. profile: above pins). */
feedClientFilterPanelHost,
onSingleRelayKindlessEmpty, onSingleRelayKindlessEmpty,
onSingleRelayBrowseEmpty, onSingleRelayBrowseEmpty,
feedTopNotice, feedTopNotice,
@ -897,7 +888,6 @@ const NoteList = forwardRef(
showFeedClientFilter?: boolean showFeedClientFilter?: boolean
hostPrimaryPageName?: TPrimaryPageName hostPrimaryPageName?: TPrimaryPageName
feedClientFilterTabRowHost?: HTMLElement | null feedClientFilterTabRowHost?: HTMLElement | null
feedClientFilterPanelHost?: HTMLElement | null
/** Single-relay kindless: if EOSE with no events, parent switches to explicit kinds in `subRequests`. */ /** Single-relay kindless: if EOSE with no events, parent switches to explicit kinds in `subRequests`. */
onSingleRelayKindlessEmpty?: () => void onSingleRelayKindlessEmpty?: () => void
/** Relay explore: explicit kinds EOSE empty — parent retries kindless `{ limit }` once. */ /** Relay explore: explicit kinds EOSE empty — parent retries kindless `{ limit }` once. */
@ -1065,19 +1055,12 @@ const NoteList = forwardRef(
// Memoize subRequests serialization to avoid expensive JSON.stringify on every render // Memoize subRequests serialization to avoid expensive JSON.stringify on every render
const subRequestsKey = useMemo(() => legacyFeedSubscriptionKey(subRequests), [subRequests]) const subRequestsKey = useMemo(() => legacyFeedSubscriptionKey(subRequests), [subRequests])
const feedRelayUrls = useMemo(() => { const feedRelayUrls = useMemo(
const urls = uniqueRelayUrlsFromSubRequests(subRequests) () => uniqueRelayUrlsFromSubRequests(subRequests),
if (feedSubscriptionKey === 'home-all-favorites') { [subRequestsKey]
return ensureHomeFeedTrendingRelay(urls) )
}
return urls
}, [subRequestsKey, feedSubscriptionKey])
const feedAttestedSuperchatIds = useFeedAttestedSuperchatIds(feedRelayUrls) const feedAttestedSuperchatIds = useFeedAttestedSuperchatIds(feedRelayUrls)
const feedAttestedSuperchatIdsRef = useRef(feedAttestedSuperchatIds)
useEffect(() => {
feedAttestedSuperchatIdsRef.current = feedAttestedSuperchatIds
}, [feedAttestedSuperchatIds])
const followingFeedDeltaSubRequestsKey = useMemo( const followingFeedDeltaSubRequestsKey = useMemo(
() => () =>
@ -1126,14 +1109,9 @@ const NoteList = forwardRef(
const primaryPageCtx = usePrimaryPageOptional() const primaryPageCtx = usePrimaryPageOptional()
const primaryPageCurrent = primaryPageCtx?.current ?? null const primaryPageCurrent = primaryPageCtx?.current ?? null
const primaryPanelFrozen = primaryPageCtx?.frozen ?? false const primaryPanelFrozen = primaryPageCtx?.frozen ?? false
const primaryFeedDisplayed = primaryPageCtx?.display ?? true /** Only pause timelines on the active primary page feed — not secondary-panel profiles, search, etc. */
/**
* Pause timelines only when the active primary feed is hidden (e.g. mobile note takeover,
* single-pane sheet). Double-pane and mobile feed overlay keep `display` true keep loading.
*/
const pauseTimelineForPrimaryFreeze = const pauseTimelineForPrimaryFreeze =
primaryPanelFrozen && primaryPanelFrozen &&
!primaryFeedDisplayed &&
hostPrimaryPageName != null && hostPrimaryPageName != null &&
hostPrimaryPageName === primaryPageCurrent hostPrimaryPageName === primaryPageCurrent
@ -1397,23 +1375,20 @@ const NoteList = forwardRef(
[withKindFilter, showAllKinds] [withKindFilter, showAllKinds]
) )
const pinnedEventHexIdSet = useMemo(() => {
const set = new Set<string>()
pinnedEventIds.forEach((id) => {
try {
const { type, data } = decode(id)
if (type === 'nevent') {
set.add(data.id)
}
} catch {
// ignore
}
})
return set
}, [pinnedEventIds])
const shouldHideEvent = useCallback( const shouldHideEvent = useCallback(
(evt: Event) => { (evt: Event) => {
const pinnedEventHexIdSet = new Set()
pinnedEventIds.forEach((id) => {
try {
const { type, data } = decode(id)
if (type === 'nevent') {
pinnedEventHexIdSet.add(data.id)
}
} catch {
// ignore
}
})
if (pinnedEventHexIdSet.has(evt.id)) return true if (pinnedEventHexIdSet.has(evt.id)) return true
if (isEventDeleted(evt)) return true if (isEventDeleted(evt)) return true
if (hideReplies && isReplyNoteEvent(evt)) return true if (hideReplies && isReplyNoteEvent(evt)) return true
@ -1433,7 +1408,7 @@ const NoteList = forwardRef(
if ( if (
!shouldIncludePaymentInFeed( !shouldIncludePaymentInFeed(
evt, evt,
feedAttestedSuperchatIdsRef.current, feedAttestedSuperchatIds,
incomingPaymentRecipientPubkey incomingPaymentRecipientPubkey
) )
) { ) {
@ -1464,8 +1439,9 @@ const NoteList = forwardRef(
hideReplies, hideReplies,
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
mutePubkeySet, mutePubkeySet,
pinnedEventHexIdSet, pinnedEventIds,
isEventDeleted, isEventDeleted,
feedAttestedSuperchatIds,
incomingPaymentRecipientPubkey, incomingPaymentRecipientPubkey,
extraShouldHideEvent, extraShouldHideEvent,
homeFeedActiveSeenOnAllowlist, homeFeedActiveSeenOnAllowlist,
@ -1763,6 +1739,15 @@ const NoteList = forwardRef(
clientFilteredVisibleCountRef.current = clientFilteredEvents.length clientFilteredVisibleCountRef.current = clientFilteredEvents.length
}, [clientFilteredEvents.length]) }, [clientFilteredEvents.length])
const visibleNoteIdsForStatsPrefetchKey = useMemo(
() =>
clientFilteredEvents
.slice(0, Math.min(120, Math.max(showCount + 64, 64)))
.map((e) => e.id)
.join('\n'),
[clientFilteredEvents, showCount]
)
const enqueueFeedProfilePubkeys = useCallback((need: string[]) => { const enqueueFeedProfilePubkeys = useCallback((need: string[]) => {
if (need.length === 0) return if (need.length === 0) return
const gen = feedProfileBatchGenRef.current const gen = feedProfileBatchGenRef.current
@ -1788,7 +1773,7 @@ const NoteList = forwardRef(
chunks.push(need.slice(i, i + FEED_PROFILE_CHUNK)) chunks.push(need.slice(i, i + FEED_PROFILE_CHUNK))
} }
const settled = await Promise.allSettled( const settled = await Promise.allSettled(
chunks.map((chunk) => fetchProfilesMetadataBatch(chunk)) chunks.map((chunk) => client.fetchProfilesForPubkeys(chunk))
) )
if (gen !== feedProfileBatchGenRef.current) return if (gen !== feedProfileBatchGenRef.current) return
@ -1826,6 +1811,51 @@ const NoteList = forwardRef(
})() })()
}, []) }, [])
const statsProfilePrefetchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingStatsProfilePubkeysRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (!visibleNoteIdsForStatsPrefetchKey) return
const ids = visibleNoteIdsForStatsPrefetchKey.split('\n').filter(Boolean)
const flushStatsProfiles = () => {
statsProfilePrefetchDebounceRef.current = null
const need = [...pendingStatsProfilePubkeysRef.current].filter(
(pk) => !feedProfileLoadedRef.current.has(pk)
)
pendingStatsProfilePubkeysRef.current.clear()
enqueueFeedProfilePubkeys(need)
}
const onStatsUpdate = (noteId: string) => {
const candidates = new Set<string>()
collectProfilePrefetchPubkeysFromNoteStats(noteStatsService.getNoteStats(noteId), candidates)
for (const pk of candidates) {
if (!feedProfileLoadedRef.current.has(pk)) {
pendingStatsProfilePubkeysRef.current.add(pk)
}
}
if (pendingStatsProfilePubkeysRef.current.size === 0) return
if (statsProfilePrefetchDebounceRef.current) {
clearTimeout(statsProfilePrefetchDebounceRef.current)
}
statsProfilePrefetchDebounceRef.current = setTimeout(
flushStatsProfiles,
FEED_PROFILE_BATCH_DEBOUNCE_MS
)
}
const unsubs = ids.map((id) => noteStatsService.subscribeNoteStats(id, () => onStatsUpdate(id)))
return () => {
unsubs.forEach((u) => u())
if (statsProfilePrefetchDebounceRef.current) {
clearTimeout(statsProfilePrefetchDebounceRef.current)
statsProfilePrefetchDebounceRef.current = null
}
pendingStatsProfilePubkeysRef.current.clear()
}
}, [visibleNoteIdsForStatsPrefetchKey, enqueueFeedProfilePubkeys])
const clientFilteredNewEvents = useMemo( const clientFilteredNewEvents = useMemo(
() => () =>
showFeedClientFilter ? applyClientFeedFilter(filteredNewEvents) : filteredNewEvents, showFeedClientFilter ? applyClientFeedFilter(filteredNewEvents) : filteredNewEvents,
@ -1879,12 +1909,11 @@ const NoteList = forwardRef(
const handle = window.setTimeout(() => { const handle = window.setTimeout(() => {
const candidates = new Set<string>() const candidates = new Set<string>()
const emojiAuthors = new Set<string>() const emojiAuthors = new Set<string>()
const profilePrefetchCap = Math.min(120, Math.max(showCount + 64, 64)) for (const e of timelineEventsForFilter) {
for (const e of filteredEvents.slice(0, profilePrefetchCap)) {
collectProfilePrefetchPubkeysFromEvent(e, candidates) collectProfilePrefetchPubkeysFromEvent(e, candidates)
collectReactionAuthorPubkeysForEmojiPrefetch([e], emojiAuthors) collectReactionAuthorPubkeysForEmojiPrefetch([e], emojiAuthors)
} }
for (const e of newEvents.slice(0, 32)) { for (const e of newEvents) {
collectProfilePrefetchPubkeysFromEvent(e, candidates) collectProfilePrefetchPubkeysFromEvent(e, candidates)
collectReactionAuthorPubkeysForEmojiPrefetch([e], emojiAuthors) collectReactionAuthorPubkeysForEmojiPrefetch([e], emojiAuthors)
} }
@ -1901,7 +1930,7 @@ const NoteList = forwardRef(
}, FEED_PROFILE_BATCH_DEBOUNCE_MS) }, FEED_PROFILE_BATCH_DEBOUNCE_MS)
return () => window.clearTimeout(handle) return () => window.clearTimeout(handle)
}, [ }, [
filteredEvents, timelineEventsForFilter,
newEvents, newEvents,
clientFilteredEvents, clientFilteredEvents,
showCount, showCount,
@ -2246,11 +2275,9 @@ const NoteList = forwardRef(
) )
.filter((req) => req.urls.length > 0) .filter((req) => req.urls.length > 0)
if (mapped.length === 0) return if (mapped.length === 0) return
const diskReq = mapped as Array<{ urls: string[]; filter: TSubRequestFilter }> const disk = await client.getTimelineDiskSnapshotEvents(
const disk = await client.getLocalFeedEvents(diskReq, { mapped as Array<{ urls: string[]; filter: TSubRequestFilter }>
maxRowsScanned: 50_000, )
maxMatches: Math.min(FEED_FULL_SEARCH_MERGE_CAP, Math.max(LIMIT, 200))
})
if (diskPrimeCancelled || timelineEffectStale() || !disk.length) return if (diskPrimeCancelled || timelineEffectStale() || !disk.length) return
const cap = areAlgoRelays ? ALGO_LIMIT : LIMIT const cap = areAlgoRelays ? ALGO_LIMIT : LIMIT
const merged = collapseDuplicateNip18RepostTimelineRows(mergeEventBatchesById([], disk, cap, areAlgoRelays)) const merged = collapseDuplicateNip18RepostTimelineRows(mergeEventBatchesById([], disk, cap, areAlgoRelays))
@ -2533,20 +2560,6 @@ const NoteList = forwardRef(
? ALGO_LIMIT ? ALGO_LIMIT
: LIMIT : LIMIT
const paintLocalWarmupTimeline = (merged: Event[], variant: string) => {
if (merged.length === 0 || timelineEffectStale()) return
timelineMergeBootstrapRef.current = merged.slice()
setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = { variant, mergedCount: merged.length }
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
}
/** Profile feeds: bounded fetch in parallel with subscribe (do not wait for EOSE / outcomes). */ /** Profile feeds: bounded fetch in parallel with subscribe (do not wait for EOSE / outcomes). */
const runProfileTimelineNetworkFetch = (variant: string) => { const runProfileTimelineNetworkFetch = (variant: string) => {
if (!profileAuthorWarmSpecForRefresh || !profileMappedForRefresh) return if (!profileAuthorWarmSpecForRefresh || !profileMappedForRefresh) return
@ -2606,18 +2619,12 @@ const NoteList = forwardRef(
) )
} }
const shouldAwaitLocalDiskWarmup =
!oneShotFetch &&
mappedSubRequests.length > 0 &&
!relayAuthoritativeFeedOnlyRef.current
const isSpellPageLocalWarmup = const isSpellPageLocalWarmup =
hostPrimaryPageName === 'spells' && shouldAwaitLocalDiskWarmup hostPrimaryPageName === 'spells' && !oneShotFetch && mappedSubRequests.length > 0
/** /**
* Session + IndexedDB hydration without blocking relay REQ/subscribe. Merges the same way as live * Session + IndexedDB hydration without blocking relay REQ/subscribe. Merges the same way as live
* {@link onEvents} so rows appear as soon as local sources resolve. * {@link onEvents} so rows appear as soon as local sources resolve.
* Skipped when {@link shouldAwaitLocalDiskWarmup} already painted from disk in `init`.
*/ */
const startNonBlockingTimelineDiskPrime = () => { const startNonBlockingTimelineDiskPrime = () => {
const strictSingleRelayAuthoritative = const strictSingleRelayAuthoritative =
@ -2627,7 +2634,7 @@ const NoteList = forwardRef(
(allowKindlessRelayExploreRef.current && useFilterAsIsRef.current)) (allowKindlessRelayExploreRef.current && useFilterAsIsRef.current))
if (relayAuthoritativeFeedOnlyRef.current && !strictSingleRelayAuthoritative) return if (relayAuthoritativeFeedOnlyRef.current && !strictSingleRelayAuthoritative) return
if (oneShotFetch || mappedSubRequests.length === 0) return if (oneShotFetch || mappedSubRequests.length === 0) return
if (shouldAwaitLocalDiskWarmup) return if (isSpellPageLocalWarmup) return
const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>
const strictSingleRelayShard = const strictSingleRelayShard =
mappedSubRequests.length === 1 && mappedSubRequests.length === 1 &&
@ -2757,54 +2764,80 @@ const NoteList = forwardRef(
setLoading(!!oneShotFetch) setLoading(!!oneShotFetch)
} else { } else {
let primedFromDisk = false let primedFromDisk = false
let localMergeBase: Event[] = [] let spellLocalMergeBase: Event[] = []
const profileMapped = mappedSubRequests as Array<{
urls: string[] if (isSpellPageLocalWarmup) {
filter: TSubRequestFilter const shardFilters = mappedSubRequests.map(({ filter }) => filter as Filter)
}> const matchesSpellLocal = (ev: Event) =>
const profileAuthorWarmSpec = getProfileAuthorWarmupSpec(profileMapped) shardFilters.some((f) => eventMatchesSubRequestFilterWithWindow(ev, f))
const kindsForScan = unionKindsForSpellLocalWarmup(
if (shouldAwaitLocalDiskWarmup) { shardFilters,
if (isSpellPageLocalWarmup) { effectiveShowKindsRef.current
const shardFilters = mappedSubRequests.map(({ filter }) => filter as Filter) )
const matchesSpellLocal = (ev: Event) => const sinceTightest = tightestSinceFromSpellFilters(shardFilters)
shardFilters.some((f) => eventMatchesSubRequestFilterWithWindow(ev, f)) const localLayerCap = Math.min(
const kindsForScan = unionKindsForSpellLocalWarmup( FEED_FULL_SEARCH_MERGE_CAP,
shardFilters, Math.max(eventCapEarly, 200)
effectiveShowKindsRef.current )
) const sessionScanCap = Math.min(800, localLayerCap * 4)
const sinceTightest = tightestSinceFromSpellFilters(shardFilters)
const localLayerCap = Math.min( const sessionHits = client
FEED_FULL_SEARCH_MERGE_CAP, .getSessionEventsMatchingSearch('', sessionScanCap, kindsForScan)
Math.max(eventCapEarly, 200) .filter(matchesSpellLocal)
) .sort((a, b) => b.created_at - a.created_at)
const sessionScanCap = Math.min(800, localLayerCap * 4)
if (!timelineEffectStale() && sessionHits.length > 0) {
const sessionHits = client const narrowedS = narrowLiveBatch(sessionHits)
.getSessionEventsMatchingSearch('', sessionScanCap, kindsForScan) if (narrowedS.length > 0) {
.filter(matchesSpellLocal) const mergedS = collapseDuplicateNip18RepostTimelineRows(
.sort((a, b) => b.created_at - a.created_at) mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays)
)
if (!timelineEffectStale() && sessionHits.length > 0) { if (mergedS.length > 0) {
const narrowedS = narrowLiveBatch(sessionHits) spellLocalMergeBase = mergedS
if (narrowedS.length > 0) { timelineMergeBootstrapRef.current = mergedS.slice()
const mergedS = collapseDuplicateNip18RepostTimelineRows( setEvents(mergedS)
mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays) lastEventsForTimelinePrefetchRef.current = mergedS
) setNewEvents([])
if (mergedS.length > 0) { setShowCount(revealBatchSize ?? SHOW_COUNT)
localMergeBase = mergedS setLoading(false)
primedFromDisk = true feedPaintRelayPendingRef.current = true
paintLocalWarmupTimeline(mergedS, 'spell_local_session') feedPaintRelayMetaRef.current = {
variant: 'spell_local_session',
mergedCount: mergedS.length
} }
primedFromDisk = true
} }
} }
}
void (async () => {
try { try {
const filterAwareDiskReq = mappedSubRequests as Array<{ const filterAwareDiskReq = mappedSubRequests as Array<{
urls: string[] urls: string[]
filter: TSubRequestFilter filter: TSubRequestFilter
}> }>
const mergeSpellLocalDiskLayer = (incoming: Event[], variant: string) => {
if (!effectActive || timelineEffectStale()) return
const narrowed = narrowLiveBatch(incoming)
if (narrowed.length === 0) return
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(spellLocalMergeBase, narrowed, eventCapEarly, areAlgoRelays)
)
if (merged.length === 0) return
spellLocalMergeBase = merged
timelineMergeBootstrapRef.current = merged.slice()
setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = { variant, mergedCount: merged.length }
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
}
const mentionRecipients = recipientPubkeysFromSpellFilters(shardFilters) const mentionRecipients = recipientPubkeysFromSpellFilters(shardFilters)
if (mentionRecipients.length === 1) { if (mentionRecipients.length === 1) {
try { try {
@ -2812,19 +2845,10 @@ const NoteList = forwardRef(
mentionRecipients[0]!, mentionRecipients[0]!,
localLayerCap localLayerCap
) )
const payRows = paymentNotifications.filter(matchesSpellLocal) mergeSpellLocalDiskLayer(
if (payRows.length > 0 && !timelineEffectStale()) { paymentNotifications.filter(matchesSpellLocal),
const narrowedPay = narrowLiveBatch(payRows) 'spell_payment_notifications_idb'
if (narrowedPay.length > 0) { )
const mergedPay = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(localMergeBase, narrowedPay, eventCapEarly, areAlgoRelays)
)
if (mergedPay.length > 0) {
localMergeBase = mergedPay
primedFromDisk = true
}
}
}
} catch { } catch {
/* best-effort */ /* best-effort */
} }
@ -2846,89 +2870,108 @@ const NoteList = forwardRef(
maxMatches: localLayerCap * 2 maxMatches: localLayerCap * 2
}) })
]) ])
if (effectActive && !timelineEffectStale()) { if (!effectActive || timelineEffectStale()) return
const seen = new Set<string>() const seen = new Set<string>()
const combinedRaw: Event[] = [] const combinedRaw: Event[] = []
for (const ev of diskRaw) { for (const ev of diskRaw) {
if (seen.has(ev.id)) continue if (seen.has(ev.id)) continue
seen.add(ev.id) seen.add(ev.id)
combinedRaw.push(ev) combinedRaw.push(ev)
}
for (const ev of filterAwareLocalRaw) {
if (seen.has(ev.id)) continue
seen.add(ev.id)
combinedRaw.push(ev)
}
for (const ev of fromPub) {
if (seen.has(ev.id)) continue
if (!matchesSpellLocal(ev)) continue
seen.add(ev.id)
combinedRaw.push(ev)
}
for (const ev of fromArch) {
if (seen.has(ev.id)) continue
if (!matchesSpellLocal(ev)) continue
seen.add(ev.id)
combinedRaw.push(ev)
}
combinedRaw.sort((a, b) => b.created_at - a.created_at)
if (combinedRaw.length > 0) {
const diskNarrowed = narrowLiveBatch(combinedRaw)
if (diskNarrowed.length > 0) {
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(localMergeBase, diskNarrowed, eventCapEarly, areAlgoRelays)
)
if (merged.length > 0) {
localMergeBase = merged
primedFromDisk = true
}
}
}
} }
for (const ev of filterAwareLocalRaw) {
if (primedFromDisk && localMergeBase.length > 0 && !timelineEffectStale()) { if (seen.has(ev.id)) continue
paintLocalWarmupTimeline(localMergeBase, 'spell_local_disk') seen.add(ev.id)
combinedRaw.push(ev)
}
for (const ev of fromPub) {
if (seen.has(ev.id)) continue
if (!matchesSpellLocal(ev)) continue
seen.add(ev.id)
combinedRaw.push(ev)
}
for (const ev of fromArch) {
if (seen.has(ev.id)) continue
if (!matchesSpellLocal(ev)) continue
seen.add(ev.id)
combinedRaw.push(ev)
}
combinedRaw.sort((a, b) => b.created_at - a.created_at)
if (combinedRaw.length === 0) return
const diskNarrowed = narrowLiveBatch(combinedRaw)
if (diskNarrowed.length === 0) return
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(spellLocalMergeBase, diskNarrowed, eventCapEarly, areAlgoRelays)
)
if (merged.length === 0) return
timelineMergeBootstrapRef.current = merged.slice()
setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant:
spellLocalMergeBase.length > 0 ? 'spell_local_merged' : 'disk_snapshot',
mergedCount: merged.length
} }
} catch { } catch {
/* spell local + disk snapshot is best-effort */ /* spell local + disk snapshot is best-effort */
} }
} else if (isProfileTimelineFeed && profileAuthorWarmSpec && !timelineEffectStale()) { })()
} else {
const profileMapped = mappedSubRequests as Array<{
urls: string[]
filter: TSubRequestFilter
}>
const profileAuthorWarmSpec = getProfileAuthorWarmupSpec(profileMapped)
if (isProfileTimelineFeed && profileAuthorWarmSpec && !timelineEffectStale()) {
profileLocalPrimingPendingRef.current = true profileLocalPrimingPendingRef.current = true
try { const sessionScanLimit = Math.min(4000, Math.max(eventCapEarly * 4, 800))
const sessionScanLimit = Math.min(4000, Math.max(eventCapEarly * 4, 800)) const sessionHits = client.eventService.listSessionEventsAuthoredBy(
const sessionHits = client.eventService.listSessionEventsAuthoredBy( profileAuthorWarmSpec.author,
profileAuthorWarmSpec.author, { kinds: profileAuthorWarmSpec.kinds, limit: sessionScanLimit }
{ kinds: profileAuthorWarmSpec.kinds, limit: sessionScanLimit } )
) if (sessionHits.length > 0) {
if (sessionHits.length > 0) { const narrowedS = narrowLiveBatch(sessionHits as Event[])
const narrowedS = narrowLiveBatch(sessionHits as Event[]) if (narrowedS.length > 0) {
if (narrowedS.length > 0) { const mergedS = collapseDuplicateNip18RepostTimelineRows(
const mergedS = collapseDuplicateNip18RepostTimelineRows( mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays)
mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays) )
) if (mergedS.length > 0) {
if (mergedS.length > 0) { timelineMergeBootstrapRef.current = mergedS.slice()
localMergeBase = mergedS setEvents(mergedS)
primedFromDisk = true lastEventsForTimelinePrefetchRef.current = mergedS
paintLocalWarmupTimeline(mergedS, 'profile_local_session') setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'profile_local_session',
mergedCount: mergedS.length
} }
primedFromDisk = true
} }
} }
}
const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> void (async () => {
const archiveCap = Math.min(2000, Math.max(eventCapEarly, 150)) try {
const [fromArchive, diskSnap, fromLocalFeed] = await Promise.all([ const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>
indexedDb.scanEventArchiveByAuthorPubkey(profileAuthorWarmSpec.author, { const archiveCap = Math.min(2000, Math.max(eventCapEarly, 150))
kinds: profileAuthorWarmSpec.kinds, const [fromArchive, diskSnap, fromLocalFeed] = await Promise.all([
maxRowsScanned: 16_000, indexedDb.scanEventArchiveByAuthorPubkey(profileAuthorWarmSpec.author, {
maxMatches: archiveCap kinds: profileAuthorWarmSpec.kinds,
}), maxRowsScanned: 16_000,
client.getTimelineDiskSnapshotEvents(diskReq), maxMatches: archiveCap
client.getLocalFeedEvents(diskReq, { }),
maxRowsScanned: 16_000, client.getTimelineDiskSnapshotEvents(diskReq),
maxMatches: archiveCap client.getLocalFeedEvents(diskReq, {
}) maxRowsScanned: 16_000,
]) maxMatches: archiveCap
if (effectActive && !timelineEffectStale()) { })
])
if (!effectActive || timelineEffectStale()) return
const premerged = mergeEventBatchesById( const premerged = mergeEventBatchesById(
[], [],
[...(fromArchive as Event[]), ...(diskSnap as Event[]), ...(fromLocalFeed as Event[])], [...(fromArchive as Event[]), ...(diskSnap as Event[]), ...(fromLocalFeed as Event[])],
@ -2938,22 +2981,34 @@ const NoteList = forwardRef(
if (premerged.length > 0) { if (premerged.length > 0) {
const narrowed = narrowLiveBatch(premerged) const narrowed = narrowLiveBatch(premerged)
if (narrowed.length > 0) { if (narrowed.length > 0) {
const merged = collapseDuplicateNip18RepostTimelineRows( setEvents((prev) => {
mergeEventBatchesById(localMergeBase, narrowed, eventCapEarly, areAlgoRelays) const merged = collapseDuplicateNip18RepostTimelineRows(
) mergeEventBatchesById(prev, narrowed, eventCapEarly, areAlgoRelays)
if (merged.length > 0) { )
localMergeBase = merged if (merged.length > 0) {
primedFromDisk = true timelineMergeBootstrapRef.current = merged.slice()
paintLocalWarmupTimeline(merged, 'profile_local_disk') }
lastEventsForTimelinePrefetchRef.current = merged
return merged
})
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
if (!feedPaintLiveRelayDoneRef.current) {
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'profile_local_archive',
mergedCount: narrowed.length
}
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
} }
} }
} }
}
const relayUrls = getProfileTimelineFetchRelayUrls(profileMapped) const relayUrls = getProfileTimelineFetchRelayUrls(profileMapped)
if (relayUrls.length > 0 && effectActive && !timelineEffectStale()) { if (relayUrls.length > 0) {
void client const fetched = await client.fetchEvents(
.fetchEvents(
relayUrls, relayUrls,
{ {
authors: [profileAuthorWarmSpec.author], authors: [profileAuthorWarmSpec.author],
@ -2968,128 +3023,45 @@ const NoteList = forwardRef(
foreground: true foreground: true
} }
) )
.then((fetched) => { if (!effectActive || timelineEffectStale()) return
if (!effectActive || timelineEffectStale() || fetched.length === 0) return if (fetched.length > 0) {
const narrowedFetch = narrowLiveBatch(fetched) const narrowedFetch = narrowLiveBatch(fetched)
if (narrowedFetch.length === 0) return if (narrowedFetch.length > 0) {
setEvents((prev) => { setEvents((prev) => {
const merged = collapseDuplicateNip18RepostTimelineRows( const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(prev, narrowedFetch, eventCapEarly, areAlgoRelays) mergeEventBatchesById(prev, narrowedFetch, eventCapEarly, areAlgoRelays)
) )
if (merged.length > 0) { if (merged.length > 0) {
timelineMergeBootstrapRef.current = merged.slice() timelineMergeBootstrapRef.current = merged.slice()
}
lastEventsForTimelinePrefetchRef.current = merged
return merged
})
feedRelayReturnedAnyEventRef.current = true
if (!feedPaintLiveRelayDoneRef.current) {
setLoading(false)
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
} }
lastEventsForTimelinePrefetchRef.current = merged
return merged
})
feedRelayReturnedAnyEventRef.current = true
if (!feedPaintLiveRelayDoneRef.current) {
setLoading(false)
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
}
})
.catch(() => {
/* best-effort */
})
}
} catch {
/* profile local archive is best-effort */
} finally {
profileLocalPrimingPendingRef.current = false
}
} else {
const shardFilters = mappedSubRequests.map(({ filter }) => filter as Filter)
const matchesTimelineLocal = (ev: Event) =>
shardFilters.some((f) => eventMatchesSubRequestFilterWithWindow(ev, f))
const kindsForScan = unionKindsForSpellLocalWarmup(
shardFilters,
effectiveShowKindsRef.current
)
const sinceTightest = tightestSinceFromSpellFilters(shardFilters)
const localLayerCap = Math.min(
FEED_FULL_SEARCH_MERGE_CAP,
Math.max(eventCapEarly, 200)
)
const sessionScanCap = Math.min(800, localLayerCap * 4)
const filterAwareDiskReq = mappedSubRequests as Array<{
urls: string[]
filter: TSubRequestFilter
}>
const sessionHits = client
.getSessionEventsMatchingSearch('', sessionScanCap, kindsForScan)
.filter(matchesTimelineLocal)
.sort((a, b) => b.created_at - a.created_at)
if (!timelineEffectStale() && sessionHits.length > 0) {
const narrowedS = narrowLiveBatch(sessionHits)
if (narrowedS.length > 0) {
const mergedS = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays)
)
if (mergedS.length > 0) {
localMergeBase = mergedS
primedFromDisk = true
paintLocalWarmupTimeline(mergedS, 'timeline_local_session')
}
}
}
try {
const [diskRaw, filterAwareLocalRaw, fromArch] = await Promise.all([
client.getTimelineDiskSnapshotEvents(filterAwareDiskReq),
client.getLocalFeedEvents(filterAwareDiskReq, {
maxRowsScanned: 50_000,
maxMatches: localLayerCap * 3
}),
indexedDb.scanEventArchiveByKinds({
kinds: kindsForScan,
since: sinceTightest,
maxRowsScanned: 50_000,
maxMatches: localLayerCap * 2
})
])
if (effectActive && !timelineEffectStale()) {
const seen = new Set<string>()
const combinedRaw: Event[] = []
for (const ev of diskRaw) {
if (seen.has(ev.id)) continue
seen.add(ev.id)
combinedRaw.push(ev)
}
for (const ev of filterAwareLocalRaw) {
if (seen.has(ev.id)) continue
seen.add(ev.id)
combinedRaw.push(ev)
}
for (const ev of fromArch as Event[]) {
if (seen.has(ev.id)) continue
if (!matchesTimelineLocal(ev)) continue
seen.add(ev.id)
combinedRaw.push(ev)
}
combinedRaw.sort((a, b) => b.created_at - a.created_at)
if (combinedRaw.length > 0) {
const diskNarrowed = narrowLiveBatch(combinedRaw)
if (diskNarrowed.length > 0) {
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(localMergeBase, diskNarrowed, eventCapEarly, areAlgoRelays)
)
if (merged.length > 0) {
localMergeBase = merged
primedFromDisk = true
paintLocalWarmupTimeline(merged, 'timeline_local_disk')
} }
} }
} }
} catch {
/* profile local archive is best-effort */
} finally {
profileLocalPrimingPendingRef.current = false
if (!effectActive || timelineEffectStale()) return
if (!feedPaintLiveRelayDoneRef.current) {
feedPaintLiveRelayDoneRef.current = true
setLoading(false)
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
}
} }
} catch { })()
/* generic local + disk snapshot is best-effort */
}
} }
} }
if (!primedFromDisk && !profileRelayStackRefinement && !shouldAwaitLocalDiskWarmup) { if (!primedFromDisk && !profileRelayStackRefinement) {
if (!keepRowsVisible) setLoading(true) if (!keepRowsVisible) setLoading(true)
timelineMergeBootstrapRef.current = [] timelineMergeBootstrapRef.current = []
setEvents([]) setEvents([])
@ -3992,21 +3964,6 @@ const NoteList = forwardRef(
eventsRef.current = events eventsRef.current = events
}, [events]) }, [events])
/** Debounced session snapshot so F5 / tab reload restores the last painted timeline (Spells notifications, etc.). */
useEffect(() => {
if (!sessionSnapshotIdentityKey || events.length === 0) return
const strictSingleRelayAuthoritative =
subRequestsRef.current.length === 1 &&
subRequestsRef.current[0]!.urls.length === 1 &&
(hostPrimaryPageNameRef.current === 'relay' ||
(allowKindlessRelayExploreRef.current && useFilterAsIsRef.current))
if (relayAuthoritativeFeedOnlyRef.current && !strictSingleRelayAuthoritative) return
const timer = window.setTimeout(() => {
setSessionFeedSnapshot(sessionSnapshotIdentityKey, events)
}, 400)
return () => window.clearTimeout(timer)
}, [events, sessionSnapshotIdentityKey, allowKindlessRelayExplore, useFilterAsIs])
useEffect(() => { useEffect(() => {
newEventsRef.current = newEvents newEventsRef.current = newEvents
}, [newEvents]) }, [newEvents])
@ -4536,7 +4493,6 @@ const NoteList = forwardRef(
let lastScrollPrefetchInvokeMs = 0 let lastScrollPrefetchInvokeMs = 0
const onScrollFlushNewNotesAtTop = () => { const onScrollFlushNewNotesAtTop = () => {
scrollActivity.markScrolling()
if (oneShotFetchRef.current) return if (oneShotFetchRef.current) return
if (feedFullSearchEventsRef.current !== null) return if (feedFullSearchEventsRef.current !== null) return
const t = scrollPrefetchTarget const t = scrollPrefetchTarget
@ -4551,7 +4507,6 @@ const NoteList = forwardRef(
} }
const onScrollPrefetch = () => { const onScrollPrefetch = () => {
scrollActivity.markScrolling()
if (scrollPrefetchRafId) return if (scrollPrefetchRafId) return
scrollPrefetchRafId = requestAnimationFrame(() => { scrollPrefetchRafId = requestAnimationFrame(() => {
scrollPrefetchRafId = 0 scrollPrefetchRafId = 0
@ -4692,17 +4647,14 @@ const NoteList = forwardRef(
</Button> </Button>
) )
const feedRelayToolbarRow =
feedRelayUrls.length > 0 ? (
<FeedRelaysIconRow
urls={feedRelayUrls}
compact
className="min-w-0 flex-1 overflow-x-auto scrollbar-hide"
/>
) : null
const feedClientFilterPanel = feedClientFilterOpen ? ( const feedClientFilterPanel = feedClientFilterOpen ? (
<div id="feed-client-filter-panel" className={feedClientFilterPanelSurfaceClass}> <div id="feed-client-filter-panel" className={feedClientFilterPanelSurfaceClass}>
{feedRelayUrls.length > 0 ? (
<div className={feedClientFilterSectionClass}>
<p className="text-sm font-medium">{t('Feed relays', { defaultValue: 'Relays in this feed' })}</p>
<FeedRelaysIconRow urls={feedRelayUrls} />
</div>
) : null}
<div className={feedClientFilterSectionClass}> <div className={feedClientFilterSectionClass}>
<Label htmlFor="feed-client-search" className="text-sm font-medium"> <Label htmlFor="feed-client-search" className="text-sm font-medium">
{t('Search loaded posts')} {t('Search loaded posts')}
@ -4881,32 +4833,17 @@ const NoteList = forwardRef(
) : null ) : null
const feedClientFilterChrome = feedClientFilterPanelPortalMode ? ( const feedClientFilterChrome = feedClientFilterPanelPortalMode ? (
<div className="flex min-w-0 w-full flex-nowrap items-center gap-1"> feedClientFilterToggleButton
{feedRelayToolbarRow}
<div className="shrink-0">{feedClientFilterToggleButton}</div>
</div>
) : ( ) : (
<> <>
<div className="flex min-w-0 flex-nowrap items-center gap-1 px-0.5"> <div className="flex items-center gap-1">{feedClientFilterToggleButton}</div>
{feedRelayToolbarRow}
<div className="ml-auto shrink-0">{feedClientFilterToggleButton}</div>
</div>
{feedClientFilterPanel} {feedClientFilterPanel}
</> </>
) )
const feedClientFilterPanelPortaled = /** Tab-row portal: toggle lives in the header; panel expands in-flow above the list. */
feedClientFilterPanelPortalMode &&
feedClientFilterPanelHost &&
feedClientFilterPanel
? createPortal(feedClientFilterPanel, feedClientFilterPanelHost)
: null
/** Tab-row portal: toggle in header; panel in {@link feedClientFilterPanelHost} or above the list. */
const feedClientFilterPanelInList = const feedClientFilterPanelInList =
feedClientFilterPanelPortalMode && !feedClientFilterPanelHost feedClientFilterPanelPortalMode ? feedClientFilterPanel : null
? feedClientFilterPanel
: null
const feedClientFilterBarEmbedded = ( const feedClientFilterBarEmbedded = (
<div className="sticky top-0 z-20 border-b border-border/80 bg-background/95 px-1 py-1 backdrop-blur supports-[backdrop-filter]:bg-background/80"> <div className="sticky top-0 z-20 border-b border-border/80 bg-background/95 px-1 py-1 backdrop-blur supports-[backdrop-filter]:bg-background/80">
@ -4927,21 +4864,15 @@ const NoteList = forwardRef(
// Relay-op rows arrive only after every relay in the wave reports terminal state. A slow or // Relay-op rows arrive only after every relay in the wave reports terminal state. A slow or
// wedged connection (e.g. NIP-42 re-auth) can delay that indefinitely while events already stream // wedged connection (e.g. NIP-42 re-auth) can delay that indefinitely while events already stream
// in — without this guard the "Looking for more events…" banner never clears. // in — without this guard the "Looking for more events…" banner never clears.
const showFeedInitialLoading =
listSourceEvents.length === 0 &&
!feedFullSearchActive &&
(loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady))
const showRelaySubscribeWavePendingBanner = const showRelaySubscribeWavePendingBanner =
!oneShotFetch && !oneShotFetch &&
!feedFullSearchActive && !feedFullSearchActive &&
subRequests.length > 0 && subRequests.length > 0 &&
relayCapabilityReady && relayCapabilityReady &&
timelineKey != null && timelineKey != null &&
timelineEventsForFilter.length === 0 && feedSubscribeRelayOutcomes.length === 0 &&
(loading || feedTimelineEmptyUiReady &&
!feedTimelineEmptyUiReady || timelineEventsForFilter.length === 0
(feedSubscribeRelayOutcomes.length === 0 && feedTimelineEmptyUiReady))
const showProgressiveLayersPendingBanner = const showProgressiveLayersPendingBanner =
Boolean(progressiveWarmupTrimmed) && progressiveLayersSearching && !feedFullSearchActive Boolean(progressiveWarmupTrimmed) && progressiveLayersSearching && !feedFullSearchActive
const showLookingForMoreEventsBanner = const showLookingForMoreEventsBanner =
@ -5013,7 +4944,9 @@ const NoteList = forwardRef(
/> />
)) ))
)} )}
{showFeedInitialLoading ? ( {listSourceEvents.length === 0 &&
!feedFullSearchActive &&
(loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? (
<div <div
ref={bottomRef} ref={bottomRef}
className={gridLayout ? 'grid grid-cols-3 gap-0.5 pr-4 min-h-[40vh]' : 'min-h-[40vh] space-y-2 px-1 py-4'} className={gridLayout ? 'grid grid-cols-3 gap-0.5 pr-4 min-h-[40vh]' : 'min-h-[40vh] space-y-2 px-1 py-4'}
@ -5021,9 +4954,6 @@ const NoteList = forwardRef(
aria-live="polite" aria-live="polite"
aria-busy="true" aria-busy="true"
> >
<p className="col-span-full px-2 pb-2 text-center text-sm text-muted-foreground">
{t('Loading feed…')}
</p>
{gridLayout {gridLayout
? Array.from({ length: 9 }).map((_, i) => ( ? Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="aspect-square animate-pulse bg-muted" /> <div key={i} className="aspect-square animate-pulse bg-muted" />
@ -5092,7 +5022,6 @@ const NoteList = forwardRef(
return ( return (
<div ref={feedRootRef} className="relative"> <div ref={feedRootRef} className="relative">
<div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" /> <div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
{feedClientFilterPanelPortaled}
<NoteFeedProfileContext.Provider value={noteFeedProfileContextValue}> <NoteFeedProfileContext.Provider value={noteFeedProfileContextValue}>
{supportTouch ? ( {supportTouch ? (
<PullToRefresh <PullToRefresh

3
src/components/NoteOptions/EditOrCloneEventDialog.tsx

@ -377,8 +377,7 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
const k = isCreate ? parsedCreateKind! : sourceEvent!.kind const k = isCreate ? parsedCreateKind! : sourceEvent!.kind
const key = advancedLabDraftPersistenceKey const key = advancedLabDraftPersistenceKey
const saved = key ? postEditorCache.getAdvancedLabDraft(key) : undefined const saved = key ? postEditorCache.getAdvancedLabDraft(key) : undefined
const useSavedLabBody = !content.trim() && saved && saved.kind === k if (saved && saved.kind === k) {
if (useSavedLabBody) {
setAdvancedLabInitial({ setAdvancedLabInitial({
kind: saved.kind, kind: saved.kind,
content: saved.content, content: saved.content,

128
src/components/NoteOptions/useMenuActions.tsx

@ -42,10 +42,7 @@ import {
isEventInPinList isEventInPinList
} from '@/lib/replaceable-list-latest' } from '@/lib/replaceable-list-latest'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { import { generateBech32IdFromATag } from '@/lib/tag'
exportPublicationDownload,
isAsciiDoctorServerConfigured
} from '@/lib/publication-export'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
@ -61,7 +58,6 @@ import {
Bell, Bell,
BellOff, BellOff,
Bookmark, Bookmark,
Download,
Pin, Pin,
Settings, Settings,
Share2, Share2,
@ -167,8 +163,7 @@ export function useMenuActions({
account, account,
relayList, relayList,
bookmarkListEvent, bookmarkListEvent,
checkLogin, checkLogin
canManageIdentity
} = useNostr() } = useNostr()
const bookmarksContext = useBookmarksOptional() const bookmarksContext = useBookmarksOptional()
const { threadFollowed, threadMuted, threadWatch } = useThreadNotificationMenuState(event) const { threadFollowed, threadMuted, threadWatch } = useThreadNotificationMenuState(event)
@ -841,25 +836,55 @@ export function useMenuActions({
const exportAsAsciidoc = async () => { const exportAsAsciidoc = async () => {
if (!isArticleType) return if (!isArticleType) return
try { try {
const title = articleMetadata?.title || 'Article'
let content = event.content
let filename = `${title}.adoc`
// For publications (30040), export all referenced sections
if (event.kind === ExtendedKind.PUBLICATION) { if (event.kind === ExtendedKind.PUBLICATION) {
closeDrawer() const contentParts: string[] = []
await toast.promise(exportPublicationDownload(event, 'adoc', relayUrls), {
loading: t('Exporting publication…'), // Extract all 'a' tag references
success: () => t('Article exported as AsciiDoc'), const aTags = event.tags.filter(tag => tag[0] === 'a' && tag[1])
error: (err: unknown) =>
t('Failed to export article') + // Fetch all referenced events
': ' + const fetchPromises = aTags.map(async (tag) => {
(err instanceof Error ? err.message : String(err)) try {
const coordinate = tag[1]
const [kindStr] = coordinate.split(':')
const kind = parseInt(kindStr)
if (isNaN(kind)) return null
// Try to fetch the event
const aTag = ['a', coordinate, tag[2] || '', tag[3] || '']
const bech32Id = generateBech32IdFromATag(aTag)
if (bech32Id) {
const fetchedEvent = await eventService.fetchEvent(bech32Id)
return fetchedEvent
}
return null
} catch (error) {
logger.warn('[NoteOptions] Error fetching referenced event for export:', error)
return null
}
}) })
return
const referencedEvents = (await Promise.all(fetchPromises)).filter((e): e is Event => e !== null)
// Combine all events into one AsciiDoc document
for (const refEvent of referencedEvents) {
const refTitle = refEvent.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled'
contentParts.push(`= ${refTitle}\n\n${refEvent.content}\n\n`)
}
if (contentParts.length > 0) {
content = contentParts.join('\n')
}
} }
const title = articleMetadata?.title || 'Article'
const content = event.content
const filename = `${title}.adoc`
const blob = new Blob([content], { type: 'text/plain' }) const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
@ -869,7 +894,7 @@ export function useMenuActions({
a.click() a.click()
document.body.removeChild(a) document.body.removeChild(a)
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
logger.info('[NoteOptions] Exported article as AsciiDoc') logger.info('[NoteOptions] Exported article as AsciiDoc')
toast.success(t('Article exported as AsciiDoc')) toast.success(t('Article exported as AsciiDoc'))
} catch (error) { } catch (error) {
@ -878,19 +903,6 @@ export function useMenuActions({
} }
} }
const exportPublicationAs = (format: 'epub' | 'pdf') => {
closeDrawer()
void toast.promise(exportPublicationDownload(event, format, relayUrls), {
loading: t('Exporting publication…'),
success: () =>
format === 'epub' ? t('Publication exported as EPUB') : t('Publication exported as PDF'),
error: (err: unknown) =>
t('Failed to export publication') +
': ' +
(err instanceof Error ? err.message : String(err))
})
}
// View on external sites functions // View on external sites functions
const handleViewOnAlexandria = () => { const handleViewOnAlexandria = () => {
if (!naddr) return if (!naddr) return
@ -1205,6 +1217,10 @@ export function useMenuActions({
if (isArticleType) { if (isArticleType) {
const isMarkdownFormat = const isMarkdownFormat =
event.kind === kinds.LongFormArticle || event.kind === ExtendedKind.NOSTR_SPECIFICATION event.kind === kinds.LongFormArticle || event.kind === ExtendedKind.NOSTR_SPECIFICATION
const isAsciidocFormat =
event.kind === ExtendedKind.WIKI_ARTICLE ||
event.kind === ExtendedKind.PUBLICATION ||
event.kind === ExtendedKind.PUBLICATION_CONTENT
if (isMarkdownFormat) { if (isMarkdownFormat) {
advancedSubMenu.push({ advancedSubMenu.push({
@ -1215,6 +1231,15 @@ export function useMenuActions({
} }
}) })
} }
if (isAsciidocFormat) {
advancedSubMenu.push({
label: t('Export as AsciiDoc'),
onClick: () => {
closeDrawer()
exportAsAsciidoc()
}
})
}
if (event.kind === ExtendedKind.PUBLICATION && publicationBroadcastSubMenu.length > 0) { if (event.kind === ExtendedKind.PUBLICATION && publicationBroadcastSubMenu.length > 0) {
advancedSubMenu.push({ advancedSubMenu.push({
label: t('Rebroadcast entire publication'), label: t('Rebroadcast entire publication'),
@ -1275,7 +1300,7 @@ export function useMenuActions({
separator: actions.length > 0 separator: actions.length > 0
}) })
if (canManageIdentity && pubkey && event.pubkey !== pubkey) { if (pubkey && event.pubkey !== pubkey) {
if (isMuted) { if (isMuted) {
actions.push({ actions.push({
icon: Bell, icon: Bell,
@ -1315,7 +1340,7 @@ export function useMenuActions({
const savesGroupStartIndex = actions.length const savesGroupStartIndex = actions.length
const savesGroupNeedsSeparator = savesGroupStartIndex > 0 const savesGroupNeedsSeparator = savesGroupStartIndex > 0
if (canManageIdentity && threadWatch && pubkey) { if (threadWatch && pubkey) {
actions.push({ actions.push({
icon: Bell, icon: Bell,
label: threadFollowed ? t('Unfollow thread notifications') : t('Follow this'), label: threadFollowed ? t('Unfollow thread notifications') : t('Follow this'),
@ -1376,7 +1401,7 @@ export function useMenuActions({
}) })
} }
if (canManageIdentity && pubkey && event.pubkey === pubkey) { if (pubkey && event.pubkey === pubkey) {
actions.push({ actions.push({
icon: Pin, icon: Pin,
label: isPinnedInMyList ? t('Unpin note') : t('Pin note'), label: isPinnedInMyList ? t('Unpin note') : t('Pin note'),
@ -1385,7 +1410,7 @@ export function useMenuActions({
}, },
separator: actions.length === savesGroupStartIndex && savesGroupNeedsSeparator separator: actions.length === savesGroupStartIndex && savesGroupNeedsSeparator
}) })
} else if (canManageIdentity && pubkey && event.pubkey !== pubkey && bookmarksContext) { } else if (pubkey && event.pubkey !== pubkey && bookmarksContext) {
actions.push({ actions.push({
icon: Bookmark, icon: Bookmark,
label: isBookmarked ? t('Remove bookmark') : t('Bookmark'), label: isBookmarked ? t('Remove bookmark') : t('Bookmark'),
@ -1415,29 +1440,6 @@ export function useMenuActions({
}) })
} }
if (event.kind === ExtendedKind.PUBLICATION) {
actions.push({
icon: Download,
label: t('Download as AsciiDoc'),
separator: actions.length === savesGroupStartIndex && savesGroupNeedsSeparator,
onClick: () => {
void exportAsAsciidoc()
}
})
if (isAsciiDoctorServerConfigured()) {
actions.push({
icon: Download,
label: t('Download as EPUB'),
onClick: () => exportPublicationAs('epub')
})
actions.push({
icon: Download,
label: t('Download as PDF'),
onClick: () => exportPublicationAs('pdf')
})
}
}
// Delete only when signed in as the author with a signing key (not read-only npub) // Delete only when signed in as the author with a signing key (not read-only npub)
if (canSignEvents && pubkey && event.pubkey === pubkey) { if (canSignEvents && pubkey && event.pubkey === pubkey) {
actions.push({ actions.push({

41
src/components/NoteStats/LikeButton.tsx

@ -20,16 +20,11 @@ import {
isDiscussionVoteEmoji isDiscussionVoteEmoji
} from '@/lib/discussion-votes' } from '@/lib/discussion-votes'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { useSignGatedControl } from '@/hooks/useSignGatedControl'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import { import type { TNoteStats } from '@/services/note-stats.service'
displayListCountWithArchives,
noteStatsHasResolvableCounts,
type TNoteStats
} from '@/services/note-stats.service'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import { SmilePlus } from 'lucide-react' import { SmilePlus } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@ -68,7 +63,6 @@ export function LikeButtonWithStats({
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { pubkey, publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin } = useNostr()
const { canSignEvents, signControlProps } = useSignGatedControl()
const { relays: statsRelays } = useNoteStatsRelayHints() const { relays: statsRelays } = useNoteStatsRelayHints()
const [liking, setLiking] = useState(false) const [liking, setLiking] = useState(false)
const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false) const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false)
@ -76,7 +70,7 @@ export function LikeButtonWithStats({
const isReplyToDiscussion = isReplyToDiscussionProp ?? false const isReplyToDiscussion = isReplyToDiscussionProp ?? false
const showDiscussionVotes = isDiscussion || isReplyToDiscussion const showDiscussionVotes = isDiscussion || isReplyToDiscussion
const statsLoaded = noteStatsHasResolvableCounts(noteStats) const statsLoaded = noteStats?.updatedAt != null
const { myLastEmoji, likeCount, upVoteCount, downVoteCount } = useMemo(() => { const { myLastEmoji, likeCount, upVoteCount, downVoteCount } = useMemo(() => {
const stats = noteStats || {} const stats = noteStats || {}
@ -97,9 +91,7 @@ export function LikeButtonWithStats({
return { return {
myLastEmoji: myLike?.emoji, myLastEmoji: myLike?.emoji,
likeCount: showDiscussionVotes likeCount: likes?.length,
? likes?.length
: displayListCountWithArchives(likes?.length, stats.archivesInteractions, 'reactions'),
upVoteCount, upVoteCount,
downVoteCount downVoteCount
} }
@ -110,7 +102,7 @@ export function LikeButtonWithStats({
const like = async (emoji: string | TEmoji) => { const like = async (emoji: string | TEmoji) => {
checkLogin(async () => { checkLogin(async () => {
if (liking || !canSignEvents) return if (liking || !pubkey) return
setLiking(true) setLiking(true)
const timer = setTimeout(() => setLiking(false), 10_000) const timer = setTimeout(() => setLiking(false), 10_000)
@ -127,11 +119,9 @@ export function LikeButtonWithStats({
: typeof myLastEmoji === 'object' : typeof myLastEmoji === 'object'
? myLastEmoji.shortcode ? myLastEmoji.shortcode
: undefined : undefined
const isTogglingOff = const isTogglingOff = showDiscussionVotes
pubkey && ? discussionVoteMatches(myLastEmoji, emoji)
(showDiscussionVotes : myLastEmojiString === emojiString
? discussionVoteMatches(myLastEmoji, emoji)
: myLastEmojiString === emojiString)
logger.debug('Like toggle check', { logger.debug('Like toggle check', {
myLastEmoji, myLastEmoji,
@ -144,7 +134,7 @@ export function LikeButtonWithStats({
if (isTogglingOff) { if (isTogglingOff) {
// User wants to toggle off - find their previous reaction and delete it // User wants to toggle off - find their previous reaction and delete it
const myReaction = noteStats?.likes?.find((like) => { const myReaction = noteStats?.likes?.find((like) => {
if (!pubkey || like.pubkey !== pubkey) return false if (like.pubkey !== pubkey) return false
if (showDiscussionVotes) return discussionVoteMatches(like.emoji, emoji) if (showDiscussionVotes) return discussionVoteMatches(like.emoji, emoji)
const likeEmojiString = typeof like.emoji === 'string' ? like.emoji : like.emoji.shortcode const likeEmojiString = typeof like.emoji === 'string' ? like.emoji : like.emoji.shortcode
return likeEmojiString === emojiString return likeEmojiString === emojiString
@ -244,7 +234,6 @@ export function LikeButtonWithStats({
} }
const openReactionPicker = () => { const openReactionPicker = () => {
if (!canSignEvents) return
if (myLastEmoji && !isEmojiReactionsOpen) { if (myLastEmoji && !isEmojiReactionsOpen) {
like(myLastEmoji) like(myLastEmoji)
return return
@ -256,7 +245,8 @@ export function LikeButtonWithStats({
<button <button
type="button" type="button"
className="flex h-full min-w-0 items-center gap-1.5 px-2 text-muted-foreground enabled:hover:text-primary touch-manipulation" className="flex h-full min-w-0 items-center gap-1.5 px-2 text-muted-foreground enabled:hover:text-primary touch-manipulation"
{...signControlProps({ title: t('Like'), disabled: liking })} title={t('Like')}
disabled={liking}
onClick={openReactionPicker} onClick={openReactionPicker}
> >
{liking ? ( {liking ? (
@ -299,10 +289,8 @@ export function LikeButtonWithStats({
<button <button
type="button" type="button"
className="flex h-full shrink-0 items-center px-2 sm:px-2.5 enabled:hover:text-primary touch-manipulation" className="flex h-full shrink-0 items-center px-2 sm:px-2.5 enabled:hover:text-primary touch-manipulation"
{...signControlProps({ title={emoji === '+' ? t('Upvote') : t('Downvote')}
title: emoji === '+' ? t('Upvote') : t('Downvote'), disabled={liking}
disabled: liking
})}
onClick={() => { onClick={() => {
like(emoji) like(emoji)
}} }}
@ -375,10 +363,7 @@ export function LikeButtonWithStats({
<div className="flex h-full min-w-0 items-center"> <div className="flex h-full min-w-0 items-center">
<DropdownMenu open={isEmojiReactionsOpen} onOpenChange={setIsEmojiReactionsOpen}> <DropdownMenu open={isEmojiReactionsOpen} onOpenChange={setIsEmojiReactionsOpen}>
<DropdownMenuTrigger asChild>{likeIconButton}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{likeIconButton}</DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent side="top" className="p-0 w-fit">
side="top"
className="p-0 w-[min(100vw-1rem,350px)] max-w-[calc(100vw-1rem)] overflow-hidden"
>
{likeEmojiPicker} {likeEmojiPicker}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

19
src/components/NoteStats/ReplyButton.tsx

@ -1,12 +1,7 @@
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSignGatedControl } from '@/hooks/useSignGatedControl'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { import type { TNoteStats } from '@/services/note-stats.service'
displayListCountWithArchives,
noteStatsHasResolvableCounts,
type TNoteStats
} from '@/services/note-stats.service'
import { MessageCircle } from 'lucide-react' import { MessageCircle } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
@ -23,22 +18,17 @@ type ReplyButtonProps = {
export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: ReplyButtonProps) { export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: ReplyButtonProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr() const { pubkey, checkLogin } = useNostr()
const { signControlProps } = useSignGatedControl()
const { replyCount, hasReplied } = useMemo(() => { const { replyCount, hasReplied } = useMemo(() => {
const hasReplied = pubkey const hasReplied = pubkey
? noteStats?.replies?.some((reply) => reply.pubkey === pubkey) ? noteStats?.replies?.some((reply) => reply.pubkey === pubkey)
: false : false
return { return {
replyCount: displayListCountWithArchives( replyCount: noteStats?.replies?.length ?? 0,
noteStats?.replies?.length,
noteStats?.archivesInteractions,
'replies'
),
hasReplied hasReplied
} }
}, [noteStats, event.id, pubkey]) }, [noteStats, event.id, pubkey])
const statsLoaded = noteStatsHasResolvableCounts(noteStats) const statsLoaded = noteStats?.updatedAt != null
const replyCountLabel = statsLoaded const replyCountLabel = statsLoaded
? replyCount >= 100 ? replyCount >= 100
? '99+' ? '99+'
@ -49,7 +39,6 @@ export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: Re
return ( return (
<> <>
<button <button
type="button"
className={cn( className={cn(
'flex gap-1.5 items-center enabled:hover:text-blue-400 px-2 h-full min-h-11 touch-manipulation', 'flex gap-1.5 items-center enabled:hover:text-blue-400 px-2 h-full min-h-11 touch-manipulation',
hasReplied ? 'text-blue-400' : 'text-muted-foreground' hasReplied ? 'text-blue-400' : 'text-muted-foreground'
@ -60,7 +49,7 @@ export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: Re
setOpen(true) setOpen(true)
}) })
}} }}
{...signControlProps({ title: t('Reply') })} title={t('Reply')}
> >
<MessageCircle /> <MessageCircle />
{!hideCount && replyCountLabel !== '' && ( {!hideCount && replyCountLabel !== '' && (

27
src/components/NoteStats/RepostButton.tsx

@ -18,15 +18,10 @@ import { createRepostDraftEvent } from '@/lib/draft-event'
import { getNoteBech32Id } from '@/lib/event' import { getNoteBech32Id } from '@/lib/event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { useSignGatedControl } from '@/hooks/useSignGatedControl'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import { import type { TNoteStats } from '@/services/note-stats.service'
displayListCountWithArchives,
noteStatsHasResolvableCounts,
type TNoteStats
} from '@/services/note-stats.service'
import { PencilLine, Repeat } from 'lucide-react' import { PencilLine, Repeat } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
@ -47,34 +42,29 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { publish, checkLogin, pubkey } = useNostr() const { publish, checkLogin, pubkey } = useNostr()
const { canSignEvents, signControlProps } = useSignGatedControl()
const { relays: statsRelays } = useNoteStatsRelayHints() const { relays: statsRelays } = useNoteStatsRelayHints()
const [reposting, setReposting] = useState(false) const [reposting, setReposting] = useState(false)
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false) const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const statsLoaded = noteStatsHasResolvableCounts(noteStats) const statsLoaded = noteStats?.updatedAt != null
const { repostCount, hasReposted } = useMemo(() => { const { repostCount, hasReposted } = useMemo(() => {
return { return {
repostCount: displayListCountWithArchives( repostCount: noteStats?.reposts?.length,
noteStats?.reposts?.length,
noteStats?.archivesInteractions,
'reposts'
),
hasReposted: pubkey ? noteStats?.repostPubkeySet?.has(pubkey) : false hasReposted: pubkey ? noteStats?.repostPubkeySet?.has(pubkey) : false
} }
}, [noteStats, event.id, pubkey]) }, [noteStats, event.id, pubkey])
const showRepostCount = !hideCount && (statsLoaded || (repostCount ?? 0) > 0) const showRepostCount = !hideCount && (statsLoaded || (repostCount ?? 0) > 0)
const canRepost = canSignEvents && !hasReposted && !reposting const canRepost = !hasReposted && !reposting
const repost = async () => { const repost = async () => {
checkLogin(async () => { checkLogin(async () => {
if (!canRepost) return if (!canRepost || !pubkey) return
setReposting(true) setReposting(true)
const timer = setTimeout(() => setReposting(false), 5000) const timer = setTimeout(() => setReposting(false), 5000)
try { try {
const hasReposted = pubkey ? noteStats?.repostPubkeySet?.has(pubkey) : false const hasReposted = noteStats?.repostPubkeySet?.has(pubkey)
if (hasReposted) return if (hasReposted) return
if (!noteStats?.updatedAt) { if (!noteStats?.updatedAt) {
await noteStatsService.fetchNoteStats(event, pubkey, statsRelays, { foreground: true }) await noteStatsService.fetchNoteStats(event, pubkey, statsRelays, { foreground: true })
@ -121,9 +111,8 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
'flex h-full items-center enabled:hover:text-lime-500 px-2 touch-manipulation', 'flex h-full items-center enabled:hover:text-lime-500 px-2 touch-manipulation',
hasReposted ? 'text-lime-500' : 'text-muted-foreground' hasReposted ? 'text-lime-500' : 'text-muted-foreground'
)} )}
{...signControlProps({ title: t('Boost'), disabled: !canSignEvents })} title={t('Boost')}
onClick={() => { onClick={() => {
if (!canSignEvents) return
if (isSmallScreen) { if (isSmallScreen) {
setIsDrawerOpen(true) setIsDrawerOpen(true)
} }
@ -184,7 +173,6 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
setIsPostDialogOpen(true) setIsPostDialogOpen(true)
}) })
}} }}
{...signControlProps()}
className={drawerMenuButtonClassName} className={drawerMenuButtonClassName}
variant="ghost" variant="ghost"
> >
@ -221,7 +209,6 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
setIsPostDialogOpen(true) setIsPostDialogOpen(true)
}) })
}} }}
disabled={!canSignEvents}
> >
<PencilLine /> {t('Quote')} <PencilLine /> {t('Quote')}
</DropdownMenuItem> </DropdownMenuItem>

110
src/components/NoteStats/SeenOnButton.tsx

@ -0,0 +1,110 @@
import { useSmartRelayNavigation } from '@/PageManager'
import { Button } from '@/components/ui/button'
import {
drawerMenuButtonClassName,
drawerMenuContentClassName,
drawerMenuScrollClassName
} from '@/components/DrawerMenuItem'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useSeenOnRelays } from '@/hooks/useSeenOnRelays'
import { toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Server } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
export default function SeenOnButton({
event,
/** When set (home favorites feed), only list relays from the feed allowlist. */
allowedRelays
}: {
event: Event
allowedRelays?: readonly string[]
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { navigateToRelay } = useSmartRelayNavigation()
const relays = useSeenOnRelays(event.id, allowedRelays)
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const trigger = (
<button
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-primary pl-3 h-full"
title={t('Seen on')}
disabled={relays.length === 0}
onClick={() => {
if (isSmallScreen) {
setIsDrawerOpen(true)
}
}}
>
<Server />
{relays.length > 0 ? <span className="text-sm">{relays.length}</span> : null}
</button>
)
if (isSmallScreen) {
return (
<>
{trigger}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
<DrawerContent hideOverlay className={drawerMenuContentClassName}>
<DrawerHeader className="sr-only">
<DrawerTitle>Seen on</DrawerTitle>
</DrawerHeader>
<div className={drawerMenuScrollClassName}>
{relays.map((relay) => (
<Button
className={drawerMenuButtonClassName}
variant="ghost"
key={relay}
onClick={() => {
setIsDrawerOpen(false)
setTimeout(() => {
navigateToRelay(toRelay(relay))
}, 50)
}}
>
<RelayIcon url={relay} className="size-5 shrink-0" />
<span className="min-w-0 flex-1 text-left">{simplifyUrl(relay)}</span>
</Button>
))}
</div>
</DrawerContent>
</Drawer>
</>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>{t('Seen on')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{relays.map((relay) => (
<DropdownMenuItem
key={relay}
onSelect={(e) => e.preventDefault()}
onClick={() => navigateToRelay(toRelay(relay))}
className="min-w-52"
>
<RelayIcon url={relay} />
{simplifyUrl(relay)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

8
src/components/NoteStats/ZapButton.tsx

@ -1,8 +1,4 @@
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import {
displayZapSatsWithArchives,
noteStatsHasResolvableCounts
} from '@/services/note-stats.service'
import { recipientHasAnyPaymentOptions } from '@/lib/merge-payment-methods' import { recipientHasAnyPaymentOptions } from '@/lib/merge-payment-methods'
import { import {
buildRecipientPaymentData, buildRecipientPaymentData,
@ -128,10 +124,10 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const [openPaymentDialog, setOpenPaymentDialog] = useState(false) const [openPaymentDialog, setOpenPaymentDialog] = useState(false)
const statsLoaded = noteStatsHasResolvableCounts(noteStats) const statsLoaded = noteStats?.updatedAt != null
const { zapAmount, hasZapped } = useMemo(() => { const { zapAmount, hasZapped } = useMemo(() => {
return { return {
zapAmount: displayZapSatsWithArchives(noteStats?.zaps, noteStats?.archivesInteractions), zapAmount: noteStats?.zaps?.reduce((acc, zap) => acc + zap.amount, 0),
hasZapped: pubkey ? noteStats?.zaps?.some((zap) => zap.pubkey === pubkey) : false hasZapped: pubkey ? noteStats?.zaps?.some((zap) => zap.pubkey === pubkey) : false
} }
}, [noteStats, pubkey]) }, [noteStats, pubkey])

3
src/components/NoteStats/index.tsx

@ -115,9 +115,6 @@ export default function NoteStats({
useEffect(() => { useEffect(() => {
if (!fetchIfNotExisting) return if (!fetchIfNotExisting) return
if (shouldDeferStatsFetch && !isNearViewport) return if (shouldDeferStatsFetch && !isNearViewport) return
noteStatsService.prefetchArchivesInteractions(event.id)
/** Feed cards: Archives aggregate counts are enough for badges — skip relay REQ storms. */
if (!foregroundStats) return
setLoading(true) setLoading(true)
noteStatsService noteStatsService
.fetchNoteStats(event, pubkey, statsRelaysRef.current, { .fetchNoteStats(event, pubkey, statsRelaysRef.current, {

106
src/components/NotificationThreadWatchButtons/index.tsx

@ -0,0 +1,106 @@
import { cn } from '@/lib/utils'
import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider'
import { Bell, BellOff } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useNostr } from '@/providers/NostrProvider'
export default function NotificationThreadWatchButtons({ event }: { event: Event }) {
const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr()
const watch = useNotificationThreadWatchOptional()
const [busy, setBusy] = useState<'follow' | 'mute' | null>(null)
// Show for your own notes too (e.g. notifications feed): you may still want follow/mute on that anchor.
if (!watch || !pubkey) return null
const followed = watch.isFollowedForNotifications(event)
const muted = watch.isMutedForNotifications(event)
const onFollow = (e: React.MouseEvent) => {
e.stopPropagation()
void checkLogin(async () => {
setBusy('follow')
try {
if (followed) {
const ok = await watch.unfollowThreadForNotifications(event)
if (ok) {
toast.success(t('Unfollowed thread notifications'))
} else {
toast.error(t('Thread notification list update failed'))
}
} else {
await watch.followThreadForNotifications(event)
toast.success(t('Following thread for notifications'))
}
} catch (err) {
toast.error(t('Thread notification list update failed') + ': ' + (err as Error).message)
} finally {
setBusy(null)
}
})
}
const onMute = (e: React.MouseEvent) => {
e.stopPropagation()
void checkLogin(async () => {
setBusy('mute')
try {
if (muted) {
const ok = await watch.unmuteThreadForNotifications(event)
if (ok) {
toast.success(t('Unmuted thread notifications'))
} else {
toast.error(t('Thread notification list update failed'))
}
} else {
await watch.muteThreadForNotifications(event)
toast.success(t('Muted thread for notifications'))
}
} catch (err) {
toast.error(t('Thread notification list update failed') + ': ' + (err as Error).message)
} finally {
setBusy(null)
}
})
}
return (
<>
<button
type="button"
className={cn(
'rounded p-1 transition-colors enabled:hover:bg-muted',
followed
? 'bg-primary/15 text-primary ring-1 ring-inset ring-primary/35'
: 'text-muted-foreground'
)}
disabled={busy !== null}
aria-pressed={followed}
title={followed ? t('Unfollow thread notifications') : t('Follow this')}
aria-label={followed ? t('Unfollow thread notifications') : t('Follow this')}
onClick={onFollow}
>
<Bell className={cn('size-4', followed && 'fill-current')} />
</button>
<button
type="button"
className={cn(
'rounded p-1 transition-colors enabled:hover:bg-muted',
muted
? 'bg-destructive/15 text-destructive ring-1 ring-inset ring-destructive/30'
: 'text-muted-foreground'
)}
disabled={busy !== null}
aria-pressed={muted}
title={muted ? t('Unmute thread notifications') : t('Mute this')}
aria-label={muted ? t('Unmute thread notifications') : t('Mute this')}
onClick={onMute}
>
<BellOff className={cn('size-4', muted && 'fill-current')} />
</button>
</>
)
}

542
src/components/PostEditor/PostContent.tsx

File diff suppressed because it is too large Load Diff

171
src/components/PostEditor/PostEditorAdvancedPanel.tsx

@ -1,27 +1,12 @@
import { MAX_PUBLISH_RELAYS } from '@/constants' import { MAX_PUBLISH_RELAYS } from '@/constants'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Slider } from '@/components/ui/slider' import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import {
CONTENT_WARNING_CUSTOM_SELECT_VALUE,
CONTENT_WARNING_PRESETS,
DEFAULT_CONTENT_WARNING_LABEL,
isPresetContentWarningLabel,
normalizeContentWarningLabel
} from '@/lib/content-warning'
import type { TPrePublishRelayCapPreview } from '@/lib/pre-publish-relay-cap' import type { TPrePublishRelayCapPreview } from '@/lib/pre-publish-relay-cap'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { Dispatch, SetStateAction, useEffect, useMemo } from 'react' import { Dispatch, SetStateAction, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Mentions from './Mentions' import Mentions from './Mentions'
import PostRelaySelector from './PostRelaySelector' import PostRelaySelector from './PostRelaySelector'
@ -33,8 +18,6 @@ export type PostEditorAdvancedPanelProps = {
setAddClientTag: Dispatch<SetStateAction<boolean>> setAddClientTag: Dispatch<SetStateAction<boolean>>
isNsfw: boolean isNsfw: boolean
setIsNsfw: Dispatch<SetStateAction<boolean>> setIsNsfw: Dispatch<SetStateAction<boolean>>
contentWarningLabel: string
setContentWarningLabel: Dispatch<SetStateAction<string>>
minPow: number minPow: number
setMinPow: Dispatch<SetStateAction<number>> setMinPow: Dispatch<SetStateAction<number>>
/** Relay picker + cap hints (hidden for modes that do not pick relays). */ /** Relay picker + cap hints (hidden for modes that do not pick relays). */
@ -68,8 +51,6 @@ export default function PostEditorAdvancedPanel({
setAddClientTag, setAddClientTag,
isNsfw, isNsfw,
setIsNsfw, setIsNsfw,
contentWarningLabel,
setContentWarningLabel,
minPow, minPow,
setMinPow, setMinPow,
showRelayPicker = false, showRelayPicker = false,
@ -95,29 +76,19 @@ export default function PostEditorAdvancedPanel({
setAddClientTag(storage.getAddClientTag()) setAddClientTag(storage.getAddClientTag())
}, [setAddClientTag]) }, [setAddClientTag])
if (!show) return null
const onAddClientTagChange = (checked: boolean) => { const onAddClientTagChange = (checked: boolean) => {
storage.setAddClientTag(checked) storage.setAddClientTag(checked)
setAddClientTag(checked) setAddClientTag(checked)
} }
const selectValue = useMemo(() => {
const trimmed = contentWarningLabel.trim()
if (!trimmed) return DEFAULT_CONTENT_WARNING_LABEL
if (isPresetContentWarningLabel(trimmed)) return trimmed
return CONTENT_WARNING_CUSTOM_SELECT_VALUE
}, [contentWarningLabel])
// Mentions + relay picker must stay mounted when Advanced is collapsed so auto-selection
// effects still run (especially on mobile where users often post without opening Advanced).
return ( return (
<div className={cn(!show && 'hidden')} aria-hidden={!show}> <div className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="space-y-4 rounded-lg border border-border bg-muted/25 p-3"> <div>
{show ? ( <p className="text-sm font-medium">{t('Advanced')}</p>
<div> <p className="text-xs text-muted-foreground mt-0.5">{t('Post editor advanced hint')}</p>
<p className="text-sm font-medium">{t('Advanced')}</p> </div>
<p className="text-xs text-muted-foreground mt-0.5">{t('Post editor advanced hint')}</p>
</div>
) : null}
{showMentionsPicker && setMentions ? ( {showMentionsPicker && setMentions ? (
<div className="space-y-2"> <div className="space-y-2">
@ -171,101 +142,47 @@ export default function PostEditorAdvancedPanel({
</div> </div>
) : null} ) : null}
{show ? ( <div className="space-y-4 pt-1 border-t border-border">
<div className="space-y-4 pt-1 border-t border-border"> <div className="space-y-2">
<div className="space-y-2"> <div className="flex items-center space-x-2">
<div className="flex items-center space-x-2"> <Label htmlFor="add-client-tag" className="text-sm font-normal">
<Label htmlFor="add-client-tag" className="text-sm font-normal"> {t('Add client tag')}
{t('Add client tag')}
</Label>
<Switch
id="add-client-tag"
checked={addClientTag}
onCheckedChange={onAddClientTagChange}
disabled={posting}
/>
</div>
<p className="text-muted-foreground text-xs">{t('Show others this was sent via Imwald')}</p>
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Label htmlFor="add-content-warning-tag" className="text-sm font-normal">
{t('Content warning')}
</Label>
<Switch
id="add-content-warning-tag"
checked={isNsfw}
onCheckedChange={(checked) => {
setIsNsfw(checked)
if (checked) {
setContentWarningLabel((prev) => prev.trim() || DEFAULT_CONTENT_WARNING_LABEL)
} else {
setContentWarningLabel('')
}
}}
disabled={posting}
/>
</div>
<p className="text-muted-foreground text-xs">{t('Content warning hint')}</p>
{isNsfw ? (
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
<Select
value={selectValue}
onValueChange={(value) => {
if (value === CONTENT_WARNING_CUSTOM_SELECT_VALUE) {
if (isPresetContentWarningLabel(contentWarningLabel.trim())) {
setContentWarningLabel('')
}
return
}
setContentWarningLabel(value)
}}
disabled={posting}
>
<SelectTrigger aria-label={t('Content warning preset')}>
<SelectValue placeholder={t('Content warning preset')} />
</SelectTrigger>
<SelectContent>
{CONTENT_WARNING_PRESETS.map((preset) => (
<SelectItem key={preset} value={preset}>
{preset}
</SelectItem>
))}
<SelectItem value={CONTENT_WARNING_CUSTOM_SELECT_VALUE}>{t('Custom label…')}</SelectItem>
</SelectContent>
</Select>
{selectValue === CONTENT_WARNING_CUSTOM_SELECT_VALUE ? (
<Input
value={contentWarningLabel}
onChange={(e) => setContentWarningLabel(e.target.value)}
onBlur={() =>
setContentWarningLabel((prev) => normalizeContentWarningLabel(prev))
}
placeholder={t('Content warning custom placeholder')}
disabled={posting}
maxLength={80}
/>
) : null}
</div>
) : null}
</div>
<div className="grid gap-2">
<Label className="text-sm font-normal">
{t('Proof of Work (difficulty {{minPow}})', { minPow })}
</Label> </Label>
<Slider <Switch
defaultValue={[0]} id="add-client-tag"
value={[minPow]} checked={addClientTag}
onValueChange={([pow]) => setMinPow(pow)} onCheckedChange={onAddClientTagChange}
max={28}
step={1}
disabled={posting} disabled={posting}
/> />
</div> </div>
<p className="text-muted-foreground text-xs">{t('Show others this was sent via Imwald')}</p>
</div>
<div className="flex items-center space-x-2">
<Label htmlFor="add-nsfw-tag" className="text-sm font-normal">
{t('NSFW')}
</Label>
<Switch
id="add-nsfw-tag"
checked={isNsfw}
onCheckedChange={setIsNsfw}
disabled={posting}
/>
</div>
<div className="grid gap-2">
<Label className="text-sm font-normal">
{t('Proof of Work (difficulty {{minPow}})', { minPow })}
</Label>
<Slider
defaultValue={[0]}
value={[minPow]}
onValueChange={([pow]) => setMinPow(pow)}
max={28}
step={1}
disabled={posting}
/>
</div> </div>
) : null}
</div> </div>
</div> </div>
) )

62
src/components/PostEditor/PostRelaySelector.tsx

@ -86,26 +86,19 @@ export default function PostRelaySelector({
/** Auto-picked relays from {@link relaySelectionService}; used to detect manual relay-picker changes. */ /** Auto-picked relays from {@link relaySelectionService}; used to detect manual relay-picker changes. */
const autoSelectedRelayUrlsRef = useRef<string[]>([]) const autoSelectedRelayUrlsRef = useRef<string[]>([])
const [previousSelectableCount, setPreviousSelectableCount] = useState(0) const [previousSelectableCount, setPreviousSelectableCount] = useState(0)
const hasManualSelectionRef = useRef(false) // Generation counter: incremented every time the effect fires; async callback checks whether
const previousSelectableCountRef = useRef(0) // it's still the latest invocation before committing state, preventing stale races.
const publicLivelyDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) const selectionGenRef = useRef(0)
useEffect(() => { useEffect(() => {
hasManualSelectionRef.current = hasManualSelection return nip66Service.subscribePublicLivelyUpdated(() => {
}, [hasManualSelection]) setPublicLivelyRevision((v) => v + 1)
})
useEffect(() => { }, [])
previousSelectableCountRef.current = previousSelectableCount
}, [previousSelectableCount])
useEffect(() => { useEffect(() => {
return nip66Service.subscribePublicLivelyUpdated(() => { void nip66Service.getPublicLivelyRelayUrls().then(() => {
if (publicLivelyDebounceRef.current) clearTimeout(publicLivelyDebounceRef.current) setPublicLivelyRevision((v) => v + 1)
// Debounce: NIP-66 can emit many updates during discovery; batch them so selection
// is not restarted before the prior run finishes (which left "Loading…" stuck).
publicLivelyDebounceRef.current = setTimeout(() => {
setPublicLivelyRevision((v) => v + 1)
}, 600)
}) })
}, []) }, [])
@ -166,12 +159,6 @@ export default function PostRelaySelector({
return [...new Set(matches)].sort().join('\n') return [...new Set(matches)].sort().join('\n')
}, [postContent, isDiscussionReply, isPublicMessage, mentions]) }, [postContent, isDiscussionReply, isPublicMessage, mentions])
/** Stable dep for PM recipient changes — raw `mentions` array identity changes every extract. */
const mentionsRelaySignature = useMemo(
() => (isPublicMessage && mentions.length > 0 ? [...mentions].sort().join('\n') : ''),
[isPublicMessage, mentions]
)
// Memoize arrays to prevent unnecessary re-renders // Memoize arrays to prevent unnecessary re-renders
const memoizedFavoriteRelays = useMemo(() => favoriteRelays, [favoriteRelays]) const memoizedFavoriteRelays = useMemo(() => favoriteRelays, [favoriteRelays])
const memoizedBlockedRelays = useMemo(() => { const memoizedBlockedRelays = useMemo(() => {
@ -188,13 +175,13 @@ export default function PostRelaySelector({
const memoizedRelaySets = useMemo(() => relaySets, [relaySets]) const memoizedRelaySets = useMemo(() => relaySets, [relaySets])
const memoizedOpenFrom = useMemo(() => openFrom, [openFrom]) const memoizedOpenFrom = useMemo(() => openFrom, [openFrom])
// Single relay-selection effect. Cleanup sets `active = false` so superseded runs never // Single relay-selection effect. The generation counter (selectionGenRef) guards against
// commit stale state; only the latest run clears the loading indicator. // stale async completions: if a newer invocation has started, the older one discards its results.
useEffect(() => { useEffect(() => {
let active = true const gen = ++selectionGenRef.current
setIsLoading(true)
const updateRelaySelection = async () => { const updateRelaySelection = async () => {
setIsLoading(true)
try { try {
let userWriteRelays: string[] = [] let userWriteRelays: string[] = []
if (pubkey && relayList) { if (pubkey && relayList) {
@ -216,43 +203,41 @@ export default function PostRelaySelector({
openFrom: memoizedOpenFrom openFrom: memoizedOpenFrom
}) })
if (!active) return // Discard results from a superseded invocation
if (gen !== selectionGenRef.current) return
const newSelectableCount = result.selectableRelays.length const newSelectableCount = result.selectableRelays.length
const selectableRelaysChanged = newSelectableCount !== previousSelectableCountRef.current const selectableRelaysChanged = newSelectableCount !== previousSelectableCount
setSelectableRelays(result.selectableRelays) setSelectableRelays(result.selectableRelays)
setRelayTypes(result.relayTypes ?? {}) setRelayTypes(result.relayTypes ?? {})
setPreviousSelectableCount(newSelectableCount) setPreviousSelectableCount(newSelectableCount)
if (!hasManualSelectionRef.current || selectableRelaysChanged) { if (!hasManualSelection || selectableRelaysChanged) {
const cacheRelays = result.selectableRelays.filter(url => isLocalNetworkUrl(url)) const cacheRelays = result.selectableRelays.filter(url => isLocalNetworkUrl(url))
const selectedWithCache = Array.from(new Set([...result.selectedRelays, ...cacheRelays])) const selectedWithCache = Array.from(new Set([...result.selectedRelays, ...cacheRelays]))
const capped = capAutoSelectedRelays(result.selectableRelays, selectedWithCache) const capped = capAutoSelectedRelays(result.selectableRelays, selectedWithCache)
autoSelectedRelayUrlsRef.current = capped autoSelectedRelayUrlsRef.current = capped
setSelectedRelayUrls(capped) setSelectedRelayUrls(capped)
setDescription(describeRelaySelection(capped)) setDescription(describeRelaySelection(capped))
if (selectableRelaysChanged && hasManualSelectionRef.current) { if (selectableRelaysChanged && hasManualSelection) {
setHasManualSelection(false) setHasManualSelection(false)
} }
} }
} catch (error) { } catch (error) {
if (!active) return if (gen !== selectionGenRef.current) return
logger.error('Failed to update relay selection', { error }) logger.error('Failed to update relay selection', { error })
setSelectableRelays([]) setSelectableRelays([])
if (!hasManualSelectionRef.current) { if (!hasManualSelection) {
setSelectedRelayUrls([]) setSelectedRelayUrls([])
setDescription(t('No relays selected')) setDescription(t('No relays selected'))
} }
} finally { } finally {
if (active) setIsLoading(false) if (gen === selectionGenRef.current) setIsLoading(false)
} }
} }
void updateRelaySelection() updateRelaySelection()
return () => {
active = false
}
}, [ }, [
memoizedOpenFrom, memoizedOpenFrom,
_parentEvent, _parentEvent,
@ -262,10 +247,9 @@ export default function PostRelaySelector({
isPublicMessage, isPublicMessage,
pubkey, pubkey,
relayList, relayList,
userReadRelaysForSelection,
isDiscussionReply, isDiscussionReply,
contentRelaySignature, contentRelaySignature,
mentionsRelaySignature, mentions,
describeRelaySelection, describeRelaySelection,
addRandomRelaysToPublish, addRandomRelaysToPublish,
publicLivelyRevision, publicLivelyRevision,

7
src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx

@ -9,7 +9,6 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import UserItem, { UserItemSkeleton } from '@/components/UserItem' import UserItem, { UserItemSkeleton } from '@/components/UserItem'
import { useSearchProfiles } from '@/hooks' import { useSearchProfiles } from '@/hooks'
import { MENTION_NPUB_DROPDOWN_LIMIT } from '@/services/mention-event-search.service' import { MENTION_NPUB_DROPDOWN_LIMIT } from '@/services/mention-event-search.service'
import postEditor from '@/services/post-editor.service'
import { AtSign, FileSearch } from 'lucide-react' import { AtSign, FileSearch } from 'lucide-react'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -43,7 +42,6 @@ export function MentionAndEventToolbarButtons({
const selectNpub = useCallback( const selectNpub = useCallback(
(npub: string) => { (npub: string) => {
insertAtCursor(`nostr:${npub} `) insertAtCursor(`nostr:${npub} `)
postEditor.closeSuggestionPopup()
closeMention() closeMention()
}, },
[insertAtCursor, closeMention] [insertAtCursor, closeMention]
@ -116,10 +114,7 @@ export function MentionAndEventToolbarButtons({
size="icon" size="icon"
title={t('Insert event or address')} title={t('Insert event or address')}
className={btnClass} className={btnClass}
onClick={() => { onClick={() => neventPicker?.openNeventPicker((link) => insertAtCursor(link + ' '))}
postEditor.closeSuggestionPopup()
neventPicker?.openNeventPicker((link) => insertAtCursor(link + ' '))
}}
> >
<FileSearch className="h-4 w-4" /> <FileSearch className="h-4 w-4" />
</Button> </Button>

27
src/components/PostEditor/PostTextarea/Mention/MentionList.tsx

@ -9,7 +9,6 @@ import { SimpleUserAvatar } from '../../../UserAvatar'
import { SimpleUsername } from '../../../Username' import { SimpleUsername } from '../../../Username'
import type { PickerSearchMode } from '@/services/mention-event-search.service' import type { PickerSearchMode } from '@/services/mention-event-search.service'
import { NEVENT_NADDR_PICKER_ID } from './constants' import { NEVENT_NADDR_PICKER_ID } from './constants'
import { SUGGESTION_POPUP_Z_INDEX } from '../suggestion-popup'
export type MentionListItem = string | { id: string; mode?: PickerSearchMode } export type MentionListItem = string | { id: string; mode?: PickerSearchMode }
@ -21,8 +20,6 @@ export interface MentionListProps {
onSelectIndex?: (index: number) => void onSelectIndex?: (index: number) => void
/** When provided, used to detect if we're inside a dialog (for z-index). */ /** When provided, used to detect if we're inside a dialog (for z-index). */
editor?: Editor editor?: Editor
/** True while mention search is in flight (show placeholder instead of hiding the list). */
loading?: boolean
} }
export interface MentionListHandle { export interface MentionListHandle {
@ -32,6 +29,7 @@ export interface MentionListHandle {
const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) => { const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const items = props.items ?? [] const items = props.items ?? []
const inDialog = Boolean(props.editor?.view?.dom?.closest?.('[role="dialog"]'))
const [internalIndex, setInternalIndex] = useState<number>(0) const [internalIndex, setInternalIndex] = useState<number>(0)
const isControlled = props.selectedIndex !== undefined const isControlled = props.selectedIndex !== undefined
const selectedIndex = isControlled ? props.selectedIndex! : internalIndex const selectedIndex = isControlled ? props.selectedIndex! : internalIndex
@ -98,32 +96,15 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
})) }))
if (!items.length) { if (!items.length) {
if (!props.loading) { return null
return (
<div
className="border rounded-lg bg-background pointer-events-auto p-3 max-w-[min(calc(100vw-1.5rem),28rem)]"
style={{ zIndex: SUGGESTION_POPUP_Z_INDEX }}
>
<p className="text-sm text-muted-foreground">{t('No users found')}</p>
</div>
)
}
return (
<div
className="border rounded-lg bg-background pointer-events-auto p-3 max-w-[min(calc(100vw-1.5rem),28rem)]"
style={{ zIndex: SUGGESTION_POPUP_Z_INDEX }}
>
<p className="text-sm text-muted-foreground">{t('Searching…')}</p>
</div>
)
} }
return ( return (
<div <div
className={cn( className={cn(
'border rounded-lg bg-background pointer-events-auto flex flex-col min-h-0 max-h-[min(85dvh,calc(100dvh-6rem))] max-w-[min(calc(100vw-1.5rem),28rem)] overflow-x-hidden overflow-y-auto overscroll-contain popover-scroll-y' 'border rounded-lg bg-background pointer-events-auto flex flex-col min-h-0 max-h-[min(85dvh,calc(100dvh-6rem))] max-w-[min(calc(100vw-1.5rem),28rem)] overflow-x-hidden overflow-y-auto overscroll-contain popover-scroll-y',
inDialog ? 'z-[290]' : 'z-[110]'
)} )}
style={{ zIndex: SUGGESTION_POPUP_Z_INDEX }}
onWheel={(e: React.WheelEvent) => e.stopPropagation()} onWheel={(e: React.WheelEvent) => e.stopPropagation()}
onTouchMove={(e: React.TouchEvent) => e.stopPropagation()} onTouchMove={(e: React.TouchEvent) => e.stopPropagation()}
> >

70
src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx

@ -1,3 +1,4 @@
import * as React from 'react'
import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants' import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants'
import { getNoteBech32Id } from '@/lib/event' import { getNoteBech32Id } from '@/lib/event'
import { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview' import { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview'
@ -20,6 +21,8 @@ import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Search } from 'lucide-react' import { Search } from 'lucide-react'
import type { Editor } from '@tiptap/core'
import { OPEN_NEVENT_PICKER_EVENT, extendMentionRangeToEndOfWord } from './suggestion'
type NeventNaddrPickerDialogProps = { type NeventNaddrPickerDialogProps = {
open: boolean open: boolean
@ -183,4 +186,69 @@ function NeventNaddrPickerDialog({
) )
} }
export default NeventNaddrPickerDialog type NeventPickerContextValue = {
openNeventPicker: (onSelected: (nostrLink: string) => void, initialMode?: PickerSearchMode) => void
}
export const NeventPickerContext = React.createContext<NeventPickerContextValue | null>(null)
export function NeventPickerProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
const [onSelectedRef, setOnSelectedRef] = useState<((link: string) => void) | null>(null)
const [initialMode, setInitialMode] = useState<PickerSearchMode>('nevent')
useEffect(() => {
const handler = (e: Event) => {
const { editor, range, initialMode: detailMode } = (e as CustomEvent<{
editor: Editor
range: { from: number; to: number }
initialMode?: PickerSearchMode
}>).detail
const to = extendMentionRangeToEndOfWord(editor, range)
setOnSelectedRef(() => (link: string) => {
editor.chain().focus().insertContentAt({ from: range.from, to }, link + ' ').run()
})
setInitialMode(detailMode ?? 'nevent')
setOpen(true)
}
window.addEventListener(OPEN_NEVENT_PICKER_EVENT, handler)
return () => window.removeEventListener(OPEN_NEVENT_PICKER_EVENT, handler)
}, [])
const openNeventPicker = useCallback((onSelected: (nostrLink: string) => void, mode?: PickerSearchMode) => {
setOnSelectedRef(() => onSelected)
setInitialMode(mode ?? 'nevent')
setOpen(true)
}, [])
const handleSelect = useCallback(
(link: string) => {
onSelectedRef?.(link)
setOnSelectedRef(null)
},
[onSelectedRef]
)
const handleOpenChange = useCallback((next: boolean) => {
if (!next) {
setOnSelectedRef(null)
setInitialMode('nevent')
}
setOpen(next)
}, [])
const value = React.useMemo(() => ({ openNeventPicker }), [openNeventPicker])
return (
<NeventPickerContext.Provider value={value}>
{children}
<NeventNaddrPickerDialog
open={open}
onOpenChange={handleOpenChange}
onSelect={handleSelect}
initialMode={initialMode}
/>
</NeventPickerContext.Provider>
)
}

71
src/components/PostEditor/PostTextarea/Mention/NeventPickerProvider.tsx

@ -1,71 +0,0 @@
import postEditor from '@/services/post-editor.service'
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { Editor } from '@tiptap/core'
import type { PickerSearchMode } from '@/services/mention-event-search.service'
import NeventNaddrPickerDialog from './NeventNaddrPickerDialog'
import { NeventPickerContext } from './nevent-picker-context'
import { OPEN_NEVENT_PICKER_EVENT, extendMentionRangeToEndOfWord } from './suggestion'
export function NeventPickerProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
const [onSelectedRef, setOnSelectedRef] = useState<((link: string) => void) | null>(null)
const [initialMode, setInitialMode] = useState<PickerSearchMode>('nevent')
useEffect(() => {
const handler = (e: Event) => {
const { editor, range, initialMode: detailMode } = (
e as CustomEvent<{
editor: Editor
range: { from: number; to: number }
initialMode?: PickerSearchMode
}>
).detail
const to = extendMentionRangeToEndOfWord(editor, range)
setOnSelectedRef(() => (link: string) => {
editor.chain().focus().insertContentAt({ from: range.from, to }, link + ' ').run()
postEditor.closeSuggestionPopup()
})
setInitialMode(detailMode ?? 'nevent')
setOpen(true)
}
window.addEventListener(OPEN_NEVENT_PICKER_EVENT, handler)
return () => window.removeEventListener(OPEN_NEVENT_PICKER_EVENT, handler)
}, [])
const openNeventPicker = useCallback((onSelected: (nostrLink: string) => void, mode?: PickerSearchMode) => {
setOnSelectedRef(() => onSelected)
setInitialMode(mode ?? 'nevent')
setOpen(true)
}, [])
const handleSelect = useCallback(
(link: string) => {
onSelectedRef?.(link)
setOnSelectedRef(null)
postEditor.closeSuggestionPopup()
},
[onSelectedRef]
)
const handleOpenChange = useCallback((next: boolean) => {
if (!next) {
setOnSelectedRef(null)
setInitialMode('nevent')
}
setOpen(next)
}, [])
const value = useMemo(() => ({ openNeventPicker }), [openNeventPicker])
return (
<NeventPickerContext.Provider value={value}>
{children}
<NeventNaddrPickerDialog
open={open}
onOpenChange={handleOpenChange}
onSelect={handleSelect}
initialMode={initialMode}
/>
</NeventPickerContext.Provider>
)
}

17
src/components/PostEditor/PostTextarea/Mention/mention-range.test.ts

@ -1,17 +0,0 @@
import { describe, expect, it } from 'vitest'
import { mentionQueryLengthInText } from './suggestion'
describe('mentionQueryLengthInText', () => {
it('includes the full handle after @', () => {
expect(mentionQueryLengthInText('@Nusa')).toBe(5)
expect(mentionQueryLengthInText('@Nusa more')).toBe(5)
})
it('includes dotted NIP-05 style handles', () => {
expect(mentionQueryLengthInText('@user.name')).toBe(10)
})
it('supports query text without the leading @', () => {
expect(mentionQueryLengthInText('Nusa')).toBe(4)
})
})

8
src/components/PostEditor/PostTextarea/Mention/nevent-picker-context.ts

@ -1,8 +0,0 @@
import { createContext } from 'react'
import type { PickerSearchMode } from '@/services/mention-event-search.service'
export type NeventPickerContextValue = {
openNeventPicker: (onSelected: (nostrLink: string) => void, initialMode?: PickerSearchMode) => void
}
export const NeventPickerContext = createContext<NeventPickerContextValue | null>(null)

247
src/components/PostEditor/PostTextarea/Mention/suggestion.ts

@ -1,3 +1,4 @@
import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants'
import { import {
MENTION_NPUB_DROPDOWN_LIMIT, MENTION_NPUB_DROPDOWN_LIMIT,
searchNpubsForMention, searchNpubsForMention,
@ -7,9 +8,9 @@ import postEditor from '@/services/post-editor.service'
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
import { ReactRenderer } from '@tiptap/react' import { ReactRenderer } from '@tiptap/react'
import { SuggestionKeyDownProps, type SuggestionProps } from '@tiptap/suggestion' import { SuggestionKeyDownProps, type SuggestionProps } from '@tiptap/suggestion'
import tippy, { GetReferenceClientRect, Instance, Props } from 'tippy.js'
import MentionList, { MentionListHandle, MentionListProps, type MentionListItem } from './MentionList' import MentionList, { MentionListHandle, MentionListProps, type MentionListItem } from './MentionList'
import { NEVENT_NADDR_PICKER_ID } from './constants' import { NEVENT_NADDR_PICKER_ID } from './constants'
import { createSuggestionPopup } from '../suggestion-popup'
export type { PickerSearchMode } export type { PickerSearchMode }
@ -18,71 +19,29 @@ const MENTION_CHAR = '@'
export const OPEN_NEVENT_PICKER_EVENT = 'open-nevent-picker' export const OPEN_NEVENT_PICKER_EVENT = 'open-nevent-picker'
// Shared state for incremental updates
let currentComponent: ReactRenderer<MentionListHandle, MentionListProps> | undefined let currentComponent: ReactRenderer<MentionListHandle, MentionListProps> | undefined
let currentQuery = '' let currentQuery = ''
let backgroundSearchController: AbortController | null = null
let mentionSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null
let mentionSearchGeneration = 0 let mentionSearchGeneration = 0
const MENTION_QUERY_CHAR = /[\w.-]/ /** Extend range.to to include any trailing word chars (handle, NIP-05) so the full @handle is replaced. Exported for nevent picker. */
/** Length of @query (including @) within `text` starting at index 0. */
export function mentionQueryLengthInText(text: string, mentionChar = MENTION_CHAR): number {
if (text.startsWith(mentionChar)) {
let i = mentionChar.length
while (i < text.length && MENTION_QUERY_CHAR.test(text[i]!)) i++
return i
}
let i = 0
while (i < text.length && MENTION_QUERY_CHAR.test(text[i]!)) i++
return i
}
/**
* Extend range.to through the full @handle typed in the document.
* TipTap's range.to is often stale (especially on mouse pick); scanning the doc text avoids leaving a trailing letter.
*/
export function extendMentionRangeToEndOfWord(editor: Editor, range: { from: number; to: number }): number { export function extendMentionRangeToEndOfWord(editor: Editor, range: { from: number; to: number }): number {
const { doc, selection } = editor.state const { doc } = editor.state
const scanEnd = Math.min(doc.content.size, range.from + 300) let pos = range.to
const prefix = doc.textBetween(range.from, scanEnd, '', '') while (pos < doc.content.size) {
const $pos = doc.resolve(pos)
let end = range.to const node = $pos.nodeAfter
if (!node || !node.isText) break
const queryLen = mentionQueryLengthInText(prefix) const text = node.text ?? ''
if (queryLen > 0) { const offset = pos - $pos.start()
end = Math.max(end, range.from + queryLen) let i = offset
} else { while (i < text.length && /[\w.-]/.test(text[i]!)) i++
let pos = range.to if (i === offset) break
while (pos < scanEnd) { pos += i - offset
const ch = doc.textBetween(pos, pos + 1, '', '')
if (!ch || !MENTION_QUERY_CHAR.test(ch)) break
pos += 1
}
end = Math.max(end, pos)
} }
return pos
// Click-to-select can leave the caret ahead of the last suggestion range update.
if (selection.empty && selection.to > end) {
let pos = selection.to
while (pos < scanEnd) {
const ch = doc.textBetween(pos, pos + 1, '', '')
if (!ch || !MENTION_QUERY_CHAR.test(ch)) break
pos += 1
}
end = Math.max(end, pos)
}
return end
}
function mountPopup(
popup: ReturnType<typeof createSuggestionPopup>,
props: { editor: Editor; clientRect?: (() => DOMRect | null) | null },
component: ReactRenderer<MentionListHandle, MentionListProps>
) {
popup.ensure({
clientRect: props.clientRect,
content: component.element
})
} }
const suggestion = { const suggestion = {
@ -96,18 +55,10 @@ const suggestion = {
props: { id: string | null; label?: string | null; mode?: PickerSearchMode } props: { id: string | null; label?: string | null; mode?: PickerSearchMode }
}) => { }) => {
if (props.id === NEVENT_NADDR_PICKER_ID) { if (props.id === NEVENT_NADDR_PICKER_ID) {
const to = extendMentionRangeToEndOfWord(editor, range)
const insertAt = range.from
// Drop @naddr / @nevent trigger text so the suggestion session ends before the dialog opens.
editor.chain().focus().deleteRange({ from: range.from, to }).run()
postEditor.closeSuggestionPopup() postEditor.closeSuggestionPopup()
window.dispatchEvent( window.dispatchEvent(
new CustomEvent(OPEN_NEVENT_PICKER_EVENT, { new CustomEvent(OPEN_NEVENT_PICKER_EVENT, {
detail: { detail: { editor, range, initialMode: props.mode ?? 'nevent' }
editor,
range: { from: insertAt, to: insertAt },
initialMode: props.mode ?? 'nevent'
}
}) })
) )
return return
@ -126,7 +77,6 @@ const suggestion = {
]) ])
.run() .run()
editor.view.dom.ownerDocument.defaultView?.getSelection()?.collapseToEnd() editor.view.dom.ownerDocument.defaultView?.getSelection()?.collapseToEnd()
postEditor.closeSuggestionPopup()
}, },
items: async ({ query }: { query: string }) => { items: async ({ query }: { query: string }) => {
@ -135,96 +85,139 @@ const suggestion = {
const mode: PickerSearchMode = q === 'naddr' || q.startsWith('naddr') ? 'naddr' : 'nevent' const mode: PickerSearchMode = q === 'naddr' || q.startsWith('naddr') ? 'naddr' : 'nevent'
return [{ id: NEVENT_NADDR_PICKER_ID, mode }] return [{ id: NEVENT_NADDR_PICKER_ID, mode }]
} }
if (mentionSearchDebounceTimer) clearTimeout(mentionSearchDebounceTimer)
const generation = ++mentionSearchGeneration const generation = ++mentionSearchGeneration
currentQuery = q
const updateComponent = (npubs: string[]) => { return new Promise<MentionListItem[]>((resolve) => {
if (generation !== mentionSearchGeneration || currentQuery !== q) return mentionSearchDebounceTimer = setTimeout(async () => {
if (currentComponent) { if (generation !== mentionSearchGeneration) return
currentComponent.updateProps({ items: npubs, loading: false })
}
}
if (currentComponent) { if (currentQuery !== q && backgroundSearchController) {
currentComponent.updateProps({ items: [], loading: true }) backgroundSearchController.abort()
} backgroundSearchController = null
}
currentQuery = q
try { const updateComponent = (npubs: string[]) => {
const results = await searchNpubsForMention(query, MENTION_NPUB_DROPDOWN_LIMIT, updateComponent) if (currentComponent && currentQuery === q && generation === mentionSearchGeneration) {
if (generation === mentionSearchGeneration) { currentComponent.updateProps({ items: npubs })
currentComponent?.updateProps({ items: results ?? [], loading: false }) }
return results ?? [] }
}
return [] backgroundSearchController = new AbortController()
} catch { try {
if (generation === mentionSearchGeneration) { const results = await searchNpubsForMention(query, MENTION_NPUB_DROPDOWN_LIMIT, updateComponent)
currentComponent?.updateProps({ items: [], loading: false }) if (generation === mentionSearchGeneration) resolve(results ?? [])
} } catch {
return [] if (generation === mentionSearchGeneration) resolve([])
} }
}, SEARCH_QUERY_DEBOUNCE_MS)
})
}, },
render: () => { render: () => {
let component: ReactRenderer<MentionListHandle, MentionListProps> | undefined let component: ReactRenderer<MentionListHandle, MentionListProps> | undefined
let popup: ReturnType<typeof createSuggestionPopup> | undefined let popup: Instance[] = []
let closePopup: (() => void) | undefined let touchListener: (e: TouchEvent) => void
let closePopup: () => void
let exited = false let exited = false
const exit = () => {
if (exited) return
exited = true
mentionSearchGeneration += 1
postEditor.isSuggestionPopupOpen = false
currentComponent = undefined
currentQuery = ''
popup?.destroy()
popup = undefined
component?.destroy()
component = undefined
if (closePopup) {
postEditor.removeEventListener('closeSuggestionPopup', closePopup)
}
}
return { return {
onBeforeStart: () => { onBeforeStart: () => {
closePopup = exit touchListener = (e: TouchEvent) => {
postEditor.removeEventListener('closeSuggestionPopup', closePopup) if (popup && popup[0] && postEditor.isSuggestionPopupOpen) {
const popupElement = popup[0].popper
if (popupElement && !popupElement.contains(e.target as Node)) {
popup[0].hide()
}
}
}
document.addEventListener('touchstart', touchListener)
closePopup = () => {
if (popup && popup[0]) {
popup[0].hide()
}
}
postEditor.addEventListener('closeSuggestionPopup', closePopup) postEditor.addEventListener('closeSuggestionPopup', closePopup)
}, },
onStart: (props: SuggestionProps<MentionListItem>) => { onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
exited = false
closePopup = exit
postEditor.removeEventListener('closeSuggestionPopup', closePopup)
postEditor.addEventListener('closeSuggestionPopup', closePopup)
popup = createSuggestionPopup(props.editor)
component = new ReactRenderer(MentionList, { component = new ReactRenderer(MentionList, {
props: { ...props, loading: true }, props,
editor: props.editor editor: props.editor
}) })
// Store component reference for incremental updates
currentComponent = component currentComponent = component
mountPopup(popup, props, component)
if (!props.clientRect) {
return
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
hideOnClick: true,
touch: true,
onShow() {
postEditor.isSuggestionPopupOpen = true
},
onHide() {
postEditor.isSuggestionPopupOpen = false
}
})
}, },
onUpdate(props: SuggestionProps<MentionListItem>) { onUpdate(props: SuggestionProps<MentionListItem>) {
if (exited) return
component?.updateProps(props) component?.updateProps(props)
if (popup && component) {
mountPopup(popup, props, component) if (!props.clientRect) {
return
} }
popup[0]?.setProps({
getReferenceClientRect: props.clientRect
} as Partial<Props>)
}, },
onKeyDown(props: SuggestionKeyDownProps) { onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === 'Escape') { if (props.event.key === 'Escape') {
exit() popup[0]?.hide()
return true return true
} }
return component?.ref?.onKeyDown(props) ?? false return component?.ref?.onKeyDown(props) ?? false
}, },
onExit() { onExit() {
exit() if (exited) return
exited = true
postEditor.isSuggestionPopupOpen = false
// Abort background search
if (backgroundSearchController) {
backgroundSearchController.abort()
backgroundSearchController = null
}
currentComponent = undefined
currentQuery = ''
if (popup[0]) {
popup[0].destroy()
popup = []
}
if (component) {
component.destroy()
component = undefined
}
document.removeEventListener('touchstart', touchListener)
postEditor.removeEventListener('closeSuggestionPopup', closePopup)
} }
} }
} }

2
src/components/PostEditor/PostTextarea/Mention/useNeventPicker.ts

@ -1,5 +1,5 @@
import * as React from 'react' import * as React from 'react'
import { NeventPickerContext } from './nevent-picker-context' import { NeventPickerContext } from './NeventNaddrPickerDialog'
export function useNeventPicker() { export function useNeventPicker() {
return React.useContext(NeventPickerContext) return React.useContext(NeventPickerContext)

103
src/components/PostEditor/PostTextarea/Preview.tsx

@ -11,16 +11,13 @@ import { createFakeEvent } from '@/lib/event'
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { cleanUrl, rewritePlainTextHttpUrls } from '@/lib/url' import { cleanUrl, rewritePlainTextHttpUrls } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { mergeContentWarningTagsFromDraftOptions, type TContentWarningDraftOptions } from '@/lib/content-warning'
import { TPollCreateData } from '@/types' import { TPollCreateData } from '@/types'
import { kinds, nip19 } from 'nostr-tools' import { kinds, nip19 } from 'nostr-tools'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { useMemo, type ReactNode } from 'react' import { useMemo, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import ContentPreview from '../../ContentPreview' import ContentPreview from '../../ContentPreview'
import Content from '../../Content' import Content from '../../Content'
import Highlight from '../../Note/Highlight' import Highlight from '../../Note/Highlight'
import MusicTrackNote from '../../Note/MusicTrackNote'
import MarkdownArticle from '../../Note/MarkdownArticle/MarkdownArticle' import MarkdownArticle from '../../Note/MarkdownArticle/MarkdownArticle'
import AsciidocArticle from '../../Note/AsciidocArticle/AsciidocArticle' import AsciidocArticle from '../../Note/AsciidocArticle/AsciidocArticle'
import { HighlightData } from '../HighlightEditor' import { HighlightData } from '../HighlightEditor'
@ -34,10 +31,8 @@ export default function Preview({
mediaImetaTags, mediaImetaTags,
mediaUrl, mediaUrl,
articleMetadata, articleMetadata,
musicTrackMetadata,
extraPreviewTags, extraPreviewTags,
addClientTag = true, addClientTag = true
contentWarning
}: { }: {
content: string content: string
className?: string className?: string
@ -55,26 +50,11 @@ export default function Preview({
/** Kind 30817: each number becomes a `k` tag. */ /** Kind 30817: each number becomes a `k` tag. */
affectedKinds?: number[] affectedKinds?: number[]
} }
musicTrackMetadata?: {
dTag?: string
title?: string
audioUrl?: string
artist?: string
imageUrl?: string
album?: string
durationSec?: number
format?: string
language?: string
genres?: string[]
}
/** Merged into the fake event (e.g. kind 11 discussion title / topic tags). */ /** Merged into the fake event (e.g. kind 11 discussion title / topic tags). */
extraPreviewTags?: string[][] extraPreviewTags?: string[][]
/** When true (default), preview matches publish: Imwald `client` + attribution `alt` tags and badge. */ /** When true (default), preview matches publish: Imwald `client` + attribution `alt` tags and badge. */
addClientTag?: boolean addClientTag?: boolean
/** Composer Advanced panel content-warning settings. */
contentWarning?: TContentWarningDraftOptions
}) { }) {
const { t } = useTranslation()
const { content: processedContent, emojiTags, highlightTags, pollTags } = useMemo( const { content: processedContent, emojiTags, highlightTags, pollTags } = useMemo(
() => { () => {
// Clean tracking parameters from URLs in the preview // Clean tracking parameters from URLs in the preview
@ -188,56 +168,15 @@ export default function Preview({
tags.push(...normalizedTopics.map((topic) => ['t', topic])) tags.push(...normalizedTopics.map((topic) => ['t', topic]))
} }
} }
if (musicTrackMetadata && kind === ExtendedKind.MUSIC_TRACK) {
if (musicTrackMetadata.dTag) {
tags.push(['d', musicTrackMetadata.dTag])
}
if (musicTrackMetadata.title) {
tags.push(['title', musicTrackMetadata.title])
}
if (musicTrackMetadata.audioUrl) {
tags.push(['url', musicTrackMetadata.audioUrl])
}
tags.push(['t', 'music'])
if (musicTrackMetadata.artist) {
tags.push(['artist', musicTrackMetadata.artist])
}
if (musicTrackMetadata.imageUrl) {
tags.push(['image', musicTrackMetadata.imageUrl])
}
if (musicTrackMetadata.album) {
tags.push(['album', musicTrackMetadata.album])
}
if (musicTrackMetadata.durationSec) {
tags.push(['duration', String(musicTrackMetadata.durationSec)])
}
if (musicTrackMetadata.format) {
tags.push(['format', musicTrackMetadata.format])
}
if (musicTrackMetadata.language) {
tags.push(['language', musicTrackMetadata.language])
}
if (musicTrackMetadata.genres?.length) {
for (const g of musicTrackMetadata.genres) {
const topic = normalizeTopic(g.trim())
if (topic && topic !== 'music') {
tags.push(['t', topic])
}
}
}
}
if (extraPreviewTags?.length) { if (extraPreviewTags?.length) {
tags.push(...extraPreviewTags) tags.push(...extraPreviewTags)
} }
if (contentWarning) {
mergeContentWarningTagsFromDraftOptions(tags, contentWarning)
}
const stripped = stripImwaldAttributionTags(tags) const stripped = stripImwaldAttributionTags(tags)
if (addClientTag) { if (addClientTag) {
stripped.push(buildClientTag()) stripped.push(buildClientTag())
} }
return stripped return stripped
}, [emojiTags, highlightTags, pollTags, mediaImetaTags, articleMetadata, musicTrackMetadata, kind, extraPreviewTags, addClientTag, contentWarning]) }, [emojiTags, highlightTags, pollTags, mediaImetaTags, articleMetadata, kind, extraPreviewTags, addClientTag])
const fakeEvent = useMemo(() => { const fakeEvent = useMemo(() => {
// For voice comments, include the media URL in content if not already there // For voice comments, include the media URL in content if not already there
@ -253,28 +192,6 @@ export default function Preview({
}) })
}, [processedContent, allTags, kind, mediaUrl]) }, [processedContent, allTags, kind, mediaUrl])
const hasPreviewBody = useMemo(() => {
if (processedContent.trim()) return true
if (mediaUrl?.trim()) return true
if (articleMetadata?.title?.trim()) return true
if (articleMetadata?.summary?.trim()) return true
if (musicTrackMetadata?.title?.trim()) return true
if (musicTrackMetadata?.audioUrl?.trim()) return true
if (kind === ExtendedKind.POLL && pollCreateData?.options.some((o) => o.trim())) return true
if (kind === kinds.Highlights && highlightData?.sourceValue?.trim()) return true
if ((mediaImetaTags?.length ?? 0) > 0) return true
return false
}, [
processedContent,
mediaUrl,
articleMetadata,
musicTrackMetadata,
kind,
pollCreateData,
highlightData,
mediaImetaTags
])
const selectableClass = 'select-text' const selectableClass = 'select-text'
const withClientBadge = (node: ReactNode) => const withClientBadge = (node: ReactNode) =>
addClientTag ? ( addClientTag ? (
@ -288,14 +205,6 @@ export default function Preview({
node node
) )
if (!hasPreviewBody) {
return (
<Card className={cn('p-3 text-sm text-muted-foreground', className, selectableClass)}>
{t('Post editor preview empty')}
</Card>
)
}
// For polls, use ContentPreview to show poll properly // For polls, use ContentPreview to show poll properly
if (kind === ExtendedKind.POLL) { if (kind === ExtendedKind.POLL) {
return withClientBadge( return withClientBadge(
@ -368,14 +277,6 @@ export default function Preview({
) )
} }
if (kind === ExtendedKind.MUSIC_TRACK) {
return withClientBadge(
<Card className={cn('p-3', className, selectableClass)}>
<MusicTrackNote event={fakeEvent} loadMedia className="mt-0" />
</Card>
)
}
return withClientBadge( return withClientBadge(
<Card className={cn('p-3', className, selectableClass)}> <Card className={cn('p-3', className, selectableClass)}>
<Content event={fakeEvent} className="h-full" mustLoadMedia /> <Content event={fakeEvent} className="h-full" mustLoadMedia />

205
src/components/PostEditor/PostTextarea/index.tsx

@ -1,10 +1,8 @@
import { ExtendedKind } from '@/constants'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { parseEditorJsonToText, plainTextToTipTapDoc } from '@/lib/tiptap' import { parseEditorJsonToText, plainTextToTipTapDoc } from '@/lib/tiptap'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import customEmojiService from '@/services/custom-emoji.service' import customEmojiService from '@/services/custom-emoji.service'
import postEditorCache from '@/services/post-editor-cache.service' import postEditorCache from '@/services/post-editor-cache.service'
import postEditorService from '@/services/post-editor.service'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import Document from '@tiptap/extension-document' import Document from '@tiptap/extension-document'
import { HardBreak } from '@tiptap/extension-hard-break' import { HardBreak } from '@tiptap/extension-hard-break'
@ -15,13 +13,10 @@ import Text from '@tiptap/extension-text'
import { TextSelection } from '@tiptap/pm/state' import { TextSelection } from '@tiptap/pm/state'
import { Editor, EditorContent, useEditor } from '@tiptap/react' import { Editor, EditorContent, useEditor } from '@tiptap/react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { import {
Dispatch, Dispatch,
forwardRef, forwardRef,
SetStateAction, SetStateAction,
useCallback,
useEffect,
useImperativeHandle, useImperativeHandle,
useMemo, useMemo,
useRef, useRef,
@ -36,7 +31,6 @@ import mentionSuggestion from './Mention/suggestion'
import Preview from './Preview' import Preview from './Preview'
import { HighlightData } from '../HighlightEditor' import { HighlightData } from '../HighlightEditor'
import { getKindDescription } from '@/lib/kind-description' import { getKindDescription } from '@/lib/kind-description'
import type { TContentWarningDraftOptions } from '@/lib/content-warning'
export type TPostTextareaHandle = { export type TPostTextareaHandle = {
appendText: (text: string, addNewline?: boolean) => void appendText: (text: string, addNewline?: boolean) => void
@ -84,21 +78,8 @@ const PostTextarea = forwardRef<
topics?: string[] topics?: string[]
affectedKinds?: number[] affectedKinds?: number[]
} }
musicTrackMetadata?: {
dTag?: string
title?: string
audioUrl?: string
artist?: string
imageUrl?: string
album?: string
durationSec?: number
format?: string
language?: string
genres?: string[]
}
extraPreviewTags?: string[][] extraPreviewTags?: string[][]
addClientTag?: boolean addClientTag?: boolean
contentWarning?: TContentWarningDraftOptions
} }
>( >(
( (
@ -122,74 +103,48 @@ const PostTextarea = forwardRef<
mediaImetaTags, mediaImetaTags,
mediaUrl, mediaUrl,
articleMetadata, articleMetadata,
musicTrackMetadata,
extraPreviewTags, extraPreviewTags,
addClientTag = true, addClientTag = true
contentWarning
}, },
ref ref
) => { ) => {
const { t } = useTranslation() const { t } = useTranslation()
const isSmallScreen = useScreenSizeOptional()?.isSmallScreen ?? false
const onUploadSuccessRef = useRef(onUploadSuccess) const onUploadSuccessRef = useRef(onUploadSuccess)
onUploadSuccessRef.current = onUploadSuccess onUploadSuccessRef.current = onUploadSuccess
const onUploadCompressPhaseRef = useRef(onUploadCompressPhase) const onUploadCompressPhaseRef = useRef(onUploadCompressPhase)
onUploadCompressPhaseRef.current = onUploadCompressPhase onUploadCompressPhaseRef.current = onUploadCompressPhase
const onUploadCompressProgressRef = useRef(onUploadCompressProgress) const onUploadCompressProgressRef = useRef(onUploadCompressProgress)
onUploadCompressProgressRef.current = onUploadCompressProgress onUploadCompressProgressRef.current = onUploadCompressProgress
const onUploadStartRef = useRef(onUploadStart)
onUploadStartRef.current = onUploadStart
const onUploadEndRef = useRef(onUploadEnd)
onUploadEndRef.current = onUploadEnd
const onUploadProgressRef = useRef(onUploadProgress)
onUploadProgressRef.current = onUploadProgress
const onSubmitRef = useRef(onSubmit)
onSubmitRef.current = onSubmit
const [activeTab, setActiveTab] = useState('edit') const [activeTab, setActiveTab] = useState('edit')
const activeTabRef = useRef(activeTab)
activeTabRef.current = activeTab
const [previewContent, setPreviewContent] = useState('')
const editorRef = useRef<Editor | null>(null) const editorRef = useRef<Editor | null>(null)
const syncPreviewFromEditor = useCallback(() => {
const ed = editorRef.current
const live = ed ? parseEditorJsonToText(ed.getJSON()) : text
setPreviewContent(live)
if (ed) setText(live)
return live
}, [setText, text])
const previewSurfaceClass = cn(
kind === ExtendedKind.POLL
? 'min-h-20'
: isSmallScreen
? 'min-h-[min(36dvh,17rem)]'
: 'min-h-52'
)
const kindDescription = useMemo(() => getKindDescription(kind), [kind]) const kindDescription = useMemo(() => getKindDescription(kind), [kind])
const placeholderText = useMemo( const editor = useEditor({
() => t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')', // TipTap + Radix Dialog/Tabs: defer init so React 18 does not warn about flushSync in a lifecycle.
[t] immediatelyRender: false,
) extensions: [
// Extension instances must be stable — recreating them each render makes useEditor destroy/recreate
// the editor (blank composer in reply dialog).
const extensions = useMemo(
() => [
Document, Document,
Paragraph, Paragraph,
Text, Text,
History, History,
HardBreak, HardBreak,
Placeholder.configure({ placeholder: placeholderText }), Placeholder.configure({
Emoji.configure({ suggestion: emojiSuggestion }), placeholder:
Mention.configure({ suggestion: mentionSuggestion }), t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')'
}),
Emoji.configure({
suggestion: emojiSuggestion
}),
Mention.configure({
suggestion: mentionSuggestion
}),
ClipboardAndDropHandler.configure({ ClipboardAndDropHandler.configure({
onUploadStart: (file, cancel) => onUploadStartRef.current?.(file, cancel), onUploadStart: (file, cancel) => {
onUploadEnd: (file) => onUploadEndRef.current?.(file), onUploadStart?.(file, cancel)
onUploadProgress: (file, p) => onUploadProgressRef.current?.(file, p), },
onUploadEnd: (file) => onUploadEnd?.(file),
onUploadProgress: (file, p) => onUploadProgress?.(file, p),
onUploadSuccess: (result) => onUploadSuccessRef.current?.(result), onUploadSuccess: (result) => onUploadSuccessRef.current?.(result),
onUploadCompressPhase: (file, phase) => onUploadCompressPhase: (file, phase) =>
onUploadCompressPhaseRef.current?.(file, phase), onUploadCompressPhaseRef.current?.(file, phase),
@ -197,31 +152,18 @@ const PostTextarea = forwardRef<
onUploadCompressProgressRef.current?.(file, pct) onUploadCompressProgressRef.current?.(file, pct)
}) })
], ],
[placeholderText]
)
const editorSurfaceClass = useMemo(
() =>
cn(
'border rounded-lg p-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
isSmallScreen && 'h-full min-h-0 flex-1 overflow-y-auto overscroll-y-contain',
className
),
[className, isSmallScreen]
)
const editor = useEditor({
// TipTap + Radix Dialog/Tabs: defer init so React 18 does not warn about flushSync in a lifecycle.
immediatelyRender: false,
extensions,
editorProps: { editorProps: {
attributes: { attributes: {
class: editorSurfaceClass class: cn(
'border rounded-lg p-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
className
)
}, },
handleKeyDown: (_view, event) => { handleKeyDown: (_view, event) => {
// Handle Ctrl+Enter or Cmd+Enter for submit
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
event.preventDefault() event.preventDefault()
onSubmitRef.current?.() onSubmit?.()
return true return true
} }
return false return false
@ -232,38 +174,16 @@ const PostTextarea = forwardRef<
}, },
content: postEditorCache.getPostContentCache({ kind, defaultContent, parentEvent }), content: postEditorCache.getPostContentCache({ kind, defaultContent, parentEvent }),
onUpdate(props) { onUpdate(props) {
const live = parseEditorJsonToText(props.editor.getJSON()) setText(parseEditorJsonToText(props.editor.getJSON()))
setText(live)
if (activeTabRef.current === 'preview') {
setPreviewContent(live)
}
postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, props.editor.getJSON()) postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, props.editor.getJSON())
}, },
onCreate(props) { onCreate(props) {
const live = parseEditorJsonToText(props.editor.getJSON()) setText(parseEditorJsonToText(props.editor.getJSON()))
setText(live)
setPreviewContent(live)
} }
}) })
editorRef.current = editor editorRef.current = editor
useEffect(() => {
postEditorService.setReplyParentEvent(parentEvent)
return () => postEditorService.setReplyParentEvent(undefined)
}, [parentEvent])
useEffect(() => {
if (!editor) return
editor.setOptions({
editorProps: {
...editor.options.editorProps,
attributes: { class: editorSurfaceClass }
}
})
}, [editor, editorSurfaceClass])
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
appendText: (text: string, addNewline = false) => { appendText: (text: string, addNewline = false) => {
const ed = editorRef.current const ed = editorRef.current
@ -314,7 +234,6 @@ const PostTextarea = forwardRef<
// Also clear the cache // Also clear the cache
postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, editor.getJSON()) postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, editor.getJSON())
setText('') setText('')
setPreviewContent('')
} }
}, },
syncFromPostCache: () => { syncFromPostCache: () => {
@ -324,9 +243,7 @@ const PostTextarea = forwardRef<
if (next === undefined) return if (next === undefined) return
editor.chain().setContent(next).run() editor.chain().setContent(next).run()
const json = editor.getJSON() const json = editor.getJSON()
const live = parseEditorJsonToText(json) setText(parseEditorJsonToText(json))
setText(live)
setPreviewContent(live)
postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, json) postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, json)
}, },
getText: () => { getText: () => {
@ -345,31 +262,17 @@ const PostTextarea = forwardRef<
const json = plainTextToTipTapDoc(plain) const json = plainTextToTipTapDoc(plain)
editor.chain().setContent(json).run() editor.chain().setContent(json).run()
postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, editor.getJSON()) postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, editor.getJSON())
const live = parseEditorJsonToText(editor.getJSON()) setText(parseEditorJsonToText(editor.getJSON()))
setText(live)
setPreviewContent(live)
} }
})) }))
const editorShellClass = cn( if (!editor) {
'border rounded-lg p-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', return null
className }
)
return ( return (
<Tabs <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-2">
value={activeTab} <div className="flex min-w-0 flex-col gap-2">
onValueChange={(tab) => {
if (tab === 'preview') {
syncPreviewFromEditor()
}
setActiveTab(tab)
}}
className={cn(
isSmallScreen ? 'flex min-h-0 flex-1 flex-col gap-2 overflow-hidden' : 'space-y-2'
)}
>
<div className="flex min-w-0 shrink-0 flex-col gap-2">
<TabsList className="w-auto shrink-0 justify-start"> <TabsList className="w-auto shrink-0 justify-start">
<TabsTrigger value="edit" title={t('Edit')}> <TabsTrigger value="edit" title={t('Edit')}>
{t('Edit')} {t('Edit')}
@ -387,53 +290,29 @@ const PostTextarea = forwardRef<
<TabsContent <TabsContent
value="edit" value="edit"
forceMount forceMount
className={cn( className="mt-0 data-[state=inactive]:hidden focus-visible:ring-0 focus-visible:ring-offset-0"
'mt-0 data-[state=inactive]:hidden focus-visible:ring-0 focus-visible:ring-offset-0',
isSmallScreen && 'flex min-h-0 flex-1 flex-col overflow-hidden'
)}
> >
{editor ? ( <EditorContent className="tiptap" editor={editor} />
<EditorContent
className={cn(
'tiptap',
isSmallScreen && 'flex min-h-0 flex-1 flex-col overflow-hidden'
)}
editor={editor}
/>
) : (
<div
className={cn(editorShellClass, 'text-muted-foreground')}
aria-hidden
>
{placeholderText}
</div>
)}
</TabsContent> </TabsContent>
<TabsContent <TabsContent
value="preview" value="preview"
forceMount className="mt-0 data-[state=inactive]:hidden focus-visible:ring-0 focus-visible:ring-offset-0"
className={cn(
'mt-0 data-[state=inactive]:hidden focus-visible:ring-0 focus-visible:ring-offset-0',
isSmallScreen && 'flex min-h-0 flex-1 flex-col overflow-hidden'
)}
> >
<div className={cn('space-y-2', isSmallScreen && 'flex min-h-0 flex-1 flex-col')}> <div className="space-y-2">
<div className="text-xs text-muted-foreground shrink-0"> <div className="text-xs text-muted-foreground">
kind {kindDescription.number}: {kindDescription.description} kind {kindDescription.number}: {kindDescription.description}
</div> </div>
<Preview <Preview
content={previewContent} content={text}
className={previewSurfaceClass} className={className}
kind={kind} kind={kind}
highlightData={highlightData} highlightData={highlightData}
pollCreateData={pollCreateData} pollCreateData={pollCreateData}
mediaImetaTags={mediaImetaTags} mediaImetaTags={mediaImetaTags}
mediaUrl={mediaUrl} mediaUrl={mediaUrl}
articleMetadata={articleMetadata} articleMetadata={articleMetadata}
musicTrackMetadata={musicTrackMetadata}
extraPreviewTags={extraPreviewTags} extraPreviewTags={extraPreviewTags}
addClientTag={addClientTag} addClientTag={addClientTag}
contentWarning={contentWarning}
/> />
</div> </div>
</TabsContent> </TabsContent>

90
src/components/PostEditor/PostTextarea/suggestion-popup.ts

@ -1,90 +0,0 @@
import postEditor from '@/services/post-editor.service'
import type { Editor } from '@tiptap/core'
import tippy, { type GetReferenceClientRect, type Instance, type Props } from 'tippy.js'
/** Above Radix Sheet/Dialog (`z-50`) so @-mention / emoji lists stay visible on mobile. */
export const SUGGESTION_POPUP_Z_INDEX = 350
export type SuggestionPopupController = {
ensure: (props: {
clientRect?: (() => DOMRect | null) | null
content: Element
}) => void
hide: () => void
destroy: () => void
}
export function createSuggestionPopup(editor: Editor): SuggestionPopupController {
let popup: Instance | undefined
let touchListener: ((e: TouchEvent) => void) | undefined
const destroy = () => {
if (touchListener) {
document.removeEventListener('touchstart', touchListener)
touchListener = undefined
}
if (popup) {
popup.destroy()
popup = undefined
}
postEditor.isSuggestionPopupOpen = false
}
const ensure = (props: {
clientRect?: (() => DOMRect | null) | null
content: Element
}) => {
if (!props.clientRect) return
if (!touchListener) {
touchListener = (e: TouchEvent) => {
if (!popup || !postEditor.isSuggestionPopupOpen) return
const target = e.target as Node
if (popup.popper?.contains(target)) return
const editorEl = editor.view?.dom
if (editorEl?.contains(target)) return
popup.hide()
}
document.addEventListener('touchstart', touchListener, { passive: true })
}
const rectProps = {
getReferenceClientRect: props.clientRect as GetReferenceClientRect
}
if (popup) {
popup.setProps({
...rectProps,
content: props.content
} as Partial<Props>)
if (!popup.state.isVisible) popup.show()
return
}
popup = tippy(document.body, {
...rectProps,
appendTo: () => document.body,
content: props.content,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
hideOnClick: false,
maxWidth: 'none',
zIndex: SUGGESTION_POPUP_Z_INDEX,
touch: true,
onShow() {
postEditor.isSuggestionPopupOpen = true
},
onHide() {
postEditor.isSuggestionPopupOpen = false
}
})
}
return {
ensure,
hide: () => popup?.hide(),
destroy
}
}

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

Loading…
Cancel
Save