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. 268
      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. 13
      src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx
  34. 39
      src/components/FavoriteRelaysSetting/RelayItem.tsx
  35. 18
      src/components/FavoriteRelaysSetting/RelaySet.tsx
  36. 33
      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. 122
      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. 538
      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. 245
      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 @@ @@ -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 @@ @@ -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 @@ -4,5 +4,3 @@ VITE_PROXY_SERVER=/sites
VITE_READ_ALOUD_TTS_URL=/api/piper-tts
VITE_LANGUAGE_TOOL_URL=/api/languagetool
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} @@ -13,9 +13,6 @@ ENV VITE_LANGUAGE_TOOL_URL=${VITE_LANGUAGE_TOOL_URL}
ARG 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 GIT_COMMIT=unknown
ARG BUILD_TIME

3
PROXY_SETUP.md

@ -35,7 +35,6 @@ These are enabled by build-time URLs: @@ -35,7 +35,6 @@ These are enabled by build-time URLs:
VITE_READ_ALOUD_TTS_URL=/api/piper-tts
VITE_LANGUAGE_TOOL_URL=/api/languagetool
VITE_TRANSLATE_URL=/api/translate
VITE_ASCIIDOCTOR_SERVER_URL=/api/asciidoctor
```
Proxy targets:
@ -47,8 +46,6 @@ ProxyPass /api/languagetool http://127.0.0.1:8010 @@ -47,8 +46,6 @@ ProxyPass /api/languagetool http://127.0.0.1:8010
ProxyPassReverse /api/languagetool http://127.0.0.1:8010
ProxyPass /api/translate http://127.0.0.1:5000
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`.

2
docker-compose.prod.yml

@ -17,7 +17,7 @@ @@ -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.
#
# 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
services:

182
docs/performance-fixes.md

@ -1,182 +0,0 @@ @@ -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 @@ @@ -1,12 +1,12 @@
{
"name": "imwald",
"version": "23.21.3",
"version": "23.17.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "imwald",
"version": "23.21.3",
"version": "23.17.3",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",
@ -66,7 +66,6 @@ @@ -66,7 +66,6 @@
"embla-carousel-react": "^8.6.0",
"embla-carousel-wheel-gestures": "^8.1.0",
"emoji-picker-element": "^1.29.1",
"emoji-picker-element-data": "^1.8.0",
"flexsearch": "^0.7.43",
"highlight.js": "^11.9.0",
"hls.js": "^1.6.15",
@ -9623,12 +9622,6 @@ @@ -9623,12 +9622,6 @@
"integrity": "sha512-TOiHzu9Dqib3x4MwcAi3wi3RdyT4SoeB4b15AvH1ks4SBwTl7DeebhZ0d3x6dNi4XfNU7IGRZ7NBQllj0RqwrQ==",
"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": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",

3
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"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",
"private": true,
"type": "module",
@ -96,7 +96,6 @@ @@ -96,7 +96,6 @@
"embla-carousel-react": "^8.6.0",
"embla-carousel-wheel-gestures": "^8.1.0",
"emoji-picker-element": "^1.29.1",
"emoji-picker-element-data": "^1.8.0",
"flexsearch": "^0.7.43",
"highlight.js": "^11.9.0",
"hls.js": "^1.6.15",

4
scripts/README-deploy.md

@ -59,9 +59,9 @@ docker compose -f docker-compose.prod.yml up -d @@ -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.
**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).

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

@ -14,7 +14,6 @@ @@ -14,7 +14,6 @@
# 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.
# 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 -e
@ -35,18 +34,16 @@ READ_ALOUD_TTS_URL="${READ_ALOUD_TTS_URL:-/api/piper-tts}" @@ -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).
LANGUAGE_TOOL_URL="${LANGUAGE_TOOL_URL-/api/languagetool}"
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)"
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 \
--build-arg "VITE_PROXY_SERVER=$PROXY_ORIGIN" \
--build-arg "VITE_READ_ALOUD_TTS_URL=$READ_ALOUD_TTS_URL" \
--build-arg "VITE_LANGUAGE_TOOL_URL=$LANGUAGE_TOOL_URL" \
--build-arg "VITE_TRANSLATE_URL=$TRANSLATE_URL" \
--build-arg "VITE_ASCIIDOCTOR_SERVER_URL=$ASCIIDOCTOR_SERVER_URL" \
--build-arg "APP_VERSION=$VERSION" \
--build-arg "GIT_COMMIT=$GIT_COMMIT" \
--build-arg "BUILD_TIME=$BUILD_TIME" \

268
src/PageManager.tsx

@ -96,7 +96,6 @@ const MePageLazy = lazy(() => import('./pages/primary/MePage')) @@ -96,7 +96,6 @@ const MePageLazy = lazy(() => import('./pages/primary/MePage'))
const ProfilePageLazy = lazy(() => import('./pages/primary/ProfilePage'))
const RelayPageLazy = lazy(() => import('./pages/primary/RelayPage'))
const SearchPageLazy = lazy(() => import('./pages/primary/SearchPage'))
const LibraryPageLazy = lazy(() => import('./pages/primary/LibraryPage'))
const RssPageLazy = lazy(() => import('./pages/primary/RssPage'))
const SettingsPrimaryPageLazy = lazy(() => import('./pages/primary/SettingsPrimaryPage'))
const CalendarPrimaryPageLazy = lazy(() => import('./pages/primary/CalendarPrimaryPage'))
@ -110,7 +109,6 @@ const PostSignupBackupRedirectLazy = lazy(() => import('@/components/PostSignupB @@ -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). */
const SecondaryProfilePageLazy = lazy(() => import('@/pages/secondary/ProfilePage'))
const PrimaryFollowingListPageLazy = lazy(() => import('@/pages/secondary/FollowingListPage'))
const PrimaryFollowersListPageLazy = lazy(() => import('@/pages/secondary/FollowersListPage'))
const PrimaryMuteListPageLazy = lazy(() => import('@/pages/secondary/MuteListPage'))
const PrimaryBookmarkListPageLazy = lazy(() => import('@/pages/secondary/BookmarkListPage'))
const PrimaryNotificationThreadFollowListPageLazy = lazy(() =>
@ -147,7 +145,6 @@ const PRIMARY_PAGE_REF_MAP = { @@ -147,7 +145,6 @@ const PRIMARY_PAGE_REF_MAP = {
profile: createRef<TPageRef>(),
relay: createRef<TPageRef>(),
search: createRef<TPageRef>(),
library: createRef<TPageRef>(),
rss: createRef<TPageRef>(),
settings: createRef<TPageRef>(),
spells: createRef<TPageRef>(),
@ -187,11 +184,6 @@ const getPrimaryPageMap = () => ({ @@ -187,11 +184,6 @@ const getPrimaryPageMap = () => ({
<SearchPageLazy ref={PRIMARY_PAGE_REF_MAP.search} />
</Suspense>
),
library: (
<Suspense fallback={primaryPageLazyFallback}>
<LibraryPageLazy ref={PRIMARY_PAGE_REF_MAP.library} />
</Suspense>
),
rss: (
<Suspense fallback={primaryPageLazyFallback}>
<RssPageLazy ref={PRIMARY_PAGE_REF_MAP.rss} />
@ -307,7 +299,6 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str @@ -307,7 +299,6 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str
// Pages that should preserve context in the URL
const contextualPages: TPrimaryPageName[] = [
'search',
'library',
'profile',
'feed',
'spells',
@ -331,7 +322,6 @@ function buildRssArticleUrl( @@ -331,7 +322,6 @@ function buildRssArticleUrl(
const key = encodeRssArticlePathSegment(articleUrl)
const contextualPages: TPrimaryPageName[] = [
'search',
'library',
'profile',
'feed',
'spells',
@ -454,7 +444,7 @@ function extractValidNoteId(raw: string): string | null { @@ -454,7 +444,7 @@ function extractValidNoteId(raw: string): string | null {
function parseNoteUrl(url: string): { noteId: string; context?: string } | null {
// Match patterns like /discussions/notes/{noteId} or /notes/{noteId}
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) {
const noteId = extractValidNoteId(contextualMatch[2])
@ -473,9 +463,10 @@ function parseNoteUrl(url: string): { noteId: string; context?: string } | null @@ -473,9 +463,10 @@ function parseNoteUrl(url: string): { noteId: string; context?: string } | 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() {
const { push: pushSecondaryPage } = useSecondaryPage()
const { openDrawer } = useNoteDrawer()
const { isSmallScreen } = useScreenSize()
const { current: currentPrimaryPage } = usePrimaryPage()
@ -488,7 +479,31 @@ export function useSmartNoteNavigation() { @@ -488,7 +479,31 @@ export function useSmartNoteNavigation() {
}
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
const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage)
@ -500,8 +515,10 @@ export function useSmartNoteNavigation() { @@ -500,8 +515,10 @@ export function useSmartNoteNavigation() {
// Desktop: check panel mode
const currentPanelMode = storage.getPanelMode()
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)
openDrawer(noteId, event)
} else {
// Double-pane: use secondary panel
pushSecondaryPage(contextualUrl)
@ -515,10 +532,11 @@ export function useSmartNoteNavigation() { @@ -515,10 +532,11 @@ export function useSmartNoteNavigation() {
/** Safe variant for createRoot trees (e.g. AsciidocArticle embedded notes). Returns no-op navigation when outside providers. */
export function useSmartNoteNavigationOptional() {
const pushSecondaryPage = useSecondaryPageOptional()
const noteDrawer = useNoteDrawerOptional()
const screenSize = useScreenSizeOptional()
const primaryPage = usePrimaryPageOptional()
if (!pushSecondaryPage || !screenSize || !primaryPage) {
if (!pushSecondaryPage || !noteDrawer || !screenSize || !primaryPage) {
return {
navigateToNote: (url: string, _event?: Event, _relatedEvents?: Event[]) => {
window.location.href = url
@ -527,6 +545,7 @@ export function useSmartNoteNavigationOptional() { @@ -527,6 +545,7 @@ export function useSmartNoteNavigationOptional() {
}
const { push } = pushSecondaryPage
const { openDrawer } = noteDrawer
const { isSmallScreen } = screenSize
const { current: currentPrimaryPage } = primaryPage
@ -537,7 +556,25 @@ export function useSmartNoteNavigationOptional() { @@ -537,7 +556,25 @@ export function useSmartNoteNavigationOptional() {
return
}
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)
if (isSmallScreen) {
push(contextualUrl)
@ -545,6 +582,7 @@ export function useSmartNoteNavigationOptional() { @@ -545,6 +582,7 @@ export function useSmartNoteNavigationOptional() {
const currentPanelMode = storage.getPanelMode()
if (currentPanelMode === 'single') {
push(contextualUrl)
openDrawer(noteId, event)
} else {
push(contextualUrl)
}
@ -754,27 +792,6 @@ export function useSmartFollowingListNavigation() { @@ -754,27 +792,6 @@ export function useSmartFollowingListNavigation() {
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
export function useSmartMuteListNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
@ -1358,6 +1375,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1358,6 +1375,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (isSmallScreen || panelMode === 'single') {
// Seed stack so in-note navigation (e.g. quotes → back) can pop to this note
pushNoteUrlOnStack(buildNoteUrl(noteId, resolved.name))
if (!isSmallScreen) {
openDrawer(noteId)
}
setTimeout(() => {
setCurrentPrimaryPage(resolved.name)
@ -1378,6 +1398,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1378,6 +1398,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (isSmallScreen || panelMode === 'single') {
pushNoteUrlOnStack(contextualUrl)
if (!isSmallScreen) {
openDrawer(noteId)
}
return
} else {
pushNoteUrlOnStack(contextualUrl)
@ -1479,12 +1502,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1479,12 +1502,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// 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)
const pathOnlyForSecondary = pathname.split('?')[0].split('#')[0]
if (pathOnlyForSecondary.startsWith('/settings/') && pathOnlyForSecondary !== '/settings') {
setCurrentPrimaryPage('settings')
setPrimaryPages((prev) => mergePrimaryPageEntry(prev, { name: 'settings' }))
}
setSecondaryStack((prevStack) => {
if (isCurrentPage(prevStack, url)) return prevStack
@ -1671,6 +1688,27 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1671,6 +1688,27 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
window.location.pathname + window.location.search + window.location.hash
if (locUrl !== '/' && 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
}
state = { index: -1, url: '/' }
@ -1765,6 +1803,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1765,6 +1803,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0]
if (noteId) {
if (isSmallScreen || panelMode === 'single') {
if (!isSmallScreen) {
openDrawer(noteId)
}
const built = findAndCreateComponent(state.url, state.index)
if (built.component) {
return [
@ -1807,6 +1848,29 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1807,6 +1848,29 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
closeDrawer()
}
// 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
return newStack
@ -1942,7 +2006,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1942,7 +2006,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
if (
primaryViewType === 'following' ||
primaryViewType === 'followers' ||
primaryViewType === 'others-relay-settings'
) {
const currentPath = window.location.pathname.split('?')[0].split('#')[0]
@ -1977,14 +2040,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1977,14 +2040,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
if (isCurrentPage(secondaryStackRef.current, url)) {
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) {
window.history.pushState({ index: top.index, url }, '', url)
}
@ -1993,9 +2048,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1993,9 +2048,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
recentSecondaryPushRef.current = { url, at: now }
// Mobile overlays the feed — keep stats/live updates on the visible timeline.
noteStatsService.setBackgroundStatsPaused(true)
if (!isSmallScreen) {
noteStatsService.setBackgroundStatsPaused(true)
client.interruptBackgroundQueries()
}
@ -2042,15 +2096,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2042,15 +2096,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (isCurrentPage(prevStack, url)) {
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) {
window.history.pushState({ index: top.index, url }, '', url)
}
@ -2170,6 +2215,16 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2170,6 +2215,16 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
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 now = Date.now()
if (now - lastPopSecondaryPageAt < POP_SECONDARY_PAGE_DEBOUNCE_MS) return
@ -2182,10 +2237,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2182,10 +2237,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
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 (stackLen > 1) {
popOneSecondaryStackFrame()
const next = popOneSecondaryStackFrame()
syncDrawerToSecondaryStackTop(next)
ignorePopStateRef.current = true
window.history.back()
} else {
@ -2252,9 +2308,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2252,9 +2308,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const shouldBeOpen =
panelMode === 'single' &&
!isSmallScreen &&
secondaryStack.length > 0
secondaryStack.length > 0 &&
!drawerOpen
setSinglePaneSheetOpen(shouldBeOpen)
}, [panelMode, isSmallScreen, secondaryStack.length])
}, [panelMode, isSmallScreen, secondaryStack.length, drawerOpen])
const primaryObscured =
secondaryStack.length > 0 || drawerOpen || primaryNoteView != null
@ -2265,12 +2322,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2265,12 +2322,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const mobileSecondaryOverlaysFeed =
isSmallScreen && secondaryStack.length > 0 && primaryNoteView == null
const primaryFeedStillVisible =
panelMode === 'double' || !primaryObscured || mobileSecondaryOverlaysFeed
useLayoutEffect(() => {
const pauseBackgroundStats = primaryObscured && !primaryFeedStillVisible
noteStatsService.setBackgroundStatsPaused(pauseBackgroundStats)
noteStatsService.setBackgroundStatsPaused(primaryFrozen)
if (primaryFrozen) {
extendProfileNetworkDeferral(PROFILE_SECONDARY_PANEL_DEFER_MS)
// 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 }) { @@ -2279,7 +2332,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
client.interruptBackgroundQueries()
}
}
}, [primaryObscured, primaryFeedStillVisible, isSmallScreen, panelMode, primaryNoteView])
}, [primaryFrozen, isSmallScreen, panelMode, primaryNoteView])
const primaryPageContextValue = useMemo(
(): PrimaryPageContextValue => ({
@ -2500,7 +2553,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2500,7 +2553,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
{/* Generic drawer for secondary stack in single-pane mode (for relay pages, etc.) */}
{panelMode === 'single' &&
!isSmallScreen &&
secondaryStack.length > 0 && (
secondaryStack.length > 0 &&
!drawerOpen && (
<Sheet
open={singlePaneSheetOpen}
registerWithModalManager={false}
@ -2514,15 +2568,22 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2514,15 +2568,22 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
>
<SheetContent
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
onPointerDownOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)}
onInteractOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)}
>
<TopSecondaryStackPane
item={secondaryStack[secondaryStack.length - 1]!}
className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
/>
<div className="h-full">
{secondaryStack.map((item, index) => {
const isLast = index === secondaryStack.length - 1
if (!isLast) return null
return (
<div key={item.index}>
{item.component}
</div>
)
})}
</div>
</SheetContent>
</Sheet>
)}
@ -2570,57 +2631,12 @@ export function SecondaryPageLink({ @@ -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) {
const currentPage = stack[stack.length - 1]
if (!currentPage) return false
const match =
currentPage.url === url || secondaryPanelUrlsMatch(currentPage.url, url)
logger.component('PageManager', 'isCurrentPage check', {
currentUrl: currentPage.url,
newUrl: url,
match
})
return match
logger.component('PageManager', 'isCurrentPage check', { currentUrl: currentPage.url, newUrl: url, match: currentPage.url === url })
return currentPage.url === url
}
/** 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 @@ @@ -1,15 +1,13 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { isRedundantAccountPick, isSameAccount } from '@/lib/account'
import { formatPubkey, hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
import { isSameAccount } from '@/lib/account'
import { formatPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { TAccountPointer, TSignerType } from '@/types'
import { Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username'
@ -25,14 +23,7 @@ export default function AccountList({ @@ -25,14 +23,7 @@ export default function AccountList({
* dialogs fighting over focus trapping). */
closeDialog?: () => void
}) {
const { t } = useTranslation()
const {
accounts,
account,
switchAccount,
removeAccount,
retryNip07SignerForPreferredAccount
} = useNostr()
const { accounts, account, switchAccount, removeAccount } = useNostr()
const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null)
return (
@ -42,48 +33,19 @@ export default function AccountList({ @@ -42,48 +33,19 @@ export default function AccountList({
key={`${act.pubkey}-${act.signerType}`}
className={cn(
'relative rounded-lg',
account &&
hexPubkeysEqual(
normalizeHexPubkey(act.pubkey),
normalizeHexPubkey(account.pubkey)
) &&
(act.signerType === account.signerType ||
(account.signerType === 'npub' && act.signerType === 'nip-07'))
? 'border border-primary'
: 'clickable'
act.pubkey === account?.pubkey ? 'border border-primary' : 'clickable'
)}
onClick={() => {
void (async () => {
if (isRedundantAccountPick(act, account)) {
if (account?.signerType === 'npub' && act.signerType === 'nip-07') {
setSwitchingAccount(act)
await switchAccount(act)
const ok = await retryNip07SignerForPreferredAccount()
if (ok) {
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 (isSameAccount(act, account)) return
setSwitchingAccount(act)
if (act.signerType === 'ncryptsec') {
closeDialog?.()
}
switchAccount(act)
.then(() => {
if (act.signerType !== 'ncryptsec') afterSwitch()
} finally {
setSwitchingAccount(null)
}
})()
})
.finally(() => setSwitchingAccount(null))
}}
>
<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' @@ -5,8 +5,7 @@ import { Separator } from '@/components/ui/separator'
import { useNostr } from '@/providers/NostrProvider'
import { generateSecretKey } from 'nostr-tools'
import { nsecEncode } from 'nostr-tools/nip19'
import { useState, useCallback } from 'react'
import { Loader2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import AccountList from '../AccountList'
@ -42,26 +41,9 @@ function AccountManagerNav({ @@ -42,26 +41,9 @@ function AccountManagerNav({
close?: () => void
}) {
const { t } = useTranslation()
const { nip07Login, nsecLogin, accounts, isNip07LoginInFlight, requestAccountNetworkHydrate } =
useNostr()
const { nip07Login, nsecLogin, accounts } = useNostr()
const [password, setPassword] = useState('')
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 () => {
setSigningUp(true)
@ -85,14 +67,7 @@ function AccountManagerNav({ @@ -85,14 +67,7 @@ function AccountManagerNav({
</div>
<div className="space-y-2 mt-4">
{!!window.nostr && (
<Button
onClick={() => void handleExtensionLogin()}
disabled={extensionLoginPending || isNip07LoginInFlight}
className="w-full"
>
{extensionLoginPending || isNip07LoginInFlight ? (
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden />
) : null}
<Button onClick={() => nip07Login().then(() => close?.())} className="w-full">
{t('Login with Browser Extension')}
</Button>
)}

115
src/components/AccountQuickSwitchMenuItems.tsx

@ -1,115 +0,0 @@ @@ -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 { @@ -30,7 +30,6 @@ import {
serializePublishPreviewLabJson,
type AdvancedEventLabSlice
} from '@/lib/advanced-event-lab-slice'
import type { TContentWarningDraftOptions } from '@/lib/content-warning'
import { translateAdvancedLabMarkup } from '@/lib/advanced-lab-markup-protect'
import {
warmTranslateLanguagesOnce,
@ -202,8 +201,6 @@ export type AdvancedEventLabDialogProps = { @@ -202,8 +201,6 @@ export type AdvancedEventLabDialogProps = {
previewEmojiTags?: string[][]
/** When true (default), JSON preview includes the Imwald `client` tag like publish. */
addClientTag?: boolean
/** Composer Advanced panel content-warning settings (merged into JSON preview). */
contentWarning?: TContentWarningDraftOptions
}
function useDarkModeFlag(): boolean {
@ -237,8 +234,7 @@ export default function AdvancedEventLabDialog({ @@ -237,8 +234,7 @@ export default function AdvancedEventLabDialog({
draftPersistenceKey = null,
previewAuthorPubkey = null,
previewEmojiTags,
addClientTag = true,
contentWarning
addClientTag = true
}: AdvancedEventLabDialogProps) {
const { t, i18n } = useTranslation()
/** `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({ @@ -320,10 +316,10 @@ export default function AdvancedEventLabDialog({
content,
tags: editableRowsToLabTags(labTagRows)
},
{ addClientTag, contentWarning }
{ addClientTag }
)
)
}, [kindEditable, initial, labTagRows, addClientTag, contentWarning])
}, [kindEditable, initial, labTagRows, addClientTag])
useEffect(() => {
refreshLabJsonPreviewRef.current = refreshLabJsonPreview

25
src/components/AnonUserAvatar.tsx

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

7
src/components/CacheBrowser/CacheBrowserDialog.tsx

@ -6,7 +6,6 @@ import { Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Copy, @@ -6,7 +6,6 @@ import { Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Copy,
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
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 { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
@ -178,11 +177,7 @@ export default function CacheBrowserDialog({ @@ -178,11 +177,7 @@ export default function CacheBrowserDialog({
if (!selectedStore) return
if (!confirm(t('Are you sure you want to delete all items from this store?'))) return
try {
if (selectedStore === StoreNames.LIBRARY_PUBLICATION_INDEX) {
await clearAllLibraryIndexCaches()
} else {
await indexedDb.clearStore(selectedStore)
}
await indexedDb.clearStore(selectedStore)
setStoreItems([])
void loadCacheInfo()
toast.success(t('All items deleted successfully'))

34
src/components/ConnectedRelays/ActiveRelaysDropdownSection.tsx

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

4
src/components/ContentPreview/FollowPackPreview.tsx

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

54
src/components/EmojiPicker/index.tsx

@ -1,6 +1,3 @@ @@ -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 { recordEmojiUsed } from '@/lib/recently-used-emojis'
import { useNostr } from '@/providers/NostrProvider'
@ -15,23 +12,18 @@ export { DEFAULT_SUGGESTED_EMOJIS as EMOJI_PICKER_REACTIONS } from '@/lib/like-r @@ -15,23 +12,18 @@ export { DEFAULT_SUGGESTED_EMOJIS as EMOJI_PICKER_REACTIONS } from '@/lib/like-r
export default function EmojiPicker({
onEmojiClick,
reactionsDefaultOpen,
reactions,
layout = 'popover'
reactions
}: {
onEmojiClick: (emoji: string | TEmoji | undefined, event: Event) => void
reactionsDefaultOpen?: boolean
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 { pubkey } = useNostr()
const [mode, setMode] = useState<'reactions' | 'full'>(
reactionsDefaultOpen ? 'reactions' : 'full'
)
const [customEmojiTick, setCustomEmojiTick] = useState(0)
const [pickerReady, setPickerReady] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const pickerRef = useRef<(HTMLElement & { customEmoji: unknown[] }) | null>(null)
@ -51,17 +43,15 @@ export default function EmojiPicker({ @@ -51,17 +43,15 @@ export default function EmojiPicker({
if (mode !== 'full') return
let cancelled = false
setPickerReady(false)
preloadEmojiPickerModule().then(({ Picker }) => {
import('emoji-picker-element').then(({ Picker }) => {
if (cancelled || !containerRef.current) return
const picker = new Picker({
dataSource: EMOJI_PICKER_DATA_SOURCE,
customEmoji: customEmojis
}) as HTMLElement & { customEmoji: unknown[] }
const picker = new Picker() as HTMLElement & { customEmoji: unknown[] }
pickerRef.current = picker
picker.customEmoji = customEmojis
if (themeSetting === 'dark') {
picker.className = 'dark'
} else if (themeSetting === 'light') {
@ -69,15 +59,6 @@ export default function EmojiPicker({ @@ -69,15 +59,6 @@ export default function EmojiPicker({
}
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')
const handleClick = (e: Event) => {
@ -123,18 +104,16 @@ export default function EmojiPicker({ @@ -123,18 +104,16 @@ export default function EmojiPicker({
picker.addEventListener('emoji-click', handleClick)
containerRef.current.appendChild(picker)
if (!cancelled) setPickerReady(true)
})
return () => {
cancelled = true
setPickerReady(false)
if (pickerRef.current) {
pickerRef.current.remove()
pickerRef.current = null
}
}
}, [mode, inDrawer])
}, [mode])
useEffect(() => {
if (pickerRef.current) {
@ -207,26 +186,9 @@ export default function EmojiPicker({ @@ -207,26 +186,9 @@ export default function EmojiPicker({
}
return (
<div
className={cn(
'flex w-full min-w-0 flex-col',
inDrawer && 'min-h-0 flex-1'
)}
>
<div className="flex w-full min-w-0 flex-col">
{ownEmojisRow}
<div
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 ref={containerRef} className="min-h-0 w-full flex-1 overflow-hidden" />
</div>
)
}

36
src/components/EmojiPickerDialog/index.tsx

@ -4,10 +4,9 @@ import { @@ -4,10 +4,9 @@ import {
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { preloadEmojiPicker } from '@/lib/emoji-picker-preload'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { TEmoji } from '@/types'
import { useCallback, useEffect, useState } from 'react'
import { useState } from 'react'
import EmojiPicker from '../EmojiPicker'
export default function EmojiPickerDialog({
@ -22,35 +21,15 @@ export default function EmojiPickerDialog({ @@ -22,35 +21,15 @@ export default function EmojiPickerDialog({
}) {
const { isSmallScreen } = useScreenSize()
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) {
return (
<Drawer
open={open}
onOpenChange={handleOpenChange}
handleOnly
shouldScaleBackground={false}
repositionInputs={false}
>
<Drawer open={open} onOpenChange={setOpen} handleOnly shouldScaleBackground={false}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent
dragHandle="vaul"
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) => {
const t = e.target as HTMLElement | null
if (t?.closest?.('[data-vaul-overlay]')) return
@ -60,10 +39,9 @@ export default function EmojiPickerDialog({ @@ -60,10 +39,9 @@ export default function EmojiPickerDialog({
<DrawerHeader className="sr-only">
<DrawerTitle>Emoji Picker</DrawerTitle>
</DrawerHeader>
<div className="flex min-h-0 w-full max-w-[100vw] flex-1 flex-col overflow-hidden">
{pickerMounted ? (
<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">
{open ? (
<EmojiPicker
layout="drawer"
onEmojiClick={(emoji, e) => {
e.stopPropagation()
setOpen(false)
@ -78,11 +56,11 @@ export default function EmojiPickerDialog({ @@ -78,11 +56,11 @@ export default function EmojiPickerDialog({
}
return (
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
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}
>
<EmojiPicker

135
src/components/Explore/ExploreFavoriteRelays.tsx

@ -0,0 +1,135 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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() { @@ -53,19 +53,19 @@ export default function AddNewRelay() {
return (
<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
placeholder={t('Add a new relay')}
value={input}
onChange={handleNewRelayInputChange}
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')}
</Button>
</div>
{errorMsg && <div className="text-destructive text-sm">{errorMsg}</div>}
{errorMsg && <div className="text-destructive text-sm pl-8">{errorMsg}</div>}
</div>
)
}

10
src/components/FavoriteRelaysSetting/BlockedRelayItem.tsx

@ -29,19 +29,19 @@ export default function BlockedRelayItem({ relay }: { relay: string }) { @@ -29,19 +29,19 @@ export default function BlockedRelayItem({ relay }: { relay: string }) {
return (
<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))}
>
<div className="flex min-w-0 flex-1 items-start gap-2">
<RelayIcon url={relay} skipRelayInfoFetch className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1 break-all text-sm font-semibold leading-snug">{relay}</div>
<div className="flex items-center gap-2 flex-1">
<RelayIcon url={relay} skipRelayInfoFetch />
<div className="flex-1 w-0 truncate font-semibold">{relay}</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleUnblock}
disabled={isLoading}
className="h-8 w-8 shrink-0 p-0"
className="h-8 w-8 p-0"
>
{isLoading ? (
<Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />

13
src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx

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

39
src/components/FavoriteRelaysSetting/RelayItem.tsx

@ -22,33 +22,32 @@ export default function RelayItem({ relay, isBlocked = false }: { relay: string; @@ -22,33 +22,32 @@ export default function RelayItem({ relay, isBlocked = false }: { relay: string;
return (
<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}
style={style}
onClick={() => push(toRelay(relay))}
>
<div
className="shrink-0 cursor-grab touch-none rounded p-2 hover:bg-muted active:cursor-grabbing"
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
>
<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 className="flex items-center gap-1 flex-1">
<div
className="cursor-grab active:cursor-grabbing p-2 hover:bg-muted rounded touch-none shrink-0"
{...attributes}
{...listeners}
>
<GripVertical className="size-4 text-muted-foreground" />
</div>
<div className="shrink-0 self-end sm:self-center" onClick={(e) => e.stopPropagation()}>
<SaveRelayDropdownMenu urls={[relay]} />
<div className="flex gap-2 items-center flex-1 min-w-0">
<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>
<SaveRelayDropdownMenu urls={[relay]} />
</div>
)
}

18
src/components/FavoriteRelaysSetting/RelaySet.tsx

@ -42,25 +42,25 @@ export default function RelaySet({ relaySet }: { relaySet: TRelaySet }) { @@ -42,25 +42,25 @@ export default function RelaySet({ relaySet }: { relaySet: TRelaySet }) {
}
return (
<div ref={setNodeRef} style={style} className="group relative min-w-0">
<div className="w-full min-w-0 rounded-lg border 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 min-w-0 items-center">
<div ref={setNodeRef} style={style} className="relative group">
<div className="w-full border rounded-lg px-2 py-2.5">
<div className="flex justify-between items-center">
<div className="flex items-center">
<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}
{...listeners}
>
<GripVertical className="size-4 text-muted-foreground" />
</div>
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
<div className="flex gap-2 items-center">
<div className="flex justify-center items-center w-6 h-6 shrink-0">
<FolderClosed className="size-4" />
</div>
<RelaySetName relaySet={relaySet} />
</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}>
{t('n relays', { n: relaySet.relayUrls.length })}
</RelayUrlsExpandToggle>
@ -111,7 +111,7 @@ function RelaySetName({ relaySet }: { relaySet: TRelaySet }) { @@ -111,7 +111,7 @@ function RelaySetName({ relaySet }: { relaySet: TRelaySet }) {
</Button>
</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>
)
}

33
src/components/FavoriteRelaysSetting/RelayUrl.tsx

@ -81,20 +81,16 @@ export default function RelayUrls({ relaySetId }: { relaySetId: string }) { @@ -81,20 +81,16 @@ export default function RelayUrls({ relaySetId }: { relaySetId: string }) {
<RelayUrl key={index} url={url} onRemove={() => removeRelayUrl(url)} />
))}
</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
className={`min-w-0 flex-1 ${newRelayUrlError ? 'border-destructive' : ''}`}
className={newRelayUrlError ? 'border-destructive' : ''}
placeholder={t('Add a new relay')}
value={newRelayUrl}
onKeyDown={handleRelayUrlInputKeyDown}
onChange={handleRelayUrlInputChange}
onBlur={saveNewRelayUrl}
/>
<Button
className="shrink-0 sm:w-auto"
onClick={saveNewRelayUrl}
disabled={isLoading || !newRelayUrl.trim()}
>
<Button onClick={saveNewRelayUrl} disabled={isLoading || !newRelayUrl.trim()}>
{isLoading ? t('Adding...') : t('Add')}
</Button>
</div>
@ -107,22 +103,21 @@ function RelayUrl({ url, onRemove }: { url: string; onRemove: () => void }) { @@ -107,22 +103,21 @@ function RelayUrl({ url, onRemove }: { url: string; onRemove: () => void }) {
const { push } = useSecondaryPage()
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
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))}
>
<RelayIcon url={url} className="mt-0.5 h-4 w-4 shrink-0" iconSize={10} />
<div className="min-w-0 flex-1 break-all text-sm leading-snug text-muted-foreground">{url}</div>
<RelayIcon url={url} className="w-4 h-4" iconSize={10} />
<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>
<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>
)
}

2
src/components/FavoriteRelaysSetting/index.tsx

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

47
src/components/FeedFilterToolbarRow/index.tsx

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

16
src/components/FollowButton/index.tsx

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

94
src/components/FollowingFavoriteRelayList/index.tsx

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

485
src/components/GifPicker/index.tsx

@ -8,7 +8,6 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from @@ -8,7 +8,6 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserReadInboxUrls } from '@/hooks/useUserMailboxRelayUrls'
@ -42,14 +41,6 @@ const EMPTY_FOLLOWING_PUBKEYS: readonly string[] = [] @@ -42,14 +41,6 @@ const EMPTY_FOLLOWING_PUBKEYS: readonly string[] = []
const GIFBUDDY_SEARCH_URL = (q: string) =>
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({
children,
onSelect,
@ -87,11 +78,6 @@ export default function GifPicker({ @@ -87,11 +78,6 @@ export default function GifPicker({
const [publishDescription, setPublishDescription] = useState('')
const fileInputRef = useRef<HTMLInputElement | 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()
@ -181,53 +167,13 @@ export default function GifPicker({ @@ -181,53 +167,13 @@ export default function GifPicker({
void 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(
(gif: GifMetadata) => {
const url = (gif.fallbackUrl?.trim() || gif.url).trim()
if (!url) return
const desc = publishDescription.trim()
onSelect?.(url)
handleOpenChange(false)
setOpen(false)
if (!pubkey || !/^https?:\/\//i.test(url)) return
// Fire-and-forget: waiting on every relay can freeze the UI when relays are down.
void publish(buildKind1063GifPublishDraft(url, desc), {
@ -235,7 +181,7 @@ export default function GifPicker({ @@ -235,7 +181,7 @@ export default function GifPicker({
}).catch(() => {})
if (desc) setPublishDescription('')
},
[pubkey, onSelect, publish, gif1063PublishRelayUrls, publishDescription, handleOpenChange]
[pubkey, onSelect, publish, gif1063PublishRelayUrls, publishDescription]
)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@ -281,7 +227,7 @@ export default function GifPicker({ @@ -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. */
const openGifBuddySearch = useCallback(() => {
const url = GIFBUDDY_SEARCH_URL(pasteUrl || searchInput)
const url = GIFBUDDY_SEARCH_URL(searchInput)
const w = window.open(url, '_blank', 'noopener,noreferrer')
gifbuddyPopupRef.current = w ?? null
const handler = (event: MessageEvent) => {
@ -295,7 +241,7 @@ export default function GifPicker({ @@ -295,7 +241,7 @@ export default function GifPicker({
window.removeEventListener('message', handler)
gifbuddyPopupRef.current = null
onSelect?.(urlToInsert)
handleOpenChange(false)
setOpen(false)
}
}
window.addEventListener('message', handler)
@ -304,7 +250,7 @@ export default function GifPicker({ @@ -304,7 +250,7 @@ export default function GifPicker({
gifbuddyPopupRef.current = null
}, 10 * 60 * 1000)
if (w) w.addEventListener('beforeunload', () => { clearTimeout(t); window.removeEventListener('message', handler) })
}, [pasteUrl, searchInput, onSelect, handleOpenChange])
}, [searchInput, onSelect])
const descriptionForPublish = publishDescription.trim()
@ -314,7 +260,7 @@ export default function GifPicker({ @@ -314,7 +260,7 @@ export default function GifPicker({
if (!url || !/^https?:\/\//i.test(url)) return
onSelect?.(url)
setPasteUrl('')
handleOpenChange(false)
setOpen(false)
if (pubkey) {
setPublishingPaste(true)
try {
@ -328,7 +274,7 @@ export default function GifPicker({ @@ -328,7 +274,7 @@ export default function GifPicker({
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). */
const handleArchiveAndInsert = useCallback(
@ -341,7 +287,7 @@ export default function GifPicker({ @@ -341,7 +287,7 @@ export default function GifPicker({
const desc = publishDescription.trim()
setArchivingEventId(gif.eventId)
onSelect?.(url)
handleOpenChange(false)
setOpen(false)
void loadGifs(true)
void publish(buildKind1063GifPublishDraft(url, desc), {
specifiedRelayUrls: gif1063PublishRelayUrls
@ -352,7 +298,7 @@ export default function GifPicker({ @@ -352,7 +298,7 @@ export default function GifPicker({
if (desc) setPublishDescription('')
})
},
[pubkey, publish, gif1063PublishRelayUrls, onSelect, loadGifs, publishDescription, handleOpenChange]
[pubkey, publish, gif1063PublishRelayUrls, onSelect, loadGifs, publishDescription]
)
const gifSourceKindTitle = useCallback(
@ -386,287 +332,210 @@ export default function GifPicker({ @@ -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" */
const isDrawer = isSmallScreen
const renderGifGrid = (items: GifMetadata[], showArchiveActions: boolean) =>
loading ? (
<div
className="grid grid-cols-2 gap-1 p-2 min-h-[120px]"
role="status"
aria-busy="true"
aria-live="polite"
>
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="aspect-square w-full rounded" />
))}
</div>
) : (
<div className="grid grid-cols-2 gap-1 p-2 min-h-[120px] content-start">
{items.map((gif) => {
const showArchive = showArchiveActions && gifShouldOfferNip94Archive(gif) && isLoggedIn
return (
<div
key={gif.eventId}
className="relative aspect-square min-h-0 w-full rounded overflow-hidden [contain:layout]"
const gifGrid = loading ? (
<div
className="grid grid-cols-2 gap-1 p-2 min-h-[200px]"
role="status"
aria-busy="true"
aria-live="polite"
>
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="aspect-square w-full rounded" />
))}
</div>
) : (
<div className="grid grid-cols-2 gap-1 p-2">
{gifs.map((gif) => {
const showArchive = gifShouldOfferNip94Archive(gif) && isLoggedIn
return (
<div key={gif.eventId} className="relative aspect-square rounded overflow-hidden">
<button
type="button"
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"
className={cn(
'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'
variant="secondary"
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)}
>
<img
src={gif.url}
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)}
aria-label={t(
'Publish kind 1063 (NIP-94) for this GIF and insert the URL into your post'
)}
onClick={(e) => handleArchiveAndInsert(e, gif)}
>
{gifSourceKindShortLabel(gif)}
</span>
{showArchive && (
<Button
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)}
<Download className="size-3.5" />
</Button>
)}
</div>
)
})}
</div>
)
const tabbedContent = (
const content = (
<div
ref={pickerRootRef}
data-gif-picker-root
className={cn(
'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]'
)}
>
<div className="flex shrink-0 items-center gap-2">
<p className="min-w-0 flex-1 truncate text-sm font-medium">{t('Choose a GIF')}</p>
<div className="flex items-center gap-1 shrink-0">
<Input
placeholder={t('Search GIFs')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
className={cn('size-8 shrink-0', isDrawer && 'touch-manipulation')}
onClick={(e) => {
e.stopPropagation()
handleOpenChange(false)
}}
className="shrink-0 size-8"
onClick={() => setOpen(false)}
aria-label={t('Close')}
>
<X className="size-4" />
</Button>
</div>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as GifPickerTab)}
className={cn('flex flex-col', isDrawer && 'min-h-0 flex-1')}
{error && (
<p className="text-sm text-muted-foreground px-1 shrink-0">{error}</p>
)}
<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">
<TabsTrigger
value="find"
className={cn('px-1.5 py-1.5 text-xs', isDrawer && 'touch-manipulation')}
>
{t('Find GIF')}
</TabsTrigger>
<TabsTrigger
value="import"
className={cn('px-1.5 py-1.5 text-xs', isDrawer && 'touch-manipulation')}
{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">
{gifGrid}
</div>
) : (
<ScrollArea className="h-[520px] w-full rounded-md border">{gifGrid}</ScrollArea>
)}
</div>
<div className="flex flex-col gap-2 border-t pt-2 shrink-0">
<div className="flex flex-col gap-1.5">
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={openGifBuddySearch}
>
{t('Import GIF')}
</TabsTrigger>
</TabsList>
<TabsContent
value="find"
className={cn(
'mt-2 data-[state=inactive]:hidden focus-visible:ring-0 focus-visible:ring-offset-0',
isDrawer ? 'flex min-h-0 flex-1 flex-col' : 'flex flex-col'
)}
>
{findPanel}
</TabsContent>
<TabsContent
value="import"
className={cn(
'mt-2 data-[state=inactive]:hidden focus-visible:ring-0 focus-visible:ring-offset-0',
isDrawer ? 'min-h-0 flex-1 overflow-y-auto overscroll-y-contain' : ''
)}
{...(isDrawer && { 'data-vaul-no-drag': true })}
>
{importPanel}
</TabsContent>
</Tabs>
<ExternalLink className="size-3.5 mr-1.5" />
{t('Search on GifBuddy')}
</Button>
<p className="text-xs text-muted-foreground">
{t('Opens in a new tab. Copy a GIF URL there, then paste below. If this picker closed, click “Insert GIF” again to paste.')}
</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"
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>
</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>
)
const content = tabbedContent
if (isSmallScreen) {
return (
<Drawer
open={open}
onOpenChange={handleOpenChange}
onAnimationEnd={handleDrawerAnimationEnd}
handleOnly
shouldScaleBackground={false}
repositionInputs={false}
>
<Drawer open={open} onOpenChange={setOpen} handleOnly shouldScaleBackground={false}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent
dragHandle="vaul"
portalContainer={portalContainer}
className="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()
}}
className="max-h-[min(88dvh,calc(100dvh-5rem))] px-2 pb-2"
>
<DrawerHeader className="sr-only">
<DrawerTitle>{t('Choose a GIF')}</DrawerTitle>
</DrawerHeader>
<div className="flex h-full min-h-0 w-full min-w-0 max-w-[100vw] flex-col overflow-hidden">
{drawerContentMounted ? content : null}
<div className="flex min-h-0 w-full min-w-0 max-w-[100vw] flex-1 flex-col overflow-hidden">
{content}
</div>
</DrawerContent>
</Drawer>
@ -674,7 +543,7 @@ export default function GifPicker({ @@ -674,7 +543,7 @@ export default function GifPicker({
}
return (
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="p-0" portalContainer={portalContainer}>
{content}

145
src/components/HelpAndAccountMenu.tsx

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

86
src/components/Image/index.tsx

@ -11,7 +11,7 @@ import { @@ -11,7 +11,7 @@ import {
import { TImetaInfo } from '@/types'
import { blurHashPlaceholderForMediaUrl } from '@/lib/media-placeholder-blurhash'
import { decode } from 'blurhash'
import { Image as ImageIcon, ImageOff } from 'lucide-react'
import { ImageOff } from 'lucide-react'
import {
CSSProperties,
HTMLAttributes,
@ -22,7 +22,7 @@ import { @@ -22,7 +22,7 @@ import {
useRef,
useState
} from 'react'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useTranslation } from 'react-i18next'
/** 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 { @@ -65,18 +65,15 @@ function extensionWithDotFromUrl(url: string): string {
}
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,
className = '',
classNames = {},
hideIfError = false,
/** Called after internal URL fallbacks are exhausted and the image still failed to load. */
onFinalError,
errorPlaceholder = <ImageOff />,
style: wrapperStyleProp,
holdUntilClick = false,
fetchPriority,
loading = 'eager',
onClick,
showAltCaption = false,
caption,
@ -97,12 +94,9 @@ export default function Image({ @@ -97,12 +94,9 @@ export default function Image({
/** Caption below the image; defaults to resolved alt when {@link showAltCaption} is true. */
caption?: string
hideIfError?: boolean
onFinalError?: () => void
errorPlaceholder?: React.ReactNode
/** Passed to the inner `<img>` (e.g. profile banner vs avatar load order). */
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.
* The first click runs {@link onClick} (e.g. open lightbox) and also reveals the
@ -114,9 +108,10 @@ export default function Image({ @@ -114,9 +108,10 @@ export default function Image({
holdUntilClick?: boolean
}) {
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). */
const effectiveHoldUntilClick = holdUntilClick && !autoLoadForAuthor
const effectiveHoldUntilClick =
holdUntilClick && (contentPolicy !== undefined ? !contentPolicy.autoLoadMedia : true)
const urlOk = !!url?.trim()
const [revealed, setRevealed] = useState(!effectiveHoldUntilClick)
@ -135,8 +130,6 @@ export default function Image({ @@ -135,8 +130,6 @@ export default function Image({
const imgRef = useRef<HTMLImageElement | null>(null)
/** Deduplicate onLoad vs sync cache hit vs decode() — otherwise blurhash can stick when `onLoad` never runs. */
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 imgTitle =
@ -158,21 +151,6 @@ export default function Image({ @@ -158,21 +151,6 @@ export default function Image({
const badSrc = !imageUrl?.trim() || !isRenderableMediaUrl(imageUrl.trim())
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). */
const effectiveBlurHash = useMemo(() => {
@ -193,7 +171,6 @@ export default function Image({ @@ -193,7 +171,6 @@ export default function Image({
useEffect(() => {
setImageUrl(resolvePrimalBlossomPlayableUrl(url ?? ''))
loadSettledRef.current = false
setIntrinsicDim(undefined)
wasInitiallyHeldRef.current = effectiveHoldUntilClick
const shouldHold = effectiveHoldUntilClick
const sessionRevealed = Boolean(url?.trim() && wasMediaUrlRevealed(url))
@ -218,14 +195,12 @@ export default function Image({ @@ -218,14 +195,12 @@ export default function Image({
if (loadSettledRef.current) return
loadSettledRef.current = true
clearLoadWatch()
const el = imgRef.current
if (el) captureIntrinsicDim(el)
setIsLoading(false)
setHasError(false)
// 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.
setDisplaySkeleton(false)
}, [captureIntrinsicDim])
}, [])
// Cached images are often `complete` before `onLoad` is attached (feed mounts many cards at once).
useLayoutEffect(() => {
@ -233,10 +208,19 @@ export default function Image({ @@ -233,10 +208,19 @@ export default function Image({
const el = imgRef.current
if (!el) return
if (el.complete && el.naturalWidth > 0) {
captureIntrinsicDim(el)
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(() => {
clearLoadWatch()
@ -294,7 +278,6 @@ export default function Image({ @@ -294,7 +278,6 @@ export default function Image({
setIsLoading(false)
setDisplaySkeleton(false)
setHasError(true)
onFinalError?.()
}
const handleLoad = () => {
@ -302,9 +285,9 @@ export default function Image({ @@ -302,9 +285,9 @@ export default function Image({
}
const reserveStyle = wrapperReserveStyle(
effectiveDim,
dim,
showErrorState,
displaySkeleton && !showErrorState && !effectiveDim
displaySkeleton && !showErrorState
)
const mergedWrapperStyle: CSSProperties | undefined =
reserveStyle || wrapperStyleProp
@ -325,21 +308,16 @@ export default function Image({ @@ -325,21 +308,16 @@ export default function Image({
}
const hasHoverTip = Boolean(imgTitle)
const showTapToRevealChrome = !showErrorState && !revealed && effectiveHoldUntilClick
const tapToRevealLabel = t('Click to load image')
return (
<span className={cn('block w-full not-prose', classNames.wrapper)}>
<span
className={cn(
'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'
)}
style={mergedWrapperStyle}
title={showTapToRevealChrome ? tapToRevealLabel : imgTitle}
role={showTapToRevealChrome ? 'button' : undefined}
aria-label={showTapToRevealChrome ? tapToRevealLabel : undefined}
title={imgTitle}
onClick={handleWrapperClick}
{...props}
>
@ -373,27 +351,15 @@ export default function Image({ @@ -373,27 +351,15 @@ export default function Image({
)}
</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 && (
<img
ref={imgRef}
src={imageUrl}
alt={finalAlt}
referrerPolicy="no-referrer-when-downgrade"
decoding="async"
loading={loading}
decoding={effectiveHoldUntilClick ? 'async' : 'sync'}
// `lazy` often never starts the request inside nested feed scrollers; always-load should fetch eagerly.
loading="eager"
{...(fetchPriority ? { fetchpriority: fetchPriority } : {})}
draggable={false}
onLoad={handleLoad}
@ -403,8 +369,8 @@ export default function Image({ @@ -403,8 +369,8 @@ export default function Image({
isLoading ? 'opacity-0' : 'opacity-100',
className
)}
width={effectiveDim?.width ?? dim?.width}
height={effectiveDim?.height ?? dim?.height}
width={dim?.width}
height={dim?.height}
/>
)}
{showErrorState && (

9
src/components/ImageGallery/index.tsx

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

5
src/components/ImageWithLightbox/index.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { randomString } from '@/lib/random'
import { cn } from '@/lib/utils'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import modalManager from '@/services/modal-manager.service'
import { TImetaInfo } from '@/types'
import { useEffect, useMemo, useState } from 'react'
@ -28,7 +28,8 @@ export default function ImageWithLightbox({ @@ -28,7 +28,8 @@ export default function ImageWithLightbox({
mustLoad?: boolean
}) {
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 [lightboxPortalActive, setLightboxPortalActive] = useState(false)

7
src/components/KindFilter/index.tsx

@ -168,7 +168,7 @@ export default function KindFilter({ @@ -168,7 +168,7 @@ export default function KindFilter({
size="titlebar-icon"
aria-label={t('Filter')}
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',
feedKindFilterBypass && 'text-amber-600 dark:text-amber-400'
)}
@ -178,9 +178,10 @@ export default function KindFilter({ @@ -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 && (
<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>
)

152
src/components/Library/LibraryPublicationGrid.tsx

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

15
src/components/MediaPlayer/index.tsx

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

14
src/components/MuteButton/index.tsx

@ -9,7 +9,6 @@ import { @@ -9,7 +9,6 @@ import {
} from '@/components/ui/dropdown-menu'
import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useSignGatedControl } from '@/hooks/useSignGatedControl'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { BellOff } from 'lucide-react'
@ -21,13 +20,12 @@ export default function MuteButton({ pubkey }: { pubkey: string }) { @@ -21,13 +20,12 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { pubkey: accountPubkey, checkLogin } = useNostr()
const { signControlProps, canManageIdentity } = useSignGatedControl()
const { mutePubkeySet, changing, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } =
useMuteList()
const [updating, setUpdating] = useState(false)
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) => {
e.stopPropagation()
@ -71,7 +69,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) { @@ -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"
variant="secondary"
onClick={handleUnmute}
{...signControlProps({ disabled: updating || changing })}
disabled={updating || changing}
>
{updating ? (
<Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden />
@ -86,7 +84,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) { @@ -86,7 +84,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
<Button
variant="destructive"
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')}
</Button>
@ -102,7 +100,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) { @@ -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"
variant="ghost"
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')}
</Button>
@ -110,7 +108,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) { @@ -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"
variant="ghost"
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')}
</Button>
@ -126,7 +124,6 @@ export default function MuteButton({ pubkey }: { pubkey: string }) { @@ -126,7 +124,6 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
<DropdownMenuContent>
<DropdownMenuItem
onClick={(e) => handleMute(e, true)}
disabled={signControlProps().disabled}
className="text-destructive focus:text-destructive"
>
<BellOff />
@ -134,7 +131,6 @@ export default function MuteButton({ pubkey }: { pubkey: string }) { @@ -134,7 +131,6 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => handleMute(e, false)}
disabled={signControlProps().disabled}
className="text-destructive focus:text-destructive"
>
<BellOff />

51
src/components/Nip07ExtensionKeyMismatchToast/index.tsx

@ -0,0 +1,51 @@ @@ -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 @@ @@ -1,11 +1,15 @@
import storage from '@/services/local-storage.service'
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 { HOME_GALLERY_TAB_KINDS, FAST_READ_RELAY_URLS } from '@/constants'
import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import type { TPrimaryPageName } from '@/PageManager'
import { TFeedSubRequest, TNoteListMode } from '@/types'
import { cn } from '@/lib/utils'
import { normalizeAnyRelayUrl } from '@/lib/url'
import type { Event } from 'nostr-tools'
import {
forwardRef,
@ -16,6 +20,32 @@ import { @@ -16,6 +20,32 @@ import {
useState,
type ReactNode
} 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, {
subRequests: TFeedSubRequest[]
@ -23,9 +53,9 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -23,9 +53,9 @@ const NormalFeed = forwardRef<TNoteListRef, {
/** When false, NoteList waits before opening timeline REQs (relay algo probe). */
relayCapabilityReady?: 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
/** 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
/**
* When true with {@link mergeTimelineWhenSubRequestFiltersMatch}, relay URL list can change (e.g. favorites
@ -33,8 +63,15 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -33,8 +63,15 @@ const NormalFeed = forwardRef<TNoteListRef, {
*/
preserveTimelineOnSubRequestsChange?: 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[]
/**
* 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}. */
followingFeedDeltaSubRequests?: TFeedSubRequest[]
/** Stable subscription identity; see {@link NoteList} `feedSubscriptionKey`. */
@ -76,6 +113,8 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -76,6 +113,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
oneShotAfterMergeComparator?: (a: Event, b: Event) => number
extraShouldHideEvent?: (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). */
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). */
@ -83,12 +122,12 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -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). */
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
/** Home favorites: favorites + trending relays for stats / "Seen on". */
/** Home favorites Notes tab: favorites + trending relays for stats / “Seen on”. */
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[]
}>(function NormalFeed(
{
@ -101,6 +140,8 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -101,6 +140,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
preserveTimelineOnSubRequestsChange = false,
mergeTimelineWhenSubRequestFiltersMatch = false,
repliesSubRequests,
mainFeedGalleryRelayUrls,
widenMainGalleryRelays = true,
followingFeedDeltaSubRequests,
feedSubscriptionKey,
feedTimelineScopeKey,
@ -121,6 +162,7 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -121,6 +162,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
oneShotAfterMergeComparator,
extraShouldHideEvent,
extraShouldHideRepliesEvent,
extraShouldHideGalleryEvent,
oneShotMergedCap,
timelinePublicReadFallback = false,
alexandriaEmptyUrl = null,
@ -130,8 +172,23 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -130,8 +172,23 @@ const NormalFeed = forwardRef<TNoteListRef, {
},
ref
) {
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111, feedKindFilterBypass } =
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 noteListRef = ref || internalNoteListRef
const [feedFilterTabRowHost, setFeedFilterTabRowHost] = useState<HTMLDivElement | null>(null)
@ -139,7 +196,9 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -139,7 +196,9 @@ const NormalFeed = forwardRef<TNoteListRef, {
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(
() =>
subRequests.length > 0 &&
@ -149,27 +208,70 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -149,27 +208,70 @@ const NormalFeed = forwardRef<TNoteListRef, {
[subRequests]
)
/** Replies feed by default; Wisp trending stays notes-only. Kind filter can hide replies client-side. */
const listMode: TNoteListMode = isWispTrendingOnlyFeed ? 'posts' : 'postsAndReplies'
useEffect(() => {
if (!isMainFeed) return
if (storage.getNoteListMode() === listMode) return
storage.setNoteListMode(listMode)
window.dispatchEvent(new CustomEvent('noteListModeChanged'))
}, [isMainFeed, listMode])
if (!isWispTrendingOnlyFeed) return
setListMode((m) => (m === 'posts' ? m : 'posts'))
}, [isWispTrendingOnlyFeed])
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(() => {
if (listMode === 'postsAndReplies' && repliesSubRequests) {
return repliesSubRequests
}
return subRequests
}, [listMode, subRequests, repliesSubRequests])
if (listMode !== 'media') return subRequests
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(() => {
if (listMode === 'postsAndReplies') return extraShouldHideRepliesEvent
if (listMode === 'media' && extraShouldHideGalleryEvent) return extraShouldHideGalleryEvent
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[]) => {
if (noteListRef && typeof noteListRef !== 'function') {
@ -196,40 +298,75 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -196,40 +298,75 @@ const NormalFeed = forwardRef<TNoteListRef, {
/** Include kind picker deps for single-relay chips (kindless REQ + client-side kinds). */
const subHeaderFilterDepsKey = `${allowKindlessRelayExplore ? 'kle' : 'std'}|${showKindsKey}|${feedKindFilterBypass}|${showAllKindsProp ? 'allProp' : 'k'}`
const renderFilterToolbarInFeed = !(isMainFeed && setSubHeader)
const renderTabsInFeed = !(isMainFeed && setSubHeader) && !allowKindlessRelayExplore
const filterToolbarRow = useMemo(
() => (
<FeedFilterToolbarRow
showKinds={showKinds}
onShowKindsChange={handleShowKindsChange}
onRefresh={onSubHeaderRefresh}
feedFilterTabRowSlotRef={onFeedFilterTabRowSlotRef}
includeFeedSearchSlot={showFeedClientFilter}
const mergeFilterWithTabsRow =
showFeedClientFilter && ((isMainFeed && !!setSubHeader) || renderTabsInFeed)
/** Notes / Replies / Gallery switch, plus refresh + kind filter — on Wisp trending only the tool row (no mode tabs). */
const tabsElement = useMemo(() => {
const kindRowOptions = (
<div className="flex items-center gap-0">
{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
* (maximum update depth) when navigating onto the home feed after other primaries (e.g. notifications).
* Intentionally omit `filterToolbarRow` from deps covered by `subHeaderFilterDepsKey`.
* Omit `onSubHeaderRefresh` / `onFeedFilterTabRowSlotRef`: only embedded in `filterToolbarRow`; unstable
* Intentionally omit `tabsElement` from deps covered by `listMode` + `subHeaderFilterDepsKey`.
* Omit `onSubHeaderRefresh` / `onFeedFilterTabRowSlotRef`: only embedded in `tabsElement`; unstable
* 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
* NoteList subscriptions / layout churn on the home feed.
*/
useEffect(() => {
if (!isMainFeed || !setSubHeader) return
setSubHeader(<div className={feedFilterRowChromeClass}>{filterToolbarRow}</div>)
if (mergeFilterWithTabsRow) {
setSubHeader(<div className={tabRowChromeClass}>{tabsElement}</div>)
} else {
setSubHeader(tabsElement)
}
}, [
isMainFeed,
setSubHeader,
listMode,
isWispTrendingOnlyFeed,
subHeaderFilterDepsKey,
allowKindlessRelayExplore
allowKindlessRelayExplore,
mergeFilterWithTabsRow
])
useEffect(() => {
@ -239,10 +376,15 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -239,10 +376,15 @@ const NormalFeed = forwardRef<TNoteListRef, {
return (
<>
{renderFilterToolbarInFeed ? (
<div className={cn('sticky top-0 z-20', feedFilterRowChromeClass)}>{filterToolbarRow}</div>
) : null}
<div className={cn('min-w-0', renderFilterToolbarInFeed ? 'pt-0' : 'pt-2')}>
{renderTabsInFeed &&
(mergeFilterWithTabsRow ? (
<div className={cn('sticky top-0 z-20', tabRowChromeClass)}>{tabsElement}</div>
) : (
tabsElement
))}
<div
className={cn('min-w-0', mergeFilterWithTabsRow && renderTabsInFeed ? 'pt-0' : 'pt-2')}
>
<NoteList
ref={noteListRef}
showKinds={showKinds}
@ -263,15 +405,15 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -263,15 +405,15 @@ const NormalFeed = forwardRef<TNoteListRef, {
homeFeedListMode={isMainFeed ? listMode : undefined}
homeFeedSeenOnAllowlistOp={homeFeedSeenOnAllowlistOp}
homeFeedSeenOnAllowlistReplies={homeFeedSeenOnAllowlistReplies}
useFilterAsIs={useFilterAsIs}
clientSideKindFilter={clientSideKindFilter}
allowKindlessRelayExplore={allowKindlessRelayExplore}
showAllKinds={listShowAllKinds}
gridLayout={listMode === 'media'}
revealBatchSize={listMode === 'media' && isMainFeed ? 96 : undefined}
useFilterAsIs={listMode === 'media' ? true : useFilterAsIs}
clientSideKindFilter={listMode === 'media' ? false : clientSideKindFilter}
allowKindlessRelayExplore={listMode === 'media' ? false : allowKindlessRelayExplore}
showAllKinds={listMode === 'media' ? true : listShowAllKinds}
showFeedClientFilter={showFeedClientFilter}
hostPrimaryPageName={hostPrimaryPageName}
feedClientFilterTabRowHost={
showFeedClientFilter ? feedFilterTabRowHost : undefined
}
feedClientFilterTabRowHost={mergeFilterWithTabsRow ? feedFilterTabRowHost : undefined}
onSingleRelayKindlessEmpty={onSingleRelayKindlessEmpty}
onSingleRelayBrowseEmpty={onSingleRelayBrowseEmpty}
feedTopNotice={feedTopNotice}

12
src/components/Note/ArticleCardCoverImage.tsx

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

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

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

10
src/components/Note/CommunityDefinition.tsx

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

10
src/components/Note/GroupMetadata.tsx

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

8
src/components/Note/LiveEvent.tsx

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

5
src/components/Note/LongFormCard.tsx

@ -2,7 +2,7 @@ import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb' @@ -2,7 +2,7 @@ import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link'
import { useSecondaryPageOptional } from '@/PageManager'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { cn } from '@/lib/utils'
import { Event, kinds } from 'nostr-tools'
@ -31,7 +31,8 @@ export default function LongFormCard({ @@ -31,7 +31,8 @@ export default function LongFormCard({
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 bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content])
const summaryText = (metadata.summary?.trim() || bodyBlurb).trim()

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

@ -1,7 +1,6 @@ @@ -1,7 +1,6 @@
import { useSecondaryPageOptional, useSmartHashtagNavigationOptional, useSmartRelayNavigationOptional } from '@/PageManager'
import Image from '@/components/Image'
import UserAvatar from '@/components/UserAvatar'
import { MediaAutoLoadEventProvider } from '@/providers/MediaAutoLoadEventContext'
import MediaPlayer from '@/components/MediaPlayer'
import Wikilink from '@/components/UniversalContent/Wikilink'
import { BookstrContent } from '@/components/Bookstr'
@ -111,13 +110,6 @@ function resolveImetaForMarkdownImageUrl( @@ -111,13 +110,6 @@ function resolveImetaForMarkdownImageUrl(
*/
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}). */
type TInlineEmojiLightbox = {
imageIndexMap: Map<string, number>
@ -3689,9 +3681,6 @@ function parseMarkdownContentMarked( @@ -3689,9 +3681,6 @@ function parseMarkdownContentMarked(
const renderParagraph = (token: any, key: string): React.ReactNode => {
const rawParagraphText = String(token.text ?? token.raw ?? '')
const paragraphText = rawParagraphText.trim()
if (!paragraphText) {
return null
}
const displayMathSplit = splitParagraphByDisplayMath(rawParagraphText)
if (displayMathSplit) {
return (
@ -4563,13 +4552,8 @@ function parseMarkdownContentMarked( @@ -4563,13 +4552,8 @@ function parseMarkdownContentMarked(
}
const inlineNodes = renderInlineTokens(paragraphTokens, `${key}-inline`)
const emojiOnly = isEmojiOnlyParagraphText(paragraphText)
return (
<div
key={`${key}-p`}
role="paragraph"
className={emojiOnly ? 'mb-1 last:mb-0 leading-none' : MD_PARAGRAPH_FLOW_CLASS}
>
<div key={`${key}-p`} role="paragraph" className={MD_PARAGRAPH_FLOW_CLASS}>
{inlineNodes}
</div>
)
@ -4589,10 +4573,7 @@ function parseMarkdownContentMarked( @@ -4589,10 +4573,7 @@ function parseMarkdownContentMarked(
const key = `${keyPrefix}-${i}`
switch (token.type) {
case 'space': {
const next = tokens[i + 1]
const nextIsEmojiOnly =
next?.type === 'paragraph' && isEmojiOnlyParagraphText(String(next.text ?? next.raw ?? ''))
const gapEm = nextIsEmojiOnly ? 0 : spaceTokenExtraGapEm(token)
const gapEm = spaceTokenExtraGapEm(token)
if (gapEm > 0) {
nodes.push(
<div
@ -5446,7 +5427,6 @@ export default function MarkdownArticle({ @@ -5446,7 +5427,6 @@ export default function MarkdownArticle({
event,
className,
hideMetadata = false,
hideTitle = false,
lazyMedia = true,
parentImageUrl,
fullCalendarInvite,
@ -5455,8 +5435,6 @@ export default function MarkdownArticle({ @@ -5455,8 +5435,6 @@ export default function MarkdownArticle({
event: Event
className?: string
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
* until the user opens them in the lightbox. Set to false in full/detail views
@ -6196,7 +6174,6 @@ export default function MarkdownArticle({ @@ -6196,7 +6174,6 @@ export default function MarkdownArticle({
}, [metadata.tags, hashtagsInContent])
return (
<MediaAutoLoadEventProvider event={event}>
<>
<style>{`
/* Padding (not margin) so separation does not collapse with the prior list's margin */
@ -6299,16 +6276,13 @@ export default function MarkdownArticle({ @@ -6299,16 +6276,13 @@ export default function MarkdownArticle({
</div>
)}
{/* Metadata */}
{!hideTitle && !hideMetadata && metadata.title && (
<h1 className="break-words">{metadata.title}</h1>
)}
{!hideMetadata && metadata.title && <h1 className="break-words">{metadata.title}</h1>}
{!hideMetadata && metadata.summary && (
<blockquote>
<p className="break-words">{metadata.summary}</p>
</blockquote>
)}
{!hideTitle &&
!hideMetadata &&
{!hideMetadata &&
event.kind === kinds.LongFormArticle &&
!metadata.image?.trim() && (
<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({ @@ -6320,15 +6294,13 @@ export default function MarkdownArticle({
/>
</div>
)}
{!hideTitle &&
hideMetadata &&
{hideMetadata &&
metadata.title &&
event.kind !== ExtendedKind.DISCUSSION &&
!isNip52CalendarCardKind(event.kind) && (
<h2 className="text-2xl font-bold mb-4 leading-tight break-words">{metadata.title}</h2>
)}
{!hideTitle &&
hideMetadata &&
{hideMetadata &&
metadata.title &&
event.kind === kinds.LongFormArticle &&
!metadata.image?.trim() &&
@ -6572,6 +6544,5 @@ export default function MarkdownArticle({ @@ -6572,6 +6544,5 @@ export default function MarkdownArticle({
document.body
)}
</>
</MediaAutoLoadEventProvider>
)
}

49
src/components/Note/MusicTrackNote.tsx

@ -7,32 +7,12 @@ import { @@ -7,32 +7,12 @@ import {
} from '@/lib/music-track'
import { primalR2aMirrorForBlossomPrimalUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import MarkdownArticle from './MarkdownArticle/MarkdownArticle'
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({
event,
className,
@ -42,7 +22,8 @@ export default function MusicTrackNote({ @@ -42,7 +22,8 @@ export default function MusicTrackNote({
className?: string
loadMedia?: boolean
}) {
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event)
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const mustLoad = loadMedia || autoLoadMedia
const { t } = useTranslation()
@ -56,11 +37,6 @@ export default function MusicTrackNote({ @@ -56,11 +37,6 @@ export default function MusicTrackNote({
() => (track ? primalR2aMirrorForBlossomPrimalUrl(track.audioUrl) ?? undefined : undefined),
[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) {
return (
@ -106,25 +82,12 @@ export default function MusicTrackNote({ @@ -106,25 +82,12 @@ export default function MusicTrackNote({
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{t('Music video', { defaultValue: 'Music video' })}
</p>
<MediaPlayer
src={track.videoUrl}
className="w-full max-w-none"
mustLoad={mustLoad}
authorPubkey={event.pubkey}
/>
<MediaPlayer src={track.videoUrl} className="w-full max-w-none" mustLoad={mustLoad} />
</div>
) : null}
</div>
{captionEvent ? (
<div className="mt-2 min-w-0 text-sm text-muted-foreground">
<MarkdownArticle
event={captionEvent}
hideMetadata
lazyMedia={!mustLoad}
parentImageUrl={track.imageUrl}
className="prose-sm prose-headings:text-muted-foreground prose-p:text-muted-foreground"
/>
</div>
{caption ? (
<p className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">{caption}</p>
) : null}
</div>
)

16
src/components/Note/NsfwNote.tsx

@ -1,25 +1,13 @@ @@ -1,25 +1,13 @@
import { Button } from '@/components/ui/button'
import { DEFAULT_CONTENT_WARNING_LABEL } from '@/lib/content-warning'
import { Eye } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function NsfwNote({
show,
label
}: {
show: () => void
label?: string | null
}) {
export default function NsfwNote({ show }: { show: () => void }) {
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 (
<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
onClick={(e) => {
e.stopPropagation()

54
src/components/Note/PublicationBooklistButton.tsx

@ -1,54 +0,0 @@ @@ -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 @@ @@ -1,74 +1,51 @@
import { ExtendedKind } from '@/constants'
import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb'
import { extractBookMetadata } from '@/lib/bookstr-parser'
import {
getLongFormArticleMetadataFromEvent,
getPublicationIndexMetadataFromEvent
} from '@/lib/event-metadata'
import { persistLibraryPublicationForReading } from '@/lib/library-publication-index'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPageOptional, useSmartNoteNavigationOptional } from '@/PageManager'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import Image from '../Image'
import ArticleCardCoverImage from './ArticleCardCoverImage'
import PublicationCoverFallback from './PublicationCoverFallback'
import PublicationCoverImage from './PublicationCoverImage'
import PublicationIndexMetadata from './PublicationIndexMetadata'
import { extractBookMetadata } from '@/lib/bookstr-parser'
import { ExtendedKind } from '@/constants'
export default function PublicationCard({
event,
className,
disableNavigation = false,
/** Library grid: stacked cover on top, compact cover height. */
presentation = 'default'
disableNavigation = false
}: {
event: Event
className?: string
/** When true (e.g. full note view), card is display-only; no navigate-to-note on click. */
disableNavigation?: boolean
presentation?: 'default' | 'library'
}) {
const screenSize = useScreenSizeOptional()
const isSmallScreen = screenSize?.isSmallScreen ?? false
const useStackedLayout = presentation === 'library' || isSmallScreen
const coverSize = presentation === 'library' ? 'library' : 'default'
const { navigateToNote } = useSmartNoteNavigationOptional()
const secondaryPage = useSecondaryPageOptional()
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 indexMetadata = useMemo(
() => (event.kind === ExtendedKind.PUBLICATION ? getPublicationIndexMetadataFromEvent(event) : null),
[event]
)
const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content])
const summaryText = (metadata.summary?.trim() || bodyBlurb).trim()
const bookMetadata = useMemo(() => extractBookMetadata(event), [event])
// Kind 30040 is always a publication index (NKBIP-01). Do not treat `T`/`v` tags as bookstr —
// 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 isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book
const handleCardClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (disableNavigation) return
persistLibraryPublicationForReading(event)
navigateToNote(toNote(event), event)
}
const titleComponent = metadata.title ? (
<div className="min-w-0 text-xl font-semibold break-words sm:line-clamp-2">{metadata.title}</div>
) : null
const titleComponent = metadata.title ? <div className="text-xl font-semibold break-words min-w-0 sm:line-clamp-2">{metadata.title}</div> : null
const formatBookName = (book: string) => {
return book
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
@ -106,70 +83,21 @@ export default function PublicationCard({ @@ -106,70 +83,21 @@ export default function PublicationCard({
</div>
) : null
const cardShellClass = cn(
'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>
)
}
if (isSmallScreen) {
return (
<div className={cn('w-full min-w-0', className)}>
<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}
>
<div className="flex min-w-0 items-start gap-4">
{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 ? (
{metadata.image && autoLoadMedia && (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="mb-3 aspect-video w-full max-w-full"
hideIfError
holdUntilClick={!autoLoadMedia}
/>
) : (
<ArticleCardCoverImage
event={event}
imageUrl={metadata.image}
autoLoadMedia={autoLoadMedia}
layout="stacked"
/>
)}
<div className="min-w-0 space-y-2 overflow-hidden">
@ -187,24 +115,19 @@ export default function PublicationCard({ @@ -187,24 +115,19 @@ export default function PublicationCard({
return (
<div className={cn('w-full min-w-0', className)}>
<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}
>
<div className="flex min-w-0 gap-4">
{metadata.image ? (
{metadata.image && autoLoadMedia && (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
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]"
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">

38
src/components/Note/PublicationCoverFallback.tsx

@ -1,38 +0,0 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -1,19 +1,12 @@
import { buildHighlightDataFromEvent } from '@/lib/build-highlight-data'
import { isMobileBrowserProfile } from '@/lib/client-platform'
import { useCreateHighlight } from './CreateHighlightContext'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools'
import { Highlighter } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 {
let node: Node | null = range.commonAncestorContainer
if (node.nodeType !== Node.ELEMENT_NODE) node = node.parentElement
@ -28,46 +21,6 @@ function getParagraphContextFromRange(range: Range): string { @@ -28,46 +21,6 @@ function getParagraphContextFromRange(range: Range): string {
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({
event,
children
@ -76,259 +29,144 @@ export default function SelectionHighlightTrigger({ @@ -76,259 +29,144 @@ export default function SelectionHighlightTrigger({
children: React.ReactNode
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const openHighlight = useCreateHighlight()
const containerRef = useRef<HTMLDivElement>(null)
const [selectedText, setSelectedText] = useState('')
const [paragraphContext, setParagraphContext] = useState('')
const [toolbarPos, setToolbarPos] = useState<{ top: number; left: number } | null>(null)
const [showMobileDrawer, setShowMobileDrawer] = useState(false)
const [toolbar, setToolbar] = useState<{
selectedText: string
paragraphContext: string
top: number
left: number
} | null>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const touchEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const selectionStableTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isSelectingRef = useRef(false)
const lastSelectionChangeRef = useRef(0)
const clearUi = useCallback(() => {
setSelectedText('')
setParagraphContext('')
setToolbarPos(null)
setShowMobileDrawer(false)
}, [])
const applySelection = useCallback(
(forceShow = false) => {
if (!openHighlight || !containerRef.current) return
const hit = readSelectionInContainer(containerRef.current)
if (!hit) {
clearUi()
return
}
setSelectedText(hit.selectedText)
setParagraphContext(hit.paragraphContext)
if (isSmallScreen) {
if (forceShow || !isSelectingRef.current) {
setShowMobileDrawer(true)
setToolbarPos(null)
}
return
}
// True while a touch is physically in contact with the screen.
const isTouchActiveRef = useRef(false)
const evaluateSelection = useCallback(() => {
if (!openHighlight || !containerRef.current) return
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
setToolbar(null)
return
}
const range = sel.getRangeAt(0)
if (!containerRef.current.contains(range.commonAncestorContainer)) {
setToolbar(null)
return
}
const selectedText = range.toString().trim()
if (!selectedText) {
setToolbar(null)
return
}
const toolbarHeight = 44
const margin = 8
const top =
hit.rect.top - toolbarHeight < margin ? hit.rect.bottom + margin : hit.rect.top - toolbarHeight
const rawLeft = hit.rect.left + hit.rect.width / 2 - 80
const left = Math.max(margin, Math.min(rawLeft, window.innerWidth - 176 - margin))
setToolbarPos({ top, left })
setShowMobileDrawer(false)
},
[clearUi, isSmallScreen, openHighlight]
)
const rect = range.getBoundingClientRect()
const toolbarHeight = 44
const margin = 8
// Prefer above the selection; fall back to below if too close to top of viewport.
const top =
rect.top - toolbarHeight < margin ? rect.bottom + margin : rect.top - toolbarHeight
const rawLeft = rect.left + rect.width / 2 - 80
const left = Math.max(margin, Math.min(rawLeft, window.innerWidth - 176 - margin))
const scheduleDesktopSelection = useCallback(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => applySelection(true), DESKTOP_SELECTION_DELAY_MS)
}, [applySelection])
setToolbar({ selectedText, paragraphContext: getParagraphContextFromRange(range), top, left })
}, [openHighlight])
const scheduleMobileStableSelection = useCallback(() => {
lastSelectionChangeRef.current = Date.now()
if (selectionStableTimeoutRef.current) clearTimeout(selectionStableTimeoutRef.current)
selectionStableTimeoutRef.current = setTimeout(() => {
const elapsed = Date.now() - lastSelectionChangeRef.current
if (elapsed >= MOBILE_SELECTION_STABLE_MS && !isSelectingRef.current) {
applySelection(true)
}
}, MOBILE_SELECTION_STABLE_MS)
}, [applySelection])
// Desktop: mouseup fires reliably after text selection by mouse.
const handleMouseUp = useCallback(() => {
evaluateSelection()
}, [evaluateSelection])
useEffect(() => {
if (!openHighlight) return
const onMouseUp = (e: MouseEvent) => {
if (isSmallScreen) return
const el =
e.target instanceof Element ? e.target : e.target instanceof Node ? e.target.parentElement : null
if (el?.closest('[data-selection-highlight-ui]')) return
scheduleDesktopSelection()
const schedule = (delayMs: number) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(evaluateSelection, delayMs)
}
// Mobile: finger touches screen — mark active so selectionchange is suppressed during
// the gesture itself (avoids positioning the toolbar mid-drag).
const onTouchStart = () => {
if (!isSmallScreen) return
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)
isTouchActiveRef.current = true
}
// 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 = () => {
if (!isSmallScreen) return
if (touchEndTimeoutRef.current) clearTimeout(touchEndTimeoutRef.current)
touchEndTimeoutRef.current = setTimeout(() => {
isSelectingRef.current = false
scheduleMobileStableSelection()
}, MOBILE_TOUCH_END_SETTLE_MS)
isTouchActiveRef.current = false
schedule(isMobileBrowserProfile() ? 280 : 600)
}
// 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 = () => {
if (isSmallScreen) {
lastSelectionChangeRef.current = Date.now()
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()
if (isTouchActiveRef.current) return
schedule(80)
}
// 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) => {
if (!containerRef.current) return
const target = e.target
if (!(target instanceof Node) || !containerRef.current.contains(target)) return
queueMicrotask(() => applySelection(true))
const t = e.target
if (!(t instanceof Node) || !containerRef.current.contains(t)) return
queueMicrotask(() => evaluateSelection())
}
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('touchstart', onTouchStart, { passive: true })
document.addEventListener('touchmove', onTouchMove, { passive: true })
document.addEventListener('touchend', onTouchEnd, { passive: true })
document.addEventListener('selectionchange', onSelectionChange)
document.addEventListener('contextmenu', onContextMenu)
return () => {
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('touchstart', onTouchStart)
document.removeEventListener('touchmove', onTouchMove)
document.removeEventListener('touchend', onTouchEnd)
document.removeEventListener('selectionchange', onSelectionChange)
document.removeEventListener('contextmenu', onContextMenu)
if (debounceRef.current) clearTimeout(debounceRef.current)
if (touchEndTimeoutRef.current) clearTimeout(touchEndTimeoutRef.current)
if (selectionStableTimeoutRef.current) clearTimeout(selectionStableTimeoutRef.current)
}
}, [
applySelection,
clearUi,
isSmallScreen,
openHighlight,
scheduleDesktopSelection,
scheduleMobileStableSelection
])
}, [openHighlight, evaluateSelection])
const handleCreateHighlight = useCallback(() => {
if (!selectedText || !openHighlight) return
const highlightData = buildHighlightDataFromEvent(event, paragraphContext)
openHighlight(highlightData, selectedText)
clearUi()
if (!toolbar || !openHighlight) return
const highlightData = buildHighlightDataFromEvent(event, toolbar.paragraphContext)
openHighlight(highlightData, toolbar.selectedText)
setToolbar(null)
window.getSelection()?.removeAllRanges()
}, [clearUi, event, openHighlight, paragraphContext, selectedText])
}, [event, toolbar, openHighlight])
const handleDismiss = useCallback(() => {
clearUi()
window.getSelection()?.removeAllRanges()
}, [clearUi])
setToolbar(null)
}, [])
if (!openHighlight) return <>{children}</>
const showDesktopToolbar = !isSmallScreen && selectedText && toolbarPos
return (
<div ref={containerRef} className="relative select-text">
<div ref={containerRef} onMouseUp={handleMouseUp} className="relative">
{children}
{showDesktopToolbar ? (
{toolbar && (
<>
<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"
data-selection-highlight-ui
style={{ top: toolbarPos.top, left: toolbarPos.left }}
className="fixed z-[150] flex items-center gap-1 rounded-md border bg-background px-2 py-1.5 shadow-lg"
style={{ top: toolbar.top, left: toolbar.left }}
>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1.5"
onClick={(e) => {
e.stopPropagation()
handleCreateHighlight()
}}
onClick={handleCreateHighlight}
>
<Highlighter className="h-4 w-4" />
{t('Create Highlight')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={(e) => {
e.stopPropagation()
handleDismiss()
}}
>
<Button type="button" variant="ghost" size="sm" className="h-8 px-2" onClick={handleDismiss}>
{t('Cancel')}
</Button>
</div>
<div
className="fixed inset-0 z-[149]"
aria-hidden
data-selection-highlight-ui
onClick={handleDismiss}
/>
<div className="fixed inset-0 z-[149]" aria-hidden 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>
)
}

5
src/components/Note/WikiCard.tsx

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

54
src/components/Note/index.tsx

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

2
src/components/NoteCard/MainNoteCard.tsx

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

5
src/components/NoteDrawer/index.tsx

@ -72,7 +72,7 @@ export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }: @@ -72,7 +72,7 @@ export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }:
<Sheet open={open} onOpenChange={onOpenChange} registerWithModalManager={false}>
<SheetContent
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
onPointerDownOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)}
onInteractOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)}
@ -83,9 +83,8 @@ export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }: @@ -83,9 +83,8 @@ export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }:
style={{ width: MOBILE_SWIPE_BACK_EDGE_PX }}
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
key={displayNoteId}
id={displayNoteId}
index={currentIndex}
hideTitlebar={false}

707
src/components/NoteList/index.tsx

@ -32,14 +32,11 @@ import { isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal @@ -32,14 +32,11 @@ import { isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal
import { eventSeenOnMatchesAllowlist } from '@/lib/relay-allowlist'
import { uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls'
import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { collapseStaleAddressableRevisions } from '@/lib/replaceable-revision'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
import { fetchProfilesMetadataBatch } from '@/lib/profile-metadata-batch'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { useFeedAttestedSuperchatIds } from '@/hooks/useFeedAttestedSuperchatIds'
import { shouldIncludePaymentInFeed } from '@/lib/superchat'
import { scrollActivity } from '@/lib/scroll-activity.service'
import { isTouchDevice } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useDeletedEventSafe } from '@/providers/DeletedEventProvider'
@ -114,10 +111,7 @@ import { @@ -114,10 +111,7 @@ import {
stableFeedKindKey
} from '@/features/feed/descriptor'
import { mapNoteListSubRequestsForTimeline } from '@/features/feed/note-list-requests'
import {
ensureHomeFeedTrendingRelay,
stripNostrLandAggrFromTimelineSubRequests
} from '@/lib/home-feed-relays'
import { stripNostrLandAggrFromTimelineSubRequests } from '@/lib/home-feed-relays'
import { createFetchEventsFeedRuntimeLoader } from '@/features/feed/client-loader'
import { FeedRuntime } from '@/features/feed/runtime'
import { buildFeedDiagnosticsSnapshot, logFeedDiagnostics } from '@/features/feed/diagnostics'
@ -366,10 +360,9 @@ function mergeEventBatchesById( @@ -366,10 +360,9 @@ function mergeEventBatchesById(
for (const e of incoming) {
byId.set(e.id, e)
}
return collapseStaleAddressableRevisions(
Array.from(byId.values())
.sort((a, b) => b.created_at - a.created_at)
).slice(0, cap)
return Array.from(byId.values())
.sort((a, b) => b.created_at - a.created_at)
.slice(0, 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( @@ -820,8 +813,6 @@ const NoteList = forwardRef(
* sits on that row instead of an extra bar above the list. Omitted on spells / standalone NoteList.
*/
feedClientFilterTabRowHost,
/** When set with {@link feedClientFilterTabRowHost}, portaled filter panel renders here (e.g. profile: above pins). */
feedClientFilterPanelHost,
onSingleRelayKindlessEmpty,
onSingleRelayBrowseEmpty,
feedTopNotice,
@ -897,7 +888,6 @@ const NoteList = forwardRef( @@ -897,7 +888,6 @@ const NoteList = forwardRef(
showFeedClientFilter?: boolean
hostPrimaryPageName?: TPrimaryPageName
feedClientFilterTabRowHost?: HTMLElement | null
feedClientFilterPanelHost?: HTMLElement | null
/** Single-relay kindless: if EOSE with no events, parent switches to explicit kinds in `subRequests`. */
onSingleRelayKindlessEmpty?: () => void
/** Relay explore: explicit kinds EOSE empty — parent retries kindless `{ limit }` once. */
@ -1065,19 +1055,12 @@ const NoteList = forwardRef( @@ -1065,19 +1055,12 @@ const NoteList = forwardRef(
// Memoize subRequests serialization to avoid expensive JSON.stringify on every render
const subRequestsKey = useMemo(() => legacyFeedSubscriptionKey(subRequests), [subRequests])
const feedRelayUrls = useMemo(() => {
const urls = uniqueRelayUrlsFromSubRequests(subRequests)
if (feedSubscriptionKey === 'home-all-favorites') {
return ensureHomeFeedTrendingRelay(urls)
}
return urls
}, [subRequestsKey, feedSubscriptionKey])
const feedRelayUrls = useMemo(
() => uniqueRelayUrlsFromSubRequests(subRequests),
[subRequestsKey]
)
const feedAttestedSuperchatIds = useFeedAttestedSuperchatIds(feedRelayUrls)
const feedAttestedSuperchatIdsRef = useRef(feedAttestedSuperchatIds)
useEffect(() => {
feedAttestedSuperchatIdsRef.current = feedAttestedSuperchatIds
}, [feedAttestedSuperchatIds])
const followingFeedDeltaSubRequestsKey = useMemo(
() =>
@ -1126,14 +1109,9 @@ const NoteList = forwardRef( @@ -1126,14 +1109,9 @@ const NoteList = forwardRef(
const primaryPageCtx = usePrimaryPageOptional()
const primaryPageCurrent = primaryPageCtx?.current ?? null
const primaryPanelFrozen = primaryPageCtx?.frozen ?? false
const primaryFeedDisplayed = primaryPageCtx?.display ?? true
/**
* 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.
*/
/** Only pause timelines on the active primary page feed — not secondary-panel profiles, search, etc. */
const pauseTimelineForPrimaryFreeze =
primaryPanelFrozen &&
!primaryFeedDisplayed &&
hostPrimaryPageName != null &&
hostPrimaryPageName === primaryPageCurrent
@ -1397,23 +1375,20 @@ const NoteList = forwardRef( @@ -1397,23 +1375,20 @@ const NoteList = forwardRef(
[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(
(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 (isEventDeleted(evt)) return true
if (hideReplies && isReplyNoteEvent(evt)) return true
@ -1433,7 +1408,7 @@ const NoteList = forwardRef( @@ -1433,7 +1408,7 @@ const NoteList = forwardRef(
if (
!shouldIncludePaymentInFeed(
evt,
feedAttestedSuperchatIdsRef.current,
feedAttestedSuperchatIds,
incomingPaymentRecipientPubkey
)
) {
@ -1464,8 +1439,9 @@ const NoteList = forwardRef( @@ -1464,8 +1439,9 @@ const NoteList = forwardRef(
hideReplies,
hideContentMentioningMutedUsers,
mutePubkeySet,
pinnedEventHexIdSet,
pinnedEventIds,
isEventDeleted,
feedAttestedSuperchatIds,
incomingPaymentRecipientPubkey,
extraShouldHideEvent,
homeFeedActiveSeenOnAllowlist,
@ -1763,6 +1739,15 @@ const NoteList = forwardRef( @@ -1763,6 +1739,15 @@ const NoteList = forwardRef(
clientFilteredVisibleCountRef.current = 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[]) => {
if (need.length === 0) return
const gen = feedProfileBatchGenRef.current
@ -1788,7 +1773,7 @@ const NoteList = forwardRef( @@ -1788,7 +1773,7 @@ const NoteList = forwardRef(
chunks.push(need.slice(i, i + FEED_PROFILE_CHUNK))
}
const settled = await Promise.allSettled(
chunks.map((chunk) => fetchProfilesMetadataBatch(chunk))
chunks.map((chunk) => client.fetchProfilesForPubkeys(chunk))
)
if (gen !== feedProfileBatchGenRef.current) return
@ -1826,6 +1811,51 @@ const NoteList = forwardRef( @@ -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(
() =>
showFeedClientFilter ? applyClientFeedFilter(filteredNewEvents) : filteredNewEvents,
@ -1879,12 +1909,11 @@ const NoteList = forwardRef( @@ -1879,12 +1909,11 @@ const NoteList = forwardRef(
const handle = window.setTimeout(() => {
const candidates = new Set<string>()
const emojiAuthors = new Set<string>()
const profilePrefetchCap = Math.min(120, Math.max(showCount + 64, 64))
for (const e of filteredEvents.slice(0, profilePrefetchCap)) {
for (const e of timelineEventsForFilter) {
collectProfilePrefetchPubkeysFromEvent(e, candidates)
collectReactionAuthorPubkeysForEmojiPrefetch([e], emojiAuthors)
}
for (const e of newEvents.slice(0, 32)) {
for (const e of newEvents) {
collectProfilePrefetchPubkeysFromEvent(e, candidates)
collectReactionAuthorPubkeysForEmojiPrefetch([e], emojiAuthors)
}
@ -1901,7 +1930,7 @@ const NoteList = forwardRef( @@ -1901,7 +1930,7 @@ const NoteList = forwardRef(
}, FEED_PROFILE_BATCH_DEBOUNCE_MS)
return () => window.clearTimeout(handle)
}, [
filteredEvents,
timelineEventsForFilter,
newEvents,
clientFilteredEvents,
showCount,
@ -2246,11 +2275,9 @@ const NoteList = forwardRef( @@ -2246,11 +2275,9 @@ const NoteList = forwardRef(
)
.filter((req) => req.urls.length > 0)
if (mapped.length === 0) return
const diskReq = mapped as Array<{ urls: string[]; filter: TSubRequestFilter }>
const disk = await client.getLocalFeedEvents(diskReq, {
maxRowsScanned: 50_000,
maxMatches: Math.min(FEED_FULL_SEARCH_MERGE_CAP, Math.max(LIMIT, 200))
})
const disk = await client.getTimelineDiskSnapshotEvents(
mapped as Array<{ urls: string[]; filter: TSubRequestFilter }>
)
if (diskPrimeCancelled || timelineEffectStale() || !disk.length) return
const cap = areAlgoRelays ? ALGO_LIMIT : LIMIT
const merged = collapseDuplicateNip18RepostTimelineRows(mergeEventBatchesById([], disk, cap, areAlgoRelays))
@ -2533,20 +2560,6 @@ const NoteList = forwardRef( @@ -2533,20 +2560,6 @@ const NoteList = forwardRef(
? ALGO_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). */
const runProfileTimelineNetworkFetch = (variant: string) => {
if (!profileAuthorWarmSpecForRefresh || !profileMappedForRefresh) return
@ -2606,18 +2619,12 @@ const NoteList = forwardRef( @@ -2606,18 +2619,12 @@ const NoteList = forwardRef(
)
}
const shouldAwaitLocalDiskWarmup =
!oneShotFetch &&
mappedSubRequests.length > 0 &&
!relayAuthoritativeFeedOnlyRef.current
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
* {@link onEvents} so rows appear as soon as local sources resolve.
* Skipped when {@link shouldAwaitLocalDiskWarmup} already painted from disk in `init`.
*/
const startNonBlockingTimelineDiskPrime = () => {
const strictSingleRelayAuthoritative =
@ -2627,7 +2634,7 @@ const NoteList = forwardRef( @@ -2627,7 +2634,7 @@ const NoteList = forwardRef(
(allowKindlessRelayExploreRef.current && useFilterAsIsRef.current))
if (relayAuthoritativeFeedOnlyRef.current && !strictSingleRelayAuthoritative) return
if (oneShotFetch || mappedSubRequests.length === 0) return
if (shouldAwaitLocalDiskWarmup) return
if (isSpellPageLocalWarmup) return
const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>
const strictSingleRelayShard =
mappedSubRequests.length === 1 &&
@ -2757,54 +2764,80 @@ const NoteList = forwardRef( @@ -2757,54 +2764,80 @@ const NoteList = forwardRef(
setLoading(!!oneShotFetch)
} else {
let primedFromDisk = false
let localMergeBase: Event[] = []
const profileMapped = mappedSubRequests as Array<{
urls: string[]
filter: TSubRequestFilter
}>
const profileAuthorWarmSpec = getProfileAuthorWarmupSpec(profileMapped)
if (shouldAwaitLocalDiskWarmup) {
if (isSpellPageLocalWarmup) {
const shardFilters = mappedSubRequests.map(({ filter }) => filter as Filter)
const matchesSpellLocal = (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 sessionHits = client
.getSessionEventsMatchingSearch('', sessionScanCap, kindsForScan)
.filter(matchesSpellLocal)
.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, 'spell_local_session')
let spellLocalMergeBase: Event[] = []
if (isSpellPageLocalWarmup) {
const shardFilters = mappedSubRequests.map(({ filter }) => filter as Filter)
const matchesSpellLocal = (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 sessionHits = client
.getSessionEventsMatchingSearch('', sessionScanCap, kindsForScan)
.filter(matchesSpellLocal)
.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) {
spellLocalMergeBase = mergedS
timelineMergeBootstrapRef.current = mergedS.slice()
setEvents(mergedS)
lastEventsForTimelinePrefetchRef.current = mergedS
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'spell_local_session',
mergedCount: mergedS.length
}
primedFromDisk = true
}
}
}
void (async () => {
try {
const filterAwareDiskReq = mappedSubRequests as Array<{
urls: string[]
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)
if (mentionRecipients.length === 1) {
try {
@ -2812,19 +2845,10 @@ const NoteList = forwardRef( @@ -2812,19 +2845,10 @@ const NoteList = forwardRef(
mentionRecipients[0]!,
localLayerCap
)
const payRows = paymentNotifications.filter(matchesSpellLocal)
if (payRows.length > 0 && !timelineEffectStale()) {
const narrowedPay = narrowLiveBatch(payRows)
if (narrowedPay.length > 0) {
const mergedPay = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(localMergeBase, narrowedPay, eventCapEarly, areAlgoRelays)
)
if (mergedPay.length > 0) {
localMergeBase = mergedPay
primedFromDisk = true
}
}
}
mergeSpellLocalDiskLayer(
paymentNotifications.filter(matchesSpellLocal),
'spell_payment_notifications_idb'
)
} catch {
/* best-effort */
}
@ -2846,89 +2870,108 @@ const NoteList = forwardRef( @@ -2846,89 +2870,108 @@ const NoteList = forwardRef(
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 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
}
}
}
if (!effectActive || timelineEffectStale()) return
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)
}
if (primedFromDisk && localMergeBase.length > 0 && !timelineEffectStale()) {
paintLocalWarmupTimeline(localMergeBase, 'spell_local_disk')
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) 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 {
/* 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
try {
const sessionScanLimit = Math.min(4000, Math.max(eventCapEarly * 4, 800))
const sessionHits = client.eventService.listSessionEventsAuthoredBy(
profileAuthorWarmSpec.author,
{ kinds: profileAuthorWarmSpec.kinds, limit: sessionScanLimit }
)
if (sessionHits.length > 0) {
const narrowedS = narrowLiveBatch(sessionHits as Event[])
if (narrowedS.length > 0) {
const mergedS = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays)
)
if (mergedS.length > 0) {
localMergeBase = mergedS
primedFromDisk = true
paintLocalWarmupTimeline(mergedS, 'profile_local_session')
const sessionScanLimit = Math.min(4000, Math.max(eventCapEarly * 4, 800))
const sessionHits = client.eventService.listSessionEventsAuthoredBy(
profileAuthorWarmSpec.author,
{ kinds: profileAuthorWarmSpec.kinds, limit: sessionScanLimit }
)
if (sessionHits.length > 0) {
const narrowedS = narrowLiveBatch(sessionHits as Event[])
if (narrowedS.length > 0) {
const mergedS = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays)
)
if (mergedS.length > 0) {
timelineMergeBootstrapRef.current = mergedS.slice()
setEvents(mergedS)
lastEventsForTimelinePrefetchRef.current = mergedS
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 }>
const archiveCap = Math.min(2000, Math.max(eventCapEarly, 150))
const [fromArchive, diskSnap, fromLocalFeed] = await Promise.all([
indexedDb.scanEventArchiveByAuthorPubkey(profileAuthorWarmSpec.author, {
kinds: profileAuthorWarmSpec.kinds,
maxRowsScanned: 16_000,
maxMatches: archiveCap
}),
client.getTimelineDiskSnapshotEvents(diskReq),
client.getLocalFeedEvents(diskReq, {
maxRowsScanned: 16_000,
maxMatches: archiveCap
})
])
if (effectActive && !timelineEffectStale()) {
void (async () => {
try {
const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>
const archiveCap = Math.min(2000, Math.max(eventCapEarly, 150))
const [fromArchive, diskSnap, fromLocalFeed] = await Promise.all([
indexedDb.scanEventArchiveByAuthorPubkey(profileAuthorWarmSpec.author, {
kinds: profileAuthorWarmSpec.kinds,
maxRowsScanned: 16_000,
maxMatches: archiveCap
}),
client.getTimelineDiskSnapshotEvents(diskReq),
client.getLocalFeedEvents(diskReq, {
maxRowsScanned: 16_000,
maxMatches: archiveCap
})
])
if (!effectActive || timelineEffectStale()) return
const premerged = mergeEventBatchesById(
[],
[...(fromArchive as Event[]), ...(diskSnap as Event[]), ...(fromLocalFeed as Event[])],
@ -2938,22 +2981,34 @@ const NoteList = forwardRef( @@ -2938,22 +2981,34 @@ const NoteList = forwardRef(
if (premerged.length > 0) {
const narrowed = narrowLiveBatch(premerged)
if (narrowed.length > 0) {
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(localMergeBase, narrowed, eventCapEarly, areAlgoRelays)
)
if (merged.length > 0) {
localMergeBase = merged
primedFromDisk = true
paintLocalWarmupTimeline(merged, 'profile_local_disk')
setEvents((prev) => {
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(prev, narrowed, eventCapEarly, areAlgoRelays)
)
if (merged.length > 0) {
timelineMergeBootstrapRef.current = merged.slice()
}
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)
if (relayUrls.length > 0 && effectActive && !timelineEffectStale()) {
void client
.fetchEvents(
const relayUrls = getProfileTimelineFetchRelayUrls(profileMapped)
if (relayUrls.length > 0) {
const fetched = await client.fetchEvents(
relayUrls,
{
authors: [profileAuthorWarmSpec.author],
@ -2968,128 +3023,45 @@ const NoteList = forwardRef( @@ -2968,128 +3023,45 @@ const NoteList = forwardRef(
foreground: true
}
)
.then((fetched) => {
if (!effectActive || timelineEffectStale() || fetched.length === 0) return
if (!effectActive || timelineEffectStale()) return
if (fetched.length > 0) {
const narrowedFetch = narrowLiveBatch(fetched)
if (narrowedFetch.length === 0) return
setEvents((prev) => {
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(prev, narrowedFetch, eventCapEarly, areAlgoRelays)
)
if (merged.length > 0) {
timelineMergeBootstrapRef.current = merged.slice()
if (narrowedFetch.length > 0) {
setEvents((prev) => {
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(prev, narrowedFetch, eventCapEarly, areAlgoRelays)
)
if (merged.length > 0) {
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)
timelineMergeBootstrapRef.current = []
setEvents([])
@ -3992,21 +3964,6 @@ const NoteList = forwardRef( @@ -3992,21 +3964,6 @@ const NoteList = forwardRef(
eventsRef.current = 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(() => {
newEventsRef.current = newEvents
}, [newEvents])
@ -4536,7 +4493,6 @@ const NoteList = forwardRef( @@ -4536,7 +4493,6 @@ const NoteList = forwardRef(
let lastScrollPrefetchInvokeMs = 0
const onScrollFlushNewNotesAtTop = () => {
scrollActivity.markScrolling()
if (oneShotFetchRef.current) return
if (feedFullSearchEventsRef.current !== null) return
const t = scrollPrefetchTarget
@ -4551,7 +4507,6 @@ const NoteList = forwardRef( @@ -4551,7 +4507,6 @@ const NoteList = forwardRef(
}
const onScrollPrefetch = () => {
scrollActivity.markScrolling()
if (scrollPrefetchRafId) return
scrollPrefetchRafId = requestAnimationFrame(() => {
scrollPrefetchRafId = 0
@ -4692,17 +4647,14 @@ const NoteList = forwardRef( @@ -4692,17 +4647,14 @@ const NoteList = forwardRef(
</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 ? (
<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}>
<Label htmlFor="feed-client-search" className="text-sm font-medium">
{t('Search loaded posts')}
@ -4881,32 +4833,17 @@ const NoteList = forwardRef( @@ -4881,32 +4833,17 @@ const NoteList = forwardRef(
) : null
const feedClientFilterChrome = feedClientFilterPanelPortalMode ? (
<div className="flex min-w-0 w-full flex-nowrap items-center gap-1">
{feedRelayToolbarRow}
<div className="shrink-0">{feedClientFilterToggleButton}</div>
</div>
feedClientFilterToggleButton
) : (
<>
<div className="flex min-w-0 flex-nowrap items-center gap-1 px-0.5">
{feedRelayToolbarRow}
<div className="ml-auto shrink-0">{feedClientFilterToggleButton}</div>
</div>
<div className="flex items-center gap-1">{feedClientFilterToggleButton}</div>
{feedClientFilterPanel}
</>
)
const feedClientFilterPanelPortaled =
feedClientFilterPanelPortalMode &&
feedClientFilterPanelHost &&
feedClientFilterPanel
? createPortal(feedClientFilterPanel, feedClientFilterPanelHost)
: null
/** Tab-row portal: toggle in header; panel in {@link feedClientFilterPanelHost} or above the list. */
/** Tab-row portal: toggle lives in the header; panel expands in-flow above the list. */
const feedClientFilterPanelInList =
feedClientFilterPanelPortalMode && !feedClientFilterPanelHost
? feedClientFilterPanel
: null
feedClientFilterPanelPortalMode ? feedClientFilterPanel : null
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">
@ -4927,21 +4864,15 @@ const NoteList = forwardRef( @@ -4927,21 +4864,15 @@ const NoteList = forwardRef(
// 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
// 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 =
!oneShotFetch &&
!feedFullSearchActive &&
subRequests.length > 0 &&
relayCapabilityReady &&
timelineKey != null &&
timelineEventsForFilter.length === 0 &&
(loading ||
!feedTimelineEmptyUiReady ||
(feedSubscribeRelayOutcomes.length === 0 && feedTimelineEmptyUiReady))
feedSubscribeRelayOutcomes.length === 0 &&
feedTimelineEmptyUiReady &&
timelineEventsForFilter.length === 0
const showProgressiveLayersPendingBanner =
Boolean(progressiveWarmupTrimmed) && progressiveLayersSearching && !feedFullSearchActive
const showLookingForMoreEventsBanner =
@ -5013,7 +4944,9 @@ const NoteList = forwardRef( @@ -5013,7 +4944,9 @@ const NoteList = forwardRef(
/>
))
)}
{showFeedInitialLoading ? (
{listSourceEvents.length === 0 &&
!feedFullSearchActive &&
(loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? (
<div
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'}
@ -5021,9 +4954,6 @@ const NoteList = forwardRef( @@ -5021,9 +4954,6 @@ const NoteList = forwardRef(
aria-live="polite"
aria-busy="true"
>
<p className="col-span-full px-2 pb-2 text-center text-sm text-muted-foreground">
{t('Loading feed…')}
</p>
{gridLayout
? Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="aspect-square animate-pulse bg-muted" />
@ -5092,7 +5022,6 @@ const NoteList = forwardRef( @@ -5092,7 +5022,6 @@ const NoteList = forwardRef(
return (
<div ref={feedRootRef} className="relative">
<div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
{feedClientFilterPanelPortaled}
<NoteFeedProfileContext.Provider value={noteFeedProfileContextValue}>
{supportTouch ? (
<PullToRefresh

3
src/components/NoteOptions/EditOrCloneEventDialog.tsx

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

122
src/components/NoteOptions/useMenuActions.tsx

@ -42,10 +42,7 @@ import { @@ -42,10 +42,7 @@ import {
isEventInPinList
} from '@/lib/replaceable-list-latest'
import indexedDb from '@/services/indexed-db.service'
import {
exportPublicationDownload,
isAsciiDoctorServerConfigured
} from '@/lib/publication-export'
import { generateBech32IdFromATag } from '@/lib/tag'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useMuteList } from '@/contexts/mute-list-context'
@ -61,7 +58,6 @@ import { @@ -61,7 +58,6 @@ import {
Bell,
BellOff,
Bookmark,
Download,
Pin,
Settings,
Share2,
@ -167,8 +163,7 @@ export function useMenuActions({ @@ -167,8 +163,7 @@ export function useMenuActions({
account,
relayList,
bookmarkListEvent,
checkLogin,
canManageIdentity
checkLogin
} = useNostr()
const bookmarksContext = useBookmarksOptional()
const { threadFollowed, threadMuted, threadWatch } = useThreadNotificationMenuState(event)
@ -843,22 +838,52 @@ export function useMenuActions({ @@ -843,22 +838,52 @@ export function useMenuActions({
if (!isArticleType) return
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) {
closeDrawer()
await toast.promise(exportPublicationDownload(event, 'adoc', relayUrls), {
loading: t('Exporting publication…'),
success: () => t('Article exported as AsciiDoc'),
error: (err: unknown) =>
t('Failed to export article') +
': ' +
(err instanceof Error ? err.message : String(err))
const contentParts: string[] = []
// Extract all 'a' tag references
const aTags = event.tags.filter(tag => tag[0] === 'a' && tag[1])
// Fetch all referenced events
const fetchPromises = aTags.map(async (tag) => {
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 title = articleMetadata?.title || 'Article'
const content = event.content
const filename = `${title}.adoc`
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 blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
@ -878,19 +903,6 @@ export function useMenuActions({ @@ -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
const handleViewOnAlexandria = () => {
if (!naddr) return
@ -1205,6 +1217,10 @@ export function useMenuActions({ @@ -1205,6 +1217,10 @@ export function useMenuActions({
if (isArticleType) {
const isMarkdownFormat =
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) {
advancedSubMenu.push({
@ -1215,6 +1231,15 @@ export function useMenuActions({ @@ -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) {
advancedSubMenu.push({
label: t('Rebroadcast entire publication'),
@ -1275,7 +1300,7 @@ export function useMenuActions({ @@ -1275,7 +1300,7 @@ export function useMenuActions({
separator: actions.length > 0
})
if (canManageIdentity && pubkey && event.pubkey !== pubkey) {
if (pubkey && event.pubkey !== pubkey) {
if (isMuted) {
actions.push({
icon: Bell,
@ -1315,7 +1340,7 @@ export function useMenuActions({ @@ -1315,7 +1340,7 @@ export function useMenuActions({
const savesGroupStartIndex = actions.length
const savesGroupNeedsSeparator = savesGroupStartIndex > 0
if (canManageIdentity && threadWatch && pubkey) {
if (threadWatch && pubkey) {
actions.push({
icon: Bell,
label: threadFollowed ? t('Unfollow thread notifications') : t('Follow this'),
@ -1376,7 +1401,7 @@ export function useMenuActions({ @@ -1376,7 +1401,7 @@ export function useMenuActions({
})
}
if (canManageIdentity && pubkey && event.pubkey === pubkey) {
if (pubkey && event.pubkey === pubkey) {
actions.push({
icon: Pin,
label: isPinnedInMyList ? t('Unpin note') : t('Pin note'),
@ -1385,7 +1410,7 @@ export function useMenuActions({ @@ -1385,7 +1410,7 @@ export function useMenuActions({
},
separator: actions.length === savesGroupStartIndex && savesGroupNeedsSeparator
})
} else if (canManageIdentity && pubkey && event.pubkey !== pubkey && bookmarksContext) {
} else if (pubkey && event.pubkey !== pubkey && bookmarksContext) {
actions.push({
icon: Bookmark,
label: isBookmarked ? t('Remove bookmark') : t('Bookmark'),
@ -1415,29 +1440,6 @@ export function useMenuActions({ @@ -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)
if (canSignEvents && pubkey && event.pubkey === pubkey) {
actions.push({

41
src/components/NoteStats/LikeButton.tsx

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

19
src/components/NoteStats/ReplyButton.tsx

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

27
src/components/NoteStats/RepostButton.tsx

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

110
src/components/NoteStats/SeenOnButton.tsx

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

3
src/components/NoteStats/index.tsx

@ -115,9 +115,6 @@ export default function NoteStats({ @@ -115,9 +115,6 @@ export default function NoteStats({
useEffect(() => {
if (!fetchIfNotExisting) 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)
noteStatsService
.fetchNoteStats(event, pubkey, statsRelaysRef.current, {

106
src/components/NotificationThreadWatchButtons/index.tsx

@ -0,0 +1,106 @@ @@ -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>
</>
)
}

538
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 @@ @@ -1,27 +1,12 @@
import { MAX_PUBLISH_RELAYS } from '@/constants'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Slider } from '@/components/ui/slider'
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 { cn } from '@/lib/utils'
import storage from '@/services/local-storage.service'
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 Mentions from './Mentions'
import PostRelaySelector from './PostRelaySelector'
@ -33,8 +18,6 @@ export type PostEditorAdvancedPanelProps = { @@ -33,8 +18,6 @@ export type PostEditorAdvancedPanelProps = {
setAddClientTag: Dispatch<SetStateAction<boolean>>
isNsfw: boolean
setIsNsfw: Dispatch<SetStateAction<boolean>>
contentWarningLabel: string
setContentWarningLabel: Dispatch<SetStateAction<string>>
minPow: number
setMinPow: Dispatch<SetStateAction<number>>
/** Relay picker + cap hints (hidden for modes that do not pick relays). */
@ -68,8 +51,6 @@ export default function PostEditorAdvancedPanel({ @@ -68,8 +51,6 @@ export default function PostEditorAdvancedPanel({
setAddClientTag,
isNsfw,
setIsNsfw,
contentWarningLabel,
setContentWarningLabel,
minPow,
setMinPow,
showRelayPicker = false,
@ -95,29 +76,19 @@ export default function PostEditorAdvancedPanel({ @@ -95,29 +76,19 @@ export default function PostEditorAdvancedPanel({
setAddClientTag(storage.getAddClientTag())
}, [setAddClientTag])
if (!show) return null
const onAddClientTagChange = (checked: boolean) => {
storage.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 (
<div className={cn(!show && 'hidden')} aria-hidden={!show}>
<div className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
{show ? (
<div>
<p className="text-sm font-medium">{t('Advanced')}</p>
<p className="text-xs text-muted-foreground mt-0.5">{t('Post editor advanced hint')}</p>
</div>
) : null}
<div className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div>
<p className="text-sm font-medium">{t('Advanced')}</p>
<p className="text-xs text-muted-foreground mt-0.5">{t('Post editor advanced hint')}</p>
</div>
{showMentionsPicker && setMentions ? (
<div className="space-y-2">
@ -171,101 +142,47 @@ export default function PostEditorAdvancedPanel({ @@ -171,101 +142,47 @@ export default function PostEditorAdvancedPanel({
</div>
) : null}
{show ? (
<div className="space-y-4 pt-1 border-t border-border">
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Label htmlFor="add-client-tag" className="text-sm font-normal">
{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 })}
<div className="space-y-4 pt-1 border-t border-border">
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Label htmlFor="add-client-tag" className="text-sm font-normal">
{t('Add client tag')}
</Label>
<Slider
defaultValue={[0]}
value={[minPow]}
onValueChange={([pow]) => setMinPow(pow)}
max={28}
step={1}
<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="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>
) : null}
</div>
</div>
)

62
src/components/PostEditor/PostRelaySelector.tsx

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

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

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

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

@ -9,7 +9,6 @@ import { SimpleUserAvatar } from '../../../UserAvatar' @@ -9,7 +9,6 @@ import { SimpleUserAvatar } from '../../../UserAvatar'
import { SimpleUsername } from '../../../Username'
import type { PickerSearchMode } from '@/services/mention-event-search.service'
import { NEVENT_NADDR_PICKER_ID } from './constants'
import { SUGGESTION_POPUP_Z_INDEX } from '../suggestion-popup'
export type MentionListItem = string | { id: string; mode?: PickerSearchMode }
@ -21,8 +20,6 @@ export interface MentionListProps { @@ -21,8 +20,6 @@ export interface MentionListProps {
onSelectIndex?: (index: number) => void
/** When provided, used to detect if we're inside a dialog (for z-index). */
editor?: Editor
/** True while mention search is in flight (show placeholder instead of hiding the list). */
loading?: boolean
}
export interface MentionListHandle {
@ -32,6 +29,7 @@ export interface MentionListHandle { @@ -32,6 +29,7 @@ export interface MentionListHandle {
const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) => {
const { t } = useTranslation()
const items = props.items ?? []
const inDialog = Boolean(props.editor?.view?.dom?.closest?.('[role="dialog"]'))
const [internalIndex, setInternalIndex] = useState<number>(0)
const isControlled = props.selectedIndex !== undefined
const selectedIndex = isControlled ? props.selectedIndex! : internalIndex
@ -98,32 +96,15 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) @@ -98,32 +96,15 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
}))
if (!items.length) {
if (!props.loading) {
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 null
}
return (
<div
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()}
onTouchMove={(e: React.TouchEvent) => e.stopPropagation()}
>

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

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import * as React from 'react'
import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants'
import { getNoteBech32Id } from '@/lib/event'
import { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview'
@ -20,6 +21,8 @@ import { useCallback, useEffect, useState } from 'react' @@ -20,6 +21,8 @@ import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Skeleton } from '@/components/ui/skeleton'
import { Search } from 'lucide-react'
import type { Editor } from '@tiptap/core'
import { OPEN_NEVENT_PICKER_EVENT, extendMentionRangeToEndOfWord } from './suggestion'
type NeventNaddrPickerDialogProps = {
open: boolean
@ -183,4 +186,69 @@ function NeventNaddrPickerDialog({ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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)

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

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants'
import {
MENTION_NPUB_DROPDOWN_LIMIT,
searchNpubsForMention,
@ -7,9 +8,9 @@ import postEditor from '@/services/post-editor.service' @@ -7,9 +8,9 @@ import postEditor from '@/services/post-editor.service'
import type { Editor } from '@tiptap/core'
import { ReactRenderer } from '@tiptap/react'
import { SuggestionKeyDownProps, type SuggestionProps } from '@tiptap/suggestion'
import tippy, { GetReferenceClientRect, Instance, Props } from 'tippy.js'
import MentionList, { MentionListHandle, MentionListProps, type MentionListItem } from './MentionList'
import { NEVENT_NADDR_PICKER_ID } from './constants'
import { createSuggestionPopup } from '../suggestion-popup'
export type { PickerSearchMode }
@ -18,71 +19,29 @@ const MENTION_CHAR = '@' @@ -18,71 +19,29 @@ const MENTION_CHAR = '@'
export const OPEN_NEVENT_PICKER_EVENT = 'open-nevent-picker'
// Shared state for incremental updates
let currentComponent: ReactRenderer<MentionListHandle, MentionListProps> | undefined
let currentQuery = ''
let backgroundSearchController: AbortController | null = null
let mentionSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null
let mentionSearchGeneration = 0
const MENTION_QUERY_CHAR = /[\w.-]/
/** 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.
*/
/** Extend range.to to include any trailing word chars (handle, NIP-05) so the full @handle is replaced. Exported for nevent picker. */
export function extendMentionRangeToEndOfWord(editor: Editor, range: { from: number; to: number }): number {
const { doc, selection } = editor.state
const scanEnd = Math.min(doc.content.size, range.from + 300)
const prefix = doc.textBetween(range.from, scanEnd, '', '')
let end = range.to
const queryLen = mentionQueryLengthInText(prefix)
if (queryLen > 0) {
end = Math.max(end, range.from + queryLen)
} else {
let pos = range.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)
const { doc } = editor.state
let pos = range.to
while (pos < doc.content.size) {
const $pos = doc.resolve(pos)
const node = $pos.nodeAfter
if (!node || !node.isText) break
const text = node.text ?? ''
const offset = pos - $pos.start()
let i = offset
while (i < text.length && /[\w.-]/.test(text[i]!)) i++
if (i === offset) break
pos += i - offset
}
// 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
})
return pos
}
const suggestion = {
@ -96,18 +55,10 @@ const suggestion = { @@ -96,18 +55,10 @@ const suggestion = {
props: { id: string | null; label?: string | null; mode?: PickerSearchMode }
}) => {
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()
window.dispatchEvent(
new CustomEvent(OPEN_NEVENT_PICKER_EVENT, {
detail: {
editor,
range: { from: insertAt, to: insertAt },
initialMode: props.mode ?? 'nevent'
}
detail: { editor, range, initialMode: props.mode ?? 'nevent' }
})
)
return
@ -126,7 +77,6 @@ const suggestion = { @@ -126,7 +77,6 @@ const suggestion = {
])
.run()
editor.view.dom.ownerDocument.defaultView?.getSelection()?.collapseToEnd()
postEditor.closeSuggestionPopup()
},
items: async ({ query }: { query: string }) => {
@ -136,95 +86,138 @@ const suggestion = { @@ -136,95 +86,138 @@ const suggestion = {
return [{ id: NEVENT_NADDR_PICKER_ID, mode }]
}
if (mentionSearchDebounceTimer) clearTimeout(mentionSearchDebounceTimer)
const generation = ++mentionSearchGeneration
currentQuery = q
const updateComponent = (npubs: string[]) => {
if (generation !== mentionSearchGeneration || currentQuery !== q) return
if (currentComponent) {
currentComponent.updateProps({ items: npubs, loading: false })
}
}
return new Promise<MentionListItem[]>((resolve) => {
mentionSearchDebounceTimer = setTimeout(async () => {
if (generation !== mentionSearchGeneration) return
if (currentComponent) {
currentComponent.updateProps({ items: [], loading: true })
}
if (currentQuery !== q && backgroundSearchController) {
backgroundSearchController.abort()
backgroundSearchController = null
}
currentQuery = q
try {
const results = await searchNpubsForMention(query, MENTION_NPUB_DROPDOWN_LIMIT, updateComponent)
if (generation === mentionSearchGeneration) {
currentComponent?.updateProps({ items: results ?? [], loading: false })
return results ?? []
}
return []
} catch {
if (generation === mentionSearchGeneration) {
currentComponent?.updateProps({ items: [], loading: false })
}
return []
}
const updateComponent = (npubs: string[]) => {
if (currentComponent && currentQuery === q && generation === mentionSearchGeneration) {
currentComponent.updateProps({ items: npubs })
}
}
backgroundSearchController = new AbortController()
try {
const results = await searchNpubsForMention(query, MENTION_NPUB_DROPDOWN_LIMIT, updateComponent)
if (generation === mentionSearchGeneration) resolve(results ?? [])
} catch {
if (generation === mentionSearchGeneration) resolve([])
}
}, SEARCH_QUERY_DEBOUNCE_MS)
})
},
render: () => {
let component: ReactRenderer<MentionListHandle, MentionListProps> | undefined
let popup: ReturnType<typeof createSuggestionPopup> | undefined
let closePopup: (() => void) | undefined
let popup: Instance[] = []
let touchListener: (e: TouchEvent) => void
let closePopup: () => void
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 {
onBeforeStart: () => {
closePopup = exit
postEditor.removeEventListener('closeSuggestionPopup', closePopup)
touchListener = (e: TouchEvent) => {
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)
},
onStart: (props: SuggestionProps<MentionListItem>) => {
exited = false
closePopup = exit
postEditor.removeEventListener('closeSuggestionPopup', closePopup)
postEditor.addEventListener('closeSuggestionPopup', closePopup)
popup = createSuggestionPopup(props.editor)
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component = new ReactRenderer(MentionList, {
props: { ...props, loading: true },
props,
editor: props.editor
})
// Store component reference for incremental updates
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>) {
if (exited) return
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) {
if (props.event.key === 'Escape') {
exit()
popup[0]?.hide()
return true
}
return component?.ref?.onKeyDown(props) ?? false
},
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 @@ @@ -1,5 +1,5 @@
import * as React from 'react'
import { NeventPickerContext } from './nevent-picker-context'
import { NeventPickerContext } from './NeventNaddrPickerDialog'
export function useNeventPicker() {
return React.useContext(NeventPickerContext)

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

@ -11,16 +11,13 @@ import { createFakeEvent } from '@/lib/event' @@ -11,16 +11,13 @@ import { createFakeEvent } from '@/lib/event'
import { randomString } from '@/lib/random'
import { cleanUrl, rewritePlainTextHttpUrls } from '@/lib/url'
import { cn } from '@/lib/utils'
import { mergeContentWarningTagsFromDraftOptions, type TContentWarningDraftOptions } from '@/lib/content-warning'
import { TPollCreateData } from '@/types'
import { kinds, nip19 } from 'nostr-tools'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { useMemo, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import ContentPreview from '../../ContentPreview'
import Content from '../../Content'
import Highlight from '../../Note/Highlight'
import MusicTrackNote from '../../Note/MusicTrackNote'
import MarkdownArticle from '../../Note/MarkdownArticle/MarkdownArticle'
import AsciidocArticle from '../../Note/AsciidocArticle/AsciidocArticle'
import { HighlightData } from '../HighlightEditor'
@ -34,10 +31,8 @@ export default function Preview({ @@ -34,10 +31,8 @@ export default function Preview({
mediaImetaTags,
mediaUrl,
articleMetadata,
musicTrackMetadata,
extraPreviewTags,
addClientTag = true,
contentWarning
addClientTag = true
}: {
content: string
className?: string
@ -55,26 +50,11 @@ export default function Preview({ @@ -55,26 +50,11 @@ export default function Preview({
/** Kind 30817: each number becomes a `k` tag. */
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). */
extraPreviewTags?: string[][]
/** When true (default), preview matches publish: Imwald `client` + attribution `alt` tags and badge. */
addClientTag?: boolean
/** Composer Advanced panel content-warning settings. */
contentWarning?: TContentWarningDraftOptions
}) {
const { t } = useTranslation()
const { content: processedContent, emojiTags, highlightTags, pollTags } = useMemo(
() => {
// Clean tracking parameters from URLs in the preview
@ -188,56 +168,15 @@ export default function Preview({ @@ -188,56 +168,15 @@ export default function Preview({
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) {
tags.push(...extraPreviewTags)
}
if (contentWarning) {
mergeContentWarningTagsFromDraftOptions(tags, contentWarning)
}
const stripped = stripImwaldAttributionTags(tags)
if (addClientTag) {
stripped.push(buildClientTag())
}
return stripped
}, [emojiTags, highlightTags, pollTags, mediaImetaTags, articleMetadata, musicTrackMetadata, kind, extraPreviewTags, addClientTag, contentWarning])
}, [emojiTags, highlightTags, pollTags, mediaImetaTags, articleMetadata, kind, extraPreviewTags, addClientTag])
const fakeEvent = useMemo(() => {
// For voice comments, include the media URL in content if not already there
@ -253,28 +192,6 @@ export default function Preview({ @@ -253,28 +192,6 @@ export default function Preview({
})
}, [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 withClientBadge = (node: ReactNode) =>
addClientTag ? (
@ -288,14 +205,6 @@ export default function Preview({ @@ -288,14 +205,6 @@ export default function Preview({
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
if (kind === ExtendedKind.POLL) {
return withClientBadge(
@ -368,14 +277,6 @@ export default function Preview({ @@ -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(
<Card className={cn('p-3', className, selectableClass)}>
<Content event={fakeEvent} className="h-full" mustLoadMedia />

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

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

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

@ -1,90 +0,0 @@ @@ -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