feat(profile): inline-expand hero avatar in desktop side pane instead of swapping the whole card to full-view

This commit is contained in:
heaven 2026-05-11 01:11:09 +03:00
parent 9c204c1af6
commit 626a7c2d1d
7 changed files with 74 additions and 82 deletions

View file

@ -426,6 +426,7 @@
"Room": {
"drag_to_close": "Drag up to close",
"collapse_avatar": "Collapse avatar",
"expand_avatar": "Open avatar",
"new_messages": "New Messages",
"jump_to_unread": "Jump to Unread",
"mark_as_read": "Mark as Read",

View file

@ -428,6 +428,7 @@
"Room": {
"drag_to_close": "Потянуть вверх чтобы закрыть",
"collapse_avatar": "Свернуть аватар",
"expand_avatar": "Развернуть аватар",
"new_messages": "Новые сообщения",
"jump_to_unread": "К непрочитанным",
"mark_as_read": "Отметить прочитанным",

View file

@ -75,6 +75,12 @@ type UserHeroProps = {
presence?: UserPresence;
encrypted?: boolean;
onAvatarClick?: () => void;
// Desktop side-pane only. When true the avatar stretches to fill
// the card width as a square; the rest of the hero (name / presence
// / e2ee chip) stays in flow below. The online dot is suppressed in
// this mode — it makes no sense as a tiny corner glyph on a
// ~340px avatar tile.
avatarExpanded?: boolean;
};
export function UserHero({
@ -84,6 +90,7 @@ export function UserHero({
presence,
encrypted,
onAvatarClick,
avatarExpanded,
}: UserHeroProps) {
const { t } = useTranslation();
const username = getMxIdLocalPart(userId);
@ -124,7 +131,10 @@ export function UserHero({
const fallbackBg = `linear-gradient(135deg, ${fallbackColor}, ${shadeHex(fallbackColor, -22)})`;
const avatarNode = (
<span className={css.HeroAvatar} aria-hidden={onAvatarClick ? undefined : true}>
<span
className={classNames(css.HeroAvatar, avatarExpanded && css.HeroAvatarExpanded)}
aria-hidden={onAvatarClick ? undefined : true}
>
<UserAvatar
userId={userId}
src={avatarUrl}
@ -135,7 +145,7 @@ export function UserHero({
</span>
)}
/>
{online && <span className={css.HeroOnlineDot} aria-hidden />}
{online && !avatarExpanded && <span className={css.HeroOnlineDot} aria-hidden />}
</span>
);
@ -145,8 +155,14 @@ export function UserHero({
<button
type="button"
onClick={onAvatarClick}
className={css.HeroAvatarButton}
aria-label={t('Room.expand_avatar', { defaultValue: 'Open avatar' })}
className={classNames(
css.HeroAvatarButton,
avatarExpanded && css.HeroAvatarButtonExpanded
)}
aria-label={t(
avatarExpanded ? 'Room.collapse_avatar' : 'Room.expand_avatar',
{ defaultValue: avatarExpanded ? 'Close avatar' : 'Open avatar' }
)}
>
{avatarNode}
</button>

View file

@ -45,9 +45,19 @@ import * as css from './styles.css';
type UserRoomProfileProps = {
userId: string;
onAvatarClick?: () => void;
// Desktop side-pane only — forwarded straight to `UserHero` so the
// hero avatar can render in its inline-expanded mode while the
// rest of the card stays in flow. Mobile leaves this `undefined`
// and keeps using the full-rail avatar swap inside
// `RoomViewProfilePanel`.
avatarExpanded?: boolean;
};
export function UserRoomProfile({ userId, onAvatarClick }: UserRoomProfileProps) {
export function UserRoomProfile({
userId,
onAvatarClick,
avatarExpanded,
}: UserRoomProfileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const navigate = useNavigate();
@ -137,6 +147,7 @@ export function UserRoomProfile({ userId, onAvatarClick }: UserRoomProfileProps)
presence={presence}
encrypted={encrypted}
onAvatarClick={onAvatarClick}
avatarExpanded={avatarExpanded}
/>
<div className={css.InfoSection}>

View file

@ -86,6 +86,29 @@ export const HeroAvatarButton = style({
cursor: 'pointer',
});
// Desktop side-pane expanded avatar. Clicking the small hero avatar
// in the right-side profile pane flips this on: the wrapper
// stretches to the card width, squares up via `aspect-ratio` so the
// server-side 1:1 avatar shows full-bleed without cropping, and the
// rest of the card stays in flow below. Replaces the legacy full-pane
// avatar swap which hid the name / presence / info rows entirely.
// `border-radius: !important` is mandatory: the global override in
// `components/user-avatar/UserAvatar.css.ts` uses `!important` to
// force every avatar parent to 50%, so without matching specificity
// the expanded square would render as a huge circle.
export const HeroAvatarExpanded = style({
width: '100%',
height: 'auto',
aspectRatio: '1 / 1',
borderRadius: `${toRem(16)} !important`,
boxShadow: 'none',
});
export const HeroAvatarButtonExpanded = style({
display: 'flex',
width: '100%',
});
export const HeroIdentity = style({
width: '100%',
});

View file

@ -39,32 +39,3 @@ export const scrollWrap = style({
},
});
export const avatarFullView = style({
flex: 1,
minHeight: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
background: 'transparent',
border: 'none',
padding: 0,
});
export const avatarFullImage = style({
width: '100%',
height: '100%',
objectFit: 'cover',
});
export const avatarFullFallback = style({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: toRem(96),
fontWeight: 600,
color: color.Surface.Container,
textTransform: 'uppercase',
});

View file

@ -14,13 +14,8 @@ import { useTranslation } from 'react-i18next';
import { userRoomProfileAtom } from '../../state/userRoomProfile';
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useRoom } from '../../hooks/useRoom';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { getMemberAvatarMxc } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { UserRoomProfile } from '../../components/user-profile/UserRoomProfile';
import { stopPropagation } from '../../utils/keyboard';
import colorMXID from '../../../util/colorMXID';
import { PageHeader } from '../../components/page';
import { ContainerColor } from '../../styles/ContainerColor.css';
import * as css from './RoomViewProfileSidePanel.css';
@ -30,11 +25,16 @@ export function RoomViewProfileSidePanel() {
const profileState = useAtomValue(userRoomProfileAtom);
const close = useCloseUserRoomProfile();
const room = useRoom();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const open = !!profileState;
const [avatarMode, setAvatarMode] = useState(false);
// Inline avatar-expanded mode: the hero avatar stretches to fill
// the card width as a square, pushing the rest of the card down in
// the scroll container. Replaces the legacy full-pane image swap
// which hid the name / presence / info rows. Mobile keeps the
// full-rail swap inside `RoomViewProfilePanel` — the rail there is
// short enough that pushing content down would just create a
// scroll well.
const [avatarExpanded, setAvatarExpanded] = useState(false);
// Close profile when the room changes — atom is global state and
// would otherwise carry the previous room's userId into this room
@ -44,20 +44,13 @@ export function RoomViewProfileSidePanel() {
// Reset avatar-zoom mode whenever the rendered user changes.
useEffect(() => {
setAvatarMode(false);
setAvatarExpanded(false);
}, [profileState?.userId]);
const profileStateRef = useRef(profileState);
profileStateRef.current = profileState;
const renderUserId = profileState?.userId;
const renderUserAvatarMxc = renderUserId
? getMemberAvatarMxc(room, renderUserId)
: undefined;
const renderUserAvatarUrl =
(renderUserAvatarMxc &&
mxcUrlToHttp(mx, renderUserAvatarMxc, useAuthentication, 720, 720, 'scale')) ??
undefined;
if (!open || !renderUserId) return null;
@ -96,39 +89,15 @@ export function RoomViewProfileSidePanel() {
</Box>
</PageHeader>
{avatarMode ? (
<button
type="button"
className={css.avatarFullView}
onClick={() => setAvatarMode(false)}
aria-label={t('Room.collapse_avatar')}
>
{renderUserAvatarUrl ? (
<img
className={css.avatarFullImage}
src={renderUserAvatarUrl}
alt={renderUserId}
draggable={false}
/>
) : (
<div
className={css.avatarFullFallback}
style={{ backgroundColor: colorMXID(renderUserId) }}
>
{getMxIdLocalPart(renderUserId)?.[0] ?? '?'}
</div>
)}
</button>
) : (
<div className={css.scrollWrap}>
<div style={{ padding: config.space.S400 }}>
<UserRoomProfile
userId={renderUserId}
onAvatarClick={() => setAvatarMode(true)}
/>
</div>
<div className={css.scrollWrap}>
<div style={{ padding: config.space.S400 }}>
<UserRoomProfile
userId={renderUserId}
onAvatarClick={() => setAvatarExpanded((prev) => !prev)}
avatarExpanded={avatarExpanded}
/>
</div>
)}
</div>
</div>
</FocusTrap>
);