(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)
+ )
+ ])
}
/**