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);
} }
}, },
@ -214,114 +216,111 @@ export function Room({ renderRoomView }: RoomProps) {
<PowerLevelsContextProvider value={powerLevels}> <PowerLevelsContextProvider value={powerLevels}>
<ThreadDrawerOpenProvider value={showThreadDrawer}> <ThreadDrawerOpenProvider value={showThreadDrawer}>
<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 } {callView && (screenSize === ScreenSize.Desktop || !chat) && (
: undefined <Box
} grow="Yes"
> direction="Column"
{callView && (screenSize === ScreenSize.Desktop || !chat) && ( className={paintParentVoid ? ContainerColor({ variant: 'Background' }) : undefined}
<Box // No chat-column padding-top / bg: the silhouette inside
grow="Yes" // `MobileProfileHorseshoe` owns the safe-top inset and bg.
direction="Column" // See the !callView twin block below for the rationale.
className={ >
paintParentVoid <MobileMediaViewerHorseshoe>
? ContainerColor({ variant: 'Background' }) {/* `useMembersWrapper` is gated on `!callView`, so this
: undefined branch is always the profile wrapper kept explicit
} for parity with the `!callView` twin below. */}
// No chat-column padding-top / bg: the silhouette inside <RoomViewProfilePanel header={<RoomViewHeader callView />}>
// `MobileProfileHorseshoe` owns the safe-top inset and bg. <Box grow="Yes">
// See the !callView twin block below for the rationale. <CallView />
> </Box>
<MobileMediaViewerHorseshoe> </RoomViewProfilePanel>
<RoomViewProfilePanel header={<RoomViewHeader callView />}> </MobileMediaViewerHorseshoe>
<Box grow="Yes"> </Box>
<CallView /> )}
</Box> {!callView && !drawerHidesChat && (
</RoomViewProfilePanel> <Box
</MobileMediaViewerHorseshoe> grow="Yes"
</Box> direction="Column"
)} className={paintParentVoid ? ContainerColor({ variant: 'Background' }) : undefined}
{!callView && !drawerHidesChat && ( // No padding-top / bg on mobile chat column: whichever
<Box // mobile horseshoe wraps the chat (profile for 1:1,
grow="Yes" // members for group / channel) permanently sits at
direction="Column" // body_top with its own `padding-top: var(--vojo-safe-
className={ // top)` keeping chat-header / panel content below the
paintParentVoid // status-bar icons. The silhouette's bg fades between
? ContainerColor({ variant: 'Background' }) // chat-surface tone (when closed) and sheet tone (when
: undefined // fully open) as the user drags. Desktop branch still
} // works because `--vojo-safe-top` resolves to 0 on web.
// No padding-top / bg on mobile chat column: the silhouette >
// inside `MobileProfileHorseshoe` permanently sits at body_top <MobileMediaViewerHorseshoe>
// with its own `padding-top: var(--vojo-safe-top)` keeping {useMembersWrapper ? (
// chat-header / panel content below the status-bar icons. <RoomViewMembersPanel header={<RoomViewHeader />}>
// The silhouette's bg fades between chat-surface tone (when <Box grow="Yes">
// closed) and user-card tone (when fully open) as the user {renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}
// drags. Desktop branch still works because `--vojo-safe-top` </Box>
// resolves to 0 on web. </RoomViewMembersPanel>
> ) : (
<MobileMediaViewerHorseshoe> <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>
)} )}
{/* Tablet / Desktop: profile or media renders as a third pane {/* Tablet / Desktop: profile or media renders as a third pane
to the right of the chat. Mobile uses the top horseshoe to the right of the chat. Mobile uses the top horseshoe
(profile) and the bottom horseshoe (media), so we don't (profile) and the bottom horseshoe (media), so we don't
mount the side panes there. The 12px void gap sits between mount the side panes there. The 12px void gap sits between
the chat column and whichever pane is open both panes the chat column and whichever pane is open both panes
share the same gap geometry since they're mutually share the same gap geometry since they're mutually
exclusive via the open hooks. */} exclusive via the open hooks. */}
{!isMobile && !showThreadDrawer && ( {!isMobile && !showThreadDrawer && (
<>
{(showProfileHorseshoe || showMediaHorseshoe) && <VoidGap />}
<RoomViewProfileSidePanel />
<RoomViewMediaSidePanel />
</>
)}
{callView && chat && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<CallChatView />
</>
)}
{/* Members drawer hidden when thread drawer is open three
simultaneous side panes don't fit the chat column on
anything narrower than ultrawide. The thread is the more
recent intent so it wins. */}
{!callView &&
!isOneOnOne &&
!showThreadDrawer &&
screenSize === ScreenSize.Desktop &&
isDrawer && (
<> <>
{showMembersHorseshoe && <VoidGap />} {(showProfileHorseshoe || showMediaHorseshoe) && <VoidGap />}
<MembersDrawer key={room.roomId} room={room} members={members} /> <RoomViewProfileSidePanel />
<RoomViewMediaSidePanel />
</> </>
)} )}
{showThreadDrawer && decodedRootId && parentRoomPath && (
<> {callView && chat && (
{showThreadHorseshoe && <VoidGap />} <>
<ThreadDrawer {screenSize === ScreenSize.Desktop && (
key={`${room.roomId}/${decodedRootId}`} <Line variant="Background" direction="Vertical" size="300" />
room={room} )}
rootId={decodedRootId} <CallChatView />
parentRoomPath={parentRoomPath} </>
variant={isMobile ? 'mobile' : 'desktop'} )}
/> {/* Members side pane atom-driven (transient), opens via
</> tap on avatar+title in the chat header (see
)} `RoomViewHeaderDm`). Hidden when thread drawer is open
</Box> three simultaneous side panes don't fit on anything
narrower than ultrawide. */}
{showMembersHorseshoe && (
<>
<VoidGap />
<RoomViewMembersSidePanel key={room.roomId} />
</>
)}
{showThreadDrawer && decodedRootId && parentRoomPath && (
<>
{showThreadHorseshoe && <VoidGap />}
<ThreadDrawer
key={`${room.roomId}/${decodedRootId}`}
room={room}
rootId={decodedRootId}
parentRoomPath={parentRoomPath}
variant={isMobile ? 'mobile' : 'desktop'}
/>
</>
)}
</Box>
</MediaViewerHostContext.Provider> </MediaViewerHostContext.Provider>
</ThreadDrawerOpenProvider> </ThreadDrawerOpenProvider>
</PowerLevelsContextProvider> </PowerLevelsContextProvider>

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);