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