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, backgroundColor: color.Secondary.Container,
color: color.Secondary.OnContainer, color: color.Secondary.OnContainer,
textTransform: 'capitalize', 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: { selectors: {
'&[data-image-loaded="true"]': { '&[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* // `Intl.RelativeTimeFormat` here because it follows the *browser*
// locale, not the i18next-selected language; mixing those gives us // locale, not the i18next-selected language; mixing those gives us
// English «5 minutes ago» under a Russian UI. // English «5 minutes ago» under a Russian UI.
const formatLastSeen = ( const formatLastSeen = (ts: number, t: ReturnType<typeof useTranslation>['t']): string => {
ts: number,
t: ReturnType<typeof useTranslation>['t']
): string => {
const now = Date.now(); const now = Date.now();
const diffMs = Math.max(0, now - ts); const diffMs = Math.max(0, now - ts);
const mins = Math.floor(diffMs / 60_000); const mins = Math.floor(diffMs / 60_000);
@ -71,7 +68,13 @@ const formatLastSeen = (
type UserHeroProps = { type UserHeroProps = {
userId: string; userId: string;
displayName?: string; displayName?: string;
// Small (96px) cropped thumbnail for the collapsed hero — fast to fetch.
avatarUrl?: string; 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; presence?: UserPresence;
encrypted?: boolean; encrypted?: boolean;
onAvatarClick?: () => void; onAvatarClick?: () => void;
@ -87,6 +90,7 @@ export function UserHero({
userId, userId,
displayName, displayName,
avatarUrl, avatarUrl,
avatarUrlExpanded,
presence, presence,
encrypted, encrypted,
onAvatarClick, onAvatarClick,
@ -137,7 +141,7 @@ export function UserHero({
> >
<UserAvatar <UserAvatar
userId={userId} userId={userId}
src={avatarUrl} src={avatarExpanded ? avatarUrlExpanded ?? avatarUrl : avatarUrl}
alt={displayName ?? userId} alt={displayName ?? userId}
renderFallback={() => ( renderFallback={() => (
<span className={css.HeroAvatarFallback} style={{ backgroundImage: fallbackBg }}> <span className={css.HeroAvatarFallback} style={{ backgroundImage: fallbackBg }}>
@ -159,10 +163,9 @@ export function UserHero({
css.HeroAvatarButton, css.HeroAvatarButton,
avatarExpanded && css.HeroAvatarButtonExpanded avatarExpanded && css.HeroAvatarButtonExpanded
)} )}
aria-label={t( aria-label={t(avatarExpanded ? 'Room.collapse_avatar' : 'Room.expand_avatar', {
avatarExpanded ? 'Room.collapse_avatar' : 'Room.expand_avatar', defaultValue: avatarExpanded ? 'Close avatar' : 'Open avatar',
{ defaultValue: avatarExpanded ? 'Close avatar' : 'Open avatar' } })}
)}
> >
{avatarNode} {avatarNode}
</button> </button>

View file

@ -53,11 +53,7 @@ type UserRoomProfileProps = {
avatarExpanded?: boolean; avatarExpanded?: boolean;
}; };
export function UserRoomProfile({ export function UserRoomProfile({ userId, onAvatarClick, avatarExpanded }: UserRoomProfileProps) {
userId,
onAvatarClick,
avatarExpanded,
}: UserRoomProfileProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const navigate = useNavigate(); const navigate = useNavigate();
@ -86,7 +82,23 @@ export function UserRoomProfile({
const displayName = getMemberDisplayName(room, userId); const displayName = getMemberDisplayName(room, userId);
const avatarMxc = getMemberAvatarMxc(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 // Pass the raw SDK presence through. `UserHero` already guards on
// `lastActiveTs > 0` before formatting the last-seen line, so the // `lastActiveTs > 0` before formatting the last-seen line, so the
@ -144,6 +156,7 @@ export function UserRoomProfile({
userId={userId} userId={userId}
displayName={displayName} displayName={displayName}
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
avatarUrlExpanded={avatarUrlExpanded}
presence={presence} presence={presence}
encrypted={encrypted} encrypted={encrypted}
onAvatarClick={onAvatarClick} onAvatarClick={onAvatarClick}