From aa3dbc13ef34848c1dc0b723e621cf35af84c1d8 Mon Sep 17 00:00:00 2001 From: heaven Date: Thu, 28 May 2026 20:51:37 +0300 Subject: [PATCH] feat(members): replace MembersDrawer with Dawn-styled members sheet and group hero for every non-1:1 room and channel --- public/locales/en.json | 4 + public/locales/ru.json | 6 + .../components/members-list/MembersList.tsx | 199 ++++++ .../members-list/RoomMembersHero.tsx | 131 ++++ src/app/components/members-list/styles.css.ts | 187 ++++++ src/app/features/room/Room.tsx | 239 ++++--- src/app/features/room/RoomViewHeaderDm.tsx | 80 ++- .../features/room/RoomViewMembersPanel.css.ts | 128 ++++ .../features/room/RoomViewMembersPanel.tsx | 632 ++++++++++++++++++ .../room/RoomViewMembersSidePanel.css.ts | 45 ++ .../room/RoomViewMembersSidePanel.tsx | 88 +++ .../features/room/RoomViewProfilePanel.tsx | 9 +- src/app/hooks/useRoomMembers.ts | 9 + src/app/state/hooks/mediaViewer.ts | 14 +- src/app/state/hooks/roomMembersSheet.ts | 34 + src/app/state/hooks/userRoomProfile.ts | 9 +- src/app/state/roomMembersSheet.ts | 24 + 17 files changed, 1682 insertions(+), 156 deletions(-) create mode 100644 src/app/components/members-list/MembersList.tsx create mode 100644 src/app/components/members-list/RoomMembersHero.tsx create mode 100644 src/app/components/members-list/styles.css.ts create mode 100644 src/app/features/room/RoomViewMembersPanel.css.ts create mode 100644 src/app/features/room/RoomViewMembersPanel.tsx create mode 100644 src/app/features/room/RoomViewMembersSidePanel.css.ts create mode 100644 src/app/features/room/RoomViewMembersSidePanel.tsx create mode 100644 src/app/state/hooks/roomMembersSheet.ts create mode 100644 src/app/state/roomMembersSheet.ts diff --git a/public/locales/en.json b/public/locales/en.json index 9912b342..bbfaaae1 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -484,6 +484,10 @@ "members_count_other": "{{formattedCount}} Members", "hide_members": "Hide Members", "show_members": "Show Members", + "members_pane_title": "Members", + "members_sheet_title_one": "{{formattedCount}} member", + "members_sheet_title_other": "{{formattedCount}} members", + "open_members_of": "Open members of {{name}}", "more_options": "More Options", "close": "Close", "search": "Search", diff --git a/public/locales/ru.json b/public/locales/ru.json index adb18d75..0e6c95a7 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -492,6 +492,12 @@ "members_count_other": "{{formattedCount}} участника", "hide_members": "Скрыть участников", "show_members": "Показать участников", + "members_pane_title": "Участники", + "members_sheet_title_one": "{{formattedCount}} участник", + "members_sheet_title_few": "{{formattedCount}} участника", + "members_sheet_title_many": "{{formattedCount}} участников", + "members_sheet_title_other": "{{formattedCount}} участника", + "open_members_of": "Открыть участников: {{name}}", "more_options": "Ещё", "close": "Закрыть", "search": "Поиск", diff --git a/src/app/components/members-list/MembersList.tsx b/src/app/components/members-list/MembersList.tsx new file mode 100644 index 00000000..91602622 --- /dev/null +++ b/src/app/components/members-list/MembersList.tsx @@ -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 ( + + ); +} + +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(); + 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(); + 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 ( +
+ +
+ {flat.map((entry, idx) => { + if (!('userId' in entry)) { + const count = tagCounts.get(entry) ?? 0; + return ( +
+ + {entry.name} + + + {count} + +
+ ); + } + return ( + + ); + })} +
+
+ ); +} diff --git a/src/app/components/members-list/RoomMembersHero.tsx b/src/app/components/members-list/RoomMembersHero.tsx new file mode 100644 index 00000000..9e9226d3 --- /dev/null +++ b/src/app/components/members-list/RoomMembersHero.tsx @@ -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 `` 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 + // `` 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 = ( + + ( + + )} + /> + + ); + + return ( + + {onAvatarClick ? ( + + ) : ( + avatarNode + )} + + + {name} + + + + + {t('Room.members_sheet_title', { + count: memberCount, + formattedCount: millify(memberCount), + })} + + {encrypted && ( + <> + + · + + + + + {t('Room.encrypted_short')} + + + + )} + + + {topic && ( + + {topic} + + )} + + ); +} diff --git a/src/app/components/members-list/styles.css.ts b/src/app/components/members-list/styles.css.ts new file mode 100644 index 00000000..c726ad55 --- /dev/null +++ b/src/app/components/members-list/styles.css.ts @@ -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 +// ` + ); + } else { + identityArea = ( <> {avatarNode} {titleBlock} ); + } return ( {callButtonVisible && } - {/* Member toggle — desktop-only. Visible in two cases: - - group rooms (>2 members): toggles the members drawer. - - call rooms (`callView=true`), regardless of size: routes to - Room Settings → Members so 1:1 call rooms can still reach - the member list (drawer doesn't render in callView). - For non-call 1:1 rooms it stays hidden because the avatar tap - already opens the peer profile sheet. */} - {(callView || !isOneOnOne) && screenSize === ScreenSize.Desktop && ( + {/* Member toggle — kept only for `callView` (1:1 or group): the + members side-pane / horseshoe isn't mounted under the call + surface, so we still need a way to reach the member list + via Room Settings → Members. Non-call group rooms now use + the identity button above (tap on avatar+title) — keeps + the gesture symmetrical with 1:1 peer profile. */} + {callView && screenSize === ScreenSize.Desktop && ( - {callView ? ( - {t('Room.members')} - ) : ( - {peopleDrawer ? t('Room.hide_members') : t('Room.show_members')} - )} + {t('Room.members')} } > {(triggerRef) => ( - + )} diff --git a/src/app/features/room/RoomViewMembersPanel.css.ts b/src/app/features/room/RoomViewMembersPanel.css.ts new file mode 100644 index 00000000..43cc5d03 --- /dev/null +++ b/src/app/features/room/RoomViewMembersPanel.css.ts @@ -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', +}); diff --git a/src/app/features/room/RoomViewMembersPanel.tsx b/src/app/features/room/RoomViewMembersPanel.tsx new file mode 100644 index 00000000..954713db --- /dev/null +++ b/src/app/features/room/RoomViewMembersPanel.tsx @@ -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(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(null); + const panelRef = useRef(null); + const headerInnerRef = useRef(null); + const panelContentRef = useRef(null); + const panelScrollRef = useRef(null); + const panelMeasureRef = useRef(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(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 = ( + + ); + } else if (groupAvatarMode) { + // Group hero avatar full-view — same pattern, source is the room. + panelBody = ( + + ); + } else { + panelBody = ( +
+
+
+ {showProfile && profileState ? ( +
+ setUserAvatarMode(true)} + /> +
+ ) : ( + setGroupAvatarMode(true)} + /> + )} +
+
+
+
+
+
+ ); + } + + return ( +
+
+ 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} + > +
0 ? 'visible' : 'hidden', + }} + > +
+ {panelBody} +
+
+
+ +
0 + ? `${(1 - expandedFraction) * headerNaturalHeight}px` + : 'auto', + transition: headerViewportTransition, + touchAction: headerDragEnabled ? 'pan-x' : undefined, + }} + > +
+ {header} +
+
+
+ +
0 ? 'hidden' : undefined, + transition: chatBodyTransition, + overscrollBehaviorY: 'contain', + }} + > + {children} +
+
+ ); +} + +export function RoomViewMembersPanel({ header, children }: RoomViewMembersPanelProps) { + const isMobile = useScreenSizeContext() === ScreenSize.Mobile; + if (!isMobile) { + return ( + <> + {header} + {children} + + ); + } + return {children}; +} diff --git a/src/app/features/room/RoomViewMembersSidePanel.css.ts b/src/app/features/room/RoomViewMembersSidePanel.css.ts new file mode 100644 index 00000000..913ae0ec --- /dev/null +++ b/src/app/features/room/RoomViewMembersSidePanel.css.ts @@ -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', + }, + }, +}); diff --git a/src/app/features/room/RoomViewMembersSidePanel.tsx b/src/app/features/room/RoomViewMembersSidePanel.tsx new file mode 100644 index 00000000..b85a6f24 --- /dev/null +++ b/src/app/features/room/RoomViewMembersSidePanel.tsx @@ -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 ( + true, + escapeDeactivates: stopPropagation, + onDeactivate: () => { + if (sheetStateRef.current) close(); + }, + checkCanFocusTrap: () => Promise.resolve(), + }} + active={open} + > +
+ + + + {t('Room.members_pane_title')} + + + + + + + + + +
+ +
+
+
+ ); +} diff --git a/src/app/features/room/RoomViewProfilePanel.tsx b/src/app/features/room/RoomViewProfilePanel.tsx index 538f8413..f650b919 100644 --- a/src/app/features/room/RoomViewProfilePanel.tsx +++ b/src/app/features/room/RoomViewProfilePanel.tsx @@ -246,11 +246,14 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) if (liveUserId) lastUserIdRef.current = liveUserId; const renderUserId = liveUserId ?? lastUserIdRef.current; + // Drop width/height — the silhouette-filling fullview on a 3x DPR + // phone needs ~1200 native px wide, so a 720 thumbnail gets both + // recompressed by Synapse AND upscaled by the browser, visibly + // pixelating the avatar («шакалит»). Matches `MediaViewerBody` — + // the other Vojo fullscreen surface fetches the original. const renderUserAvatarMxc = renderUserId ? getMemberAvatarMxc(room, renderUserId) : undefined; const renderUserAvatarUrl = - (renderUserAvatarMxc && - mxcUrlToHttp(mx, renderUserAvatarMxc, useAuthentication, 720, 720, 'scale')) ?? - undefined; + (renderUserAvatarMxc && mxcUrlToHttp(mx, renderUserAvatarMxc, useAuthentication)) ?? undefined; const dragRef = useRef(null); dragRef.current = drag; diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts index df369011..723352a5 100644 --- a/src/app/hooks/useRoomMembers.ts +++ b/src/app/hooks/useRoomMembers.ts @@ -26,10 +26,19 @@ export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] = mx.on(RoomMemberEvent.Membership, updateMemberList); mx.on(RoomMemberEvent.PowerLevel, updateMemberList); + // Display-name changes ride `RoomMemberEvent.Name` (per + // matrix-js-sdk `RoomMember.setRawDisplayName` → `RoomMember.emit + // (RoomMemberEvent.Name, ...)`). Without the subscription a peer + // renaming themselves stays under the old name in any list that + // reads `useRoomMembers` (Members sheet, Settings → Members, + // Lobby member-drawer, user-mention autocomplete). Element Web's + // `MemberListViewModel` subscribes to the same trio. + mx.on(RoomMemberEvent.Name, updateMemberList); return () => { disposed = true; mx.removeListener(RoomMemberEvent.Membership, updateMemberList); mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList); + mx.removeListener(RoomMemberEvent.Name, updateMemberList); }; }, [mx, roomId]); diff --git a/src/app/state/hooks/mediaViewer.ts b/src/app/state/hooks/mediaViewer.ts index 650d48f2..d90fcb02 100644 --- a/src/app/state/hooks/mediaViewer.ts +++ b/src/app/state/hooks/mediaViewer.ts @@ -2,21 +2,23 @@ import { useCallback } from 'react'; import { useSetAtom } from 'jotai'; import { mediaViewerAtom, MediaViewerEntry } from '../mediaViewer'; import { userRoomProfileAtom } from '../userRoomProfile'; +import { roomMembersSheetAtom } from '../roomMembersSheet'; export const useOpenMediaViewer = (): ((entry: MediaViewerEntry) => void) => { const setMedia = useSetAtom(mediaViewerAtom); - // Close the profile side pane / horseshoe when opening media — - // having both open simultaneously fights for chat-column width on - // desktop and stacks two horseshoe surfaces on mobile. Mutual - // exclusivity is the simplest contract until the user asks for - // stacking. + // Close other right-pane sheets when opening media — profile / members + // / media all fight for the same desktop slot and would stack into two + // horseshoes on mobile. Mutual exclusion is the simplest contract + // until the user asks for stacking. const setProfile = useSetAtom(userRoomProfileAtom); + const setRoomMembersSheet = useSetAtom(roomMembersSheetAtom); return useCallback( (entry) => { setProfile(undefined); + setRoomMembersSheet(undefined); setMedia(entry); }, - [setMedia, setProfile] + [setMedia, setProfile, setRoomMembersSheet] ); }; diff --git a/src/app/state/hooks/roomMembersSheet.ts b/src/app/state/hooks/roomMembersSheet.ts new file mode 100644 index 00000000..9d24d507 --- /dev/null +++ b/src/app/state/hooks/roomMembersSheet.ts @@ -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] + ); +}; diff --git a/src/app/state/hooks/userRoomProfile.ts b/src/app/state/hooks/userRoomProfile.ts index 00652e0e..d67270c6 100644 --- a/src/app/state/hooks/userRoomProfile.ts +++ b/src/app/state/hooks/userRoomProfile.ts @@ -3,6 +3,7 @@ import { useAtomValue, useSetAtom } from 'jotai'; import { Position, RectCords } from 'folds'; import { userRoomProfileAtom, UserRoomProfileState } from '../userRoomProfile'; import { mediaViewerAtom } from '../mediaViewer'; +import { roomMembersSheetAtom } from '../roomMembersSheet'; export const useUserRoomProfileState = (): UserRoomProfileState | undefined => { const data = useAtomValue(userRoomProfileAtom); @@ -37,13 +38,19 @@ export const useOpenUserRoomProfile = (): OpenCallback => { // `RoomViewMediaSidePanel` would mount as siblings and fight for // the right-pane slot. const setMediaViewer = useSetAtom(mediaViewerAtom); + // Symmetric mutual exclusion with the group-room members sheet — when + // the user taps a row inside the members list, the per-user profile + // takes over the right-pane slot. On mobile the members horseshoe + // closes its own way; this also handles the desktop side-pane swap. + const setRoomMembersSheet = useSetAtom(roomMembersSheetAtom); const open: OpenCallback = useCallback( (roomId, spaceId, userId, cords, position) => { setMediaViewer(undefined); + setRoomMembersSheet(undefined); setUserRoomProfile({ roomId, spaceId, userId, cords, position }); }, - [setUserRoomProfile, setMediaViewer] + [setUserRoomProfile, setMediaViewer, setRoomMembersSheet] ); return open; diff --git a/src/app/state/roomMembersSheet.ts b/src/app/state/roomMembersSheet.ts new file mode 100644 index 00000000..e8cd4342 --- /dev/null +++ b/src/app/state/roomMembersSheet.ts @@ -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(undefined);