Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
9e28a1eb5a
  1. 102
      src/components/JsonViewDialog/index.tsx
  2. 34
      src/components/RelayInfo/RelayReviewsPreview.tsx
  3. 5
      src/i18n/locales/de.ts
  4. 5
      src/i18n/locales/en.ts
  5. 37
      src/lib/nostr-relay-auth-patch.ts
  6. 52
      src/lib/relay-auth-feedback.ts
  7. 38
      src/lib/relay-review-feed.ts
  8. 31
      src/pages/secondary/FollowingListPage/index.tsx
  9. 60
      src/pages/secondary/MuteListPage/index.tsx
  10. 63
      src/pages/secondary/OthersRelaySettingsPage/index.tsx
  11. 22
      src/pages/secondary/RelayReviewsPage/index.tsx
  12. 35
      src/pages/secondary/RelaySettingsPage/index.tsx
  13. 4
      src/services/client.service.ts

102
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 (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="h-[60vh] w-[95vw] max-w-[400px] sm:w-[90vw] sm:max-w-[600px] md:w-[85vw] md:max-w-[800px] lg:w-[80vw] lg:max-w-[1000px] xl:w-[75vw] xl:max-w-[1200px] 2xl:w-[70vw] 2xl:max-w-[1400px] flex flex-col overflow-hidden">
<DialogHeader className="shrink-0 pr-8">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<DialogTitle>{title ?? t('View JSON')}</DialogTitle>
<DialogDescription className="sr-only">{t('View JSON')}</DialogDescription>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
title={copied ? t('Copied!') : t('Copy to clipboard')}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setWordWrapEnabled(!wordWrapEnabled)}
title={wordWrapEnabled ? t('Disable word wrap') : t('Enable word wrap')}
>
<WrapText className={`h-4 w-4 ${wordWrapEnabled ? '' : 'opacity-50'}`} />
</Button>
</div>
</div>
</DialogHeader>
<div className="flex-1 min-h-0 min-w-0 overflow-hidden">
<ScrollArea className="h-full w-full">
<div className="w-full min-w-0 max-w-full pr-4">
<pre
className={`text-sm text-muted-foreground select-text min-w-0 ${wordWrapEnabled ? 'whitespace-pre-wrap overflow-x-hidden' : 'whitespace-pre overflow-x-auto'}`}
style={{
wordBreak: wordWrapEnabled ? 'break-all' : 'normal',
overflowWrap: wordWrapEnabled ? 'anywhere' : 'normal',
maxWidth: '100%',
width: '100%',
boxSizing: 'border-box'
}}
>
{text}
</pre>
</div>
<ScrollBar
orientation="horizontal"
className={wordWrapEnabled ? 'opacity-0 pointer-events-none' : ''}
/>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
)
}

34
src/components/RelayInfo/RelayReviewsPreview.tsx

@ -11,6 +11,11 @@ import { FAST_READ_RELAY_URLS, ExtendedKind } from '@/constants'
import { compareEvents } from '@/lib/event' import { compareEvents } from '@/lib/event'
import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata' import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata'
import { toRelayReviews } from '@/lib/link' import { toRelayReviews } from '@/lib/link'
import {
relayReviewDTagsForRelayUrl,
relayReviewEventTargetsRelay,
relayReviewsFeedSnapshotKey
} from '@/lib/relay-review-feed'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { cn, isTouchDevice } from '@/lib/utils' import { cn, isTouchDevice } from '@/lib/utils'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
@ -18,6 +23,7 @@ import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import { queryService } from '@/services/client.service' import { queryService } from '@/services/client.service'
import { getSessionFeedSnapshot } from '@/services/session-feed-snapshot.service'
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures' import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'
import type { NostrEvent } from 'nostr-tools' import type { NostrEvent } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
@ -82,13 +88,37 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string })
setInitialized(false) setInitialized(false)
const normalizedTarget = normalizeUrl(relayUrl) || relayUrl 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<string, NostrEvent>()
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 = [ 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 = { const filter = {
kinds: [ExtendedKind.RELAY_REVIEW], kinds: [ExtendedKind.RELAY_REVIEW],
'#d': [relayUrl], '#d': dTags.length > 0 ? dTags : [relayUrl],
limit: 100 limit: 100
} }

5
src/i18n/locales/de.ts

@ -25,6 +25,11 @@ export default {
'All favorite relays': 'Alle Lieblingsrelais', 'All favorite relays': 'Alle Lieblingsrelais',
'Pinned note': 'Angehefteter Beitrag', 'Pinned note': 'Angehefteter Beitrag',
'Relay settings': 'Relay-Einstellungen', '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', Settings: 'Einstellungen',
'Account menu': 'Kontomenü', 'Account menu': 'Kontomenü',
SidebarRelays: 'Relays', SidebarRelays: 'Relays',

5
src/i18n/locales/en.ts

@ -22,6 +22,11 @@ export default {
'All favorite relays': 'All favorite relays', 'All favorite relays': 'All favorite relays',
'Pinned note': 'Pinned note', 'Pinned note': 'Pinned note',
'Relay settings': 'Relays and Storage Settings', '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', Settings: 'Settings',
'Account menu': 'Account menu', 'Account menu': 'Account menu',
SidebarRelays: 'Relays', SidebarRelays: 'Relays',

37
src/lib/nostr-relay-auth-patch.ts

@ -1,6 +1,7 @@
import logger from '@/lib/logger' 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 type { EventTemplate, VerifiedEvent } from 'nostr-tools'
import { AbstractRelay } from 'nostr-tools/abstract-relay'
type EventPubWaiter = { type EventPubWaiter = {
resolve: (v: unknown) => void resolve: (v: unknown) => void
@ -16,7 +17,7 @@ type RelayInternals = {
authPromise?: Promise<string> authPromise?: Promise<string>
} }
let patched = false const patchedConstructors = new WeakSet<Function>()
function asRelayInternals(relay: AbstractRelay): RelayInternals { function asRelayInternals(relay: AbstractRelay): RelayInternals {
return relay as unknown as 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 * `nostr-tools` main `SimplePool` bundle embeds its own `AbstractRelay` class; it is **not** the same
* queues `signEvent`), and `send()` throwing {@link SendingOnClosedConnection} without a handler. * 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 { export function patchPoolRelayAuthRaceAndFeedback(relay: object): void {
if (patched) return const ctor = (relay as { constructor: Function }).constructor
patched = true if (patchedConstructors.has(ctor)) return
patchedConstructors.add(ctor)
const origSend = AbstractRelay.prototype.send const proto = ctor.prototype as AbstractRelay
const origAuth = AbstractRelay.prototype.auth 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) const r = asRelayInternals(this)
if (!r.connectionPromise && typeof message === 'string' && message.startsWith('["AUTH"')) { if (!r.connectionPromise && typeof message === 'string' && message.startsWith('["AUTH"')) {
abortPendingAuthForDeadSocket(r, message) abortPendingAuthForDeadSocket(r, message)
@ -68,14 +73,19 @@ export function installNostrRelayAuthRaceMitigation(): void {
return origSend.call(this, message) as Promise<void> return origSend.call(this, message) as Promise<void>
} }
AbstractRelay.prototype.auth = function ( proto.auth = function (
this: AbstractRelay, this: AbstractRelay,
signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent> signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>
) { ) {
const r = asRelayInternals(this) const r = asRelayInternals(this)
return (origAuth.call(this, signAuthEvent) as Promise<string>).catch((err: Error) => { const url = r.url
return (origAuth.call(this, signAuthEvent) as Promise<string>)
.then((okReason) => {
notifyRelayNip42Accepted(url, typeof okReason === 'string' ? okReason : undefined)
return okReason
})
.catch((err: Error) => {
const msg = err?.message ?? '' const msg = err?.message ?? ''
/** Hard close while `auth()` is in flight rejects open publish/auth waiters with this reason. */
const benignRace = const benignRace =
err?.name === 'SendingOnClosedConnection' || err?.name === 'SendingOnClosedConnection' ||
msg.includes('relay connection closed before AUTH') || msg.includes('relay connection closed before AUTH') ||
@ -85,6 +95,7 @@ export function installNostrRelayAuthRaceMitigation(): void {
r.authPromise = undefined r.authPromise = undefined
return '' return ''
} }
notifyRelayNip42Rejected(url, msg)
throw err throw err
}) })
} }

52
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<string>()
const nip42NotifiedReject = new Set<string>()
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 })
}

38
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
}

31
src/pages/secondary/FollowingListPage/index.tsx

@ -1,8 +1,17 @@
import JsonViewDialog from '@/components/JsonViewDialog'
import ProfileList from '@/components/ProfileList' import ProfileList from '@/components/ProfileList'
import { RefreshButton } from '@/components/RefreshButton' 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 { useFetchFollowings, useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { Code, MoreVertical } from 'lucide-react'
import { forwardRef, useCallback, useEffect, useState } from 'react' import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -36,9 +45,29 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id?
: t('Following') : t('Following')
} }
hideBackButton={hideTitlebar} hideBackButton={hideTitlebar}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bumpList} />} controls={
hideTitlebar ? undefined : (
<div className="flex items-center gap-0">
<RefreshButton onClick={bumpList} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('More options')}>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setJsonOpen(true)}>
<Code className="size-4 mr-2" />
{t('View JSON')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
displayScrollToTopButton displayScrollToTopButton
> >
<JsonViewDialog value={followJsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} />
<ProfileList pubkeys={followings} /> <ProfileList pubkeys={followings} />
</SecondaryPageLayout> </SecondaryPageLayout>
) )

60
src/pages/secondary/MuteListPage/index.tsx

@ -1,7 +1,14 @@
import JsonViewDialog from '@/components/JsonViewDialog'
import MuteButton from '@/components/MuteButton' import MuteButton from '@/components/MuteButton'
import Nip05 from '@/components/Nip05' import Nip05 from '@/components/Nip05'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username' import Username from '@/components/Username'
@ -9,8 +16,9 @@ import { useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import indexedDb from '@/services/indexed-db.service'
import { useNostr } from '@/providers/NostrProvider' 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 { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import NotFoundPage from '../NotFoundPage' import NotFoundPage from '../NotFoundPage'
@ -18,8 +26,10 @@ import NotFoundPage from '../NotFoundPage'
const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { profile, pubkey } = useNostr() const { profile, pubkey, muteListEvent } = useNostr()
const { getMutePubkeys } = useMuteList() const { getMutePubkeys } = useMuteList()
const [jsonOpen, setJsonOpen] = useState(false)
const [jsonPayload, setJsonPayload] = useState<unknown>(null)
const mutePubkeys = useMemo(() => getMutePubkeys(), [pubkey]) const mutePubkeys = useMemo(() => getMutePubkeys(), [pubkey])
const [visibleMutePubkeys, setVisibleMutePubkeys] = useState<string[]>([]) const [visibleMutePubkeys, setVisibleMutePubkeys] = useState<string[]>([])
const [listRefreshKey, setListRefreshKey] = useState(0) 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 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(() => { useEffect(() => {
if (!hideTitlebar) { if (!hideTitlebar) {
registerPrimaryPanelRefresh(null) registerPrimaryPanelRefresh(null)
@ -78,9 +108,33 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb
index={index} index={index}
title={hideTitlebar ? undefined : t("username's muted", { username: profile.username })} title={hideTitlebar ? undefined : t("username's muted", { username: profile.username })}
hideBackButton={hideTitlebar} hideBackButton={hideTitlebar}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bumpList} />} controls={
hideTitlebar ? undefined : (
<div className="flex items-center gap-0">
<RefreshButton onClick={bumpList} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('More options')}>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => void openMuteListJson()}>
<Code className="size-4 mr-2" />
{t('View JSON')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
displayScrollToTopButton displayScrollToTopButton
> >
<JsonViewDialog
value={jsonPayload}
isOpen={jsonOpen}
onClose={() => setJsonOpen(false)}
/>
<div key={listRefreshKey} className="space-y-2 px-4 pt-2"> <div key={listRefreshKey} className="space-y-2 px-4 pt-2">
{visibleMutePubkeys.map((pubkey, index) => ( {visibleMutePubkeys.map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} /> <UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />

63
src/pages/secondary/OthersRelaySettingsPage/index.tsx

@ -1,8 +1,20 @@
import JsonViewDialog from '@/components/JsonViewDialog'
import OthersRelayList from '@/components/OthersRelayList' import OthersRelayList from '@/components/OthersRelayList'
import { RefreshButton } from '@/components/RefreshButton' 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 SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' 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 { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -10,10 +22,37 @@ const RelaySettingsPage = forwardRef(({ id, index, hideTitlebar = false }: { id?
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { profile } = useFetchProfile(id) const { profile } = useFetchProfile(id)
const { relayList } = useFetchRelayList(profile?.pubkey)
const [listKey, setListKey] = useState(0) const [listKey, setListKey] = useState(0)
const [jsonOpen, setJsonOpen] = useState(false)
const [jsonPayload, setJsonPayload] = useState<unknown>(null)
const bumpList = useCallback(() => setListKey((k) => k + 1), []) 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(() => { useEffect(() => {
if (!hideTitlebar) { if (!hideTitlebar) {
registerPrimaryPanelRefresh(null) registerPrimaryPanelRefresh(null)
@ -33,8 +72,28 @@ const RelaySettingsPage = forwardRef(({ id, index, hideTitlebar = false }: { id?
index={index} index={index}
title={hideTitlebar ? undefined : t("username's used relays", { username: profile.username })} title={hideTitlebar ? undefined : t("username's used relays", { username: profile.username })}
hideBackButton={hideTitlebar} hideBackButton={hideTitlebar}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bumpList} />} controls={
hideTitlebar ? undefined : (
<div className="flex items-center gap-0">
<RefreshButton onClick={bumpList} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('More options')}>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => void openRelayListJson()}>
<Code className="size-4 mr-2" />
{t('View JSON')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
> >
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} />
<div key={listKey} className="px-4 pt-3"> <div key={listKey} className="px-4 pt-3">
<OthersRelayList userId={id} /> <OthersRelayList userId={id} />
</div> </div>

22
src/pages/secondary/RelayReviewsPage/index.tsx

@ -4,6 +4,7 @@ import { RefreshButton } from '@/components/RefreshButton'
import { FAST_READ_RELAY_URLS, ExtendedKind } from '@/constants' import { FAST_READ_RELAY_URLS, ExtendedKind } from '@/constants'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { relayReviewDTagsForRelayUrl, relayReviewsFeedSnapshotKey } from '@/lib/relay-review-feed'
import { normalizeUrl, simplifyUrl } from '@/lib/url' import { normalizeUrl, simplifyUrl } from '@/lib/url'
import type { TFeedSubRequest } from '@/types' import type { TFeedSubRequest } from '@/types'
import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react'
@ -26,23 +27,14 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url
}, [hideTitlebar, registerPrimaryPanelRefresh, bumpFeed]) }, [hideTitlebar, registerPrimaryPanelRefresh, bumpFeed])
const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url]) 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}. */ /** `d` tag values vary by client (raw vs normalized URL); REQ must OR-match every variant. */
const relayReviewDTags = useMemo(() => { const relayReviewDTags = useMemo(
const raw = url?.trim() () => (url ? relayReviewDTagsForRelayUrl(url) : []),
const norm = normalizedUrl?.trim() [url]
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])
/** Stable identity for session feed snapshot (decoupled from FAST_READ_RELAY_URLS JSON churn). */ /** Stable identity for session feed snapshot (decoupled from FAST_READ_RELAY_URLS JSON churn). */
const relayReviewsFeedSubscriptionKey = useMemo( const relayReviewsFeedSubscriptionKey = useMemo(
() => () => (normalizedUrl ? relayReviewsFeedSnapshotKey(normalizedUrl) : ''),
normalizedUrl ? `relay-reviews:v1|${normalizedUrl}|k=${ExtendedKind.RELAY_REVIEW}` : '',
[normalizedUrl] [normalizedUrl]
) )
const reviewsSubRequests = useMemo<TFeedSubRequest[]>(() => { const reviewsSubRequests = useMemo<TFeedSubRequest[]>(() => {

35
src/pages/secondary/RelaySettingsPage/index.tsx

@ -1,11 +1,24 @@
import HttpRelaysSetting from '@/components/HttpRelaysSetting' import HttpRelaysSetting from '@/components/HttpRelaysSetting'
import JsonViewDialog from '@/components/JsonViewDialog'
import MailboxSetting from '@/components/MailboxSetting' import MailboxSetting from '@/components/MailboxSetting'
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting' import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
import SessionRelaysTab from '@/components/SessionRelaysTab' import SessionRelaysTab from '@/components/SessionRelaysTab'
import { RefreshButton } from '@/components/RefreshButton' 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ExtendedKind } from '@/constants'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' 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 { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -47,8 +60,28 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
ref={ref} ref={ref}
index={index} index={index}
title={hideTitlebar ? undefined : t('Relays and Storage Settings')} title={hideTitlebar ? undefined : t('Relays and Storage Settings')}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bump} />} controls={
hideTitlebar ? undefined : (
<div className="flex items-center gap-0">
<RefreshButton onClick={bump} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('More options')}>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => void openRelayListJson()}>
<Code className="size-4 mr-2" />
{t('View JSON')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
> >
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} />
<Tabs key={contentKey} value={tabValue} onValueChange={setTabValue} className="px-4 py-3 space-y-4"> <Tabs key={contentKey} value={tabValue} onValueChange={setTabValue} className="px-4 py-3 space-y-4">
<TabsList className="flex-col sm:flex-row h-auto sm:h-9"> <TabsList className="flex-col sm:flex-row h-auto sm:h-9">
<TabsTrigger value="favorite-relays" className="w-full sm:w-auto">{t('Favorite Relays')}</TabsTrigger> <TabsTrigger value="favorite-relays" className="w-full sm:w-auto">{t('Favorite Relays')}</TabsTrigger>

4
src/services/client.service.ts

@ -32,7 +32,7 @@ function canonicalSeenOnEventId(eventId: string): string {
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger' 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 { queueRelayAuthSign } from '@/lib/relay-auth-sign-queue'
import { import {
authenticateNip42Relay, authenticateNip42Relay,
@ -217,7 +217,6 @@ class ClientService extends EventTarget {
constructor() { constructor() {
super() super()
installNostrRelayAuthRaceMitigation()
this.pool = new SimplePool() this.pool = new SimplePool()
this.pool.trackRelays = true this.pool.trackRelays = true
const rawEnsureRelay = this.pool.ensureRelay.bind(this.pool) const rawEnsureRelay = this.pool.ensureRelay.bind(this.pool)
@ -234,6 +233,7 @@ class ClientService extends EventTarget {
...params, ...params,
connectionTimeout connectionTimeout
}) })
patchPoolRelayAuthRaceAndFeedback(relay)
applyRelayNip42AckTimeout(relay) applyRelayNip42AckTimeout(relay)
return relay return relay
} }

Loading…
Cancel
Save