Browse Source

more performance adjustments

imwald
Silberengel 3 weeks ago
parent
commit
bdb2e987b5
  1. 4
      src/components/Note/index.tsx
  2. 2
      src/components/ReplyNote/index.tsx
  3. 113
      src/components/UserAvatar/index.tsx
  4. 21
      src/lib/event-metadata.ts
  5. 2
      src/types/index.d.ts

4
src/components/Note/index.tsx

@ -433,7 +433,7 @@ export default function Note({ @@ -433,7 +433,7 @@ export default function Note({
) : (
<ReactionEmojiDisplay event={event} />
)}
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'medium' : 'normal'} />
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'medium' : 'normal'} maxFileSizeKb={500} />
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-2 overflow-hidden">
<Username
userId={event.pubkey}
@ -480,7 +480,7 @@ export default function Note({ @@ -480,7 +480,7 @@ export default function Note({
</>
) : (
<>
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'medium' : 'normal'} />
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'medium' : 'normal'} maxFileSizeKb={500} />
<div className="flex-1 w-0">
<div className="flex gap-2 items-center">
<Username

2
src/components/ReplyNote/index.tsx

@ -103,7 +103,7 @@ export default function ReplyNote({ @@ -103,7 +103,7 @@ export default function ReplyNote({
>
<Collapsible>
<div className="flex space-x-2 items-start px-4 pt-3">
<UserAvatar userId={headerUserId} size="medium" className="shrink-0 mt-0.5" />
<UserAvatar userId={headerUserId} size="medium" className="shrink-0 mt-0.5" maxFileSizeKb={500} />
<div className="w-full overflow-hidden">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 w-0">

113
src/components/UserAvatar/index.tsx

@ -13,14 +13,45 @@ function isHttpOrHttpsUrl(url: string): boolean { @@ -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<string, number | null>()
/**
* 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<string>()
/**
* 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<number | null> {
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<HTMLDivElement | null>
containerRef: RefObject<HTMLDivElement | null>,
maxFileSizeBytes?: number
): string {
const remoteHttp = useMemo(() => {
const a = profileAvatar?.trim()
@ -30,17 +61,39 @@ function useDeferRemoteProfileAvatar( @@ -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( @@ -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({ @@ -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({ @@ -111,7 +172,24 @@ export default function UserAvatar({
)
const containerRef = useRef<HTMLDivElement>(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({ @@ -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({ @@ -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({ @@ -212,7 +293,22 @@ export function SimpleUserAvatar({
)
const containerRef = useRef<HTMLDivElement>(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({ @@ -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

21
src/lib/event-metadata.ts

@ -5,7 +5,7 @@ import { buildATag } from './draft-event' @@ -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) { @@ -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,

2
src/types/index.d.ts vendored

@ -22,6 +22,8 @@ export type TProfile = { @@ -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

Loading…
Cancel
Save