feat(profile): inline-expand hero avatar in desktop side pane instead of swapping the whole card to full-view
This commit is contained in:
parent
9c204c1af6
commit
626a7c2d1d
7 changed files with 74 additions and 82 deletions
|
|
@ -426,6 +426,7 @@
|
||||||
"Room": {
|
"Room": {
|
||||||
"drag_to_close": "Drag up to close",
|
"drag_to_close": "Drag up to close",
|
||||||
"collapse_avatar": "Collapse avatar",
|
"collapse_avatar": "Collapse avatar",
|
||||||
|
"expand_avatar": "Open avatar",
|
||||||
"new_messages": "New Messages",
|
"new_messages": "New Messages",
|
||||||
"jump_to_unread": "Jump to Unread",
|
"jump_to_unread": "Jump to Unread",
|
||||||
"mark_as_read": "Mark as Read",
|
"mark_as_read": "Mark as Read",
|
||||||
|
|
|
||||||
|
|
@ -428,6 +428,7 @@
|
||||||
"Room": {
|
"Room": {
|
||||||
"drag_to_close": "Потянуть вверх чтобы закрыть",
|
"drag_to_close": "Потянуть вверх чтобы закрыть",
|
||||||
"collapse_avatar": "Свернуть аватар",
|
"collapse_avatar": "Свернуть аватар",
|
||||||
|
"expand_avatar": "Развернуть аватар",
|
||||||
"new_messages": "Новые сообщения",
|
"new_messages": "Новые сообщения",
|
||||||
"jump_to_unread": "К непрочитанным",
|
"jump_to_unread": "К непрочитанным",
|
||||||
"mark_as_read": "Отметить прочитанным",
|
"mark_as_read": "Отметить прочитанным",
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,12 @@ type UserHeroProps = {
|
||||||
presence?: UserPresence;
|
presence?: UserPresence;
|
||||||
encrypted?: boolean;
|
encrypted?: boolean;
|
||||||
onAvatarClick?: () => void;
|
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({
|
export function UserHero({
|
||||||
|
|
@ -84,6 +90,7 @@ export function UserHero({
|
||||||
presence,
|
presence,
|
||||||
encrypted,
|
encrypted,
|
||||||
onAvatarClick,
|
onAvatarClick,
|
||||||
|
avatarExpanded,
|
||||||
}: UserHeroProps) {
|
}: UserHeroProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const username = getMxIdLocalPart(userId);
|
const username = getMxIdLocalPart(userId);
|
||||||
|
|
@ -124,7 +131,10 @@ export function UserHero({
|
||||||
const fallbackBg = `linear-gradient(135deg, ${fallbackColor}, ${shadeHex(fallbackColor, -22)})`;
|
const fallbackBg = `linear-gradient(135deg, ${fallbackColor}, ${shadeHex(fallbackColor, -22)})`;
|
||||||
|
|
||||||
const avatarNode = (
|
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
|
<UserAvatar
|
||||||
userId={userId}
|
userId={userId}
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
|
|
@ -135,7 +145,7 @@ export function UserHero({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{online && <span className={css.HeroOnlineDot} aria-hidden />}
|
{online && !avatarExpanded && <span className={css.HeroOnlineDot} aria-hidden />}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -145,8 +155,14 @@ export function UserHero({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAvatarClick}
|
onClick={onAvatarClick}
|
||||||
className={css.HeroAvatarButton}
|
className={classNames(
|
||||||
aria-label={t('Room.expand_avatar', { defaultValue: 'Open avatar' })}
|
css.HeroAvatarButton,
|
||||||
|
avatarExpanded && css.HeroAvatarButtonExpanded
|
||||||
|
)}
|
||||||
|
aria-label={t(
|
||||||
|
avatarExpanded ? 'Room.collapse_avatar' : 'Room.expand_avatar',
|
||||||
|
{ defaultValue: avatarExpanded ? 'Close avatar' : 'Open avatar' }
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{avatarNode}
|
{avatarNode}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,19 @@ import * as css from './styles.css';
|
||||||
type UserRoomProfileProps = {
|
type UserRoomProfileProps = {
|
||||||
userId: string;
|
userId: string;
|
||||||
onAvatarClick?: () => void;
|
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 mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -137,6 +147,7 @@ export function UserRoomProfile({ userId, onAvatarClick }: UserRoomProfileProps)
|
||||||
presence={presence}
|
presence={presence}
|
||||||
encrypted={encrypted}
|
encrypted={encrypted}
|
||||||
onAvatarClick={onAvatarClick}
|
onAvatarClick={onAvatarClick}
|
||||||
|
avatarExpanded={avatarExpanded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={css.InfoSection}>
|
<div className={css.InfoSection}>
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,29 @@ export const HeroAvatarButton = style({
|
||||||
cursor: 'pointer',
|
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({
|
export const HeroIdentity = style({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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',
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,8 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { userRoomProfileAtom } from '../../state/userRoomProfile';
|
import { userRoomProfileAtom } from '../../state/userRoomProfile';
|
||||||
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
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 { UserRoomProfile } from '../../components/user-profile/UserRoomProfile';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
|
||||||
import { PageHeader } from '../../components/page';
|
import { PageHeader } from '../../components/page';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import * as css from './RoomViewProfileSidePanel.css';
|
import * as css from './RoomViewProfileSidePanel.css';
|
||||||
|
|
@ -30,11 +25,16 @@ export function RoomViewProfileSidePanel() {
|
||||||
const profileState = useAtomValue(userRoomProfileAtom);
|
const profileState = useAtomValue(userRoomProfileAtom);
|
||||||
const close = useCloseUserRoomProfile();
|
const close = useCloseUserRoomProfile();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const mx = useMatrixClient();
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
|
|
||||||
const open = !!profileState;
|
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
|
// Close profile when the room changes — atom is global state and
|
||||||
// would otherwise carry the previous room's userId into this room
|
// 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.
|
// Reset avatar-zoom mode whenever the rendered user changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAvatarMode(false);
|
setAvatarExpanded(false);
|
||||||
}, [profileState?.userId]);
|
}, [profileState?.userId]);
|
||||||
|
|
||||||
const profileStateRef = useRef(profileState);
|
const profileStateRef = useRef(profileState);
|
||||||
profileStateRef.current = profileState;
|
profileStateRef.current = profileState;
|
||||||
|
|
||||||
const renderUserId = profileState?.userId;
|
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;
|
if (!open || !renderUserId) return null;
|
||||||
|
|
||||||
|
|
@ -96,39 +89,15 @@ export function RoomViewProfileSidePanel() {
|
||||||
</Box>
|
</Box>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{avatarMode ? (
|
<div className={css.scrollWrap}>
|
||||||
<button
|
<div style={{ padding: config.space.S400 }}>
|
||||||
type="button"
|
<UserRoomProfile
|
||||||
className={css.avatarFullView}
|
userId={renderUserId}
|
||||||
onClick={() => setAvatarMode(false)}
|
onAvatarClick={() => setAvatarExpanded((prev) => !prev)}
|
||||||
aria-label={t('Room.collapse_avatar')}
|
avatarExpanded={avatarExpanded}
|
||||||
>
|
/>
|
||||||
{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>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue