From 9e28a1eb5ae2a2e4ccd28b744cbb6e87c928ef73 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 30 Mar 2026 12:56:33 +0200 Subject: [PATCH] bug-fixes --- src/components/JsonViewDialog/index.tsx | 102 ++++++++++++++++++ .../RelayInfo/RelayReviewsPreview.tsx | 34 +++++- src/i18n/locales/de.ts | 5 + src/i18n/locales/en.ts | 5 + src/lib/nostr-relay-auth-patch.ts | 61 ++++++----- src/lib/relay-auth-feedback.ts | 52 +++++++++ src/lib/relay-review-feed.ts | 38 +++++++ .../secondary/FollowingListPage/index.tsx | 31 +++++- src/pages/secondary/MuteListPage/index.tsx | 60 ++++++++++- .../OthersRelaySettingsPage/index.tsx | 63 ++++++++++- .../secondary/RelayReviewsPage/index.tsx | 22 ++-- .../secondary/RelaySettingsPage/index.tsx | 35 +++++- src/services/client.service.ts | 4 +- 13 files changed, 461 insertions(+), 51 deletions(-) create mode 100644 src/components/JsonViewDialog/index.tsx create mode 100644 src/lib/relay-auth-feedback.ts create mode 100644 src/lib/relay-review-feed.ts diff --git a/src/components/JsonViewDialog/index.tsx b/src/components/JsonViewDialog/index.tsx new file mode 100644 index 00000000..a69ca5da --- /dev/null +++ b/src/components/JsonViewDialog/index.tsx @@ -0,0 +1,102 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' +import { Button } from '@/components/ui/button' +import { WrapText, Copy, Check } from 'lucide-react' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import logger from '@/lib/logger' + +export default function JsonViewDialog({ + title, + value, + isOpen, + onClose +}: { + title?: string + value: unknown + isOpen: boolean + onClose: () => void +}) { + const { t } = useTranslation() + const [wordWrapEnabled, setWordWrapEnabled] = useState(true) + const [copied, setCopied] = useState(false) + + const text = useMemo(() => { + try { + return JSON.stringify(value, null, 2) + } catch (e) { + return String(e) + } + }, [value]) + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (err) { + logger.error('Failed to copy JSON view', { error: err }) + } + } + + return ( + !open && onClose()}> + + +
+
+ {title ?? t('View JSON')} + {t('View JSON')} +
+
+ + +
+
+
+
+ +
+
+                {text}
+              
+
+ +
+
+
+
+ ) +} diff --git a/src/components/RelayInfo/RelayReviewsPreview.tsx b/src/components/RelayInfo/RelayReviewsPreview.tsx index 736e1bbc..f80d80fd 100644 --- a/src/components/RelayInfo/RelayReviewsPreview.tsx +++ b/src/components/RelayInfo/RelayReviewsPreview.tsx @@ -11,6 +11,11 @@ import { FAST_READ_RELAY_URLS, ExtendedKind } from '@/constants' import { compareEvents } from '@/lib/event' import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata' import { toRelayReviews } from '@/lib/link' +import { + relayReviewDTagsForRelayUrl, + relayReviewEventTargetsRelay, + relayReviewsFeedSnapshotKey +} from '@/lib/relay-review-feed' import { normalizeUrl } from '@/lib/url' import { cn, isTouchDevice } from '@/lib/utils' import { useMuteList } from '@/contexts/mute-list-context' @@ -18,6 +23,7 @@ import { muteSetHas } from '@/lib/mute-set' import { useNostr } from '@/providers/NostrProvider' import { useUserTrust } from '@/contexts/user-trust-context' import { queryService } from '@/services/client.service' +import { getSessionFeedSnapshot } from '@/services/session-feed-snapshot.service' import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures' import type { NostrEvent } from 'nostr-tools' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -82,13 +88,37 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string }) setInitialized(false) const normalizedTarget = normalizeUrl(relayUrl) || relayUrl + const dTags = relayReviewDTagsForRelayUrl(relayUrl) + const snapKey = relayReviewsFeedSnapshotKey(normalizedTarget) + const fromSession = getSessionFeedSnapshot(snapKey) + if (fromSession?.length) { + let seedMy: NostrEvent | null = null + const seedByPubkey = new Map() + for (const evt of fromSession) { + if (evt.kind !== ExtendedKind.RELAY_REVIEW || !relayReviewEventTargetsRelay(evt, relayUrl)) + continue + if (muteSetHas(mutePubkeySet, evt.pubkey)) continue + if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) continue + const st = getStarsFromRelayReviewEvent(evt) + if (!st) continue + if (pubkey && evt.pubkey === pubkey) { + if (!seedMy || evt.created_at > seedMy.created_at) seedMy = evt + } else { + const ex = seedByPubkey.get(evt.pubkey) + if (!ex || evt.created_at > ex.created_at) seedByPubkey.set(evt.pubkey, evt) + } + } + setMyReview(seedMy) + setReviews([...seedByPubkey.values()].sort((a, b) => compareEvents(b, a))) + } + const uniqueUrls = [ - ...new Set([...FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u), normalizedTarget]) + ...new Set([normalizedTarget, ...FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u)]) ] const filter = { kinds: [ExtendedKind.RELAY_REVIEW], - '#d': [relayUrl], + '#d': dTags.length > 0 ? dTags : [relayUrl], limit: 100 } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 374250ef..a4486593 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -25,6 +25,11 @@ export default { 'All favorite relays': 'Alle Lieblingsrelais', 'Pinned note': 'Angehefteter Beitrag', 'Relay settings': 'Relay-Einstellungen', + 'Relay auth accepted (NIP-42)': + 'Das Relay hat die Authentifizierung akzeptiert (NIP-42): {{relay}}{{detailSuffix}}', + 'Relay auth rejected (NIP-42)': + 'Das Relay hat die Authentifizierung abgelehnt (NIP-42): {{relay}} — {{message}}', + 'Relay auth error unknown': 'Unbekannter Fehler', Settings: 'Einstellungen', 'Account menu': 'Kontomenü', SidebarRelays: 'Relays', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index dc181f94..3e83c5a1 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -22,6 +22,11 @@ export default { 'All favorite relays': 'All favorite relays', 'Pinned note': 'Pinned note', 'Relay settings': 'Relays and Storage Settings', + 'Relay auth accepted (NIP-42)': + 'The relay accepted authentication (NIP-42): {{relay}}{{detailSuffix}}', + 'Relay auth rejected (NIP-42)': + 'The relay rejected authentication (NIP-42): {{relay}} — {{message}}', + 'Relay auth error unknown': 'Unknown error', Settings: 'Settings', 'Account menu': 'Account menu', SidebarRelays: 'Relays', diff --git a/src/lib/nostr-relay-auth-patch.ts b/src/lib/nostr-relay-auth-patch.ts index 2ebfd9a6..c53be8ef 100644 --- a/src/lib/nostr-relay-auth-patch.ts +++ b/src/lib/nostr-relay-auth-patch.ts @@ -1,6 +1,7 @@ import logger from '@/lib/logger' +import { notifyRelayNip42Accepted, notifyRelayNip42Rejected } from '@/lib/relay-auth-feedback' +import type { AbstractRelay } from 'nostr-tools/abstract-relay' import type { EventTemplate, VerifiedEvent } from 'nostr-tools' -import { AbstractRelay } from 'nostr-tools/abstract-relay' type EventPubWaiter = { resolve: (v: unknown) => void @@ -16,7 +17,7 @@ type RelayInternals = { authPromise?: Promise } -let patched = false +const patchedConstructors = new WeakSet() function asRelayInternals(relay: AbstractRelay): RelayInternals { return relay as unknown as RelayInternals @@ -46,17 +47,21 @@ function abortPendingAuthForDeadSocket(relay: RelayInternals, message: string) { } /** - * Mitigate races between nostr-tools NIP-42 `AUTH`, WebSocket teardown (e.g. connect timeout while NIP-07 - * queues `signEvent`), and `send()` throwing {@link SendingOnClosedConnection} without a handler. + * `nostr-tools` main `SimplePool` bundle embeds its own `AbstractRelay` class; it is **not** the same + * object as `nostr-tools/abstract-relay`. Patching only the latter never affected pool connections, so + * NIP-42 toast/feedback never ran. Call this once per relay **class** using the first instance from + * `pool.ensureRelay` (same constructor for all pool relays). */ -export function installNostrRelayAuthRaceMitigation(): void { - if (patched) return - patched = true +export function patchPoolRelayAuthRaceAndFeedback(relay: object): void { + const ctor = (relay as { constructor: Function }).constructor + if (patchedConstructors.has(ctor)) return + patchedConstructors.add(ctor) - const origSend = AbstractRelay.prototype.send - const origAuth = AbstractRelay.prototype.auth + const proto = ctor.prototype as AbstractRelay + const origSend = proto.send + const origAuth = proto.auth - AbstractRelay.prototype.send = function (this: AbstractRelay, message: string) { + proto.send = function (this: AbstractRelay, message: string) { const r = asRelayInternals(this) if (!r.connectionPromise && typeof message === 'string' && message.startsWith('["AUTH"')) { abortPendingAuthForDeadSocket(r, message) @@ -68,24 +73,30 @@ export function installNostrRelayAuthRaceMitigation(): void { return origSend.call(this, message) as Promise } - AbstractRelay.prototype.auth = function ( + proto.auth = function ( this: AbstractRelay, signAuthEvent: (evt: EventTemplate) => Promise ) { const r = asRelayInternals(this) - return (origAuth.call(this, signAuthEvent) as Promise).catch((err: Error) => { - const msg = err?.message ?? '' - /** Hard close while `auth()` is in flight rejects open publish/auth waiters with this reason. */ - const benignRace = - err?.name === 'SendingOnClosedConnection' || - msg.includes('relay connection closed before AUTH') || - /relay connection closed/i.test(msg) - if (benignRace) { - logger.warn('[RelayOp] Relay AUTH aborted (benign race)', { url: r.url, detail: msg }) - r.authPromise = undefined - return '' - } - throw err - }) + const url = r.url + return (origAuth.call(this, signAuthEvent) as Promise) + .then((okReason) => { + notifyRelayNip42Accepted(url, typeof okReason === 'string' ? okReason : undefined) + return okReason + }) + .catch((err: Error) => { + const msg = err?.message ?? '' + const benignRace = + err?.name === 'SendingOnClosedConnection' || + msg.includes('relay connection closed before AUTH') || + /relay connection closed/i.test(msg) + if (benignRace) { + logger.warn('[RelayOp] Relay AUTH aborted (benign race)', { url: r.url, detail: msg }) + r.authPromise = undefined + return '' + } + notifyRelayNip42Rejected(url, msg) + throw err + }) } } diff --git a/src/lib/relay-auth-feedback.ts b/src/lib/relay-auth-feedback.ts new file mode 100644 index 00000000..d9e29809 --- /dev/null +++ b/src/lib/relay-auth-feedback.ts @@ -0,0 +1,52 @@ +import i18n from '@/i18n' +import { normalizeUrl, simplifyUrl } from '@/lib/url' +import logger from '@/lib/logger' +import { toast } from 'sonner' + +/** Many subs / resubscribes call `auth()` on the same relay; one success/reject per URL per tab session is enough. */ +const nip42NotifiedAccept = new Set() +const nip42NotifiedReject = new Set() + +function sessionKeyForRelay(url: string): string { + return normalizeUrl(url) || url.trim() +} + +function relayLabel(url: string): string { + const n = normalizeUrl(url) || url + try { + return simplifyUrl(n) + } catch { + return n + } +} + +/** User-visible result after the relay responds to NIP-42 AUTH (`OK` / failure). */ +export function notifyRelayNip42Accepted(url: string, okReason?: string): void { + const relay = relayLabel(url) + const detailSuffix = okReason?.trim() ? ` (${okReason.trim()})` : '' + toast.success( + i18n.t('Relay auth accepted (NIP-42)', { + relay, + detailSuffix, + defaultValue: `The relay accepted authentication (NIP-42): ${relay}${detailSuffix}` + }) + ) + logger.info('[NIP-42] Auth accepted by relay', { url, okReason }) +} + +export function notifyRelayNip42Rejected(url: string, message: string): void { + const key = sessionKeyForRelay(url) + if (!key || nip42NotifiedAccept.has(key) || nip42NotifiedReject.has(key)) return + nip42NotifiedReject.add(key) + + const relay = relayLabel(url) + const msg = message.trim() || i18n.t('Relay auth error unknown', { defaultValue: 'Unknown error' }) + toast.error( + i18n.t('Relay auth rejected (NIP-42)', { + relay, + message: msg, + defaultValue: `The relay rejected authentication (NIP-42): ${relay} — ${msg}` + }) + ) + logger.warn('[NIP-42] Auth rejected by relay', { url, message: msg }) +} diff --git a/src/lib/relay-review-feed.ts b/src/lib/relay-review-feed.ts new file mode 100644 index 00000000..ee34ebdc --- /dev/null +++ b/src/lib/relay-review-feed.ts @@ -0,0 +1,38 @@ +import { ExtendedKind } from '@/constants' +import { normalizeUrl } from '@/lib/url' +import type { Event } from 'nostr-tools' + +/** + * `d` tag values on kind 31987 vary by client (trailing slash, scheme, etc.). REQ `#d` is OR-matched; + * include every variant we care about for the relay being viewed. + */ +export function relayReviewDTagsForRelayUrl(url: string): string[] { + const raw = url?.trim() + if (!raw) return [] + const norm = normalizeUrl(raw) || raw + const uniq: string[] = [] + const add = (s: string | undefined) => { + const t = s?.trim() + if (t && !uniq.includes(t)) uniq.push(t) + } + add(raw) + add(norm) + return uniq +} + +/** Same key as {@link RelayReviewsPage} / NoteList session snapshot. */ +export function relayReviewsFeedSnapshotKey(normalizedRelayUrl: string): string { + return `relay-reviews:v1|${normalizedRelayUrl}|k=${ExtendedKind.RELAY_REVIEW}` +} + +/** Whether a cached or live event is a review for this relay (handles `d` vs URL normalization drift). */ +export function relayReviewEventTargetsRelay(event: Event, relayUrl: string): boolean { + if (event.kind !== ExtendedKind.RELAY_REVIEW) return false + const d = event.tags.find((t) => t[0] === 'd')?.[1]?.trim() + if (!d) return false + const candidates = relayReviewDTagsForRelayUrl(relayUrl) + if (candidates.includes(d)) return true + const dNorm = normalizeUrl(d) || d + const targetNorm = normalizeUrl(relayUrl) || relayUrl + return dNorm === targetNorm +} diff --git a/src/pages/secondary/FollowingListPage/index.tsx b/src/pages/secondary/FollowingListPage/index.tsx index 7e3f658d..fdd0095b 100644 --- a/src/pages/secondary/FollowingListPage/index.tsx +++ b/src/pages/secondary/FollowingListPage/index.tsx @@ -1,8 +1,17 @@ +import JsonViewDialog from '@/components/JsonViewDialog' import ProfileList from '@/components/ProfileList' import { RefreshButton } from '@/components/RefreshButton' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' import { useFetchFollowings, useFetchProfile } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' +import { Code, MoreVertical } from 'lucide-react' import { forwardRef, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -36,9 +45,29 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id? : t('Following') } hideBackButton={hideTitlebar} - controls={hideTitlebar ? undefined : } + controls={ + hideTitlebar ? undefined : ( +
+ + + + + + + setJsonOpen(true)}> + + {t('View JSON')} + + + +
+ ) + } displayScrollToTopButton > + setJsonOpen(false)} /> ) diff --git a/src/pages/secondary/MuteListPage/index.tsx b/src/pages/secondary/MuteListPage/index.tsx index 400bca8a..8d29b835 100644 --- a/src/pages/secondary/MuteListPage/index.tsx +++ b/src/pages/secondary/MuteListPage/index.tsx @@ -1,7 +1,14 @@ +import JsonViewDialog from '@/components/JsonViewDialog' import MuteButton from '@/components/MuteButton' import Nip05 from '@/components/Nip05' import { RefreshButton } from '@/components/RefreshButton' import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' import { Skeleton } from '@/components/ui/skeleton' import UserAvatar from '@/components/UserAvatar' import Username from '@/components/Username' @@ -9,8 +16,9 @@ import { useFetchProfile } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { useMuteList } from '@/contexts/mute-list-context' +import indexedDb from '@/services/indexed-db.service' import { useNostr } from '@/providers/NostrProvider' -import { Lock, Unlock } from 'lucide-react' +import { Code, Lock, MoreVertical, Unlock } from 'lucide-react' import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import NotFoundPage from '../NotFoundPage' @@ -18,8 +26,10 @@ import NotFoundPage from '../NotFoundPage' const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const { t } = useTranslation() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() - const { profile, pubkey } = useNostr() + const { profile, pubkey, muteListEvent } = useNostr() const { getMutePubkeys } = useMuteList() + const [jsonOpen, setJsonOpen] = useState(false) + const [jsonPayload, setJsonPayload] = useState(null) const mutePubkeys = useMemo(() => getMutePubkeys(), [pubkey]) const [visibleMutePubkeys, setVisibleMutePubkeys] = useState([]) const [listRefreshKey, setListRefreshKey] = useState(0) @@ -27,6 +37,26 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb const bumpList = useCallback(() => setListRefreshKey((k) => k + 1), []) + const openMuteListJson = useCallback(async () => { + const derivedPubkeys = getMutePubkeys() + let indexedDbDecryptedPrivateTags: string[][] | null = null + if (muteListEvent?.id) { + try { + indexedDbDecryptedPrivateTags = await indexedDb.getMuteDecryptedTags(muteListEvent.id) + } catch { + indexedDbDecryptedPrivateTags = null + } + } + setJsonPayload({ + muteListEvent: muteListEvent ?? null, + derivedMutePubkeys: derivedPubkeys, + indexedDbDecryptedPrivateTags, + note: + 'Private mutes live in kind 10000 `content` (NIP-04). Decrypt failures in the console usually mean wrong key, read-only session, or bad/corrupt ciphertext — not necessarily a bad public tag list.' + }) + setJsonOpen(true) + }, [getMutePubkeys, muteListEvent]) + useEffect(() => { if (!hideTitlebar) { registerPrimaryPanelRefresh(null) @@ -78,9 +108,33 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb index={index} title={hideTitlebar ? undefined : t("username's muted", { username: profile.username })} hideBackButton={hideTitlebar} - controls={hideTitlebar ? undefined : } + controls={ + hideTitlebar ? undefined : ( +
+ + + + + + + void openMuteListJson()}> + + {t('View JSON')} + + + +
+ ) + } displayScrollToTopButton > + setJsonOpen(false)} + />
{visibleMutePubkeys.map((pubkey, index) => ( diff --git a/src/pages/secondary/OthersRelaySettingsPage/index.tsx b/src/pages/secondary/OthersRelaySettingsPage/index.tsx index 58ed14d6..38fdb700 100644 --- a/src/pages/secondary/OthersRelaySettingsPage/index.tsx +++ b/src/pages/secondary/OthersRelaySettingsPage/index.tsx @@ -1,8 +1,20 @@ +import JsonViewDialog from '@/components/JsonViewDialog' import OthersRelayList from '@/components/OthersRelayList' import { RefreshButton } from '@/components/RefreshButton' -import { useFetchProfile } from '@/hooks' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { ExtendedKind } from '@/constants' +import { useFetchProfile, useFetchRelayList } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' +import indexedDb from '@/services/indexed-db.service' +import { Code, MoreVertical } from 'lucide-react' +import { kinds } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -10,10 +22,37 @@ const RelaySettingsPage = forwardRef(({ id, index, hideTitlebar = false }: { id? const { t } = useTranslation() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { profile } = useFetchProfile(id) + const { relayList } = useFetchRelayList(profile?.pubkey) const [listKey, setListKey] = useState(0) + const [jsonOpen, setJsonOpen] = useState(false) + const [jsonPayload, setJsonPayload] = useState(null) const bumpList = useCallback(() => setListKey((k) => k + 1), []) + const openRelayListJson = useCallback(async () => { + const pk = profile?.pubkey + if (!pk) { + setJsonPayload({ error: 'No profile pubkey' }) + setJsonOpen(true) + return + } + const [k10002, k10432, k10243] = await Promise.all([ + indexedDb.getReplaceableEvent(pk, kinds.RelayList).catch(() => null), + indexedDb.getReplaceableEvent(pk, ExtendedKind.CACHE_RELAYS).catch(() => null), + indexedDb.getReplaceableEvent(pk, ExtendedKind.HTTP_RELAY_LIST).catch(() => null) + ]) + setJsonPayload({ + pubkey: pk, + mergedRelayList: relayList, + kind10002_mailbox_fromIndexedDb: k10002 ?? null, + kind10432_cacheRelays_fromIndexedDb: k10432 ?? null, + kind10243_httpRelayList_fromIndexedDb: k10243 ?? null, + note: + 'Merged list is from the network/cache service. IndexedDB events appear only if this pubkey’s replaceable lists were stored locally (e.g. after a profile or relay fetch).' + }) + setJsonOpen(true) + }, [profile?.pubkey, relayList]) + useEffect(() => { if (!hideTitlebar) { registerPrimaryPanelRefresh(null) @@ -33,8 +72,28 @@ const RelaySettingsPage = forwardRef(({ id, index, hideTitlebar = false }: { id? index={index} title={hideTitlebar ? undefined : t("username's used relays", { username: profile.username })} hideBackButton={hideTitlebar} - controls={hideTitlebar ? undefined : } + controls={ + hideTitlebar ? undefined : ( +
+ + + + + + + void openRelayListJson()}> + + {t('View JSON')} + + + +
+ ) + } > + setJsonOpen(false)} />
diff --git a/src/pages/secondary/RelayReviewsPage/index.tsx b/src/pages/secondary/RelayReviewsPage/index.tsx index 06722e69..a3e47222 100644 --- a/src/pages/secondary/RelayReviewsPage/index.tsx +++ b/src/pages/secondary/RelayReviewsPage/index.tsx @@ -4,6 +4,7 @@ import { RefreshButton } from '@/components/RefreshButton' import { FAST_READ_RELAY_URLS, ExtendedKind } from '@/constants' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' +import { relayReviewDTagsForRelayUrl, relayReviewsFeedSnapshotKey } from '@/lib/relay-review-feed' import { normalizeUrl, simplifyUrl } from '@/lib/url' import type { TFeedSubRequest } from '@/types' import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react' @@ -26,23 +27,14 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url }, [hideTitlebar, registerPrimaryPanelRefresh, bumpFeed]) const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url]) - /** `d` tag values vary by client (raw vs normalized URL); REQ should OR-match like {@link RelayReviewsPreview}. */ - const relayReviewDTags = useMemo(() => { - const raw = url?.trim() - const norm = normalizedUrl?.trim() - const uniq: string[] = [] - const add = (s: string | undefined) => { - const t = s?.trim() - if (t && !uniq.includes(t)) uniq.push(t) - } - add(raw) - add(norm) - return uniq - }, [url, normalizedUrl]) + /** `d` tag values vary by client (raw vs normalized URL); REQ must OR-match every variant. */ + const relayReviewDTags = useMemo( + () => (url ? relayReviewDTagsForRelayUrl(url) : []), + [url] + ) /** Stable identity for session feed snapshot (decoupled from FAST_READ_RELAY_URLS JSON churn). */ const relayReviewsFeedSubscriptionKey = useMemo( - () => - normalizedUrl ? `relay-reviews:v1|${normalizedUrl}|k=${ExtendedKind.RELAY_REVIEW}` : '', + () => (normalizedUrl ? relayReviewsFeedSnapshotKey(normalizedUrl) : ''), [normalizedUrl] ) const reviewsSubRequests = useMemo(() => { diff --git a/src/pages/secondary/RelaySettingsPage/index.tsx b/src/pages/secondary/RelaySettingsPage/index.tsx index 45a936e7..fdbda04d 100644 --- a/src/pages/secondary/RelaySettingsPage/index.tsx +++ b/src/pages/secondary/RelaySettingsPage/index.tsx @@ -1,11 +1,24 @@ import HttpRelaysSetting from '@/components/HttpRelaysSetting' +import JsonViewDialog from '@/components/JsonViewDialog' import MailboxSetting from '@/components/MailboxSetting' import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting' import SessionRelaysTab from '@/components/SessionRelaysTab' import { RefreshButton } from '@/components/RefreshButton' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { ExtendedKind } from '@/constants' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' +import { useNostr } from '@/providers/NostrProvider' +import indexedDb from '@/services/indexed-db.service' +import { Code, MoreVertical } from 'lucide-react' +import { kinds } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -47,8 +60,28 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: ref={ref} index={index} title={hideTitlebar ? undefined : t('Relays and Storage Settings')} - controls={hideTitlebar ? undefined : } + controls={ + hideTitlebar ? undefined : ( +
+ + + + + + + void openRelayListJson()}> + + {t('View JSON')} + + + +
+ ) + } > + setJsonOpen(false)} /> {t('Favorite Relays')} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 91303f66..f29c8498 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -32,7 +32,7 @@ function canonicalSeenOnEventId(eventId: string): string { import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' -import { installNostrRelayAuthRaceMitigation } from '@/lib/nostr-relay-auth-patch' +import { patchPoolRelayAuthRaceAndFeedback } from '@/lib/nostr-relay-auth-patch' import { queueRelayAuthSign } from '@/lib/relay-auth-sign-queue' import { authenticateNip42Relay, @@ -217,7 +217,6 @@ class ClientService extends EventTarget { constructor() { super() - installNostrRelayAuthRaceMitigation() this.pool = new SimplePool() this.pool.trackRelays = true const rawEnsureRelay = this.pool.ensureRelay.bind(this.pool) @@ -234,6 +233,7 @@ class ClientService extends EventTarget { ...params, connectionTimeout }) + patchPoolRelayAuthRaceAndFeedback(relay) applyRelayNip42AckTimeout(relay) return relay }