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,
|
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"]': {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 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
|
// 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}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue