feat(channels): replace workspace switcher popout with sliding horseshoe sheet and inline create-channel row, retire sidebar CreateTab
This commit is contained in:
parent
dce683291c
commit
cab6b788e3
18 changed files with 1036 additions and 545 deletions
|
|
@ -398,7 +398,12 @@
|
||||||
"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_aria": "Switch community",
|
||||||
"workspace_switcher_active_marker": "Current"
|
"workspace_switcher_create_space": "Create community",
|
||||||
|
"workspace_switcher_drag_to_close": "Drag down to close",
|
||||||
|
"workspace_switcher_member_count_one": "{{count}} member",
|
||||||
|
"workspace_switcher_member_count_other": "{{count}} members",
|
||||||
|
"workspace_footer_subtitle": "Community",
|
||||||
|
"create_channel": "Create channel"
|
||||||
},
|
},
|
||||||
"Call": {
|
"Call": {
|
||||||
"start": "Start call",
|
"start": "Start call",
|
||||||
|
|
|
||||||
|
|
@ -400,7 +400,14 @@
|
||||||
"pick_channel_desc": "Откройте канал из списка слева, чтобы начать читать.",
|
"pick_channel_desc": "Откройте канал из списка слева, чтобы начать читать.",
|
||||||
"root_category": "Каналы",
|
"root_category": "Каналы",
|
||||||
"workspace_switcher_aria": "Сменить сообщество",
|
"workspace_switcher_aria": "Сменить сообщество",
|
||||||
"workspace_switcher_active_marker": "Текущее"
|
"workspace_switcher_create_space": "Создать сообщество",
|
||||||
|
"workspace_switcher_drag_to_close": "Потяните вниз, чтобы закрыть",
|
||||||
|
"workspace_switcher_member_count_one": "{{count}} участник",
|
||||||
|
"workspace_switcher_member_count_few": "{{count}} участника",
|
||||||
|
"workspace_switcher_member_count_many": "{{count}} участников",
|
||||||
|
"workspace_switcher_member_count_other": "{{count}} участника",
|
||||||
|
"workspace_footer_subtitle": "Сообщество",
|
||||||
|
"create_channel": "Создать канал"
|
||||||
},
|
},
|
||||||
"Call": {
|
"Call": {
|
||||||
"start": "Позвонить",
|
"start": "Позвонить",
|
||||||
|
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
import React, { FormEventHandler, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import FocusTrap from 'focus-trap-react';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
Overlay,
|
|
||||||
OverlayCenter,
|
|
||||||
OverlayBackdrop,
|
|
||||||
Header,
|
|
||||||
config,
|
|
||||||
Box,
|
|
||||||
Text,
|
|
||||||
IconButton,
|
|
||||||
Icon,
|
|
||||||
Icons,
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
color,
|
|
||||||
} from 'folds';
|
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
|
||||||
import { isRoomAlias, isRoomId } from '../../utils/matrix';
|
|
||||||
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to';
|
|
||||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
|
||||||
|
|
||||||
type JoinAddressProps = {
|
|
||||||
onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
};
|
|
||||||
export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [invalid, setInvalid] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
setInvalid(false);
|
|
||||||
|
|
||||||
const target = evt.target as HTMLFormElement | undefined;
|
|
||||||
const addressInput = target?.addressInput as HTMLInputElement | undefined;
|
|
||||||
const address = addressInput?.value.trim();
|
|
||||||
if (!address) return;
|
|
||||||
|
|
||||||
if (isRoomId(address) || isRoomAlias(address)) {
|
|
||||||
onOpen(address);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (testMatrixTo(address)) {
|
|
||||||
const decodedAddress = tryDecodeURIComponent(address);
|
|
||||||
const toRoom = parseMatrixToRoom(decodedAddress);
|
|
||||||
if (toRoom) {
|
|
||||||
onOpen(toRoom.roomIdOrAlias, toRoom.viaServers);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toEvent = parseMatrixToRoomEvent(decodedAddress);
|
|
||||||
if (toEvent) {
|
|
||||||
onOpen(toEvent.roomIdOrAlias, toEvent.viaServers, toEvent.eventId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setInvalid(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
||||||
<OverlayCenter>
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: onCancel,
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Dialog variant="Surface">
|
|
||||||
<Header
|
|
||||||
style={{
|
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
|
||||||
}}
|
|
||||||
variant="Surface"
|
|
||||||
size="500"
|
|
||||||
>
|
|
||||||
<Box grow="Yes">
|
|
||||||
<Text size="H4">{t('Home.join_with_address')}</Text>
|
|
||||||
</Box>
|
|
||||||
<IconButton size="300" onClick={onCancel} radii="300">
|
|
||||||
<Icon src={Icons.Cross} />
|
|
||||||
</IconButton>
|
|
||||||
</Header>
|
|
||||||
<Box
|
|
||||||
as="form"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
style={{ padding: config.space.S400, paddingTop: 0 }}
|
|
||||||
direction="Column"
|
|
||||||
gap="400"
|
|
||||||
>
|
|
||||||
<Box direction="Column" gap="200">
|
|
||||||
<Text priority="400" size="T300">
|
|
||||||
{t('Home.join_address_desc')}
|
|
||||||
</Text>
|
|
||||||
<Text as="ul" size="T200" priority="300" style={{ paddingLeft: config.space.S400 }}>
|
|
||||||
<li>#community:server</li>
|
|
||||||
<li>https://matrix.to/#/#community:server</li>
|
|
||||||
<li>https://matrix.to/#/!xYzAj?via=server</li>
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box direction="Column" gap="100">
|
|
||||||
<Text size="L400">{t('Home.address')}</Text>
|
|
||||||
<Input
|
|
||||||
size="500"
|
|
||||||
autoFocus
|
|
||||||
name="addressInput"
|
|
||||||
variant="Background"
|
|
||||||
placeholder="#community:server"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{invalid && (
|
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
|
||||||
<b>{t('Home.invalid_address')}</b>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Button type="submit" variant="Primary">
|
|
||||||
<Text size="B400">{t('Home.open')}</Text>
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Dialog>
|
|
||||||
</FocusTrap>
|
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from './JoinAddressPrompt';
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { useMatch } from 'react-router-dom';
|
|
||||||
import { getCreatePath } from '../../pages/pathUtils';
|
|
||||||
|
|
||||||
export const useCreateSelected = (): boolean => {
|
|
||||||
const match = useMatch({
|
|
||||||
path: getCreatePath(),
|
|
||||||
caseSensitive: true,
|
|
||||||
end: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return !!match;
|
|
||||||
};
|
|
||||||
|
|
@ -15,7 +15,6 @@ import {
|
||||||
UnverifiedTab,
|
UnverifiedTab,
|
||||||
SearchTab,
|
SearchTab,
|
||||||
} from './sidebar';
|
} from './sidebar';
|
||||||
import { CreateTab } from './sidebar/CreateTab';
|
|
||||||
|
|
||||||
export function SidebarNav() {
|
export function SidebarNav() {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -32,7 +31,6 @@ export function SidebarNav() {
|
||||||
<SidebarStackSeparator />
|
<SidebarStackSeparator />
|
||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
<ExploreTab />
|
<ExploreTab />
|
||||||
<CreateTab />
|
|
||||||
</SidebarStack>
|
</SidebarStack>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
src/app/pages/client/channels/ChannelCreateRow.tsx
Normal file
57
src/app/pages/client/channels/ChannelCreateRow.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Avatar, Box, Icon, Icons, Text, color, config, toRem } from 'folds';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { NavButton, NavItem, NavItemContent } from '../../../components/nav';
|
||||||
|
import { useOpenCreateRoomModal } from '../../../state/hooks/createRoomModal';
|
||||||
|
import { CreateRoomType } from '../../../components/create-room/types';
|
||||||
|
|
||||||
|
const ROW_MIN_HEIGHT = toRem(56);
|
||||||
|
|
||||||
|
type ChannelCreateRowProps = {
|
||||||
|
space: Room;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChannelCreateRow({ space }: ChannelCreateRowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const openCreateRoomModal = useOpenCreateRoomModal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
padding: `${toRem(6)} ${config.space.S100}`,
|
||||||
|
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NavItem variant="Background" radii="400" style={{ minHeight: ROW_MIN_HEIGHT }}>
|
||||||
|
<NavButton
|
||||||
|
onClick={() => openCreateRoomModal(space.roomId, CreateRoomType.TextRoom)}
|
||||||
|
aria-label={t('Channels.create_channel')}
|
||||||
|
>
|
||||||
|
<NavItemContent>
|
||||||
|
<Box
|
||||||
|
as="span"
|
||||||
|
grow="Yes"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="300"
|
||||||
|
style={{
|
||||||
|
minHeight: ROW_MIN_HEIGHT,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
padding: `${toRem(6)} 0`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar size="300" radii="400">
|
||||||
|
<Icon src={Icons.Plus} size="100" />
|
||||||
|
</Avatar>
|
||||||
|
<Box as="span" grow="Yes" style={{ minWidth: 0 }}>
|
||||||
|
<Text as="span" size="T300" truncate style={{ fontWeight: 600 }}>
|
||||||
|
{t('Channels.create_channel')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</NavItemContent>
|
||||||
|
</NavButton>
|
||||||
|
</NavItem>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@ 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 } from './ChannelsList';
|
import { ChannelsList } from './ChannelsList';
|
||||||
|
import { ChannelCreateRow } from './ChannelCreateRow';
|
||||||
|
import { ChannelsWorkspaceHorseshoe } from './ChannelsWorkspaceHorseshoe';
|
||||||
import { WorkspaceFooter } from './WorkspaceFooter';
|
import { WorkspaceFooter } from './WorkspaceFooter';
|
||||||
import { ACTIVE_SPACE_KEY } from './useActiveSpace';
|
import { ACTIVE_SPACE_KEY } from './useActiveSpace';
|
||||||
|
|
||||||
|
|
@ -51,11 +53,21 @@ export function Channels() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageNav resizable>
|
<PageNav resizable>
|
||||||
<DirectStreamHeader />
|
{/* The horseshoe wraps the entire channels column so the workspace
|
||||||
<PageNavContent scrollRef={scrollRef}>
|
switcher sheet slides up from the bottom of THIS PageNav slot
|
||||||
<ChannelsList scrollRef={scrollRef} />
|
(not the viewport). The wrapped children stay put; their
|
||||||
</PageNavContent>
|
bottom edge is carved by an animated clip-path with a 12px
|
||||||
<WorkspaceFooter space={space} />
|
void between the carve and the sliding silhouette. See
|
||||||
|
ChannelsWorkspaceHorseshoe.tsx for the canonical idioms it
|
||||||
|
inherits from MobileSettingsHorseshoe (commit a7d6fc2). */}
|
||||||
|
<ChannelsWorkspaceHorseshoe space={space}>
|
||||||
|
<DirectStreamHeader />
|
||||||
|
<PageNavContent scrollRef={scrollRef}>
|
||||||
|
<ChannelsList scrollRef={scrollRef} />
|
||||||
|
</PageNavContent>
|
||||||
|
<ChannelCreateRow space={space} />
|
||||||
|
<WorkspaceFooter space={space} />
|
||||||
|
</ChannelsWorkspaceHorseshoe>
|
||||||
</PageNav>
|
</PageNav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
113
src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts
Normal file
113
src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, toRem } from 'folds';
|
||||||
|
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../../styles/horseshoe';
|
||||||
|
|
||||||
|
// Re-exported so the TSX can pick up the constants without crossing the
|
||||||
|
// vanilla-extract / runtime boundary twice. Mirror of the canonical
|
||||||
|
// MobileSettingsHorseshoe css module.
|
||||||
|
export const HORSESHOE_RADIUS_PX = VOJO_HORSESHOE_RADIUS_PX;
|
||||||
|
export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
|
||||||
|
|
||||||
|
// Outer container — anchor for the two absolutely-positioned panes
|
||||||
|
// (`appBody` and `silhouette`). `flex: 1` fills PageNav's inner column
|
||||||
|
// slot. `overflow: hidden` clips the rounded carves against the
|
||||||
|
// container's bg, which is painted inline with the void colour when
|
||||||
|
// the sheet is active. See MobileSettingsHorseshoe.css.ts for the full
|
||||||
|
// rationale on every property here — kept verbatim except for the file
|
||||||
|
// header.
|
||||||
|
export const container = style({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'column',
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrapped children (DirectStreamHeader → ChannelsList → ChannelCreateRow →
|
||||||
|
// WorkspaceFooter). Stays put — the bottom is carved away by an animated
|
||||||
|
// `clip-path: inset(...)` with rounded BL/BR. `backgroundColor` is
|
||||||
|
// load-bearing: the container is painted with the void colour when the
|
||||||
|
// sheet is active, so without an opaque bg the void would bleed through
|
||||||
|
// every transparent gap between list rows. See canonical for the full
|
||||||
|
// reasoning on clip-path vs translate vs flex-shrink.
|
||||||
|
export const appBody = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
willChange: 'clip-path',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Workspace switcher sheet surface. Anchored at the bottom of the
|
||||||
|
// container; height animates 0 → railHeight as the user drags / clicks.
|
||||||
|
// `SurfaceVariant.Container` matches the chat-pane tone and keeps the
|
||||||
|
// safe-area / handle gaps from reading as dark stripes on edge-to-edge
|
||||||
|
// Android — same rationale as the canonical settings horseshoe.
|
||||||
|
export const silhouette = style({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
willChange: 'height, border-top-left-radius, border-top-right-radius',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Top-anchored panel content. Padding-bottom reserves Android nav-bar
|
||||||
|
// inset so the create-space row never tucks under the gesture pill.
|
||||||
|
export const panelContent = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 20px drag-to-close band at the top of the silhouette. The ONLY
|
||||||
|
// drag-down origin once the sheet is open — touches on the spaces list
|
||||||
|
// below this strip are not drag-sensitive, so internal scroll keeps
|
||||||
|
// working without a gesture conflict.
|
||||||
|
export const panelHandle = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
height: toRem(20),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'grab',
|
||||||
|
touchAction: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
selectors: {
|
||||||
|
'&:active': { cursor: 'grabbing' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const panelHandleBar = style({
|
||||||
|
width: toRem(36),
|
||||||
|
height: toRem(4),
|
||||||
|
borderRadius: toRem(4),
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Holds the workspace switcher list. `flex: 1` so it grows below the
|
||||||
|
// 20px handle to fill the remaining panel height; internal Scroll inside
|
||||||
|
// the switcher handles overflow when the user has many orphan spaces.
|
||||||
|
export const panelBody = style({
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
});
|
||||||
464
src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.tsx
Normal file
464
src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.tsx
Normal file
|
|
@ -0,0 +1,464 @@
|
||||||
|
// Sliding horseshoe sheet for the channels workspace switcher. Mirror of
|
||||||
|
// `features/settings/MobileSettingsHorseshoe.tsx` (canonical, shipped in
|
||||||
|
// commit a7d6fc2) with three deliberate adaptations:
|
||||||
|
//
|
||||||
|
// • Single code path on web AND native — no
|
||||||
|
// `useScreenSizeContext()` mobile-only gate. The horseshoe lives
|
||||||
|
// wherever channels render, so a desktop tap on the workspace
|
||||||
|
// footer slides the sheet up inside the PageNav column the same
|
||||||
|
// way a mobile tap does.
|
||||||
|
// • Rail height is a fraction (0.55) of the wrapper container's
|
||||||
|
// height, NOT `window.innerHeight`. The sheet is scoped to its
|
||||||
|
// PageNav slot, so a viewport-relative rail would overshoot on
|
||||||
|
// desktop where the column is short. `ResizeObserver` on the
|
||||||
|
// container drives `containerHeightPx`; the rail recomputes
|
||||||
|
// on column resize.
|
||||||
|
// • Drag-origin data attribute is renamed to
|
||||||
|
// `data-channels-workspace-drag-origin` so it doesn't collide with
|
||||||
|
// the existing settings-sheet selector elsewhere on the page.
|
||||||
|
//
|
||||||
|
// Everything else (clip-path carve, void colour, VAUL easing curve,
|
||||||
|
// entry rAF gate, hasEntered mirror, keepMounted unmount delay,
|
||||||
|
// dialog/aria-label invariants, portal marker for Android back) is
|
||||||
|
// preserved verbatim — those were learned through review cycles on the
|
||||||
|
// canonical horseshoe and are not re-litigated here.
|
||||||
|
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { channelsWorkspaceSheetAtom } from '../../../state/channelsWorkspaceSheet';
|
||||||
|
import {
|
||||||
|
useCloseChannelsWorkspaceSheet,
|
||||||
|
useOpenChannelsWorkspaceSheet,
|
||||||
|
} from '../../../state/hooks/channelsWorkspaceSheet';
|
||||||
|
import { VOJO_HORSESHOE_VOID_COLOR } from '../../../styles/horseshoe';
|
||||||
|
import { WorkspaceSwitcherSheet } from './WorkspaceSwitcherSheet';
|
||||||
|
import * as css from './ChannelsWorkspaceHorseshoe.css';
|
||||||
|
|
||||||
|
// Data attribute set on `WorkspaceFooter` to mark it as the drag-up /
|
||||||
|
// tap-to-open origin for the sheet. The document-level touchstart /
|
||||||
|
// pointerdown listeners use `target.closest(SELECTOR)` so the footer's
|
||||||
|
// own onClick handler still fires for no-movement taps. Keep this in
|
||||||
|
// sync with the attribute spread on the footer row.
|
||||||
|
export const CHANNELS_WORKSPACE_DRAG_ORIGIN_ATTR = 'data-channels-workspace-drag-origin';
|
||||||
|
const DRAG_ORIGIN_SELECTOR = `[${CHANNELS_WORKSPACE_DRAG_ORIGIN_ATTR}]`;
|
||||||
|
|
||||||
|
const VAUL_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)';
|
||||||
|
const ANIMATION_MS = 250;
|
||||||
|
// Commit distance (px). 80px matches the canonical horseshoe so the two
|
||||||
|
// gestures (settings open, workspace open) feel identical to muscle
|
||||||
|
// memory.
|
||||||
|
const COMMIT_THRESHOLD_PX = 80;
|
||||||
|
// Sheet rail as a fraction of the PageNav column's measured height.
|
||||||
|
// Workspace switcher content is small (spaces list + one create row), so
|
||||||
|
// 55% reads as a compact tray rather than a half-screen takeover.
|
||||||
|
const RAIL_FRACTION = 0.55;
|
||||||
|
// Drag distance over which radii + void gap ramp from 0 to full during
|
||||||
|
// finger-drag. Matched to COMMIT_THRESHOLD_PX so the silhouette is
|
||||||
|
// fully formed exactly when the gesture qualifies to commit.
|
||||||
|
const HORSESHOE_EMERGE_PX = 80;
|
||||||
|
|
||||||
|
// Symmetric cubic in-out — linear ramp was too snappy in the canonical
|
||||||
|
// horseshoe (corners jumped in within ~10px of drag); cubic keeps them
|
||||||
|
// subtle until ~40% of the gesture, then blossoms around the midpoint.
|
||||||
|
const easeInOutCubic = (t: number): number =>
|
||||||
|
t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2;
|
||||||
|
|
||||||
|
type DragSource = 'footer' | 'handle';
|
||||||
|
|
||||||
|
type DragState = {
|
||||||
|
source: DragSource;
|
||||||
|
inputType: 'touch' | 'pointer';
|
||||||
|
startY: number;
|
||||||
|
deltaY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChannelsWorkspaceHorseshoeProps = {
|
||||||
|
space: Room;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChannelsWorkspaceHorseshoe({
|
||||||
|
space,
|
||||||
|
children,
|
||||||
|
}: ChannelsWorkspaceHorseshoeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const open = useAtomValue(channelsWorkspaceSheetAtom);
|
||||||
|
const openSheet = useOpenChannelsWorkspaceSheet();
|
||||||
|
const closeSheet = useCloseChannelsWorkspaceSheet();
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerHeightPx, setContainerHeightPx] = useState(0);
|
||||||
|
const [drag, setDrag] = useState<DragState | null>(null);
|
||||||
|
|
||||||
|
// ResizeObserver on the wrapper — rail height is scoped to THIS
|
||||||
|
// column. PageNav width changes via the resizable handle on desktop
|
||||||
|
// and column height changes with viewport / split-screen on Android;
|
||||||
|
// ResizeObserver handles both without a manual `window.resize`
|
||||||
|
// listener which wouldn't catch the column-resize case.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return undefined;
|
||||||
|
setContainerHeightPx(el.getBoundingClientRect().height);
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (!entry) return;
|
||||||
|
setContainerHeightPx(entry.contentRect.height);
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const railHeightPx = Math.round(containerHeightPx * RAIL_FRACTION);
|
||||||
|
|
||||||
|
// Entry-animation gate. Kept for gesture-feel parity with the
|
||||||
|
// canonical horseshoe even though the channels sheet has no
|
||||||
|
// deep-link path (atom is only ever flipped by a footer tap/drag on
|
||||||
|
// an already-mounted Channels column, so a "snap-open on mount"
|
||||||
|
// can't actually happen here). The `hasEnteredRef` mirror still
|
||||||
|
// matters because React 18 strict-mode dev runs the unmount
|
||||||
|
// cleanup before the rAF fires; without the guard the effect
|
||||||
|
// would clear the atom mid-rehearsal and break HMR.
|
||||||
|
const [hasEntered, setHasEntered] = useState(false);
|
||||||
|
const hasEnteredRef = useRef(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const id = requestAnimationFrame(() => {
|
||||||
|
hasEnteredRef.current = true;
|
||||||
|
setHasEntered(true);
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Delay unmount of the sheet content by `ANIMATION_MS` so the slide-
|
||||||
|
// down has something to render. Without this, clearing the atom would
|
||||||
|
// unmount the spaces list instantly and the user would see an empty
|
||||||
|
// panel shrink instead of the menu sliding away with it.
|
||||||
|
const [keepMounted, setKeepMounted] = useState(open);
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setKeepMounted(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const id = window.setTimeout(() => setKeepMounted(false), ANIMATION_MS);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const baseExpanded = open && hasEntered ? railHeightPx : 0;
|
||||||
|
// CLAMP via Math.max/min, not early-return — see canonical
|
||||||
|
// `applyMove` for the bug a wrong-direction early-return introduces
|
||||||
|
// (stale deltaY survives a reversed swipe and commits on release).
|
||||||
|
const expandedPx = drag
|
||||||
|
? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY))
|
||||||
|
: baseExpanded;
|
||||||
|
const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
|
||||||
|
const isDragging = drag !== null;
|
||||||
|
const horseshoeActive = expandedPx > 0;
|
||||||
|
|
||||||
|
const handleRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Refs so the always-installed document listeners see the latest
|
||||||
|
// state without re-subscribing on every render. Same pattern as the
|
||||||
|
// canonical horseshoe.
|
||||||
|
const dragRef = useRef<DragState | null>(null);
|
||||||
|
dragRef.current = drag;
|
||||||
|
const openRef = useRef(open);
|
||||||
|
openRef.current = open;
|
||||||
|
const openSheetRef = useRef(openSheet);
|
||||||
|
openSheetRef.current = openSheet;
|
||||||
|
const closeSheetRef = useRef(closeSheet);
|
||||||
|
closeSheetRef.current = closeSheet;
|
||||||
|
|
||||||
|
// Clear the atom on unmount — switching tabs (Direct, Bots), picking
|
||||||
|
// a channel, or navigating to /explore unmounts Channels.tsx; without
|
||||||
|
// this clear, returning to /channels later would auto-reopen the
|
||||||
|
// sheet. `hasEnteredRef` skips the strict-mode rehearsal cleanup
|
||||||
|
// before the rAF flag flips.
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (!hasEnteredRef.current) return;
|
||||||
|
if (openRef.current) closeSheetRef.current();
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hardware Escape (web only, rare on mobile) → close. Plain keydown,
|
||||||
|
// no FocusTrap — same rationale as the canonical (focus-trap-react
|
||||||
|
// throws when its container has no tabbable nodes, which is exactly
|
||||||
|
// the case mid-drag before the sheet has rendered any focusable
|
||||||
|
// children).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (
|
||||||
|
target &&
|
||||||
|
(target.tagName === 'INPUT' ||
|
||||||
|
target.tagName === 'TEXTAREA' ||
|
||||||
|
target.isContentEditable)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeSheetRef.current();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Drag mechanics — two origin paths:
|
||||||
|
//
|
||||||
|
// 1. Document-level touch/pointer on anything matching
|
||||||
|
// `[data-channels-workspace-drag-origin]` (= WorkspaceFooter).
|
||||||
|
// Passive touchstart / non-passive listeners are split so the
|
||||||
|
// row's own onClick handler still fires for no-movement taps.
|
||||||
|
// 2. Element-level touch/pointer on `handleRef` (the 20px drag
|
||||||
|
// band at the top of the open sheet). Only triggers when the
|
||||||
|
// sheet is open; touches on the spaces list inside the panel
|
||||||
|
// are not drag-sensitive so internal scroll works without
|
||||||
|
// conflict.
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEl = handleRef.current;
|
||||||
|
|
||||||
|
// CLAMP, not early-return — reversal of gesture direction must
|
||||||
|
// drag `deltaY` back toward 0. footer source clamps the upward
|
||||||
|
// drag-open path to negative deltas; handle source clamps the
|
||||||
|
// downward drag-close path to positive deltas.
|
||||||
|
const applyMove = (clientY: number, e: TouchEvent | PointerEvent) => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d) return;
|
||||||
|
const rawDelta = clientY - d.startY;
|
||||||
|
const nextDelta =
|
||||||
|
d.source === 'footer' ? Math.min(0, rawDelta) : Math.max(0, rawDelta);
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
setDrag({ ...d, deltaY: nextDelta });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyEnd = () => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d) return;
|
||||||
|
if (d.source === 'footer' && -d.deltaY > COMMIT_THRESHOLD_PX) {
|
||||||
|
openSheetRef.current();
|
||||||
|
} else if (d.source === 'handle' && d.deltaY > COMMIT_THRESHOLD_PX) {
|
||||||
|
closeSheetRef.current();
|
||||||
|
}
|
||||||
|
setDrag(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetIsDragOrigin = (target: EventTarget | null): boolean => {
|
||||||
|
if (!target || !(target instanceof Element)) return false;
|
||||||
|
return target.closest(DRAG_ORIGIN_SELECTOR) !== null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Touch path ===
|
||||||
|
const onDocTouchStart = (e: TouchEvent) => {
|
||||||
|
if (dragRef.current) return;
|
||||||
|
if (openRef.current) return; // sheet open → handle owns drag
|
||||||
|
if (!targetIsDragOrigin(e.target)) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
setDrag({
|
||||||
|
source: 'footer',
|
||||||
|
inputType: 'touch',
|
||||||
|
startY: touch.clientY,
|
||||||
|
deltaY: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onHandleTouchStart = (e: TouchEvent) => {
|
||||||
|
if (dragRef.current) return;
|
||||||
|
if (!openRef.current) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
setDrag({
|
||||||
|
source: 'handle',
|
||||||
|
inputType: 'touch',
|
||||||
|
startY: touch.clientY,
|
||||||
|
deltaY: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onTouchMove = (e: TouchEvent) => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d || d.inputType !== 'touch') return;
|
||||||
|
applyMove(e.touches[0].clientY, e);
|
||||||
|
};
|
||||||
|
const onTouchEnd = () => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d || d.inputType !== 'touch') return;
|
||||||
|
applyEnd();
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Mouse / pen path ===
|
||||||
|
const onDocPointerDown = (e: PointerEvent) => {
|
||||||
|
if (e.pointerType === 'touch') return;
|
||||||
|
if (dragRef.current) return;
|
||||||
|
if (openRef.current) return;
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
if (!targetIsDragOrigin(e.target)) return;
|
||||||
|
setDrag({
|
||||||
|
source: 'footer',
|
||||||
|
inputType: 'pointer',
|
||||||
|
startY: e.clientY,
|
||||||
|
deltaY: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onHandlePointerDown = (e: PointerEvent) => {
|
||||||
|
if (e.pointerType === 'touch') return;
|
||||||
|
if (dragRef.current) return;
|
||||||
|
if (!openRef.current) return;
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
setDrag({
|
||||||
|
source: 'handle',
|
||||||
|
inputType: 'pointer',
|
||||||
|
startY: e.clientY,
|
||||||
|
deltaY: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onDocPointerMove = (e: PointerEvent) => {
|
||||||
|
if (e.pointerType === 'touch') return;
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d || d.inputType !== 'pointer') return;
|
||||||
|
applyMove(e.clientY, e);
|
||||||
|
};
|
||||||
|
const onDocPointerEnd = (e: PointerEvent) => {
|
||||||
|
if (e.pointerType === 'touch') return;
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d || d.inputType !== 'pointer') return;
|
||||||
|
applyEnd();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('touchstart', onDocTouchStart, { passive: true });
|
||||||
|
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||||
|
document.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||||
|
document.addEventListener('touchcancel', onTouchEnd, { passive: true });
|
||||||
|
document.addEventListener('pointerdown', onDocPointerDown);
|
||||||
|
document.addEventListener('pointermove', onDocPointerMove, { passive: false });
|
||||||
|
document.addEventListener('pointerup', onDocPointerEnd, { passive: true });
|
||||||
|
document.addEventListener('pointercancel', onDocPointerEnd, { passive: true });
|
||||||
|
if (handleEl) {
|
||||||
|
handleEl.addEventListener('touchstart', onHandleTouchStart, { passive: true });
|
||||||
|
handleEl.addEventListener('pointerdown', onHandlePointerDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('touchstart', onDocTouchStart);
|
||||||
|
document.removeEventListener('touchmove', onTouchMove);
|
||||||
|
document.removeEventListener('touchend', onTouchEnd);
|
||||||
|
document.removeEventListener('touchcancel', onTouchEnd);
|
||||||
|
document.removeEventListener('pointerdown', onDocPointerDown);
|
||||||
|
document.removeEventListener('pointermove', onDocPointerMove);
|
||||||
|
document.removeEventListener('pointerup', onDocPointerEnd);
|
||||||
|
document.removeEventListener('pointercancel', onDocPointerEnd);
|
||||||
|
if (handleEl) {
|
||||||
|
handleEl.removeEventListener('touchstart', onHandleTouchStart);
|
||||||
|
handleEl.removeEventListener('pointerdown', onHandlePointerDown);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Geometry — radii + void gap ramp via easeInOutCubic during drag;
|
||||||
|
// release jumps to the full value and CSS transition (VAUL_EASING)
|
||||||
|
// carries the visual. See canonical for the visual-curve rationale.
|
||||||
|
let horseshoeRamp: number;
|
||||||
|
if (isDragging) {
|
||||||
|
horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX));
|
||||||
|
} else {
|
||||||
|
horseshoeRamp = expandedFraction > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
|
||||||
|
const appBodyRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
|
||||||
|
const appBodyGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX;
|
||||||
|
const appBodyMaskBottomPx = expandedPx + appBodyGapPx;
|
||||||
|
|
||||||
|
// `inset(top right bottom left round TL TR BR BL)` — only BR/BL carry
|
||||||
|
// the radius so the visible top portion of appBody has rounded
|
||||||
|
// bottom corners at the clip boundary. Always emitted (even at all
|
||||||
|
// zeros) so CSS can transition smoothly between closed and open.
|
||||||
|
const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px round 0px 0px ${appBodyRadiusPx}px ${appBodyRadiusPx}px)`;
|
||||||
|
|
||||||
|
const silhouetteTransition = isDragging
|
||||||
|
? 'none'
|
||||||
|
: `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||||
|
const appBodyTransition = isDragging
|
||||||
|
? 'none'
|
||||||
|
: `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||||
|
|
||||||
|
const containerStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSheet = keepMounted || isDragging;
|
||||||
|
|
||||||
|
// Portal marker so `useAndroidBackButton` dispatches Escape instead of
|
||||||
|
// `navigate(-1)` while the sheet is open. The keydown handler above
|
||||||
|
// catches the synthetic Escape and closes the sheet — same pattern
|
||||||
|
// the canonical horseshoe uses. Falls back to body for SSR / tests
|
||||||
|
// where `#portalContainer` isn't mounted yet.
|
||||||
|
const portalTarget =
|
||||||
|
typeof document !== 'undefined'
|
||||||
|
? document.getElementById('portalContainer') ?? document.body
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={css.container} style={containerStyle}>
|
||||||
|
{open && portalTarget
|
||||||
|
? createPortal(
|
||||||
|
<div
|
||||||
|
data-vojo-channels-workspace-sheet-active="true"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>,
|
||||||
|
portalTarget
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
<div
|
||||||
|
className={css.appBody}
|
||||||
|
style={{
|
||||||
|
clipPath: appBodyClipPath,
|
||||||
|
transition: appBodyTransition,
|
||||||
|
overscrollBehaviorY: 'contain',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={css.silhouette}
|
||||||
|
style={{
|
||||||
|
height: `${expandedPx}px`,
|
||||||
|
borderTopLeftRadius: `${silhouetteRadiusPx}px`,
|
||||||
|
borderTopRightRadius: `${silhouetteRadiusPx}px`,
|
||||||
|
transition: silhouetteTransition,
|
||||||
|
visibility: expandedPx > 0 ? 'visible' : 'hidden',
|
||||||
|
// Reset `--vojo-safe-top` for everything mounted inside the
|
||||||
|
// sheet — same trick the canonical horseshoe uses (status-bar
|
||||||
|
// padding is owned by the outer PageNav column; re-applying
|
||||||
|
// it inside the sheet would create dead space above the
|
||||||
|
// panel header).
|
||||||
|
['--vojo-safe-top' as string]: '0px',
|
||||||
|
}}
|
||||||
|
// `role="dialog"` + `aria-label` only. NO `aria-modal="true"`
|
||||||
|
// (no focus trap; outside content stays interactive). NO
|
||||||
|
// `aria-labelledby` (the labelled target may unmount during
|
||||||
|
// the close animation). See canonical for the rationale.
|
||||||
|
role="dialog"
|
||||||
|
aria-label={t('Channels.workspace_switcher_aria')}
|
||||||
|
>
|
||||||
|
<div className={css.panelContent} style={{ height: `${railHeightPx}px` }}>
|
||||||
|
<div
|
||||||
|
ref={handleRef}
|
||||||
|
className={css.panelHandle}
|
||||||
|
aria-label={t('Channels.workspace_switcher_drag_to_close')}
|
||||||
|
>
|
||||||
|
<div className={css.panelHandleBar} />
|
||||||
|
</div>
|
||||||
|
<div className={css.panelBody}>
|
||||||
|
{renderSheet && (
|
||||||
|
<WorkspaceSwitcherSheet
|
||||||
|
space={space}
|
||||||
|
requestClose={() => closeSheetRef.current()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/app/pages/client/channels/SpaceAvatar.tsx
Normal file
47
src/app/pages/client/channels/SpaceAvatar.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { color, toRem } from 'folds';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
|
||||||
|
export type SpaceAvatarProps = {
|
||||||
|
space: Room;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Square-rounded space avatar with auto-generated initial fallback. Kept
|
||||||
|
// outside the folds `<Avatar>` recipe because every call-site needs an
|
||||||
|
// explicit pixel size (currently 40 — workspace footer + in-sheet
|
||||||
|
// SpaceRow); folds Avatar tokens don't expose that step cleanly. The
|
||||||
|
// border-radius scales with `size` (`Math.max(6, size / 3.5)`), so any
|
||||||
|
// future caller passing a different size still gets a proportional
|
||||||
|
// rounded-square shape.
|
||||||
|
export 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,32 +1,7 @@
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { color, config, toRem } from 'folds';
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
const plateBase = {
|
export const WorkspaceFooterShell = style({
|
||||||
display: 'flex',
|
padding: `${toRem(6)} ${config.space.S100}`,
|
||||||
alignItems: 'center',
|
|
||||||
gap: toRem(8),
|
|
||||||
padding: `${toRem(8)} ${toRem(12)}`,
|
|
||||||
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
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}`,
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,250 +1,103 @@
|
||||||
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import {
|
import { Box, Icon, Icons, Text, toRem } from 'folds';
|
||||||
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 { 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 { useRoomName } from '../../../hooks/useRoomMeta';
|
||||||
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
import { NavButton, NavItem, NavItemContent } from '../../../components/nav';
|
||||||
import { getChannelsSpacePath } from '../../pathUtils';
|
import { channelsWorkspaceSheetAtom } from '../../../state/channelsWorkspaceSheet';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
|
||||||
import {
|
import {
|
||||||
ActiveSpaceMenuRow,
|
useCloseChannelsWorkspaceSheet,
|
||||||
WorkspaceFooterPlate,
|
useOpenChannelsWorkspaceSheet,
|
||||||
WorkspaceFooterTrigger,
|
} from '../../../state/hooks/channelsWorkspaceSheet';
|
||||||
} from './WorkspaceFooter.css';
|
import { SpaceAvatar } from './SpaceAvatar';
|
||||||
|
import { CHANNELS_WORKSPACE_DRAG_ORIGIN_ATTR } from './ChannelsWorkspaceHorseshoe';
|
||||||
|
import { WorkspaceFooterShell } from './WorkspaceFooter.css';
|
||||||
|
|
||||||
type SpaceAvatarProps = {
|
const ROW_MIN_HEIGHT = toRem(68);
|
||||||
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 = {
|
type WorkspaceFooterProps = {
|
||||||
space: Room;
|
space: Room;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Bottom-of-channels-list workspace switcher. Multi-orphan users get a
|
// Bottom-of-channels-list workspace identity row. Styled like
|
||||||
// clickable trigger (avatar + name + chevron) that opens a PopOut listing
|
// `DirectSelfRow` so the DM and Channels panes get the same fat footer
|
||||||
// every joined orphan Space; the active row wears an inset Primary.Main
|
// button. Tap → open the horseshoe sheet (atom-driven). Drag-up →
|
||||||
// ring. Single-orphan users see a static plate (no chevron, no click) —
|
// `ChannelsWorkspaceHorseshoe` picks up the gesture via the
|
||||||
// nothing to switch to.
|
// `data-channels-workspace-drag-origin` data attribute and drives the
|
||||||
|
// sheet expansion from 0 → railHeight. Tap and drag are reconciled by
|
||||||
|
// the canonical document-level listener pattern: touchstart /
|
||||||
|
// pointerdown are passive, so the synthesised click still fires for
|
||||||
|
// no-movement taps.
|
||||||
//
|
//
|
||||||
// Active-space tracking lives upstream in `Channels.tsx::useEffect` (writes
|
// Active-space tracking lives upstream in `Channels.tsx::useEffect`
|
||||||
// localStorage) and `useActiveSpace` (URL > localStorage > first orphan
|
// (writes localStorage) and `useActiveSpace` (URL > localStorage >
|
||||||
// resolver). Leave-active-space cascades through `roomToParentsAtom`'s
|
// first orphan resolver). The sheet's spaces list re-resolves from
|
||||||
// MyMembership listener — the orphan list shrinks, `useActiveSpace`'s
|
// `useOrphanSpaces` and updates reactively when the user leaves the
|
||||||
// orphanSet filter drops the stale id, and the channels surface re-resolves
|
// active space.
|
||||||
// to the next orphan automatically.
|
|
||||||
export function WorkspaceFooter({ space }: WorkspaceFooterProps) {
|
export function WorkspaceFooter({ space }: WorkspaceFooterProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
|
||||||
const orphanSpaceIds = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
|
|
||||||
const activeName = useRoomName(space);
|
const activeName = useRoomName(space);
|
||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const open = useAtomValue(channelsWorkspaceSheetAtom);
|
||||||
// FocusTrap's `clickOutsideDeactivates` fires on mousedown — when the
|
const openSheet = useOpenChannelsWorkspaceSheet();
|
||||||
// user clicks the trigger button to close an open menu, the deactivate
|
const closeSheet = useCloseChannelsWorkspaceSheet();
|
||||||
// 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(() => {
|
const handleToggle = () => {
|
||||||
lastCloseAtRef.current = Date.now();
|
if (open) {
|
||||||
setMenuAnchor(undefined);
|
closeSheet();
|
||||||
}, []);
|
} else {
|
||||||
|
openSheet();
|
||||||
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 (
|
return (
|
||||||
<>
|
<Box
|
||||||
<button
|
{...{ [CHANNELS_WORKSPACE_DRAG_ORIGIN_ATTR]: true }}
|
||||||
type="button"
|
className={WorkspaceFooterShell}
|
||||||
onClick={handleToggle}
|
>
|
||||||
aria-label={t('Channels.workspace_switcher_aria')}
|
<NavItem variant="Background" radii="400" style={{ minHeight: ROW_MIN_HEIGHT }}>
|
||||||
aria-haspopup="menu"
|
<NavButton
|
||||||
aria-expanded={!!menuAnchor}
|
onClick={handleToggle}
|
||||||
className={WorkspaceFooterTrigger}
|
aria-label={t('Channels.workspace_switcher_aria')}
|
||||||
>
|
aria-haspopup="dialog"
|
||||||
<SpaceAvatar space={space} size={28} />
|
aria-expanded={open}
|
||||||
{nameCell}
|
>
|
||||||
<Icon size="100" src={Icons.ChevronTop} />
|
<NavItemContent>
|
||||||
</button>
|
<Box
|
||||||
|
as="span"
|
||||||
{menuAnchor && (
|
grow="Yes"
|
||||||
<PopOut
|
alignItems="Center"
|
||||||
anchor={menuAnchor}
|
gap="300"
|
||||||
position="Top"
|
style={{
|
||||||
align="Start"
|
minHeight: ROW_MIN_HEIGHT,
|
||||||
content={
|
boxSizing: 'border-box',
|
||||||
<FocusTrap
|
padding: `${toRem(8)} 0`,
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
returnFocusOnDeactivate: false,
|
|
||||||
onDeactivate: closeMenu,
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
|
||||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu
|
<SpaceAvatar space={space} size={40} />
|
||||||
style={{
|
<Box
|
||||||
minWidth: toRem(240),
|
as="span"
|
||||||
maxWidth: toRem(320),
|
direction="Column"
|
||||||
maxHeight: toRem(360),
|
grow="Yes"
|
||||||
overflowY: 'auto',
|
gap="100"
|
||||||
}}
|
style={{ minWidth: 0, overflow: 'hidden' }}
|
||||||
>
|
>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Text as="span" size="T300" truncate style={{ fontWeight: 600 }}>
|
||||||
{orphanSpaceIds.map((spaceId) => {
|
{activeName}
|
||||||
const r = mx.getRoom(spaceId);
|
</Text>
|
||||||
if (!r) return null;
|
<Text as="span" size="T200" truncate style={{ opacity: 0.6 }}>
|
||||||
return (
|
{t('Channels.workspace_footer_subtitle')}
|
||||||
<SpaceMenuRow
|
</Text>
|
||||||
key={spaceId}
|
</Box>
|
||||||
space={r}
|
<Icon
|
||||||
isActive={spaceId === space.roomId}
|
src={Icons.ChevronTop}
|
||||||
activeMarker={activeMarker}
|
size="100"
|
||||||
onPick={handlePickSpace}
|
style={{ opacity: 0.55, flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
);
|
</Box>
|
||||||
})}
|
</NavItemContent>
|
||||||
</Box>
|
</NavButton>
|
||||||
</Menu>
|
</NavItem>
|
||||||
</FocusTrap>
|
</Box>
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
src/app/pages/client/channels/WorkspaceSwitcherSheet.css.ts
Normal file
19
src/app/pages/client/channels/WorkspaceSwitcherSheet.css.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, toRem } from 'folds';
|
||||||
|
|
||||||
|
// Create-community leading icon slot. Same 40px width as `SpaceAvatar`
|
||||||
|
// below so the text columns line up, but NO background / radius — the
|
||||||
|
// `+` reads as a plain icon at the start of the row, not as a chip.
|
||||||
|
// Every earlier «colour the chip somehow» iteration (Surface.Container,
|
||||||
|
// Primary.Container, Primary.Main, Primary.MainHover, transparent-
|
||||||
|
// inheriting-row-hover) read as «yet another tile that stands out» —
|
||||||
|
// the chip itself was the regression. Stripped to icon-only.
|
||||||
|
export const CreateCommunityTile = style({
|
||||||
|
width: toRem(40),
|
||||||
|
height: toRem(40),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
color: color.Background.OnContainer,
|
||||||
|
});
|
||||||
200
src/app/pages/client/channels/WorkspaceSwitcherSheet.tsx
Normal file
200
src/app/pages/client/channels/WorkspaceSwitcherSheet.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { Box, Icon, Icons, Scroll, Text, color, config, toRem } from 'folds';
|
||||||
|
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 { useOpenCreateSpaceModal } from '../../../state/hooks/createSpaceModal';
|
||||||
|
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
||||||
|
import { getChannelsSpacePath } from '../../pathUtils';
|
||||||
|
import { NavButton, NavItem, NavItemContent } from '../../../components/nav';
|
||||||
|
import { SpaceAvatar } from './SpaceAvatar';
|
||||||
|
import { CreateCommunityTile } from './WorkspaceSwitcherSheet.css';
|
||||||
|
|
||||||
|
const ROW_MIN_HEIGHT = toRem(60);
|
||||||
|
|
||||||
|
type CreateCommunityRowProps = {
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Top action of the sheet. The row uses `variant="SurfaceVariant"` so
|
||||||
|
// its card bg matches the sheet's silhouette bg
|
||||||
|
// (`SurfaceVariant.Container` = #181a20 — see
|
||||||
|
// `ChannelsWorkspaceHorseshoe.css.ts::silhouette`) instead of the
|
||||||
|
// darker `Background.Container` the data rows below use. Result: the
|
||||||
|
// «create» row blends into the sheet surface (no visible card
|
||||||
|
// outline), while space rows stay as distinct list-item cards. The
|
||||||
|
// leading `+` is a bare icon centred in the same 40-wide slot the
|
||||||
|
// space rows use for `SpaceAvatar`, so the text columns align.
|
||||||
|
function CreateCommunityRow({ onClick }: CreateCommunityRowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<NavItem variant="SurfaceVariant" radii="400" style={{ minHeight: ROW_MIN_HEIGHT }}>
|
||||||
|
<NavButton onClick={onClick} aria-label={t('Channels.workspace_switcher_create_space')}>
|
||||||
|
<NavItemContent>
|
||||||
|
<Box
|
||||||
|
as="span"
|
||||||
|
grow="Yes"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="300"
|
||||||
|
style={{
|
||||||
|
minHeight: ROW_MIN_HEIGHT,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
padding: `${toRem(6)} 0`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={CreateCommunityTile}>
|
||||||
|
<Icon src={Icons.Plus} size="300" />
|
||||||
|
</span>
|
||||||
|
<Box as="span" grow="Yes" style={{ minWidth: 0 }}>
|
||||||
|
<Text as="span" size="T300" truncate style={{ fontWeight: 600 }}>
|
||||||
|
{t('Channels.workspace_switcher_create_space')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</NavItemContent>
|
||||||
|
</NavButton>
|
||||||
|
</NavItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpaceRowProps = {
|
||||||
|
space: Room;
|
||||||
|
isActive: boolean;
|
||||||
|
onPick: (spaceId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One space row in the sheet. Mirrors the DM-row visual: avatar 40,
|
||||||
|
// two text lines (name + member-count subtitle). The active community
|
||||||
|
// is signalled solely via `aria-selected` — Folds NavItem paints
|
||||||
|
// `ContainerActive` on `&[aria-selected=true]`, which is enough on
|
||||||
|
// this surface tier; an explicit trailing label was tried and
|
||||||
|
// dropped per product call.
|
||||||
|
//
|
||||||
|
// `useRoomName` lives here so the per-space subscription stays scoped
|
||||||
|
// (Rules of Hooks) and admin renames reflect in the dropdown without
|
||||||
|
// a parent re-render.
|
||||||
|
function SpaceRow({ space, isActive, onPick }: SpaceRowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const name = useRoomName(space);
|
||||||
|
const memberCount = space.getJoinedMemberCount();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavItem
|
||||||
|
variant="Background"
|
||||||
|
radii="400"
|
||||||
|
aria-selected={isActive}
|
||||||
|
style={{ minHeight: ROW_MIN_HEIGHT }}
|
||||||
|
>
|
||||||
|
<NavButton onClick={() => onPick(space.roomId)}>
|
||||||
|
<NavItemContent>
|
||||||
|
<Box
|
||||||
|
as="span"
|
||||||
|
grow="Yes"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="300"
|
||||||
|
style={{
|
||||||
|
minHeight: ROW_MIN_HEIGHT,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
padding: `${toRem(6)} 0`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SpaceAvatar space={space} size={40} />
|
||||||
|
<Box
|
||||||
|
as="span"
|
||||||
|
direction="Column"
|
||||||
|
grow="Yes"
|
||||||
|
gap="100"
|
||||||
|
style={{ minWidth: 0, overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<Text as="span" size="T300" truncate style={{ fontWeight: 600 }}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Text as="span" size="T200" truncate style={{ opacity: 0.6 }}>
|
||||||
|
{t('Channels.workspace_switcher_member_count', { count: memberCount })}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</NavItemContent>
|
||||||
|
</NavButton>
|
||||||
|
</NavItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceSwitcherSheetProps = {
|
||||||
|
space: Room;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// In-sheet workspace switcher panel. Top: «Create community» row +
|
||||||
|
// separator. Bottom: scrollable list of orphan spaces styled like the
|
||||||
|
// DM list (NavItem-Background rows, `aria-selected` for the active
|
||||||
|
// community — no harsh inset ring). `requestClose` is plumbed from
|
||||||
|
// the horseshoe via the canonical `closeSheetRef.current()` indirection
|
||||||
|
// so any picker action collapses the sheet on its way out.
|
||||||
|
export function WorkspaceSwitcherSheet({ space, requestClose }: WorkspaceSwitcherSheetProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
const orphanSpaceIds = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
|
||||||
|
const openCreateSpaceModal = useOpenCreateSpaceModal();
|
||||||
|
|
||||||
|
const handlePickSpace = useCallback(
|
||||||
|
(spaceId: string) => {
|
||||||
|
requestClose();
|
||||||
|
if (spaceId === space.roomId) return;
|
||||||
|
const idOrAlias = getCanonicalAliasOrRoomId(mx, spaceId);
|
||||||
|
navigate(getChannelsSpacePath(idOrAlias));
|
||||||
|
},
|
||||||
|
[requestClose, mx, navigate, space.roomId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateSpace = useCallback(() => {
|
||||||
|
requestClose();
|
||||||
|
openCreateSpaceModal();
|
||||||
|
}, [requestClose, openCreateSpaceModal]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Scroll
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
hideTrack
|
||||||
|
style={{ flex: 1, minHeight: 0, minWidth: 0 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
style={{
|
||||||
|
padding: `${config.space.S100} ${config.space.S200} ${config.space.S300}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CreateCommunityRow onClick={handleCreateSpace} />
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
height: toRem(1),
|
||||||
|
background: color.Surface.ContainerLine,
|
||||||
|
margin: `${config.space.S100} ${config.space.S100}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{orphanSpaceIds.map((spaceId) => {
|
||||||
|
const r = mx.getRoom(spaceId);
|
||||||
|
if (!r) return null;
|
||||||
|
return (
|
||||||
|
<SpaceRow
|
||||||
|
key={spaceId}
|
||||||
|
space={r}
|
||||||
|
isActive={spaceId === space.roomId}
|
||||||
|
onPick={handlePickSpace}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
import React, { MouseEventHandler, useState } from 'react';
|
|
||||||
import { Box, config, Icon, Icons, Menu, PopOut, RectCords, Text } from 'folds';
|
|
||||||
import FocusTrap from 'focus-trap-react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
|
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
|
||||||
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
|
||||||
import {
|
|
||||||
encodeSearchParamValueArray,
|
|
||||||
getCreatePath,
|
|
||||||
getSpacePath,
|
|
||||||
withSearchParam,
|
|
||||||
} from '../../pathUtils';
|
|
||||||
import { useCreateSelected } from '../../../hooks/router/useCreateSelected';
|
|
||||||
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
|
|
||||||
import { _RoomSearchParams } from '../../paths';
|
|
||||||
|
|
||||||
export function CreateTab() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const createSelected = useCreateSelected();
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
|
||||||
const [joinAddress, setJoinAddress] = useState(false);
|
|
||||||
|
|
||||||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
|
||||||
setMenuCords(menuCords ? undefined : evt.currentTarget.getBoundingClientRect());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateSpace = () => {
|
|
||||||
navigate(getCreatePath());
|
|
||||||
setMenuCords(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJoinWithAddress = () => {
|
|
||||||
setJoinAddress(true);
|
|
||||||
setMenuCords(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarItem active={createSelected}>
|
|
||||||
<SidebarItemTooltip tooltip={t('Create.add_space')}>
|
|
||||||
{(triggerRef) => (
|
|
||||||
<PopOut
|
|
||||||
anchor={menuCords}
|
|
||||||
position="Right"
|
|
||||||
align="Center"
|
|
||||||
content={
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
returnFocusOnDeactivate: false,
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: () => setMenuCords(undefined),
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
isKeyForward: (evt: KeyboardEvent) =>
|
|
||||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
|
||||||
isKeyBackward: (evt: KeyboardEvent) =>
|
|
||||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Menu>
|
|
||||||
<Box direction="Column">
|
|
||||||
<SequenceCard
|
|
||||||
style={{ padding: config.space.S300 }}
|
|
||||||
variant="Surface"
|
|
||||||
direction="Column"
|
|
||||||
gap="100"
|
|
||||||
radii="0"
|
|
||||||
as="button"
|
|
||||||
type="button"
|
|
||||||
onClick={handleCreateSpace}
|
|
||||||
>
|
|
||||||
<SettingTile before={<Icon size="400" src={Icons.Space} />}>
|
|
||||||
<Text size="H6">{t('Create.create_space')}</Text>
|
|
||||||
<Text size="T300" priority="300">
|
|
||||||
{t('Create.create_space_desc')}
|
|
||||||
</Text>
|
|
||||||
</SettingTile>
|
|
||||||
</SequenceCard>
|
|
||||||
<SequenceCard
|
|
||||||
style={{ padding: config.space.S300 }}
|
|
||||||
variant="Surface"
|
|
||||||
direction="Column"
|
|
||||||
gap="100"
|
|
||||||
radii="0"
|
|
||||||
as="button"
|
|
||||||
type="button"
|
|
||||||
onClick={handleJoinWithAddress}
|
|
||||||
>
|
|
||||||
<SettingTile before={<Icon size="400" src={Icons.Link} />}>
|
|
||||||
<Text size="H6">{t('Create.join_with_address')}</Text>
|
|
||||||
<Text size="T300" priority="300">
|
|
||||||
{t('Create.join_with_address_desc')}
|
|
||||||
</Text>
|
|
||||||
</SettingTile>
|
|
||||||
</SequenceCard>
|
|
||||||
</Box>
|
|
||||||
</Menu>
|
|
||||||
</FocusTrap>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SidebarAvatar
|
|
||||||
className={menuCords ? ContainerColor({ variant: 'Surface' }) : undefined}
|
|
||||||
as="button"
|
|
||||||
ref={triggerRef}
|
|
||||||
outlined
|
|
||||||
onClick={handleMenu}
|
|
||||||
>
|
|
||||||
<Icon src={Icons.Plus} />
|
|
||||||
</SidebarAvatar>
|
|
||||||
{joinAddress && (
|
|
||||||
<JoinAddressPrompt
|
|
||||||
onCancel={() => setJoinAddress(false)}
|
|
||||||
onOpen={(roomIdOrAlias, viaServers) => {
|
|
||||||
setJoinAddress(false);
|
|
||||||
const path = getSpacePath(roomIdOrAlias);
|
|
||||||
navigate(
|
|
||||||
viaServers
|
|
||||||
? withSearchParam<_RoomSearchParams>(path, {
|
|
||||||
viaServers: encodeSearchParamValueArray(viaServers),
|
|
||||||
})
|
|
||||||
: path
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</PopOut>
|
|
||||||
)}
|
|
||||||
</SidebarItemTooltip>
|
|
||||||
</SidebarItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
6
src/app/state/channelsWorkspaceSheet.ts
Normal file
6
src/app/state/channelsWorkspaceSheet.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
// Open state for the channels workspace-switcher horseshoe sheet (PageNav-
|
||||||
|
// scoped, web + native parity). Mirror of `settingsSheetAtom`, simpler
|
||||||
|
// payload: the sheet has no sub-pages so a plain boolean is enough.
|
||||||
|
export const channelsWorkspaceSheetAtom = atom<boolean>(false);
|
||||||
17
src/app/state/hooks/channelsWorkspaceSheet.ts
Normal file
17
src/app/state/hooks/channelsWorkspaceSheet.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { channelsWorkspaceSheetAtom } from '../channelsWorkspaceSheet';
|
||||||
|
|
||||||
|
export const useOpenChannelsWorkspaceSheet = (): (() => void) => {
|
||||||
|
const setSheet = useSetAtom(channelsWorkspaceSheetAtom);
|
||||||
|
return useCallback(() => {
|
||||||
|
setSheet(true);
|
||||||
|
}, [setSheet]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCloseChannelsWorkspaceSheet = (): (() => void) => {
|
||||||
|
const setSheet = useSetAtom(channelsWorkspaceSheetAtom);
|
||||||
|
return useCallback(() => {
|
||||||
|
setSheet(false);
|
||||||
|
}, [setSheet]);
|
||||||
|
};
|
||||||
Loading…
Add table
Reference in a new issue