diff --git a/src/app/components/user-avatar/UserAvatar.css.ts b/src/app/components/user-avatar/UserAvatar.css.ts index 34283d73..f93983cb 100644 --- a/src/app/components/user-avatar/UserAvatar.css.ts +++ b/src/app/components/user-avatar/UserAvatar.css.ts @@ -5,6 +5,12 @@ export const UserAvatar = style({ backgroundColor: color.Secondary.Container, color: color.Secondary.OnContainer, textTransform: 'capitalize', + // Centre-crop to fill the (always-circular) avatar box, so a `scale` + // thumbnail (aspect-preserved — e.g. the 320 scale the profile hero requests + // because Synapse caps `crop` thumbnails at 96px) covers the circle exactly + // like a server-side crop would. No-op for the square crop thumbnails used + // by the small avatars elsewhere. + objectFit: 'cover', selectors: { '&[data-image-loaded="true"]': { diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 899ecd1e..12b6c700 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -49,10 +49,7 @@ const shadeHex = (hex: string, amt: number): string => { // `Intl.RelativeTimeFormat` here because it follows the *browser* // locale, not the i18next-selected language; mixing those gives us // English «5 minutes ago» under a Russian UI. -const formatLastSeen = ( - ts: number, - t: ReturnType['t'] -): string => { +const formatLastSeen = (ts: number, t: ReturnType['t']): string => { const now = Date.now(); const diffMs = Math.max(0, now - ts); const mins = Math.floor(diffMs / 60_000); @@ -71,7 +68,13 @@ const formatLastSeen = ( type UserHeroProps = { userId: string; displayName?: string; + // Small (96px) cropped thumbnail for the collapsed hero — fast to fetch. avatarUrl?: string; + // Full-resolution avatar used ONLY in the expanded (~340px) state below, so + // the blown-up tile stays sharp instead of upscaling the 96px thumbnail. + // Mirrors RoomViewProfilePanel's full-res mobile fullview. Falls back to + // `avatarUrl` when absent. + avatarUrlExpanded?: string; presence?: UserPresence; encrypted?: boolean; onAvatarClick?: () => void; @@ -87,6 +90,7 @@ export function UserHero({ userId, displayName, avatarUrl, + avatarUrlExpanded, presence, encrypted, onAvatarClick, @@ -137,7 +141,7 @@ export function UserHero({ > ( @@ -159,10 +163,9 @@ export function UserHero({ css.HeroAvatarButton, avatarExpanded && css.HeroAvatarButtonExpanded )} - aria-label={t( - avatarExpanded ? 'Room.collapse_avatar' : 'Room.expand_avatar', - { defaultValue: avatarExpanded ? 'Close avatar' : 'Open avatar' } - )} + aria-label={t(avatarExpanded ? 'Room.collapse_avatar' : 'Room.expand_avatar', { + defaultValue: avatarExpanded ? 'Close avatar' : 'Open avatar', + })} > {avatarNode} diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 02a95ca0..ff301764 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -53,11 +53,7 @@ type UserRoomProfileProps = { avatarExpanded?: boolean; }; -export function UserRoomProfile({ - userId, - onAvatarClick, - avatarExpanded, -}: UserRoomProfileProps) { +export function UserRoomProfile({ userId, onAvatarClick, avatarExpanded }: UserRoomProfileProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const navigate = useNavigate(); @@ -86,7 +82,23 @@ export function UserRoomProfile({ const displayName = getMemberDisplayName(room, userId); const avatarMxc = getMemberAvatarMxc(room, userId); - const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; + // The collapsed HeroAvatar renders at 96px CSS (user-profile/styles.css.ts). + // A `crop` thumbnail can't fix retina sharpness here: Synapse only + // pre-generates 32px and 96px CROP thumbnails by default, so any `crop` + // request > 96 just returns the 96px one — upscaled and pixelated on a 2–3× + // display. `scale` thumbnails go up to 320/640/800, so we request a 320 scale: + // sharp on retina yet ~10× smaller than the full-resolution original (which + // the SDK returns when no dimensions are passed, and which made the hero + // visibly progressive-load on open). `UserAvatar` forces `object-fit: cover`, + // so a non-square scale thumbnail still fills the circle. The full-res + // original is reserved for the expanded tile + tap-to-zoom fullview below. + const avatarUrl = + (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication, 320, 320, 'scale')) ?? undefined; + // Full-resolution original for the desktop side-pane expanded (~340px) tile — + // a 96px thumbnail would upscale and pixelate when blown up. Only fetched + // when the avatar is expanded (UserHero swaps to this src then). + const avatarUrlExpanded = + (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; // Pass the raw SDK presence through. `UserHero` already guards on // `lastActiveTs > 0` before formatting the last-seen line, so the @@ -144,6 +156,7 @@ export function UserRoomProfile({ userId={userId} displayName={displayName} avatarUrl={avatarUrl} + avatarUrlExpanded={avatarUrlExpanded} presence={presence} encrypted={encrypted} onAvatarClick={onAvatarClick}