Browse Source

bug-fixes

imwald
Silberengel 2 weeks ago
parent
commit
76d3b9ac83
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 8
      src/components/NoteStats/LikeButton.tsx
  4. 2
      src/components/NoteStats/ReplyButton.tsx
  5. 4
      src/components/NoteStats/RepostButton.tsx
  6. 2
      src/components/NoteStats/ZapButton.tsx
  7. 4
      src/components/NoteStats/index.tsx
  8. 6
      src/components/ProfileListBySearch/index.tsx
  9. 6
      src/components/SearchBar/index.tsx
  10. 3
      src/components/SearchResult/index.tsx
  11. 2
      src/constants.ts
  12. 28
      src/lib/relay-list-builder.test.ts
  13. 52
      src/lib/relay-list-builder.ts
  14. 10
      src/lib/relay-nip42-auth.ts
  15. 12
      src/lib/relay-publish-filter.test.ts
  16. 36
      src/lib/relay-publish-filter.ts
  17. 16
      src/lib/relay-url-priority.test.ts
  18. 4
      src/lib/relay-url-priority.ts
  19. 21
      src/lib/url.ts
  20. 35
      src/providers/NostrProvider/index.tsx
  21. 4
      src/services/client-replaceable-events.service.ts
  22. 105
      src/services/client.service.ts
  23. 7
      src/services/nip66.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.17.1", "version": "23.17.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.17.1", "version": "23.17.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.17.1", "version": "23.17.2",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

8
src/components/NoteStats/LikeButton.tsx

@ -244,13 +244,13 @@ export function LikeButtonWithStats({
const likeIconButton = ( const likeIconButton = (
<button <button
type="button" type="button"
className="flex h-full items-center gap-0.5 px-1.5 text-muted-foreground enabled:hover:text-primary" className="flex h-full min-w-0 items-center gap-1.5 px-2 text-muted-foreground enabled:hover:text-primary touch-manipulation"
title={t('Like')} title={t('Like')}
disabled={liking} disabled={liking}
onClick={openReactionPicker} onClick={openReactionPicker}
> >
{liking ? ( {liking ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> <Skeleton className="size-5 shrink-0 rounded-full" aria-hidden />
) : myLastEmoji && !useIconOnlyLikeTrigger ? ( ) : myLastEmoji && !useIconOnlyLikeTrigger ? (
<Emoji emoji={myLastEmoji} classNames={{ img: EMOJI_IMG_INLINE_CLASS }} /> <Emoji emoji={myLastEmoji} classNames={{ img: EMOJI_IMG_INLINE_CLASS }} />
) : ( ) : (
@ -288,7 +288,7 @@ export function LikeButtonWithStats({
> >
<button <button
type="button" type="button"
className="flex h-full shrink-0 items-center px-1.5 sm:px-2 enabled:hover:text-primary" className="flex h-full shrink-0 items-center px-2 sm:px-2.5 enabled:hover:text-primary touch-manipulation"
title={emoji === '+' ? t('Upvote') : t('Downvote')} title={emoji === '+' ? t('Upvote') : t('Downvote')}
disabled={liking} disabled={liking}
onClick={() => { onClick={() => {
@ -296,7 +296,7 @@ export function LikeButtonWithStats({
}} }}
> >
{liking ? ( {liking ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> <Skeleton className="size-5 shrink-0 rounded-full" aria-hidden />
) : ( ) : (
<span className="text-base leading-none" aria-hidden> <span className="text-base leading-none" aria-hidden>
{arrow} {arrow}

2
src/components/NoteStats/ReplyButton.tsx

@ -40,7 +40,7 @@ export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: Re
<> <>
<button <button
className={cn( className={cn(
'flex gap-1 items-center enabled:hover:text-blue-400 px-1.5 h-full', 'flex gap-1.5 items-center enabled:hover:text-blue-400 px-2 h-full min-h-11 touch-manipulation',
hasReplied ? 'text-blue-400' : 'text-muted-foreground' hasReplied ? 'text-blue-400' : 'text-muted-foreground'
)} )}
onClick={(e) => { onClick={(e) => {

4
src/components/NoteStats/RepostButton.tsx

@ -108,7 +108,7 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
<button <button
type="button" type="button"
className={cn( className={cn(
'flex h-full items-center enabled:hover:text-lime-500 px-1.5', 'flex h-full items-center enabled:hover:text-lime-500 px-2 touch-manipulation',
hasReposted ? 'text-lime-500' : 'text-muted-foreground' hasReposted ? 'text-lime-500' : 'text-muted-foreground'
)} )}
title={t('Boost')} title={t('Boost')}
@ -118,7 +118,7 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
} }
}} }}
> >
{reposting ? <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> : <Repeat />} {reposting ? <Skeleton className="size-5 shrink-0 rounded-full" aria-hidden /> : <Repeat />}
</button> </button>
) )

2
src/components/NoteStats/ZapButton.tsx

@ -225,7 +225,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
<button <button
type="button" type="button"
className={cn( className={cn(
'group flex h-full items-center px-1.5', 'group flex h-full items-center px-2 touch-manipulation',
disable ? 'cursor-not-allowed' : 'cursor-pointer' disable ? 'cursor-not-allowed' : 'cursor-pointer'
)} )}
title={zapButtonTitle} title={zapButtonTitle}

4
src/components/NoteStats/index.tsx

@ -184,8 +184,8 @@ export default function NoteStats({
> >
<div <div
className={cn( className={cn(
'flex w-full min-w-0 flex-wrap items-center justify-start gap-x-3 gap-y-1 sm:gap-x-4', 'flex w-full min-w-0 flex-wrap items-center justify-start gap-x-6 gap-y-2 sm:gap-x-5',
'[&_svg]:size-[15px] [&_button]:min-h-9 [&_button]:max-w-full [&_button]:px-1 sm:[&_button]:px-1.5', '[&_svg]:size-5 [&_button]:min-h-11 [&_button]:max-w-full [&_button]:px-3 [&_button]:touch-manipulation sm:[&_button]:min-h-10 sm:[&_button]:px-2',
loading ? 'animate-pulse' : '', loading ? 'animate-pulse' : '',
classNames?.buttonBar classNames?.buttonBar
)} )}

6
src/components/ProfileListBySearch/index.tsx

@ -1,8 +1,8 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { PROFILE_RELAY_URLS } from '@/constants' import { PROFILE_RELAY_URLS } from '@/constants'
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service' import client from '@/services/client.service'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -13,9 +13,7 @@ import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSea
const LIMIT = 50 const LIMIT = 50
const PROFILE_SEARCH_RELAY_URLS = Array.from( const PROFILE_SEARCH_RELAY_URLS = dedupeNormalizeRelayUrlsOrdered(PROFILE_RELAY_URLS)
new Set(PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean))
)
export function ProfileListBySearch({ export function ProfileListBySearch({
search, search,

6
src/components/SearchBar/index.tsx

@ -4,7 +4,7 @@ import { toNote, toNoteList } from '@/lib/link'
import client from '@/services/client.service' import client from '@/services/client.service'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { isKind10243HttpRelayTagUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl } from '@/lib/url' import { isKind10243HttpRelayTagUrl, isWebsocketUrl, looksLikeNostrBech32Identifier, normalizeAnyRelayUrl, normalizeHttpRelayUrl } from '@/lib/url'
import { normalizeToDTag } from '@/lib/search-parser' import { normalizeToDTag } from '@/lib/search-parser'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartNoteNavigation, useSmartHashtagNavigation } from '@/PageManager' import { useSmartNoteNavigation, useSmartHashtagNavigation } from '@/PageManager'
@ -55,8 +55,10 @@ const SearchBar = forwardRef<
if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) { if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) {
return undefined return undefined
} }
const trimmed = input.trim()
if (!trimmed || looksLikeNostrBech32Identifier(trimmed)) return undefined
try { try {
const n = normalizeAnyRelayUrl(input) || normalizeHttpRelayUrl(input) const n = normalizeAnyRelayUrl(trimmed) || normalizeHttpRelayUrl(trimmed)
if (!n || (!isWebsocketUrl(n) && !isKind10243HttpRelayTagUrl(n))) return undefined if (!n || (!isWebsocketUrl(n) && !isKind10243HttpRelayTagUrl(n))) return undefined
return n return n
} catch { } catch {

3
src/components/SearchResult/index.tsx

@ -33,7 +33,8 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
) { ) {
return return
} }
client.interruptBackgroundQueries({ closePooledRelayConnections: true }) /** Yield pool capacity to search REQs without closing in-flight NIP-50 sockets (that zeroed results). */
client.interruptBackgroundQueries()
}, [searchParams?.type, searchParams?.search, searchParams?.input]) }, [searchParams?.type, searchParams?.search, searchParams?.input])
/** NIP-50 / index relays — always queried first on their own shard so dead personal relays cannot zero out search. */ /** NIP-50 / index relays — always queried first on their own shard so dead personal relays cannot zero out search. */

2
src/constants.ts

@ -471,7 +471,7 @@ export const READ_ONLY_RELAY_URLS = [
'wss://filter.nostr.wine', 'wss://filter.nostr.wine',
'wss://primus.nostr1.com', 'wss://primus.nostr1.com',
'wss://feeds.nostrarchives.com', 'wss://feeds.nostrarchives.com',
'wss://feeds.nostrarchives.com/notes/trending/reactions/today' 'wss://spatia-arcana.com'
] ]
/** /**

28
src/lib/relay-list-builder.test.ts

@ -1,5 +1,31 @@
import { PROFILE_RELAY_URLS } from '@/constants'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { buildReplyReadRelayList } from '@/lib/relay-list-builder' import {
buildAccountSessionNetworkHydrateRelayUrls,
buildReplyReadRelayList
} from '@/lib/relay-list-builder'
import { kinds } from 'nostr-tools'
describe('buildAccountSessionNetworkHydrateRelayUrls', () => {
it('uses personal mailbox and profile index relays, not FAST_READ', () => {
const relayListEvent = {
id: 'a',
pubkey: 'b'.repeat(64),
created_at: 1,
kind: kinds.RelayList,
tags: [
['r', 'wss://relay.example.com/', 'read'],
['r', 'wss://relay.example.com/', 'write']
],
content: '',
sig: 'c'.repeat(128)
}
const urls = buildAccountSessionNetworkHydrateRelayUrls({ relayListEvent })
expect(urls).toContain('wss://relay.example.com/')
expect(urls.some((u) => PROFILE_RELAY_URLS.includes(u))).toBe(true)
expect(urls.some((u) => u.includes('theforest.nostr1.com'))).toBe(false)
})
})
describe('buildReplyReadRelayList relayAuthoritative', () => { describe('buildReplyReadRelayList relayAuthoritative', () => {
it('returns only thread hints and author/user layers without favorite bootstrap', async () => { it('returns only thread hints and author/user layers without favorite bootstrap', async () => {

52
src/lib/relay-list-builder.ts

@ -10,6 +10,7 @@
*/ */
import { FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { getHttpRelayListFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { isRelayBlockedByUser } from '@/lib/relay-blocked' import { isRelayBlockedByUser } from '@/lib/relay-blocked'
@ -33,6 +34,57 @@ import type { Event } from 'nostr-tools'
/** Max author NIP-65 read / write URLs merged into comprehensive read lists (shared with viewer first). */ /** Max author NIP-65 read / write URLs merged into comprehensive read lists (shared with viewer first). */
export const AUTHOR_NIP65_RELAY_CAP = 2 export const AUTHOR_NIP65_RELAY_CAP = 2
/**
* Relays for logged-in account session network hydrate (NostrProvider).
* Uses the viewer's cached mailbox / favorites plus {@link PROFILE_RELAY_URLS} not {@link FAST_READ_RELAY_URLS},
* which are blocked under the personal-relay read policy and caused empty/slow startup merges.
*/
export function buildAccountSessionNetworkHydrateRelayUrls(options: {
relayListEvent?: Event | null
cacheRelayListEvent?: Event | null
httpRelayListEvent?: Event | null
favoriteRelaysEvent?: Event | null
blockedRelays?: string[]
cap?: number
}): string[] {
const blocked = options.blockedRelays ?? []
const seen = new Set<string>()
const out: string[] = []
const push = (raw: string | undefined) => {
if (!raw) return
const n = normalizeAnyRelayUrl(raw) || normalizeUrl(raw) || raw.trim()
if (!n) return
const key = relayKey(n)
if (!key || seen.has(key)) return
seen.add(key)
out.push(n)
}
if (options.relayListEvent) {
const rl = getRelayListFromEvent(options.relayListEvent, blocked)
for (const u of [...rl.read, ...rl.write, ...(rl.httpRead ?? []), ...(rl.httpWrite ?? [])]) {
push(u)
}
}
if (options.cacheRelayListEvent) {
const crl = getRelayListFromEvent(options.cacheRelayListEvent)
for (const u of [...crl.read, ...crl.write]) push(u)
}
if (options.httpRelayListEvent) {
const hrl = getHttpRelayListFromEvent(options.httpRelayListEvent, blocked)
for (const u of [...hrl.httpRead, ...hrl.httpWrite]) push(u)
}
if (options.favoriteRelaysEvent) {
for (const [tag, val] of options.favoriteRelaysEvent.tags) {
if (tag === 'relay' && val) push(val)
}
}
for (const u of PROFILE_RELAY_URLS) push(u)
const cap = options.cap ?? 16
return out.slice(0, cap)
}
function relayKey(url: string): string { function relayKey(url: string): string {
return canonicalRelaySessionKey(url) return canonicalRelaySessionKey(url)
} }

10
src/lib/relay-nip42-auth.ts

@ -19,6 +19,16 @@ export function isRelayAuthRequiredErrorMessage(message: string): boolean {
return /auth-required/i.test(message) return /auth-required/i.test(message)
} }
/** Socket dropped between AUTH and EVENT, or nostr-tools {@link SendingOnClosedConnection}. */
export function isRelayConnectionClosedError(err: unknown): boolean {
if (err != null && typeof err === 'object' && 'name' in err) {
const name = String((err as { name: unknown }).name)
if (name === 'SendingOnClosedConnection') return true
}
const msg = err instanceof Error ? err.message : String(err)
return /SendingOnClosedConnection|on a closed connection|relay connection closed|websocket closed/i.test(msg)
}
/** nostr-tools default when {@link Subscription.close} runs from the client. */ /** nostr-tools default when {@link Subscription.close} runs from the client. */
export function isRelaySubscriptionClosedByCaller(reason: string): boolean { export function isRelaySubscriptionClosedByCaller(reason: string): boolean {
return reason.trim() === 'closed by caller' return reason.trim() === 'closed by caller'

12
src/lib/relay-publish-filter.test.ts

@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import { import {
filterContextAuthorReadRelaysForPublish, filterContextAuthorReadRelaysForPublish,
filterRelaysForEventPublish, filterRelaysForEventPublish,
isReadOnlyRelayUrl,
isRelayPublishPolicyRejection, isRelayPublishPolicyRejection,
relayAllowsPublishKind relayAllowsPublishKind
} from './relay-publish-filter' } from './relay-publish-filter'
@ -32,6 +33,17 @@ describe('relay-publish-filter', () => {
expect(out).toEqual(['wss://relay.primal.net/']) expect(out).toEqual(['wss://relay.primal.net/'])
}) })
it('strips filter.nostr.wine broadcast paths (hostname match on READ_ONLY_RELAY_URLS)', () => {
const broadcast =
'wss://filter.nostr.wine/npub13epj452d892app3mjath3uxgs9l03rylzxwkymdp50avukztmfeschauwt?broadcast=true'
expect(isReadOnlyRelayUrl(broadcast)).toBe(true)
const out = filterRelaysForEventPublish(
['wss://relay.damus.io/', broadcast],
kinds.Reaction
)
expect(out).toEqual(['wss://relay.damus.io/'])
})
it('strips profile mirrors from author read hints', () => { it('strips profile mirrors from author read hints', () => {
const out = filterContextAuthorReadRelaysForPublish([ const out = filterContextAuthorReadRelaysForPublish([
'wss://profiles.nostrver.se/', 'wss://profiles.nostrver.se/',

36
src/lib/relay-publish-filter.ts

@ -16,6 +16,20 @@ export const PROFILE_INDEX_ONLY_RELAY_URLS = [
'wss://indexer.coracle.social/' 'wss://indexer.coracle.social/'
] as const ] as const
function relayKey(url: string): string {
return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
}
function relayHostname(url: string): string | null {
const normalized = normalizeAnyRelayUrl(url) || url.trim()
if (!normalized) return null
try {
return new URL(normalized).hostname.toLowerCase()
} catch {
return null
}
}
const profileIndexOnlyKeySet = new Set( const profileIndexOnlyKeySet = new Set(
PROFILE_INDEX_ONLY_RELAY_URLS.map((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase()).filter(Boolean) PROFILE_INDEX_ONLY_RELAY_URLS.map((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase()).filter(Boolean)
) )
@ -24,20 +38,30 @@ const readOnlyKeySet = new Set(
READ_ONLY_RELAY_URLS.map((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase()).filter(Boolean) READ_ONLY_RELAY_URLS.map((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase()).filter(Boolean)
) )
const profileIndexOnlyHostSet = new Set(
PROFILE_INDEX_ONLY_RELAY_URLS.map((u) => relayHostname(u)).filter((h): h is string => !!h)
)
const readOnlyHostSet = new Set(
READ_ONLY_RELAY_URLS.map((u) => relayHostname(u)).filter((h): h is string => !!h)
)
const profileIndexPublishKindSet = new Set<number>(AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS) const profileIndexPublishKindSet = new Set<number>(AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS)
function relayKey(url: string): string { /** True when `url` matches a known entry exactly or shares its hostname (e.g. filter.nostr.wine/npub… paths). */
return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() function relayMatchesHostOrExact(url: string, keySet: ReadonlySet<string>, hostSet: ReadonlySet<string>): boolean {
const key = relayKey(url)
if (key.length > 0 && keySet.has(key)) return true
const host = relayHostname(url)
return host != null && hostSet.has(host)
} }
export function isProfileIndexOnlyRelay(url: string): boolean { export function isProfileIndexOnlyRelay(url: string): boolean {
const key = relayKey(url) return relayMatchesHostOrExact(url, profileIndexOnlyKeySet, profileIndexOnlyHostSet)
return key.length > 0 && profileIndexOnlyKeySet.has(key)
} }
export function isReadOnlyRelayUrl(url: string): boolean { export function isReadOnlyRelayUrl(url: string): boolean {
const key = relayKey(url) return relayMatchesHostOrExact(url, readOnlyKeySet, readOnlyHostSet)
return key.length > 0 && readOnlyKeySet.has(key)
} }
/** True when this relay may receive an EVENT for `eventKind` (profile/list replaceables only on profile mirrors). */ /** True when this relay may receive an EVENT for `eventKind` (profile/list replaceables only on profile mirrors). */

16
src/lib/relay-url-priority.test.ts

@ -8,6 +8,22 @@ import { buildProfilePageReadRelayUrls, getFavoritesFeedRelayUrls } from '@/lib/
import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize' import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
import { syncViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-relay-eligibility' import { syncViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-relay-eligibility'
describe('dedupeNormalizeRelayUrlsOrdered', () => {
it('drops npub, nevent, and other non-relay strings', () => {
const npub = 'npub1uq6dv4yq94704gk5r22jsqg9gy2wpxkk5dft9q5gugc8tj53nq2qg5q22d'
const nevent =
'nevent1qvzqqqqqqypzpcp56e2gqttul23dgx549qqs2sg5uzdddg6jk2pg3c3swh9frxq5qqsx3aamhhkjej4jhn0v7693j6hj08mpp7j87w2mt8vdnjja60t04rgalkuxh'
expect(
dedupeNormalizeRelayUrlsOrdered([
'wss://relay.example.com/',
npub,
nevent,
'not-a-relay'
])
).toEqual(['wss://relay.example.com/'])
})
})
describe('filterContextAuthorReadRelaysForPublish', () => { describe('filterContextAuthorReadRelaysForPublish', () => {
it('drops loopback, LAN, .onion, and profile/index mirrors; keeps public relays', () => { it('drops loopback, LAN, .onion, and profile/index mirrors; keeps public relays', () => {
const out = filterContextAuthorReadRelaysForPublish([ const out = filterContextAuthorReadRelaysForPublish([

4
src/lib/relay-url-priority.ts

@ -7,6 +7,7 @@ import {
import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy'
import { import {
isLocalNetworkUrl, isLocalNetworkUrl,
isValidRelayFetchUrl,
normalizeAnyRelayUrl, normalizeAnyRelayUrl,
normalizeRelayUrlByScheme, normalizeRelayUrlByScheme,
normalizeUrl normalizeUrl
@ -18,7 +19,8 @@ export function dedupeNormalizeRelayUrlsOrdered(urls: readonly string[]): string
const seen = new Set<string>() const seen = new Set<string>()
const out: string[] = [] const out: string[] = []
for (const u of urls) { for (const u of urls) {
const n = normalizeRelayUrlByScheme(u) || u.trim() if (!isValidRelayFetchUrl(u)) continue
const n = normalizeRelayUrlByScheme(u)
if (!n || seen.has(n)) continue if (!n || seen.has(n)) continue
seen.add(n) seen.add(n)
out.push(n) out.push(n)

21
src/lib/url.ts

@ -33,6 +33,25 @@ export function isKind10243HttpRelayTagUrl(url: string): boolean {
return /^https?:\/\/.+/i.test(u) return /^https?:\/\/.+/i.test(u)
} }
/** Bech32 nostr identifiers (npub, nevent, …) — not relay URLs. */
export function looksLikeNostrBech32Identifier(value: string): boolean {
const v = value.trim().replace(/^nostr:/i, '').trim()
if (!v) return false
if (/^[0-9a-f]{64}$/i.test(v)) return true
return /^(npub|nprofile|nevent|note|naddr)1[a-z0-9]+$/i.test(v)
}
/** True when normalized to a WebSocket relay or kind-10243 HTTP index base. */
export function isValidRelayFetchUrl(url: string): boolean {
const trimmed = url.trim()
if (!trimmed || looksLikeNostrBech32Identifier(trimmed)) return false
if (isKind10243HttpRelayTagUrl(trimmed)) {
return Boolean(normalizeHttpRelayUrl(trimmed))
}
const ws = normalizeUrl(trimmed)
return Boolean(ws && isWebsocketUrl(ws))
}
/** @deprecated Prefer {@link isKind10243HttpRelayTagUrl} only when parsing kind 10243. */ /** @deprecated Prefer {@link isKind10243HttpRelayTagUrl} only when parsing kind 10243. */
export function isHttpRelayUrl(url: string): boolean { export function isHttpRelayUrl(url: string): boolean {
return isKind10243HttpRelayTagUrl(url) return isKind10243HttpRelayTagUrl(url)
@ -216,7 +235,9 @@ export function normalizeUrl(url: string): string {
const trimmed = url.trim() const trimmed = url.trim()
if (!trimmed) return '' if (!trimmed) return ''
if (!trimmed.includes('://')) { if (!trimmed.includes('://')) {
if (!looksLikeNostrBech32Identifier(trimmed)) {
logger.warn('WebSocket relay URL requires ws: or wss: prefix', { url: trimmed }) logger.warn('WebSocket relay URL requires ws: or wss: prefix', { url: trimmed })
}
return '' return ''
} }

35
src/providers/NostrProvider/index.tsx

@ -39,6 +39,7 @@ import {
mergeHydratedCacheRelayListEvents mergeHydratedCacheRelayListEvents
} from '@/lib/event-metadata' } from '@/lib/event-metadata'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { buildAccountSessionNetworkHydrateRelayUrls } from '@/lib/relay-list-builder'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import { import {
parseBlockedRelayUrlsFromEvent, parseBlockedRelayUrlsFromEvent,
@ -445,6 +446,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
Date.now() - lastNetworkHydrateAt < ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS)) Date.now() - lastNetworkHydrateAt < ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS))
if (!skipNetworkHydrate) { if (!skipNetworkHydrate) {
/** Personal-relay policy must be synced before network REQs so profile index relays stay allowed. */
await client.syncViewerPersonalRelayKeys(account.pubkey)
const hydrateNetworkRelays = buildAccountSessionNetworkHydrateRelayUrls({
relayListEvent: storedRelayListEvent,
cacheRelayListEvent: storedCacheRelayListEvent,
httpRelayListEvent: storedHttpRelayListEvent ?? null,
favoriteRelaysEvent: storedFavoriteRelaysEvent,
blockedRelays
})
// Fetch RSS feed list from relays if cache is missing or stale (older than 1 hour) // Fetch RSS feed list from relays if cache is missing or stale (older than 1 hour)
const rssFeedListStale = const rssFeedListStale =
!storedRssFeedListEvent || !storedRssFeedListEvent ||
@ -457,7 +468,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}) })
queryService queryService
.fetchEvents(FAST_READ_RELAY_URLS.concat(PROFILE_RELAY_URLS), { .fetchEvents(hydrateNetworkRelays, {
kinds: [ExtendedKind.RSS_FEED_LIST], kinds: [ExtendedKind.RSS_FEED_LIST],
authors: [account.pubkey], authors: [account.pubkey],
limit: 1 limit: 1
@ -500,15 +511,15 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
const [relayListEvents, cacheRelayListEvents, httpRelayListEvents] = await Promise.all([ const [relayListEvents, cacheRelayListEvents, httpRelayListEvents] = await Promise.all([
queryService.fetchEvents(FAST_READ_RELAY_URLS, { queryService.fetchEvents(hydrateNetworkRelays, {
kinds: [kinds.RelayList], kinds: [kinds.RelayList],
authors: [account.pubkey] authors: [account.pubkey]
}, hydrateFetchOpts), }, hydrateFetchOpts),
queryService.fetchEvents(FAST_READ_RELAY_URLS, { queryService.fetchEvents(hydrateNetworkRelays, {
kinds: [ExtendedKind.CACHE_RELAYS], kinds: [ExtendedKind.CACHE_RELAYS],
authors: [account.pubkey] authors: [account.pubkey]
}, hydrateFetchOpts), }, hydrateFetchOpts),
queryService.fetchEvents(FAST_READ_RELAY_URLS, { queryService.fetchEvents(hydrateNetworkRelays, {
kinds: [ExtendedKind.HTTP_RELAY_LIST], kinds: [ExtendedKind.HTTP_RELAY_LIST],
authors: [account.pubkey], authors: [account.pubkey],
limit: 1 limit: 1
@ -550,13 +561,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
setRelayList(mergedRelayList) setRelayList(mergedRelayList)
const normalizedRelays = [ const fetchRelays = buildAccountSessionNetworkHydrateRelayUrls({
...mergedRelayList.write.map((url: string) => normalizeUrl(url) || url), relayListEvent: relayListEvent ?? storedRelayListEvent,
...mergedRelayList.read.map((url: string) => normalizeUrl(url) || url), cacheRelayListEvent: cacheRelayListEvent ?? storedCacheRelayListEvent,
...FAST_READ_RELAY_URLS.map((url: string) => normalizeUrl(url) || url), httpRelayListEvent: httpRelayListEventFetched ?? storedHttpRelayListEvent ?? null,
...PROFILE_RELAY_URLS.map((url: string) => normalizeUrl(url) || url) favoriteRelaysEvent: storedFavoriteRelaysEvent,
] blockedRelays
const fetchRelays = Array.from(new Set(normalizedRelays)).slice(0, 16) })
const events = await queryService.fetchEvents(fetchRelays, [ const events = await queryService.fetchEvents(fetchRelays, [
{ {
kinds: [...AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS], kinds: [...AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS],
@ -798,7 +809,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
} }
await replaceableEventService void replaceableEventService
.refreshAuthorPublishedReplaceablesFromRelays(account.pubkey) .refreshAuthorPublishedReplaceablesFromRelays(account.pubkey)
.catch((err) => { .catch((err) => {
logger.debug('[NostrProvider] Author replaceables refresh after hydrate failed', { error: err }) logger.debug('[NostrProvider] Author replaceables refresh after hydrate failed', { error: err })

4
src/services/client-replaceable-events.service.ts

@ -726,7 +726,9 @@ export class ReplaceableEventService {
const queryOpts = { const queryOpts = {
replaceableRace: useReplaceableRace, replaceableRace: useReplaceableRace,
eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100, eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100,
globalTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS : 2000 globalTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS : 2000,
/** Feed avatar batches must not be aborted by feed/search {@link interruptBackgroundQueries}. */
...(kind === kinds.Metadata ? { foreground: true as const } : {})
} }
let events: NEvent[] let events: NEvent[]

105
src/services/client.service.ts

@ -152,6 +152,7 @@ import {
authenticateNip42Relay, authenticateNip42Relay,
isRelayAuthRequiredCloseReason, isRelayAuthRequiredCloseReason,
isRelayAuthRequiredErrorMessage, isRelayAuthRequiredErrorMessage,
isRelayConnectionClosedError,
isRelaySubscriptionClosedByCaller isRelaySubscriptionClosedByCaller
} from '@/lib/relay-nip42-auth' } from '@/lib/relay-nip42-auth'
import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning' import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning'
@ -160,7 +161,7 @@ import { hexPubkeysEqual, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/
import { collectNip05ValuesFromKind0 } from '@/lib/profile-metadata-search' import { collectNip05ValuesFromKind0 } from '@/lib/profile-metadata-search'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag' import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag'
import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter' import { filterRelaysForEventPublish, isReadOnlyRelayUrl } from '@/lib/relay-publish-filter'
import { getPaymentAttestationTargetId } from '@/lib/superchat' import { getPaymentAttestationTargetId } from '@/lib/superchat'
import { import {
buildPublicMessagePublishRelayUrls, buildPublicMessagePublishRelayUrls,
@ -503,13 +504,15 @@ class ClientService extends EventTarget {
}) })
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err) const msg = err instanceof Error ? err.message : String(err)
const skipStrike =
msg.includes('[metadata-relays-only]') ||
msg.includes('[relay-strike]') ||
msg.includes('[relay-rate-limit]') ||
msg.includes('[offline]') ||
msg.includes('[http-index-relay]')
if ( if (
params?.purpose !== 'write' && !skipStrike &&
!msg.includes('[metadata-relays-only]') && (params?.purpose !== 'write' || isLocalNetworkUrl(url))
!msg.includes('[relay-strike]') &&
!msg.includes('[relay-rate-limit]') &&
!msg.includes('[offline]') &&
!msg.includes('[http-index-relay]')
) { ) {
relaySessionStrikes.recordConnectionFailure(url, msg, 'connection') relaySessionStrikes.recordConnectionFailure(url, msg, 'connection')
} }
@ -1389,11 +1392,7 @@ class ClientService extends EventTarget {
spellRelayList = this.emptyRelayListForPublish() spellRelayList = this.emptyRelayListForPublish()
} }
const spellWriteFilteredRaw = await collectViewerWriteOutboxUrls(event.pubkey, spellRelayList) const spellWriteFilteredRaw = await collectViewerWriteOutboxUrls(event.pubkey, spellRelayList)
const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u)) const spellWriteFiltered = spellWriteFilteredRaw.filter((url) => !isReadOnlyRelayUrl(url))
const spellWriteFiltered = spellWriteFilteredRaw.filter((url) => {
const n = normalizeRelayUrlByScheme(url) || url
return !readOnlySet.has(n)
})
return finish( return finish(
this.filterPublishingRelays( this.filterPublishingRelays(
buildPrioritizedWriteRelayUrls({ buildPrioritizedWriteRelayUrls({
@ -1586,12 +1585,11 @@ class ClientService extends EventTarget {
* so they stay in the random-relay pool even if not currently in monitoring data. * so they stay in the random-relay pool even if not currently in monitoring data.
*/ */
getSessionSuccessfulPublishRelayUrlsForRandomPool(): string[] { getSessionSuccessfulPublishRelayUrlsForRandomPool(): string[] {
const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u))
const out: string[] = [] const out: string[] = []
for (const [url, stats] of this.sessionRelayPublishStats.entries()) { for (const [url, stats] of this.sessionRelayPublishStats.entries()) {
if (stats.successCount < 1) continue if (stats.successCount < 1) continue
const n = canonicalRelaySessionKey(url) const n = canonicalRelaySessionKey(url)
if (!n || readOnlySet.has(n)) continue if (!n || isReadOnlyRelayUrl(n)) continue
out.push(n) out.push(n)
} }
out.sort((a, b) => { out.sort((a, b) => {
@ -1667,10 +1665,9 @@ class ClientService extends EventTarget {
* preferring those that have succeeded and been fast this session. Excludes read-only relays. * preferring those that have succeeded and been fast this session. Excludes read-only relays.
*/ */
getPreferredRelaysForRandom(candidateUrls: string[], count: number): string[] { getPreferredRelaysForRandom(candidateUrls: string[], count: number): string[] {
const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u))
const normalizedCandidates = candidateUrls const normalizedCandidates = candidateUrls
.map((u) => normalizeAnyRelayUrl(u) || u) .map((u) => normalizeAnyRelayUrl(u) || u)
.filter((n) => n && !readOnlySet.has(n)) .filter((n) => n && !isReadOnlyRelayUrl(n))
const unique = Array.from(new Set(normalizedCandidates)) const unique = Array.from(new Set(normalizedCandidates))
const preferred: string[] = [] const preferred: string[] = []
const rest: string[] = [] const rest: string[] = []
@ -2026,13 +2023,43 @@ class ClientService extends EventTarget {
) { ) {
logger.debug(`[PublishEvent] Auth required, attempting authentication`, { url }) logger.debug(`[PublishEvent] Auth required, attempting authentication`, { url })
applyRelayNip42AckTimeout(relay as unknown as AbstractRelay) applyRelayNip42AckTimeout(relay as unknown as AbstractRelay)
return authenticateNip42Relay(relay, (authEvt: EventTemplate) => const signAuth = (authEvt: EventTemplate) =>
queueRelayAuthSign(() => that.signer!.signEvent(authEvt)) queueRelayAuthSign(() => that.signer!.signEvent(authEvt))
const preparePublishRelay = async (): Promise<Relay> => {
const r = await that.pool.ensureRelay(url, ensureOpts)
const relayKeyPub = normalizeUrl(url) || url
patchRelayNoticeForFetchFailures(r as unknown as AbstractRelay, relayKeyPub, (u, m) =>
that.handleRelayNoticeSession(u, m)
) )
.then(() => { applyRelayNip42AckTimeout(r as unknown as AbstractRelay)
logger.debug(`[PublishEvent] Auth successful, retrying publish`, { url }) return r
return relay.publish(event) }
const publishAfterAuth = async (): Promise<void> => {
await authenticateNip42Relay(relay as unknown as AbstractRelay, signAuth)
let liveRelay = await preparePublishRelay()
for (let authPubAttempt = 0; authPubAttempt < 2; authPubAttempt++) {
try {
await liveRelay.publish(event)
return
} catch (retryErr) {
if (!isRelayConnectionClosedError(retryErr) || authPubAttempt === 1) {
throw retryErr
}
logger.debug('[PublishEvent] Publish after auth on closed socket; reconnecting', {
url
}) })
try {
that.pool.close([url])
} catch {
/* ignore */
}
await new Promise((r) => setTimeout(r, 350))
liveRelay = await preparePublishRelay()
await authenticateNip42Relay(liveRelay as unknown as AbstractRelay, signAuth)
}
}
}
return publishAfterAuth()
.then(() => { .then(() => {
logger.debug(`[PublishEvent] Successfully published after auth`, { url }) logger.debug(`[PublishEvent] Successfully published after auth`, { url })
that.recordPublishSuccess(url, Date.now() - startMs) that.recordPublishSuccess(url, Date.now() - startMs)
@ -2041,10 +2068,12 @@ class ClientService extends EventTarget {
relayStatuses.push({ url, success: true }) relayStatuses.push({ url, success: true })
}) })
.catch((authError) => { .catch((authError) => {
logger.error(`[PublishEvent] Auth or publish failed`, { url, error: authError.message }) const authMsg =
authError instanceof Error ? authError.message : String(authError)
logger.error(`[PublishEvent] Auth or publish failed`, { url, error: authMsg })
errors.push({ url, error: authError }) errors.push({ url, error: authError })
relayStatuses.push({ url, success: false, error: authError.message }) relayStatuses.push({ url, success: false, error: authMsg })
relaySessionStrikes.recordPublishFailure(url, authError.message) relaySessionStrikes.recordPublishFailure(url, authMsg)
}) })
} else { } else {
logger.error(`[PublishEvent] Publish failed`, { url, error: error.message }) logger.error(`[PublishEvent] Publish failed`, { url, error: error.message })
@ -3989,18 +4018,16 @@ class ClientService extends EventTarget {
): Promise<TProfile[]> { ): Promise<TProfile[]> {
void this.ensureProfileSearchIndexFromIdb() void this.ensureProfileSearchIndexFromIdb()
const searchStr = typeof filter.search === 'string' ? filter.search.trim() : '' const searchStr = typeof filter.search === 'string' ? filter.search.trim() : ''
const normalizedAll = dedupeNormalizeRelayUrlsOrdered( const normalizedAll = dedupeNormalizeRelayUrlsOrdered(relayUrls)
relayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean) const profileRelayLayer = dedupeNormalizeRelayUrlsOrdered(PROFILE_RELAY_URLS)
) const searchableSet = new Set(
const profileRelayLayer = dedupeNormalizeRelayUrlsOrdered( dedupeNormalizeRelayUrlsOrdered([
PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) ...SEARCHABLE_RELAY_URLS,
) ...getViewerNostrLandAggrSearchRelayUrls(),
const searchableSet = new Set([ ...nip66Service.getSearchableRelayUrls(),
...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u), ...PROFILE_RELAY_URLS
...getViewerNostrLandAggrSearchRelayUrls().map((u) => normalizeUrl(u) || u),
...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u),
...PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
]) ])
)
let urls = normalizedAll let urls = normalizedAll
if (searchStr.length > 0 && !options?.relaysOnly) { if (searchStr.length > 0 && !options?.relaysOnly) {
const searchCapable = normalizedAll.filter( const searchCapable = normalizedAll.filter(
@ -4042,7 +4069,7 @@ class ClientService extends EventTarget {
options?.globalTimeout ?? options?.globalTimeout ??
(usesNip50TextSearch ? NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000 : 9000), (usesNip50TextSearch ? NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000 : 9000),
relayOpSource: 'ClientService.searchProfiles', relayOpSource: 'ClientService.searchProfiles',
foreground: usesNip50TextSearch || usesAuthorsLookup, foreground: usesNip50TextSearch || usesAuthorsLookup || options?.relaysOnly === true,
signal: options?.signal signal: options?.signal
}) })
@ -4065,15 +4092,13 @@ class ClientService extends EventTarget {
} }
private profileRelaySearchUrls(): string[] { private profileRelaySearchUrls(): string[] {
return dedupeNormalizeRelayUrlsOrdered( return dedupeNormalizeRelayUrlsOrdered(PROFILE_RELAY_URLS)
PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
)
} }
private nip50ProfileIndexRelayUrls(): string[] { private nip50ProfileIndexRelayUrls(): string[] {
return dedupeNormalizeRelayUrlsOrdered([ return dedupeNormalizeRelayUrlsOrdered([
...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u), ...SEARCHABLE_RELAY_URLS,
...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u) ...nip66Service.getSearchableRelayUrls()
]) ])
} }

7
src/services/nip66.service.ts

@ -6,7 +6,7 @@
* require this data to function; use as a hint only. * require this data to function; use as a hint only.
*/ */
import { normalizeUrl } from '@/lib/url' import { normalizeUrl, looksLikeNostrBech32Identifier, isWebsocketUrl } from '@/lib/url'
import indexDb from '@/services/indexed-db.service' import indexDb from '@/services/indexed-db.service'
import { TNip66RelayDiscovery } from '@/types' import { TNip66RelayDiscovery } from '@/types'
import { Event as NEvent } from 'nostr-tools' import { Event as NEvent } from 'nostr-tools'
@ -22,7 +22,10 @@ function parseEvent(ev: NEvent): TNip66RelayDiscovery | null {
if (ev.kind !== RELAY_DISCOVERY_KIND) return null if (ev.kind !== RELAY_DISCOVERY_KIND) return null
const d = ev.tags.find((t) => t[0] === 'd')?.[1] const d = ev.tags.find((t) => t[0] === 'd')?.[1]
if (!d) return null if (!d) return null
const url = d.startsWith('wss://') || d.startsWith('ws://') ? d : `wss://${d}` const dTrim = d.trim()
if (!dTrim || looksLikeNostrBech32Identifier(dTrim)) return null
const url = dTrim.startsWith('wss://') || dTrim.startsWith('ws://') ? dTrim : `wss://${dTrim}`
if (!isWebsocketUrl(url)) return null
const nips = ev.tags.filter((t) => t[0] === 'N').map((t) => parseInt(t[1], 10)).filter((n) => !Number.isNaN(n)) const nips = ev.tags.filter((t) => t[0] === 'N').map((t) => parseInt(t[1], 10)).filter((n) => !Number.isNaN(n))
const requirements: TNip66RelayDiscovery['requirements'] = {} const requirements: TNip66RelayDiscovery['requirements'] = {}

Loading…
Cancel
Save