feat(members): replace MembersDrawer with Dawn-styled members sheet and group hero for every non-1:1 room and channel

This commit is contained in:
heaven 2026-05-28 20:51:37 +03:00
parent 1665cb185f
commit aa3dbc13ef
17 changed files with 1682 additions and 156 deletions

View file

@ -484,6 +484,10 @@
"members_count_other": "{{formattedCount}} Members", "members_count_other": "{{formattedCount}} Members",
"hide_members": "Hide Members", "hide_members": "Hide Members",
"show_members": "Show Members", "show_members": "Show Members",
"members_pane_title": "Members",
"members_sheet_title_one": "{{formattedCount}} member",
"members_sheet_title_other": "{{formattedCount}} members",
"open_members_of": "Open members of {{name}}",
"more_options": "More Options", "more_options": "More Options",
"close": "Close", "close": "Close",
"search": "Search", "search": "Search",

View file

@ -492,6 +492,12 @@
"members_count_other": "{{formattedCount}} участника", "members_count_other": "{{formattedCount}} участника",
"hide_members": "Скрыть участников", "hide_members": "Скрыть участников",
"show_members": "Показать участников", "show_members": "Показать участников",
"members_pane_title": "Участники",
"members_sheet_title_one": "{{formattedCount}} участник",
"members_sheet_title_few": "{{formattedCount}} участника",
"members_sheet_title_many": "{{formattedCount}} участников",
"members_sheet_title_other": "{{formattedCount}} участника",
"open_members_of": "Открыть участников: {{name}}",
"more_options": "Ещё", "more_options": "Ещё",
"close": "Закрыть", "close": "Закрыть",
"search": "Поиск", "search": "Поиск",

View file

@ -0,0 +1,199 @@
// Dawn-styled members sheet body. Renders a centred room hero
// (avatar + name + count + e2ee + optional topic) and a flat
// power-tag-grouped list of joined members. Mirrors the visual
// language of the 1:1 peer-profile sheet (`UserHero` + info table)
// so the two sheets read as one design system.
//
// No internal scroll container — the host (mobile horseshoe or
// desktop side pane) wraps this in its own scroll surface. That
// lets the host measure the natural content height for content-fit
// rail sizing.
//
// Tap on a row opens the per-user profile via `useOpenUserRoomProfile`.
// Atom mutual-exclusion (`useOpenUserRoomProfile` clears the members
// atom) routes the transition cleanly: on desktop the right-pane
// content swaps; on mobile group the same horseshoe silhouette
// switches body (handled in `RoomViewMembersPanel`).
//
// Search / filter / sort were intentionally not ported — product asked
// for a clean grouped list first. The legacy
// `features/room/MembersDrawer.tsx` keeps those affordances and is
// still used by `features/lobby/Lobby.tsx` for space lobbies.
import React, { useMemo } from 'react';
import { Avatar, Icon, Icons, Text } from 'folds';
import { Room, RoomMember } from 'matrix-js-sdk';
import classNames from 'classnames';
import { TypingIndicator } from '../typing-indicator';
import { UserAvatar } from '../user-avatar';
import { Membership, MemberPowerTag } from '../../../types/matrix/room';
import { GetMemberPowerTag, useFlattenPowerTagMembers } from '../../hooks/useMemberPowerTag';
import { useMemberPowerSort } from '../../hooks/useMemberSort';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { useGetMemberPowerLevel, usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { RoomMembersHero } from './RoomMembersHero';
import * as css from './styles.css';
type MembersListProps = {
room: Room;
members: RoomMember[];
getPowerTag: GetMemberPowerTag;
// Forwarded to `RoomMembersHero` — tap on hero avatar opens
// full-view. Only the mobile horseshoe wires this; the desktop
// side pane doesn't get an avatar-zoom path (the right-pane chrome
// is fixed-width so there's no «expand to silhouette»).
onHeroAvatarClick?: () => void;
};
type MemberRowProps = {
room: Room;
member: RoomMember;
typing: boolean;
onOpenProfile: (userId: string, anchor: HTMLButtonElement) => void;
};
function MemberRow({ room, member, typing, onOpenProfile }: MemberRowProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const name =
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
// First-name display per design: only what fits before the first space
// (or the whole string if no space). Keeps each row narrow against the
// ~240 px panel without horizontal ellipsis kicking in immediately.
const shortName = name.split(/\s+/)[0] ?? name;
const avatarMxcUrl = member.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(avatarMxcUrl, 64, 64, 'crop', undefined, false, useAuthentication)
: undefined;
return (
<button
type="button"
className={css.MemberRow}
onClick={(evt) => onOpenProfile(member.userId, evt.currentTarget)}
>
<Avatar size="200" className={css.MemberAvatar}>
<UserAvatar
userId={member.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
<div className={css.MemberMainCol}>
<div className={css.MemberNameRow}>
<Text as="span" size="T300" truncate className={css.MemberName}>
{shortName}
</Text>
</div>
{typing && (
<span className={css.MemberTyping}>
<TypingIndicator size="300" />
</span>
)}
</div>
</button>
);
}
export function MembersList({ room, members, getPowerTag, onHeroAvatarClick }: MembersListProps) {
const openUserRoomProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
const typingMembers = useRoomTypingMember(room.roomId);
// Filter + sort BEFORE flatten — `useFlattenPowerTagMembers` emits a
// new group header whenever the current member's tag differs from the
// previous one's tag. Feeding it an unsorted list produces duplicate
// role headers ("admin · 1, member · 5, admin · 1") and the row
// counts go wrong. Mirrors the legacy `MembersDrawer.tsx::filteredMembers`
// pipeline (filter joined-only → sort by power).
//
// Joined-only matches the design canon's "Участники · 28" — banned /
// kicked / left memberships have their own surfaces in moderation
// settings, not in the members sheet.
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const getPowerLevel = useGetMemberPowerLevel(powerLevels);
const memberPowerSort = useMemberPowerSort(creators, getPowerLevel);
const processedMembers = useMemo(
() => members.filter((m) => m.membership === Membership.Join).sort(memberPowerSort),
[members, memberPowerSort]
);
const flat = useFlattenPowerTagMembers(processedMembers, getPowerTag);
// Index of the first tag entry in the flat list — used to drop the
// top padding on the first section header so it lines up tight with
// the rule above (the List's top border).
const firstTagIndex = flat.findIndex((entry) => !('userId' in entry));
// Per-tag member count for the section header — cheap (single pass)
// and lets the design's "admin · 1 / mod · 1 / участники · 22" labels
// read truthfully.
const tagCounts = useMemo(() => {
const counts = new Map<MemberPowerTag, number>();
flat.forEach((entry) => {
if ('userId' in entry) return;
counts.set(entry, 0);
});
let currentTag: MemberPowerTag | undefined;
flat.forEach((entry) => {
if (!('userId' in entry)) {
currentTag = entry;
return;
}
if (currentTag) counts.set(currentTag, (counts.get(currentTag) ?? 0) + 1);
});
return counts;
}, [flat]);
const typingByUser = useMemo(() => {
const set = new Set<string>();
typingMembers.forEach((receipt) => set.add(receipt.userId));
return set;
}, [typingMembers]);
const handleOpenProfile = (userId: string, anchor: HTMLButtonElement) => {
openUserRoomProfile(room.roomId, space?.roomId, userId, anchor.getBoundingClientRect(), 'Left');
};
return (
<div className={css.Root}>
<RoomMembersHero room={room} onAvatarClick={onHeroAvatarClick} />
<div className={css.List}>
{flat.map((entry, idx) => {
if (!('userId' in entry)) {
const count = tagCounts.get(entry) ?? 0;
return (
<div
key={`tag-${entry.name}`}
className={classNames(css.GroupLabel, idx === firstTagIndex && css.GroupLabelFirst)}
>
<Text as="span" size="T200" className={css.GroupLabelText}>
{entry.name}
</Text>
<Text as="span" size="T200" className={css.GroupLabelCount}>
{count}
</Text>
</div>
);
}
return (
<MemberRow
key={`user-${entry.userId}`}
room={room}
member={entry}
typing={typingByUser.has(entry.userId)}
onOpenProfile={handleOpenProfile}
/>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,131 @@
// Centred hero block on top of the group-room members sheet —
// counterpart of `UserHero` from the 1:1 peer-profile sheet.
// Renders: large gradient room avatar, room name, subline («N
// members · e2ee») and optional topic clamped to a few lines.
// Consumed by both the mobile horseshoe (`RoomViewMembersPanel`)
// and the desktop right-side pane (`RoomViewMembersSidePanel`)
// via `MembersList`.
import React from 'react';
import { Box, Icon, Icons, Text } from 'folds';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Room } from 'matrix-js-sdk';
import { RoomAvatar, RoomIcon } from '../room-avatar';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
import { useRoomMemberCount } from '../../hooks/useRoomMemberCount';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { mxcUrlToHttp } from '../../utils/matrix';
import { millify } from '../../plugins/millify';
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
import * as css from './styles.css';
type RoomMembersHeroProps = {
room: Room;
// Optional avatar-tap handler. When provided the avatar surface
// becomes a button (one tab stop, focus ring). The host decides
// what tap does — typically swap the panel body to a full-view of
// the avatar, mirroring the 1:1 profile-horseshoe behaviour.
onAvatarClick?: () => void;
};
export function RoomMembersHero({ room, onAvatarClick }: RoomMembersHeroProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
// Group sheet: use the room's own avatar, NOT the peer fallback —
// the 1:1 peer-fallback path lives in `RoomViewHeaderDm` for the
// chat header, and here we're showing the room itself.
//
// No width/height passed to `mxcUrlToHttp` — same as `UserRoomProfile`
// does for the 1:1 hero. Synapse returns the original upload, the
// browser scales it via CSS without resampling artefacts. Asking for
// a small thumbnail (e.g. 192x192) routes through Synapse's JPEG
// recompression pipeline and ships visibly «pixelated» avatars on
// high-DPR phones (96 CSS px = 192288 native px on 2x/3x screens,
// so the thumb is right at the edge before the recompression hits).
const avatarMxc = useRoomAvatar(room, false);
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
const name = useRoomName(room);
const topic = useRoomTopic(room);
const memberCount = useRoomMemberCount(room);
const encrypted = !!useStateEvent(room, StateEvent.RoomEncryption);
// Drop the folds `<Avatar size="…">` wrapper here on purpose —
// `RoomAvatar` already renders the folds `AvatarImage` / `AvatarFallback`
// primitives with our `.RoomAvatar` class that fills the parent
// (`width: 100% / height: 100%`). Wrapping it in another sized
// `<Avatar>` double-wraps the surface and the inner avatar ends up
// smaller than its container, drifting off-centre. Mirrors the
// pattern `UserHero` uses for the 1:1 profile sheet.
const avatarNode = (
<span className={css.HeroAvatar} aria-hidden={onAvatarClick ? undefined : true}>
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={name}
renderFallback={() => (
<RoomIcon size="600" joinRule={room.getJoinRule()} roomType={room.getType()} />
)}
/>
</span>
);
return (
<Box direction="Column" alignItems="Center" gap="200" className={css.HeroRoot}>
{onAvatarClick ? (
<button
type="button"
onClick={onAvatarClick}
className={css.HeroAvatarButton}
aria-label={t('Room.expand_avatar', { defaultValue: 'Open avatar' })}
>
{avatarNode}
</button>
) : (
avatarNode
)}
<Text size="H4" align="Center" className={classNames(BreakWord, LineClamp3)} title={name}>
{name}
</Text>
<Box alignItems="Center" gap="200" className={css.HeroSubline}>
<Text as="span" size="T200" className={css.HeroMembersCount}>
{t('Room.members_sheet_title', {
count: memberCount,
formattedCount: millify(memberCount),
})}
</Text>
{encrypted && (
<>
<span className={css.HeroBullet} aria-hidden>
·
</span>
<span className={css.HeroE2ee}>
<Icon size="50" src={Icons.Lock} filled />
<Text as="span" size="T200">
{t('Room.encrypted_short')}
</Text>
</span>
</>
)}
</Box>
{topic && (
<Text
as="p"
size="T200"
align="Center"
className={classNames(BreakWord, LineClamp3, css.HeroTopic)}
>
{topic}
</Text>
)}
</Box>
);
}

View file

@ -0,0 +1,187 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// Outer column that owns the sheet body — hero + group list. The host
// (mobile horseshoe / desktop side pane) decides the surrounding
// scroll strategy; this component just renders content flat so the
// host can measure its `scrollHeight` for content-fit rail sizing.
export const Root = style({
display: 'flex',
flexDirection: 'column',
width: '100%',
});
// ── Hero ────────────────────────────────────────────────────────
//
// Centred avatar + name + subline + topic. Visual rhyme with
// `UserHero` from the 1:1 peer profile sheet — same gap rhythm and
// padding so the two sheets read as one design system.
export const HeroRoot = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: `${config.space.S500} ${config.space.S400} ${config.space.S300}`,
width: '100%',
gap: toRem(8),
});
// Avatar wrapper — circular 96 px to mirror the 1:1 `UserHero`. The
// `RoomAvatar` primitive renders as an absolutely-filling image /
// fallback inside this fixed-size container, so the avatar is its
// own boundary and stays exactly centred under the column. Using
// `display: block` (not inline-flex) avoids inline-baseline padding
// that can offset the visual centre by 1-2 px on some browsers.
// `boxShadow` ring matches the Dawn outline rhythm used by
// `UserHero` (chat-list rows, avatar surface plate).
export const HeroAvatar = style({
position: 'relative',
display: 'block',
width: toRem(96),
height: toRem(96),
borderRadius: '50%',
flexShrink: 0,
boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Background.Container}`,
});
// Native button chrome reset for the tap-to-zoom avatar wrapper.
// Mirrors `UserHero.HeroAvatarButton`. Keeps the visible avatar
// pixel-identical to the non-clickable path so the user can't tell
// «is this tappable?» from look alone — the focus ring on
// keyboard-tab is the affordance.
export const HeroAvatarButton = style({
display: 'inline-flex',
background: 'transparent',
border: 'none',
padding: 0,
margin: 0,
cursor: 'pointer',
});
// Subline row beneath the name — «N members · e2ee» layout mirrors
// the user-profile hero's presence + e2ee row.
export const HeroSubline = style({
width: '100%',
justifyContent: 'center',
flexWrap: 'wrap',
});
export const HeroMembersCount = style({
color: color.Surface.OnContainer,
opacity: 0.7,
});
export const HeroBullet = style({
color: color.Surface.ContainerLine,
});
export const HeroE2ee = style({
display: 'inline-flex',
alignItems: 'center',
gap: toRem(4),
color: color.Success.Main,
});
// Optional topic line — muted secondary text, clamped to keep the
// hero compact. Long topics overflow into the inner list scroll
// only if the user keeps reading via the host's scroll handle.
export const HeroTopic = style({
marginTop: toRem(4),
color: color.Surface.OnContainer,
opacity: 0.65,
maxWidth: toRem(360),
});
// ── List ────────────────────────────────────────────────────────
// Spans the full width below the hero. Top border separates the
// hero block from the role-grouped list — same divider rhythm as
// the user-profile `InfoSection` rule above the chips.
export const List = style({
display: 'flex',
flexDirection: 'column',
padding: `0 0 ${config.space.S200}`,
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
});
// Group divider — uppercase muted label + numeric count. Spaced apart
// to match `stream-v2-dawn.jsx` ("ADMIN · 1", "MOD · 1", "BOTS · 1",
// "УЧАСТНИКИ · 22", "ГОСТИ · 4").
export const GroupLabel = style({
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
padding: `${config.space.S400} ${config.space.S400} ${config.space.S200}`,
});
export const GroupLabelFirst = style({
paddingTop: config.space.S300,
});
export const GroupLabelText = style({
color: color.Surface.OnContainer,
textTransform: 'uppercase',
letterSpacing: toRem(1),
fontWeight: 600,
opacity: 0.7,
});
export const GroupLabelCount = style({
color: color.Surface.OnContainer,
fontVariantNumeric: 'tabular-nums',
opacity: 0.5,
});
// Member row — full-width button so the entire surface is tappable
// for opening the per-user profile. `text-align: left` because the
// `<button>` resets it to `center` by default. Hover surface raises
// to `Surface.ContainerHover`, matching the chat-list rows.
export const MemberRow = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
width: '100%',
padding: `${config.space.S200} ${config.space.S400}`,
background: 'transparent',
border: 'none',
color: color.Surface.OnContainer,
textAlign: 'left',
cursor: 'pointer',
minHeight: toRem(40),
selectors: {
'&:hover': {
backgroundColor: color.Surface.ContainerHover,
},
'&:focus-visible': {
outline: `${config.borderWidth.B400} solid ${color.Primary.Main}`,
outlineOffset: `-${config.borderWidth.B400}`,
},
},
});
export const MemberAvatar = style({
flexShrink: 0,
});
export const MemberMainCol = style({
display: 'flex',
flexDirection: 'column',
minWidth: 0,
flex: 1,
});
export const MemberNameRow = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
minWidth: 0,
});
export const MemberName = style({
minWidth: 0,
flex: '0 1 auto',
});
export const MemberTyping = style({
display: 'inline-flex',
marginTop: toRem(2),
});

View file

@ -7,7 +7,6 @@ import { RoomView } from './RoomView';
import { userRoomProfileAtom } from '../../state/userRoomProfile'; import { userRoomProfileAtom } from '../../state/userRoomProfile';
import { ContainerColor } from '../../styles/ContainerColor.css'; import { ContainerColor } from '../../styles/ContainerColor.css';
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe'; import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
import { MembersDrawer } from './MembersDrawer';
import { ThreadDrawer } from './ThreadDrawer'; import { ThreadDrawer } from './ThreadDrawer';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
@ -17,17 +16,19 @@ import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import { markAsRead } from '../../utils/notifications'; import { markAsRead } from '../../utils/notifications';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers';
import { CallView } from '../call/CallView'; import { CallView } from '../call/CallView';
import { RoomViewHeader } from './RoomViewHeader'; import { RoomViewHeader } from './RoomViewHeader';
import { callChatAtom } from '../../state/callEmbed'; import { callChatAtom } from '../../state/callEmbed';
import { CallChatView } from './CallChatView'; import { CallChatView } from './CallChatView';
import { RoomViewProfilePanel } from './RoomViewProfilePanel'; import { RoomViewProfilePanel } from './RoomViewProfilePanel';
import { RoomViewProfileSidePanel } from './RoomViewProfileSidePanel'; import { RoomViewProfileSidePanel } from './RoomViewProfileSidePanel';
import { RoomViewMembersPanel } from './RoomViewMembersPanel';
import { RoomViewMembersSidePanel } from './RoomViewMembersSidePanel';
import { MobileMediaViewerHorseshoe } from './MobileMediaViewerHorseshoe'; import { MobileMediaViewerHorseshoe } from './MobileMediaViewerHorseshoe';
import { RoomViewMediaSidePanel } from './RoomViewMediaSidePanel'; import { RoomViewMediaSidePanel } from './RoomViewMediaSidePanel';
import { MediaViewerHostContext } from './mediaViewerHostContext'; import { MediaViewerHostContext } from './mediaViewerHostContext';
import { mediaViewerAtom } from '../../state/mediaViewer'; import { mediaViewerAtom } from '../../state/mediaViewer';
import { roomMembersSheetAtom } from '../../state/roomMembersSheet';
import { useChannelsMode, ThreadDrawerOpenProvider } from '../../hooks/useChannelsMode'; import { useChannelsMode, ThreadDrawerOpenProvider } from '../../hooks/useChannelsMode';
import { CHANNELS_THREAD_PATH } from '../../pages/paths'; import { CHANNELS_THREAD_PATH } from '../../pages/paths';
import { getChannelsRoomPath } from '../../pages/pathUtils'; import { getChannelsRoomPath } from '../../pages/pathUtils';
@ -94,7 +95,10 @@ export function Room({ renderRoomView }: RoomProps) {
decodedRootId = matchedRootParam; decodedRootId = matchedRootParam;
} }
} }
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); // Legacy `isPeopleDrawer` localStorage setting still drives the
// space-lobby MembersDrawer (see `features/lobby/Lobby.tsx`). The
// room view itself migrates to the transient atom-driven members
// sheet, so the value is no longer read here.
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile; const isMobile = screenSize === ScreenSize.Mobile;
@ -103,7 +107,6 @@ export function Room({ renderRoomView }: RoomProps) {
// M5 nav-stack work picks up where M2 left off without re-routing. // M5 nav-stack work picks up where M2 left off without re-routing.
const drawerHidesChat = showThreadDrawer && isMobile; const drawerHidesChat = showThreadDrawer && isMobile;
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId);
const chat = useAtomValue(callChatAtom); const chat = useAtomValue(callChatAtom);
// 1:1 rooms get peer-profile-sheet via avatar tap in the header instead of // 1:1 rooms get peer-profile-sheet via avatar tap in the header instead of
// the members drawer — see dm_1x1_redesign.md §8 P4 deliverable 9. The // the members drawer — see dm_1x1_redesign.md §8 P4 deliverable 9. The
@ -131,6 +134,7 @@ export function Room({ renderRoomView }: RoomProps) {
// parent void can't bleed through any transparent slivers. // parent void can't bleed through any transparent slivers.
const profileOpen = !!useAtomValue(userRoomProfileAtom); const profileOpen = !!useAtomValue(userRoomProfileAtom);
const mediaOpen = !!useAtomValue(mediaViewerAtom); const mediaOpen = !!useAtomValue(mediaViewerAtom);
const membersSheetOpen = !!useAtomValue(roomMembersSheetAtom);
const callView = room.isCallRoom(); const callView = room.isCallRoom();
const showProfileHorseshoe = profileOpen && !isMobile && !showThreadDrawer; const showProfileHorseshoe = profileOpen && !isMobile && !showThreadDrawer;
// Media viewer side pane on desktop — same horseshoe seam idiom // Media viewer side pane on desktop — same horseshoe seam idiom
@ -142,17 +146,21 @@ export function Room({ renderRoomView }: RoomProps) {
// Thread drawer side pane on desktop. Mobile hides the chat column // Thread drawer side pane on desktop. Mobile hides the chat column
// entirely (`drawerHidesChat`) so the seam doesn't apply there. // entirely (`drawerHidesChat`) so the seam doesn't apply there.
const showThreadHorseshoe = showThreadDrawer && !isMobile; const showThreadHorseshoe = showThreadDrawer && !isMobile;
// Members drawer side pane — mirrors the same horseshoe seam as the // Members side pane — mirrors the profile / media seam. Gated on:
// profile/media/thread panes. Gated on the exact same conditions that // any non-1:1 room (group DM, group room, channel) on desktop, atom
// mount `<MembersDrawer>` below (group room, desktop, drawer setting on, // open, no thread overlay, no call surface. The atom-driven model
// no thread overlay, no call surface) so the void gap appears iff the // replaces the legacy `settingsAtom.isPeopleDrawer` localStorage flag
// pane appears. // in the room view — `LobbyHeader` keeps reading that flag for the
// space lobby. Channels share the same members surface as group
// rooms per design canon `stream-v2-dawn.jsx::ChannelDesktop`.
const showMembersHorseshoe = const showMembersHorseshoe =
!callView && !callView && !isOneOnOne && !showThreadDrawer && !isMobile && membersSheetOpen;
!isOneOnOne && // Mobile mounts the members horseshoe wrapper for every non-1:1,
!showThreadDrawer && // non-call room — group DMs, group rooms, AND channels. 1:1 rooms
screenSize === ScreenSize.Desktop && // keep using `RoomViewProfilePanel`; the call surface routes member
isDrawer; // discovery through Room Settings (see the user-icon button kept in
// `RoomViewHeaderDm` for `callView`).
const useMembersWrapper = !isOneOnOne && !callView;
// True whenever any right-side pane is mounted. Drives the parent flex // True whenever any right-side pane is mounted. Drives the parent flex
// row's void background and the chat column's explicit Background paint // row's void background and the chat column's explicit Background paint
// — both prevent the chat-side surface from bleeding through the carved // — both prevent the chat-side surface from bleeding through the carved
@ -161,10 +169,7 @@ export function Room({ renderRoomView }: RoomProps) {
// would over-render when only members is open — there is no // would over-render when only members is open — there is no
// profile/media slot to anchor it to). // profile/media slot to anchor it to).
const paintParentVoid = const paintParentVoid =
showProfileHorseshoe || showProfileHorseshoe || showMediaHorseshoe || showThreadHorseshoe || showMembersHorseshoe;
showMediaHorseshoe ||
showThreadHorseshoe ||
showMembersHorseshoe;
useKeyDown( useKeyDown(
window, window,
@ -179,10 +184,7 @@ export function Room({ renderRoomView }: RoomProps) {
// surface marks main as read and leaves threads alone — works // surface marks main as read and leaves threads alone — works
// unconditionally. Under the kill switch the handler reverts // unconditionally. Under the kill switch the handler reverts
// to blind DELETE, so we keep the drawer-open guard there. // to blind DELETE, so we keep the drawer-open guard there.
if ( if (isKeyHotkey('escape', evt) && (unreadThreadingEnabled || !showThreadDrawer)) {
isKeyHotkey('escape', evt) &&
(unreadThreadingEnabled || !showThreadDrawer)
) {
markAsRead(mx, room.roomId, hideActivity); markAsRead(mx, room.roomId, hideActivity);
} }
}, },
@ -216,26 +218,21 @@ export function Room({ renderRoomView }: RoomProps) {
<MediaViewerHostContext.Provider value={mediaHostValue}> <MediaViewerHostContext.Provider value={mediaHostValue}>
<Box <Box
grow="Yes" grow="Yes"
style={ style={paintParentVoid ? { backgroundColor: VOJO_HORSESHOE_VOID_COLOR } : undefined}
paintParentVoid
? { backgroundColor: VOJO_HORSESHOE_VOID_COLOR }
: undefined
}
> >
{callView && (screenSize === ScreenSize.Desktop || !chat) && ( {callView && (screenSize === ScreenSize.Desktop || !chat) && (
<Box <Box
grow="Yes" grow="Yes"
direction="Column" direction="Column"
className={ className={paintParentVoid ? ContainerColor({ variant: 'Background' }) : undefined}
paintParentVoid
? ContainerColor({ variant: 'Background' })
: undefined
}
// No chat-column padding-top / bg: the silhouette inside // No chat-column padding-top / bg: the silhouette inside
// `MobileProfileHorseshoe` owns the safe-top inset and bg. // `MobileProfileHorseshoe` owns the safe-top inset and bg.
// See the !callView twin block below for the rationale. // See the !callView twin block below for the rationale.
> >
<MobileMediaViewerHorseshoe> <MobileMediaViewerHorseshoe>
{/* `useMembersWrapper` is gated on `!callView`, so this
branch is always the profile wrapper kept explicit
for parity with the `!callView` twin below. */}
<RoomViewProfilePanel header={<RoomViewHeader callView />}> <RoomViewProfilePanel header={<RoomViewHeader callView />}>
<Box grow="Yes"> <Box grow="Yes">
<CallView /> <CallView />
@ -248,26 +245,31 @@ export function Room({ renderRoomView }: RoomProps) {
<Box <Box
grow="Yes" grow="Yes"
direction="Column" direction="Column"
className={ className={paintParentVoid ? ContainerColor({ variant: 'Background' }) : undefined}
paintParentVoid // No padding-top / bg on mobile chat column: whichever
? ContainerColor({ variant: 'Background' }) // mobile horseshoe wraps the chat (profile for 1:1,
: undefined // members for group / channel) permanently sits at
} // body_top with its own `padding-top: var(--vojo-safe-
// No padding-top / bg on mobile chat column: the silhouette // top)` keeping chat-header / panel content below the
// inside `MobileProfileHorseshoe` permanently sits at body_top // status-bar icons. The silhouette's bg fades between
// with its own `padding-top: var(--vojo-safe-top)` keeping // chat-surface tone (when closed) and sheet tone (when
// chat-header / panel content below the status-bar icons. // fully open) as the user drags. Desktop branch still
// The silhouette's bg fades between chat-surface tone (when // works because `--vojo-safe-top` resolves to 0 on web.
// closed) and user-card tone (when fully open) as the user
// drags. Desktop branch still works because `--vojo-safe-top`
// resolves to 0 on web.
> >
<MobileMediaViewerHorseshoe> <MobileMediaViewerHorseshoe>
{useMembersWrapper ? (
<RoomViewMembersPanel header={<RoomViewHeader />}>
<Box grow="Yes">
{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}
</Box>
</RoomViewMembersPanel>
) : (
<RoomViewProfilePanel header={<RoomViewHeader />}> <RoomViewProfilePanel header={<RoomViewHeader />}>
<Box grow="Yes"> <Box grow="Yes">
{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />} {renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}
</Box> </Box>
</RoomViewProfilePanel> </RoomViewProfilePanel>
)}
</MobileMediaViewerHorseshoe> </MobileMediaViewerHorseshoe>
</Box> </Box>
)} )}
@ -295,18 +297,15 @@ export function Room({ renderRoomView }: RoomProps) {
<CallChatView /> <CallChatView />
</> </>
)} )}
{/* Members drawer hidden when thread drawer is open three {/* Members side pane atom-driven (transient), opens via
simultaneous side panes don't fit the chat column on tap on avatar+title in the chat header (see
anything narrower than ultrawide. The thread is the more `RoomViewHeaderDm`). Hidden when thread drawer is open
recent intent so it wins. */} three simultaneous side panes don't fit on anything
{!callView && narrower than ultrawide. */}
!isOneOnOne && {showMembersHorseshoe && (
!showThreadDrawer &&
screenSize === ScreenSize.Desktop &&
isDrawer && (
<> <>
{showMembersHorseshoe && <VoidGap />} <VoidGap />
<MembersDrawer key={room.roomId} room={room} members={members} /> <RoomViewMembersSidePanel key={room.roomId} />
</> </>
)} )}
{showThreadDrawer && decodedRootId && parentRoomPath && ( {showThreadDrawer && decodedRootId && parentRoomPath && (

View file

@ -60,6 +60,11 @@ import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall'; import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import {
useCloseRoomMembersSheet,
useOpenRoomMembersSheet,
useRoomMembersSheetState,
} from '../../state/hooks/roomMembersSheet';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { callEmbedAtom } from '../../state/callEmbed'; import { callEmbedAtom } from '../../state/callEmbed';
import { searchModalAtom } from '../../state/searchModal'; import { searchModalAtom } from '../../state/searchModal';
@ -495,12 +500,14 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
const peerPresence = useUserPresence(peerUserId ?? ''); const peerPresence = useUserPresence(peerUserId ?? '');
const peerOnline = !!peerUserId && peerPresence?.presence === Presence.Online; const peerOnline = !!peerUserId && peerPresence?.presence === Presence.Online;
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>(); const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const setSearchOpen = useSetAtom(searchModalAtom); const setSearchOpen = useSetAtom(searchModalAtom);
const openUserRoomProfile = useOpenUserRoomProfile(); const openUserRoomProfile = useOpenUserRoomProfile();
const openRoomMembersSheet = useOpenRoomMembersSheet();
const closeRoomMembersSheet = useCloseRoomMembersSheet();
const membersSheetState = useRoomMembersSheetState();
const membersSheetOpen = membersSheetState?.roomId === room.roomId;
const parentSpace = useSpaceOptionally(); const parentSpace = useSpaceOptionally();
const openSettings = useOpenRoomSettings(); const openSettings = useOpenRoomSettings();
@ -523,12 +530,18 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
'Bottom' 'Bottom'
); );
}; };
const handleMemberToggle = () => { const handleGroupIdentityClick = () => {
if (callView) { if (membersSheetOpen) {
openSettings(room.roomId, parentSpace?.roomId, RoomSettingsPage.MembersPage); closeRoomMembersSheet();
return; } else {
openRoomMembersSheet(room.roomId);
} }
setPeopleDrawer(!peopleDrawer); };
// `callView` keeps the legacy «open Members in Settings» fallback —
// the members side-pane isn't mounted while we're inside the call
// surface, so a separate path is still needed to reach the list.
const handleCallViewMembers = () => {
openSettings(room.roomId, parentSpace?.roomId, RoomSettingsPage.MembersPage);
}; };
const avatarNode = ( const avatarNode = (
@ -610,10 +623,14 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
// For 1:1 rooms wrap the avatar AND title in a single button so a tap // For 1:1 rooms wrap the avatar AND title in a single button so a tap
// anywhere on the user identity (avatar, name, handle, online tag) // anywhere on the user identity (avatar, name, handle, online tag)
// opens the user-room-profile sheet — one tab stop, one focus // opens the user-room-profile sheet — one tab stop, one focus
// outline, single popout anchor. Groups keep the avatar non-clickable // outline, single popout anchor. For group rooms the same button
// and the title as a plain row. // pattern opens the members sheet instead — keeps the gesture
const identityArea = // language symmetrical across 1:1 and group. `callView` falls back
isOneOnOne && peerUserId ? ( // to a static row because neither sheet is reachable inside the
// call surface.
let identityArea: React.ReactNode;
if (isOneOnOne && peerUserId) {
identityArea = (
<button <button
type="button" type="button"
className={css.PeerIdentityTrigger} className={css.PeerIdentityTrigger}
@ -623,12 +640,28 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
{avatarNode} {avatarNode}
{titleBlock} {titleBlock}
</button> </button>
) : ( );
} else if (!isOneOnOne && !callView) {
identityArea = (
<button
type="button"
className={css.PeerIdentityTrigger}
onClick={handleGroupIdentityClick}
aria-pressed={membersSheetOpen}
aria-label={t('Room.open_members_of', { name })}
>
{avatarNode}
{titleBlock}
</button>
);
} else {
identityArea = (
<> <>
<span className={css.PeerAvatarStatic}>{avatarNode}</span> <span className={css.PeerAvatarStatic}>{avatarNode}</span>
{titleBlock} {titleBlock}
</> </>
); );
}
return ( return (
<PageHeader <PageHeader
@ -657,29 +690,24 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
<Box shrink="No" alignItems="Center"> <Box shrink="No" alignItems="Center">
{callButtonVisible && <DmCallButton room={room} />} {callButtonVisible && <DmCallButton room={room} />}
{/* Member toggle desktop-only. Visible in two cases: {/* Member toggle kept only for `callView` (1:1 or group): the
- group rooms (>2 members): toggles the members drawer. members side-pane / horseshoe isn't mounted under the call
- call rooms (`callView=true`), regardless of size: routes to surface, so we still need a way to reach the member list
Room Settings Members so 1:1 call rooms can still reach via Room Settings Members. Non-call group rooms now use
the member list (drawer doesn't render in callView). the identity button above (tap on avatar+title) keeps
For non-call 1:1 rooms it stays hidden because the avatar tap the gesture symmetrical with 1:1 peer profile. */}
already opens the peer profile sheet. */} {callView && screenSize === ScreenSize.Desktop && (
{(callView || !isOneOnOne) && screenSize === ScreenSize.Desktop && (
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"
offset={4} offset={4}
tooltip={ tooltip={
<Tooltip> <Tooltip>
{callView ? (
<Text>{t('Room.members')}</Text> <Text>{t('Room.members')}</Text>
) : (
<Text>{peopleDrawer ? t('Room.hide_members') : t('Room.show_members')}</Text>
)}
</Tooltip> </Tooltip>
} }
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={handleMemberToggle}> <IconButton fill="None" ref={triggerRef} onClick={handleCallViewMembers}>
<Icon size="400" src={Icons.User} /> <Icon size="400" src={Icons.User} />
</IconButton> </IconButton>
)} )}

View file

@ -0,0 +1,128 @@
// Mobile members horseshoe — exact mirror of the 1:1 profile
// horseshoe in `RoomViewProfilePanel.css.ts`. Same silhouette
// wrapper geometry, same handle, same chatBody gap + radius
// constants — both ends of the chat read with identical visual
// language. Kept as a separate file (rather than reusing the profile
// CSS) because the two horseshoes can diverge in the future without
// rippling regressions through the other surface.
import { style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
// Re-export the two horseshoe constants from the profile panel so any
// future tweak (e.g. radius=28 trial) lives in one place.
export { HORSESHOE_GAP_PX, HORSESHOE_RADIUS_PX } from './RoomViewProfilePanel.css';
// Re-export the avatar-full-view styles from the profile panel — both
// the embedded user-profile branch (tap on member row → tap on avatar)
// and the group-hero branch (tap on group avatar) use the same
// silhouette-filling overlay treatment. Keeping the styles in one
// place ensures the two surfaces stay visually identical.
export { avatarFullView, avatarFullImage, avatarFullFallback } from './RoomViewProfilePanel.css';
export const container = style({
position: 'relative',
display: 'flex',
flex: 1,
flexDirection: 'column',
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
});
export const silhouette = style({
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
overflow: 'hidden',
backgroundColor: color.Background.Container,
willChange: 'border-bottom-left-radius, border-bottom-right-radius',
});
export const panelViewport = style({
position: 'relative',
width: '100%',
overflow: 'hidden',
willChange: 'height',
touchAction: 'pan-y',
userSelect: 'none',
});
// Anchor at the top so the list slides downward as the viewport grows
// — title strip + first group label become visible first. Mirrors the
// profile panel's emerge direction. `padding-top: var(--vojo-safe-top)`
// keeps the list clear of the Android status-bar icons in the open
// state.
export const panelContent = style({
position: 'absolute',
top: 0,
left: 0,
right: 0,
boxSizing: 'border-box',
paddingTop: 'var(--vojo-safe-top, 0px)',
});
export const panelInner = style({
display: 'flex',
flexDirection: 'column',
height: '100%',
});
// Wrapper around the `MembersList` so the host can switch overflow on
// when content exceeds the safety cap. Same idiom as `panelScroll` in
// the profile panel — the list owns its own scroll, but at the rail
// boundary the host still hides chrome and toggles overflow.
export const panelScroll = style({
flex: 1,
minHeight: 0,
scrollbarWidth: 'none',
selectors: {
'&::-webkit-scrollbar': {
display: 'none',
},
},
});
export const panelHandle = style({
flexShrink: 0,
height: toRem(20),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'grab',
touchAction: 'none',
selectors: {
'&:active': { cursor: 'grabbing' },
},
});
export const panelHandleBar = style({
width: toRem(36),
height: toRem(4),
borderRadius: toRem(4),
backgroundColor: color.Surface.ContainerLine,
});
export const headerViewport = style({
flexShrink: 0,
overflow: 'hidden',
willChange: 'height',
userSelect: 'none',
});
export const headerViewportInner = style({
boxSizing: 'border-box',
minHeight: 0,
overflow: 'hidden',
paddingTop: 'var(--vojo-safe-top, 0px)',
backgroundColor: color.SurfaceVariant.Container,
});
export const chatBody = style({
display: 'flex',
flex: 1,
flexDirection: 'column',
minWidth: 0,
minHeight: 0,
willChange: 'margin-top, border-top-left-radius, border-top-right-radius',
});

View file

@ -0,0 +1,632 @@
// Wrapper around the chat column that adds the mobile members
// horseshoe — the group-room counterpart of `RoomViewProfilePanel`.
// Drag-down on the chat header opens the members sheet; drag-up on
// the panel closes it. Geometry is identical to the 1:1 profile
// horseshoe (same silhouette + chatBody gap + 32 px radius + Vaul
// release curve + easeInOutCubic during drag + 80 px commit),
// keeping both ends of the chat visually consistent.
//
// Tablet + Desktop branch is a pass-through; the right-side members
// pane is mounted by `Room.tsx` as a sibling via
// `RoomViewMembersSidePanel`. Mobile is the only surface that owns a
// horseshoe.
//
// Gated on the host (`Room.tsx`) — mounted for any non-1:1, non-
// callView room: group DMs, group rooms, AND channels (per design
// canon `stream-v2-dawn.jsx::ChannelDesktop`).
//
// **Profile sheet co-mount** — wherever this component mounts, the
// legacy `RoomViewProfilePanel` does NOT (the two wrappers are picked
// mutually exclusive at `Room.tsx::useMembersWrapper`). To keep tap-
// on-member-row → user-profile reachable, the same silhouette ALSO
// renders the per-user `UserRoomProfile` when `userRoomProfileAtom`
// is set. Members sheet and profile sheet are mutually exclusive at
// the atom layer (`useOpenUserRoomProfile` clears the members atom
// and vice versa), so the silhouette body just picks whichever atom
// is non-null to render. Geometry, drag mechanics and FocusTrap stay
// the same.
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next';
import { config } from 'folds';
import { roomMembersSheetAtom } from '../../state/roomMembersSheet';
import {
useCloseRoomMembersSheet,
useOpenRoomMembersSheet,
} from '../../state/hooks/roomMembersSheet';
import { userRoomProfileAtom } from '../../state/userRoomProfile';
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers';
import { usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { MembersList } from '../../components/members-list/MembersList';
import { UserRoomProfile } from '../../components/user-profile/UserRoomProfile';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta';
import { getMemberAvatarMxc } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { stopPropagation } from '../../utils/keyboard';
import colorMXID from '../../../util/colorMXID';
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
import * as css from './RoomViewMembersPanel.css';
// Same constants as the profile horseshoe — see RoomViewProfilePanel.tsx
// for the rationale. Keep paired so the two surfaces stay in lockstep.
const MAX_RAIL_FRACTION = 0.85;
const COMMIT_THRESHOLD_PX = 80;
const ANIMATION_MS = 250;
const HORSESHOE_EMERGE_PX = 80;
const VAUL_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)';
const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
type RoomViewMembersPanelProps = {
header: ReactNode;
children: ReactNode;
};
type DragState = {
source: 'header' | 'panel';
inputType: 'touch' | 'pointer';
startY: number;
deltaY: number;
};
function MobileMembersHorseshoe({ header, children }: RoomViewMembersPanelProps) {
const { t } = useTranslation();
const sheetState = useAtomValue(roomMembersSheetAtom);
const profileState = useAtomValue(userRoomProfileAtom);
const close = useCloseRoomMembersSheet();
const closeProfile = useCloseUserRoomProfile();
const openSheet = useOpenRoomMembersSheet();
const room = useRoom();
const mx = useMatrixClient();
const isOneOnOne = useIsOneOnOne();
// Drag-down on the chat header is the entry point for the members
// sheet. Disabled in 1:1 rooms as defence-in-depth — this wrapper
// shouldn't even be mounted there (1:1 owns its own profile
// horseshoe).
const headerDragEnabled = !isOneOnOne;
const members = useRoomMembers(mx, room.roomId);
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const useAuthentication = useMediaAuthentication();
// Room-level avatar/name for the group hero AND its full-view swap.
// No width/height passed to `mxcUrlToHttp` — Synapse returns the
// upload at original resolution and the browser scales it via CSS.
// Asking for a fixed thumbnail (e.g. 720x720) routes through
// Synapse's JPEG recompression and visibly pixelates on high-DPR
// phones (a 400 CSS px silhouette = ~1200 native px on 3x screens,
// so a 720 thumb gets upscaled AND recompressed twice).
// Matches `MediaViewerBody` (the other Vojo fullscreen surface).
const roomAvatarMxc = useRoomAvatar(room, false);
const roomAvatarUrl =
(roomAvatarMxc && mxcUrlToHttp(mx, roomAvatarMxc, useAuthentication)) ?? undefined;
const roomName = useRoomName(room);
const [drag, setDrag] = useState<DragState | null>(null);
// Two independent avatar-zoom modes that swap the panel body for a
// silhouette-filling avatar view (`avatarFullView`). Mirrors the
// 1:1 `MobileProfileHorseshoe.avatarMode`: full-view replaces ALL
// card content (name, info rows, etc.) — different from the desktop
// side-pane's inline-expand (`avatarExpanded`) which keeps the rest
// of the card in flow below. The two modes target different bodies:
//
// • `userAvatarMode` — tap on avatar inside the embedded user
// profile (member-row → profile).
// • `groupAvatarMode` — tap on the group hero avatar (sheet root).
//
// Each resets when its source identifier changes so reopening the
// sheet / changing the viewed user doesn't land in zoom mode by
// accident.
const [userAvatarMode, setUserAvatarMode] = useState(false);
useEffect(() => {
setUserAvatarMode(false);
}, [profileState?.userId]);
const [groupAvatarMode, setGroupAvatarMode] = useState(false);
useEffect(() => {
setGroupAvatarMode(false);
}, [sheetState?.roomId]);
// Defensive — if the user navigates from members to user-profile (atom
// swap), the group-avatar mode must close so the embedded profile
// body actually shows.
useEffect(() => {
if (profileState) setGroupAvatarMode(false);
}, [profileState]);
// Mirror: if user-profile flips back to members sheet, user-avatar
// mode must reset so the member list is visible.
useEffect(() => {
if (sheetState) setUserAvatarMode(false);
}, [sheetState]);
// Same no-thumbnail rationale as `roomAvatarUrl` above — Synapse
// recompression visibly shakals the silhouette-filling fullview on
// high-DPR phones. Original resolution + CSS downscale stays crisp.
const renderUserId = profileState?.userId;
const renderUserAvatarMxc = renderUserId ? getMemberAvatarMxc(room, renderUserId) : undefined;
const renderUserAvatarUrl =
(renderUserAvatarMxc && mxcUrlToHttp(mx, renderUserAvatarMxc, useAuthentication)) ?? undefined;
const headerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const headerInnerRef = useRef<HTMLDivElement>(null);
const panelContentRef = useRef<HTMLDivElement>(null);
const panelScrollRef = useRef<HTMLDivElement>(null);
const panelMeasureRef = useRef<HTMLDivElement>(null);
const [headerNaturalHeight, setHeaderNaturalHeight] = useState(0);
// Measured natural height of whichever body is currently rendered
// (members list with hero, OR the embedded user profile). Drives
// content-fit rail sizing — the sheet only opens as tall as it needs
// to be, with a safety cap so a 200-member list doesn't fill the
// whole screen.
const [contentNaturalHeight, setContentNaturalHeight] = useState(0);
// Close both sheets when the room changes — atoms are global state
// and would otherwise carry the previous room's selection across
// navigation. Profile gets the same cleanup because the mobile
// group path is now the ONLY surface rendering it (the legacy
// `MobileProfileHorseshoe` doesn't mount in group rooms).
useEffect(
() => () => {
close();
closeProfile();
},
[room.roomId, close, closeProfile]
);
const [viewportHeight, setViewportHeight] = useState(() => {
if (typeof window === 'undefined') return 800;
return window.innerHeight;
});
useEffect(() => {
const onResize = () => setViewportHeight(window.innerHeight);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
// Hoisted ahead of the measurement effect below so its `useLayoutEffect`
// dependency array can reference it. Re-evaluated every render — atom
// reads above are reactive.
const showProfile = !!profileState;
// Rail height: cap at 85% of viewport, but shrink to the actual
// content height when it fits. Same pattern as the 1:1 profile
// horseshoe (`MobileProfileHorseshoe`) so the two sheets feel
// consistent — short-content groups get a compact rail, large
// groups cap at 85vh and the list scrolls internally.
//
// Measurement runs whenever the active body content changes (hero
// height, member-list length, switching to embedded profile),
// tracked by ResizeObserver on `panelMeasureRef`.
useLayoutEffect(() => {
const measureEl = panelMeasureRef.current;
const contentEl = panelContentRef.current;
if (!measureEl || !contentEl) return undefined;
const measure = () => {
const innerH = measureEl.scrollHeight;
if (innerH <= 0) return;
const padTop = parseFloat(getComputedStyle(contentEl).paddingTop) || 0;
// 20 px must stay in sync with `panelHandle.height` in css.ts.
setContentNaturalHeight(innerH + padTop + 20);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(measureEl);
return () => ro.disconnect();
}, [showProfile]);
const maxRailPx = Math.round(viewportHeight * MAX_RAIL_FRACTION);
const railHeightPx =
contentNaturalHeight > 0
? Math.min(maxRailPx, contentNaturalHeight)
: Math.round(viewportHeight * 0.55);
// Internal scroll only when the natural content exceeds the cap. In
// the common fit case the panel is exactly content-sized — no
// drag-inside-drag, no inner overscroll noise.
const contentOverflows = contentNaturalHeight > 0 && contentNaturalHeight > maxRailPx;
useLayoutEffect(() => {
const el = headerInnerRef.current;
if (!el) return undefined;
const measure = () => {
const next = el.scrollHeight;
if (next > 0) setHeaderNaturalHeight(next);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const open = !!sheetState || showProfile;
const baseExpanded = open ? railHeightPx : 0;
const expandedPx = drag
? Math.max(0, Math.min(railHeightPx, baseExpanded + drag.deltaY))
: baseExpanded;
const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
const isDragging = drag !== null;
const horseshoeActive = expandedPx > 0;
const dragRef = useRef<DragState | null>(null);
dragRef.current = drag;
// `openRef` tracks whether either sheet is currently visible — drag-up
// commits use it to decide what to close.
const openRef = useRef(open);
openRef.current = open;
const sheetStateRef = useRef(sheetState);
sheetStateRef.current = sheetState;
const profileStateRef = useRef(profileState);
profileStateRef.current = profileState;
const openSheetRef = useRef(openSheet);
openSheetRef.current = openSheet;
const closeRef = useRef(close);
closeRef.current = close;
const closeProfileRef = useRef(closeProfile);
closeProfileRef.current = closeProfile;
const roomIdRef = useRef(room.roomId);
roomIdRef.current = room.roomId;
useEffect(() => {
const headerEl = headerRef.current;
const panelEl = panelRef.current;
const applyMove = (clientY: number, e: TouchEvent | PointerEvent) => {
const d = dragRef.current;
if (!d) return;
const rawDelta = clientY - d.startY;
let nextDelta = rawDelta;
if (d.source === 'header') {
nextDelta = Math.max(0, rawDelta);
} else {
if (rawDelta > 0) return;
nextDelta = rawDelta;
}
if (e.cancelable) e.preventDefault();
setDrag({ ...d, deltaY: nextDelta });
};
const applyEnd = () => {
const d = dragRef.current;
if (!d) return;
if (d.source === 'header' && d.deltaY > COMMIT_THRESHOLD_PX) {
openSheetRef.current(roomIdRef.current);
} else if (d.source === 'panel' && -d.deltaY > COMMIT_THRESHOLD_PX) {
// Close whichever sheet was open. Profile takes precedence
// (it's the «foreground» sheet when both atoms are
// hypothetically set — in practice they're mutually exclusive).
if (profileStateRef.current) closeProfileRef.current();
else if (sheetStateRef.current) closeRef.current();
}
setDrag(null);
};
// Scroll-aware panel drag-start: if the touch landed inside the
// members list's scroll container AND it's not already at the top,
// bail out so the user's swipe-up scrolls the list instead of
// dismissing the sheet. This is the Vaul drag-handle vs scrollable-
// body contract — without it, drag-up at any rest position closes
// the sheet, which feels wrong on long lists.
const panelScrollableHasScrollUp = (): boolean => {
const el = panelScrollRef.current;
if (!el) return false;
return el.scrollTop > 0;
};
const onHeaderTouchStart = (e: TouchEvent) => {
if (dragRef.current) return;
if (openRef.current) return;
if (!headerDragEnabled) return;
const touch = e.touches[0];
setDrag({ source: 'header', inputType: 'touch', startY: touch.clientY, deltaY: 0 });
};
const onPanelTouchStart = (e: TouchEvent) => {
if (dragRef.current) return;
if (!openRef.current) return;
if (panelScrollableHasScrollUp()) return;
const touch = e.touches[0];
setDrag({ source: 'panel', inputType: 'touch', startY: touch.clientY, deltaY: 0 });
};
const onTouchMove = (e: TouchEvent) => {
const d = dragRef.current;
if (!d || d.inputType !== 'touch') return;
applyMove(e.touches[0].clientY, e);
};
const onTouchEnd = () => {
const d = dragRef.current;
if (!d || d.inputType !== 'touch') return;
applyEnd();
};
const onHeaderPointerDown = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
if (dragRef.current) return;
if (openRef.current) return;
if (!headerDragEnabled) return;
if (e.button !== 0) return;
setDrag({ source: 'header', inputType: 'pointer', startY: e.clientY, deltaY: 0 });
};
const onPanelPointerDown = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
if (dragRef.current) return;
if (!openRef.current) return;
if (panelScrollableHasScrollUp()) return;
if (e.button !== 0) return;
setDrag({ source: 'panel', inputType: 'pointer', startY: e.clientY, deltaY: 0 });
};
const onDocumentPointerMove = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
const d = dragRef.current;
if (!d || d.inputType !== 'pointer') return;
applyMove(e.clientY, e);
};
const onDocumentPointerEnd = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
const d = dragRef.current;
if (!d || d.inputType !== 'pointer') return;
applyEnd();
};
if (headerEl) {
headerEl.addEventListener('touchstart', onHeaderTouchStart, { passive: true });
headerEl.addEventListener('touchmove', onTouchMove, { passive: false });
headerEl.addEventListener('touchend', onTouchEnd, { passive: true });
headerEl.addEventListener('touchcancel', onTouchEnd, { passive: true });
headerEl.addEventListener('pointerdown', onHeaderPointerDown);
}
if (panelEl) {
panelEl.addEventListener('touchstart', onPanelTouchStart, { passive: true });
panelEl.addEventListener('touchmove', onTouchMove, { passive: false });
panelEl.addEventListener('touchend', onTouchEnd, { passive: true });
panelEl.addEventListener('touchcancel', onTouchEnd, { passive: true });
panelEl.addEventListener('pointerdown', onPanelPointerDown);
}
document.addEventListener('pointermove', onDocumentPointerMove, { passive: false });
document.addEventListener('pointerup', onDocumentPointerEnd, { passive: true });
document.addEventListener('pointercancel', onDocumentPointerEnd, { passive: true });
return () => {
if (headerEl) {
headerEl.removeEventListener('touchstart', onHeaderTouchStart);
headerEl.removeEventListener('touchmove', onTouchMove);
headerEl.removeEventListener('touchend', onTouchEnd);
headerEl.removeEventListener('touchcancel', onTouchEnd);
headerEl.removeEventListener('pointerdown', onHeaderPointerDown);
}
if (panelEl) {
panelEl.removeEventListener('touchstart', onPanelTouchStart);
panelEl.removeEventListener('touchmove', onTouchMove);
panelEl.removeEventListener('touchend', onTouchEnd);
panelEl.removeEventListener('touchcancel', onTouchEnd);
panelEl.removeEventListener('pointerdown', onPanelPointerDown);
}
document.removeEventListener('pointermove', onDocumentPointerMove);
document.removeEventListener('pointerup', onDocumentPointerEnd);
document.removeEventListener('pointercancel', onDocumentPointerEnd);
};
}, [headerDragEnabled]);
let horseshoeRamp: number;
if (isDragging) {
horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX));
} else if (horseshoeActive) {
horseshoeRamp = 1;
} else {
horseshoeRamp = 0;
}
const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
const chatRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
const chatGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX;
const panelViewportTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
const headerViewportTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
const silhouetteTransition = isDragging
? 'none'
: `border-bottom-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-bottom-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
const chatBodyTransition = isDragging
? 'none'
: `margin-top ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
const containerStyle: React.CSSProperties = {
backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined,
};
// Body content selection — three mutually exclusive branches. Hoisted
// here so the JSX below doesn't need a nested ternary (eslint
// `no-nested-ternary`).
let panelBody: ReactNode;
if (userAvatarMode && renderUserId) {
// User-profile avatar full-view: silhouette-filling swap. Mirrors
// `MobileProfileHorseshoe.avatarMode` exactly. NOT the desktop
// side-pane `avatarExpanded` inline-expand path (which would push
// the name/info rows into the rail and read as a broken layout).
panelBody = (
<button
type="button"
className={css.avatarFullView}
onClick={() => setUserAvatarMode(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] ?? '?').toUpperCase()}
</div>
)}
</button>
);
} else if (groupAvatarMode) {
// Group hero avatar full-view — same pattern, source is the room.
panelBody = (
<button
type="button"
className={css.avatarFullView}
onClick={() => setGroupAvatarMode(false)}
aria-label={t('Room.collapse_avatar')}
>
{roomAvatarUrl ? (
<img
className={css.avatarFullImage}
src={roomAvatarUrl}
alt={roomName}
draggable={false}
/>
) : (
<div
className={css.avatarFullFallback}
style={{ backgroundColor: colorMXID(room.roomId) }}
>
{(roomName?.[0] ?? '?').toUpperCase()}
</div>
)}
</button>
);
} else {
panelBody = (
<div className={css.panelInner}>
<div
ref={panelScrollRef}
className={css.panelScroll}
style={{ overflowY: contentOverflows ? 'auto' : 'hidden' }}
>
<div ref={panelMeasureRef}>
{showProfile && profileState ? (
<div style={{ padding: config.space.S400 }}>
<UserRoomProfile
userId={profileState.userId}
onAvatarClick={() => setUserAvatarMode(true)}
/>
</div>
) : (
<MembersList
room={room}
members={members}
getPowerTag={getPowerTag}
onHeroAvatarClick={() => setGroupAvatarMode(true)}
/>
)}
</div>
</div>
<div className={css.panelHandle} aria-label={t('Room.drag_to_close')}>
<div className={css.panelHandleBar} />
</div>
</div>
);
}
return (
<div className={css.container} style={containerStyle}>
<div
className={css.silhouette}
style={{
borderBottomLeftRadius: `${silhouetteRadiusPx}px`,
borderBottomRightRadius: `${silhouetteRadiusPx}px`,
transition: silhouetteTransition,
}}
>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: false,
allowOutsideClick: () => true,
escapeDeactivates: stopPropagation,
onDeactivate: () => {
// Mirror manual close — let users hit Esc to dismiss the
// sheet even on a hybrid keyboard / touch device.
if (profileStateRef.current) closeProfileRef.current();
else if (sheetStateRef.current) closeRef.current();
},
checkCanFocusTrap: () => Promise.resolve(),
}}
active={open}
>
<div
ref={panelRef}
className={css.panelViewport}
style={{
height: `${expandedPx}px`,
transition: panelViewportTransition,
visibility: expandedPx > 0 ? 'visible' : 'hidden',
}}
>
<div
ref={panelContentRef}
className={css.panelContent}
style={{ height: `${railHeightPx}px` }}
>
{panelBody}
</div>
</div>
</FocusTrap>
<div
ref={headerRef}
className={css.headerViewport}
style={{
height:
headerNaturalHeight > 0
? `${(1 - expandedFraction) * headerNaturalHeight}px`
: 'auto',
transition: headerViewportTransition,
touchAction: headerDragEnabled ? 'pan-x' : undefined,
}}
>
<div ref={headerInnerRef} className={css.headerViewportInner}>
{header}
</div>
</div>
</div>
<div
className={css.chatBody}
style={{
marginTop: `${chatGapPx}px`,
borderTopLeftRadius: chatRadiusPx ? `${chatRadiusPx}px` : undefined,
borderTopRightRadius: chatRadiusPx ? `${chatRadiusPx}px` : undefined,
overflow: chatRadiusPx > 0 ? 'hidden' : undefined,
transition: chatBodyTransition,
overscrollBehaviorY: 'contain',
}}
>
{children}
</div>
</div>
);
}
export function RoomViewMembersPanel({ header, children }: RoomViewMembersPanelProps) {
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
if (!isMobile) {
return (
<>
{header}
{children}
</>
);
}
return <MobileMembersHorseshoe header={header}>{children}</MobileMembersHorseshoe>;
}

View file

@ -0,0 +1,45 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
// Right-side members pane for desktop / tablet. Sized like the profile
// side panel — wide enough for the grouped role list but not so wide
// it eats the chat column. TL + BL corners carved through the 12 px
// horseshoe void gap rendered by `Room.tsx`, mirroring
// `RoomViewProfileSidePanel`.
export const panel = style({
flexShrink: 0,
width: `clamp(${toRem(260)}, 22%, ${toRem(340)})`,
display: 'flex',
flexDirection: 'column',
backgroundColor: color.Surface.Container,
minHeight: 0,
overflow: 'hidden',
borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
borderBottomLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
});
// Header rule lines up with the chat header's bottom border via the
// shared `PageHeader` chrome the host injects — same idiom as the
// profile side panel.
export const header = style({
paddingLeft: config.space.S300,
});
// Scroll container — `MembersList` renders its hero + grouped list
// flat, so this is the one element that owns vertical overflow on
// desktop. Scrollbar chrome is suppressed to match the rest of the
// Vojo right-pane surfaces (profile + media).
export const body = style({
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
scrollbarWidth: 'none',
selectors: {
'&::-webkit-scrollbar': {
display: 'none',
},
},
});

View file

@ -0,0 +1,88 @@
// Desktop / tablet right-side members pane. Same horseshoe void seam
// idiom as `RoomViewProfileSidePanel` (TL + BL carved across the 12 px
// gap painted by the parent flex row in `Room.tsx`). Mobile uses the
// top horseshoe `RoomViewMembersPanel` instead.
//
// Renders the same `MembersList` body the mobile horseshoe shows, so
// the two surfaces stay visually identical content-wise — only the
// chrome differs.
import React, { useEffect, useRef } from 'react';
import { useAtomValue } from 'jotai';
import { Box, Icon, IconButton, Icons, Text } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next';
import { roomMembersSheetAtom } from '../../state/roomMembersSheet';
import { useCloseRoomMembersSheet } from '../../state/hooks/roomMembersSheet';
import { useRoom } from '../../hooks/useRoom';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers';
import { usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { MembersList } from '../../components/members-list/MembersList';
import { stopPropagation } from '../../utils/keyboard';
import { PageHeader } from '../../components/page';
import { ContainerColor } from '../../styles/ContainerColor.css';
import * as css from './RoomViewMembersSidePanel.css';
export function RoomViewMembersSidePanel() {
const { t } = useTranslation();
const sheetState = useAtomValue(roomMembersSheetAtom);
const close = useCloseRoomMembersSheet();
const room = useRoom();
const mx = useMatrixClient();
const members = useRoomMembers(mx, room.roomId);
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const open = !!sheetState;
// Close sheet when the room changes — atom is global state.
useEffect(() => () => close(), [room.roomId, close]);
const sheetStateRef = useRef(sheetState);
sheetStateRef.current = sheetState;
if (!open) return null;
return (
<FocusTrap
focusTrapOptions={{
initialFocus: false,
// Outside clicks pass through to the chat but don't close the
// pane — mirrors `RoomViewProfileSidePanel`. Explicit close is
// via `×` or `Esc`.
clickOutsideDeactivates: false,
allowOutsideClick: () => true,
escapeDeactivates: stopPropagation,
onDeactivate: () => {
if (sheetStateRef.current) close();
},
checkCanFocusTrap: () => Promise.resolve(),
}}
active={open}
>
<div className={css.panel}>
<PageHeader className={`${ContainerColor({ variant: 'Surface' })} ${css.header}`}>
<Box grow="Yes" alignItems="Center">
<Text size="H4" truncate>
{t('Room.members_pane_title')}
</Text>
</Box>
<Box shrink="No" alignItems="Center">
<IconButton fill="None" onClick={close} aria-label={t('Room.close')}>
<Icon size="400" src={Icons.Cross} />
</IconButton>
</Box>
</PageHeader>
<div className={css.body}>
<MembersList room={room} members={members} getPowerTag={getPowerTag} />
</div>
</div>
</FocusTrap>
);
}

View file

@ -246,11 +246,14 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
if (liveUserId) lastUserIdRef.current = liveUserId; if (liveUserId) lastUserIdRef.current = liveUserId;
const renderUserId = liveUserId ?? lastUserIdRef.current; const renderUserId = liveUserId ?? lastUserIdRef.current;
// Drop width/height — the silhouette-filling fullview on a 3x DPR
// phone needs ~1200 native px wide, so a 720 thumbnail gets both
// recompressed by Synapse AND upscaled by the browser, visibly
// pixelating the avatar («шакалит»). Matches `MediaViewerBody` —
// the other Vojo fullscreen surface fetches the original.
const renderUserAvatarMxc = renderUserId ? getMemberAvatarMxc(room, renderUserId) : undefined; const renderUserAvatarMxc = renderUserId ? getMemberAvatarMxc(room, renderUserId) : undefined;
const renderUserAvatarUrl = const renderUserAvatarUrl =
(renderUserAvatarMxc && (renderUserAvatarMxc && mxcUrlToHttp(mx, renderUserAvatarMxc, useAuthentication)) ?? undefined;
mxcUrlToHttp(mx, renderUserAvatarMxc, useAuthentication, 720, 720, 'scale')) ??
undefined;
const dragRef = useRef<DragState | null>(null); const dragRef = useRef<DragState | null>(null);
dragRef.current = drag; dragRef.current = drag;

View file

@ -26,10 +26,19 @@ export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] =
mx.on(RoomMemberEvent.Membership, updateMemberList); mx.on(RoomMemberEvent.Membership, updateMemberList);
mx.on(RoomMemberEvent.PowerLevel, updateMemberList); mx.on(RoomMemberEvent.PowerLevel, updateMemberList);
// Display-name changes ride `RoomMemberEvent.Name` (per
// matrix-js-sdk `RoomMember.setRawDisplayName` → `RoomMember.emit
// (RoomMemberEvent.Name, ...)`). Without the subscription a peer
// renaming themselves stays under the old name in any list that
// reads `useRoomMembers` (Members sheet, Settings → Members,
// Lobby member-drawer, user-mention autocomplete). Element Web's
// `MemberListViewModel` subscribes to the same trio.
mx.on(RoomMemberEvent.Name, updateMemberList);
return () => { return () => {
disposed = true; disposed = true;
mx.removeListener(RoomMemberEvent.Membership, updateMemberList); mx.removeListener(RoomMemberEvent.Membership, updateMemberList);
mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList); mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList);
mx.removeListener(RoomMemberEvent.Name, updateMemberList);
}; };
}, [mx, roomId]); }, [mx, roomId]);

View file

@ -2,21 +2,23 @@ import { useCallback } from 'react';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { mediaViewerAtom, MediaViewerEntry } from '../mediaViewer'; import { mediaViewerAtom, MediaViewerEntry } from '../mediaViewer';
import { userRoomProfileAtom } from '../userRoomProfile'; import { userRoomProfileAtom } from '../userRoomProfile';
import { roomMembersSheetAtom } from '../roomMembersSheet';
export const useOpenMediaViewer = (): ((entry: MediaViewerEntry) => void) => { export const useOpenMediaViewer = (): ((entry: MediaViewerEntry) => void) => {
const setMedia = useSetAtom(mediaViewerAtom); const setMedia = useSetAtom(mediaViewerAtom);
// Close the profile side pane / horseshoe when opening media — // Close other right-pane sheets when opening media — profile / members
// having both open simultaneously fights for chat-column width on // / media all fight for the same desktop slot and would stack into two
// desktop and stacks two horseshoe surfaces on mobile. Mutual // horseshoes on mobile. Mutual exclusion is the simplest contract
// exclusivity is the simplest contract until the user asks for // until the user asks for stacking.
// stacking.
const setProfile = useSetAtom(userRoomProfileAtom); const setProfile = useSetAtom(userRoomProfileAtom);
const setRoomMembersSheet = useSetAtom(roomMembersSheetAtom);
return useCallback( return useCallback(
(entry) => { (entry) => {
setProfile(undefined); setProfile(undefined);
setRoomMembersSheet(undefined);
setMedia(entry); setMedia(entry);
}, },
[setMedia, setProfile] [setMedia, setProfile, setRoomMembersSheet]
); );
}; };

View file

@ -0,0 +1,34 @@
import { useCallback } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { roomMembersSheetAtom, RoomMembersSheetState } from '../roomMembersSheet';
import { mediaViewerAtom } from '../mediaViewer';
import { userRoomProfileAtom } from '../userRoomProfile';
export const useRoomMembersSheetState = (): RoomMembersSheetState | undefined =>
useAtomValue(roomMembersSheetAtom);
type CloseCallback = () => void;
export const useCloseRoomMembersSheet = (): CloseCallback => {
const setSheet = useSetAtom(roomMembersSheetAtom);
return useCallback(() => setSheet(undefined), [setSheet]);
};
type OpenCallback = (roomId: string) => void;
export const useOpenRoomMembersSheet = (): OpenCallback => {
const setSheet = useSetAtom(roomMembersSheetAtom);
// Mutual exclusion with the other right-pane surfaces — same idiom as
// `useOpenUserRoomProfile` clears `mediaViewerAtom`. On desktop the
// members sheet, the user-room-profile sheet, and the media viewer
// share the right-pane slot; only one can be visible.
const setMediaViewer = useSetAtom(mediaViewerAtom);
const setUserRoomProfile = useSetAtom(userRoomProfileAtom);
return useCallback(
(roomId: string) => {
setMediaViewer(undefined);
setUserRoomProfile(undefined);
setSheet({ roomId });
},
[setSheet, setMediaViewer, setUserRoomProfile]
);
};

View file

@ -3,6 +3,7 @@ import { useAtomValue, useSetAtom } from 'jotai';
import { Position, RectCords } from 'folds'; import { Position, RectCords } from 'folds';
import { userRoomProfileAtom, UserRoomProfileState } from '../userRoomProfile'; import { userRoomProfileAtom, UserRoomProfileState } from '../userRoomProfile';
import { mediaViewerAtom } from '../mediaViewer'; import { mediaViewerAtom } from '../mediaViewer';
import { roomMembersSheetAtom } from '../roomMembersSheet';
export const useUserRoomProfileState = (): UserRoomProfileState | undefined => { export const useUserRoomProfileState = (): UserRoomProfileState | undefined => {
const data = useAtomValue(userRoomProfileAtom); const data = useAtomValue(userRoomProfileAtom);
@ -37,13 +38,19 @@ export const useOpenUserRoomProfile = (): OpenCallback => {
// `RoomViewMediaSidePanel` would mount as siblings and fight for // `RoomViewMediaSidePanel` would mount as siblings and fight for
// the right-pane slot. // the right-pane slot.
const setMediaViewer = useSetAtom(mediaViewerAtom); const setMediaViewer = useSetAtom(mediaViewerAtom);
// Symmetric mutual exclusion with the group-room members sheet — when
// the user taps a row inside the members list, the per-user profile
// takes over the right-pane slot. On mobile the members horseshoe
// closes its own way; this also handles the desktop side-pane swap.
const setRoomMembersSheet = useSetAtom(roomMembersSheetAtom);
const open: OpenCallback = useCallback( const open: OpenCallback = useCallback(
(roomId, spaceId, userId, cords, position) => { (roomId, spaceId, userId, cords, position) => {
setMediaViewer(undefined); setMediaViewer(undefined);
setRoomMembersSheet(undefined);
setUserRoomProfile({ roomId, spaceId, userId, cords, position }); setUserRoomProfile({ roomId, spaceId, userId, cords, position });
}, },
[setUserRoomProfile, setMediaViewer] [setUserRoomProfile, setMediaViewer, setRoomMembersSheet]
); );
return open; return open;

View file

@ -0,0 +1,24 @@
import { atom } from 'jotai';
// Transient open/closed state for the group-room members sheet — mirrors
// `userRoomProfileAtom` (the 1:1 peer-profile sheet) in semantics and
// lifetime. `undefined` ↔ closed; `{ roomId }` ↔ open against that room.
//
// Closes implicitly on:
// • room change (effect inside RoomViewMembersPanel /
// RoomViewMembersSidePanel mirrors the profile
// sheet's close-on-room-change effect)
// • drag-up (mobile) / `×` / Esc (desktop)
// • user opens the per-row UserRoomProfile from the list — `useOpenUser
// RoomProfile` already clears `mediaViewerAtom`; we use the same
// mutual-exclusion pattern here so the two sheets don't fight for
// the right-pane slot on desktop.
//
// Distinct from `settingsAtom.isPeopleDrawer` (localStorage flag) which
// still governs `LobbyHeader` + `Lobby` for space lobbies. The room view
// stops reading `isPeopleDrawer`; the lobby keeps it.
export type RoomMembersSheetState = {
roomId: string;
};
export const roomMembersSheetAtom = atom<RoomMembersSheetState | undefined>(undefined);