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:
parent
bfe2f89a28
commit
3f76336e57
3 changed files with 37 additions and 15 deletions
|
|
@ -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"]': {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue