Compare commits
No commits in common. '2cadd4b2a934ca17ba0db659f6018750ccee48c6' and 'da4b2cb1db95fe84d548d6d3994c1fbc71a3f885' have entirely different histories.
2cadd4b2a9
...
da4b2cb1db
313 changed files with 5268 additions and 20042 deletions
@ -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. |
|
||||||
``` |
|
||||||
@ -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. |
|
||||||
@ -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. |
|
||||||
@ -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 /> |
|
||||||
</> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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)) |
||||||
|
}} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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) |
|
||||||
}) |
|
||||||
}) |
|
||||||
@ -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) |
|
||||||
@ -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…
Reference in new issue