Browse Source

fix broadcasting

imwald
Silberengel 3 weeks ago
parent
commit
1d34db52fa
  1. 2
      src/components/UserAvatar/UserAvatar.test.tsx
  2. 43
      src/components/UserAvatar/index.tsx
  3. 7
      src/constants.ts
  4. 15
      src/hooks/useFetchProfile.tsx
  5. 46
      src/lib/pubkey.ts
  6. 30
      src/services/client.service.ts

2
src/components/UserAvatar/UserAvatar.test.tsx

@ -21,6 +21,8 @@ vi.mock('@/PageManager', () => ({
vi.mock('@/lib/pubkey', () => ({ vi.mock('@/lib/pubkey', () => ({
userIdToPubkey: (id: string) => (id.startsWith('npub') ? 'decoded_pubkey' : id), 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) => generateImageByPubkey: (_pubkey: string) =>
`data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10" fill="gray"/></svg>`)}` `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10" fill="gray"/></svg>`)}`
})) }))

43
src/components/UserAvatar/index.tsx

@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { useFetchProfile } from '@/hooks' import { useFetchProfile } from '@/hooks'
import { toNostrBuildThumbUrl } from '@/lib/nostr-build' import { toNostrBuildThumbUrl } from '@/lib/nostr-build'
import { isImage, isMedia, isVideo } from '@/lib/url' 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 { toProfile } from '@/lib/link'
import { seedProfileForNavigation } from '@/lib/profile-navigation-seed' import { seedProfileForNavigation } from '@/lib/profile-navigation-seed'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -181,12 +181,15 @@ export default function UserAvatar({
const pubkey = useMemo(() => { const pubkey = useMemo(() => {
if (!userId) return '' if (!userId) return ''
const decodedPubkey = userIdToPubkey(userId) 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]) }, [userId, profile?.pubkey])
const identiconSource = pubkey || userId.trim()
const defaultAvatar = useMemo( const defaultAvatar = useMemo(
() => (pubkey ? generateImageByPubkey(pubkey) : ''), () => (identiconSource ? generateImageByPubkey(identiconSource) : ''),
[pubkey] [identiconSource]
) )
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@ -235,12 +238,11 @@ export default function UserAvatar({
if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc) if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
} }
// Use pubkey from decoded userId if profile isn't loaded yet
const displayPubkey = profile?.pubkey || pubkey || '' 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 if (!userId.trim()) {
// Otherwise show skeleton while loading
if (!profile && !pubkey) {
return ( return (
<Skeleton className={cn('shrink-0', UserAvatarSizeCnMap[size], 'rounded-full', className)} /> <Skeleton className={cn('shrink-0', UserAvatarSizeCnMap[size], 'rounded-full', className)} />
) )
@ -255,8 +257,9 @@ export default function UserAvatar({
style={{ position: 'relative', zIndex: 10, isolation: 'isolate', display: 'block' }} style={{ position: 'relative', zIndex: 10, isolation: 'isolate', display: 'block' }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (!profileNavTarget) return
if (profile) seedProfileForNavigation(profile) if (profile) seedProfileForNavigation(profile)
navigateToProfile(toProfile(displayPubkey)) navigateToProfile(toProfile(profileNavTarget))
}} }}
> >
{!imgError && currentSrc ? ( {!imgError && currentSrc ? (
@ -288,7 +291,7 @@ export default function UserAvatar({
) : ( ) : (
// Show initials or placeholder when image fails // Show initials or placeholder when image fails
<div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground"> <div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground">
{displayPubkey ? displayPubkey.slice(0, 2).toUpperCase() : ''} {(displayPubkey || userId).slice(0, 2).toUpperCase()}
</div> </div>
)} )}
</div> </div>
@ -320,12 +323,15 @@ export function SimpleUserAvatar({
const pubkey = useMemo(() => { const pubkey = useMemo(() => {
if (!userId) return '' if (!userId) return ''
const decodedPubkey = userIdToPubkey(userId) 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]) }, [userId, profile?.pubkey])
const identiconSource = pubkey || userId.trim()
const defaultAvatar = useMemo( const defaultAvatar = useMemo(
() => (pubkey ? generateImageByPubkey(pubkey) : ''), () => (identiconSource ? generateImageByPubkey(identiconSource) : ''),
[pubkey] [identiconSource]
) )
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@ -372,15 +378,12 @@ export function SimpleUserAvatar({
if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc) if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
} }
// If we have a pubkey (from decoding npub/nprofile or profile), show avatar even without profile if (!userId.trim()) {
// Otherwise show skeleton while loading
if (!profile && !pubkey) {
return ( return (
<Skeleton className={cn('shrink-0', UserAvatarSizeCnMap[size], 'rounded-full', className)} /> <Skeleton className={cn('shrink-0', UserAvatarSizeCnMap[size], 'rounded-full', className)} />
) )
} }
// Use pubkey from decoded userId if profile isn't loaded yet
const displayPubkey = profile?.pubkey || pubkey || '' const displayPubkey = profile?.pubkey || pubkey || ''
// Render image directly instead of using Radix UI Avatar for better reliability // 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 // Show initials or placeholder when image fails
<div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground"> <div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground">
{displayPubkey ? displayPubkey.slice(0, 2).toUpperCase() : ''} {(displayPubkey || userId).slice(0, 2).toUpperCase()}
</div> </div>
)} )}
</div> </div>

7
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. */ /** After a publish wave, failed NIP-65 write (outbox) relays are retried once after this delay. */
export const OUTBOX_PUBLISH_RETRY_DELAY_MS = 5000 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`). */ /** Max merged URLs per REQ / timeline relay list (see `relay-url-priority`). */
export const MAX_REQ_RELAY_URLS = MAX_CONCURRENT_RELAY_CONNECTIONS export const MAX_REQ_RELAY_URLS = MAX_CONCURRENT_RELAY_CONNECTIONS

15
src/hooks/useFetchProfile.tsx

@ -417,9 +417,9 @@ export function useFetchProfile(id?: string, skipCache = false) {
}) })
if (!extractedPubkey) { if (!extractedPubkey) {
logger.error('[useFetchProfile] Invalid id - could not extract pubkey', { logger.debug('[useFetchProfile] Invalid id - could not extract pubkey', {
id, idLength: id.length,
idLength: id.length prefix: id.slice(0, 16)
}) })
setProfile(null) setProfile(null)
setPubkey(null) setPubkey(null)
@ -431,11 +431,10 @@ export function useFetchProfile(id?: string, skipCache = false) {
// Validate pubkey format // Validate pubkey format
if (extractedPubkey.length !== 64 || !/^[0-9a-f]{64}$/.test(extractedPubkey)) { if (extractedPubkey.length !== 64 || !/^[0-9a-f]{64}$/.test(extractedPubkey)) {
logger.error('[useFetchProfile] Invalid pubkey format', { logger.debug('[useFetchProfile] Invalid pubkey format (non-hex id passed through userIdToPubkey)', {
id, idLength: id.length,
extractedPubkey, extractedLen: extractedPubkey.length,
pubkeyLength: extractedPubkey.length, prefix: id.slice(0, 12)
expectedLength: 64
}) })
setProfile(null) setProfile(null)
setPubkey(null) setPubkey(null)

46
src/lib/pubkey.ts

@ -1,7 +1,16 @@
import { LRUCache } from 'lru-cache' import { LRUCache } from 'lru-cache'
import { sha256 } from '@noble/hashes/sha2'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import logger from '@/lib/logger' 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) { export function formatPubkey(pubkey: string) {
const npub = pubkeyToNpub(pubkey) const npub = pubkeyToNpub(pubkey)
if (npub) { if (npub) {
@ -40,23 +49,29 @@ export function pubkeyToNpub(pubkey: string) {
} }
export function userIdToPubkey(userId: 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 { try {
const { type, data } = nip19.decode(userId) const { type, data } = nip19.decode(trimmed)
if (type === 'npub') { if (type === 'npub' && typeof data === 'string' && isValidPubkey(data)) {
return data return data.toLowerCase()
} else if (type === 'nprofile') {
return data.pubkey
} }
} catch (error) { if (type === 'nprofile' && data && typeof data.pubkey === 'string' && isValidPubkey(data.pubkey)) {
logger.error('Error decoding userId', { userId, error }) 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)) { if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase() return trimmed.toLowerCase()
} }
return userId return trimmed
} }
/** Lowercase 64-char hex pubkeys for stable Maps, REQ filters, and tag comparison. */ /** Lowercase 64-char hex pubkeys for stable Maps, REQ filters, and tag comparison. */
@ -95,12 +110,15 @@ const pubkeyImageCache = new LRUCache<string, string>({ max: 1000 })
const CACHE_VERSION = 'v2' const CACHE_VERSION = 'v2'
export function generateImageByPubkey(pubkey: string): string { export function generateImageByPubkey(pubkey: string): string {
const cacheKey = `${CACHE_VERSION}:${pubkey}` const seed = stableHexSeedForIdenticon(pubkey)
const cacheKey = `${CACHE_VERSION}:${seed}`
if (pubkeyImageCache.has(cacheKey)) { if (pubkeyImageCache.has(cacheKey)) {
return pubkeyImageCache.get(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 // Split into 3 parts for colors and the rest for control points
const colors: string[] = [] const colors: string[] = []
@ -123,11 +141,11 @@ export function generateImageByPubkey(pubkey: string): string {
const c = colors[index % (colors.length - 1)] const c = colors[index % (colors.length - 1)]
return ` return `
<radialGradient id="grad${index}-${pubkey}" cx="${cx}%" cy="${cy}%" r="${r}%"> <radialGradient id="grad${index}-${svgIdSafe}" cx="${cx}%" cy="${cy}%" r="${r}%">
<stop offset="0%" style="stop-color:${c};stop-opacity:1" /> <stop offset="0%" style="stop-color:${c};stop-opacity:1" />
<stop offset="100%" style="stop-color:${c};stop-opacity:0" /> <stop offset="100%" style="stop-color:${c};stop-opacity:0" />
</radialGradient> </radialGradient>
<rect width="100%" height="100%" fill="url(#grad${index}-${pubkey})" /> <rect width="100%" height="100%" fill="url(#grad${index}-${svgIdSafe})" />
` `
}) })
.join('') .join('')

30
src/services/client.service.ts

@ -13,6 +13,7 @@ import {
relaysAfterSocialKindBlockedStrip, relaysAfterSocialKindBlockedStrip,
SOCIAL_KIND_BLOCKED_RELAY_URLS, SOCIAL_KIND_BLOCKED_RELAY_URLS,
MAX_PUBLISH_RELAYS, MAX_PUBLISH_RELAYS,
PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS,
RELAY_POOL_CONNECTION_TIMEOUT_MS, RELAY_POOL_CONNECTION_TIMEOUT_MS,
RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS, RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS,
TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY, 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). */ /** NIP-65 `write` URLs for `event.pubkey`, filtered for publish (no read-only / social-kind blocks). */
private async getUserOutboxRelayUrlsForPublish(event: NEvent): Promise<string[]> { private async getUserOutboxRelayUrlsForPublish(event: NEvent): Promise<string[]> {
try { try {
const relayList = await this.fetchRelayList(event.pubkey) const relayList = await Promise.race([
this.fetchRelayList(event.pubkey),
new Promise<null>((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 ?? []) const wsOut = (relayList?.write ?? [])
.map((u) => normalizeUrl(u) || u) .map((u) => normalizeUrl(u) || u)
.filter((u): u is string => !!u) .filter((u): u is string => !!u)
@ -646,7 +658,21 @@ class ClientService extends EventTarget {
event: NEvent, event: NEvent,
favoriteRelayUrls: string[] = [] favoriteRelayUrls: string[] = []
): Promise<string[]> { ): Promise<string[]> {
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<string[]>((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)
)
])
} }
/** /**

Loading…
Cancel
Save