diff --git a/public/locales/en.json b/public/locales/en.json
index da3dd0fd..d4920808 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -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",
diff --git a/public/locales/ru.json b/public/locales/ru.json
index 24ced785..d626802a 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -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": "Позвонить",
diff --git a/src/app/pages/client/channels/Channels.tsx b/src/app/pages/client/channels/Channels.tsx
index e615ad4e..7944ce3f 100644
--- a/src/app/pages/client/channels/Channels.tsx
+++ b/src/app/pages/client/channels/Channels.tsx
@@ -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
diff --git a/src/app/pages/client/channels/ChannelsList.tsx b/src/app/pages/client/channels/ChannelsList.tsx
index bf5c22bd..1dbecdf6 100644
--- a/src/app/pages/client/channels/ChannelsList.tsx
+++ b/src/app/pages/client/channels/ChannelsList.tsx
@@ -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 (
-
-
- {!httpUrl && initial}
-
-
- {space.name}
-
-
- );
-}
diff --git a/src/app/pages/client/channels/WorkspaceFooter.css.ts b/src/app/pages/client/channels/WorkspaceFooter.css.ts
new file mode 100644
index 00000000..96dc5402
--- /dev/null
+++ b/src/app/pages/client/channels/WorkspaceFooter.css.ts
@@ -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}`,
+});
diff --git a/src/app/pages/client/channels/WorkspaceFooter.tsx b/src/app/pages/client/channels/WorkspaceFooter.tsx
new file mode 100644
index 00000000..4f274672
--- /dev/null
+++ b/src/app/pages/client/channels/WorkspaceFooter.tsx
@@ -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 (
+
+ {!httpUrl && initial}
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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();
+ // 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 = 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 = (
+
+ {activeName}
+
+ );
+
+ if (!canSwitch) {
+ return (
+
+
+ {nameCell}
+
+ );
+ }
+
+ return (
+ <>
+
+
+ {menuAnchor && (
+ evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+
+ }
+ />
+ )}
+ >
+ );
+}