Compare commits
81 Commits
da4b2cb1db
...
2cadd4b2a9
313 changed files with 20042 additions and 5268 deletions
@ -0,0 +1,165 @@
@@ -0,0 +1,165 @@
|
||||
# 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. |
||||
``` |
||||
@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
|
||||
# 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. |
||||
@ -0,0 +1,182 @@
@@ -0,0 +1,182 @@
|
||||
# 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. |
||||
@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
|
||||
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 /> |
||||
</> |
||||
) |
||||
} |
||||
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -1,34 +0,0 @@
@@ -1,34 +0,0 @@
|
||||
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> |
||||
</> |
||||
) |
||||
} |
||||
@ -1,106 +0,0 @@
@@ -1,106 +0,0 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -1,13 +0,0 @@
@@ -1,13 +0,0 @@
|
||||
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 |
||||
} |
||||
@ -1,135 +0,0 @@
@@ -1,135 +0,0 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -1,77 +0,0 @@
@@ -1,77 +0,0 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -1,239 +0,0 @@
@@ -1,239 +0,0 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -1,146 +0,0 @@
@@ -1,146 +0,0 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -1,94 +0,0 @@
@@ -1,94 +0,0 @@
|
||||
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)) |
||||
}} |
||||
/> |
||||
) |
||||
} |
||||
@ -0,0 +1,152 @@
@@ -0,0 +1,152 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -1,51 +0,0 @@
@@ -1,51 +0,0 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -0,0 +1,301 @@
@@ -0,0 +1,301 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -0,0 +1,208 @@
@@ -0,0 +1,208 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -1,110 +0,0 @@
@@ -1,110 +0,0 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -1,106 +0,0 @@
@@ -1,106 +0,0 @@
|
||||
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> |
||||
</> |
||||
) |
||||
} |
||||
@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
|
||||
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) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
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) |
||||
@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
|
||||
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