fix(profile): load the hero avatar as a sharp scale thumbnail and keep full-res only for the expanded view

This commit is contained in:
heaven 2026-05-29 02:36:28 +03:00
parent bfe2f89a28
commit 3f76336e57
3 changed files with 37 additions and 15 deletions

View file

@ -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"]': {

View file

@ -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<typeof useTranslation>['t']
): string => {
const formatLastSeen = (ts: number, t: ReturnType<typeof useTranslation>['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({
>
<UserAvatar
userId={userId}
src={avatarUrl}
src={avatarExpanded ? avatarUrlExpanded ?? avatarUrl : avatarUrl}
alt={displayName ?? userId}
renderFallback={() => (
<span className={css.HeroAvatarFallback} style={{ backgroundImage: fallbackBg }}>
@ -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}
</button>

View file

@ -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 23×
// 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}