feat(channels): ship M6 workspace switcher dropdown with rename-reactive trigger and rows for multi-space users

This commit is contained in:
v.lagerev 2026-05-10 20:56:14 +03:00
parent 19f2d64c0d
commit b98c49b787
6 changed files with 291 additions and 61 deletions

View file

@ -394,7 +394,9 @@
"explore_cta": "Find a community",
"pick_channel_title": "Pick a channel",
"pick_channel_desc": "Choose a channel from the list on the left to start reading.",
"root_category": "Channels"
"root_category": "Channels",
"workspace_switcher_aria": "Switch community",
"workspace_switcher_active_marker": "Current"
},
"Call": {
"start": "Start call",

View file

@ -396,7 +396,9 @@
"explore_cta": "Найти сообщество",
"pick_channel_title": "Выберите канал",
"pick_channel_desc": "Откройте канал из списка слева, чтобы начать читать.",
"root_category": "Каналы"
"root_category": "Каналы",
"workspace_switcher_aria": "Сменить сообщество",
"workspace_switcher_active_marker": "Текущее"
},
"Call": {
"start": "Позвонить",

View file

@ -2,7 +2,8 @@ import React, { useEffect, useRef } from 'react';
import { useSpace } from '../../../hooks/useSpace';
import { PageNav, PageNavContent } from '../../../components/page';
import { DirectStreamHeader } from '../direct/DirectStreamHeader';
import { ChannelsList, WorkspaceFooter } from './ChannelsList';
import { ChannelsList } from './ChannelsList';
import { WorkspaceFooter } from './WorkspaceFooter';
import { ACTIVE_SPACE_KEY } from './useActiveSpace';
// Stub nav rendered at the /channels/ index route when the user has no

View file

@ -1,7 +1,7 @@
import React, { MutableRefObject, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useAtom, useAtomValue } from 'jotai';
import { Box, color, config, toRem } from 'folds';
import { Box, config } from 'folds';
import { Room } from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@ -156,60 +156,3 @@ export function ChannelsList({ scrollRef }: ChannelsListProps) {
);
}
type WorkspaceFooterProps = {
space: Room;
};
export function WorkspaceFooter({ space }: WorkspaceFooterProps) {
const mx = useMatrixClient();
const mxcUrl = space.getMxcAvatarUrl();
const httpUrl = mxcUrl
? mx.mxcUrlToHttp(mxcUrl, 48, 48, 'crop', undefined, false, true) ?? undefined
: undefined;
const initial = (space.name || space.roomId).trim().slice(0, 1).toUpperCase();
return (
<Box
shrink="No"
alignItems="Center"
gap="200"
style={{
padding: `${toRem(8)} ${toRem(12)}`,
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
}}
>
<div
style={{
width: toRem(28),
height: toRem(28),
borderRadius: toRem(8),
background: httpUrl ? `center/cover no-repeat url("${httpUrl}")` : color.Primary.Container,
color: color.Primary.OnContainer,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 600,
fontSize: toRem(13),
flexShrink: 0,
}}
aria-hidden
>
{!httpUrl && initial}
</div>
<div
style={{
flexGrow: 1,
color: color.Surface.OnContainer,
fontSize: toRem(13),
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={space.name}
>
{space.name}
</div>
</Box>
);
}

View file

@ -0,0 +1,32 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
const plateBase = {
display: 'flex',
alignItems: 'center',
gap: toRem(8),
padding: `${toRem(8)} ${toRem(12)}`,
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
flexShrink: 0,
} as const;
export const WorkspaceFooterPlate = style(plateBase);
export const WorkspaceFooterTrigger = style({
all: 'unset',
cursor: 'pointer',
...plateBase,
selectors: {
'&:hover, &:focus-visible': {
backgroundColor: color.Surface.ContainerHover,
},
'&:active, &[aria-expanded=true]': {
backgroundColor: color.Surface.ContainerActive,
},
},
});
export const ActiveSpaceMenuRow = style({
boxShadow: `inset 0 0 0 ${toRem(2)} ${color.Primary.Main}`,
});

View file

@ -0,0 +1,250 @@
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import {
Box,
Icon,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
color,
config,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { useOrphanSpaces } from '../../../state/hooks/roomList';
import { useRoomName } from '../../../hooks/useRoomMeta';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { getChannelsSpacePath } from '../../pathUtils';
import { stopPropagation } from '../../../utils/keyboard';
import {
ActiveSpaceMenuRow,
WorkspaceFooterPlate,
WorkspaceFooterTrigger,
} from './WorkspaceFooter.css';
type SpaceAvatarProps = {
space: Room;
size: number;
};
function SpaceAvatar({ space, size }: SpaceAvatarProps) {
const mx = useMatrixClient();
const mxcUrl = space.getMxcAvatarUrl();
const httpUrl = mxcUrl
? mx.mxcUrlToHttp(mxcUrl, size * 2, size * 2, 'crop', undefined, false, true) ?? undefined
: undefined;
const initial = (space.name || space.roomId).trim().slice(0, 1).toUpperCase();
return (
<div
style={{
width: toRem(size),
height: toRem(size),
borderRadius: toRem(Math.max(6, Math.round(size / 3.5))),
background: httpUrl
? `center/cover no-repeat url("${httpUrl}")`
: color.Primary.Container,
color: color.Primary.OnContainer,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 600,
fontSize: toRem(Math.round(size * 0.46)),
flexShrink: 0,
}}
aria-hidden
>
{!httpUrl && initial}
</div>
);
}
type SpaceMenuRowProps = {
space: Room;
isActive: boolean;
activeMarker: string;
onPick: (spaceId: string) => void;
};
// Inner row component so the per-space `useRoomName` subscription stays
// scoped — putting the hook directly inside `.map()` would violate Rules of
// Hooks. Subscribing here keeps the dropdown reactive when an admin renames
// a space mid-session.
function SpaceMenuRow({ space, isActive, activeMarker, onPick }: SpaceMenuRowProps) {
const name = useRoomName(space);
return (
<MenuItem
onClick={() => onPick(space.roomId)}
size="300"
radii="300"
before={<SpaceAvatar space={space} size={24} />}
after={
isActive ? (
<Text size="L400" style={{ color: color.Primary.Main }}>
{activeMarker}
</Text>
) : undefined
}
aria-current={isActive ? 'true' : undefined}
className={isActive ? ActiveSpaceMenuRow : undefined}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{name}
</Text>
</MenuItem>
);
}
type WorkspaceFooterProps = {
space: Room;
};
// Bottom-of-channels-list workspace switcher. Multi-orphan users get a
// clickable trigger (avatar + name + chevron) that opens a PopOut listing
// every joined orphan Space; the active row wears an inset Primary.Main
// ring. Single-orphan users see a static plate (no chevron, no click) —
// nothing to switch to.
//
// Active-space tracking lives upstream in `Channels.tsx::useEffect` (writes
// localStorage) and `useActiveSpace` (URL > localStorage > first orphan
// resolver). Leave-active-space cascades through `roomToParentsAtom`'s
// MyMembership listener — the orphan list shrinks, `useActiveSpace`'s
// orphanSet filter drops the stale id, and the channels surface re-resolves
// to the next orphan automatically.
export function WorkspaceFooter({ space }: WorkspaceFooterProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const navigate = useNavigate();
const roomToParents = useAtomValue(roomToParentsAtom);
const orphanSpaceIds = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
const activeName = useRoomName(space);
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
// FocusTrap's `clickOutsideDeactivates` fires on mousedown — when the
// user clicks the trigger button to close an open menu, the deactivate
// hook runs first (closing the menu), and the trailing `click` event on
// the trigger would re-open it. Stamp the close time so the trigger
// suppresses re-open attempts inside that grace window.
const lastCloseAtRef = useRef(0);
const closeMenu = useCallback(() => {
lastCloseAtRef.current = Date.now();
setMenuAnchor(undefined);
}, []);
const handleToggle: MouseEventHandler<HTMLButtonElement> = useCallback((evt) => {
if (Date.now() - lastCloseAtRef.current < 250) return;
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((current) => (current ? undefined : cords));
}, []);
const handlePickSpace = useCallback(
(spaceId: string) => {
closeMenu();
if (spaceId === space.roomId) return;
const idOrAlias = getCanonicalAliasOrRoomId(mx, spaceId);
navigate(getChannelsSpacePath(idOrAlias));
},
[closeMenu, mx, navigate, space.roomId]
);
const canSwitch = orphanSpaceIds.length > 1;
const activeMarker = t('Channels.workspace_switcher_active_marker');
const nameCell = (
<div
style={{
flexGrow: 1,
color: color.Surface.OnContainer,
fontSize: toRem(13),
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textAlign: 'left',
}}
title={activeName}
>
{activeName}
</div>
);
if (!canSwitch) {
return (
<div className={WorkspaceFooterPlate}>
<SpaceAvatar space={space} size={28} />
{nameCell}
</div>
);
}
return (
<>
<button
type="button"
onClick={handleToggle}
aria-label={t('Channels.workspace_switcher_aria')}
aria-haspopup="menu"
aria-expanded={!!menuAnchor}
className={WorkspaceFooterTrigger}
>
<SpaceAvatar space={space} size={28} />
{nameCell}
<Icon size="100" src={Icons.ChevronTop} />
</button>
{menuAnchor && (
<PopOut
anchor={menuAnchor}
position="Top"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: closeMenu,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu
style={{
minWidth: toRem(240),
maxWidth: toRem(320),
maxHeight: toRem(360),
overflowY: 'auto',
}}
>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{orphanSpaceIds.map((spaceId) => {
const r = mx.getRoom(spaceId);
if (!r) return null;
return (
<SpaceMenuRow
key={spaceId}
space={r}
isActive={spaceId === space.roomId}
activeMarker={activeMarker}
onPick={handlePickSpace}
/>
);
})}
</Box>
</Menu>
</FocusTrap>
}
/>
)}
</>
);
}