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 ( + onPick(space.roomId)} + size="300" + radii="300" + before={} + after={ + isActive ? ( + + {activeMarker} + + ) : undefined + } + aria-current={isActive ? 'true' : undefined} + className={isActive ? ActiveSpaceMenuRow : undefined} + > + + {name} + + + ); +} + +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, + }} + > + + + {orphanSpaceIds.map((spaceId) => { + const r = mx.getRoom(spaceId); + if (!r) return null; + return ( + + ); + })} + + + + } + /> + )} + + ); +}