feat(channels): ship M6 workspace switcher dropdown with rename-reactive trigger and rows for multi-space users
This commit is contained in:
parent
19f2d64c0d
commit
b98c49b787
6 changed files with 291 additions and 61 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Позвонить",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
32
src/app/pages/client/channels/WorkspaceFooter.css.ts
Normal file
32
src/app/pages/client/channels/WorkspaceFooter.css.ts
Normal 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}`,
|
||||
});
|
||||
250
src/app/pages/client/channels/WorkspaceFooter.tsx
Normal file
250
src/app/pages/client/channels/WorkspaceFooter.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue