From 1d34db52faed427db51ec4d74f7f51e1ca0753f3 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 10 Apr 2026 15:41:10 +0200 Subject: [PATCH] fix broadcasting --- src/components/UserAvatar/UserAvatar.test.tsx | 2 + src/components/UserAvatar/index.tsx | 43 +++++++++-------- src/constants.ts | 7 +++ src/hooks/useFetchProfile.tsx | 15 +++--- src/lib/pubkey.ts | 46 +++++++++++++------ src/services/client.service.ts | 30 +++++++++++- 6 files changed, 99 insertions(+), 44 deletions(-) diff --git a/src/components/UserAvatar/UserAvatar.test.tsx b/src/components/UserAvatar/UserAvatar.test.tsx index 969d990d..4123fce9 100644 --- a/src/components/UserAvatar/UserAvatar.test.tsx +++ b/src/components/UserAvatar/UserAvatar.test.tsx @@ -21,6 +21,8 @@ vi.mock('@/PageManager', () => ({ vi.mock('@/lib/pubkey', () => ({ userIdToPubkey: (id: string) => (id.startsWith('npub') ? 'decoded_pubkey' : id), + isValidPubkey: (pk: string) => + /^[0-9a-f]{64}$/i.test(pk) || pk === 'test_pubkey' || pk === 'decoded_pubkey', generateImageByPubkey: (_pubkey: string) => `data:image/svg+xml,${encodeURIComponent(``)}` })) diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx index 256faf65..e1609637 100644 --- a/src/components/UserAvatar/index.tsx +++ b/src/components/UserAvatar/index.tsx @@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { useFetchProfile } from '@/hooks' import { toNostrBuildThumbUrl } from '@/lib/nostr-build' import { isImage, isMedia, isVideo } from '@/lib/url' -import { generateImageByPubkey, userIdToPubkey } from '@/lib/pubkey' +import { generateImageByPubkey, isValidPubkey, userIdToPubkey } from '@/lib/pubkey' import { toProfile } from '@/lib/link' import { seedProfileForNavigation } from '@/lib/profile-navigation-seed' import { cn } from '@/lib/utils' @@ -181,12 +181,15 @@ export default function UserAvatar({ const pubkey = useMemo(() => { if (!userId) return '' const decodedPubkey = userIdToPubkey(userId) - return decodedPubkey || profile?.pubkey || '' + if (isValidPubkey(decodedPubkey)) return decodedPubkey + const fromProfile = profile?.pubkey + return fromProfile && isValidPubkey(fromProfile) ? fromProfile : '' }, [userId, profile?.pubkey]) - + + const identiconSource = pubkey || userId.trim() const defaultAvatar = useMemo( - () => (pubkey ? generateImageByPubkey(pubkey) : ''), - [pubkey] + () => (identiconSource ? generateImageByPubkey(identiconSource) : ''), + [identiconSource] ) const containerRef = useRef(null) @@ -235,12 +238,11 @@ export default function UserAvatar({ if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc) } - // Use pubkey from decoded userId if profile isn't loaded yet const displayPubkey = profile?.pubkey || pubkey || '' + const profileNavTarget = + userId.startsWith('npub1') || userId.startsWith('nprofile1') ? userId : displayPubkey - // If we have a pubkey (from decoding npub/nprofile or profile), show avatar even without profile - // Otherwise show skeleton while loading - if (!profile && !pubkey) { + if (!userId.trim()) { return ( ) @@ -255,8 +257,9 @@ export default function UserAvatar({ style={{ position: 'relative', zIndex: 10, isolation: 'isolate', display: 'block' }} onClick={(e) => { e.stopPropagation() + if (!profileNavTarget) return if (profile) seedProfileForNavigation(profile) - navigateToProfile(toProfile(displayPubkey)) + navigateToProfile(toProfile(profileNavTarget)) }} > {!imgError && currentSrc ? ( @@ -288,7 +291,7 @@ export default function UserAvatar({ ) : ( // Show initials or placeholder when image fails
- {displayPubkey ? displayPubkey.slice(0, 2).toUpperCase() : ''} + {(displayPubkey || userId).slice(0, 2).toUpperCase()}
)} @@ -320,12 +323,15 @@ export function SimpleUserAvatar({ const pubkey = useMemo(() => { if (!userId) return '' const decodedPubkey = userIdToPubkey(userId) - return decodedPubkey || profile?.pubkey || '' + if (isValidPubkey(decodedPubkey)) return decodedPubkey + const fromProfile = profile?.pubkey + return fromProfile && isValidPubkey(fromProfile) ? fromProfile : '' }, [userId, profile?.pubkey]) - + + const identiconSource = pubkey || userId.trim() const defaultAvatar = useMemo( - () => (pubkey ? generateImageByPubkey(pubkey) : ''), - [pubkey] + () => (identiconSource ? generateImageByPubkey(identiconSource) : ''), + [identiconSource] ) const containerRef = useRef(null) @@ -372,15 +378,12 @@ export function SimpleUserAvatar({ if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc) } - // If we have a pubkey (from decoding npub/nprofile or profile), show avatar even without profile - // Otherwise show skeleton while loading - if (!profile && !pubkey) { + if (!userId.trim()) { return ( ) } - // Use pubkey from decoded userId if profile isn't loaded yet const displayPubkey = profile?.pubkey || pubkey || '' // Render image directly instead of using Radix UI Avatar for better reliability @@ -418,7 +421,7 @@ export function SimpleUserAvatar({ ) : ( // Show initials or placeholder when image fails
- {displayPubkey ? displayPubkey.slice(0, 2).toUpperCase() : ''} + {(displayPubkey || userId).slice(0, 2).toUpperCase()}
)} diff --git a/src/constants.ts b/src/constants.ts index 0e7ee54a..a596a49c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -106,6 +106,13 @@ export const MAX_PUBLISH_RELAYS = 20 /** After a publish wave, failed NIP-65 write (outbox) relays are retried once after this delay. */ export const OUTBOX_PUBLISH_RETRY_DELAY_MS = 5000 +/** + * Cap how long we wait on NIP-65 / inbox relay-list fetches before publishing. + * Without this, a stuck `fetchRelayList` / `fetchRelayLists` can leave republish toasts loading forever + * (the 30s publish timeout only runs after targets are resolved). + */ +export const PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS = 12_000 + /** Max merged URLs per REQ / timeline relay list (see `relay-url-priority`). */ export const MAX_REQ_RELAY_URLS = MAX_CONCURRENT_RELAY_CONNECTIONS diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index 0cc37162..7bf058b7 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -417,9 +417,9 @@ export function useFetchProfile(id?: string, skipCache = false) { }) if (!extractedPubkey) { - logger.error('[useFetchProfile] Invalid id - could not extract pubkey', { - id, - idLength: id.length + logger.debug('[useFetchProfile] Invalid id - could not extract pubkey', { + idLength: id.length, + prefix: id.slice(0, 16) }) setProfile(null) setPubkey(null) @@ -431,11 +431,10 @@ export function useFetchProfile(id?: string, skipCache = false) { // Validate pubkey format if (extractedPubkey.length !== 64 || !/^[0-9a-f]{64}$/.test(extractedPubkey)) { - logger.error('[useFetchProfile] Invalid pubkey format', { - id, - extractedPubkey, - pubkeyLength: extractedPubkey.length, - expectedLength: 64 + logger.debug('[useFetchProfile] Invalid pubkey format (non-hex id passed through userIdToPubkey)', { + idLength: id.length, + extractedLen: extractedPubkey.length, + prefix: id.slice(0, 12) }) setProfile(null) setPubkey(null) diff --git a/src/lib/pubkey.ts b/src/lib/pubkey.ts index b1a9875f..adb5499a 100644 --- a/src/lib/pubkey.ts +++ b/src/lib/pubkey.ts @@ -1,7 +1,16 @@ import { LRUCache } from 'lru-cache' +import { sha256 } from '@noble/hashes/sha2' import { nip19 } from 'nostr-tools' import logger from '@/lib/logger' +/** 64-char lowercase hex for identicon math; hashes arbitrary strings (e.g. bad npub paste). */ +function stableHexSeedForIdenticon(input: string): string { + const t = input.trim() + if (/^[0-9a-f]{64}$/i.test(t)) return t.toLowerCase() + const bytes = sha256(new TextEncoder().encode(t)) + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') +} + export function formatPubkey(pubkey: string) { const npub = pubkeyToNpub(pubkey) if (npub) { @@ -40,23 +49,29 @@ export function pubkeyToNpub(pubkey: string) { } export function userIdToPubkey(userId: string) { - if (userId.startsWith('npub1') || userId.startsWith('nprofile1')) { + const trimmed = userId.trim() + if (!trimmed) return '' + + if (trimmed.startsWith('npub1') || trimmed.startsWith('nprofile1')) { try { - const { type, data } = nip19.decode(userId) - if (type === 'npub') { - return data - } else if (type === 'nprofile') { - return data.pubkey + const { type, data } = nip19.decode(trimmed) + if (type === 'npub' && typeof data === 'string' && isValidPubkey(data)) { + return data.toLowerCase() } - } catch (error) { - logger.error('Error decoding userId', { userId, error }) + if (type === 'nprofile' && data && typeof data.pubkey === 'string' && isValidPubkey(data.pubkey)) { + return data.pubkey.toLowerCase() + } + } catch { + // Wrong-length or bad-checksum bech32 — do not pass the literal npub string downstream as "hex pubkey" + logger.debug('userIdToPubkey: nip19 decode failed', { len: trimmed.length, prefix: trimmed.slice(0, 12) }) } + return '' } - const trimmed = userId.trim() + if (/^[0-9a-f]{64}$/i.test(trimmed)) { return trimmed.toLowerCase() } - return userId + return trimmed } /** Lowercase 64-char hex pubkeys for stable Maps, REQ filters, and tag comparison. */ @@ -95,12 +110,15 @@ const pubkeyImageCache = new LRUCache({ max: 1000 }) const CACHE_VERSION = 'v2' export function generateImageByPubkey(pubkey: string): string { - const cacheKey = `${CACHE_VERSION}:${pubkey}` + const seed = stableHexSeedForIdenticon(pubkey) + const cacheKey = `${CACHE_VERSION}:${seed}` if (pubkeyImageCache.has(cacheKey)) { return pubkeyImageCache.get(cacheKey)! } - const paddedPubkey = pubkey.padEnd(66, '0') + const paddedPubkey = seed.padEnd(66, '0') + /** XML/HTML id tokens must not contain `nevent1…` or other punctuation from pasted ids */ + const svgIdSafe = `g${seed.slice(0, 20)}` // Split into 3 parts for colors and the rest for control points const colors: string[] = [] @@ -123,11 +141,11 @@ export function generateImageByPubkey(pubkey: string): string { const c = colors[index % (colors.length - 1)] return ` - + - + ` }) .join('') diff --git a/src/services/client.service.ts b/src/services/client.service.ts index fe450cae..ea58be26 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -13,6 +13,7 @@ import { relaysAfterSocialKindBlockedStrip, SOCIAL_KIND_BLOCKED_RELAY_URLS, MAX_PUBLISH_RELAYS, + PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS, RELAY_POOL_CONNECTION_TIMEOUT_MS, RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS, TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY, @@ -502,7 +503,18 @@ class ClientService extends EventTarget { /** NIP-65 `write` URLs for `event.pubkey`, filtered for publish (no read-only / social-kind blocks). */ private async getUserOutboxRelayUrlsForPublish(event: NEvent): Promise { try { - const relayList = await this.fetchRelayList(event.pubkey) + const relayList = await Promise.race([ + this.fetchRelayList(event.pubkey), + new Promise((resolve) => + setTimeout(() => resolve(null), PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS) + ) + ]) + if (relayList == null) { + logger.warn('[PublishEvent] fetchRelayList timed out while resolving outboxes; publishing without NIP-65 prepend', { + pubkey: event.pubkey.slice(0, 12) + }) + return [] + } const wsOut = (relayList?.write ?? []) .map((u) => normalizeUrl(u) || u) .filter((u): u is string => !!u) @@ -646,7 +658,21 @@ class ClientService extends EventTarget { event: NEvent, favoriteRelayUrls: string[] = [] ): Promise { - return this.prioritizePublishUrlList(relayUrls, event, favoriteRelayUrls) + const fallbackOrder = (): string[] => + this.filterPublishingRelays(dedupeNormalizeRelayUrlsOrdered(relayUrls), event).slice(0, MAX_PUBLISH_RELAYS) + + return await Promise.race([ + this.prioritizePublishUrlList(relayUrls, event, favoriteRelayUrls), + new Promise((resolve) => + setTimeout(() => { + logger.warn('[PublishEvent] prioritizePublishUrlList timed out; using deduped relay order without inbox fetch', { + kind: event.kind, + relayCount: relayUrls.length + }) + resolve(fallbackOrder()) + }, PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS) + ) + ]) } /**