feat(channels): ship M6 workspace switcher dropdown with rename-reactive trigger and rows for multi-space users
This commit is contained in:
parent
0b31e6b930
commit
d4b05619a8
6 changed files with 291 additions and 61 deletions
|
|
@ -394,7 +394,9 @@
|
||||||
"explore_cta": "Find a community",
|
"explore_cta": "Find a community",
|
||||||
"pick_channel_title": "Pick a channel",
|
"pick_channel_title": "Pick a channel",
|
||||||
"pick_channel_desc": "Choose a channel from the list on the left to start reading.",
|
"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": {
|
"Call": {
|
||||||
"start": "Start call",
|
"start": "Start call",
|
||||||
|
|
|
||||||
|
|
@ -396,7 +396,9 @@
|
||||||
"explore_cta": "Найти сообщество",
|
"explore_cta": "Найти сообщество",
|
||||||
"pick_channel_title": "Выберите канал",
|
"pick_channel_title": "Выберите канал",
|
||||||
"pick_channel_desc": "Откройте канал из списка слева, чтобы начать читать.",
|
"pick_channel_desc": "Откройте канал из списка слева, чтобы начать читать.",
|
||||||
"root_category": "Каналы"
|
"root_category": "Каналы",
|
||||||
|
"workspace_switcher_aria": "Сменить сообщество",
|
||||||
|
"workspace_switcher_active_marker": "Текущее"
|
||||||
},
|
},
|
||||||
"Call": {
|
"Call": {
|
||||||
"start": "Позвонить",
|
"start": "Позвонить",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import React, { useEffect, useRef } from 'react';
|
||||||
import { useSpace } from '../../../hooks/useSpace';
|
import { useSpace } from '../../../hooks/useSpace';
|
||||||
import { PageNav, PageNavContent } from '../../../components/page';
|
import { PageNav, PageNavContent } from '../../../components/page';
|
||||||
import { DirectStreamHeader } from '../direct/DirectStreamHeader';
|
import { DirectStreamHeader } from '../direct/DirectStreamHeader';
|
||||||
import { ChannelsList, WorkspaceFooter } from './ChannelsList';
|
import { ChannelsList } from './ChannelsList';
|
||||||
|
import { WorkspaceFooter } from './WorkspaceFooter';
|
||||||
import { ACTIVE_SPACE_KEY } from './useActiveSpace';
|
import { ACTIVE_SPACE_KEY } from './useActiveSpace';
|
||||||
|
|
||||||
// Stub nav rendered at the /channels/ index route when the user has no
|
// 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 React, { MutableRefObject, useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { Box, color, config, toRem } from 'folds';
|
import { Box, config } from 'folds';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
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