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:
parent
1665cb185f
commit
aa3dbc13ef
17 changed files with 1682 additions and 156 deletions
|
|
@ -484,6 +484,10 @@
|
|||
"members_count_other": "{{formattedCount}} Members",
|
||||
"hide_members": "Hide 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",
|
||||
"close": "Close",
|
||||
"search": "Search",
|
||||
|
|
|
|||
|
|
@ -492,6 +492,12 @@
|
|||
"members_count_other": "{{formattedCount}} участника",
|
||||
"hide_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": "Ещё",
|
||||
"close": "Закрыть",
|
||||
"search": "Поиск",
|
||||
|
|
|
|||
199
src/app/components/members-list/MembersList.tsx
Normal file
199
src/app/components/members-list/MembersList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src/app/components/members-list/RoomMembersHero.tsx
Normal file
131
src/app/components/members-list/RoomMembersHero.tsx
Normal 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 = 192–288 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>
|
||||
);
|
||||
}
|
||||
187
src/app/components/members-list/styles.css.ts
Normal file
187
src/app/components/members-list/styles.css.ts
Normal 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),
|
||||
});
|
||||
|
|
@ -7,7 +7,6 @@ import { RoomView } from './RoomView';
|
|||
import { userRoomProfileAtom } from '../../state/userRoomProfile';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
|
||||
import { MembersDrawer } from './MembersDrawer';
|
||||
import { ThreadDrawer } from './ThreadDrawer';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
|
|
@ -17,17 +16,19 @@ import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
|
|||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||
import { CallView } from '../call/CallView';
|
||||
import { RoomViewHeader } from './RoomViewHeader';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { CallChatView } from './CallChatView';
|
||||
import { RoomViewProfilePanel } from './RoomViewProfilePanel';
|
||||
import { RoomViewProfileSidePanel } from './RoomViewProfileSidePanel';
|
||||
import { RoomViewMembersPanel } from './RoomViewMembersPanel';
|
||||
import { RoomViewMembersSidePanel } from './RoomViewMembersSidePanel';
|
||||
import { MobileMediaViewerHorseshoe } from './MobileMediaViewerHorseshoe';
|
||||
import { RoomViewMediaSidePanel } from './RoomViewMediaSidePanel';
|
||||
import { MediaViewerHostContext } from './mediaViewerHostContext';
|
||||
import { mediaViewerAtom } from '../../state/mediaViewer';
|
||||
import { roomMembersSheetAtom } from '../../state/roomMembersSheet';
|
||||
import { useChannelsMode, ThreadDrawerOpenProvider } from '../../hooks/useChannelsMode';
|
||||
import { CHANNELS_THREAD_PATH } from '../../pages/paths';
|
||||
import { getChannelsRoomPath } from '../../pages/pathUtils';
|
||||
|
|
@ -94,7 +95,10 @@ export function Room({ renderRoomView }: RoomProps) {
|
|||
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 screenSize = useScreenSizeContext();
|
||||
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.
|
||||
const drawerHidesChat = showThreadDrawer && isMobile;
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const members = useRoomMembers(mx, room.roomId);
|
||||
const chat = useAtomValue(callChatAtom);
|
||||
// 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
|
||||
|
|
@ -131,6 +134,7 @@ export function Room({ renderRoomView }: RoomProps) {
|
|||
// parent void can't bleed through any transparent slivers.
|
||||
const profileOpen = !!useAtomValue(userRoomProfileAtom);
|
||||
const mediaOpen = !!useAtomValue(mediaViewerAtom);
|
||||
const membersSheetOpen = !!useAtomValue(roomMembersSheetAtom);
|
||||
const callView = room.isCallRoom();
|
||||
const showProfileHorseshoe = profileOpen && !isMobile && !showThreadDrawer;
|
||||
// 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
|
||||
// entirely (`drawerHidesChat`) so the seam doesn't apply there.
|
||||
const showThreadHorseshoe = showThreadDrawer && !isMobile;
|
||||
// Members drawer side pane — mirrors the same horseshoe seam as the
|
||||
// profile/media/thread panes. Gated on the exact same conditions that
|
||||
// mount `<MembersDrawer>` below (group room, desktop, drawer setting on,
|
||||
// no thread overlay, no call surface) so the void gap appears iff the
|
||||
// pane appears.
|
||||
// Members side pane — mirrors the profile / media seam. Gated on:
|
||||
// any non-1:1 room (group DM, group room, channel) on desktop, atom
|
||||
// open, no thread overlay, no call surface. The atom-driven model
|
||||
// replaces the legacy `settingsAtom.isPeopleDrawer` localStorage flag
|
||||
// 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 =
|
||||
!callView &&
|
||||
!isOneOnOne &&
|
||||
!showThreadDrawer &&
|
||||
screenSize === ScreenSize.Desktop &&
|
||||
isDrawer;
|
||||
!callView && !isOneOnOne && !showThreadDrawer && !isMobile && membersSheetOpen;
|
||||
// Mobile mounts the members horseshoe wrapper for every non-1:1,
|
||||
// non-call room — group DMs, group rooms, AND channels. 1:1 rooms
|
||||
// keep using `RoomViewProfilePanel`; the call surface routes member
|
||||
// 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
|
||||
// row's void background and the chat column's explicit Background paint
|
||||
// — 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
|
||||
// profile/media slot to anchor it to).
|
||||
const paintParentVoid =
|
||||
showProfileHorseshoe ||
|
||||
showMediaHorseshoe ||
|
||||
showThreadHorseshoe ||
|
||||
showMembersHorseshoe;
|
||||
showProfileHorseshoe || showMediaHorseshoe || showThreadHorseshoe || showMembersHorseshoe;
|
||||
|
||||
useKeyDown(
|
||||
window,
|
||||
|
|
@ -179,10 +184,7 @@ export function Room({ renderRoomView }: RoomProps) {
|
|||
// surface marks main as read and leaves threads alone — works
|
||||
// unconditionally. Under the kill switch the handler reverts
|
||||
// to blind DELETE, so we keep the drawer-open guard there.
|
||||
if (
|
||||
isKeyHotkey('escape', evt) &&
|
||||
(unreadThreadingEnabled || !showThreadDrawer)
|
||||
) {
|
||||
if (isKeyHotkey('escape', evt) && (unreadThreadingEnabled || !showThreadDrawer)) {
|
||||
markAsRead(mx, room.roomId, hideActivity);
|
||||
}
|
||||
},
|
||||
|
|
@ -214,114 +216,111 @@ export function Room({ renderRoomView }: RoomProps) {
|
|||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
<ThreadDrawerOpenProvider value={showThreadDrawer}>
|
||||
<MediaViewerHostContext.Provider value={mediaHostValue}>
|
||||
<Box
|
||||
grow="Yes"
|
||||
style={
|
||||
paintParentVoid
|
||||
? { backgroundColor: VOJO_HORSESHOE_VOID_COLOR }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
|
||||
<Box
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
className={
|
||||
paintParentVoid
|
||||
? ContainerColor({ variant: 'Background' })
|
||||
: undefined
|
||||
}
|
||||
// No chat-column padding-top / bg: the silhouette inside
|
||||
// `MobileProfileHorseshoe` owns the safe-top inset and bg.
|
||||
// See the !callView twin block below for the rationale.
|
||||
>
|
||||
<MobileMediaViewerHorseshoe>
|
||||
<RoomViewProfilePanel header={<RoomViewHeader callView />}>
|
||||
<Box grow="Yes">
|
||||
<CallView />
|
||||
</Box>
|
||||
</RoomViewProfilePanel>
|
||||
</MobileMediaViewerHorseshoe>
|
||||
</Box>
|
||||
)}
|
||||
{!callView && !drawerHidesChat && (
|
||||
<Box
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
className={
|
||||
paintParentVoid
|
||||
? ContainerColor({ variant: 'Background' })
|
||||
: undefined
|
||||
}
|
||||
// No padding-top / bg on mobile chat column: the silhouette
|
||||
// inside `MobileProfileHorseshoe` permanently sits at body_top
|
||||
// with its own `padding-top: var(--vojo-safe-top)` keeping
|
||||
// chat-header / panel content below the status-bar icons.
|
||||
// The silhouette's bg fades between chat-surface tone (when
|
||||
// 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>
|
||||
<RoomViewProfilePanel header={<RoomViewHeader />}>
|
||||
<Box grow="Yes">
|
||||
{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}
|
||||
</Box>
|
||||
</RoomViewProfilePanel>
|
||||
</MobileMediaViewerHorseshoe>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
grow="Yes"
|
||||
style={paintParentVoid ? { backgroundColor: VOJO_HORSESHOE_VOID_COLOR } : undefined}
|
||||
>
|
||||
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
|
||||
<Box
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
className={paintParentVoid ? ContainerColor({ variant: 'Background' }) : undefined}
|
||||
// No chat-column padding-top / bg: the silhouette inside
|
||||
// `MobileProfileHorseshoe` owns the safe-top inset and bg.
|
||||
// See the !callView twin block below for the rationale.
|
||||
>
|
||||
<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 />}>
|
||||
<Box grow="Yes">
|
||||
<CallView />
|
||||
</Box>
|
||||
</RoomViewProfilePanel>
|
||||
</MobileMediaViewerHorseshoe>
|
||||
</Box>
|
||||
)}
|
||||
{!callView && !drawerHidesChat && (
|
||||
<Box
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
className={paintParentVoid ? ContainerColor({ variant: 'Background' }) : undefined}
|
||||
// No padding-top / bg on mobile chat column: whichever
|
||||
// mobile horseshoe wraps the chat (profile for 1:1,
|
||||
// members for group / channel) permanently sits at
|
||||
// body_top with its own `padding-top: var(--vojo-safe-
|
||||
// top)` keeping chat-header / panel content below the
|
||||
// status-bar icons. The silhouette's bg fades between
|
||||
// chat-surface tone (when closed) and sheet tone (when
|
||||
// fully open) as the user drags. Desktop branch still
|
||||
// works because `--vojo-safe-top` resolves to 0 on web.
|
||||
>
|
||||
<MobileMediaViewerHorseshoe>
|
||||
{useMembersWrapper ? (
|
||||
<RoomViewMembersPanel header={<RoomViewHeader />}>
|
||||
<Box grow="Yes">
|
||||
{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}
|
||||
</Box>
|
||||
</RoomViewMembersPanel>
|
||||
) : (
|
||||
<RoomViewProfilePanel header={<RoomViewHeader />}>
|
||||
<Box grow="Yes">
|
||||
{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}
|
||||
</Box>
|
||||
</RoomViewProfilePanel>
|
||||
)}
|
||||
</MobileMediaViewerHorseshoe>
|
||||
</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
|
||||
(profile) and the bottom horseshoe (media), so we don't
|
||||
mount the side panes there. The 12px void gap sits between
|
||||
the chat column and whichever pane is open — both panes
|
||||
share the same gap geometry since they're mutually
|
||||
exclusive via the open hooks. */}
|
||||
{!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 && (
|
||||
{!isMobile && !showThreadDrawer && (
|
||||
<>
|
||||
{showMembersHorseshoe && <VoidGap />}
|
||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||
{(showProfileHorseshoe || showMediaHorseshoe) && <VoidGap />}
|
||||
<RoomViewProfileSidePanel />
|
||||
<RoomViewMediaSidePanel />
|
||||
</>
|
||||
)}
|
||||
{showThreadDrawer && decodedRootId && parentRoomPath && (
|
||||
<>
|
||||
{showThreadHorseshoe && <VoidGap />}
|
||||
<ThreadDrawer
|
||||
key={`${room.roomId}/${decodedRootId}`}
|
||||
room={room}
|
||||
rootId={decodedRootId}
|
||||
parentRoomPath={parentRoomPath}
|
||||
variant={isMobile ? 'mobile' : 'desktop'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{callView && chat && (
|
||||
<>
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
)}
|
||||
<CallChatView />
|
||||
</>
|
||||
)}
|
||||
{/* Members side pane — atom-driven (transient), opens via
|
||||
tap on avatar+title in the chat header (see
|
||||
`RoomViewHeaderDm`). Hidden when thread drawer is open —
|
||||
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>
|
||||
</ThreadDrawerOpenProvider>
|
||||
</PowerLevelsContextProvider>
|
||||
|
|
|
|||
|
|
@ -60,6 +60,11 @@ import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
|||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import {
|
||||
useCloseRoomMembersSheet,
|
||||
useOpenRoomMembersSheet,
|
||||
useRoomMembersSheetState,
|
||||
} from '../../state/hooks/roomMembersSheet';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import { searchModalAtom } from '../../state/searchModal';
|
||||
|
|
@ -495,12 +500,14 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
|
|||
const peerPresence = useUserPresence(peerUserId ?? '');
|
||||
const peerOnline = !!peerUserId && peerPresence?.presence === Presence.Online;
|
||||
|
||||
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||
const setSearchOpen = useSetAtom(searchModalAtom);
|
||||
const openUserRoomProfile = useOpenUserRoomProfile();
|
||||
const openRoomMembersSheet = useOpenRoomMembersSheet();
|
||||
const closeRoomMembersSheet = useCloseRoomMembersSheet();
|
||||
const membersSheetState = useRoomMembersSheetState();
|
||||
const membersSheetOpen = membersSheetState?.roomId === room.roomId;
|
||||
const parentSpace = useSpaceOptionally();
|
||||
const openSettings = useOpenRoomSettings();
|
||||
|
||||
|
|
@ -523,12 +530,18 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
|
|||
'Bottom'
|
||||
);
|
||||
};
|
||||
const handleMemberToggle = () => {
|
||||
if (callView) {
|
||||
openSettings(room.roomId, parentSpace?.roomId, RoomSettingsPage.MembersPage);
|
||||
return;
|
||||
const handleGroupIdentityClick = () => {
|
||||
if (membersSheetOpen) {
|
||||
closeRoomMembersSheet();
|
||||
} 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 = (
|
||||
|
|
@ -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
|
||||
// anywhere on the user identity (avatar, name, handle, online tag)
|
||||
// opens the user-room-profile sheet — one tab stop, one focus
|
||||
// outline, single popout anchor. Groups keep the avatar non-clickable
|
||||
// and the title as a plain row.
|
||||
const identityArea =
|
||||
isOneOnOne && peerUserId ? (
|
||||
// outline, single popout anchor. For group rooms the same button
|
||||
// pattern opens the members sheet instead — keeps the gesture
|
||||
// language symmetrical across 1:1 and group. `callView` falls back
|
||||
// to a static row because neither sheet is reachable inside the
|
||||
// call surface.
|
||||
let identityArea: React.ReactNode;
|
||||
if (isOneOnOne && peerUserId) {
|
||||
identityArea = (
|
||||
<button
|
||||
type="button"
|
||||
className={css.PeerIdentityTrigger}
|
||||
|
|
@ -623,12 +640,28 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
|
|||
{avatarNode}
|
||||
{titleBlock}
|
||||
</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>
|
||||
{titleBlock}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageHeader
|
||||
|
|
@ -657,29 +690,24 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
|
|||
<Box shrink="No" alignItems="Center">
|
||||
{callButtonVisible && <DmCallButton room={room} />}
|
||||
|
||||
{/* Member toggle — desktop-only. Visible in two cases:
|
||||
- group rooms (>2 members): toggles the members drawer.
|
||||
- call rooms (`callView=true`), regardless of size: routes to
|
||||
Room Settings → Members so 1:1 call rooms can still reach
|
||||
the member list (drawer doesn't render in callView).
|
||||
For non-call 1:1 rooms it stays hidden because the avatar tap
|
||||
already opens the peer profile sheet. */}
|
||||
{(callView || !isOneOnOne) && screenSize === ScreenSize.Desktop && (
|
||||
{/* Member toggle — kept only for `callView` (1:1 or group): the
|
||||
members side-pane / horseshoe isn't mounted under the call
|
||||
surface, so we still need a way to reach the member list
|
||||
via Room Settings → Members. Non-call group rooms now use
|
||||
the identity button above (tap on avatar+title) — keeps
|
||||
the gesture symmetrical with 1:1 peer profile. */}
|
||||
{callView && screenSize === ScreenSize.Desktop && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
{callView ? (
|
||||
<Text>{t('Room.members')}</Text>
|
||||
) : (
|
||||
<Text>{peopleDrawer ? t('Room.hide_members') : t('Room.show_members')}</Text>
|
||||
)}
|
||||
<Text>{t('Room.members')}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton fill="None" ref={triggerRef} onClick={handleMemberToggle}>
|
||||
<IconButton fill="None" ref={triggerRef} onClick={handleCallViewMembers}>
|
||||
<Icon size="400" src={Icons.User} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
|
|||
128
src/app/features/room/RoomViewMembersPanel.css.ts
Normal file
128
src/app/features/room/RoomViewMembersPanel.css.ts
Normal 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',
|
||||
});
|
||||
632
src/app/features/room/RoomViewMembersPanel.tsx
Normal file
632
src/app/features/room/RoomViewMembersPanel.tsx
Normal 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>;
|
||||
}
|
||||
45
src/app/features/room/RoomViewMembersSidePanel.css.ts
Normal file
45
src/app/features/room/RoomViewMembersSidePanel.css.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
88
src/app/features/room/RoomViewMembersSidePanel.tsx
Normal file
88
src/app/features/room/RoomViewMembersSidePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -246,11 +246,14 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
|
|||
if (liveUserId) lastUserIdRef.current = liveUserId;
|
||||
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 renderUserAvatarUrl =
|
||||
(renderUserAvatarMxc &&
|
||||
mxcUrlToHttp(mx, renderUserAvatarMxc, useAuthentication, 720, 720, 'scale')) ??
|
||||
undefined;
|
||||
(renderUserAvatarMxc && mxcUrlToHttp(mx, renderUserAvatarMxc, useAuthentication)) ?? undefined;
|
||||
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
dragRef.current = drag;
|
||||
|
|
|
|||
|
|
@ -26,10 +26,19 @@ export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] =
|
|||
|
||||
mx.on(RoomMemberEvent.Membership, 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 () => {
|
||||
disposed = true;
|
||||
mx.removeListener(RoomMemberEvent.Membership, updateMemberList);
|
||||
mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList);
|
||||
mx.removeListener(RoomMemberEvent.Name, updateMemberList);
|
||||
};
|
||||
}, [mx, roomId]);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,21 +2,23 @@ import { useCallback } from 'react';
|
|||
import { useSetAtom } from 'jotai';
|
||||
import { mediaViewerAtom, MediaViewerEntry } from '../mediaViewer';
|
||||
import { userRoomProfileAtom } from '../userRoomProfile';
|
||||
import { roomMembersSheetAtom } from '../roomMembersSheet';
|
||||
|
||||
export const useOpenMediaViewer = (): ((entry: MediaViewerEntry) => void) => {
|
||||
const setMedia = useSetAtom(mediaViewerAtom);
|
||||
// Close the profile side pane / horseshoe when opening media —
|
||||
// having both open simultaneously fights for chat-column width on
|
||||
// desktop and stacks two horseshoe surfaces on mobile. Mutual
|
||||
// exclusivity is the simplest contract until the user asks for
|
||||
// stacking.
|
||||
// Close other right-pane sheets when opening media — profile / members
|
||||
// / media all fight for the same desktop slot and would stack into two
|
||||
// horseshoes on mobile. Mutual exclusion is the simplest contract
|
||||
// until the user asks for stacking.
|
||||
const setProfile = useSetAtom(userRoomProfileAtom);
|
||||
const setRoomMembersSheet = useSetAtom(roomMembersSheetAtom);
|
||||
return useCallback(
|
||||
(entry) => {
|
||||
setProfile(undefined);
|
||||
setRoomMembersSheet(undefined);
|
||||
setMedia(entry);
|
||||
},
|
||||
[setMedia, setProfile]
|
||||
[setMedia, setProfile, setRoomMembersSheet]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
34
src/app/state/hooks/roomMembersSheet.ts
Normal file
34
src/app/state/hooks/roomMembersSheet.ts
Normal 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]
|
||||
);
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@ import { useAtomValue, useSetAtom } from 'jotai';
|
|||
import { Position, RectCords } from 'folds';
|
||||
import { userRoomProfileAtom, UserRoomProfileState } from '../userRoomProfile';
|
||||
import { mediaViewerAtom } from '../mediaViewer';
|
||||
import { roomMembersSheetAtom } from '../roomMembersSheet';
|
||||
|
||||
export const useUserRoomProfileState = (): UserRoomProfileState | undefined => {
|
||||
const data = useAtomValue(userRoomProfileAtom);
|
||||
|
|
@ -37,13 +38,19 @@ export const useOpenUserRoomProfile = (): OpenCallback => {
|
|||
// `RoomViewMediaSidePanel` would mount as siblings and fight for
|
||||
// the right-pane slot.
|
||||
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(
|
||||
(roomId, spaceId, userId, cords, position) => {
|
||||
setMediaViewer(undefined);
|
||||
setRoomMembersSheet(undefined);
|
||||
setUserRoomProfile({ roomId, spaceId, userId, cords, position });
|
||||
},
|
||||
[setUserRoomProfile, setMediaViewer]
|
||||
[setUserRoomProfile, setMediaViewer, setRoomMembersSheet]
|
||||
);
|
||||
|
||||
return open;
|
||||
|
|
|
|||
24
src/app/state/roomMembersSheet.ts
Normal file
24
src/app/state/roomMembersSheet.ts
Normal 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);
|
||||
Loading…
Add table
Reference in a new issue