diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx
index 6ff0cf0d..e3756c98 100644
--- a/src/components/UserAvatar/index.tsx
+++ b/src/components/UserAvatar/index.tsx
@@ -13,14 +13,45 @@ function isHttpOrHttpsUrl(url: string): boolean {
return /^https?:\/\//i.test(url.trim())
}
+/** Module-level cache: URL → file size in bytes, or null if unknown (CORS blocked / no header). */
+const urlSizeCache = new Map()
+
+/**
+ * URLs that have fired onLoad successfully this session.
+ * When a URL is here the image is already in the browser's HTTP cache, so we can
+ * skip both the IntersectionObserver delay and the HEAD-request size check.
+ */
+const loadedAvatarUrls = new Set()
+
+/**
+ * Non-blocking HEAD request to get Content-Length for a URL.
+ * Result is cached permanently in memory. Resolves null on CORS failure or missing header.
+ */
+async function fetchUrlSizeBytes(url: string): Promise {
+ if (urlSizeCache.has(url)) return urlSizeCache.get(url)!
+ try {
+ const res = await fetch(url, { method: 'HEAD' })
+ const cl = res.headers.get('content-length')
+ const size = cl ? parseInt(cl, 10) : null
+ urlSizeCache.set(url, size)
+ return size
+ } catch {
+ urlSizeCache.set(url, null)
+ return null
+ }
+}
+
/**
* Defer loading remote profile pictures until the avatar is near the viewport so handles/text
* can paint first; identicon (data URL) shows until then.
+ * Also enforces an optional maxFileSizeBytes cap — shows fallback for avatars that are confirmed
+ * larger than the cap (based on a cached HEAD request).
*/
function useDeferRemoteProfileAvatar(
profileAvatar: string | undefined,
fallbackSrc: string,
- containerRef: RefObject
+ containerRef: RefObject,
+ maxFileSizeBytes?: number
): string {
const remoteHttp = useMemo(() => {
const a = profileAvatar?.trim()
@@ -30,17 +61,39 @@ function useDeferRemoteProfileAvatar(
return toNostrBuildThumbUrl(a)
}, [profileAvatar])
+ // If this URL loaded successfully earlier this session it's already in the browser's
+ // HTTP cache — skip both the viewport delay and the size check.
+ const alreadyCached = remoteHttp ? loadedAvatarUrls.has(remoteHttp) : false
+
+ const [sizeBlocked, setSizeBlocked] = useState(false)
+
+ useEffect(() => {
+ if (!remoteHttp || !maxFileSizeBytes || alreadyCached) {
+ setSizeBlocked(false)
+ return
+ }
+ if (urlSizeCache.has(remoteHttp)) {
+ const cached = urlSizeCache.get(remoteHttp)
+ setSizeBlocked(cached != null && cached > maxFileSizeBytes)
+ return
+ }
+ fetchUrlSizeBytes(remoteHttp).then((size) => {
+ setSizeBlocked(size != null && size > maxFileSizeBytes)
+ })
+ }, [remoteHttp, maxFileSizeBytes, alreadyCached])
+
const nonHttpAvatar = useMemo(() => {
const a = profileAvatar?.trim()
if (a && !isHttpOrHttpsUrl(a)) return a
return ''
}, [profileAvatar])
- const [allowRemote, setAllowRemote] = useState(() => remoteHttp === '')
+ // Already cached → show immediately without waiting for IntersectionObserver.
+ const [allowRemote, setAllowRemote] = useState(() => remoteHttp === '' || alreadyCached)
useEffect(() => {
- setAllowRemote(remoteHttp === '')
- }, [remoteHttp])
+ setAllowRemote(remoteHttp === '' || alreadyCached)
+ }, [remoteHttp, alreadyCached])
useEffect(() => {
if (!remoteHttp || allowRemote) return
@@ -62,6 +115,7 @@ function useDeferRemoteProfileAvatar(
return () => io.disconnect()
}, [remoteHttp, allowRemote, containerRef])
+ if (sizeBlocked) return fallbackSrc
return nonHttpAvatar || (remoteHttp && allowRemote ? remoteHttp : '') || fallbackSrc
}
@@ -80,13 +134,20 @@ export default function UserAvatar({
userId,
className,
size = 'normal',
- prefetchedProfile
+ prefetchedProfile,
+ maxFileSizeKb = 2048
}: {
userId: string
className?: string
size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
/** Same pubkey as userId; use avatar from search/cache until fetch completes. */
prefetchedProfile?: TProfile
+ /**
+ * Skip avatar images larger than this (KB) — uses the generated placeholder instead.
+ * Non-nostr.build sizes are checked via a cached HEAD request; unknown sizes are shown.
+ * Defaults to 2048 (2 MB). Pass a lower value (e.g. 500) for dense feed contexts.
+ */
+ maxFileSizeKb?: number
}) {
const { profile: fetchedProfile } = useFetchProfile(userId)
const profile = useMemo(() => {
@@ -111,7 +172,24 @@ export default function UserAvatar({
)
const containerRef = useRef(null)
- const avatarSrc = useDeferRemoteProfileAvatar(profile?.avatar, defaultAvatar, containerRef)
+
+ // Seed the size cache from imeta data on the profile event — avoids a HEAD request
+ // when the kind-0 event already carries the file size.
+ useMemo(() => {
+ if (profile?.avatar && profile.pictureSize != null) {
+ const thumbUrl = toNostrBuildThumbUrl(profile.avatar)
+ if (!urlSizeCache.has(thumbUrl)) {
+ urlSizeCache.set(thumbUrl, profile.pictureSize)
+ }
+ }
+ }, [profile?.avatar, profile?.pictureSize])
+
+ const avatarSrc = useDeferRemoteProfileAvatar(
+ profile?.avatar,
+ defaultAvatar,
+ containerRef,
+ maxFileSizeKb != null ? maxFileSizeKb * 1024 : undefined
+ )
// All hooks must be called before any early returns
const [imgError, setImgError] = useState(false)
@@ -134,6 +212,7 @@ export default function UserAvatar({
const handleImageLoad = () => {
setImgError(false)
+ if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
}
// Use pubkey from decoded userId if profile isn't loaded yet
@@ -184,12 +263,14 @@ export function SimpleUserAvatar({
userId,
size = 'normal',
className,
- prefetchedProfile
+ prefetchedProfile,
+ maxFileSizeKb = 2048
}: {
userId: string
size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
className?: string
prefetchedProfile?: TProfile
+ maxFileSizeKb?: number
}) {
const { profile: fetchedProfile } = useFetchProfile(userId)
const profile = useMemo(() => {
@@ -212,7 +293,22 @@ export function SimpleUserAvatar({
)
const containerRef = useRef(null)
- const avatarSrc = useDeferRemoteProfileAvatar(profile?.avatar, defaultAvatar, containerRef)
+
+ useMemo(() => {
+ if (profile?.avatar && profile.pictureSize != null) {
+ const thumbUrl = toNostrBuildThumbUrl(profile.avatar)
+ if (!urlSizeCache.has(thumbUrl)) {
+ urlSizeCache.set(thumbUrl, profile.pictureSize)
+ }
+ }
+ }, [profile?.avatar, profile?.pictureSize])
+
+ const avatarSrc = useDeferRemoteProfileAvatar(
+ profile?.avatar,
+ defaultAvatar,
+ containerRef,
+ maxFileSizeKb != null ? maxFileSizeKb * 1024 : undefined
+ )
// All hooks must be called before any early returns
const [imgError, setImgError] = useState(false)
@@ -235,6 +331,7 @@ export function SimpleUserAvatar({
const handleImageLoad = () => {
setImgError(false)
+ if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
}
// If we have a pubkey (from decoding npub/nprofile or profile), show avatar even without profile
diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts
index 8b1be7c6..20829d61 100644
--- a/src/lib/event-metadata.ts
+++ b/src/lib/event-metadata.ts
@@ -5,7 +5,7 @@ import { buildATag } from './draft-event'
import { getReplaceableEventIdentifier } from './event'
import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning'
import { formatPubkey, pubkeyToNpub } from './pubkey'
-import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from './tag'
+import { generateBech32IdFromATag, generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag'
import { isHttpRelayUrl, isWebsocketUrl, normalizeHttpRelayUrl, normalizeHttpUrl, normalizeUrl } from './url'
import { isTorBrowser } from './utils'
import logger from '@/lib/logger'
@@ -201,11 +201,28 @@ export function getProfileFromEvent(event: Event) {
profileObj.name?.trim() ||
nip05?.split('@')[0]?.trim()
+ // Resolve picture URL (prefer tag over JSON)
+ const pictureTags = event.tags.filter(tag => tag[0] === 'picture' && tag[1]).map(tag => tag[1])
+ const avatarUrl = pictureTags.length > 0 ? pictureTags[0] : profileObj.picture
+
+ // Look up file size from any matching imeta tag in the kind-0 event
+ let pictureSize: number | undefined
+ if (avatarUrl) {
+ for (const tag of event.tags) {
+ const info = getImetaInfoFromImetaTag(tag)
+ if (info && info.url === avatarUrl && info.size != null) {
+ pictureSize = info.size
+ break
+ }
+ }
+ }
+
return {
pubkey: event.pubkey,
npub: pubkeyToNpub(event.pubkey) ?? '',
banner: profileObj.banner,
- avatar: profileObj.picture,
+ avatar: avatarUrl,
+ pictureSize,
username: username || formatPubkey(event.pubkey),
original_username: username,
nip05,
diff --git a/src/types/index.d.ts b/src/types/index.d.ts
index 23af404c..cbd3a080 100644
--- a/src/types/index.d.ts
+++ b/src/types/index.d.ts
@@ -22,6 +22,8 @@ export type TProfile = {
original_username?: string
banner?: string
avatar?: string
+ /** File size of the profile picture in bytes, sourced from a matching imeta tag in the kind-0 event. */
+ pictureSize?: number
nip05?: string
nip05List?: string[]
about?: string